100 Commits
Author SHA1 Message Date
AskaEth 31ab1ff004 Merge branch 'feat/w80-voice-failed-status-judgment' into android 2026-10-27 21:33:01 +08:00
AskaEth 576dd58a6b fix(w80): 让「画不画语音形态」只剩「键在且 status 不是 failed」两条判据
后端 M57-M59 预告(devdocs/02 第 1255/1260 行我们的答复):`voice` 键在不再等于
「这是一段能播的声音」。合成降级 / 入站转写失败的行 `status = "failed"`,正文是一句话,
`fallbackReason` 里给出降级原因——按老判据会被画成一个点下去必然播不了的语音气泡。

判据拆成两问,各自落在一处(domain/chat/VoiceMessage.kt):
- `hasVoice` = `voice` 键存在 —— 「这条**来自**一段声音」;
- `isPlayableVoice` = `status != "failed"` —— 认不出的 status 一律当可播(fail-open:
  多画一枚语音形态最坏是点了播不出来,有既有失败文案兜着;反方向会把真有副本的语音
  静默吞成纯文字,用户再也点不到);
- `isVoiceMessage` = 两者相与 —— 「画不画语音形态」。

failed 行改画「正文 + 一行灰色小标」:`voiceFallbackNote` 给固定前半句 + 服务端
`fallbackReason` **原样**(不猜闭集、不做映射——取值集合后端尚未定稿;缺席/空白时退化成
固定那一句,不白屏)。新组件 `VoiceFallbackNote` 用 footnote2 +
onSurfaceVariantSummary,与语音形态旁边那几句脚注同一档。

`isContentlessUserMessage` 挂 `hasVoice` 而**不**是 `isVoiceMessage`:failed 行虽没有语音
形态,但有正文和那行小标,同样不是「什么都没有」——判错了屏幕上会多一句假的
「内容没有被传过来」。`messageTimeCarrier` 同理按 `hasVoice` 挑落脚点。

DTO 上补第八个键 `fallbackReason`(只在 failed 时有,`explicitNulls = false` 时不写出),
所以转写成功的行键集仍是原来七个。

验证:`assembleDebug` + `testDebugUnitTest` 全绿,142 个测试文件 / 1766 个用例 / 0 失败
(基线 1755,新增 11 条;VoiceMessageTest 20、VoiceDtoTest 13、WsEventPersistedVoiceTest 7)。
2026-10-27 21:22:28 +08:00
AskaEth a9c940d39f Merge branch 'feat/w79-share-declare-wiring' into android 2026-10-27 00:30:44 +08:00
AskaEth f6eb93027d fix(w79): 让「分享零声明 / 批量只第一条声明」只剩一个判据
接 W76 的 finding:M2 把口径提成纯函数 ShareRules.declaresSendPreference 并加了穷举测试,
但接线那一行落在 M2 scope 外,于是判据仍是两处、每处各半个——分享路径写死
applySendPreference = false,批量那条写 applySendPreference && index == 0,两处都单测不到,
而纯函数没有调用方(dead code)。

改法是把入口的事实交回入口:emitPlannedMessages 的参数从 applySendPreference: Boolean
改成 fromShare: Boolean(sendShared 传 true、输入栏 send 传 false),函数体只写
applySendPreference = ShareRules.declaresSendPreference(fromShare, index)。于是「谁声明」
这个问题的答案只剩 ShareRules 一处,VM 只负责把 (fromShare, index) 喂进去。

emitAndDeliver 的 applySendPreference: Boolean = true 默认值一并去掉(改必填)。唯一调用方
就是 emitPlannedMessages,留着默认值等于把口径又抄了一遍:任何新调用点不写这个参数就会
静默拿到「声明」,而这正是本次要消灭的那种第二出处。deliver 里 if (applySendPreference)
与 parallel = applySendPreference && … 一字未改,只是值的来源变了。

行为零变化(逐入口核对):分享路径 fromShare = true ⇒ declaresSendPreference 恒 false ⇒
零 interrupt 帧、零 parallel,与原先写死 false 等价;手打路径 fromShare = false ⇒ 只有
index == 0 声明,与原先 && index == 0 等价。

测试:新增 ShareDeclarationWiringTest 3 条,钉的是「喂进去的是什么」——判据本身已被
ShareSendDeclarationTest 在裸整数上穷举,这里补另一半:VM 那个 forEachIndexed 的 index
是 plan.messages 的位置,不是选图顺序、也不是 imagesToSend 的下标,两者在超限截断时会分叉。
所以用 imageSendPlan + plan.messages.indices 复刻 VM 的形状,覆盖分享 N=0..MAX_IMAGES
恒不声明、手打只第一条声明、7 张图截断到 4 条时声明不落到这次不发的消息上。
ShareSendDeclarationTest 不动:裸整数判据与 plan 形状互补,合并会丢掉一层的分辨力。
全量 testDebugUnitTest --rerun-tasks:142 suites / 1755 tests / 0 failures / 0 errors /
0 skipped(基线 141/1752,+1 suite / +3 tests)。

顺手 git rm --cached .cyrene-build.lock:快照 e94bdf6 把一个空的构建锁带进了分支根目录,
锁的实际位置在仓库之外(D:/Project/Code/Uni/.cyrene-build.lock),这个 0 字节副本是误入。

仍未验:ChatViewModel 的编排本身仍没有测试穿过(项目缺 fake AppContainer / SessionsApi
基建,已登记在 05 的遗留里),本次靠「参数必填 + 判据单点 + 复刻喂入形状」三条结构约束守。
2026-10-27 00:21:20 +08:00
AskaEth e94bdf6583 tower: snapshot of uncommitted base checkout changes (worktree wt-6) 2026-10-26 23:59:21 +08:00
AskaEth 6aa434dad5 Merge branch 'feat/w75-tts-speech-bearer-playback' into android 2026-10-26 23:58:41 +08:00
AskaEth 222f691b78 fix(w75): 让「停止朗读」真的能停,切走不再补响旧朗读,缓存键带上 baseUrl
修 rev-w75 的三条发现(M5 / W75 TTS 朗读)。

P1-1 「停止朗读是空操作」—— 根因是三处合谋,谁都没错、合起来没反应:
MessageBubble 在播放中画暂停符、读屏名字是 TTS_STOP_DESCRIPTION,可 onSpeak 直通
viewModel::speak;而 beginTtsSpeak 的防抖只认 pending,播放中文件早就到手 ⇒ 不是
pending ⇒ 放行;整格被换成新的 pending 后缓存命中秒回,起播 effect 又因为「已经在放
同一条」跳过 toggleClip —— 唯一带停止语义的那条路径根本没走到。

改法:把「这一下是停还是念」抽成纯函数 ttsSpeakIntent(playback, messageId, clip),
判据落在那条唯一播放通道上(它是「有没有在响」的唯一真相),不再让三处各自解释
「播放中」是什么意思。ChatScreen 的 onSpeak 据它分派:Stop -> voicePlayer.stop(),
Speak -> viewModel.speak(message)。另需 clip.file != null:通道处于 Loading 时也算
active,但那一刻文件可能还没到手,判成 Stop 会停掉一次没起播的载入。

P2-1 「切走再切回补响旧朗读」—— 改成按「这一屏还在不在」判,而不是按时间:新增纯函数
ChatUiState.dropPendingTtsAutoPlay()(只对 autoPlay && file == null 的那一格置假),
由 ChatScreen 的 DisposableEffect.onDispose 调用。放弃「记发起时刻 + 超龄丢」是因为
时间不是这件事的判据 —— 合成一段长文本本来就可能花十几秒,按秒数丢会把「人就在看着
屏幕」的那一次也一起丢掉。配套回归点:applyTtsOutcome 的 autoPlay 改成从旧那一格
继承(原先硬写 false);否则作废之后结果晚到又会把闸门抬回真,等于没修。

