Files
Cyrene-For-Android/devdocs/07-W50-主动消息-验收与避坑.md
T
AskaEth b23948bed0 docs(05/07): 锚点补记二十八 —— W48/W49 设备验收通过(换环境串味 / 抽屉未读角标)
改了什么:
- 05 新增锚点补记二十八:W48 的判别法(先清空服务端主会话再换账号,否则两账号同一份历史看不出串味)与结果
  (A 的本地气泡与持久行在 B 账号下都 0 次、抽屉残留 0 次);W49 的机制证据(抽屉关着时客户端自己在
  消息到达后 60ms 发出 GET /api/v1/sessions)与可见结果(次级会话行 未读 1、正在看的主会话无角标);
  并更正一处认知:红点是抽屉内会话行的未读角标,菜单图标上没有点。
- 07 的坑清单补两条:① 登录页地址栏留空时 App 会回落到上次记住的地址(我因此白追一小时「请求怎么打到
  真后端」);②「登出」是设置页最底部的图标按钮(图标是 MiuixIcons.Back、只有 contentDescription),
  截图上像「返回」很难找。

为什么:这批验收原来只有单测覆盖,结清后 05 的待办里不再挂着它;两条坑都是这次真实花掉时间的,
写下来避免下次重演。
2026-10-06 08:24:00 +08:00

88 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. **登录页的「服务器地址」留空时,App 会回落到上一次记住的地址**(不是报错、也不是用界面上那句示例 `http://192.168.1.10:8080`)。
我为此白追了一个小时「请求怎么打到真后端去了」:那一次地址栏恰好是空的(我清过),而当时的 reverse 指向真后端。
**教训**:凡是要换后端,先 `uiautomator dump` 确认地址栏里**真有值**(`http://localhost/`),别信「刚才填过」。
10. **「登出」是设置页最底部的一个图标按钮**(`ui/settings/SettingsScreen.kt:160`,图标用的是 `MiuixIcons.Back`、
只有 `contentDescription = "登出"`,红字):截图上它看起来像「返回」,非常难找。找法:dump 里搜 `content-desc="登出"`
(位置在「关于 → 版本」下面 32dp,约 `y≈2109` 处),或先把设置页滑到底。
⚠️ 这条同时是个**观感问题**(主人可能根本不知道它在哪),要不要换成带文字的危险行条目属于产品决定。
11. 验收结束时的状态:`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:` 前缀屏蔽动作入口。