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

7.2 KiB
Raw Blame History

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 冷启动要跑类校验(日志里每条 100170ms), **等 2535 秒再动**;并把三个动画开关置 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. 复现整套验收的最短路径

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: 前缀屏蔽动作入口。