P2-2 「TTS 缓存键缺 baseUrl」—— cacheName 的哈希输入改为
baseUrl.trimEnd('/') + "\n" + sessionId + "\n" + text。换一台服务器后,同一个
(sessionId, text) 在那边可能是另一个声音(人格映射 / 发音人 / 合成实现都是那台的),
命中上一台合成的 WAV 就是念错人。trimEnd('/') 归一,http://a/ 与 http://a 不分两份
缓存。类 KDoc 与 cacheName 的 KDoc 同步改写。

测试:TtsSpeakStateTest 11 -> 20(+9),SpeechClientTest 8 -> 10(+2)。
全量 testDebugUnitTest --rerun-tasks:138 suites / 1726 tests / 0 failures /
0 errors / 0 skipped(基线 138/1715,+11)。

仍未验:真机出声;请求在飞时没有「取消在飞请求」;(sessionId, text) 这份缓存对服务端
换发音人仍无失效手段(rev-w75 已同意这半边可接受)。
2026-10-26 23:52:23 +08:00
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 504c500e20 W75 她的消息朗读 —— /audio/speech 只走 owner Bearer,字节嗅探 WAV,缓存落 media/
后端回执三十八 §3 点名「独立真机 Bearer 样例待安卓首次接入时顺手确证」,本批次就是
这次接入:在她的消息气泡外侧加一枚「朗读」图标,点按 → 剥标签取纯文本 → POST
/api/v1/audio/speech → 播放 WAV。只做手动点按,不做自动朗读 / 设置页开关 / 实时流。

## 契约(Cyrene_v2/Docs/api-client.md,2246 行)

- L126(§1.2 U6 表)、L1603(v0.97 补写)、L1609(v0.96 sessionId 增量)、
  L561(416 非信封旁注,同句明说 /audio/speech 的 200 也不是信封)、
  L2173(voice / format 不生效)。
- POST /api/v1/audio/speech,owner-only 双凭据门:Authorization: Bearer <owner JWT>
  **或** X-API-Key,两分支同 authorize(RoleOwner);无 / 无效 → 401,错误形状同 §4.1
  {error:{code,message}} 信封。
- 请求体 {text, voice?, format?, instruction?, sessionId?}。voice 接受但不生效、
  format 不生效;发音人由 sessionId 的生效人格解析(会话绑定 → 全局默认 → 配置 default),
  **省略 sessionId 不等于没有角色**。
- 成功 200 + 原始音频字节(非信封),产物恒 WAV,**按字节嗅探,不要照抄 Content-Type**。
- 错误码:空 text → 400;500 voice_unavailable / tts_failed / tts_persona_unmapped /
  tts_no_character / tts_empty(后者带响应头 X-Voice-Provider: silent)。

## 只走 Bearer,不加 API Key 输入框

主人 2026-10-26 拍板:本端 owner 已持有 JWT,再往设置里塞一个 API Key 输入框只是把
同一份凭据复制一遍、多一个会过期的状态。SpeechClient 的 tokenProvider 直接接
AuthManager 的当前 token,401 交给既有的 unauthorizedPolicy 收口。

## 落盘

新增 data/api/SpeechApi.kt、data/voice/SpeechClient.kt、domain/chat/VoiceSpeak.kt、
ui/chat/VoiceSpeak.kt;改 ChatViewModel / MessageBubble / ChatScreen。AppContainer 未动
(SpeechClient 在 ViewModel 里 lazy 构造,baseUrl 与 token 都取自 container.authManager)。

