# W50「她主动说的话实时到安卓」——验收证据与避坑 > 状态:**W50 已合并并验证**(`android` 分支 `afee5f6`,已推)。本文件是该批次的**证据与操作细节**; > 状态与待办仍以 `05-开发进度与交接.md` 的锚点补记二十七为准(那里只放结论与指针)。 --- ## 1. 验收判据与证据(真机 = 模拟器 `emulator-5554`,假后端) 场景是怎么造出来的:`python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一"` (它按真后端的顺序**先落一行 assistant 历史行、再广播带同一 `id`/`createdAt` 的 `proactive_message` 帧**)。 | 判据 | 操作 | 证据 | 结果 | |---|---|---|---| | ① 主会话**实时**出现 | 注入后 5s 取 dump | 广播回执 `delivered=1`;dump 里有 `[78,406][400,472] \| 主动消息验收一`,其下 `发送至 网页` | ✅ | | ② 次级会话**不**出现 | 抽屉 → 「新会话」 | 该页 dump 里 `grep -c 主动消息验收一` ⇒ **0**(页面显示「还没有消息」) | ✅ | | ③ 刷新/重启后**仍在且只一条** | ①切回主会话 ②同 `id` 重投一帧 ③`force-stop` 后重启 | ①计数 **1** ②重投后仍 **1**(按 `id` upsert 生效)③重启后 **1** | ✅ | | ④ 无 `id`(后端落库失败)**不静默丢**、也不假装落库 | `--without-id` 注入 → 再重启 | 注入后计数 **1**(本地气泡);重启后 **0**(历史里没有它),而落库那条仍 **1** | ✅ | 截图(主会话单条助手气泡 + 左下对齐)与 dump 一致,渲染无重叠。判据以 **dump 计数**为准(比截图更硬,也更省上下文)。 ## 2. 环境坑(这次花掉的时间都在这,别再重复) 1. **`input text` / `input keyevent` 打出的标点会变全角**(`:`→`:`、`.`→`。`、`/`→`/`)。 原因:模拟器**系统 locale 是 zh-CN**,Gboard 走中文标点;字母数字不受影响。 **绕法(不必改 locale)**:`normalizeBaseUrl`(`app/.../data/api/ApiClientFactory.kt:109`)在缺 scheme 时 会补 `http://` ⇒ 服务器地址栏只填**纯字母** `localhost`,再用 `adb reverse tcp:80 tcp:<后端端口>` 把设备的 80 端口接到后端。**附带好处:切假后端/真后端只要改 reverse,一个字都不用重打。** 2. **`adb reverse tcp:80` 需要 root**:`adb root` 在本 AVD(userdebug)可用, 但它**会重启 adbd ⇒ 所有 `adb reverse` 丢失**,之后必须重新加(包括 `tcp:10942`)。 3. **点太快会 ANR 被系统强杀**:实测 `ANR in top.yeij.cyrene … Waited 5065ms for MotionEvent(… (367.0, 780.0))` 紧跟 `Force finishing activity`。App 冷启动要跑类校验(日志里每条 100~170ms), **等 25~35 秒再动**;并把三个动画开关置 0(`window_animation_scale` / `transition_animation_scale` / `animator_duration_scale`)。 4. **屏幕会自己黑**(`svc power stayon true` + `screen_off_timeout` 都不一定挡得住): 交互前 `input keyevent 224`,并用 `dumpsys window | grep mCurrentFocus` **确认前台是本 App**—— 黑屏时点击会落到桌面(这次白跑过两轮),回到桌面后必须重新 `am start`。 5. **`adb shell … /sdcard/…` 的路径会被 MSYS 改写**(变成 `C:/Program Files/Git/sdcard/…`)⇒ 命令前 `export MSYS_NO_PATHCONV=1`。 6. **Python 输出 GBK 会炸**(`UnicodeEncodeError: 'gbk' codec can't encode character`)⇒ `export PYTHONIOENCODING=utf-8`(`ws_probe.py` 里那段 `reconfigure` 就是同一件事)。 7. **`uiautomator dump` 时好时坏**(`ERROR: null root node returned by UiTestAutomationBridge`):失败就重试; 文字/坐标判据一律优先用 dump(`bounds | text`),截图只在需要看观感时用。 8. **滑动手势会被系统返回手势吃掉**:从屏幕底部附近起滑会直接回桌面。真要滚动就避开底部区域 (例如 `input swipe 540 1500 540 800 300`),别用 `1900 → 700`。 9. 验收结束时的状态:`adb root` 仍是开的;reverse 为 `tcp:80 → tcp:10942` 与 `tcp:10942 → tcp:10942` (都是真后端);App 拿的是假后端签发的 token ⇒ 会在真后端的 401 上登出,登录页地址栏留着 `localhost`。 ## 3. 复现整套验收的最短路径 ```bash export PATH="$PATH:/c/Users/Aska/AppData/Local/Android/Sdk/platform-tools" export MSYS_NO_PATHCONV=1 PYTHONIOENCODING=utf-8 # 1) 假后端 + 反向端口(80 ⇒ 假后端,地址栏填 localhost 即可) python devtools/fake-backend/fake_backend.py --port 18090 & adb root && adb reverse tcp:80 tcp:18090 # 2) App 侧:清数据 → 登录页填 localhost / 任意用户名密码 → 登录(假后端不校验) adb shell pm clear top.yeij.cyrene adb shell am start -n top.yeij.cyrene/.app.MainActivity # 等 25~35s 再动 # 3) 注入 + 看判据(四条判据见第 1 节) python devtools/fake-backend/proactive_inject.py --port 18090 --text "主动消息验收一" python devtools/fake-backend/proactive_inject.py --port 18090 --text "落库失败验收" --without-id ``` ## 4. 这个批次顺带发现的客户端缺陷(已随 W50 修掉) `proactive_message` 载荷里的 `timestamp` 在服务端是 Go `time.Time` ⇒ 线上是 **RFC3339 字符串**, 而客户端旧声明是 `Long?` ⇒ kotlinx 解码抛异常、被 `WsEvent.parse` 的 `runCatching` (`app/.../data/ws/WsEvent.kt:654`)**吞成 null** ⇒ 整帧在成形之前就没了。 也就是说:**只加一条 `onEvent` 分支根本碰不到帧**。现在按字面量收(字符串/数字都吃); 该字段全仓零消费点,排序去重一律用 `createdAt`。 ## 5. W50 留下的已知窄尾巴(**只在「落库失败」那一档可达**) 降级气泡是一条 `role=assistant` 的 `messages` 行,而删除入口的判据 `canDeleteMessage` 只看 role ⇒ 长按能删它,删除会用本地 id(`proactive-local:` 前缀)打服务端并失败报错。 **没修的理由**:要收口得给每条消息加「是否服务端行」的标志位,会动 `ChatDisplayItem` 与气泡渲染, 超出该批次范围。**最省的收口方式**:在 UI 层按 `proactive-local:` 前缀屏蔽动作入口。