03-Niskle Link 互联方案(头显端)
Niskle Link 互联方案(头显端)
本文是三份文档中的头显端实现方案。协议只在
01-互联协议规范-v1.md里定义,本文不重复定义协议,只描述 Link 侧如何实现它。文中「协议 §N」均指该文档。
目标版本:Link 1.9.0(当前
app/build.gradle.kts:19-20= versionCode 162 / 1.8.1)对应协议阶段:P4–P7(协议 §13) 编写日期:2026-09-15 状态:设计定稿,尚未实现
--
1. 目标与非目标
1.1 目标
| # | 目标 | 验收方式 |
|---|---|---|
| G1 | 不摘头显就能看 PC 端 Niskle Hub 的运行状态 | 头显出现「已连接」与状态卡片,数字与 PC 端一致 |
| G2 | 开关追踪、调面捕灵敏度、操作体感追踪 | 头显点一下,PC 桌面 UI 立刻同步 |
| G3 | PC 端出错时头显里能看见(用户看不到 PC 屏幕) | 杀掉引擎后头显弹 notice toast |
| G4 | 免手动输 IP 的局域网发现 | 冷启动 3 秒内自动找到 PC |
| G5 | 不引入任何新第三方依赖 | settings.gradle.kts:1-18、app/build.gradle.kts:90-111 不变 |
| G6 | 新代码可回归验证 | 新增 app/src/test,覆盖协议解析/状态机/发现排序 |
1.2 非目标
| 非目标 | 理由 |
|---|---|
| 云端中继 / 跨网 | 已锁定局域网直连,协议 §10.4 只预留形状 |
| OpenXR / 沉浸式 3D | 已锁定 2D 悬浮面板,不引入 XR SDK |
| 按能力清单自动生成界面 | 协议 §6.1:界面永远手写 Compose,清单只做门控 |
| 替代桌面 UI / 取代 FrameCast 视频 | LINK 是桌面 UI 的平行入口,复用同一套状态(协议 §14) |
| QR 扫码配对 | 见 §5.4,列为后续改进 |
| 头显端 TLS | 局域网明文 + 令牌,与协议 §9 一致(network_security_config.xml:3 已允许明文) |
2. 整体架构
2.1 包结构(新增 app/src/main/java/com/framecast/stream/remote/)
| 文件 | 职责 |
|---|---|
RemoteHub.kt | app 级单例,持有 CoroutineScope(SupervisorJob() + Dispatchers.IO),组装并暴露给 UI |
RemoteRepository.kt | 连接状态机 + 能力清单缓存 + 事件分发(唯一有状态的类) |
RemoteClient.kt | HttpURLConnection 短超时封送:info/pair/rpc/capabilities/unpair |
HttpJson.kt | 信封的 org.json 编解码 + 错误码映射 |
RemoteError.kt | sealed class,与协议 §4.4 封闭错误码一一对应 |
EventStream.kt | SSE 读取循环、退避重连、应用服务端 retry |
RemoteDiscovery.kt | 发现优先级调度(协议 §10.2 四条) |
BeaconListener.kt | UDP 37022 监听 + peer 表(instance_id 去重、6 秒过期) |
MjpegPeerHint.kt | 「从既有入站 MJPEG 连接学习 PC IP」的唯一写入口 |
NetworkSnapshot.kt | 接口列表 / 本机候选 IPv4 / 子网前缀(补 NetworkUtils 只有一个字符串的缺陷) |
CapabilityGate.kt | 纯函数:op 不存在或 since > 1 → 置灰 |
RemoteTokenStore.kt | Keystore 信封加密 + 落盘,唯一能解开令牌的地方 |
KeystoreCipher.kt | android.security.keystore AES-GCM 加解密 |
RemotePrefs.kt | 新 DataStore 键的读写(仅 suspend) |
RemoteModels.kt | 手写 data class:HubInfo/HubCapabilities/HubOperation/RemoteStatus/RemoteNotice |
RemoteLogScrubber.kt | 统一脱敏,供日志与导出使用 |
ui/remote/RemoteViewModel.kt | 唯一 ViewModel,AndroidViewModel(同 MainViewModel.kt:34 风格) |
ui/remote/LinkScreen.kt | LINK 顶层页壳(子页切换) |
ui/remote/{Connect,Overview,Face,Body,Update}Page.kt | 五个手写 Compose 页面 |
ui/remote/RemoteControls.kt | 先建的缺失组件(§8.3) |
remote/ 不 import service. 或 ui.,唯一例外见 §2.3。
2.2 数据流
┌──────────────────────── Compose 层(ui/remote/)────────────────────────┐
│ LinkScreen ─┬─ ConnectPage 配对码 / 手动 IP / 发现的 PC 列表 │
│ ├─ OverviewPage 状态 + 快捷开关 │
│ ├─ FacePage 面捕参数 │
│ ├─ BodyPage 设备列表 / 部位分配 / 校准 / 自动测量 │
│ └─ UpdatePage 更新 + 已配对设备管理 │
│ │ │
│ RemoteToastHost ◄── Flow<RemoteNotice>(面板内 toast,绝不静默) │
└────────────────────┬─────────────────────────────────────────────────────┘
│ StateFlow<RemoteUiState> / 事件回调
┌────────────────────▼─────────────────────────────────────────────────────┐
│ RemoteViewModel (AndroidViewModel) —— 无业务逻辑 │
└────────────────────┬─────────────────────────────────────────────────────┘
┌────────────────────▼─────────────────────────────────────────────────────┐
│ RemoteRepository —— 连接状态机(§3) │
│ ├─ 能力清单缓存 Map<op, HubOperation> ──► CapabilityGate │
│ └─ 令牌(内存态;磁盘态由 RemoteTokenStore 加密保存) │
└──┬──────────────────┬───────────────────────┬────────────────────────────┘
┌──▼───────────────┐ ┌▼────────────────────┐ ┌▼───────────────────────────┐
│ RemoteClient │ │ EventStream (SSE) │ │ RemoteDiscovery │
│ HttpURLConnection│ │ BufferedReader │ │ 1 记住的端点 │
│ 2s / 5s 超时 │ │ 退避 1→2→4…30s │ │ 2 MjpegPeerHint(入站源地址)│
│ 请求 id 关联 │ │ 服务端 retry 覆盖 │ │ 3 BeaconListener(UDP 37022)│
│ busy→按 retry │ │ status/notice 分发 │ │ 4 手动 IP │
└──────────────────┘ └─────────────────────┘ └──────┬─────────────────────┘
│ 只读
┌───────────▼─────────────────────┐
│ MjpegHttpServer(既有,:18980) │
└─────────────────────────────────┘2.3 与既有 StreamService 的关系:互不依赖
StreamService 用静态 companion flow 发布状态、onBind 返回 null(StreamService.kt:111),没有 binder、没有第三个进程内服务位。
不新增前台服务:控制 PC 的场景就是用户正看着 LINK 面板的时候,面板不可见时保持 SSE 也没有意义(没人看 toast)。新开 FGS 还要与
StreamService的foregroundServiceType="camera|connectedDevice"(AndroidManifest.xml:98)争抢类型与startForeground路径(StreamService.kt:498-525),风险大于收益。RemoteHub是FrameCastApp持有的 app 单例(照抄FrameCastApp.kt:14-39持preferences/instance的既有模式,零新概念)。唯一接触点:
MjpegHttpServer.handleClient(socket)(:94)记录socket.inetAddress.hostAddress到MjpegPeerHint。单向、可空、可失败;remote/不 importservice.,耦合面 1 行。--
3. 连接状态机
3.1 状态与 UI
| 状态 | 进入条件 | UI 表现 |
|---|---|---|
未配置 | 从未配对成功且最近一次发现为空 | 「搜索 PC」大按钮;30 秒无结果则直接显示「手动输入 IP」入口 |
搜索中 | 四级发现依次尝试中 | 进度指示 + 「正在寻找 Niskle Hub…」,10 秒后追加「也可以手动输入 IP」 |
待配对 | 发现到 PC 且 /v1/info 返回 paired:false | 六位码输入框 + 「请在 PC 上点击『配对新设备』」 |
配对中 | POST /v1/pair 在途 | 按钮 loading(复用 LiquidActionButton 的 loading,LiquidControls.kt:250) |
连接中 | 有令牌,正拉 /v1/capabilities 并建 SSE | 骨架 + 「正在连接 DESKTOP-ABC…」 |
已连接 | 清单已缓存且 SSE 已通 | 绿点徽章 + PC 名 + 版本;状态卡片实时刷新 |
降级 | 链路在但语义失败(unavailable/timeout/internal) | 黄点徽章 + 「PC 端部分服务未运行」,具体控件置灰并附原因 |
连接中断 | SSE 断开且重连未成功,或 rpc 抛 IOException/超时 | 灰点徽章 + 「连接中断,正在重连…」;保留最后一帧状态,不显示「未配对」(协议 §8.2) |
unknown_op不改变连接状态(协议 §4.4 硬规则):只让CapabilityGate把对应控件置灰,行内显示「PC 端版本过旧」。
3.2 关键迁移
| 当前 | 事件 | 下一个 | 副作用 |
|---|---|---|---|
| 搜索中 | /v1/info 且 paired:true + 有令牌 | 连接中 | 写 remote_last_endpoint |
| 搜索中 | paired:false,或本机无令牌 | 待配对 | 显示配对码输入框 |
| 搜索中 | 四步全败 | 未配置 | 面板内 toast:「没找到 PC。请确认 Hub 已启动且在同一 Wi-Fi。」 |
| 配对中 | ok:true | 连接中 | 加密存 token + client_id + hub.instance_id |
| 配对中 | forbidden(码错) | 待配对 | 清空输入,toast 原样展示服务端 message(含剩余次数) |
| 配对中 | unsupported_version | 未配置 | toast:「PC 端版本不兼容,请更新 Hub 或 Link」 |
| 连接中 | unauthorized | 待配对 | 清除令牌与 client_id + toast:「配对已失效,请重新配对」 |
| 连接中 | 三次重试仍失败 | 连接中断 | 保留令牌,指数退避 1/2/4/8s(上限 30s) |
| 已连接 | SSE 断开 | 连接中断 | 保留最后一帧状态;不弹 toast(噪声),只变徽章 |
| 已连接 | rpc 返回 unavailable/internal/timeout | 降级 | 面板内 toast 显示服务端中文 message |
| 降级 | 任意一次 rpc 成功 | 已连接 | 清掉降级徽章 |
| 连接中断 | 重连成功 | 已连接 | 重拉 capabilities(PC 可能刚更新) |
| 连接中断 | 用户点「重新配对」 | 待配对 | 清令牌 + 调 POST /v1/unpair(失败忽略) |
| 任意 | 网络接口变化(Wi-Fi 切换/插拔 USB 网卡) | 搜索中 | 丢弃 peer 表重新发现 |
3.3 三种典型故障的确定行为
| 场景 | 判定依据 | 表现 |
|---|---|---|
| PC 关机 | TCP connect 2 秒超时,四级发现全败 | 未配置,toast 指引检查 Hub 与网络,不无限重试轰炸 |
| Hub 未启动(PC 在线) | 发现失败但 MjpegPeerHint 有 IP,或 /v1/info 连接被拒 | toast:「找到了 PC 192.168.x.x,但 Hub 未启动」——比笼统的「没找到」有用得多 |
| 令牌失效 | 任意请求 unauthorized(401) | 清令牌 → 待配对 |
| PC 版本过旧 | 清单缺该 op,或 rpc 返回 unknown_op | 连接状态不变,控件置灰 + 行内说明 |
4. 发现实现
按协议 §10.2 的四级优先级,前一步成功即停止。
4.1 第 1 级:记住的上次成功端点
持久化 remote_last_endpoint(192.168.1.10:21120)。冷启动先 GET /v1/info 探测,2 秒超时;失败降级到第 2 级,该键先留着不删(PC 可能只是还没开机完)。
4.2 第 2 级:从既有入站 MJPEG 连接学习 PC IP(零新增机制)
PC 端 Engine 会主动连接 Link 的 MJPEG 服务器(mjpeg_http_client.cpp:171-174),Link 因此天然知道 PC 的源地址:
在
MjpegHttpServer.handleClient(socket)(MjpegHttpServer.kt:94)把socket.inetAddress.hostAddress写入MjpegPeerHint(带时间戳),只接受通过协议 §10.1 安全 IPv4 校验的地址。发现流程读该 hint,配
21120做/v1/info探测。StreamService未运行、hint 为空时直接跳过。
在已经推流的正常场景下这一条几乎总是第一个命中,完全不需要广播;代价是 1 行写入。
4.3 第 3 级:监听 Hub 信标(UDP 37022)
LanDiscovery 是只发不收:registerService(LanDiscovery.kt:137-160)、DatagramSocket 只 send(:171-174,205-207),没有 discoverServices、没有 ResolveListener、没有接收线程。接收侧由新建的 BeaconListener 承担:
| 项 | 设计 |
|---|---|
| socket | DatagramSocket(null) → reuseAddress = true → bind(InetSocketAddress(37022)),1024 字节缓冲 |
| 线程 | 专用 daemon 线程(同 LanDiscovery.kt:167-169 命名习惯),soTimeout = 1000ms 以便响应停止 |
| 校验 | app == "NiskleHub"、proto <= 1、来源 IP 过安全 IPv4 校验(首字节非 0/127、<224、非 255.255.255.255,协议 §10.1)、ts 与本地时钟差 > 30 秒丢弃 |
| peer 表 | ConcurrentHashMap<instance_id, Peer(ip, port, name, paired, lastSeenAt)>;同 id 只更新 lastSeenAt,新 IP 覆盖旧 IP 并重置 |
| 过期 | 每写入前清理 now - lastSeenAt > 6000ms(信标 2 秒一次,允许丢 2 帧) |
| 广播地址 | 先 255.255.255.255:37022;3 秒内零 peer 则用 NetworkSnapshot 枚举各接口的子网定向广播地址再试一轮 |
4.4 MulticastLock:需要,但只在有限窗口持有
CHANGE_WIFI_MULTICAST_STATE 已在 AndroidManifest.xml:21 声明却从未使用。现代 AP 广播抑制与 Android Wi-Fi 省电会按接口丢弃广播帧,Pico Neo3 在息屏/省电档尤其明显——不持锁时「时好时坏」是最难排查的现场问题。但长持锁显著增加功耗,因此窗口定得很窄:只在发现流程进行中且 LINK 面板可见时 createMulticastLock("niskle-link-discovery"),最长 30 秒或首个 peer 到达即释放;acquire()/release() 严格配对,在 finally 里释放。
4.5 第 4 级:手动 IP(VR 里必须好用)
入口自动浮现:搜索 10 秒无结果即显示,不藏在设置里。
LiquidTextField(LiquidControls.kt:967)+KeyboardOptions(keyboardType = KeyboardType.Number)。头显数字键盘没有.,因此输入目标不是完整 IPv4,二选一:① 默认只输最后一段(用NetworkSnapshot推本机前缀如192.168.1.,只输10,覆盖 95% 同网段场景);② 完整 IPv4,用KeyboardType.Uri,作为「高级」开关。端口默认
21120,折叠在「高级」里,同样只允许数字。输满即自动探测
/v1/info,用LiquidProgressIndicator(:1185)显示进度;失败用字段的isError+supportingText报错,成功写入remote_last_endpoint。--
5. 配对流程 UI
5.1 用户看到什么
PC 端在 Hub 设置页点「配对新设备」→ Hub 窗口显示 6 位大字号数字码,TTL 120 秒(协议 §5.3)。
头显进入
待配对:标题「在 PC 上点击『配对新设备』」+ 六格大号输入框(RemotePinField,§8.3),每格触摸目标 ≥ 64dp。输满 6 位按钮自动点亮(
LiquidActionButton的loading,LiquidControls.kt:250)。成功 →
连接中→已连接+ toast「已连接到 DESKTOP-ABC」;失败 → 清空输入,toast 显示服务端message(协议 §4.3 要求它是可直接展示的中文)。
5.2 错误处理
| 情况 | 服务端返回 | 头显表现 |
|---|---|---|
| 配对码错误 | forbidden + message(含剩余次数) | 清空输入,toast 原文展示;连续 3 次后输入框上方常驻「还剩 N 次机会」 |
| 配对码过期 | forbidden | toast:「配对码已过期,请在 PC 上重新生成」 |
| 5 次用尽 | forbidden | 输入框禁用 3 秒 + toast:「配对码已作废」;不自动重试(避免与 PC 端重新生成的节奏打架) |
| PC 端没点「配对新设备」 | forbidden | 同上,文案引导先去 PC 操作 |
| 协议版本不符 | unsupported_version | toast:「PC 端版本不兼容」并回 未配置 |
| 网络抖动 | IOException | toast:「网络中断,请重试」,保留已输入的 6 位码 |
5.3 令牌去向
token(32 字节 base64url)与 client_id 都不进普通持久化,只经 RemoteTokenStore 用 Keystore AES-GCM 包一层后落盘(§11.2)。内存态只存在 RemoteRepository 的私有字段;RemoteClient 只从参数接收令牌,异常信息里也不拼接令牌。
5.4 后续改进(本阶段不做)
App 已声明 horizonos.permission.HEADSET_CAMERA 与相机 feature(AndroidManifest.xml:11-13,37),Pico/Quest 均允许相机访问,因此用头显摄像头扫 PC 屏幕上的二维码是可行的下一步:Hub 把 {instance_id, ip, port, 一次性 pair_code} 编成二维码,扫码即完成发现 + 配对,把 §4 与 §5 压成一步。本次仅备案。
6. HTTP 客户端
6.1 为什么仍是 HttpURLConnection 且不需要新依赖
全仓库唯一客户端是
update/UpdateRepository.kt(217 行,HttpURLConnection见:8,28,106),焊死在 OTA 语义上:15s/60s 超时(UpdateConfig.kt:29-30)不可按调用覆盖、无取消。UpdateRepository不复用——对休眠中的 PC 发一次「开始追踪」会卡 UI 15–60 秒。协议只有 6 个端点、请求/响应模型、JSON 体 ≤64 KiB、下行走 SSE。这种规模下 OkHttp/Retrofit 的连接池/拦截器/类型安全收益都不成立,代价却是新依赖 + 混淆规则 + 体积。
isMinifyEnabled=true/isShrinkResources=true(app/build.gradle.kts:42-43)且proguard-rules.pro只保护 UVC/JNI(:1-16),任何反射式序列化库都要额外 keep 规则,漏一条就是 release-only 崩溃。org.json是平台内置、无反射、无 keep 需求。settings.gradle.kts:1-18未声明任何序列化插件——这本身就是引入新依赖不划算的信号。*结论:
org.json+HttpURLConnection,零新依赖**,与UpdateInfo.fromJson(UpdateInfo.kt:52-60)的手写风格一致。
6.2 客户端要求
| 项 | 值 |
|---|---|
| 连接超时 / 读取超时 | 2000 ms / 5000 ms,且每次调用可覆盖(这正是 UpdateConfig 做不到的) |
| 端点 | POST /v1/rpc、GET /v1/info、GET /v1/capabilities、POST /v1/pair、POST /v1/unpair(协议 §3) |
| 请求头 | Content-Type: application/json; charset=utf-8、Accept: application/json、Cache-Control: no-store、Authorization: Bearer <token> |
| 请求体 | {"v":1,"id":<自增>,"op":"...","args":{...}},id 从 1 单调递增、进程内唯一 |
| 关联 | 响应 id != 请求 id → 记日志并当 internal。Rust 侧无关联机制(协议 §1.3 约束 2),关联只能在 LAN 层做 |
| 取消 | withContext(Dispatchers.IO) + 外层 Job 取消,阻塞 IO 前后 ensureActive();HttpURLConnection 无原生取消,短超时兜底 |
| 并发 | 在途 rpc ≤ 4(对齐协议 §7.1),超出本地拒绝 + toast |
6.3 错误映射(封闭集合)
RemoteError 与协议 §4.4 一一对应:BadRequest/UnsupportedVersion/Unauthorized/Forbidden/UnknownOp/InvalidArgs(field)/Busy(retryAfterMs)/Unavailable/Timeout/Internal。
4xx/5xx 能解析出
error.code→ 用code(HTTP 状态码只做校验,不参与判定,避免两端状态码表漂移)。未知
code→Internal(协议 §11.1:未知错误码当internal)。非 2xx 且响应体不是 JSON(中间设备插了 HTML 页)→
Internal,message 用「PC 端返回了无法解析的响应」。连接/读取超时 →
Timeout;连接被拒 / host unreachable →Unavailable。二者区分开,否则给不出「Hub 未启动」这种有用的提示。
6.4 busy 自动重试
busy(409,协议 §7.2 的资源组串行化)是预期内的正常返回:按钮进 loading → 等 retry_after_ms(缺省 500ms、上限 3000ms,协议 §4.3)→ 自动重试最多一次 → 仍失败则停止自动重试,把按钮恢复为可手动重试并 toast 服务端 message(如「体感追踪正在启动」),不进入循环;用户离开页面或点取消 → Job.cancel() 立即终止。
为什么只自动重试一次而不是三次:协议 §7.2 的串行化是「不排队、立刻返回 busy」。 如果客户端连续重试三次,在 PC 端看起来就是一次操作被重放三次,而 Engine 命令队列溢出时会丢弃最旧 ——重试反而可能挤掉桌面用户自己的操作。一次重试是「等对方忙完」,多次重试就是「抢」。
6.5 两个具体坑
connectTimeout/readTimeout是实例属性,不是全局设置。 每个新建的HttpURLConnection都要显式赋值(参照UpdateRepository.kt:29-30,107-108);漏设就拿不到 2s/5s,退回平台默认(读超时 0 = 无限等待)。因此「建连 + 设超时 + 设头」收敛到一个私有函数,openConnection()全项目只有这一处。连接池是 per-JVM 的且行为受限。 复用依赖 keep-alive 与响应体被读完;协议要求服务端设
Connection: close或正确Content-Length(协议 §3),因此实际上每次请求都会新建 TCP 连接。局域网可接受(RTT < 2ms),但不能假设「热连接更快」,也不要基于连接复用做限流设计。响应体读完并finally { conn.disconnect() }(照抄UpdateRepository.kt:50,206)。
7. SSE 客户端
7.1 读取循环
GET /v1/events?topics=status,notice&hz=2,Accept: text/event-stream。用 BufferedReader(InputStreamReader(conn.inputStream, UTF_8)) 逐行 readLine():
| 行 | 处理 |
|---|---|
| 空行 | 事件边界:把累积的 event/data 组装并分发 |
: keepalive | 忽略(同时是链路存活的信号) |
其它 : 开头 | 忽略(SSE 注释语法) |
event: / data: | 记录事件名 / 追加数据(多行 data: 用 \n 拼接) |
id: | 记录但 v1 不使用:协议规定连上即推全量 status 快照(§8.2),不需要 Last-Event-ID 续传 |
retry: | 记录服务端建议间隔,下次重连使用,钳制 1000–30000 ms |
readTimeout 设 40 秒(服务端心跳 15 秒,允许丢 2 次)。这个值是有作用的:若服务端进程被挂起而非断开,没有读超时读取线程会永久阻塞,重连永不触发。status 的 data 用 org.json 解析成 RemoteStatus(未知字段忽略),notice 解析成 {level, text, sticky}。
7.2 重连退避
默认 1→2→4→8→16→30 秒(上限 30s),带 ±20% 抖动避免多头显同时重连。
收到过服务端
retry:时用它作基数再乘退避倍数,仍钳制 1000–30000 ms。连接稳定 10 秒后退避计数归零。
断线语义遵循协议 §8.2:只显示「连接中断」,不清除已配对状态。
重连成功后重拉
GET /v1/capabilities(PC 可能刚更新过)。
7.3 屏幕不可见时是否保活
结论:不保活,但保留快速重连能力。
面板不可见(Lifecycle < STARTED)→ 断开 SSE,转低频探测(每 30 秒一次
GET /v1/info,2 秒超时)。面板重新可见 → 立即重连并拉一次
capabilities(预期 < 500ms,用户无感)。理由:① 看不到 toast 时接收
notice毫无价值;② 头显电池与 Wi-Fi 唤醒预算宝贵,且同进程StreamService已在做 UVC 采集与 YUV→JPEG;③ 协议 §8.2 明确「断线不改变配对状态」,断开是安全的。唯一例外:用户正在观察长操作(
body.autobone、update.install)时,页面切走也保留 SSE,直到该操作结束或 5 分钟超时。--
8. 屏幕与交互
8.1 页面清单(手写 Compose,映射协议 §12.1 分组)
| 页面 | op 分组 | 内容 | 复用既有组件 | 需先建 |
|---|---|---|---|---|
| 连接/配对页 | hub.info | 发现的 PC 列表、手动 IP、6 位码输入、Hub 版本 | ContentGroup(LiquidGlass.kt:568)、LiquidActionButton(LiquidControls.kt:250)、LiquidTextField(:967)、LiquidProgressIndicator(:1185) | RemotePinField、RemoteList |
| 总览页 | hub.status/hub.performance/tracking.*/framecast.status/app.show | 连接徽章、状态卡片、快捷开关(启动/停止追踪、下半脸、眼动)、app.show 大按钮 | ContentGroup、StandardSwitch(LiquidGlass.kt:531)、StandardActionButton(:472)、ProgressiveBlurHeader(:334) | ConnectionBadge |
| 面部追踪页 | face.*、vrcft.* | 灵敏度滑块(0–200%)、时间滤波开关 + 强度、VRCFT 启停 | StandardSlider(:546)、LiquidSlider(LiquidControls.kt:764,943)、LiquidChoiceField(:1058) | RemoteDialog |
| 体感追踪页 | body.* | 设备列表(在线/电量/部位)、部位分配下拉、比例调节、校准重置、自动测量 | LiquidSegmentButton(:1161)、LiquidNavRow(:1121)、LiquidSlider、LiquidProgressIndicator | RemoteList、RemoteDropdown |
| 通知与更新页 | update.*、client.* | 更新检查、下载进度、安装、已配对设备列表与吊销 | LiquidProgressIndicator、LiquidNavRow、ContentGroup | RemoteList、RemoteDialog |
confirm: true 的操作(vrcft.stop、body.start、body.stop、body.autobone、capture.clear、update.install、client.revoke)一律用 Link 自己的确认弹层,文案逐字照抄协议 §9.4——用户看不到 PC 屏幕,文字必须说明后果。
8.2 VR 可用性规则(硬性)
触摸目标 ≥ 56dp,主操作 ≥ 64dp 高(头显手势/手柄射线落点精度远低于手指)。
不做密集文本:每卡片最多 3 行,正文 ≥ 15sp,数字用大字号独立块。
除数字外不用打字:枚举一律用选择器,只有配对码、IP、设备重命名会唤起键盘。
破坏性操作一律在 App 内二次确认,不用系统 Toast 承担确认职责。
每个错误都以面板内 toast 呈现,没有静默失败。 唯一例外是 SSE 断开(只改徽章:它会自动恢复且会连续发生)。任何
catch里既无 toast 也无状态变更,都算 bug。
8.3 先建的缺失组件
现有设计系统没有列表/LazyColumn、下拉选择、Snackbar/Toast、连接状态组件,且 LiquidModal 是 private(HomeScreen.kt:2017)。建设顺序按下表:
| 组件 | 要点 |
|---|---|
RemoteList | LazyColumn(HomeScreen.kt 全程用 verticalScroll,长列表要 Lazy)+ ContentGroup 卡片行;空/加载/错误三态齐全 |
RemoteToastHost | 顶对齐 Box + AnimatedVisibility 队列,形似 LiquidGlassSurface(LiquidGlass.kt:112);时长按 level 区分(error 5s,其余 3s) |
ConnectionBadge | 状态点 + PC 名 + 版本,绿/黄/灰;点击展开诊断(IP、端口、instance_id 前 8 位) |
RemoteDialog | 把 HomeScreen.kt:2017 的 private fun LiquidModal 提升为 internal 并移到独立文件,不做复制 |
RemoteDropdown | 复用 HomeScreen.kt:59-62 已引入的 Material3 ExposedDropdownMenuBox,外观用 ContentGroup 包 |
RemotePinField | 六格大号数字格 + 一个隐藏 BasicTextField,支持粘贴;6 个独立输入框的焦点管理会失控,不这么做 |
8.4 视觉预期
supportsOpticalGlass 是 Build.VERSION.SDK_INT >= TIRAMISU(HomeScreen.kt:201、LiquidGlass.kt:134)。Pico Neo3 是 Android 10(API 29),远低于 33,因此目标设备上「液态玻璃」会退化到 LiquidGlass.kt:287-304 的平面分支:纯色 tint + 阴影 + 边框。这不是 bug,是既有行为。本次不承诺任何视觉打磨:不靠模糊区分层级,不把关键信息藏在玻璃层后面;验收只看「API 29 上信息清晰、可点、状态可读」。
9. 通知(notice → 面板内 toast)
链路:SSE event: notice → RemoteRepository → Flow<RemoteNotice> → RemoteToastHost。协议 §8.3 的 notice 是头显场景价值最高的主题。
| 机制 | 规则 | |
|---|---|---|
| 去重键 | `level + " | " + text` |
| 去重窗口 | 同一键 10 秒内只入队一次(与协议 §8.3 的服务端去重窗口一致),重复命中则把已显示条目计数 +1,显示为「文本 ×N」 | |
| 速率上限 | 队列最多 4 条(与 Hub 桌面端一致)、消费间隔 400ms;整体不超过 5 条/10 秒;超限丢弃最旧的保留最新(用户最需要当前状态) | |
| 合并 | 同 level 且 1 秒内的多条合成一条(换行分隔,最多 3 行) | |
| 普通通知 | 展示 3 秒后自动消失(与 Hub 桌面端一致) | |
sticky: true | 不自动消失,带「知道了」;同时最多 1 条 sticky,新 sticky 顶掉旧的(旧的降级为普通 toast);最长 30 秒后强制降级为可关闭的常驻条,不长期占据画面(协议 §8.3) | |
| 断线清空 | SSE 断开时清空队列(重连后的旧错误已无意义)。协议 §8.3 规定服务端不重发历史通知 | |
| 第二道防线 | 服务端「变化检测 + 节流」是协议要求(§8.2),客户端这层仍要做——不能假设服务端实现正确 |
status 变化驱动的本地提示(如「追踪已启动」)也走同一队列,保证用户操作总有反馈。
10. 导航改造
10.1 现状为什么不可扩展
NiskleTab 只有两个成员(LiquidGlass.kt:102)。加第三个 Tab 要改 7 处硬编码:枚举本身、selectedIndex = if (selected == STREAM) 0 else 1(LiquidBottomTabs.kt:373)、if (index == 0) … else …(:382)、tabsCount = 2 与固定 Modifier.width(220.dp)(:386-387)、两个字面量 Tab 块(:390-434)、when(tab)(HomeScreen.kt:270-341)、按 ordinal 排序的 AnimatedContent(:366)。而 HomeScreen.kt 已 2503 行,HomeScreen(...) 已吃 18 个状态值 + 19 个回调(:154-196)。
10.2 改造三步(每步独立可编译)
| 步 | 动作 | 风险 |
|---|---|---|
| 1 | NiskleTab 由 enum 改为 data class NiskleTabItem(id, title, iconRes, badge) + 顶层 val NISKLE_TABS: List<NiskleTabItem>(顺序即展示顺序) | 低:纯数据搬迁 |
| 2 | NiskleLiquidFloatingNavigationBar 改为遍历 tabs:selectedIndex = tabs.indexOfFirst { it.id == selected }、tabsCount = tabs.size、宽度 Modifier.width((110 * tabs.size).dp)、Tab 内容 tabs.forEach { LiquidBottomTab(...) } | 低:行为不变,仅去硬编码 |
| 3 | pageContent 改 when (tab.id) + "link" -> LinkScreen(...);LINK 自带 RemoteViewModel,故 HomeScreen 参数只增 1 个(remote: RemoteUiState) | 中:唯一触及 2503 行文件的一步,单独提交一次 |
HomeScreen的 37 个参数本身就是下一个技术债。本次不做全面重构(无测试保护,见 §12),但边界要守住:remote 相关回调不再往HomeScreen里塞。
10.3 顶层 Tab 还是设置子页
结论:顶层 Tab(第 3 个)。
需要常驻状态可见性:用户随时都想知道「PC 还连着吗」。放进设置二级页等于把这信息藏两层,而
ConnectionBadge的价值就在常驻。操作频次:戴着头显操作 PC 是高频核心动作,「设置 → 找入口 → 进入」在 VR 里是 3 倍操作成本。
ordinal动画天然支持:AnimatedContent用targetState.ordinal判方向(HomeScreen.kt:366),列表顺序天然给出 ordinal,无需改动画代码。
代价是底部栏由 220dp 变宽到约 330dp,在 Pico Neo3 的 2D 面板里仍居中可点。SettingsPage 那套 private enum 保持不动(HomeScreen.kt:151),LINK 内部子页照抄同样的 rememberSaveable + enum 模式(:212-213),不引入 navigation-compose——为 5 个子页引入导航库不值得,且会与既有返回键处理(:262-267)打架。
11. 持久化与安全
11.1 新增 DataStore 键
文件仍是 framecast_settings_v2(PreferencesRepository.kt:19),键加在 private object Keys(:71-87,现 15 个键)。
| 键 | 类型 | 说明 | 默认 |
|---|---|---|---|
remote_token_sealed | String | Keystore 信封加密后的令牌(§11.2) | 空 |
remote_client_id | String | 本机 client id(UUID,与令牌同生命周期) | 空 |
remote_last_endpoint | String | 上次成功的 ip:port | 空 |
remote_hub_instance_id | String | 上次配对的 Hub instance_id | 空 |
remote_manual_ip | String | 手动输入的 IP(含前缀) | 空 |
remote_manual_port | Int | 手动端口 | 21120 |
remote_poll_hz | Int | status 订阅频率 1–5 | 2 |
remote_notice_level_min | String | 最低显示级别 | info |
硬规则:新键只提供 Flow 与 suspend fun set…(),不再新增 …Sync() getter。既有 runBlocking { dataStore.data.first() }(:169-171,173-175,197-203)被主线程 1Hz 与逐帧调用(StreamService.kt:50,225-232,415)是既有反模式;本次不改它(无测试保护),但新代码不复制这套写法,RemotePrefs 全部挂起。
11.2 令牌保护决策
事实:android:allowBackup="true" + dataExtractionRules/fullBackupContent(AndroidManifest.xml:51-53)。现有规则只 include domain="sharedpref"(backup_rules.xml:3、data_extraction_rules.xml:4,7),不含 file 域,所以 DataStore 的 framecast_settings_v2.preferences_pb 目前不在备份范围内——但这是巧合而非设计,且 adb backup、root、云备份策略变更都可能打破它。真正的现实风险是 AppLog/LogExporter/CrashHandler(§11.3)。
选定方案:Keystore AES-GCM 信封加密后再落入 DataStore。
| 候选 | 结论 |
|---|---|
android:allowBackup="false" | 为一个 token 牺牲全部设置的换机迁移,副作用过大,否决 |
| 只用 data-extraction 规则排除 | 依赖「规则写对且系统遵守」,漏配即明文外泄;只作补充 |
androidx.security:security-crypto | 引入新依赖,与 G5 冲突,且该库已维护状态 |
只读 javax.crypto + android.security.keystore | 采纳:零新依赖,minSdk 24 已满足 Keystore 内 AES-GCM(API 23+),密钥不可导出,抄走的文件在别的机器上解不开 |
实现要点:
别名
niskle_link_hub_token_v1,KeyGenParameterSpec用AES/GCM/NoPadding、setUserAuthenticationRequired(false)、setRandomizedEncryptionRequired(true)。信封结构(避免每次请求走 Keystore 慢路径,也便于轮换):随机 32 字节 DEK → 用 DEK 加密 token(随机 12 字节 IV,128-bit tag)→ 用 Keystore 的 KEK 加密 DEK(同样随机 12 字节 IV)→ 存 base64(
[1B 版本][12B wrapIV][wrapCt][12B dataIV][dataCt])。IV 处理:IV 每次都从
SecureRandom取新的 12 字节,重用一次都不行;GCM 下 IV 重用是灾难性错误。IV 需要保证的是唯一性而非保密性,明文拼接存储是正确的。密钥失效:
KeyPermanentlyInvalidatedException、UserNotAuthenticatedException、AEADBadTagException、解密后校验失败,全部走同一条路径——清remote_token_sealed+remote_client_id→待配对→ toast「配对信息已失效,请重新配对」。不做「先试试无令牌请求」的模糊降级,那只会让用户看到一堆unauthorized却不知发生了什么。解密入口唯一:只有
RemoteTokenStore能产出明文 token;明文只以参数形式在进程内传递,不进任何data class的toString。
11.3 日志不泄露令牌与配对码
AppLog(AppLog.kt:23-41)既 Log.x 到 logcat 又写入内存环形缓冲;LogExporter.exportToDownloads 把 AppLog.dumpAll()(LogExporter.kt:97)写到公共 Downloads 目录(:26-31);CrashHandler 闪退时把同一份缓冲写到应用私有目录 + Downloads(:37-53);:148-165 的兜底路径还会写 getExternalFilesDir,部分设备上可被文件管理器读到。措施:
RemoteLogScrubber.scrub(s):正则替换(?i)(token|authorization|bearer|pair_code|secret)\s*[:=]\s*\S+→$1=***,并对疑似 base64url 的长串整体打码。remote 日志全部过 scrubber;
RemoteClient/RemoteTokenStore/SSE 解析层不直接调AppLog。配对码只存在于配对页 Composable 局部状态与单次请求体,不进 Repository、不进持久化、不进日志。
新增自检:debug 构建导出日志前断言
dumpAll()不含任何已注册敏感串,把「有没有泄露」变成可回归的测试。顺带建议(属 PC 端范畴):
network_security_config.xml:3是裸的cleartextTrafficPermitted="true",将来若要收窄头显端明文范围,做法是改成domain-config白名单。
12. 风险与未决问题
| # | 风险 | 影响 | 缓解 |
|---|---|---|---|
| R1 | 无 git 历史、无测试、无 CI:git log 为零提交(约 30 个未跟踪项),app/src 只有 main(无 test/androidTest),任何重构都没有回滚点和回归网 | 高 | P4 的第一个交付物就是 app/src/test + 纯函数测试(发现排序、peer 过期、信封编解码、错误映射、令牌往返、CapabilityGate);为此 HttpJson/CapabilityGate/PeerTable 都不依赖 Android 框架。动手前另打一份目录快照(不执行任何 git 写命令) |
| R2 | 广播发现可靠性:AP 广播抑制、多 AP 漫游、头显 Wi-Fi 省电都会丢信标 | 中 | ① 四级优先级保证推流场景下第 2 条即命中;② 手动 IP 始终可达;③ MulticastLock 只在搜索窗口持有(§4.4) |
| R3 | StreamService 同进程做 UVC 采集 + YUV→JPEG,新增 SSE 与发现线程会争 CPU/网络 | 中 | RemoteHub 只在 Dispatchers.IO;无线程轮询式 DataStore 写入;低频探测最小 30 秒;断线退避而非死重连 |
| R4 | runBlocking 反模式就在隔壁(PreferencesRepository.kt:169-203,被 StreamService.kt:50,225-232,415 以 1Hz 与逐帧调用) | 中 | 新代码全挂起;Review 检查项:remote/ 与 ui/remote/ 中不出现 runBlocking(可用 grep 当 CI 规则) |
| R5 | 玻璃 UI 在目标机上是空操作:API 33 门槛(HomeScreen.kt:201、LiquidGlass.kt:134)vs Pico Neo3 的 API 29 | 低(影响预期) | §8.4 已明确:不承诺视觉打磨,验收只看信息可读与可点 |
| R6 | Hub 端 API 尚不存在(P0–P3 未实现,/v1/* 一个端点都没有) | 高(阻塞) | ① 先写 ~150 行 mock(PowerShell HttpListener 或 Python)实现 5 个端点 + 错误注入(busy/unauthorized/unknown_op);协议 §11.4 也要求 Hub 提供探针脚本,正好复用;② Hub 端 curl 验证通过前,Link 侧联调结论都不算数 |
| R7 | 协议无机器可校验的一致性点(协议 §11.4 已自陈漂移风险) | 中 | 单元测试固化 v1 的 op 白名单与错误码封闭集;未知 op → 置灰,不崩 |
| R8 | HttpURLConnection 无取消、连接池行为受限 | 低 | 短超时 + Job 取消兜底;不基于连接复用做任何假设 |
| R9 | remote/ 与既有 update/ 都叫「Repository」但语义不同 | 低 | 用 RemoteRepository/RemoteClient 前缀;KDoc 写清二者不可互相复用 |
| R10 | 头显输入法与 Compose adjustResize 的组合差异 | 中 | windowSoftInputMode="adjustResize"(AndroidManifest.xml:72)已就位;键盘是否遮挡配对输入框只能在真机上看,编译期验证不了 |
12.1 协议修订项(已全部采纳并写入协议规范)
编写本文时针对协议规范 v1 提出了 7 条修订意见,均已采纳,下表是它们的落地位置。 实现时以协议规范为准,本节仅作追溯。
| # | 原问题 | 协议中的落地 |
|---|---|---|
| 1 | 信标既有固定发送端口又有 port 字段,语义重叠 | 协议 §10.1:信标恒发 37022/UDP 且不可配置;port 只表示控制 API 的 TCP 端口 |
| 2 | GET /v1/info 缺完整字段表 | 协议 §3.1:已补完整字段表与「不得返回什么」的清单,并新增 addresses[] |
| 3 | busy.retry_after_ms 缺省值与上限未定义 | 协议 §4.3:缺省 500 ms、上限 3000 ms,客户端最多自动重试一次 |
| 4 | retry: 的发送时机与是否持久化未定义 | 协议 §8.2:每条连接建立时发一次;客户端按 host:port 持久化(原稿「仅进程内生效」已按协议升级为持久化) |
| 5 | notice.sticky:true 缺超时与数量上限 | 协议 §8.3:30 秒后降级、同时最多 1 条、服务端 10 秒内同文本去重、整体 ≤5 条/10 秒(原稿写的 60 秒/3 条已按协议修正) |
| 6 | client.id 生成方与存储位置 | 协议 §5.2:Link 生成 UUID v4,首次配对面生成、终身不变;Hub 见到已知 id 视为重新配对,不新增条目 |
| 7 | 多网卡 PC 时学到的 IP 可能不通 | 协议 §10.3(新增):把三个来源的地址合并成候选列表并发探测,取最先成功者。/v1/info 的 addresses[] 由 Hub 枚举并排序 |
另外协议还新增了两节与本文强相关:§0.1 / §15(控制面与未来串流媒体面的解耦要求)。 Link 将来要承载串流,因此 §15.2 的分层要求同样约束本文的
remote/包 ——remote/里的纯逻辑部分不依赖 Android 框架,以便将来头显端的媒体面复用它。
13. 分阶段实施(对齐协议 §13 的 P4–P7)
| 阶段 | 交付物 | 主要文件 | 真机验证 |
|---|---|---|---|
| P4-pre | app/src/test 源集 + 纯函数单测;RemoteDialog 抽取(LiquidModal 提为 internal);底部栏改造步 1–2 | app/src/test/java/com/framecast/stream/remote/**、LiquidGlass.kt(102)、LiquidBottomTabs.kt(363-434)、HomeScreen.kt(2017) | .\gradlew.bat testDebugUnitTest 通过;头显上两个 Tab 视觉无回归 |
| P4 | 发现(四级)+ 配对 + 状态机 + HTTP/SSE 客户端 + 连接/配对页 + 总览页;导航改造步 3 | remote/**(全部)、ui/remote/{RemoteViewModel,LinkScreen,ConnectPage,OverviewPage,RemoteControls}.kt、MjpegHttpServer.kt(94 处加 1 行 hint) | 冷启动 3 秒内自动找到 PC;配对后重启 App 仍「已连接」;关掉 Hub → 面板内 toast「Hub 未启动」;PC 重启后自动重连 |
| P5 | 追踪/面捕控制页(tracking.*/face.*/vrcft.*)+ notice toast + RemoteToastHost | ui/remote/{FacePage,RemoteControls}.kt、remote/{RemoteRepository,EventStream}.kt | 头显开关追踪,PC 桌面 UI 立刻同步;杀掉 Engine → 1 秒内红 toast;vrcft.stop 弹确认 |
| P6 | 体感追踪页(设备列表、部位分配、比例、校准、自动测量) | ui/remote/BodyPage.kt、remote/RemoteModels.kt | 追踪器列表与 PC 一致;分配部位后 PC 同步;body.start(会拉起 Java)有强确认;body.autobone 全流程可走完 |
| P7 | 更新页 + 已配对设备管理 | ui/remote/UpdatePage.kt | update.check/update.install 确认文案正确;client.list 与 PC 一致;client.revoke 后该设备回到 待配对 |
每阶段收尾的固定动作:① .\gradlew.bat assembleDebug 通过;② .\gradlew.bat testDebugUnitTest 通过;③ 真机 Pico Neo3 按上表手工验证并留截图/日志;④ 全程不执行任何 git 写命令(仓库无历史,git checkout/reset/commit 都可能造成不可逆损失)。
14. AI 实现提示词(P4 + P5)
把下面整段交给一个在 D:\Work\Niskle-Link 里工作的 AI 编码代理。先只读代码,再改代码。
你在 D:\Work\Niskle-Link(Niskle Link,Android 头显端 App)里实现「远程控制 PC 端 Niskle Hub」的头显侧客户端。
一、必读(只读,不要改)
1. C:\Users\Administrator\Desktop\Niskle互联方案\01-互联协议规范-v1.md —— 唯一协议真相源,不要自己重新定义协议。
2. 先完整读这些文件再动手:
app/src/main/java/com/framecast/stream/update/{UpdateRepository,UpdateConfig,UpdateInfo}.kt
app/src/main/java/com/framecast/stream/data/PreferencesRepository.kt
app/src/main/java/com/framecast/stream/ui/screens/{HomeScreen,LiquidGlass,LiquidControls,LiquidBottomTabs}.kt
app/src/main/java/com/framecast/stream/FrameCastApp.kt
app/src/main/AndroidManifest.xml、app/build.gradle.kts、settings.gradle.kts
二、技术栈与硬约束(违反即返工)
- Kotlin + Jetpack Compose + Gradle KTS,package com.framecast.stream,minSdk 24 / targetSdk 34。
- 【禁止新增任何第三方依赖】。JSON 一律用平台自带的 org.json 手写解析(参考 UpdateInfo.fromJson)。不要引入 Retrofit/OkHttp/Gson/Moshi/kotlinx-serialization/Ktor。
- 【禁止复用 UpdateRepository】。它的超时是 15s/60s 且不可覆盖(UpdateConfig.kt:29-30),会对休眠中的 PC 卡死 UI。新写一个客户端:连接超时 2s、读取超时 5s,每次调用可覆盖;每个 HttpURLConnection 实例都必须显式设置 connectTimeout/readTimeout,并 finally { disconnect() }。
- 【持久化只用挂起函数】。绝对不要写 runBlocking,也不要新增任何 …Sync() getter(PreferencesRepository.kt:169-203 的 runBlocking 是既有反模式,不要复制)。
- 令牌不能明文落盘:用 javax.crypto + android.security.keystore 做 AES-GCM 信封加密后再存进 DataStore(framecast_settings_v2)。令牌与配对码绝不进 AppLog/日志/异常 message(AppLog 会被 LogExporter 导出到公共 Downloads 目录)。
- 设计系统组件都是 internal(模块内可见,可直接用):ContentGroup、LiquidGlassSurface、StandardActionButton、StandardSwitch、StandardSlider、ProgressiveBlurHeader、LiquidButton、LiquidActionButton(有 loading 参数)、LiquidTextField、LiquidChoiceField、LiquidNavRow、LiquidSegmentButton、LiquidProgressIndicator。缺少列表/下拉/Toast/对话框组件,需要自己建;LiquidModal 在 HomeScreen.kt:2017 是 private,把它提升为 internal,不要复制一份。
- 新增包 com.framecast.stream.remote(纯逻辑,其中不依赖 Android 框架的部分要能跑单元测试)与 com.framecast.stream.ui.remote(Compose)。
- 新建 app/src/test 单元测试源集,至少覆盖:发现优先级排序、peer 表按 instance_id 去重与 6 秒过期、请求/响应信封编解码、错误码→RemoteError 映射、令牌加解密往返、能力门控(op 不存在或 since>1 → 置灰)。
三、本次范围(只做 P4 + P5)
P4:发现(按协议 §10.2 四级优先级:记住的端点 → 从 MjpegHttpServer 入站连接学习 PC 的 IP → 监听 UDP 37022 信标 → 手动 IP)、配对界面(6 位数字码)、连接状态机(未配置/搜索中/待配对/配对中/连接中/已连接/降级/连接中断)、总览状态页。
P5:追踪与面捕控制页、SSE 客户端(status + notice)、面板内 toast 通知队列(去重 + 限流)。
另外:把底部导航栏改造成数据驱动(NiskleTab → data class 列表,去掉 tabsCount=2、selectedIndex 的 if-else、Modifier.width(220.dp) 这些硬编码),并新增一个 LINK 顶层 Tab。
不做:体感追踪页(P6)、更新页(P7)、QR 扫码、云端中继、任何 XR SDK。
四、语义规则
- unknown_op 绝不断连:只把对应控件置灰并提示「PC 端版本过旧」。
- unauthorized:清除本地令牌与 client_id,回到配对流程。
- busy:按 retry_after_ms 自动重试,**最多一次**(缺省 500ms,上限 3000ms,见协议 §4.3);仍失败则把按钮置为可手动重试并提示,不允许无限自动重试。
- SSE 断开只显示「连接中断」,不改配对状态;退避 1→2→4→8→16→30 秒,服务端 retry: 优先。
- 所有错误必须在面板内 toast 显示服务端返回的中文 message,不允许静默失败(唯一例外:SSE 断开只改状态徽章)。
- 配对码/IP 输入只允许数字键盘;触摸目标 ≥ 56dp;破坏性操作必须用 App 内确认弹层。
五、验证与汇报(必须做)
1. 运行 .\gradlew.bat assembleDebug(JDK 17、Android SDK 34),把完整结果如实报告(成功或错误原文)。
2. 运行 .\gradlew.bat testDebugUnitTest,报告通过情况。
3. 明确列出「哪些结论在没有真机 Pico Neo3 的情况下无法验证」,至少包括:UDP 广播在实际 AP 下能否收到、MulticastLock 是否必要、系统键盘是否遮挡配对输入框、与实际 Hub 的端到端联调(Hub 端 API 目前尚不存在,只能对 mock 验证)。
4. 不要执行任何 git 写命令(该仓库没有任何提交历史)。
5. 不要改动 package name、versionCode/versionName、签名配置、minSdk/targetSdk、abiFilters。15. 附:与既有模块的关系
| 既有模块 | 关系 |
|---|---|
update/UpdateRepository.kt | 不复用(超时不可覆盖、语义焊死 OTA);remote/RemoteClient.kt 是独立实现 |
service/StreamService.kt | 互不依赖;唯一接触点是 MjpegHttpServer.handleClient 把对端 IP 写进 MjpegPeerHint |
discovery/LanDiscovery.kt | 不动(继续保持 advertise-only);新增 BeaconListener 负责接收 |
util/NetworkUtils.kt | 只读复用 deviceLabel();子网/多接口需求由新增 NetworkSnapshot 补足,不改旧函数 |
data/PreferencesRepository.kt | 复用同一个 DataStore 文件,只新增键;不新增 …Sync() |
util/{AppLog,LogExporter,CrashHandler}.kt | 不改行为,但所有 remote 日志都过 RemoteLogScrubber |
ui/screens/{HomeScreen,LiquidGlass,LiquidBottomTabs}.kt | 按 §10.2 三步改造;设计系统组件直接复用,不重写 |
FrameCastApp.kt | 新增持有 RemoteHub 单例(与现有 preferences 同级) |