- 缓存名 tts_<sha256("sessionId\ntext")[:32]>.wav,落 VoiceStore.directory(cacheDir)
  即 <cacheDir>/media/,**不进 documents/** —— 系统可回收,丢掉重下一份即可。
  代价:服务端换发音人后同一段文字会命中旧音频,按 (sessionId, text) 寻址是刻意的。
- 字节嗅探 isWavBytes:size ≥ 12 + RIFF@0 + WAVE@8。200 + JSON 信封(哪怕
  Content-Type 谎报 audio/wav)判 SpeechNotAudioException,且不落缓存。
- 两档失败:TtsFailure.Unconfigured 只认 tts_persona_unmapped / tts_no_character
  两个 code(引导去设人格),其余一律 Soft(可重试)。不认识的 code 不猜,落 Soft。
- 失败只写 ttsClip.failure 气泡脚注,**不写 state.error 横幅** —— 朗读失败不该盖住聊天。
- 播放交给唯一的播放通道(ui.chat.VoicePlayer),键 tts:<messageId> 与语音副本的键
  分得开 ⇒ 「念这一条」与「放这条消息的语音副本」天然互斥,同一时刻只有一个声音。
- 起播在渲染层那个 LaunchedEffect(ttsClip.clipId, file, autoPlay):三键缺一不可 ——
  「放 → 停 → 再点同一条」时 (clipId, file) 一对没变,只有 autoPlay 由假改真才能再点着。

## 状态机抽成纯函数(可测)

VM 拖不进 JVM 测试(要 AppContainer / viewModelScope,工程里没有 coroutines-test),
所以这条路的判据都落在两个纯函数上,VM 只负责循环 CAS + launch:
- ChatUiState.beginTtsSpeak(messageId, clipId):返回 null = 这一次点击该被忽略
  (同一条正在飞)。已落好的(file != null)算「再放一遍」、失败过的算「用户重试」,
  两种都放行 —— 防抖判据只认 pending 这一种。
- ChatUiState.applyTtsOutcome(messageId, clipId, result):clipId 已经不是这一格 ⇒
  原样返回、一个字节都不写(否则「点了 B,界面在 A 下面报错」)。

## 验证

./gradlew.bat --offline compileDebugKotlin → BUILD SUCCESSFUL
./gradlew.bat --offline assembleDebug       → BUILD SUCCESSFUL(38 tasks)
./gradlew.bat --offline testDebugUnitTest --rerun → BUILD SUCCESSFUL
  138 suites / 1715 tests,0 failures / 0 errors / 0 skipped
  (基线 134 / 1675;+4 suites +40 tests 全来自新增的
   SpeechApiTest 4 / SpeechClientTest 8 / VoiceSpeakTest 16 / TtsSpeakStateTest 11)
SpeechApiTest 与 SpeechClientTest 都跑真 com.sun.net.httpserver.HttpServer + 真
OkHttp/Retrofit,不是 mock;SpeechClientTest 用 Files.createTempDirectory 验真落盘与
第二次同文本请求计数仍为 1。

未验:真机出声。已知取舍——请求在飞时图标 clickable(enabled = !speakPending) 点不动,
没有做「取消在飞请求」。
2026-10-26 23:25:42 +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
AskaEth c8f19f9c84 docs(05): 锚点补记三十七 —— 长跑第3次定案(30min 有效通过;2h 失败是模拟器 system_server 崩溃连带杀进程,五条证据)+ Android 14 划掉会终止 dataSync 前台服务(验收造场景改用返回键 finish Activity)+ W57 端到端验收通过(含未做改前对照的如实说明) 2026-10-07 00:18:51 +08:00
AskaEth f9555fba85 docs(05): 锚点补记三十六 —— W57 通知消费点提到进程级(根因链:连接是进程级/订阅挂 Activity 级 VM/replay=0 丢帧)+ 长跑第3次基线(开关确认是开的,推翻上节那条猜想)+ W57 设备验收步骤与副作用 2026-10-06 22:23:09 +08:00
AskaEth 900d191b8d merge: W57 —— 把「她主动说的话」的通知消费点提到进程级(新增 ProactiveFrameIntake;修 Activity 被划掉后收不到帧/不弹通知) 2026-10-06 22:22:26 +08:00
AskaEth 0e03eba6a4 feat(notify): W57 —— 「她主动说的话 ⇒ 弹通知」的帧消费点提到进程级
改了什么
- 新增 app/ProactiveFrameIntake:订 ChatSocket.events,把两类帧(proactive_message、
  message_persisted{proactive:true})折成四个入参交给 ProactiveNotifier。纯函数
  proactiveFrameOf 管「哪些帧算她主动说的话、每个入参取自帧上的哪个键」。构造即订阅,
  刻意不提供 start/stop。
- AppContainer:新增 proactiveFrameIntake(与 chatSocket 同一层,进程级)。
- SessionsViewModel:删掉 onEvent 里那两处转交 proactiveNotifier 的调用,本类不再判断
  通知的任何一件事(未读、「谁在跑」原样不动)。
- AppPresence / ProactiveNotifier 的 KDoc 跟着改:谁在读「在不在前台」、谁在喂帧。

为什么
改前这份订阅挂在 SessionsViewModel 的 viewModelScope 上。连接本身是进程级的
(ChatSocket 自带 scope,只有 close() 取消它,而 SessionsViewModel.onCleared 刻意不关),
所以从最近任务把 Activity 划掉之后连接照旧收帧——但 events 是 replay=0 的 SharedFlow,
没有订阅者的 tryEmit 当场丢掉:她的话进了连接、然后没了,通知永远不弹。这是 W53N 登记
的那一格缺口。

边界
- 判据与去重一个字节没动(仍在 ProactiveNotifier 的纯逻辑里)。
- 连接所有权没动:仍然只在登录态那一处 connect/close。
- 不是「新起一条连接」,只是把帧的消费点从 Activity 级提到进程级;VM 里那两处转交是
  删而不是留(留两处等于靠去重去遮一次重复消费,且「谁在喂通知」会有两个答案)。
- deliver 里接住落地异常:这条订阅不会再被重建,抛出去就是进程崩溃 + 通知整个哑掉。

验收
assembleDebug 通过;testDebugUnitTest 全量 113 suites / 1370 tests / 0 failures /
0 errors(基线 112/1361,新增的 9 条全在 ProactiveFrameIntakeTest)。
2026-10-06 22:21:26 +08:00
AskaEth 6de2f7656a docs(05): 锚点三十五补第 7 节 —— 保活长跑第2次复查结果作废(装包必然杀进程 / tcp:80 已指向真后端使注入 delivered=0 / 复查时进程本就不在),并写明重做需要的前提与代价 2026-10-06 22:14:13 +08:00
AskaEth 5f7489d70e docs(05): 锚点补记三十五 —— 模拟器已切真后端(含全角冒号坑与「未知错误」根因);主人报的标签解析 bug 定位到后端畸形 markup <p><p>正文</p> 并已容错修复 + 真机验收;W54A/C/D + W55 + W56 五支合并推送(112/1361 绿);模拟器卡的原因诊断 2026-10-06 21:44:07 +08:00
AskaEth a60e3abc12 merge: W56 —— 分享面板预览加「展开/收起」(长正文可读全文;展开态上限 240dp;短正文不显示控件) 2026-10-06 21:43:30 +08:00
AskaEth 14955ef6c0 feat(chat): 分享确认面板的正文预览可以展开 / 收起
问题:W54D 把正文那一窗改成「三行 + 内部滚动」之后「读不到整段正文」是解决了,但一次只看得见
三行。主人拍板「可以滚动 可以展开」——两个都要,所以这一轮补上后半。

改法(只动 ShareConfirmPanel.kt 与它的测试,ChatScreen / ChatViewModel 与接线一字未碰):
- 正文卡片里加一枚「展开 / 收起」(`Text` + `clickable`,不用 `TextButton`:按钮自带高度会把
  这一行撑高一截)。它挂在「分享的文字」那一行的右端,不挂在正文末尾——展开后正文可能好几屏,
  末尾那枚「收起」要滚到底才找得到。
- 收起态一个字没变:三行窗(SHARE_PREVIEW_TEXT_LINES = 3)+ 内部滚动。
- 展开态:正文那一窗撤掉自己的上限(`heightIn(max = Dp.Infinity)`,正是首帧那个状态),整段交给
  外层预览区滚;外层上限由 150dp 抬到 240dp(SHARE_PREVIEW_EXPANDED_MAX_HEIGHT)。展开态**只留
  这一层**滚动是刻意的:两层都设上限时手势先被内层吃掉、内层滚到底才轮到外层,两层上界还互相
  牵制,最容易出的错就是「明明展开了还剩半截读不到」。
- 控件**只在正文排下来超过三行时出现**。判据是行数 > SHARE_PREVIEW_TEXT_LINES,不是
  `hasVisualOverflow`:这一窗的高度不受限(`verticalScroll` 给子项的就是无上限约束),`Text` 自己
  永远报「没有溢出」,拿它当判据会在最长的那种正文上漏掉控件;行数则与上限无关,首帧就可用。
- 展开态为什么是 240dp:这个上限管的是**整个预览区**,下面还压着描述输入框(最多 4 行)与两枚
  按钮,面板贴着屏幕底、键盘一弹可用高度只剩半屏上下。150 → 240 在默认字号档上把正文窗从三行抬到
  十行上下;再往上(例如整屏的六成)先被顶掉的会是按钮。⚠️ 这个数**没在真机上量过**,是按这笔账
  取的保守值,观感由主人验收,要调只改这一个常量。
- 两条判据都做成纯函数(`sharePreviewTextOverflowsWindow` / `sharePreviewMaxHeight`),调用点只读
  结论,单测钉的就是它们。

上一批留下的「首帧回流会不会看得出来」疑点,复核结论:**留着,且确认它看不见**(理由已写进
`ShareConfirmBody` 的 KDoc):① 首帧那一下撑不动任何别的东西——正文块是预览区里的最后一个元素,
可视像素只由外层那层独立的上限裁剪决定,上面图片/文件的位置一分不动;② 展开态本来就没有这一层
上限,不存在「量完再回落」;而展开 / 收起那一帧的尺寸变化是用户动作的**目的**,不是回流。真正多
出来的是控件本身(第二帧才出现,因为行数是排完才知道的),它与标签同用 `footnote2`、行高不变,
也顶不动任何东西。组合期 `rememberTextMeasurer` 那条路要外包 `BoxWithConstraints` 拿宽度、还要
自己复现字号与字体缩放,等于把排版做两遍——为一个看不见的跳动换掉结构不划算,不做。

已知取舍(如实记下,没修):转屏(Activity 重建)后展开态回到收起。它现在活在
`remember(preview.text)` 里,跨转屏要活下来只有两条路——进 `ChatUiState`(W54C 给「已取走的分享」
与描述走的就是那条路,但那是 ChatScreen / ChatViewModel 的地盘,本批红线不许碰),或
`rememberSaveable`(本仓一次都没用过的新写法,且弹层窗口带不带 saveable registry 没在真机上验过)。
两条都没走,留一条 ⚠️ 在代码里 + 留给主人定。

测试:新增 3 条(纯函数判据)——「什么时候该出现展开控件」(0/1/3 行不给、4 行起给,并接一份
40 行的真分享走一遍)、「展开态给多高」(150 / 240 两个字面量 + 展开严格更高 + 不超过收起态的
两倍)、「两句标签不同且非空」。展开与滚动本身单测够不着(本模块没有 Compose UI 测试的依赖),
如实说明;既有断言一条未放松。

验证:`./gradlew.bat assembleDebug testDebugUnitTest --tests "*ShareConfirm*"` → BUILD SUCCESSFUL
(ShareConfirmTest 15 条 = 既有 12 + 新增 3、ShareConfirmDraftTest 3 条,0 失败 / 0 错误);
全量 `./gradlew.bat testDebugUnitTest` → BUILD SUCCESSFUL,112 suite / 1361 tests / 0 失败。
2026-10-06 21:43:12 +08:00
AskaEth 9ff2de3a1d merge: W55 —— 解析器剥掉落单标签(修后端畸形 <p><p>正文</p> 导致标签字面显示在气泡里) 2026-10-06 21:26:34 +08:00
AskaEth f3c1121887 merge: W54D —— 分享确认面板正文预览改成三行窗口 + 内部滚动(发送前能读到整段正文) 2026-10-06 21:26:33 +08:00
AskaEth a861881ec4 merge: W54C —— 转屏不再静默丢掉已取走的分享(面板状态与描述移进 ChatUiState,take 仍只发生一次) 2026-10-06 21:26:29 +08:00
AskaEth b3ded77c23 merge: W54A —— 通知一事一条(通知 id / PendingIntent requestCode 按消息稳定派生,修她连说几句时后到覆盖先到) 2026-10-06 21:26:26 +08:00
AskaEth 464e91621e fix(parse): 容错落单的段落标签,绝不把 <p> 当正文渲染
真机实测:后端产出的某条消息第三段是 `<p><p>那现在…?</p>`(两个开标签、一个闭标签)。
P_BLOCK_RE 是非贪婪的,先把外层 `<p>` 配到那个 `</p>`,内层那个裸 `<p>` 就成了「块内容」;
而 appendAnnotations 只认 `<action>` / `<narration>` / 星号,不认裸 `<p>`,于是它作为普通文本
落进气泡正文,屏幕上直接显示 `<p>那现在…`。

改了什么:新增 stripLooseTags,在切出 `<p>` 块之后按区剥掉「落单标签」——
- 消息里已有完整 `<p>…</p>` 块时,块内块外多出来的每个 `<p>` / `</p>` 一律剥掉,块外没被
  ROLE_RE 认领的 `<action>` / `</action>` 同样剥掉;
- 一个完整块都没有时,维持既有「未闭合 `<p>` 整段回落字面文本」的契约(编辑框靠它保证不
  静默改字),只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>` 与后面什么都没有的空开标签。
判据只认完整匹配已知标签词表的形状,`1 < 2` / `<3` / `a<b` / `<p2>` / `<P>` / `<p`(残缺) 一律原样保留。

为什么选择「剥」而不是「原样显示」:这些是服务端产出的标记、不是她说的话;渲染成正文等于往
气泡里塞一串用户既看不懂、也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字
都不动。这只是客户端的容错,不代表服务端这样发是对的。

流式:stablePrefixEnd 不动。扣留点取「第一个没闭合的开标签」,而前缀里的每个开标签必定在同一个
前缀内配到闭标签,所以「是不是落单」在它被产出的那一刻就已定死,中间态剥掉、收口也不用改口;
放宽扣留(把重复的 `<p><p>` 当外层冗余)反而会让 `<p>甲<p>乙` 在中间态把 `<p>甲` 当字面文本吐上屏,
等 `</p>` 到了再跳成 `甲乙`。新增测试钉住这条:逐字符喂实测畸形串时,任何一帧都不许漏出标签,
且已显示的正文只许增不许改口。

测试:assembleDebug 通过;ChatContentParserTest 55 项全绿(新增 17 项);全量 110 suites /
1349 tests / 0 failures / 0 errors(主仓同 HEAD 基线 110 suites / 1332 tests,本次 +17 项)。
2026-10-06 21:26:07 +08:00
AskaEth ec89d828d1 fix(chat): 分享确认面板的正文预览改成三行窗口 + 内部滚动
问题:正文那一窗此前是 maxLines = 3 + 省略号,超过三行的分享正文在按「发送」之前根本读不到。
面板里再没有第二处放着这段正文(输入框只剩「用户附加的描述」,初值恒为空),截断就等于
「要发出去的那段正文看不见」——而这正是用户点「发送」时唯一要确认的东西。

改法:正文那一窗高度恒为三行、内部可滚,外层预览区那 150dp 的上限原样不动,所以面板不会被
长正文撑高(图片 / 文件芯片 / 按钮的位置都不变)。滚到底后余量交给外层 scroll,嵌套滚动本身
就有,不用额外接线。

上限为什么不是写死的 dp:Miuix 的 footnote1 只给了 13sp 的字号,lineHeight 是 Unspecified,
行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。写死 dp 在默认字号档上勉强
对得上,到大字号档就会矮一截、把末行的字切一半。所以改为量:从 TextLayoutResult 上取第三行的
底边当上限,量到之前先按无上限排一次(heightIn(max = Dp.Infinity) 在确认过 Compose 的
Density.roundToPx 对 Infinity 的处理后是安全的,即「没有上限」)。

SHARE_PREVIEW_TEXT_LINES 由 private 改 internal,只为让单测能把「这一窗是三行」钉成字面量
(本模块没有 Compose UI 测试的依赖,滚动行为单测够不着)。

测试:新增两条——正文远超三行时纯函数层仍原样交付**完整**正文(一个字不少、不加省略号),
以及三行上限的字面量;既有断言一条未放松。
2026-10-06 21:11:30 +08:00
AskaEth 1142bf5ed1 fix(share): 转屏不再静默丢掉「已取走、还没确认」的那份分享 —— 面板状态移进 ChatViewModel
分享进来的内容被 `ShareIntake.take()` 取走之后没有归还路径,而它此前只挂在一格
`remember(sessionId)` 上:Activity 因转屏 / 深浅色切换重建时那次组合整份丢掉,那份分享
**既不在待发队列里、也不在界面上**(用户什么都没看到,也没有任何提示);用户已经输入到
一半的那句描述同样丢。

改法:把「已取走、正等确认的那份分享 + 用户补的描述」移进 `ChatUiState.shareConfirm`
(`ShareConfirmDraft`),那一格由 VM 持有,而 VM 活在 `ChatSessionSlots` 的 `ViewModelStore`
里、由 AppContainer 撑着 —— 比组合与 Activity 都活得久,所以重建之后面板照旧长出来,
描述也跟着回来(描述与正文绑在同一个值里,两者同生共死)。

`take()` 仍然只发生一次:认领那一步收进 `ChatViewModel.claimSharedContent`,闸门是纯函数
`shareConfirmClaim`,且**在 `take()` 之前** —— 手上已经有那份内容时一个字节都不动队列
(既不取新的一条,也不会把用户正看着的那份顶掉)。重建后的新一帧照样会走到判据这里,
而那时手上已经有东西(它活在 VM 里)⇒ 不会再取第二次。判据、出队、落地之间没有挂起点,
所以也不存在「取走了却没记住」的窗口。

顺带把这一格计入 `holdsUnsentWork`:这份内容已经没有退路,那一格被回收就等于把它删掉。
`ShareLanding` / `ShareIntake.take()` 里「取走之后直接发」的旧说法一并改成「交给确认面板」。

新增 ShareConfirmDraftTest:钉住「重建后的第二帧不再调 take、队列里那条也不许被取走」、
「内容与描述原样还在」、「队列为空时不造一个空面板」。
2026-10-06 21:09:15 +08:00
AskaEth 53339b369a feat(notify): 她主动说的话改成「一事一条」——通知号按消息派生,后到的不再顶掉先到的
改前所有她主动说的话共用一个固定的通知 id,而同 id 的 `notify` 是**替换**:她连说几句时后来
的那条把先来的顶掉,用户拿起手机只看到最后一句,前面几句一声没响就没了(这正是 W50 判据下游
的落地形状)。

改成按消息派生:
- `ProactiveNotify.kt` 新增纯函数 `proactiveNotificationId(key)`:把**去重键**(`proactiveNotifyKey`
  的输出)折成通知号。刻意与去重键同源——去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」,
  两处口径一旦分成两套,就会出现「认得出是同一条、却排出两条通知」的形状漂移(实时帧与落库帧
  两条路都会到,这条尤其要命)。
- 两个坑都真的堵上:① 号必须 > 0,而 `hashCode()` 可以是负的、甚至正好是 `Int.MIN_VALUE`
  (`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)⇒ **先展宽到 `Long` 再取绝对值**,不引任何新依赖;
  ② 不撞既有 id:全仓只有保活那条常驻通知(id = 1)与它 `PendingIntent` 的 request code = 0 是
  占用者,派生的号从 1000 起步、宽度 2×10⁹,落点与它们**结构性**不相交。
- `ProactiveNotifier.post` 的签名带上这个号(`post: (text: String, notificationId: Int) -> Unit`),
  `AppContainer` 的装配 lambda 同步改;`ProactiveNotification.kt` 删掉恒定的
  `PROACTIVE_NOTIFICATION_ID` / `PROACTIVE_OPEN_REQUEST_CODE`,通知 id 与 `PendingIntent` 的
  request code **一起**随消息走(只换 id 不换 request code,点后到的那条通知会串到先到那条的
  `PendingIntent` 上)。
- 撞号仍会发生(`String.hashCode()` 只有 32 位):后果与改前一样是「后来者替换先来者」,已在
  `proactiveNotificationId` 的 KDoc 里如实写清概率,以及为什么接受这一侧。**没有**放松任何既有断言。

测试:新增 `ProactiveNotificationIdTest`(同一条派生两次同号 / 两条不同消息不同号 / 任何输入恒 > 0,
最后一条用 hashCode 正好是 `Int.MIN_VALUE` 的字符串 `polygenelubricants` 钉住前提);另在
`ProactiveNotifierTest` 加一条端到端的号断言(两条路都到的第一条只发一次且号不变、第二条是另一个
号)。`ProactiveNotificationShapeTest` 里那条「不与保活那条相撞」改成扫一遍真实形状的键。

验证:`./gradlew.bat assembleDebug` → BUILD SUCCESSFUL;`./gradlew.bat testDebugUnitTest` →
BUILD SUCCESSFUL,111 suite / 1336 tests / 0 失败(基线 110 suite / 1332 tests,本批净增 4 条)。
2026-10-06 21:08:23 +08:00
AskaEth e5e61e131d docs(05): 锚点补记三十四 —— 压缩后续接(W54 三支并行在飞:通知一事一条/转屏丢分享/预览可滚动;第二批进程级订阅待开)+ 主人三条拍板(预览要滚动+展开、提示词已贴等回执、推送待研究)+ 环境核对 2026-10-06 21:02:43 +08:00
AskaEth 33c320219a docs(05): 锚点补记三十三 —— 续接锚点(仓库现状/本轮八项已完成及其真机证据/保活长跑在跑/待主人拍板四条/环境与两个会踩空的地方/两条教训) 2026-10-06 20:59:32 +08:00
AskaEth 827f0e61a4 docs(05): 锚点三十二补第 6 节 —— 分享面板语义改对并上机验过(预览留正文 / 输入框空+占位符 / 正文+空行+描述 拼接端到端验过)+ 保活长跑因装包重起(新基线 20:13:35 / pid 25584)
另记两条:① 预览正文仍只有 3 行 ⇒ 发送前读不到全文,属观感取舍待主人定;② adb am start 的 --es 值会被空格拆开(我那次 URL 就是这么丢的,不是 App 吞的)。
2026-10-06 20:13:52 +08:00
AskaEth 30c53451dc merge: W53X3 —— 分享面板的输入框改成「用户附加的描述」(默认空 + 占位符),正文留在预览区;发送时把分享正文与描述拼起来(空行分隔) 2026-10-06 20:10:03 +08:00
AskaEth 514901c12d fix(share): 确认面板的输入框改成「用户附加的描述」——不再预填分享正文,发送时由正文+描述拼成
W53X 的第一版把那个输入框当成了分享正文的可编辑副本(`shareConfirmDraft(share)` 直接返回分享
正文、由 ChatScreen 预填进去),在真机上就渲染成「同一句话排两遍」:预览的「分享的文字」块一遍、
输入框里又一遍,而输入框里那句还看着像一段只读展示。主人澄清语义:那个框是**用户要附加的描述**
——「这次要发什么」归预览,「你想补一句什么」才归这个框,两件事被合成一件才是根因。

改成:
- 输入框初值**恒为空**(`shareCaption` 在取走一份分享时显式归零),占位符改成「加一句描述…」
  (与输入栏那句「和昔涟说点什么…」同一个 `useLabelAsPlaceholder` 写法);预览区一个字没动
  (图片缩略图 / 文件 chip / 分享正文都留着,那才是这次要发出去的东西)。
- 新增纯判据 `shareConfirmBody(share, caption)` = 分享正文 + 用户描述:两段都在时用**一个空行**
  分隔,只有一段就只用那一段,都空就是空串(两侧空白按 `ShareRules.extract` / `sendShared`
  的口径归一,中间一个字不动,也不加任何来源标记——那是项目红线)。
- 删掉 `shareConfirmDraft`(名字说「草稿/正文」、行为是「把正文当输入框初值」,留着就是骗人);
  `shareConfirmCanSend(share, caption)` 改成**直接问 `shareConfirmBody`** 非空、或另有图片/文件,
  于是「发送」可点与否与 `sendShared` 开头那条早退同源;描述为空时不再误把「纯文本分享」判成不可发。
- ChatScreen 的发送口由 `share.copy(text = shareDraft)` 改成
  `share.copy(text = shareConfirmBody(share, shareCaption))`;接线语义(`take()` 只发生一次、
  面板渲染位置、WindowDialog 选型)一字未动。

测试:`ShareConfirmTest` 跟着改到新语义,拼接规则**穷举**钉住(都有 / 只有正文 / 只有描述 / 都空,
分隔符写成字面量 `"\n\n"`),并钉住「都空且无文件 ⇒ 不能发」与「可点 ≙ 拼出来的正文非空或另有
附件」(同源性)。10 条测试全绿。

验证:`./gradlew.bat --console=plain assembleDebug testDebugUnitTest` → BUILD SUCCESSFUL,
110 suite / 1332 tests / 0 失败(基线 110 / 1330,本批净增 2 条)。
2026-10-06 20:09:35 +08:00
AskaEth 59012f711b docs(05): 更正分享面板的语义 —— 输入框是「用户附加的描述」而不是分享正文的复刻
主人澄清:那个输入框(我这边先误判成「预览多余」)应当是用户附加描述的输入框。正确形态:
预览留着分享正文(它才是要发的内容)、输入框默认留空 + 占位符、发送时「分享正文 + 用户描述」拼接
(两段都在时空行分隔)、都空且无文件时按钮不可点。我先前那条「删预览正文」的指令方向反了,已更正,
W53X2 回来即按更正执行。
2026-10-06 20:06:00 +08:00
AskaEth 863ac93b25 docs(05): 锚点补记三十二 —— 续接锚点(主人两条新指令:复验分享面板「正文两次」、开跑保活长时间后台)+ 环境状态 + 两条各自照抄即可的跑法 2026-10-06 20:03:56 +08:00
AskaEth d8e44c6692 docs(05): 锚点补记三十一 —— W53 四项落地(后台通知/退出确认框/保活文案与入口/分享确认面板)+ 真机验收全过
- 三支各自的机制与判据要点,含两条「子代理纠正任务书」的记录(Miuix 0.9.3 没有 extra.* 包;
  proactive 判据后端放在 data 里而不是帧壳顶层——我读后端源码核过)+ 我自己补的那处
  (AppPresence 补同源 StateFlow 当 key,修「去系统设置授权后回来服务没起」)。
- 真机验收逐条证据(后台通知的形状与点它回主会话、退出确认框、保活文案与「去系统设置」、
  分享面板确实没被直接发出、INTERNET 来源是源码)。
- 仍未验/已知缺口如实列出(两条路去重只有单测、通知 id 覆盖、Activity 销毁后收不到帧、
  转屏丢已取走的分享、S4 对照组没跑干净)。
2026-10-06 19:00:03 +08:00
AskaEth 62f552f87f merge: W53X —— 分享进来先弹确认面板(预览图片/文件 + 可附文字 + 发送/取消),不再一到就发 2026-10-06 18:55:36 +08:00
AskaEth 77fbedc350 fix(keepalive): 权限「去系统设置里授权后回来」这一档现在真能把服务起起来(补 W53S 登记的那一格)
问题(W53S 标出、我核过代码确认):CyreneApp 里那个起停前台服务的 LaunchedEffect 的 key 只有
`keepAliveEnabled` 与登录态,而通知权限是**现读**的 ⇒ 用户在系统设置里授权后回到 App 时它不会重跑,
设置页已经显示「已开」而服务其实没起(要等下次启动或再动一次开关)。

修法:给 AppPresence 补一条**与既有 `appInForeground` 同源**的 StateFlow(`onStart/onStop` 里
从同一个集合重算,布尔 getter 语义一字未动),让那个 effect 收它当 key —— 「回到前台」正是
「从系统设置返回」必经、且必然变化的那一位。没有新增任何依赖。
2026-10-06 18:55:20 +08:00
AskaEth 0a43cedea9 docs(share): 补上「为什么是 WindowDialog 而不是 OverlayDialog」的硬理由
查了 Miuix 0.9.3 的源码(下载 sources jar 核对,不是猜):`OverlayDialog` 靠
`MiuixPopupUtils.DialogLayout` 把自己登记进 `LocalDialogStates` / `LocalRootDialogStates`,
而这两个 `staticCompositionLocalOf` **只有 Miuix 的 `Scaffold` 会 provide**
(`basic/Scaffold.kt` 的 Surface 里那层 CompositionLocalProvider)。

本面板的调用点在 `ChatScreen` 那个 `Scaffold` 的**外面**(与附件预览 / 编辑 / 删除三个弹层
同一处)⇒ 换用 `OverlayDialog` 会拿到一个没人读的默认空列表:**不报错、也不显示**,
分享进来会变成「什么都没发生」。这条写进 KDoc,免得后人只看到 W53S 用了 `OverlayDialog`
就顺手把这里改过去(两处调用点的处境不同:那个确认框长在设置页的 Scaffold 里)。

只改注释。验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest → BUILD SUCCESSFUL,
102 suite / 1278 tests / 0 失败。
2026-10-06 18:54:43 +08:00
AskaEth b06688f1db feat(share): 分享进来先确认再发 —— 新增确认面板
主人 2026-10-06 拍板:分享进来的内容不再「一落地就发」,要像其他 IM 一样先给一个
确认面板。此前 ChatScreen 的分享落地 effect 取走内容后**直接**调 sendShared,
用户没有任何确认机会;现在它把取走的那一份放进 Compose 状态,面板靠它渲染。

新增 `ui/chat/ShareConfirmPanel.kt`(Miuix 自己的 `WindowDialog`,不是 Material3):
- 预览:图片缩略图(与待发草稿预览同形)+ 文件芯片(显示名 / 体积)+ 分享正文(三行,
  全文在下面那个输入框里,可编辑);
- 输入框:初值就是分享进来的正文本身,**一个字符都不加**(不拼来源标记——那是红线);
- 「取消」= 本次分享作废(不发送、也不塞回待发队列);「发送」才走既有 sendShared。

为什么这么接线(都在 ChatScreen 那一小段里):
- **取走只发生一次**:`ShareIntake.take()` 只在 `shareConfirm == null` 时被调用,而
  `shareConfirm == null` 同时是这个 effect 的键 ⇒ 面板一关就再跑一遍判据、把面板开着时
  攒下的下一条分享接上(不加这个键,那一条会一直躺在队列里等连接状态碰巧再变一次);
- 面板开着时新来的分享**原地留在队列里等**,不覆盖用户正看着的那一份;
- 告知按**真正发出去的那一份**算(`share.copy(text = 输入框内容)`):正文可能刚被用户改过,
  拿改之前那份去算会说出「正文较长、本想作为文件发送」这种与事实不符的话。

未动:shareLanding / shareNoticeFor / shareWaitingNotice 的语义,sendShared 里
「超长正文 → 文件」那条裁决,ShareIntake 的提取与暂存,W49/W50/W51 的判据。

判据抽成纯函数并单测(`ShareConfirmTest`,8 条):预览该显示哪几样(图片数 / 文件 / 正文是否
非空)、输入框初值、以及「发送」可点 ≙ 真的会有东西发出去(与 sendShared 开头那条早退同一条)。

验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest → BUILD SUCCESSFUL,
102 suite / 1278 tests / 0 失败(基线 101 / 1270)。
2026-10-06 18:53:41 +08:00
AskaEth 6505512c0a merge: W53S —— 退出登录加 Miuix 确认对话框;保活那行补「24 小时累计 6 小时」上限事实 + 权限被拒时的「去系统设置」入口 2026-10-06 18:53:34 +08:00
AskaEth bbd02fc279 feat(settings): 退出登录加二次确认;保活那行补系统上限并给「去系统设置」入口
两件事都在这一个切片里,都只碰 ui/settings/**。

一、「退出登录」的确认框
- 现状:W52C 把它改成整行可点的 BasicComponent(红色标题 + Remove 图标),而它紧挨着
  「版本」那一行,两条长得几乎一样 ⇒ 误触代价是一次真登出(AuthManager.logout 会吊销
  服务端 refresh 并把本机凭据整份清空,没有撤销)。W52C 自己把这条风险标了出来。
- 改成:点条目只立一个本页的 remember 状态(不进任何 VM),弹出的确认框里
  「取消 / 点框外 / 系统返回键」三个出口都只是关掉框,一个字都不改;只有确认那一路
  才调 onLogout —— 行为链路与加框之前完全一致。
- 控件用的是 Miuix 自己的 OverlayDialog(top.yukonga.miuix.kmp.overlay),由本页 Scaffold
  自带的 MiuixPopupHost 承载:标题 / 说明 / 内容分槽,正是为这种事准备的。
  (注意 Miuix 0.9.x 并没有 extra.* 包,对话框只在这两个包里,别照旧版 import 去猜。)
- 说明文案如实带上代价:退出后本机登录信息整份清空,服务器地址也要重填。

二、「后台保持连接」那行
- 补事实:dataSync 型前台服务在 targetSdk 37 下,24 小时内累计最多 6 小时会被系统回调
  onTimeout(KeepAliveService 到点自停以免 ANR)⇒「全天候保活」做不到。原先这句只写在
  KeepAliveService 的代码注释里,用户看不到;现在写进开关下面那行字,且落在「关 / 开」
  两档——只写在「已开」那一档等于默认所有人都已经开过一遍,而默认态恰恰是关的。
- 补入口:权限把功能挡住的两档(刚被拒 / 开关开着但权限被撤)原先只说「到系统设置里允许」,
  用户得自己摸进去找。现在这两档下面多一个「去系统设置」按钮,直接跳到本 App 的通知设置页
  (ACTION_APP_NOTIFICATION_SETTINGS + EXTRA_APP_PACKAGE;厂商砍了这个页面就退到应用详情页,
  两次都失败就什么都不做,绝不崩)。按钮出现的条件与那两句文案一一对应,两处不会分叉。
- 顺手复核:点了按钮从系统设置回来时,由 ActivityResult 回调现读一次真实授权,把过期的
  「刚刚被拒」清掉 —— 比挂生命周期钩子直接,也不需要它。
- 测试:KeepAliveSummaryTest 原有的 4 条断言一条没动(新增事实挂在「关 / 开」两档,正好
  不吃「已开」与「起不来」那两条);新增两条 —— ① 关 / 开两档都必须写明 24 小时窗口与
  6 小时上限;② 权限挡住的那两档都必须把用户指向系统设置(与按钮的判据同源)。

验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest
     BUILD SUCCESSFUL;101 suite / 1272 tests / 0 失败(基线 101/1270)。
2026-10-06 18:52:31 +08:00
AskaEth 53c7494389 merge: W53N —— 她主动说的话在后台/不在聊天页时发系统通知(只这一档;读回 proactive 判据 + 按 id 去重 + 点通知切主会话) 2026-10-06 18:52:20 +08:00
AskaEth 611bc5f58a feat(W53N): 她主动说的话在后台/不在聊天页时发一条系统通知(只这一档,普通消息不弹)
改了什么:
1. WsEvent:把落库帧上被丢掉的 `proactive` 判据**读回来**(新增 MessagePersisted.proactive)。
   此前 PersistedData 只解 message,而生产 Json 是 ignoreUnknownKeys=true ⇒ 这个键既不解也不报错,
   「她主动说的话走了 IM 桥那条落库路」在客户端完全不可见。两层落点都认(data.proactive 与帧壳
   顶层 proactive 取或,契约只说了「帧顶」);值用**宽容读法**(只认字面 true),形状不认识时按
   「不是 proactive」——判错成真会给普通消息弹通知,判错成假只是漏一次提醒,方向必须偏不打扰。
2. core/notify(新):判据 + 去重 + 落地
   - shouldNotifyProactive(appInForeground, viewingTargetSession):语义只有一条——**这条消息此刻
     会不会正好被用户看到**。四档组合都写进 KDoc(含「不在前台、聊天页却还挂着」那一档)。
   - RecentProactiveNotices:进程级、容量 64、按插入序淘汰。记的是「**到达过**」而不是「弹过」,
     因为「用户正看着」那一次也算这条已经到达(他看过了,另一条路再补一帧时不该为一句看过的话弹)。
   - ProactiveNotifier:两条路共用一处;顺序是 归属 → 有没有字 → 记账/查重 → 判时机。
   - proactiveNotifyKey:落库行 id > IM 消息号 > 时间字面量 > 正文(服务端一个键都不给时的兜底)。
     实时帧的 data.id 与落库帧的 message.id 是**同一个值**,去重就靠它。
   - proactiveNotificationText:复用权威解析器 domain.parse 剥标记(不再写第二份剥标签正则)、
     折成一行、超 120 字截断;截断不劈开代理对(emoji)。
3. 前台判定不引新依赖:MainActivity 的 onStart/onStop 按**实例**登记到一个进程级集合
   (配置变更时新实例的 onStart 先于旧实例的 onStop,布尔量在那里必然误判成「后台」);
   聊天页那一半由 ChatScreen 的组合报告(DisposableEffect)。两者在 AppContainer 那层汇合。
4. 通知本体:自建渠道 `proactive`(DEFAULT,**要提醒人**,与 keep_alive 的 LOW 分开)、通知 id=2
   (与保活那条 id=1 分开,同 id 是替换会把常驻通知顶掉)、标题「昔涟」+ 正文是她的原话、
   setAutoCancel、PendingIntent.FLAG_IMMUTABLE(且 request code 与保活那条分开:两者的 Intent
   filterEquals 相等而 PendingIntent 判据不含 extras,同 code 会复用先建的那个、丢掉这个 extra)。
   通知权限现读(复用 core.keepalive.notificationsAllowed),没授权就不发。
5. 点通知 ⇒ 打开 App 并切回主会话:Intent extra → 进程级待落地意图(ProactiveTapIntake,通知可能
   在 App 没起来 / 没登录时被点)→ ChatHomeScreen 落地成「关掉设置/管理/关于/抽屉 + select(web)」,
   走的是用户点抽屉里那一行的同一条路,不引导航库。

为什么这样切:通知逻辑**绝不碰连接**(那条共享连接的所有权仍只在登录态那一处),只在帧**已经到达
之后**做判断;判断与去重全是纯函数/纯类,可单测;Android 落地只有一层薄薄的通知构造。

验证:W53N worktree(基线 f8ac95c)`assembleDebug testDebugUnitTest` 全绿 —— 109 suite / 1320
tests / 0 失败(基线 101 / 1270 / 0,新增 8 suite / 50 tests)。真机弹通知、点通知的落地未验(留给主人)。
2026-10-06 18:51:24 +08:00
AskaEth 659ab83f00 docs(05): 锚点三十补第 7 节 —— 新登记「分享确认面板」(主人 2026-10-06 提)
现状已核实:分享一进来就在 ChatScreen 的 ShareLanding.Send 支被立刻发出(ShareIntake.take() → sendShared),
用户没有任何确认机会。主人要的是像其他 IM 一样:能预览(图片/文件名与大小)、能附文字(预填分享自带的文本)、
由用户点发送才真发、可取消。已指派 W53X 且写明边界(不动 shareLanding/sendShared 语义、take 只许一次)。
2026-10-06 18:47:02 +08:00
AskaEth ed0824f335 docs(05): 锚点三十补第 6 节 —— INTERNET 显式声明的验收证据 + 构建锁协议写死三条
锁协议:① 一行一个占用者;② 只删自己那行,绝不整文件删除(这批真踩到);③ 改/删前先读一遍。
2026-10-06 18:43:14 +08:00
AskaEth f3d8d28cf7 fix(manifest): 显式声明 INTERNET(不再搭在 okhttp-android 的传递清单上)
原来 release 能联网是靠 com.squareup.okhttp3:okhttp-android:5.5.0 的清单把 INTERNET 合并进来的;
换掉/排除那个依赖,release 会静默失去网络权限(debug 侧 AGP 也注入同一份,所以本地怎么试都看不出来)。
现在显式写进源码。验收::app:processReleaseMainManifest 的 merger 报告里该权限已是
ADDED from app/src/main/AndroidManifest.xml(并 MERGED from okhttp-android)⇒ 来源是源码。
2026-10-06 18:43:13 +08:00
AskaEth f8ac95ceed docs(05): W52 待办变化 —— S4/S5/S9 移出待办,并标明仍未验/未做的三项
- S4 → W52A(真机未验,只有单测+走查);S5/S9 → W52B(真机通过);W52C/W52DE 三项真机通过。
- 仍在待办:保活→前台服务/通知/13+ 权限流 未验;INTERNET 显式声明未做;构建锁协议未写死;
  主人待拍板的保活第 3 件(收到消息弹系统通知 + 接上 proactive 判据)。
- 另记:登出条目观感已实机确认(红字「退出登录」+ 箭头离开方框图标,一眼是登出)。
2026-10-06 16:15:02 +08:00
AskaEth 46855af2dd docs(05): W52/E 复验补记 —— 「重试」不出现的根因是判据极性接反(已修,真机端到端通过)
根因:判据参数语义是「有没有东西」,调用点传了 isEmpty() ⇒ 唯一会求值的场景里结论恒假。
抓到方式:一次性诊断日志(三个输入全真而结论为假 ⇒ 只剩极性)。修在 0054a3a。
教训:判据单测全绿 ≠ 接线正确——极性/语义类错只有调用点会露,而调用点在 Compose、无 UI 测试装置 ⇒
只能靠真机验收。这是设备验收抓住单测抓不到的错的第二例。
2026-10-06 16:14:44 +08:00
AskaEth 0054a3a97a fix(chat): 首屏失败的「重试」按钮永远不出现 —— 判据极性接反(W52/E 真机验收暴露)
改了什么:
- ChatScreen 里 shouldOfferRetryOnEmpty 的第三个实参从 `state.items.isEmpty()` 改成
  `state.items.isNotEmpty()`。

为什么:判据是 `!loading && hasError && !hasItems`,参数语义是「列表里**有没有**东西」;调用点传的是
`isEmpty()`(极性相反),于是「列表为空」这个**唯一会求值**的场景里结论永远是 false ⇒ 按钮永远不出现。
判据单测用的是正确语义(不含调用点),所以全绿——这条只有真机才会露出来。

怎么定位的:先加一条一次性诊断日志把 UI 实际看到的三个输入打出来(`loading=false hasError=true
items=0 retry=false` —— 三个输入全真而结论为假 ⇒ 只可能是极性),核对判据本体后确认;定位完日志已删。
真机复验:断网冷启动 ⇒ 「消息没能加载出来」+「重试」;恢复网络后点它 ⇒ 发出
`GET /api/v1/sessions/web/messages` 且离线期间落库的那条出现在屏幕上;按钮随列表非空消失。
2026-10-06 16:14:27 +08:00
AskaEth 126a706422 docs(05): 锚点补记三十 —— W52 五项落地(已合并推送/101 suite 1270 tests 真跑绿)+ 真机验收结果
- 五项各自的机制与判据要点(S4 一格一份 ViewModelStore 与四条豁免、S5/S9 纯账本与既有 interrupt 出口、
  登出条目、降级气泡屏蔽服务端动作、保活开关与 Start/Stop/Leave 判据)。
- 真机验收:设置页两行 / 抽屉「正在思考…」/「停止」发既有 interrupt 帧 / 降级气泡无删改入口 **通过**;
  **首屏失败「重试」未通过**(断网冷启动下按钮没出现,根因未定位,写了下一步的诊断办法)。
- 保活开关→服务、S4 回收 未验(时间不够),如实标注。
- 三条教训:① 判据第一步要防「别的 App 抢前台」(gms 账号页把我们的 dump 全吃了);
  ② 更正 INTERNET 那条:release 的 INTERNET 来自 okhttp-android 传递依赖,建议显式声明;
  ③ 构建锁协议有洞(有人整文件删除导致别人的行没了)。
2026-10-06 16:10:37 +08:00
AskaEth 579eccdfd1 merge: W52K —— 「后台保持连接」开关 + 前台服务(默认关;开了才有常驻通知) 2026-10-06 16:02:42 +08:00
AskaEth 0243fb78d4 feat(设置): 新增「后台保持连接」开关与前台服务(默认关)
现状是一件保活都没做:Android 12+ 会把 cached 进程冻结,进程级共享的那条 WS 连接
(AppContainer.chatSocket)久了就断。这一批只解决「进程别被冻」这一件事,连接本身的生死
仍然归 SessionsViewModel(登录连、登出关),本服务不碰它、也不订阅任何帧。

- core/keepalive/KeepAliveService:dataSync 型前台服务,职责只有让进程活着。常驻一条 LOW
  级静默通知(不响不震、setOnlyAlertOnce、点一下回 App),渠道只在服务真的起来时才建。
- core/keepalive/KeepAlivePolicy:起停判据抽成纯函数。shouldKeepAliveRun 三个条件缺一不可
  (开关开 / 已登录 / 通知权限在);keepAliveAction 再补上「开关开着但登录态未定(自举中)
  一律不动」——不补的话热启动会看到通知闪一下,而中间那段进程正好没人保护。判据刻意**不**查
  「现在跑着没有」:那份进程内记忆会被「系统收掉服务却不走 onDestroy」写坏,于是该起的时候
  一声不响地没起(保活最不能出的错);重复 start/stop 本身是无害的。
- CyreneApp:全 App 唯一一处按判据起停,开关或登录态一变就重判一次 ⇒「启动时开着且已登录就
  在跑」「登出即停」「关开关即停」三件事不需要各自一条路径。
- 设置页新增一行(默认关),文案如实写清代价(常驻一条通知 + 多耗一点电)与当下真实状态;
  Android 13+ 申请 POST_NOTIFICATIONS,被拒则开关弹回关闭并说明原因,绝不假装开着。
  实现落在新文件 KeepAliveRow.kt:SettingsScreen.kt 只加一行调用(放在「关于」之前,不碰
  最底下的登出块),避开 W52C 正在编辑那一段。
- KeepAliveService.onTimeout:targetSdk 37 下 dataSync 前台服务 24 小时内累计跑满 6 小时会被
  系统回调,不自己停就 ANR 崩掉整个 App —— 照办 stopSelf,不绕(绕它要么换类型要么加心跳,
  都超出本功能边界)。
- Manifest:FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC / POST_NOTIFICATIONS + service
  (exported=false、foregroundServiceType=dataSync)。

测试:新增 3 个文件 13 条 —— 默认关、三条件缺一不可、四入参全表一致、文案四档各不
相同且「跑不了时一个字都不提已开」、渠道重要级只许 LOW/MIN。基线 93 suite/1205 tests 全部保持
通过,本树 96 suite/1218 tests/0 失败。
2026-10-06 15:59:28 +08:00
AskaEth d6b0c7cb20 merge: W52A —— 常驻 VM 保留最近 3 格 + LRU 回收(S4):一格会话一份 ViewModelStore
冲突仅一处(deleteSession 的 onSuccess 同一锚点):W52B 的 forgetBusy 与 W52A 的 reclaim 是同一件事
的两半,两侧都留 —— 一个让还活着的 VM 别再发注定 404 的补拉,一个把那一格的内存交出去。
2026-10-06 15:58:06 +08:00