AskaEth
|
436c0f06f6
|
Merge branch 'feat/w76-multiimage-share-batch-delivery' into android
|
2026-10-26 23:40:28 +08:00 |
|
AskaEth
|
4a9e22ea77
|
Merge branch 'feat/w77-unread-incremental-after-cursor' into android
|
2026-10-26 23:40:17 +08:00 |
|
AskaEth
|
6d158ca9bd
|
fix(w77): 满页之后的「不满页」也说不清尾巴,complete 收紧为「从未拿过满页」
r2 复审指出 `loadIncrement` 的第三档(`!hasMore` ⇒ `complete = true`)是空页档同一个
洞的另一个出口:它只看「本页满不满」,不看整个追尾里有没有拿过满页。
反例(页宽 2、`after=0`、积压 m1@500 / m2@500 / m3@500 / m4@600):第一页给
[m1,m2](满页,服务端 LIMIT 恰好把 500 那一毫秒劈开),第二页 `after=500` 只给
[m4@600]、不满页 ⇒ 旧代码报 `complete = true`,而 m3@500 既不 `> 500`、永远拿不到,
也不会触发回落整表 —— 未读红点被做小,正是这条增量路径最不能出的错。
改法:第三档改为 `complete = !chasedFullPage`,不变式收紧为
complete = true 当且仅当整个追尾从未拿过满页。
理由写在 KDoc 里:只有服务端的 LIMIT 在某一毫秒中间切过一刀才会留尾巴,客户端看不见
那一刀落在哪儿,只知道自己拿过满页就等于发生过一次切点 —— 此后无论下一页是空页还是
不满页,都说不出「追到了尾巴」。四档表(第一页空 / 第一页不满 / 满页后空 / 满页后不满 /
翻满 maxPages)与代价段一并重写。
代价(复审已认可):积压 ≥ 页宽(默认 50 条)一律回落整表,与「翻满 MAX_INCREMENT_PAGES
⇒ 回落」同类,也正合那句「积压多到增量不再便宜」;主路径(几条新消息、第一页就不满)
不受影响。
- `MessageRepository.loadIncrement`:标志位 + 第三档出口,KDoc 不变式与四档表重写;
- `MessageRepository.loadAfter`:KDoc 的告警从「满页之后的空页」改为「满页之后的一页
(空页、不满页都一样)」;
- `SessionsViewModel.flushPendingIncrements`:回落理由 2 同步为「整个追尾里拿过整页」;
- 新增反例测试 `a short page after a full page is not proof of a clean tail either`。
验证:`./gradlew testDebugUnitTest assembleDebug` BUILD SUCCESSFUL;135 个套件
1692 条测试全绿(上一轮 1691,+1 即上述反例),0 failures / 0 errors / 0 skipped。
|
2026-10-26 23:34:59 +08:00 |
|
AskaEth
|
b35b1b53ab
|
W76:修假后端 keep-alive 下请求体缓存跨请求串用(设备实测「3 张图只投出 2 张」的真凶)
`protocol_version = "HTTP/1.1"` ⇒ 同一条 keep-alive 连接上的多个请求由**同一个 Handler
实例**依次处理(`handle_one_request` 循环),而上一轮给 `_read_body` 加的缓存
(`_body_read` / `_body_cache`)从不在请求之间重置。后果两条:
- 第 2 个请求拿到第 1 个请求的 body ⇒ `out/http.jsonl` 里的摘要记成上一份;
- 第 2 个请求自己那份 body **没人从 `rfile` 读走**,残留字节留在内核缓冲里,被下一个
请求的请求行一并吃掉 ⇒ `BaseHTTPRequestHandler` 回
`501 Unsupported method ('{"sessionId":...}POST')`。
W76 设备实测里这表现成「系统分享 3 张图,只投出 2 条消息」,很容易被误判成 App 批量
投递的缺陷。修法是在 `handle_one_request` 入口把缓存清掉,按请求隔离。
验证:
1. 重启假后端后,在同一条 keep-alive 连接上连发 3 个**不同 body** 的
`POST /api/v1/chat`,三发全 202,`out/http.jsonl` 记下三个不同 body(同一 client port)。
2. 完整设备重放(DocumentsUI 选 3 张图 → 分享 → Cyrene → 发送)三发三 202,
3 条独立消息落库。
|
2026-10-26 23:27:04 +08:00 |
|
AskaEth
|
db53de1a7e
|
W77 增量追尾:满页之后的空页不再当作尾巴,未读基数的边界如实登记
review 判了两条 P2,都只动 W77 这一摊。
1) `loadIncrement` 里 `newest <= cursor` 那条同毫秒防线是死代码。服务端的 `after` 是
**严格** `>`(`list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 一致),返回的
每一行都 `createdAt > cursor`,那个分支永不触发。真实风险在别处:满页恰好把同一毫秒劈开
时,下一次 `after=newest` 只会回空页,旧代码把那次空页当成「追到了尾巴」⇒ 静默少给几条
且不回落。改成「拿过一整页之后紧跟的空页 ⇒ `complete = false`」:客户端分不出干净尾巴与
这个洞,就如实说没追完,把决定权交回调用方(未读面上是回落整表)。第一页就空仍算干净
尾巴(没有可少给的),不满一页仍算追到尾巴,翻满预算仍算没追完。
连带把测试改成真后端的形状:满页后空页那条的期望从 `complete = true` 改成 `false`;同
毫秒那条不再让假后端把同一批行回两次(严格 `>` 下后端给不出这种响应),改成「三条同毫秒、
页宽 2、第二页空」并断言第二次请求真的带 `after=500`;新增「第一页空页 ⇒ `complete =
true`」把新逻辑的另一半钉住。KDoc 里「再翻也只会拿到同一批行」这类与契约矛盾的措辞一并
重写。
2) `applyIncrement` 的 `base + unseen` 在断线期间他端已读时会多计:`session_read` 帧永久
丢,`read_at` 又是账号级游标。代码行为不动,只把措辞限定清楚 ——「同源」的只是**过滤口径**
(`count(created_at > read_at)`),基数仍是上次整表的旧值,跨端读会让它漂移,由下一次整表
刷新自愈。这是**已接受的边界**,KDoc 写明了,另加一条测试把它钉住
(`a read elsewhere while offline over-counts until the next whole table`),免得日后被
当成「已修好」。
不装「`readAt > from` ⇒ 回落整表」的保守闸:本端刚点开过该会话时 `readAt` 本来就被推到
「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量路径在正常
使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门不如
不装,理由写在 KDoc 里。
顺手把 `SessionItem.lastMessageAt` 与 `flushPendingIncrements` 里过时的注释改成新口径
(它现在同时是增量追尾的 `after` 游标)。
验证:`./gradlew.bat testDebugUnitTest assembleDebug` 全绿 —— 135 个 suite / 1691 条
(比上轮 +2)/ 0 失败 / 0 跳过,`assembleDebug` 打包成功。
|
2026-10-26 23:21:15 +08:00 |
|
AskaEth
|
d716d26864
|
W77 未读增量 —— 非激活会话的静默刷新改用 GET /messages?after= 前向增量
W49 那条「非激活会话落了新消息 ⇒ 整表重拉 GET /sessions」换掉:整表一次要回**所有**
会话(每条一行预览 + 未读计数),而真正变了的只有一个会话。改问那条会话的消息增量
`GET /messages?after=<本端已见最大 createdAt>`,只回那几条新消息。
- MessageRepository.loadIncrement:after 方向的翻页版,翻到尾巴或 maxPages 为止;
同毫秒并列推不动游标 / 翻满页数 ⇒ complete = false,不猜、也不悄悄少给几条。
byId 去重后按 MessageOrder 升序交出去。
- InactiveSessionPull 三个纯函数:incrementCursorFor(游标 = SessionItem.lastMessageAt,
不另立账本)、incrementCursors(任一 id 问不了 ⇒ 整批回落,不混着来)、
applyIncrement(只动那一行;未读 = 越过本地已读游标的条数,与服务端
unreadCountQuery 的 count(created_at > read_at) 同源;lastMessageAt 已被别人推进过
⇒ 整行不动,防重复计数;没改动就返回同一实例)。
- SessionsViewModel.requestSilentRefresh 带 sessionId,窗口里攒 pendingIncrements
(集合,不是单个 id —— 一次窗口可能合并几个不同会话);flushPendingIncrements
逐个问增量,三档回落整表:游标拿不到 / complete = false / 请求失败(含会话已在别处
删掉的 404)。一次批次最多回落一次。登出时清空 pendingIncrements。
- 节流与 trailing 语义一个字没动(silentRefreshStep / throttleAfterPull 签名未改);
init / 抽屉打开 / 新建 / 删除 / ContextChanged 仍走整表。增量路径不写
prefs.saveSnapshot(增量凑不出合格的 SessionSummaryDto,那会用更差的东西覆盖好快照),
也不捎带 loadPersonas()(此前它只是跟着整表搭便车)。
量级(同 JSON 形状实测):一次静默刷新原本 = GET /sessions 全表 + loadPersonas,
10 个会话时 4100 B、2 个请求;现在 = 1 条增量 126 B、1 个请求。
测试:新增 MessageIncrementRepositoryTest(6,真 OkHttp + 真 Retrofit + JDK
com.sun.net.httpserver,验 URI 参数、满页后再问一次得空页、同毫秒并列、maxPages
用尽、404 映射、字节量对比)与 SessionIncrementTest(13,三个纯函数穷举)。
testDebugUnitTest 全绿 135 suites / 1689 tests / 0 failures / 0 errors / 0 skipped;
assembleDebug 通过。
|
2026-10-26 22:19:17 +08:00 |
|
AskaEth
|
8babd1d2bb
|
W76:把「分享批量投递零声明」提成可单测的纯函数 + 假后端 http.jsonl 记请求体摘要
两件事都是为了让「分享 3 张图 = 3 条消息、零 interrupt」这条结论有据可查——
一条是代码侧的构造性保证,一条是设备侧的证据工具。
1) ShareRules.declaresSendPreference(fromShare, index)(纯函数)
「这一次投递要不要按『她忙时发消息』偏好声明 interrupt / parallel」原先分在两处手写:
ChatViewModel.sendShared 传 false、emitPlannedMessages 里 && index == 0。两处各是半个判据、
一处都单测不到,而这条边界正是 W25 修掉的「分享多图自相打断」(第 2 张把第 1 张刚起的回合
打掉,见 02 的 F4 与 05 那一节)。提成一处判据后语义不变,穷举整批(0..MAX_IMAGES-1 及越界)
由 ShareSendDeclarationTest 钉住:分享恒 false、手打只在第 0 条 true。
ChatViewModel 不在本次 scope,接线那一行没动(已作为 finding 上报,纯函数暂为唯一出处)。
2) 假后端 out/http.jsonl 记请求体摘要(不只是 headers)
分享多图走 REST 不走 WS(ChatSendRouting.channelFor:hasImage ⇒ Rest),所以「发了几条、
每条带的是正文还是图、有没有 parallel」只能从 HTTP 流水里读。此前它只记 headers,
现在加 body 摘要:结构照留(键 / 布尔 / 数组长度),超过 200 字符的字符串换成 <N chars>,
非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。
_read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。
assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。
|
2026-10-26 22:17:44 +08:00 |
|
AskaEth
|
53d7a9fb69
|
Merge branch 'feat/w74-voicekey-strict-drop-tolerance-fallb' into android
|
2026-10-26 22:07:22 +08:00 |
|
AskaEth
|
5989e0d4eb
|
W74 撤掉「容忍 + 下次拉取」兜底 —— voice 键存在即语音,覆盖全部读面
后端回执三十八 §二:M44(合并号 fb55769,契约 v0.97)把落库顺序改成
INSERT → 写 voice 列 → notify,于是语音消息的**第一条** message_persisted /
channel_persisted 帧就带 voice,握手 history 回放也带(与 REST 读面同一收口点
attachVoiceTo → historyVoiceOf)。据此撤掉 W73 时期的「容忍 + 下次拉取」兜底。
## 撤掉的到底是什么(调查结论,不是运行时改动)
全仓 grep 过「容忍 / tolerat / 下次拉取 / refetch / 自动升级 / 四个 REST / 覆盖率」,
**没有任何运行时的兜底分支**:`domain.chat.isVoiceMessage` 从 W73 起就一直只是
`message.voice != null`,没有重试、轮询、延迟判定或「先按文本显示」的标记位。
兜底的全部实体是三处**口径**(两处 KDoc + 一条测试注释),本次逐条改正:
1. MessageDto.kt 类头:「[voice] 是『一处覆盖三条路』的例外,只在四个 REST 读取面填充,
实时帧与握手回放缺席」→ v0.97 起**不再是例外**,四条读面都填、第一条帧就带。
2. MessageDto.kt 的 voice 字段 KDoc:删掉「覆盖率(v0.79):四个 REST 读取面才有 /
实时帧没有 / 不要在实时帧上等 voice / 等下一次拉取自动升级成语音气泡 / 这是设计不是缺陷」,
换成 v0.97 口径(全部读面都带、第一条帧就带),并把回执点名的两条实现前提写成护栏:
① voice 列写入必须先于 notify;② 帧内 voice 是发出那一刻的即时列读。
任一被改回「先 notify 再写列」,本端判据立刻退回 v0.79 行为——而本端刻意不再留兼容分支。
「列被外部改坏 ⇒ 字段缺席,与『不是语音』同形」那段仍然成立,原样保留。
3. MessageMergeTest.kt 用例 `a later copy carrying voice wins over the earlier copy`
(原 `a later page carrying voice upgrades …`):断言一字未改,只重写注释理由——
按 id 合并「新到的那份胜出」照旧必需(同一条消息会从落库帧 / 握手回放 / 分页 / 编辑帧
多路到达),但它不再是「语音升级的唯一通路」。
## 新增的钉子
WsEventPersistedVoiceTest(5 个用例)钉住 v0.97 的正面事实:message_persisted 载荷里
voice 四键齐全时读得回来且 isVoiceMessage 为真;channel_persisted 同样;平台自带转写
(四键全缺席)仍是语音但 voiceAudio 为 null(不画播放控件);voice 键缺席 ⇒ null ⇒
不是语音(判据只有一个方向);message_edited 帧不许把语音气泡打回纯文本。
这条测试的意义:PersistedData 只解 message,生产 Json 是 ignoreUnknownKeys = true ⇒
未来谁把 voice 从落库帧上摘掉不会报错,只会静默退回纯文本。
## 验证
./gradlew.bat assembleDebug → BUILD SUCCESSFUL
./gradlew.bat testDebugUnitTest → 134 suites / 1675 tests,0 failures / 0 errors / 0 skipped
(基线 133 / 1670;+1 suite +5 tests 全部来自新增的 WsEventPersistedVoiceTest)
|
2026-10-26 21:59:39 +08:00 |
|
AskaEth
|
4d25d9ec69
|
merge: W73 —— 语音发送侧(点按录音 16k/单声道 WAV 自封 + RECORD_AUDIO 权限 + voice_input 上行与 clientMsgId 幂等重发 + voice_output 播放与 provider=silent 降级 + 乐观气泡)
|
2026-10-26 19:00:57 +08:00 |
|
AskaEth
|
4e822df5ed
|
W73 语音输入闭环 —— 录音 → voice_input → voice_output(契约 v0.96 §3.15 ①)
上行(用户按住麦克风说话):
- `AudioRecord` 16 kHz / 单声道 / 16-bit PCM → 自封 44 字节 RIFF/WAVE 头。
不用 `MediaRecorder`:它默认出 m4a/aac,**不在**契约白名单 wav|mp3|ogg 里;
WAV 纯手写、零新依赖(`domain/chat/VoiceClip.kt` 是纯函数,可穷举单测)。
- 体积闸门 5 MiB(解码后):契约没给 voice_input 的上限,按已知最严的那一档
(chat.image 的 base64 硬顶)设计;录满 60 秒只用掉 36%,它是第二道闸。
空 / 太短(<1s)/ 超限三档**明确拒绝并说一句**,绝不静默。
- `voice_input` 补上 `deviceId` / `clientMsgId`(与原骨架的四个键纯增量,
省略时逐字节不变)。幂等键由纯函数 `voiceClientMsgIdFor` 决定:**重发复用同一个**。
- 乐观气泡与文本同源:同一个 `pending` 列表(`PendingMessage.voiceDurationMs`)、
同一个 `rollbackPending`;失败时那段 WAV 留在 VM 私有字段里,界面上给「重发 / 丢弃」。
语音**不进**自动补发队列(几 MB 字节不该钉在队列里等连接)。
下行(她的语音回复):
- `voice_output` 解析补齐 `sessionId`(帧壳没有,从 data 提到帧上,会话闸才真的生效)、
`deviceId`、`provider`。
- `provider == "silent"`(或 audio 缺席)⇒ **不画播放器**,只显示 reply 文字 + 一句说明。
- `text` = 转写(那段音频被识别成什么),`reply` = **她说的话**(音频念的就是它):
显示 `reply` 优先、缺席才退到 `text`(纯函数 `voiceReplyText`)。
- 音频 base64 解码落 `cacheDir/media/`,复用 W62 那条 `VoicePlayer` 通道播放
(新增 `toggleClip`,同一套 MediaPlayer 生命周期与身份比对);自动播放只认第一次
(切走再回来不会突然响一段旧语音)。
其它:Manifest 加 RECORD_AUDIO(运行时申请,被拒不再重复申请、文案指路系统设置);
UI 全部 Miuix(extended 包的 Mic),录音中整行换成「录音中 0:07 / 取消 / 发送」。
验收:assembleDebug 通过;testDebugUnitTest 全量 133 suites / 1670 tests / 0 failures
(基线 129 / 1636,新增 4 个 suite / 34 个用例)。
|
2026-10-26 19:00:16 +08:00 |
|
AskaEth
|
e0e657d5ee
|
chore: devdocs/ 停止入库 —— 主人 2026-10-07 明确指令「整个文件夹禁止进入远程仓库」
- .gitignore 新增 ;本地文件一个没删,只是不再被 git 跟踪
- 此前它曾入库(2026-10-04 授权),历史里的旧副本不重写、也不再新增
- 本地 05 里那条「devdocs 本身是入库的」纪律已就地改正,避免后来人再 git add 它
|
2026-10-07 21:25:11 +08:00 |
|
AskaEth
|
c1ebd208b1
|
merge: W72 —— 纯附件/图片/语音形态也显示时间(判据抽成 messageTimeCarrier;原本只挂在「有正文的气泡」上,纯附件消息没有正文气泡)
|
2026-10-07 19:18:28 +08:00 |
|
AskaEth
|
a94e310cd7
|
W72 纯附件消息也有气泡外侧时间
时间原本挂在「这条消息**最后一个有正文的气泡**」外侧(W69),而纯附件消息(`documents`
或 `image` 非空、`content` 为空)解析出来的那个占位气泡 `body` 也是空,`Bubble` 见空
`body` 什么都不画 ⇒ 那条时间**整条没被画出来**(主人 2026-10-07 验收 W69 时发现的缺口)。
- 判据抽成纯函数 `messageTimeCarrier(message, bubbles)`:先找最后一个有正文的气泡(W69
原本那一档,一个字都不改);一个都没有时按「谁最后画」往回退(语音形态 → 图片 →
附件芯片排),落点用 `MessageTimeCarrier` 表示。真的空气泡(`isContentlessUserMessage`)
仍由 `ContentlessNote` 那条路自己画时间,与 W69 一致,未改。
- 附件芯片排 / 图片 / 语音形态三块改为经 `BubbleWithTime` 渲染:**不是落点时 `time = null`,
连 `Box` 都不建** ⇒ 与 W69 之前逐像素相同。贴哪一侧、宽度预扣(时间不参与 weight)、
底对齐全部复用 W69 那一个组件,没有第二份布局逻辑。
- 新增 `MessageTimeCarrierTest` 11 条:纯附件 / 纯图片 / 无转写语音 / 角色无关 /
多块时的优先序 / 有正文时 W69 口径不变 / 真空消息返回 null;`MessageTimeLabelTest`
16 条一条未动。
- 假后端补夹具:`/__control/messages` 的 `text=`(**空串** = 这条没有正文)与 `documents=`
(`extracted` / `listed` / `unreadable`)。此前「纯附件消息」在本机**根本造不出来**
——工具不实现 upload 摄取链路,`PATCH` 又要求 `content` 非空——设备验收因此无从做起。
README 补 §8.3 与端点表。
验收:`assembleDebug` 通过;`testDebugUnitTest` **129 suites / 1636 tests / 0 failures /
0 errors**(基线 128 / 1625)。
|
2026-10-07 19:16:25 +08:00 |
|
AskaEth
|
f1e894e71f
|
merge: W71 —— 按契约 v0.89 最终版返工(u 加回白名单成六项;br 在 code/注记作用域内不再分隔,只有普通文本层分隔)
|
2026-10-07 19:11:37 +08:00 |
|
AskaEth
|
1055f03b46
|
merge: W70 —— 附件预览下方加元数据小字(文本:行数/服务端 chars/截断标记;图片:格式/分辨率/大小/EXIF 拍摄日期;拿不到的项一律省略)
|
2026-10-07 19:10:51 +08:00 |
|
AskaEth
|
9da25b0895
|
feat(W71): 按契约 v0.89 最终版返工 —— u 加回白名单(六项)+ <br> 只在普通文本层分隔
后端 `回执三十三`(v0.89 收口)更正了 W68/W69 的两处实现,本端照改。
一、`u` **加回**白名单(最终**六项**)
`回执三十三 §一` 明说:`回执三十一` 那句「收窄到五项」**是他们的疏漏** —— 主人随后裁定
下划线保留,后端已把 `u` 加回契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。
最终白名单 = `b`/`strong`/`i`/`em`/`code`/**`u`**,`s` 仍不在内(逐字原样)。
1. `InlineMarkup`:`PAIRED_TAG_NAMES` 加回 `"u"`、`InlineStyle` 加回 `Underline`、
`inlineStyleOf` 落到它。**只加回 `Underline`**:`s` 依旧不做(枚举里没有 `Strikethrough`,
不留走不到的渲染分支)。加回词表同时让流式扣留侧重新认 `<u` / `<U`(两边同一份判据)。
2. `MessageBubble.inlineBody`:`textDecoration = TextDecoration.Underline`(重新引入
`androidx.compose.ui.text.style.TextDecoration`;仍然只是 `AnnotatedString` + `SpanStyle`)。
3. 渲染仍是「样式只给有的那几项」,非 `<u>` 的片段逐像素未变。
二、`<br>` **不再「始终分隔」**:只有**普通文本层**的 `<br>` 才分隔(`回执三十三 §三②`),
`<code>` 与 `<action>` / `<narration>` **内部**的不分隔。后端给的两条理由我们同样成立:
① `<code>` 是**成对**元素,从中间切开必然两半不平衡 ⇒ 按硬口径「不平衡 ⇒ 不渲染」整段降级为
字面量(用户看到裸标签);② `<action>她<br>点点头</action>` 切开就是「她」「点点头」两条**聊天
消息** = 把注记正文当正文发了出去(信息泄露级)。
1. 新增 `protectedSpans`(保护区扫描)+ `appendRegion` 里跳过落在区间内的 `<br>`:
一趟线性扫描、**两个独立状态** —— `code` 一支用行内层的判据(`inlineTagEnd` +
`openInlineTagName`/`closeInlineTagName`:大小写不敏感、可带属性),角色注记一支用 `ROLE_RE`
的判据(只认小写、不带属性、找**同名**闭合)。两支各算各的,因为那本来就是两个消费侧各自的
尺子(行内层在 `<code>` 里只认自己的闭合;`ROLE_RE` 完全不看 `<code>`)。区间按**起点**升序,
切分处用游标 O(1) 判定 ⇒ 整体仍然线性(新增一条 20k 作用域的复杂度预算用例)。为此把
行内层的 `inlineTagEnd` 与 `CODE` 提为 `internal`:**标签边界与 `code` 这个名字只该有一把尺子**。
2. 作用域里的 `<br>` 算什么(**我们定的口径,写进 KDoc**):**普通字符、逐字原样** —— 不换行、
也不消掉。`<code>` 是它既有口径(里面连白名单标签都是内容,`<code><b>甲</b></code>` 显示字面
`<b>甲</b>`,由 `codeContentIsNotParsed` 钉着);注记那侧则是**强迫**的:旁白那一行画的正是
`parts` 的逐字原文(编辑框/搜索/通知同源,`parts` 是红线),换 `\n` 或删掉就是静默改用户内容。
两个作用域取同一条规矩,免得多出一种读法。「改成换行」因此被否:要么在这里改掉片段文本
(`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条规则(W68 刚从那里整体删掉)。
3. **未闭合的作用域不算作用域**(里面的 `<br>` 照旧分隔):判据只看「成对」,而消费侧本来就不认
未闭合的那个(行内层整段降级、`ROLE_RE` 配不出旁白);且「未闭合 ⇒ 保护到结尾」会让一个漏掉
`</code>` 的笔误把后面**所有** `<br>` 一并吞掉——一个畸形标签的爆炸半径不该放大到整段。
两种读法都不渲染、都不丢字,差别只在气泡怎么切,我们选与消费侧一致的那种。
4. 其余一律未动:`<br>` 大小写不敏感(两端口径一致,`回执三十三 §三①`)、`<br>` 出现在别的标签
属性里照样分隔(`§三③` 登记不修)、`<p>` 语义、星号(旁白/动作)语义、`stripLooseTags` /
`ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE`、W63 的线性特性、`parts` 逐字原文。
测试:`InlineMarkupTest` 41→43 条(`<u>` 六项之一渲染下划线且大小写/属性/叠加/未闭合各断言、
`s` 每种形态都仍逐字字面且枚举里没有 Strikethrough、`<code>` 里那个 `<br>` 是内容);
`ChatContentParserTest` 71→77 条:W69 那条「`<code>`/`<action>` 内也分隔」换成 7 条新口径用例
(code 作用域存活且无裸标签、注记不产生气泡且 `parts` 逐字、只有 code/注记算保护区、嵌套、
未闭合、两支各算各的、流式扣留残缺 `<br`),另加一条保护区扫描的复杂度预算用例;
流式一致性用例的样本补上 `<u>` 与保护区两形状。
其余既有断言(含 W68 那批 `<br>` 用例、复杂度预算)一条都没放松。
验收:`assembleDebug` 通过(app-debug.apk 47 330 538 B);`testDebugUnitTest` 全量
**127 suites / 1589 tests / 0 failures / 0 errors**(基线 1580,+9 条本次新增)。
|
2026-10-07 19:10:42 +08:00 |
|
AskaEth
|
e962489520
|
feat(preview): 预览内容下方加一行格式元数据(行数 / 编码 / 分辨率 / EXIF 拍摄日期)
主人要的是「在预览界面下方拿小字放一点简要的格式元数据」。组装全收进一个纯函数
(`PreviewMetadata`,36 档穷举单测),两个预览弹层只负责取证据 + 画一行
(`footnote2` + `onSurfaceVariantSummary`,与项目里 footer / 角标同一套)。
- **文本 / Markdown**:行数(从正文重算;本端被截断时报「显示 N 行 / 共 M 行」,只说
「显示 N 行」会让人以为文件就这么长)· 字符数(服务端 `chars` 在时报它并写明
「服务端注入口径」,否则报本端码点数并写明「本端预览内容」——两个数不是一回事)·
`UTF-8(本端解码口径)`(**只敢写本端口径**:客户端拿不到文件的真实编码,裸写一个
UTF-8 等于替文件下结论)· 服务端 `truncated` 时「正文被截断(进上下文的部分)」。
- **图片**:格式(复用 `ImageOpenTypes.openType`,退化成「图片通配」时**整项不报**)·
分辨率(`BitmapFactory.Options(inJustDecodeBounds = true)`,不整图解码)· 体积 ·
「拍摄于 …」(`android.media.ExifInterface(InputStream)`,系统 API,**不引新依赖**;
日期严格校验,`0000:00:00 00:00:00` 这类占位符判非法 ⇒ 整项缺席)。
- **非文本(PDF 这类)**:只有服务端字节嗅探所得的 mime。体积 / 服务端状态 / 「读不到」的
原因原话各自已经在芯片行与正文区那句人话里,**不在这里重复**——宁可少一项,不要一句废话。
- **拿不到的项一律缺席**:不留占位符、不写「未知」、不写 `--`;一项证据都没有就整行不画。
`devtools/fake-backend` 补一个 `image_file` 灌图参数:服务端库里没有任何带 `image` 键的
历史消息可灌,而相册选择器会把选中项**转码**、把 EXIF 丢掉(实测:41 KB 带 EXIF 的 JPEG
经它拿到手变成 23 KB 且没有 APP1),没有它「拍摄日期」这一项在设备上根本验不到。
验收:`assembleDebug` 通过;`testDebugUnitTest` **127 suites / 1600 tests / 0 failures /
0 errors**(基线 126 / 1564,新增 36 条);真机(emulator-5554, API 37)实测:文本那条看到
「5 行 · 70 字(本端预览内容) · UTF-8(本端解码口径)」,图片那条看到
「image/jpeg · 1280 × 720 · 23.0 KB」(该源图不含 EXIF,所以「拍摄于」这一项按设计缺席)。
|
2026-10-07 19:07:00 +08:00 |
|
AskaEth
|
cf00af7b42
|
merge: W69 —— 消息气泡外侧显示时间(她右侧/主人左侧;今天 HH:mm、昨天、今年内 M月D日、跨年 YYYY年M月D日)
|
2026-10-07 18:58:01 +08:00 |
|
AskaEth
|
7b39994715
|
feat(W69): 气泡外侧显示消息时间 —— 主人贴左 / 昔涟贴右的灰色小字(今天 HH:mm、昨天、今年 M月D日 HH:mm、跨年 YYYY年M月D日)
主人:「用户消息气泡左侧和他的消息气泡右侧还一点点留空的空间 能在这两份区域放一个灰色小字
显示消息发送时间吗?跨天和恒久远的消息也要标明简单日期。」——时间放在气泡**外侧那块空白**里,
不进气泡内部,也不进下方那行角标。
一、文案是纯函数 `messageTimeLabel(createdAt, nowMillis, zone)`
四档按**本地日历日**分(不是「相差不到 24 小时」):今天 `14:30` / 昨天 `昨天 09:05` /
今年内更早 `3月7日 09:05`(月日不补零、时分补零)/ 跨年 `2025年12月31日`(**不带时分**:
一年前的消息精确到分没有意义)。用 `java.time` + `Locale.ROOT`,无新依赖、无新表驱动项。
二、两条口径(都写进 KDoc 里)
1. **未来时间走「今天」那一档**(判据写成 `!day.isBefore(today)`):服务端时钟略快 / 本机略慢
时出现的时间戳没有别的语义,只显示 `HH:mm`;不造「明天」这个词,也不摆一个用户从没见过的
假日期。整整偏一天 / 一年也一起吃下。
2. **`createdAt <= 0` ⇒ 一个字都不画**(返回 `null`):0 / 负数只可能来自「这个键根本没传过来」
或解析事故,画成 `1970年1月1日` / `--:--` 都是替服务端编一个它没说过的答案。
本地降级气泡(`proactive-local:`)**有**真时间(`proactiveIngestFor` 缺席时补 `nowMillis`)
⇒ 照常显示;流式气泡与待发气泡本端没有时间戳 ⇒ 走这一档,与加这一块之前逐像素相同。
三、布局:`BubbleWithTime`(新,`MessageBubble.kt`)
时间与气泡**同一行**(`Box` + 底对齐),外侧用 `padding` 把时间那一条的宽度**先扣掉**,
气泡吃剩下的全部宽度。取舍与理由:
- 时间**不参与 weight 分配**,而是先用 `rememberTextMeasurer` 按它将要用的那个
`MiuixTheme.textStyles.footnote2` 量出**精确宽度**(`Text` 内部只是在同一份 style 上 merge
显式参数 ⇒ 量出来的就是排出来的那一串字),所以它既不会溢出屏幕、也不会被压成两行;
- 气泡可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`:长消息照旧用满能用的宽度,
**代价写进 KDoc**——有时间的消息气泡最多窄一条时间条(这是「与气泡同行」的必然结果);
只有「极窄屏 + 超大字号」才会明显变窄,那时选择如实把日期显示完整(截断 `2025年12月31日`
比窄一点的气泡更亏);
- 不用「让时间先占住宽度」那种零测量写法:`Row` 给非 weight 子节点的宽度是**按子节点顺序**
递减的,「气泡在前」的那一侧必然先把宽度吃光、把时间挤成一条竖线;
- 时间在 `clip` / `background` / `combinedClickable` 之外 ⇒ 气泡的样子、选字范围、长按点击区
一个字都没变;气泡自己那一侧离屏幕的边距与加这一块之前逐像素相同;
- 时间挂在这条消息**最后一个有正文的气泡**上(解析出的末段偶尔只有旁白,那一项不画正文框);
`ContentlessNote`(空气泡位置那句如实说明)同样挂,走同一条路。
四、未动的部分:下方那行角标(来源 / 六态 / 分享 / 「⋯」)语义一个字没改,只是**并列的另一块**;
消息内容 / 排序 / 分页 / 去重 / 「跟随底部」的触发键一概没碰;`MessageGroup` 与
`StreamingBubble` / `PendingBubble` 的既有调用一个像素都没变。
验收:`assembleDebug` 通过(APK 47,278,290 B);
`testDebugUnitTest` 全量 127 suites / 1580 tests / 0 failures / 0 errors / 0 skipped
(基线 126 / 1564 → 新增 `MessageTimeLabelTest` 16 条)。
单测覆盖每一档的两侧:同一天 / 正好午夜 / `23:59` 与次日 `00:01`(只差两分钟但跨天)/
昨天跨月 / 昨天跨年(「昨天」优先于跨年档)/ 今年内更早(月日不补零、一周前仍是这一档)/
跨年(前天、以及整整一年前同一个 `8月14日`)/ 闰年 `2/29`(当年是昨天、次年整日)/
未来(同天 + 几分钟、次日、次年)/ `0` / `-1` / `Long.MIN_VALUE` / 同一毫秒在上海与 UTC 分属两档。
`D` 认成「一年里的第几天」把 `8月14日` 排成 `8月226日`——这条正是被这批单测当场拦下的,已写进
`MessageDayFormat` 的 KDoc。
|
2026-10-07 18:57:14 +08:00 |
|
AskaEth
|
11602c0138
|
docs(05): 锚点补记四十三 —— 行内标签对齐契约 v0.89(W68 两轮:大小写不敏感 + 属性丢弃 + 不平衡整段降级 + 白名单收窄五项)+ br 改气泡分隔符(与 p 同级、三种写法等价、流式同源扣留)+ 真机八组样本逐条验收 + 三条待后端确认的口径
|
2026-10-07 17:39:38 +08:00 |
|
AskaEth
|
591f298dc0
|
merge: W68 —— 行内标签按契约 v0.89 对齐(大小写不敏感 + 属性一律丢弃 + 结构不平衡整段降级 + 白名单收窄到五项去掉 u/s)+ <br> 改为气泡分隔符(与 <p> 同级,三种写法等价)
|
2026-10-07 17:38:04 +08:00 |
|
AskaEth
|
9dee236778
|
feat(W69): 按契约 v0.89 的 <br> 更正与白名单收窄 —— <br> 改做气泡分隔符(与 <p> 同级)、撤掉 u/s
后端 `回执三十` / `回执三十一` 把 v0.89 两处更正了,本端照改。
一、`<br>` 不再是行内标签,而是与 `<p>` 同级的**气泡/消息分隔符**
(`回执三十一` 原话:「对消息内的 `<br>` 始终处理为多条消息隔断」)
1. 行内层(`InlineMarkup.kt`)整体移出:词表少一项、`InlineTag.Break` 与「`<br>` → `\n`」
那套解析/裁剪逻辑全删;白名单只剩五项 `b`/`strong`/`i`/`em`/`code`。
2. 改在气泡切分层:新增 `appendRegion`(`ChatContentParser.kt`),把每个文本区按 `BR_TAG_RE`
切开、段与段之间补 `Tok.Break`(既有 `<p>` 同款机制)。`<p>甲<br>乙</p>` / `甲<br>乙`
都是两条气泡;`<p>甲<br><br>乙</p>` 的中间空气泡照旧丢弃;`<br>` 两侧是空白不产多余气泡;
`<br>` / `<br/>` / `</br>`(含 `<br />` / `</br/>` / `<br class=x>` / 大写)等价。
3. 层序采用「**先按 `<p>`/`<br>` 切分,再对每一段各自做行内解析**」:`<b>甲<br>乙</b>` 两段各自
不平衡 ⇒ 两段都回落原文。理由写进 `appendRegion` 的 KDoc(「同级」本身就规定了层序;
一个气泡 = 一条消息,跨分隔符的标签在结构上不可能成立,后端剥标签同样是按单条消息判平衡;
实现上行内层逐气泡跑一次,一个字节都不用改)。
4. 流式侧跟上:`<br` / `<BR` / `</br` / `<br/` / `<br class=x` 这些残缺形态由
`couldBeLineBreakNamePrefix` 扣留(与 `BR_TAG_RE` 同口径);完整的 `<br>` 不入栈、直接放行。
不扣的话中间态会先把字面 `<br` 吐上屏、收口时再变成两条气泡——屏幕上的字会改口。
二、白名单**收窄到五项**(`回执三十`):`u` / `s` 从 `InlineStyle`、解析、渲染、测试里全撤
(`Underline` / `Strikethrough` 与 `MessageBubble` 的 `TextDecoration` 一并删净,不留死代码)。
现在 `<u>甲</u>` 走「白名单外 ⇒ 原样显示」,与 Web 端一致——多支持反而是制造跨端不一致。
其余口径一律不动:大小写不敏感、属性整段丢弃、不平衡整段降级为原文(粒度 = 该气泡正文)、
`<code>` 内不解析、白名单外字符一个不丢、`parts` 逐字原文、`runs` 为空时 UI 走原路。
`tokenize` 的 `<p>` 块切分 / `stripLooseTags` / `ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE` /
星号语义 / `MessageEditText`(`<br>` 不进容差,只含 `<br>` 的消息仍是编辑框恒等路径)行为未改。
渲染仍是 `AnnotatedString` + `SpanStyle`,无新依赖、无 HTML 注入。
验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 126 suites / 1564 tests / 0 failures / 0 errors。
`ChatContentParserTest` 新增 14 条(`<br>` 四情形逐个断言气泡数与文本、三种写法等价、大小写、
像标签但不是的 `<br2>`/`<boolean>`、跨分隔符标签两段各自回落、`parts` 不含结构、旁白跨分隔符依附、
流式完整/残缺两态、`<code>`/`<action>` 内也分隔、行内解析不看 `<br>`、复杂度预算);
`InlineMarkupTest` 删掉 6 条与 v0.89 冲突的(`<br>` 换行 4 条 + `u`/`s` 渲染 + `<b>甲<br>乙</b>` 单气泡
一条),新增 3 条(`u`/`s` 撤掉后走字面、`<br>` 在本层只是普通字符、`<br>` 在解析层真分成两条气泡)。
|
2026-10-07 17:36:38 +08:00 |
|
AskaEth
|
4b19278733
|
feat(W68): 行内标签按契约 v0.89 对齐 —— 大小写不敏感、属性一律丢弃、结构不平衡整段降级、补 u/s 与 </br>
后端 2026-10-07 傍晚把行内标签升成契约 v0.89(推库 `2dae469`),本端四处按它对齐。
1. 标签名大小写不敏感(`<B>` / `<STRONG>` / `<Br>` 都认)
消费侧与流式**扣留**侧一起改,两边同源:`parseInlineTag` 与
`couldBeInlineTagNamePrefix` / `isTagNameLetter` 收在 `InlineMarkup.kt` 一份,
`isPotentialTagPrefix` 引用它。原来扣留侧只扫 `a..z`,若只改消费侧,`<B` / `<b class`
这种残缺形态就会在中间态被当正文吐上屏、等 `>` 到了再吃掉——屏幕上显示过的字当场改口。
段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍只认小写,既有契约逐字未改。
2. 补 `u` / `s`:`InlineStyle` 加 Underline / Strikethrough;`MessageBubble.inlineBody`
落到 `TextDecoration.Underline` / `LineThrough`(两者叠加时给两条线,不二选一)。
3. 属性一律不解析:白名单标签只取标签名,`<b class="x" onclick="y">` 就是粗体,属性整段丢掉。
形状判据:名字后必须是 `>`、空白或 `/`,所以 `<boolean>` / `<br2>` 仍是普通文本。
`inlineTagEnd` 那扇定宽窗换成「扫到 `>` 或下一个 `<` 为止」(属性没有长度上界),仍是线性。
4. 结构不平衡 ⇒ **该气泡正文整段降级为原文**(未闭合 / 落单闭标签 / 交错;`<br>` 也一起放弃)。
取代 W67 的局部降级。粒度 = 该气泡的 `body`(不是整条消息、也不是单个 `<p>` 块),
理由写进 `parseInlineMarkup` 的 KDoc,由 `theFallbackCoversOnlyTheBubbleThatIsBroken` 钉住。
白名单外的尖括号(`<script>` 等)不参与结构判定,按契约第 1 条原样显示、字符一个不丢。
另:`</br>` 与 `<br>` / `<br/>` 等价(HTML5);`<code>` 内的内容仍不解析、也不算结构,
与契约第 3 条方向一致但不是同一条规则(原因写在 KDoc 里)。
渲染仍是 `AnnotatedString` + `SpanStyle`,不引入任何 HTML 注入路径、不新增依赖。
`parts`(编辑/搜索读的逐字原文)、`runs` 为空时 UI 走原路、`tokenize` / `stripLooseTags` /
`ContentAfterQuery` / `P_BLOCK_RE` / `ROLE_RE` / 星号语义 全部未动。
验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 126 suites / 1553 tests / 0 failures / 0 errors。
`InlineMarkupTest` 新增 11 条(大小写、属性、四种不平衡形状整段降级、气泡粒度、白名单外字符、
`u`/`s`、`</br>`、流式大小写+属性不吐标签),与 v0.89 冲突的老断言按新口径改写并在 KDoc 里
写明「口径在 W68 按契约 v0.89 变更、为什么」,不冲突的一条都没放松。
|
2026-10-07 17:25:26 +08:00 |
|
AskaEth
|
3fe55e8d0c
|
docs(05): 锚点补记四十二 —— 行内 HTML 标签渲染(W67:b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析、整段仅标记回落字面、流式扣留未闭合行内标签;不碰星号语义)+ 真机五组样本逐条验收 + 八条故意不做 + 给后端登记的一问
|
2026-10-07 17:17:26 +08:00 |
|
AskaEth
|
3d4a9f288a
|
merge: W67 —— 行内 HTML 标签基本渲染(b/strong、i/em、code、br;只认小写、未配对保持字面、code 内不解析;不碰星号语义)
|
2026-10-07 17:15:48 +08:00 |
|
AskaEth
|
d7d93b4cb0
|
feat(W67): 正文行内标签渲染 —— <b>/<strong>、<i>/<em>、<code>、<br> 四类(成对才认、只认小写、未配对/嵌套错位一律字面)
改了什么:
- 新增 `domain/parse/InlineMarkup.kt`:纯函数 `parseInlineMarkup(text) -> List<InlineRun>`
(`InlineRun(text, styles: Set<InlineStyle>)`,样式只有 Bold / Italic / Code;`<br>` 不是样式、
它是字符 `\n`)。相邻同样式的片段合并,两端空白去掉。整段只有标记没有字时(`<b></b>` / `<br>`)
返回空列表 = 回落字面原文。
- `ParsedBubble` 加一个**有默认值**的字段 `runs: List<InlineRun> = emptyList()`(既有构造点无需改动);
`toBubble` 在**段落定稿之后**对最终正文调一次 `parseInlineMarkup`:`body` 因此是正文的渲染文本
(标签已消费、`<br>` 已换成换行),而 `parts` 仍是逐字原文——编辑框 / 搜索片段 / 通知文案读的是它。
- `MessageBubble`:`BubbleBody` 多收 `runs` + `trailingCursor`;`runs` 非空时用一个 `AnnotatedString`
承载粗体 / 斜体 / 等宽底色 / 换行(`<code>` 底色:她侧 `surfaceContainerHigh`、主人侧 `onPrimary`
压 18% 浅底),光标在片段**之外**补上(不继承她最后一段的样式)。`runs` 为空走原来的 `Text(text)`,
逐像素不变(绝大多数消息都走这一档)。
- `ChatContentParser.stablePrefixEnd` 的扣留词表补上行内开标签(`TAG_NAMES` = 段落/旁白 + `INLINE_TAG_NAMES`,
两边由同一份名单生成的正则判):未闭合的行内开标签同样入栈扣留;`<br>` 自闭合、**不入栈**
(入了就永远弹不出来),只在残缺(`<br`)时按词表扣留。
- 新增 `InlineMarkupTest`:36 条用例(四类标签各自的片段形状、6 条边界逐条、星号语义反证、
流式扣留与「只增不改口」、以及编辑/搜索/分享三个调用点的护栏、复杂度回归)。
为什么:
- 只认小写:`parse` 只认小写 `<p>`(`<P>` 字面显示是既有测试钉死的),且流式扣留侧的标签名扫描只认
`a..z`——行内标签若认大小写,扣留与消费两边就分叉,中间态会把 `<B>` 当正文吐上屏、收口时再吃掉
(屏幕上显示过的字会改口)。实测命中的 2 条也都小写。
- **成对 + 正确嵌套才认,其余一律字面**:与既有「未闭合/畸形标签回落普通文本」同一口径,也保证绝不丢字
(`1 < 2`、`a<b`、`<b`、`<p2>`、`<B>` 全部原样)。正文里的 `a<b>c` 是既有测试钉着的字面文本,
行内标签没有「段落结构已被完整块定死」那种语境,所以不套用 W55 的落单标签剥离容错。
- `<code>` 里不再解析:HTML 语义上它就是「原样文本」容器,也正是「想显示尖括号怎么办」的答案。
- 不碰 Markdown 的 `*` / `_`:星号已被 `ROLE_RE` 占成旁白/动作语义(既有测试钉着),再给同两个星号
一个「斜体」的说法就是改既有语义。`#`/列表/表格/围栏/`[链接](url)`/`<a href>`/HTML 实体一律不做,
逐条理由写在 `InlineMarkup.kt` 的文件头 KDoc(0 命中、或需单独拍板)。
- 行内解析刻意**不**放进 `tokenize`:分段、旁白归属、落单标签剥离与 W63 那两处线性优化一个字不动,
`<br>` 因此只换行、不分气泡。
- 旁白/动作行不做行内解析:它读的是 `parts` 原文,与分享预览那行同源,而预览侧(`ShareConfirmPanel`)
本次是红线——只改这边就等于让「预览 == 气泡」在旁白上重新裂开。
验收:`assembleDebug` 通过(`app/build/outputs/apk/debug/app-debug.apk`,47,308,566 B);
`testDebugUnitTest` 全量 **126 suites / 1545 tests / 0 failures / 0 errors**(基线 125/1509,
本次新增 1 个测试类 + 36 条用例)。
|
2026-10-07 17:14:44 +08:00 |
|
AskaEth
|
6d4a37e484
|
docs(05): 锚点补记四十一 —— 通知横幅(DEFAULT→HIGH,因渠道重要级不可变故换 id 到 proactive_v2)+ 免打扰开关(两条渠道切 id,默认都弹,真机双档验收)+ W63 去二次方(两处 O(N²),20000 个落单标签 26405ms→17ms)+ W64 分享回落链 + W65 预览与气泡一致 + 国产 ROM 上横幅还需什么
|
2026-10-07 11:57:46 +08:00 |
|
AskaEth
|
3b9936e212
|
merge: W64 —— 分享回落链(EXTRA_TEXT → SUBJECT → TITLE)+ 三者都空时明确告知而不静默(来源提示走柔和横幅、绝不进正文)
|
2026-10-07 11:54:44 +08:00 |
|
AskaEth
|
dc5972213e
|
merge: W65 —— 分享预览改走同一套解析(所见即所发:多段成块、旁白灰斜体;超窗判据改为内容自然高 vs 窗口高)
|
2026-10-07 11:54:34 +08:00 |
|
AskaEth
|
c07a713e86
|
merge: W66 —— 免打扰开关(两条渠道:HIGH 弹横幅 / DEFAULT 只在通知栏;设置页新开关,默认两者都弹)
|
2026-10-07 11:53:58 +08:00 |
|
AskaEth
|
bfa79946d4
|
merge: W63 —— 解析器去二次方(两处 O(N²):松标签重复扫描 + P_BLOCK_RE 回溯;20000 个落单标签 26405ms → 17ms,语义一字不变)
|
2026-10-07 11:53:57 +08:00 |
|
AskaEth
|
0e2df1193d
|
feat(notify): 设置页加「免打扰」开关 —— 她主动说的话拆成两条渠道按开关发(一并重做 proactive_v2/HIGH 那份改动)
改了什么:
- 新增第二条渠道 `proactive_quiet`(`IMPORTANCE_DEFAULT`,渠道名「她主动说的话(免打扰:只在通知栏)」),
与 `proactive_v2`(`IMPORTANCE_HIGH`,弹横幅)并存;两条的定义收在一个纯函数
`proactiveChannelSpec(quiet)` 里(id / 渠道名 / 重要级 / 描述成对给全),`proactiveChannelId(quiet)` 是它的 id。
- 设置页新增「通知」段 + 一行「免打扰」(`SwitchPreference` 外包 `PreferenceCard`,照「后台保持连接」那一行;
副文案走纯函数 `proactiveQuietSummary`,写明「这不是静音」)。偏好落进既有的 `AppSettings`
(键 `proactive_quiet`,**默认 `false` = 都弹**),不新造存储。
- 发通知时按开关选渠道:`postProactiveNotification(..., quiet)`,7.x 及更早的 `setPriority` 跟着一起降回
`PRIORITY_DEFAULT`;`AppContainer` 的 `post` lambda 每次**现读** `AppSettings.current`(`StateFlow` 热副本),
所以开关一改、下一条通知就走新渠道,不必重启 App。
- 重做主仓那份未提交的渠道改动:`PROACTIVE_CHANNEL_ID` 由 `proactive` 改为 `proactive_v2`、
`PROACTIVE_IMPORTANCE` 由 DEFAULT 提到 HIGH、新增 `LEGACY_PROACTIVE_CHANNEL_ID = "proactive"` 并在建渠道时
`deleteNotificationChannel` 掉它、`setPriority` 改 `PRIORITY_HIGH`,对应测试断言同步更新。
为什么:
- 横幅由**渠道重要级**决定,而重要级**一旦创建只有用户改得动**(App 改不动)⇒「开关横幅」不可能靠运行时改
设置实现,只能两条渠道切着用。
- 刻意**不用** `setSilent(true)`:那是完全静音(连提示音都没),而用户要的是「只在通知栏提示,别弹横幅」。
- 惰性创建只保证**这一次要用的那一条**(不存在的渠道会让 `notify` 被系统静默丢弃,所以必须在发之前建):
一个从没收到过她消息的用户,系统设置里不会平白多出渠道;另一条不删(用户可能对它调过设置),
只删那条再也发不到的 legacy id。
- 去重、通知 id 派生、「是否该弹」的判据一个字节都没动。
验收:`assembleDebug` 通过;`testDebugUnitTest` 全量 **123 suites / 1479 tests / 0 failures / 0 errors**
(基线 121/1471,本次新增 2 个测试类 + 8 条用例)。
|
2026-10-07 11:53:04 +08:00 |
|
AskaEth
|
0e834fc854
|
perf(parse): 落单 <p> 的「后面还有没有正文」判据改记忆化,parse 从 O(N²) 回到 O(N)
W61 审查点出的问题:`stripLooseTags` 对**每一个**落单 `<p>` 都要调一次 `hasContentAfter`,
而那函数每次从该位置一路现扫到串尾 ⇒ 正文里有 N 个落单标签整体 O(N²)。`parse` 跑在主线程
(`ui/chat/MessageBubble.kt` 的 `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
改了什么
1. `hasContentAfter(text, from)` → `ContentAfterQuery`(同文件私有类):判定用的还是原来那段
扫描本身(`next` 逐分支对应原循环,判据仍是同一组 OPEN_P_TAG_RE / CLOSE_P_TAG_RE,没有另立
一套「什么算 `<p>` 标签」的定义),只是把走链途经位置的答案落表,之后 O(1) 命中。每个位置
至多被算一次 ⇒ 整体线性。惰性建表:没遇到落单 `<p>` 开标签的常规消息一个字节都不多花。
2. 顺着同一个输入查下去,发现同一形状下**还有另一处 O(N²)**:`tokenize` 的
`P_BLOCK_RE.findAll(raw)`——正文里没有 `</p>` 时,每个 `<p` 候选点都会带着懒惰的
`([\s\S]*?)</p>` 回溯到串尾。完整块必然以 `</p>` 收尾,所以把最后一个 `</p>` 之后切掉再扫:
每个匹配都整体落在被切下的前缀里、正则的尝试顺序也没变 ⇒ 匹配结果逐字不变,而这一路回溯没了。
实测(本机 JVM 单测内 `System.nanoTime` 计时,修复前后跑的是同一份测速脚手架、同一台机器)
`parse("<p>".repeat(N))` 修复前 修复后
N = 2000 854ms 5ms
N = 8000 4254ms 12ms
N = 20000 26405ms 17ms
另一轮完整曲线 N=1000/2000/4000/8000/16000 = 148/238/881/3700/15973ms(每翻一倍涨约 4 倍)
新增的复杂度回归测试在修复前报:`60000 字符(20000 个落单标签)用了 31226ms`
两处各自的份额(N=20000,`"<p>".repeat(N)`):落单标签判据约 23s、完整块搜索约 8.2s。
`parse(" \n<p> ".repeat(20000))`(标签间夹空白):修复前也是 20s 量级,修复后 6ms。
已知残留(**非本次引入**,改前改后一致,在测试里如实登记):`"<p ".repeat(N) + "</p>"`
这种「`<p` + 空白一路没有 `>`、最后只有一个 `</p>`」的病态输入仍走完整块搜索的最坏路径
(N=2000/8000/20000 = 105/1067/7314ms → 108/1242/7201ms)。要治它得把完整块搜索换成手写的
线性匹配(牵动气泡切分的核心语义),本切片没做。
语义
`parse` 的对外行为一字未改:气泡切分、旁白依附、空块过滤、畸形回落全部照旧。既有 121 个测试
套件 1471 条全部通过、一条期望值都没动,另外新增 2 条测试:1 条复杂度回归
(`hugeRunOfLooseTagsIsDroppedWithinALinearBudget`,20000 个落单标签 + 宽松阈值,注释里写明
它测的是复杂度不是精确耗时)+ 1 条钉住「后面有正文才留字面」的语义
(`aLooseTagIsKeptWheneverThereIsStillContentAfterIt`)。
验收:`gradlew.bat testDebugUnitTest assembleDebug --rerun-tasks` → BUILD SUCCESSFUL
(121 suites / 1473 tests / 0 failures / 0 errors;APK 已产出)。无新依赖。
|
2026-10-07 11:50:53 +08:00 |
|
AskaEth
|
4fe5509c24
|
feat(share): 正文回落链 EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(修「只有标题的分享整条静默」)
W61 分享路径审查登记的那个缺口:有些发送方不填 `EXTRA_TEXT`,内容只在
`EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里,而本端只读 `EXTRA_TEXT` ⇒ 那次分享
**整条静默**:用户点了分享,界面毫无反应、后端零请求,连 `contentUnreadable`
那条「读不进来」的告知也够不着(它的判据里没有这两个 extra)。
改动(按主人拍板的方向):
1. **回落链**:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`,取第一个非空白的
当正文(归一化口径与原来取 text 时完全一致)。`EXTRA_TEXT` 非空时它一定赢 ⇒
既有情形逐字不变(`ShareTextFallbackTest` 有专门的用例钉住这一点)。
2. **来源如实登记**:`SharedContent.textSource`(`ShareTextSource.Text/Subject/Title`)
记下正文是从哪个 extra 来的;渲染层据此说一句「这段文字来自对方的分享标题
(对方没有提供正文)」——回落来的正文与真正文长得一模一样,用户只能靠这一句分辨。
⚠️ 那句话只出现在界面上,**绝不进正文**(面板预览与发出去的消息逐字相同,有用例钉住)。
面板那边的接线需改 `ShareConfirmPanel.kt`(本批的**红线**:另一支正在改它),
所以这一句先挂在**认领那一刻**的柔和告知通道上(`ChatViewModel.claimSharedContent`
→ `showShareNotice`):它必须在用户点「发送」之前说。
3. **三个 extra 都空 / 都读不出来 ⇒ 走「读不进来」告知,绝不静默**:判据加了
`textPresent`(「三个里至少一个躺着原始值」由读了原始值的那一层报进来,与既有的
`streamPresent` 同一个形态)。「连值都没有」(误触分享)照旧静默 —— 边界没放宽。
4. **读取防御式**:四个 extra 统一读**原始值**再按形态分派(`Intent.rawExtra`,带
`runCatching`)。核对过 AOSP `android/os/BaseBundle.java`:`getCharSequence` 自己
catch `ClassCastException` 回 null(**类型不符不抛**),会抛的是 `unparcel()` 的
`BadParcelableException`(畸形 bundle)——那一路由 `runCatching` 挡住,不让异常从
`accept` 冒出去。不引入新依赖,UI 侧零新增控件。
测试:新增 `ShareTextFallbackTest`(11)、`ShareTextExtrasTest`(8)、`ShareNoticeTest` +4;
全量 testDebugUnitTest 123 suites / 1494 tests / 0 failures / 0 errors
(基线 121/1471/0/0);`assembleDebug` 通过(--no-build-cache 真跑)。
|
2026-10-07 11:41:55 +08:00 |
|
AskaEth
|
cdd58fa9c4
|
W65: 分享确认面板的预览改走与气泡同一套解析(所见即所发)
W61 审查发现的那处不一致:预览把正文 trim 之后**原样显示**、不过解析器,而发送后的气泡走
`domain/parse` 的 `parse`——分享 `"<p><p>正文</p>"` 进来,预览里是一串标签、气泡里只剩
「正文」。用户按发送之前看到的不是他将要发出的东西。
- 预览正文区改为渲染 `sharePreviewBlocks(text)`:就是 `MessageBubble` 用的那一个 `parse`
的结果(多段 ⇒ 多个块;`<action>` / `<narration>` 与星号那几种写法 ⇒ 灰斜体小字的旁白行)。
面板不再自己写一套「剥标签」;`parse` 一行未改。解析结果为空时按「整段原样显示」兜底,
与 `MessageBubble` 的 `parsed.ifEmpty { plainBubble(content) }` 同一条——绝不显示空白。
- 「超窗」判据从「数一个 Text 的行数」换成**比高度**:内容自然高度(`onSizeChanged` 挂在
`verticalScroll` **里面**,量到的是不受上限影响的自然高)vs 窗口高度(量到的行高 ×
SHARE_PREVIEW_TEXT_LINES)。比较在组合期算完、由 `SideEffect` 报上去(排版回调里读到的两个量
都是上一帧的,在那里比会永远差一帧)。窗口高也不再取「第三行的底边」:多块之后没有任何一个块的
第三行等于这一窗的第三行。
- 两处 `verticalScroll` 的有限约束一如既往:外层预览区仍是 `heightIn(sharePreviewMaxHeight)`;
正文窗仍是 `heightIn(shareConfirmWindowCap(cap))`(唯一出口,返回值恒有限),展开态把上限与滚动
一起去掉。W58 那两次崩溃的形状一个都没碰。
- 测试:`ShareTextPipelineTest` 里那条钉着「预览原样、气泡解析」分工的用例按新口径**推翻重写**
(预览与气泡的正文文本一致、标签不进可见文本),另加三条(多段块数 / 旁白段 / 空解析兜底);
`ShareConfirmTest` 的超窗判据用例换成新纯函数的穷举,并新增 `sharePreviewWindowHeight` 的用例。
验证:`assembleDebug` 通过;`testDebugUnitTest` 121 suites / 1476 tests / 0 failures / 0 errors。
|
2026-10-07 11:38:07 +08:00 |
|
AskaEth
|
541a70f126
|
docs(05): 锚点补记四十 —— 后端回执二十八四条答案 + 畸形回归通过(部署后无新畸形)+ W60 后端重启自愈(根因两半、拉不到新消息的真相)+ W61 分享审查(当前 HEAD 无残留崩溃点,主人手机是旧包;三处如实登记的缺口)+ W62 语音接收侧真机验收通过(五档夹具 + 播放出声)+ 一条流程教训(合并≠设备上是新包)
|
2026-10-07 11:19:21 +08:00 |
|
AskaEth
|
3d8bc61912
|
merge: W61 —— 分享路径审查(补 12 条跨层串测 ShareTextPipelineTest;shareConfirmWindowCap 加有限性守卫,把「上限恒有限」从调用方好习惯变成唯一出口保证)
|
2026-10-07 11:15:28 +08:00 |
|
AskaEth
|
9ced7444bd
|
merge: W62 —— 语音消息接收侧(voice DTO + 语音气泡 + 播放;无副本不画播放键、时长缺席不估算、实时帧不等 voice)
|
2026-10-07 11:14:54 +08:00 |
|
AskaEth
|
2fbf24a675
|
feat(voice): 接收侧语音消息 —— 显示 + 播放(W62 / 契约 v0.79 §4.4 与 §1.2 U19)
别的客户端(QQ / 微信)发来的语音,现在能正确显示并播放。只做接收侧,不做本端录音/发送。
契约口径(逐条落地,不自己发明键名):
- **判据 = `voice` 键存在**:绝不从 `content` 正文里认媒体类型(`[语音]` 占位标记自 v0.67 起不再
下发,正文里也可能有用户自己打的同样字样);
- **按 `status` 分支**(`transcribed` / `empty` / `failed` 三态),正文一律照常显示、一个字不改;
- **`name` / `mime` / `size` / `url` 四者同生共死** ⇒ 凑不齐就**不画播放控件**(不给必然失败的按钮);
- **`durationMs` 缺席就不显示时长**(不反算、不显示 `0:00` 冒充);
- **不在 WS 实时帧上等 `voice`**(覆盖率:只有四个 REST 读取面填充)—— 实时到达的先按普通文本显示,
由既有的「按 id 合并、新到的胜出」在下一次拉取时自动升级成语音气泡(新增单测钉住这条依赖)。
改了什么:
- `VoiceDto`(7 个键逐字照抄)+ `HistoryMessageDto.voice: VoiceDto? = null`(缺席与 null 同一件事);
- `domain/chat/VoiceMessage.kt`:判据与展示换算的**唯一落点**(`isVoiceMessage` / `voiceAudio` /
`voiceDurationLabel` / `voiceNote` / `voiceMediaUrl` / `voicePlaybackFailureMessage`);
- `domain/chat/VoicePlayback.kt`:播放通道的纯状态机(Idle / Loading / Playing / Failed),
每次转换都按 `messageId` 比对 ⇒ 迟到回调不许改状态;
- `ui/chat/VoicePlayer.kt`:`android.media.MediaPlayer`(平台 API,**不引任何新依赖**)驱动那条通道,
三处收干净:换目标先停旧、换会话换实例、组合销毁 `onDispose` 释放;
- `ui/chat/VoiceBubble.kt`:`[播放/暂停] 语音 · 时长` + `empty` 那句补充说明 + 失败提示(一行,error 色);
图标用 AAR 里真实存在的 `MiuixIcons.Play` / `Pause`(extended 包,basic 那 7 枚里没有);
- `data/api/MediaApi.kt` + `data/repository/VoiceStore.kt`:`GET {当前地址}{voice.url}`(自带的
Bearer + 401 续期走 `ApiClientFactory`),字节落 `cacheDir/media/<存储名>`(复用 `DocumentCache`
的名字判据 / 命中判定 / 原子落盘;**不落 `documents/`**,那是 FileProvider 唯一暴露的根);
- `MessageBubble` 渲染语音形态;`isContentlessUserMessage` 加一条「语音消息不算什么都没有」
(否则空转写的语音会被扣上「内容没有被传过来」那顶**假**帽子);
- `devtools/fake-backend/`:新增语音夹具(`__control/messages?voice=&duration=` + 真 2 秒 WAV 的
`/api/v1/media/{name}`)—— 生产库里一条带 `voice` 的真消息都没有,设备验收只能靠造。
为什么这么改:`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路,而语音是唯一「会出声」的
消息形态,生命周期必须有一处说清楚(写在 `VoicePlayer` 的 KDoc);播放失败必须可见(按钮回未播 +
一行短提示),一条点了没反应的语音在用户眼里就是坏的。
验收:`assembleDebug` 通过;`testDebugUnitTest` 120 suites / 1458 tests / 0 failures / 0 errors
(基线 115 / 1405,新增 5 个套件 53 条)。
|
2026-10-07 11:14:28 +08:00 |
|
AskaEth
|
22d655b73b
|
fix(chat): 分享面板的高度上限出口补上「非有限值」守卫(W61 分享全链路复核)
主人报「网易云分享文本到 App 会闪退」。已修的那个 P0(df7b504 / 6d4f2cd:面板一打开就崩 /
点展开又崩)是 Dp.Infinity 当哨兵值与展开态没去掉 verticalScroll,那是**最可能**的成因。
这一批把这**整条链**(Manifest 的 intent-filter → ShareIntake → 落地面板 → sendConfirm →
气泡渲染)从「假设主人的包就是当前 HEAD」重审了一遍,找残留崩溃点。
结论:**当前 HEAD 这条路径我没有找到残留崩溃点**。逐项复核见下面「查了什么」。审查中发现
一处**不变量只写在 KDoc 里、实现并不保证**的地方,本批把它补实(不改变任何现状行为)。
改了什么(最小的一处 + 一处 KDoc 更正):
1. `shareConfirmWindowCap` 从纯透传改成**守卫式**:`measured` 非有限(`Dp.Infinity` / `Dp.Unspecified`
即 NaN)时与「还没量到」同等对待,回落 150dp。它上面那段 KDoc 一直写着「返回值永远不是
Dp.Infinity」,而实现是 `measured ?: 150.dp` —— 今天唯一的调用方传的是 null 或
`getLineBottom(...).toDp()`(恒有限),所以**现状没有可触发的崩溃**;但那条不变量此前只由
「调用方的好习惯」保证,而它正是 W58 崩过两次的那一条。补守卫之后可以给出一句可证明的话:
这一层给出的上限**恒有限**,于是面板里那两个 `verticalScroll`(外层预览区 / 收起态正文窗)
在任何输入与状态下都不可能落在无界高度上。
2. `ShareConfirmBody` 那段 KDoc 里一句不准确的话改准:旧文说该函数的出口「要么返回 null(不设
上限)」——它恒返回 Dp,「不设上限」发生在展开态那一支(连 verticalScroll 一起去掉),与它是两回事。
新增测试:
- `ShareTextPipelineTest`(12 例,新文件):把「分享一段文本进来」这条链从 Intent 字段一路串到
气泡正文(`ShareRules.extract` → `shareConfirmPreview` / `shareConfirmBody` / `shareConfirmCanSend`
→ `parse`),此前**跨层串测完全空缺**(每一站各自有单测,没有一处把它们接起来)。钉三件事:
①**内容一字不丢**(「分享单曲 + 链接 + emoji + 多行」逐字节到气泡正文;用户补的描述同样);
②**正文里的标记按既有语义走且绝不抛异常**(`<p><p>` / `<action>` / 全角半角星号 / `1 < 2` /
半个代理对 / `\u0000` / 200 个落单 `<p>` / 10 万字符都过一遍);
③**面板预览不过解析器、气泡过解析器**(同一段带标记的字,预览里是原样、气泡里被剥离——
这是如实记下的一处不一致,不是崩溃)。
- `ShareConfirmTest`:把 `Dp.Infinity` 与 `Dp.Unspecified` 放进「任何输入都给不出非有限上限」的
输入表——原先那张表**恰好漏掉了唯一能推翻该断言的两个值**。反证过:把实现改回纯透传时,
这一条用例确实 FAILED(`no input can produce a non finite cap`),改回守卫后通过。
查了什么(逐项,都是读代码 + 纯函数求证,不是猜):
① Intent 形态:`getCharSequenceExtra` 对非 CharSequence(`String[]` / `ArrayList`)是**catch 住
ClassCastException 回 null**(SDK 源码 `BaseOs/BaseBundle.getCharSequence` 已核),不抛;
EXTRA_STREAM 走原始值按形态分派;字符串形态只认 `content://`;ClipData 纯文本条目只当
「带着内容迹象」用来出告知。② 空/边界输入:空串 / 纯空白 → `worthDelivering=false` 静默
(正确);`<p><p>` / 星号 / `<` 一律不抛(新用例钉住)。③ 面板渲染:Miuix 的 `WindowDialog` /
`TextField` 里**没有**任何 scroll 节点(反编译 `miuix-ui-0.9.3` 的 class 查过),面板里的两个
`verticalScroll` 各自都先过一层有限的 `heightIn`。④ 出队闸门:闸门在 `take()` 之前、认领落在
VM 状态里,重建不会再取;`claimSharedContent` 只有 Compose 主线程一个调用点。⑤ 发送后渲染:
`parse` 的落单标签口径(W55)与既有契约一致,新用例把分享正文含标记时的落点一并钉住。
⑥ 通知/保活:分享落地与 `ProactiveFrameIntake` / `KeepAliveService` 之间没有任何联动路径。
没覆盖 / 只能上真机的:渲染层本身(本模块没有 Compose UI 测试依赖,`checkScrollableContainerConstraints`
那一类崩溃单测抓不到——W58 的教训),以及任何需要设备的观感;本批**没有**装包上机(emulator-5554
上有别的会话在跑,主仓在我复核期间刚构建过 APK,我刻意没有动它)。
真跑:`./gradlew.bat assembleDebug` 通过;`./gradlew.bat testDebugUnitTest` 全量
**115 suites / 1400 tests / 0 skipped / 0 failures / 0 errors**(基线 114 / 1388,差的正是本批
新增的 1 个 suite 12 例,其余一条没碰坏)。
|
2026-10-07 11:00:45 +08:00 |
|
AskaEth
|
55612a3922
|
merge: W60 —— 后端重启后自愈(连接重建即收口未结束的回合/流式卡/工具卡,修「永久卡在正在思考」)
|
2026-10-07 10:54:37 +08:00 |
|
AskaEth
|
83a2a88b3c
|
fix(chat): 连接重建(后端重启)时把「进行中」全部收口,不再永久卡在思考/工具调用
改了什么
- 新增纯判据 `isReconnect(previous, current, everConnected)`(ui/chat/ReconnectReset.kt):
只有「**曾经**到过 Connected 之后又回到 Connected」才算重建,第一次接上一个字节都不动。
主判据是 VM 自己那位只置不翻的 `everConnected`(状态流会合流,`Connected → Reconnecting →
Connected` 中间那一跳可能被吞掉,只置不翻的位不受影响);第二证据是上一跳为 `Reconnecting`
(只在「本 VM 是离线期间建起来的」时成立,且那一刻它手里什么都没有,收口是恒等变换)。
- 新增 `ChatUiState.settledAfterReconnect()`(纯函数):把服务端那一轮在本端留下的五个面一次收口 ——
`turns.allClosed()`(未收口绑定全标 closed,气泡 ④ 灰态 → ⑤)、`streams.allFinished()`
(每一路停止生长:思维链不再「思考中…」、工具卡不再「执行 X…」)、`busyMode = null`、
`activeTurns = 0`、`queueDepth = 0`。无变化时返回**同一个实例**。
- `TurnStreams.kt` 新增 `allFinished()`:正文与思维链一个字都不删;**唯一**的删除是「本来就什么都
没有」的空路(`active` 而一个像素都不画的那种,留着就是一个占着列表末项的幽灵)。
- 工具卡收口 `List<ToolCallState>.settledAfterReconnect()`:`Running` ⇒ `phase = null` + `done = true`
(渲染成「工具调用 · X」——不再有省略号、也不替服务端下「失败」的结论);`Done`/`Error` 一个字节
都不动。`ToolPhase` 枚举**没加第四档**(它逐字对齐后端流程图 status,是契约面)。
- 接线在 `ChatViewModel.onConnectionChanged`:收口刻意放在既有的「同值早退」**之前**(见上),
补拉那一半(`reconnectPull` / `gapCursor` / `firstPageState`)**一个字都没改**。
为什么
后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒ 本端**永远**等不到 `turn.end` / `stream_end` /
助手落库行,而本端「进行中」的每一条闸都只认帧(灰态只认 `TurnBinding.closed`、卡片只认
`thinkingActive`/`active`/`phase`、底栏只认 `busyMode`)⇒ 三条路都不会自己收口,界面永久停在那一格。
今天唯一的兜底是 `session_idle` / `session_interrupted` 的那两个 `allClosed()` 调用点,而说那两句话的
进程已经没了。连带的第二半:那条永远 `active` 的流式路永远是 `items` 的最后一项(`reverseLayout` 下就是
屏幕最下方),而 `ChatScreen` 的「跟随底部」判的正是 `items.lastOrNull()?.key` ⇒ 末项不再变化 ⇒
此后新消息不再把列表滚到底(用户视角就是「拉不到新消息」)。
触发时机只做「连接重建」(a),**不做静默超时** (b):理由、代价与风险((b) 要多一个只能拍的时间常量、
会把正常但慢的回合误杀;但它能兜住「连接没断而编排器死锁」这一档,今天没有现场证据)都写在
`ReconnectReset.kt` 顶上,留主人拍板。
如实登记的两处代价
1. 收口**不删**正文/思维链/工具卡内容,所以那张卡本身要等一个后续事件才消失(它自己那一路的落库行,
或下一轮 `message.start` 的 `beginLeg` 把它当「已结束的路」丢掉)。若主人更想要「末项立刻让开」,
代价是那半段正文从屏幕上消失——我没选它(硬要求:不许动消息内容)。
2. `busyMode = null` 连带改变下一次发送的声明(`emitAndDeliver` 用 `busyMode != null` 算
`interruptScopeForSend` / `parallelForSend`):重建后的**第一次**发送不再先发 `interrupt`。如实说:
重建之后本端确实不知道那一轮还在不在;服务端下一次报 `session_busy` 就恢复。
「闪动」口径:`closed` 单调(迟到的帧不会把它翻回灰态),流式卡片会被后续真帧重新拉回「在跑」
(那是如实的、且不丢字),`busyMode` 由服务端下一帧恢复。
验证
- `./gradlew.bat assembleDebug` BUILD SUCCESSFUL(app-debug.apk 已产出)
- `./gradlew.bat testDebugUnitTest` 全量:**115 suites / 1405 tests / 0 failures / 0 errors**
(基线 114 / 1388 ⇒ +1 suite、+17 tests,全部来自新增的 `ReconnectResetTest`)
- 新增 `ReconnectResetTest`(17 例):连接重建 ⇒ 未收口绑定全 closed(且 `phaseOf` 真的变成 Answered);
第一次连接(`Idle`/`Connecting → Connected`)不收口;判据 5×5×2 = 50 格全表穷举;`settledAfterReconnect`
幂等且无变化时 `assertSame`;已 closed 的不会被 `withTurnBegin` 翻回;流式卡片全停且正文保留、空路被丢;
工具卡 `Running → null+done`、`Done`/`Error` 原样、后续真帧能放回 `Running`;
消息/待发/草稿/队列/error/绑定 messageIds **一律原样**。
|
2026-10-07 10:50:23 +08:00 |
|
AskaEth
|
53c3c5c8ee
|
docs(05): 锚点补记三十九 —— 后端回执二十五/二十六要点(.end 加 resultSummary 已批准、proactive 在 data 里、帧 id 同源、channel_persisted 可能带 proactive、markup 畸形确认是 bug 且已修但未部署)+ 我们去重口径复核没踩坑 + W59 工具卡结果区落地 + 真机验收边界(这批只有单测证据)
|
2026-10-07 10:15:13 +08:00 |
|
AskaEth
|
e0bdaa3537
|
merge: W59 —— 工具卡结果区(tool_execution.end 的可选 resultSummary;缺席/空串一律不画,与老服务端逐字节一致)
|
2026-10-07 10:14:30 +08:00 |
|
AskaEth
|
316cd27049
|
feat(chat): 工具卡接上 resultSummary 结果区(缺席时与今天逐字节一致)
后端新契约切片给 `tool_execution.end` 的 `data` 补了一个**可选** `resultSummary`
(与回看面 `toolCalls[].resultSummary` 同一份预算:已脱敏、已截断 ≤500 字符)。
它的语义是「拿到就带、没拿到就缺席」——失败 / 取消 / 超时 / 未落图时**整个键缺席**,
且契约明说**别把缺席读成「没有输出」**。在那之前工具卡只能显示工具名与状态。
改了什么:
- wire:`WsEvent.ToolExecutionEnd` 新增 `resultSummary: String? = null`;共用的
`ToolExecutionData` 跟着加这个字段(只由 `.end` 读,`.start` / `.update` 的领域事件
仍然没有它)。缺键 ⇒ `null`,空串 ⇒ `""`,都在解码层照原样保留、不编结论。
- 领域:`ToolCallState` 新增 `resultSummary`;`applyToolExecution` 多一个同参数
(非空白时顶替、缺席 / 空白时保留原值——三帧里只有 `.end` 带它,一次落不到不该抹掉旧的);
VM 的 `onToolExecution` 与 `ToolExecutionEnd` 分支透传。
- 判据:新增纯函数 `toolResultTextOf(tool)`:摘要非空白 ⇒ 用它;否则回落 `output`
(今天唯一的来源,口径不变);两者都没有 ⇒ `null`。
- UI:`ToolCard` 折叠区里「结果」那一块(参数之后)改由 `toolResultTextOf` 决定要不要画。
`null` 时**连标签与它上方那道 Spacer 都不画** ⇒ 摘要缺席时这一屏与后端补这个键之前
逐字节相同。空串同理(服务端承诺不发空串,但真发了也绝不渲染出一个空的结果区块)。
摘要只展示:不再截断第二次、不解析、不当 markdown;正文沿用与参数同一个 `FoldCardBody`
(`SelectionContainer` + 不设 `maxLines`),长文本整段可见,没有新增任何滚动 / 限高 modifier。
为什么这么做:一句话的判据、纯函数、单测可钉;渲染层只做「画 / 不画」这一个决定,
于是「老服务端(键恒缺席)」与「新服务端(键在场)」共用同一套 UI,不用等部署。
测试:新增 14 条(`WsEventToolExecutionTest` +5、`ToolExecutionCorrelationTest` +9),
覆盖 ①键在场 ⇒ 解出该值(含 500 字符原样透传)②键缺席 ⇒ `null` 且不报错
③空串 ⇒ 解出 `""` 且判据按「不画」处理 ④`toolResultTextOf` 在缺席 / 空串 / 有值三档下的输出
(含摘要优先于 `output`、结果落卡、缺席不抹旧值、落不到卡时不影响任何卡)。
`assembleDebug` 通过;全量单测 114 suites / 1388 tests / 0 failures / 0 errors(基线 1374)。
|
2026-10-07 10:14:10 +08:00 |
|
AskaEth
|
c40a752bcf
|
docs(05): 锚点补记三十八 —— 分享面板 P0 崩溃(两次:Dp.Infinity 当哨兵值 / 展开态只去上限没去 verticalScroll)已修并真机验收(收起 153px↔展开 255px,同一 pid 无崩溃);三条教训(单测抓不到渲染层崩溃、子代理论证再详细也不能替代真机点按、verticalScroll 的约束来源要看谁在外面);顺带推翻 W56『首帧回流看不见』的结论
|
2026-10-07 09:51:59 +08:00 |
|
AskaEth
|
df7b504fa2
|
fix(chat): 分享面板展开态也会崩 —— 只去掉上限是不够的,必须连 verticalScroll 一起去掉
补上一个修复的漏:上一笔只把展开态的 heightIn 上限去掉,没去掉 verticalScroll。而外层
ShareConfirmPreviews 自己就是一个 verticalScroll,它给内容的是**无界高度** ⇒ 这一层的 scroll
在无界高度下被测量 ⇒ 抛同一个 IllegalStateException。真机上点「展开」的那一下崩了第二次
(pid 2077,01:48:53,reason=4 APP CRASH)。
改:展开态把这一层**整个**去掉(上限 + 滚动一起),只留外层预览区那一层滚 —— 这也正是 W56
原本的设计意图('展开态只留外层那一层')。shareConfirmWindowCap 去掉了 expanded 维度(展开态
不再走它),测试同步改写并补了一条「两种状态在链上都有有限约束」。
真机验收(emulator-5554 + 真后端):收起 153px(3 行)+『展开』→ 点开展开 255px +『收起』→
点回收起 153px,**三次操作同一个 pid 4063,没有任何新崩溃记录**。
|
2026-10-07 09:51:44 +08:00 |
|
AskaEth
|
6d4f2cdbae
|
fix(chat): 分享确认面板一打开就崩 —— 正文那一层绝不能再拿到 Dp.Infinity
真机崩溃栈:heightIn(max = Dp.Infinity) 等于不施加约束,verticalScroll 在无界高度下被测量时
checkScrollableContainerConstraints 直接抛 IllegalStateException(ScrollNode.measure ←
SizeNode.measure ← DialogLayout.internalOnMeasure),分享进来、面板刚打开就闪退。
改法:这一层的高度上限只经纯函数 shareConfirmWindowCap() 一个出口 —— 首帧用
SHARE_PREVIEW_MAX_HEIGHT(150dp) 兜底(有限值),展开态这一层不设上限(外层 240dp 仍是有限约束),
并且不再用 Dp.Infinity 当「还没量到」的哨兵值(改用 null)。新增 4 条单测钉住
「任何输入组合都不会产出非有限上限」。
|
2026-10-07 09:45:34 +08:00 |
|
AskaEth
|
af9c63d267
|
docs(05): 锚点三十七补第 5 节 —— 收尾:假后端任务到点、tcp:80 切回真后端、App 已重连并拉到真实会话;并如实记录「换后端目标但地址字符串不变 ⇒ 本地消息残留」这个观察(已核实真后端没有那条测试消息,且正常换地址必经登出会清)
|
2026-10-07 03:57:03 +08:00 |
|