# DSH · 安卓外壳 把电脑上 DSH 的**远程访问网页**装进一个安卓 App。手机装一次,之后只管填地址。 ## 它做了什么 | | | |---|---| | 首屏 | **服务器地址页**:只填 `IP或域名:端口`(**不带协议**,例 `100.64.0.3:19388`);HTTPS 由勾选决定 | | 失败时 | WebView 打不开(地址错 / 电脑没开 / 不在同一网段)**自动退回地址页**并说明原因 | | 顶栏 | 网页上是左侧一枚半透明小胶囊(**设置 / 刷新**两枚图标);**设置页任何时候都进得去**,断网也能改地址 | | 返回键 | **整个由外壳接管**(见下面「返回键」一段):先关网页里的抽屉/右栏/设置面板 → 再网页历史后退 → 最后**退到后台**(不退出) | | 通知 | **模型提问时弹状态栏通知**(见下面「模型提问时的通知」):退到后台后由一条前台服务盯着,电脑窗口不可见 + 你不在看 App 时才响 | | 网页 | 就是电脑上那份 DSH:手机布局、`远程UI布局`、`UI 设置` 全由插件配置决定 | | 权限 | `INTERNET` / `ACCESS_NETWORK_STATE` / `POST_NOTIFICATIONS` / `FOREGROUND_SERVICE(_SPECIAL_USE)`;允许明文 http(Tailscale 网段内直连) | | 其它 | 附件选择(`ACTION_GET_CONTENT`)、站外链接交给系统浏览器 | 地址存在 `SharedPreferences`(`dsh_remote`)里,下次打开直接连。 ## 返回键 返回键不分给系统默认行为,而是按当前屏幕逐档决定(`MainActivity.handleBack`): | 当前屏幕 | 返回键做什么 | |---|---| | 地址页(从网页点「设置」进来) | 回网页,**不重新连**、不丢页面状态 | | 地址页(首次启动,或网页已经坏了) | 退到后台(没有「回去」可回) | | 错误页 | 回地址页改地址 —— 主框架加载失败之后 WebView 里已经没有能看的页面了,「回网页」只会退回一张白页,所以那一档不做 | | 网页,手机布局里还开着抽屉 / 右栏 / 设置面板 | **先关掉最上面那层**(走它自己的关闭入口:官方浮层派发 Escape、抽屉点「收起侧边栏」),这一下返回键就此用掉 | | 网页,主区停在「插件 / 自动化任务」这类**全局面板**上(左上角面板列表选中的那个) | 回会话 —— 走官方 `layout.selectPanel(null)`,与手点左上角那个会话图标同一条路 | | 网页,没有浮层可关,但 WebView 有历史 | 网页后退 | | 都没有 | **退到后台**(`moveTaskToBack`),**不退出 App** | 最后一档从「退出」改成「退到后台」是 1.3.1 起的行为:退到后台之后 App 还活着, 模型在电脑上提问时才能在状态栏提醒你(见下一节)。真要退出,从最近任务里把它划掉即可 —— 那时前台服务会跟着一起收掉,不会有残留通知。 「网页已经坏了」由主框架导航成没成来判:`onPageStarted` 清标记、`onReceivedError`(只认主框架)立标记, `onPageFinished` 到的时候没有标记才算「这一版真的渲染出来了」(`webOk`)。地址页那个返回图标看的是同一个标记。 三处实现上的取舍: 1. **两条返回路径都接上**。Android 13(API 33)起系统把返回交给 `OnBackInvokedCallback` (清单里 `android:enableOnBackInvokedCallback="true"`),更早的版本只有 `onBackPressed`; 两条路都进同一个 `handleBack`,所以新旧机器行为一致。代价:返回键由我们裁决, 系统就不再画「滑回桌面」的预测式预览 —— 换来的是任何一层都有确定的返回行为。 2. **问网页是异步的**。抽屉 / 右栏 / 设置面板 / 全局面板都是浏览器半区(插件 `client.js` 的 `window.__dshRaBack`)回答的,WebView 没有同步的询问入口,所以返回键会先 `evaluateJavascript` 问一次「还有几层能关的」;网页线程卡住时 400ms 后按「没有浮层」处理, 免得返回键看起来失灵。普通浏览器(没有外壳)里没有这个入口,网页那半边等于不存在。 3. **两端要配套**。返回键的「网页那一半」在插件里,不在 APK 里:**APK `1.3.0` 起 + 插件 `1.3.0` 起** (`window.__dshRaBack` 是 1.3.0 加的;1.3.1 只是把最后一档从「退出」改成「退到后台」)。 装了新 APK、电脑上还是老插件时,`window.__dshRaBack` 不存在 (外壳问不到浮层),于是照旧「网页后退 → 退到后台」—— 表现就是「抽屉开着按返回仍然直接退到后台」。 这不是 APK 的 bug,把插件也更新到同一版即可(插件页里把版本号/提交更新一次,重启一次 DSH)。 反过来,**只加一层网页浮层不用重打 APK**(外壳问的永远是那两个函数)。 ## 模型提问时的通知 模型问你「要用哪个方案?」这类选择时,如果**电脑上的 DSH 只有托盘、没有窗口**,而你**没在看手机**, 屏幕上会**从顶部弹出一条横幅**(heads-up,像收到一条微信:会响、会震、几秒后收进状态栏); 答完(在 App 里答或在电脑上答)它自己消失,点它回到 App。 ``` 宿主插件 ──记录「谁在等回答」+ 收「窗口可见吗」──► /__remote_access__/notify(长轮询,带鉴权) ▲ WatchService(前台服务)─────────────────────────────────┘ App 退到后台时起来 ``` ## 一轮结束时的通知(1.3.8 起) 同一个长轮询里,宿主还会带一个 `finish` 字段(`{ id, at, ok, reason, message, ms }`): **一轮跑完了**(`ok: true`,带用时)或者**异常结束**(`ok: false`,`reason` 是宿主按 DSH 错误码 翻好的人话:模型输出格式错 / 余额额度不足 / 网络连不上 / 被限流 / 凭据失效 / 上下文超长 / 服务端错误…)。 外壳这一侧的规则: | 处理 | 怎么做的 | |---|---| | 什么时候弹 | 与「提问」同一把闸:`enabledFinish` 开着 + **App 不在前台** + (配置要求时)**电脑窗口不可见** | | 去重 | 去重键是 **`<宿主实例>:<编号>`**(宿主每次重载换一个 `boot`,编号自增),**存在 SharedPreferences 里** —— App 切前台时这个服务会被停掉、切回后台是**新实例**,状态只放内存的话同一条会被反复弹(实测症状) | | 不重复触发 | 宿主侧也收紧:**一次 `running` 只兑现一次「结束」**,没有 `running` 过的 `idle`(维护 / 压缩等中间态)一律忽略 | | 新鲜窗口 | 服务只在你退到后台时活着,所以 `at` 在 **5 分钟内**的那一条照样提醒(「在页面上看着它跑完、然后切出去」也能收到),更旧的只记键不打扰 | | 独立通道 | 「**任务结束**」通道(`dsh-finish`,`IMPORTANCE_HIGH`)与「模型提问」分开:想只静音其中一类不用连着另一类一起关 | | 每次都弹 | 同样两个 id 轮流用(`1004` / `1005`)—— 每跑完一轮都算一条**新通知** | | 正文 | 只写结论与用时(异常写分类 + 原因),**绝不带模型回答内容**(可能很长,也不该出现在锁屏上) | | 自检 | 外壳把自己两条通道的重要性都报给宿主(`ch=` 与 `fch=`):设置页页脚因此能分开写「提问通道=4、结束通道=4」 | ### 「提问」那条:看过一眼之后再退到后台,不再重复弹 两个坑都出在「提醒过一次」这件事上: 1. 原来 App 一切到前台就 `cancelChoice()`,而它顺手把**内容指纹**也清了 —— 等于「从没提醒过」, 于是你切回后台时同一条问题被当成新的,又弹一次; 2. 指纹只存在内存里,而服务在切前台时会被停掉、切回后台是**新实例**,指纹自然也没了。 现在:**只在「问题已经不在等了」(答完了)或「宿主把通知关了」时才撤回**; 指纹**持久化**在 SharedPreferences 里,提醒过一次就记着 —— 切前台再切后台不再弹; 你手动划掉那条通知也不会再弹(那本来就是你表达过的意思)。**新问题**(内容不同)照旧会响。 ### 为什么要弹出横幅、靠什么弹 Android 上「从顶部弹出来」不是通知里的一个开关,而是几条一起成立才行。这一版逐条落实: | 条件 | 这里怎么做的 | |---|---| | 通道重要性必须是 `IMPORTANCE_HIGH` | 「模型提问」通道建的时候就是 HIGH(横幅的总开关;用户手动调低就只能去系统设置改回) | | 通知要是高优先级「消息」 | `PRIORITY_MAX` + `DEFAULT_ALL` + `VISIBILITY_PUBLIC` + `CATEGORY_MESSAGE` + `setTicker` | | **必须是新通知** | 两个 id 轮流用(`1002` / `1003`):**更新同 id 的旧通知时系统不保证再弹一次横幅**,所以每条问题都当新消息发,旧的那条顺手撤掉 | | 不能被静音/仅提示一次 | `setOngoing(false)` + `setOnlyAlertOnce(false)` | | 常驻那条别来添乱 | 「后台待命」通道**故意**是 `IMPORTANCE_LOW`:不弹、不响、不震,只做前台服务的本职 | **「我只看到状态栏里一条不弹的通知」**:那几乎一定是常驻的「后台待命」那条。真正会弹的是 「模型提问」通道那条,只在**真的有问题在等**时才出现。 ### 通知发出来了、屏幕上就是不弹(小米 / Redmi 必看) 真机上踩到过:通知栏里两条都在(带问题文字那条也发了),屏幕上就是不弹。**这不是链路问题, 是厂商那一层没放行** —— App 能做的只有把通道重要性设到最高、再给一个直达设置页的按钮: - **通知上那个「去开启横幅」按钮**(1.3.5 起)直接跳到「模型提问」通道的设置页,厂商的横幅开关就在那页; - 小米 / Redmi(MIUI、HyperOS)上按重要性排,要打开的是: 1. **后台弹出界面** —— 设置 → 应用设置 → 应用管理 → DSH → 权限管理 → 后台弹出界面 (有的版本在「其他权限」里)。**最关键的一个**:App 在后台时 MIUI 靠它决定准不准弹窗,横幅也归它管; 2. **通知管理 → 通知类别 → 模型提问 → 横幅 / 悬浮通知** —— 这一条就是横幅的开关; 3. **允许通知**(同页最上面)与 **锁屏通知**(设置 → 通知与控制中心 → 锁屏通知 → 允许 DSH); 4. **省电策略 → 无限制** 与 **自启动 → 允许** —— 否则后台那条前台服务会被系统收掉,链路整个断。 - 其余厂商位置不同,但找的都是同一件事:**允许这个应用在后台弹窗 / 显示横幅**。 - 另外两种代码管不了:勿扰模式;以及 App 就在前台(问题卡片本来就在眼前,不重复打扰)。 - **怎么确认手机这一端是通的**:App 退到后台后,看「设置 → 远程访问」底部那行自检 —— 它会写 `手机端 App 1.3.5:通知已开、横幅通道=高 ✓` 或直接说出差哪一项。 这行字是手机每次轮询时报给电脑的(详见下面「自报」一段)。 - **为什么是前台服务**:App 一退到后台,WebView 那一半随时可能被系统掐掉(JS 被节流、进程被回收), 而这条通知要的恰恰是「人不在电脑前」的时候。所以 `WatchService` 自己发 HTTP 长轮询 (带着 WebView 里那张放行 cookie:设了访问密码时是插件发的那张,否则是 DSH 自己的), 由前台服务把进程保成「活着但不可回收」。 - **只做一件事**:宿主算好「有没有待答选择 / 电脑窗口可不可见」,这里只判断该不该打扰用户 —— 不解析网页、不碰会话数据。判据三条同时成立才响:有待答 + App 不在前台 + 电脑窗口不可见。 - **长轮询而不是定时轮询**:带着上次的 `rev` 问,状态没变就一直挂着(最多 20 秒), 一变立刻返回 —— 通知几乎是实时的,而请求量只有每分钟两三次。 - **常驻那一条**:Android 要求前台服务必须挂着一条通知,所以 App 在后台时状态栏有一条 「DSH 后台待命」(`IMPORTANCE_LOW`、不响、不震、**不弹**),文案会跟着状态变(「你正在看 App, 模型提问会显示在页面上」/「电脑上的 DSH 窗口正开着,不打扰你」/「连不上电脑上的 DSH,正在重试…」)。 **回到 App 时服务会停掉**,那条常驻通知也跟着消失。 - **自报**(1.3.5 起):每次长轮询都顺带把自己的通知环境报给宿主 —— `av`(App 版本)、`n`(通知开没开)、`ch`(「模型提问」通道当前的重要性)。 宿主把它回显在 `/notify` 的 `phone` 字段里,并在状态文件里记一行;手机设置页页脚因此能写出 `手机端 App 1.3.5:通知已开、横幅通道=高 ✓`。**这是「通知发了却不弹」唯一能被看见的地方** —— 那件事发生在手机这一端,宿主与网页本来都看不见。 检测到「通知权限没开」或「通道重要性被调低」时,常驻通知直接换成对应的说明并带上那个按钮。 - **权限**:Android 13+ 的 `POST_NOTIFICATIONS` 在**第一次退到后台**时问一次(`onUserLeaveHint`); 点了「不允许」就不再问,想开去系统设置里打开「DSH → 通知」。 - **连不上怎么办**:分三种,常驻通知上那行字会直接说清是哪一种 —— - `电脑上的插件版本太旧(没有通知接口),去电脑上更新它`:电脑上的插件还是 1.3.1 之前的版本; - `登录凭据已过期:打开 App 重新输一次访问密码`:放行 cookie 过期了; - `连不上电脑上的 DSH,正在重试…`:退避重试(5s、10s…封顶 60s),连续 5 分钟连不上就自己收工 (下次打开 App 会再起来)。 前两种是「等你处理」,所以不退避、也不收工,改成每分钟重试一次 —— 在电脑上更新完插件(或重新解锁)之后,手机不用再动就会自己接上。 **这三句话是 1.3.3 才分开的**:以前一律说「连不上电脑上的 DSH」,把「电脑上的插件太旧」 这个最常见的原因说成了网络问题 —— 真机上就是这么被绕了一圈的。 - **两端要配套**:这条链路是 APK `1.3.1` + 插件 `1.3.1` 一起加的(插件现在到 1.3.3, 接口没变)。老插件没有 `/__remote_access__/notify`(会 404),外壳会照上面第三类那样报, 但不会崩、也不会误报。 - **怎么知道手机上那一页跑的是哪份代码**:手机「设置 → 远程访问」底部写着 `本页客户端 <版本> · <电脑上通知接口的状态>`。1.3.2 那次「发布了却没生效」就是栽在 「插件装好了、手机上跑的却还是旧 client.js」上,所以 1.3.3 把这行自检加了上去。 - 关掉它有两种粒度(都在「设置 → 远程访问」):「提问时通知手机」(宿主不再上报待答状态) 与「仅窗口不可见时通知」(关掉后电脑窗口开着也照样提醒)。 ## 构建 需要 **JDK 17+** 与 **Android SDK**(platform 34 / build-tools 34),Gradle 8.x: ```powershell cd android $env:JAVA_HOME = 'C:\Program Files\Android\openjdk\jdk-21.0.8' # 换成你的 JDK gradle assembleDebug # 调试包:app/build/outputs/apk/debug/app-debug.apk gradle assembleRelease # 发布包(见下面的签名) ``` > **JDK 别用太新的**:Gradle 8.13 只能跑在 JDK ≤ 23 上。用 JDK 24/25 时会在解析构建脚本那一步 > 直接失败,报 `Unsupported class file major version 69`(69 = JDK 25 的 class 版本号)—— > 这不是项目的问题,换个 JDK 21 就好。 依赖只有 Android Gradle Plugin 8.8.0,没有 androidx / Kotlin —— 断网(`--offline`)也能构建。 ### 签名(发布包) 仓库里**不放密钥**。要签发布包,自己在 `android/keystore.properties` 写(该文件已 gitignore): ```properties storeFile=C:/absolute/path/to/your.jks storePassword=... keyAlias=... keyPassword=... ``` 没有这个文件时,`assembleRelease` 产出**未签名**的 APK(也能装,但后续版本必须用同一把钥匙才能覆盖安装)。 生成密钥: ```powershell & "$env:JAVA_HOME\bin\keytool.exe" -genkeypair -v -keystore dsh-remote.jks ` -alias dshremote -keyalg RSA -keysize 2048 -validity 10950 ` -storepass 你的口令 -keypass 你的口令 -dname "CN=DSH Remote, O=personal, C=CN" ``` > 注意 `storeFile` 用**正斜杠**(`C:/...`):`keystore.properties` 是 Java Properties 格式,反斜杠会被当转义符吃掉。 ## 发布 现成的包在 Releases 里(tag 形如 `v`;应用名 **DSH**,图标是自适应图标 + 各密度 PNG)。换版本时: 1. `android/app/build.gradle` 里抬 `versionCode` / `versionName`(`versionCode` 必须比上一版大,否则覆盖安装会被拒); 2. 构建、签名,把 APK 传上去; 3. 签名文件与口令放在**仓库外**,别提交。 命名约定(跟仓库改名一起对齐过):**APK 的 `versionName` 与插件版本是同一个号**(仓库改名后从 `1.3.0` 起), 安装包叫 `dsh-plugin-remote-access-.apk`,Release tag 叫 `v` (实际发过的就是 `v1.3.1` / `v1.3.0` / `v1.1.1` / `v1.1.0` / `v1.0.1`;更早的 `dsh-remote-1.1.1.apk` 是改名前的旧文件名,下载链接早就改成指向 Releases 页,不受影响)。 **唯一的例外:只动网页半区的那一版不重发 APK。** 那种版本只改 `client.js`(以及 `index.js` 里 与页面相关的小东西),手机上刷新一次页面就生效,再让用户装一遍 500KB 的 APK 没有意义 —— 这时插件版本往前走、APK 停在上一版,并在更新日志里写明(例如插件 `1.3.2` 时 APK 仍是 `1.3.1`)。 **反过来不成立**:动了外壳代码(Java / 清单 / 图标)就必须重打 APK 并抬 `versionCode`。 当前这一版:插件 **1.3.8** · APK **1.3.8**(`versionCode` 15 —— 同一版本名内改过两次外壳,第二次抬到 15 才能覆盖安装)。`versionCode` 每次重打都必须比上一版大, 否则覆盖安装会被拒。 ## 为什么单独一个 App 浏览器也能用,但手机上: - 地址栏要手打 `http://` 加长串 IP,还容易打错; - 打不开时看到的是一张错误页,**改地址得去翻浏览器历史**; - 没有「下次直接进」的入口; - 退到后台之后就没有了 —— **浏览器页面收不到「模型在等你选择」的提醒**(那需要一条前台服务)。 这个外壳就解决这四件事,网页本身(布局、设置、UI 设置)仍然全在插件里。