166 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
AskaEth 37289391f8 merge: W52B —— 抽屉能看出哪个会话正在跑(S9)+ 行内「停止」入口(S5) 2026-10-06 15:56:23 +08:00
AskaEth bbc47dbd31 feat(chat): 常驻 ChatViewModel 加 LRU 回收(S4)—— 一格一份 ViewModelStore,最近 3 格之外整格交出去
S4 的病根:每个访问过的会话各留一个常驻 VM(都订阅那条共享连接 ⇒ 每次重连各自发一轮
after 补拉)、各留一份消息列表(含内联 base64 图片),而删会话也不回收。此前**没有按会话
交出一格的手段**:所有 VM 塞在 Activity 级那一个 ViewModelStore 里,而它只有整体 clear(),
W48 只能做到「让 key 换一代、旧实例继续留着」—— 旧实例照样活着、照样收帧。

这次把「哪几格留着」这件事单独拎出来:

- 新增 `core/session/ChatSessionSlots.kt`(进程级,挂在 AppContainer 上,与 GoneSessions
  同一层):一格一份 ViewModelStore。`ChatScreen` 改成
  `viewModel(viewModelStoreOwner = <槽位>, key = chatViewModelKey(...))`,所以「交出去一格」
  就是那一份 store 自己的 `clear()` ⇒ 那一格的 `onCleared()` + `viewModelScope` 取消
  (订阅、补拉、在飞的请求一起停)。共享连接的生死一个字节都没碰:主人仍是
  SessionsViewModel,`ChatViewModel.onCleared` 照旧**不** close(现在它会真的被调到,
  所以这句注释也一并写成事实)。
- 新增判据 `core/session/chatSessionEvictions`(**纯函数**,VM 与测试共用):
  「最近」的口径 = **最后一次成为当前会话的时刻**(由 ChatScreen 在组合到某个会话时写入);
  候选要同时满足「不是主会话 ∧ 不是屏幕上那一格 ∧ 没有没交代完的事」,按最旧的先走,
  保留最近 `keep` = 3 格(主人口径,含被豁免的那些占的名额——不然「最多 3 格」会变成
  3 + 豁免数)。两条豁免名额之外的豁免各自都有硬理由:屏幕那一格死了会「不再更新、连
  发送都静默不发」,还有没交代完的事那一格(草稿 / 排队等连接的发送)死了内容**回不来**。
- 删会话时那一格当场交出去(`SessionsViewModel.deleteSession`,与 W51 的
  `goneSessions.mark` 相邻:一个是「别发注定 404 的补拉」,一个是「这一格本来就不该留」);
  登出 / 换账号时 `clear()` 一格不留(与快照 / 已读游标 / GoneSessions 同档纪律)。
- 「还有没交代完的事」收的是**现读的探针**而不是打开那一刻的快照:回收发生在别的会话被
  打开的那一刻,而那正是用户可能刚在前一格打了草稿、还没切走的时候。

刻意不动的红线:W48 的 `chatViewModelKey` 拼法一个字没改(新槽位的 key 里同样带账号指纹,
两道闸门各管一段);W49 的节流判据、W50 的 ProactiveIngest 判据、W51 的 reconnectPull
判据都没碰,也没有把「连接恢复」做成整表重拉。

测试:新增 `ChatSessionEvictionTest`(14 例,穷举判据的每一条边界:N=3、最旧先走、主会话 /
屏幕上那一格 / 有草稿的豁免、豁免占名额、并列退回账本顺序、结论是带账号指纹的槽位 key)
与 `ChatSessionSlotsTest`(9 例,钉住「没被收的一格还是原来那一格(切回来状态还在)」与
「被收的一格下次拿到的是新的一格」、空槽位会被下一次回收扫掉、探针现读的效果)。

同批把已经说错的注释改成事实:GoneSessions / AppContainer.chatSocket / ChatHomeScreen /
ChatScreen 里那几处「VM 活在 Activity 级 ViewModelStore 里、切走 / 删会话都不回收」。
2026-10-06 15:56:13 +08:00
AskaEth 84f343ce5f feat(sessions): 抽屉里看得见「哪个会话正在生成」,并给一个能停的入口(S5 + S9)
S9 登记的是「这个会话正在生成」既看不见、也没有可查的状态面:`session_busy` /
`session_idle` / `turn_begin` 都带 sessionId 且在广播,但 `SessionsViewModel.onEvent`
一个都不认。S5 要的「能看见 + 能停」正是同一份状态的下游。

看得见(S9)
- 新增 `ui/sessions/SessionBusy.kt`:把下行帧折成「本端已知在跑的会话」账本,判据是纯函数
  `applyBusyFrame`(可单测,先例 `silentRefreshStep`)。点亮只认 `session_busy` 与
  `turn_begin` 这两条活帧——握手不回放该状态,所以没收到过帧的会话一定不显示(宁可没有);
  收尾认 `session_idle`(无条件)、`turn_end`(只收本端认得的那一路,收完一路不剩才灭灯)、
  `session_interrupted`(`activeTurns > 0` 时**不**灭——`scope:"current"` 只停一路,无条件灭
  就是 v2 前端审计记成「会错」的 A7 那条)。
- `SessionsViewModel` 用一路订阅把账本接进来,并在**离开 Connected 时当场作废**:掉线期间
  服务端那边跑完了什么本端收不到,留着就是一盏永远不灭、按钮永远点不动的灯。
- 抽屉那一行:忙碌时摘要行下面多一行「正在思考…」(与聊天页 `busyLabel` 逐字相同)。
  另起一行而不顶掉摘要,也不从 `endActions` / 标题列里分走任何一个 dp。

能停(S5)
- 新的上行动作 `SessionsViewModel.stopGenerating(sessionId)`,走**既有的** interrupt 出口
  (`WsOutbound.Interrupt` 本来就带 sessionId,既有的帧表达得了「停某一个会话」,没有新造帧)。
  恒发 `scope:"current"`:本端从不发 `all` 是一条既有不变式,而 `all` 会静默清空待处理队列
  (那份队列是用户自己敲进去的),本端也没有「丢弃 N 条」的告知面。
- 抽屉行里「停止」与「置顶」共用**同一个 endActions 槽位**:抽屉固定 300dp,`BasicComponent`
  把内部可用宽度的 60% 当 `endActions` 硬上限,现有的三个 40dp 动作已经贴着边,
  再并一个会把图标挤到裁剪。这一轮结束(灯灭)置顶立刻回来。

纪律
- `onEvent` 里只**前置**一次折帧,`when` 的分支一个字节没动 ⇒ W49 的节流静默重拉与
  [persistedRoute] 的两档语义不变;无关帧(流式 delta)原样返回同一张表,不重推状态。
- 新增 18 条单测钉住上述每一条(含「turn_id 认不得就不灭灯」「并行两路都要收」「stream_end
  不灭灯」「停止不发 all」)。
2026-10-06 15:55:04 +08:00
AskaEth aace581fb0 merge: W52C —— 退出登录从小图标按钮改成带文字的危险行条目,图标换 Remove 2026-10-06 15:50:31 +08:00
AskaEth 27116fa434 merge: W52DE —— 降级气泡不再给删改入口(proactive-local: 前缀屏蔽服务端动作);空态补一枚显式「重试」 2026-10-06 15:50:30 +08:00
AskaEth 85818105ce feat(settings): 退出登录从小图标按钮改成带文字的危险行条目,图标换 Remove(W52C)
设置页最底部原来是一枚居中、无文字的图标按钮,图标用的还是「返回」箭头(MiuixIcons.Back),
真机上看着就是返回键——主人为此翻半天才找到登出在哪。这是本次改动的由来。

- 改成 Miuix 行条目(BasicComponent + 外层 PreferenceCard,与同页其它条目同形),文字「退出登录」,
  标题与图标都用 MiuixTheme.colorScheme.error 危险色。
- 图标换成 MiuixIcons.Remove:Miuix 里没有专门的登出/退出字形(Basic 只有 7 个图标),
  extended 里只有它画的是「箭头离开方框」,语义最接近「退出」;Back 是本条要修的毛病,
  Import 方向相反(箭头进方框=导入/登录),Delete/Refresh 之类会被误读成删号或重来。
- 图标 contentDescription 置 null:文字已经把这条读出来了,图标再报一遍只会让读屏重复。

行为一字不变:仍然点了就登出(onLogout 一路接到 CyreneApp 里的 authManager.logout()),
没有新增确认流程。
2026-10-06 15:48:54 +08:00
AskaEth 74a46bd2e3 fix(chat): 降级气泡不再给删改入口(W52/D);空态补一枚显式「重试」(W52/E)
## D —— W50 留下的窄尾巴:服务端不存在的那条气泡没有动作入口

`proactive_message` 落库失败那一档,客户端会画一条**只活在本端内存里**的气泡,它的 id 由本端自铸
(`proactive-local:` 前缀,见 `ProactiveIngest`)。而删除入口的判据 `canDeleteMessage` 只看 role ⇒
它是 assistant 行、长按能删 ⇒ 拿一个服务端没见过的 id 打 `DELETE`,最好的结果是 404。

修法按主人拍的口径收在 **UI 层**:新增 `ui/chat/MessageServerActions.kt`,在角色口径之上再叠一道
「这条消息在服务端存在吗」(判据只有 `id` 前缀这一条),组合成
`canEditMessageOnServer` / `canDeleteMessageOnServer`;气泡(菜单 + 「⋯」两个入口)与 VM 里那道闸
(`startEdit` / `startDelete`)**共用同一份**,不存在两套口径。
- **不动** `ChatDisplayItem`、数据模型与 `mergeMessages` 语义(原方案要加「是否服务端行」标志位,
  会连带动气泡渲染,超出范围)。
- 动作菜单里**只有**编辑与删除两个(一个 PATCH、一个 DELETE),都是打服务端的 ⇒ 降级气泡整菜单不出现,
  正是要的效果。**文本选择 / 复制不受影响**:它走 `SelectionContainer` 那条独立的路,`onLongPress`
  为 null 时反而不再与选字抢长按。

## E —— 首屏失败时给一个显式「重试」

登记在案的缺口:**REST 首屏失败、而 WS 从头到尾没断过**。这时没有任何状态变化可挂(连接没变过、
也没有断线窗口要补),屏幕会停在空态,直到用户切走再回来或上拉。

- `ChatViewModel.retryFirstPage()`:**只是转调** W51 提好的 `loadFirstPage()`(首屏唯一的一份实现,
  与连接恢复时补的那一次共用),**一个状态位都不自己写** —— `firstPageState` 的两个出口仍然只有那一处,
  W51 的 `reconnectPull` 判据依赖这条纪律。刻意**不是**定时器:项目从 W38 起就定死「状态驱动,不按时间轮」,
  而且反复重试会把「首屏失败」这个事实擦成 `Loading`。
- 空态那一档拆成两种真相(纯函数 `shouldOfferRetryOnEmpty`:不在加载中 + 挂着一个错误 + 列表为空):
  「还没有消息」照旧;「消息没能加载出来」配一枚 Miuix `TextButton`(`CenterHint` 多一个可选 `onRetry`)。
  其余时刻一个像素都不多画。

## 测试

- 新增 `MessageServerActionsTest`(6 条):钉住降级气泡两个入口都没有(并断言**旧判据确实说「能删」**,
  把「为什么需要这道闸」写进断言)、前缀与角色无关、只认**开头**的前缀、服务端行行为逐字不变、
  未知 role 仍 fail-closed。
- 新增 `FirstPageRetryTest`(5 条):正题一格成立;正常空会话 / 首屏在飞 / 屏幕上有东西(含乐观与流式气泡)
  都不给;三个布尔八种组合穷举,钉住「有且只有一格成立」。
- `./gradlew.bat --console=plain assembleDebug testDebugUnitTest`:**95 suite / 1216 tests / 0 失败 / 0 error**
  (基线 93 / 1205,+2 suite +11 tests,全为新增)。
- 真机验收未做(任务书禁碰 adb):按钮的位置与观感、以及长按降级气泡能选字,只能真机看。
2026-10-06 15:47:49 +08:00
AskaEth d0b8c5f2f7 docs(05): 写死纪律——不准用 WaitFor 等子代理(会把回合锁住,主人无法插话)
为什么:主人当场打断我的 WaitFor 并明确「纪律是不准用 WaitFor」。这条在后端仓库 AGENTS.md §10 里原本
就有,本项目文档此前没写、我因此重犯了一次。正确做法:派完就结束回合,等待期间只做不冲突的并行工作
或停手。
2026-10-06 15:46:40 +08:00
AskaEth 4fce12249e docs(05): S7② 与 S3 的真机验收结清(S7② 通过;S3 在「在线冷启动进有内容次级会话」场景下否证)
S7②(通过):补洞请求打在已摘除的会话上 ⇒ 404 ⇒ sessionGone;那句 8 秒的告知抓到([33,378][1047,480]);
select(主会话) 的副作用 POST /sessions/web/read 两轮都观测到。⚠️ 同时**修正我自己写的判据**:原先要求
「read 出现在 GET /sessions 之前」——实测顺序不确定(两条协程各起),该顺序判据不成立;真正该验的
「refresh 失败时也能回去」本轮没做。

S3(否证):screenrecord + ffmpeg fps=8 抽帧 + 逐帧量两块区域的深色墨迹(不改生产代码)。时间线是
启动画面(新版图标)→ 「加载中…」 → 内容,**没有出现「还没有消息」空态**。局限:8fps 采样(<125ms
的闪烁会漏)、单场景、单 build,不写成「S3 不存在」。

方法论:别用墨迹的 x 宽度猜那是哪句话——我一开始据此误判,抽帧一看是「加载中…」。
2026-10-06 14:24:45 +08:00
AskaEth 9f04fc4304 docs(05): S7②/S3 第二轮受阻记录 —— 模拟器劣化与主机被占、不依赖 dump 的验证套路、收工状态
为什么:这一轮 S7② 仍未验(进次级会话那一步没成),但花掉的时间都是环境问题而非代码问题,
必须把「先看主机在跑什么」「盲点 + 用服务端流水判结果」这两条写下来,下一轮别再重摸。
2026-10-06 14:16:55 +08:00
AskaEth 0583b1bc31 docs(05): 补记 S7② 为什么这轮没验成 + 下次的干净起手(含判据与两个坑:表单错位、dump 陈旧缓存)
为什么:S7② 需要「App 停在已删会话里」才能造场景,而登录表单被前几轮实验搞乱(地址栏残留 W48 的
坏值、字段错位),input text 落不进目标字段;uiautomator dump 还会返回陈旧缓存(同一交互前后两张
dump 的地址栏展开态互相矛盾)。把判据与干净起手写下来,下一轮别再重摸。
2026-10-06 13:57:53 +08:00
AskaEth b604fefbd4 docs(05): 锚点补记二十九 —— W51 合并、S2/S7① 设备验收结论,并更正补记十九里 S7 那句写错的前提
改了什么:
- 新增锚点补记二十九:W51 做了什么(S2 的纯判据与「首屏从没成功过」这个判据为什么这么定、S7① 的
  GoneSessions、S7② 真正的缺口是「出路不能依赖网络」);设备验收结论(S2 通过:恢复网络后客户端自己
  补拉并显示出消息;S7① 通过:删掉会话后真踢一次 WS,重连窗口里没有注定 404 的补拉,而握手证明通路
  活着);以及 S3 / S7② **未验**(如实写明,不写成「没问题」)。
- 更正补记十九里 S7 那句「`sessionGone` 位没有消费者」:消费者一直都在(ChatScreen.kt:165-166 →
  ChatHomeScreen.kt:109 的 onSessionGone),由 b5c0b13(2026-10-04、该行所依据提交 ff699f5 的祖先)
  引入,已用 git merge-base --is-ancestor 验证。这句是我写错的,W51 的任务书因此也带着错前提。
- 记下这次同类教训(第二次):真机「没生效」时**先核设备上 APK 的 lastUpdateTime 与本地 APK 的时间**
  —— 我就是拿上一版 APK 测了半天;另记「撤 adb reverse 不会掐断已建立连接,要真抖 WS 用假后端的
  /__control/ws-close」。
2026-10-06 09:20:40 +08:00
AskaEth 620a4e77fa merge: W51 —— 次级会话连接恢复自己补拉(S2);已删会话不再发注定 404 的补拉、sessionGone 的出路不再依赖网络(S7) 2026-10-06 09:04:09 +08:00
AskaEth 9980fb2b28 fix(chat): 次级会话补上「连接恢复自己补拉」(S2);已删会话不再发注定 404 的补拉(S7)
## S2 —— 次级会话没有握手 history 这第二条安全网

- 新增纯判据 `ui/chat/ReconnectPull.kt`:连接恢复那一刻该补哪一段
  (Forward / FirstPage / Nothing)。判据只看四样:会话 id、首屏状态、掉线游标、
  「本端已知它没了」。
- `ChatViewModel`:把 init 那次首屏 `loadLatest` 提成 `loadFirstPage()`,init 与补拉
  **共用一份代码**(补出来的结果必须与首屏逐字段同形);新增 `FirstPageState` 作为判据输入。

为什么这么定:
- **为什么不是照抄主会话的 history**:服务端固定回放主会话(`ws_hub_conn.go`),要它回放
  次级会话就是改契约面。次级走的是本项目已有的那条 REST 口径(拉最新一页)。
- **为什么用「首屏从没成功过」而不是「本端有没有数据」**:服务端真的没有消息时列表**也是空的**,
  拿空列表当判据会让每一个空次级会话在每次重连都被整表重拉 —— 那正是 S4/S7 这一类问题的成因。
- **为什么 Loading 不算失败**:首屏还在飞时再发一次同样的请求会与它抢跑,还会改掉
  「history 帧一到就 loading=false」那段时序(S3 本轮**一个字节都不动**)。
- **为什么主会话排除在外**:它有 history 兜底,在那里再补一条 REST 就是给同一件事造第二套口径。
  主会话的兜底行为因此**逐字不变**(有洞走 Forward,其余 Nothing)。
- 补拉那一次把 `loading` 翻回真:屏幕上原本是「还没有消息」停在原地,如实应该是「加载中…」。

## S7 ① —— 注定 404 的补拉不再发生

- 新增 `core/session/GoneSessions.kt`(进程级、**不持久化**、登出/换账号时清空):本端已知
  「服务端没有这个会话」的 id 集合。写入口是 `SessionsViewModel.deleteSession` 成功那一刻
  (本端唯一的删除入口),读入口是**每一个** `ChatViewModel`。
- 判据第 1 条:已知它没了 ⇒ 什么都不发。于是本地删过的会话从此**零次**注定失败的往返;
  已经 404 过的会话从「每次重连各撞一次」收敛成**恰好一次**(`sessionGone` 同时成为补拉的闸)。

## S7 ② —— sessionGone 的出路不再依赖网络

那个位的消费者**一直都在**(`ChatScreen` 按它触发的 `LaunchedEffect` → `ChatHomeScreen` 的
`onSessionGone`:一句如实告知 + 带回主会话),所以本轮补的是它真正的缺口 —— 原来只靠 `refresh`
**成功**之后的 `resolveActiveSession` 回落(拉失败、或列表为空时刻意不回落 ⇒ 什么都不动)。
于是「已切回主会话」在网络不稳 / 服务端抖一下时是一句**做不到的话**,用户被留在已经没了的会话上、
没有任何出路。现在先在本端 `select(主会话)`(与用户点抽屉里那一行走的是**同一条**路,不另造机制),
`refresh` 退回本职:与服务端对齐、把抽屉里那条去掉。

## 未做 / 未验

- **没有真机验收**(任务书禁碰 adb):S2 的「连接恢复后次级会话自己补上」只有纯判据单测。
- 「REST 首屏失败、但 WS 从头到尾没断(因此没有任何状态变化可挂)」这一格**不在本轮范围内**,
  仍是已知缺口。
- 全程未碰 S3、主会话兜底与游标口径、W48 的 VM key、W49 的节流判据、W50 的主动消息消费路径。
2026-10-06 09:03:22 +08:00
AskaEth a9cc3c5737 chore(gitignore): 忽略仓库根的 IMG_*.png(主人从手机丢进来的素材,只用不存)
为什么:主人明确「原图不进仓库」——换图标用的 IMG_20261006_081803.png(5.8MB)留在工作树即可,
加一条只作用于仓库根的忽略规则挡住误提交(只挡 IMG_*.png,不影响别处的图片资源)。
2026-10-06 08:57:27 +08:00
AskaEth a025624798 feat(icon): 应用图标换成主人提供的新版画作(昔涟特写)
改了什么:
- mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}/ic_launcher.png:新画作按 48/72/96/144/192 缩放(lanczos)。
- 同目录 ic_launcher_round.png:同一画作按圆形 alpha 掩膜裁出(ffmpeg geq),供圆形掩膜的启动器。
- 同目录 ic_launcher_foreground.png:自适应图标前景按 108dp 画布全出血(108/162/216/324/432 像素)。
  第一版把画心缩到 76/108,实测在这台启动器上会露出背景一圈;改成铺满画布后任何掩膜下都不会露底。
- values/colors.xml:ic_launcher_background #F8F8F8 → #FFE6E0(用 ffmpeg 对源图左上角 300×300 取平均色得到,
  与画作底色同调;前景已铺满,这个底色只在极端掩膜下才可能露出)。

为什么:主人把新版图标源图放在仓库根(IMG_20261006_081803.png,5033×5033、5.8MB),要求替换应用图标。
**源图不进仓**(仍是未跟踪状态),只提交生成物;构建与安装均通过,实机底栏图标已是新版。
2026-10-06 08:30:53 +08:00
AskaEth 1d150cce85 docs(02): 客户端答复 —— 回执二十四·补:①推迟照办、②未读口径确认(维持 omitempty)、两条澄清,并反问 .end 能否带结果摘要
改了什么(只加了我们这一节,未碰他们那一节):
- ① 工具回看面推迟:照办。明确写死两条口径——不把「toolCalls[] 缺席」当「这轮没调工具」;
  不再按消息字面 join turnId(两者不是同一套标识)。也说明我们**不提前写**那个解析器。
- ② 未读面:回答他们的问题——**维持 omitempty 即可**,因为对我们权威来源是列表面(GET /sessions 恒带
  unreadCount),增量键只是省一次重拉的优化;接入口径按他们说的「键在才改、缺席什么都不做」。
  同时如实说明本端**还没消费**这个键(今天走的是 W49 的重拉,已实机验收),免得他们误以为已生效。
- 两条澄清:GET /api/v1/messages/{id} 我们从未用过(只有 PATCH/DELETE,SessionsApi.kt:130,137);
  tool_execution (a) 维持收到。
- 反问一件与回看面无关的事:能否给 tool_execution.end 加可选 resultSummary(截断、缺席语义)——
  因为 .end 只带 tool/phase/id、update 又没有生产者,实时工具卡的结果区结构性为空;这是投入最小的一条。
  明确说「不做也接受,但请明说」,我们要的是确定而不是一定要这个键。
- 告知 W50/W48/W49 已落地并实机验收(不需要他们动作)。

为什么:他们那条回执标红了「别把缺席当没调过」,并直接问我们要不要区分「确实 0 / 不知道」——
这两个都是会决定他们改不改代码的问题,必须当场答清。
2026-10-06 08:24:50 +08:00
AskaEth b23948bed0 docs(05/07): 锚点补记二十八 —— W48/W49 设备验收通过(换环境串味 / 抽屉未读角标)
改了什么:
- 05 新增锚点补记二十八:W48 的判别法(先清空服务端主会话再换账号,否则两账号同一份历史看不出串味)与结果
  (A 的本地气泡与持久行在 B 账号下都 0 次、抽屉残留 0 次);W49 的机制证据(抽屉关着时客户端自己在
  消息到达后 60ms 发出 GET /api/v1/sessions)与可见结果(次级会话行 未读 1、正在看的主会话无角标);
  并更正一处认知:红点是抽屉内会话行的未读角标,菜单图标上没有点。
- 07 的坑清单补两条:① 登录页地址栏留空时 App 会回落到上次记住的地址(我因此白追一小时「请求怎么打到
  真后端」);②「登出」是设置页最底部的图标按钮(图标是 MiuixIcons.Back、只有 contentDescription),
  截图上像「返回」很难找。

为什么:这批验收原来只有单测覆盖,结清后 05 的待办里不再挂着它;两条坑都是这次真实花掉时间的,
写下来避免下次重演。
2026-10-06 08:24:00 +08:00
AskaEth c905a47d6f docs(05/07): 锚点补记二十七 —— W50 落地并设备验收通过;细节单开 07(证据与避坑)
改了什么:
- 05 新增锚点补记二十七:结论(afee5f6 已推 / 1193 测试真跑 0 失败 / APK 尺寸)、四条硬要求逐条
  对应实现处、红线核对、设备四条判据全绿、环境坑一句话版、真后端那条主动路径是活的(副产品)、
  待办变化(W48/W49 验收环境已就绪;W50 的窄尾巴)。
- 新增 devdocs/07-W50-主动消息-验收与避坑.md:验收判据与逐条证据、完整环境坑(含绕法与命令)、
  复现整套验收的最短路径、随批次修掉的 timestamp 解码缺陷、以及「落库失败档」那条尾巴的收口建议。

为什么单开 07:05 已 1400+ 行,验收的操作细节与避坑属参考材料而非状态;05 只留结论与指针,
下一个我读 05 就能接手,需要动手复现时再翻 07。
2026-10-06 08:11:26 +08:00
AskaEth afee5f612d merge: W50 —— 她主动说的话实时进主会话(proactive_message 消费 + 帧解码修复 timestamp 类型不符) 2026-10-06 07:51:26 +08:00
AskaEth 8b5d15bdb6 fix(chat): 她主动说的话实时进主会话(W50 proactive_message 消费 + 帧解码修复)
缺口:她主动说的话既是实时帧、也是主会话历史里的一条 assistant 行,而安卓端实时看不到 ——
ChatViewModel.onEvent 里没有 ProactiveMessage 分支(落到 else -> Unit),另两个下行消费者也不认它;
而且帧里只解了 messageId(IM 侧的号),落库行的 id / createdAt 被 ignoreUnknownKeys 静默丢弃。
后端这条 Web 路由只发这一帧、不补 message_persisted,所以没有别的路能把它带进来。

一、帧解码(data/ws/WsEvent.kt)
- ProactiveData 与 ProactiveMessage 补上 id 与 createdAt(落库行的权威 id 与时间戳,同现同省)。
- 顺带修掉一个把整帧弄没的缺陷:载荷里的 timestamp 服务端是 Go time.Time,线上是 RFC3339 字符串,
  而本端按 Long? 解 ⇒ 解码抛异常、被 parse 的 runCatching 吞成 null,帧在成形之前就没了
  (所以只加一条 onEvent 分支根本碰不到它)。改成按线上的字面量收,数字形状也照样读出。

二、为什么按已落库行并入,而不是画一个只活在 UI 的气泡
真相是服务端历史里的那一行,实时帧只是它早到一步的副本:按 HistoryMessageDto.id 走既有的
mergeMessages 并入 ⇒ 实时能看到,且刷新 / 重开 / 切会话拉历史之后她的话仍在;同一条
(实时帧 + 握手回放 + 分页)无论到几次都只有一行,不需要任何额外去重。
判据抽成纯函数 proactiveIngestFor(ui/chat/ProactiveIngest.kt),可单测。

三、为什么必须自己钉死主会话
proactive_message 帧壳不带 sessionId(服务端构造这一帧时没设它,且该键是 omitempty),
而 isForSession 对空 sessionId 的口径是放行 ⇒ 这一帧会投给每一个活着的 ChatViewModel。
不钉就会在每个次级会话里都冒出一条她的话(比看不到更糟)。判据是等值比较、不是前缀:
主会话 web 与次级会话 web:xxx 是两个会话。

四、无 id 时为什么退化而不是丢弃
id 与 createdAt 一起缺席 = 服务端那一步落库失败了(它自己就说了这条没有历史行)。这时画一条
本地气泡,如实显示、不假装已落库:本地 id 用帧里已有的稳定键(IM 消息号,其次时间字面量),
同一个键第二次到达(重推 / 重放)也只是 upsert,不会成两个气泡。KDoc 写明它不在历史里、
刷新 / 重开后不会再有。
无 id 且无正文(帧里没有任何可显示的东西)才整帧跳过 —— 那一条画的是空气泡,只会让人以为出了别的事。

五、抽屉红点(复用 W49 那条路,不新写取数路径)
服务端不为这条路补 message_persisted ⇒ W49 的「非激活会话落新消息 ⇒ 节流静默重拉」不会被触发,
主会话的红点会停在她开口之前。SessionsViewModel 用同一个 persistedRoute 把这一帧接进同一条路
(复用 requestSilentRefresh 与既有的 InactiveSessionMessage 成因,不动节流判据本身):
停在别的会话 ⇒ 节流静默重拉一次(红点亮);正看着主会话 ⇒ 与 message_persisted 同一档
「看到即已读」,免得抽屉为她刚说的话亮一个红点。无 id 那一档不拉(历史里没有行,未读面上无事可做)。

测试
- 新增 WsEventProactiveFrameTest:真实帧形状(id / createdAt / RFC3339 的 timestamp)、数字形状容忍、
  落库失败那一档、以及「帧壳没有 sessionId ⇒ 会话闸对每个会话的 VM 都放行」这条事实。
- 新增 ProactiveIngestTest:归属(次级会话不并入、id 按等值比)、身份(有 id = 已落库行)、
  退化(本地气泡 / 空帧跳过)、不重复(重发与历史页都收敛成一行)。
- InactiveSessionPullTest 补一条钉子:她主动说的话该走拉列表还是「看到即已读」这条路。
2026-10-06 07:50:33 +08:00
AskaEth 5520d6015d docs(05): 更正代理旧路径引用(工具已在 devtools/ws-block-proxy/,历史条目不改写) 2026-10-06 07:45:31 +08:00
AskaEth 4c938c7330 docs(devtools): 假后端补「主动消息」验收配方(proactive_inject.py + README 场景五)
改了什么:
- 新增 devtools/fake-backend/proactive_inject.py:按真后端 SendProactive 的顺序
  (先落库、再发帧)造一条主动消息 —— 落一行 assistant 历史行,再广播带同一 id/createdAt 的
  proactive_message 帧。--without-id 是另一档(落库失败):不落行、帧上不带 id。
- README 新增「场景五」:完整命令序列 + 帧形状 + App 侧三条判据;端点速查/形状出处/已知不确定
  各补一条。
- 更正两处过时内容:§1 的工作目录还是旧 worktree 名 Cyrene-Android-FB;§8 说「客户端 W41 起先探
  /health」——W45 已把那整套删掉(SendFallback.kt:120),这个端点客户端不再消费。
- 记录踩到的坑:ws_probe.py --listen N 单独用只有 1 秒(ws_probe.py:201 是 min(listen, 1.0)),
  不带 --send/--ping 时连接会先关掉,之后所有广播都是 delivered=0。

为什么:
- 真后端没有任何 HTTP 入口能按需触发一次主动推送(backthink 只由心跳/事件决定,路由里只有一个
  GET /admin/backthink),所以「她主动说的话」在客户端这一段的验收只能自己造帧。真后端只把
  proactive_message 广播给在线连接、没有离线补发,帧壳又不带 sessionId(客户端必须自己钉主会话)
  —— 形状抄错或漏掉落库那条腿,验出来的结论就是假的。落成脚本 + 文档,下次不必重推。
2026-10-06 07:44:31 +08:00
AskaEth d9b7c22ecb docs(05): 锚点补记二十六——续接锚点(现状/环境/设备驱动协议/W50 在飞与合并检查清单/W38~W49 索引/待办排序/与后端状态/方法论教训),供下一会话直接接手 2026-10-06 07:38:29 +08:00
AskaEth d5d2feee36 docs(05): 锚点补记二十五——W48(账号指纹进 VM key 修 S1 + 搜索 VM 同类缺陷 + 5 处错注释 + 代理挪进 devtools)与 W49(非激活会话节流静默重拉修 S6,3 秒窗口 + 结构性 trailing + 一处刻意偏差)落地;合并后 1177 测试全绿 2026-10-05 22:30:08 +08:00
AskaEth 5dc6408bb9 merge: W49 —— 非激活会话落新消息时节流静默重拉(3 秒窗口 + 结构性 trailing),让抽屉红点在不开抽屉时也能出现 2026-10-05 22:28:39 +08:00
AskaEth 93cf3e359a fix(sessions): 非激活会话落新消息时自愈式节流重拉,抽屉红点不再等用户开一次抽屉(S6)
现象:App 停在主会话时,次级会话落了新消息 ⇒ 抽屉红点不出现,必须打开一次抽屉
(那次静默 refresh)才补上。

根因:message_persisted 只在 sessionId == activeSessionId 时才推进 / 更新,非激活
会话那一帧直接 return;而会话列表更新的触发点此前只有抽屉打开 / 新建 / 删除 / init
四处,于是「别的会话攒了新消息」没有任何人会去发现。

为什么是客户端自愈而不是等后端:这一帧不带 unreadCount(契约面缺一个增量),而本端
没有逐条时间线 —— 自己累加出来的数会与编辑 / 删除 / 别端操作漂移,那是造数不是修复。
帧壳本身已经说清「某个会话有新消息落库」这件事,所以本端只是据此去问一次服务端,
走的是与抽屉打开时那次完全相同的路径(refresh(silent = true),含已读游标遮罩),
只是不需要用户先开一次抽屉。后端把增量补上之后这条路可以退成不必走,但在此之前它
是唯一能把红点点亮的路。

节流窗口 3 秒,理由:
- 够快:3 秒落在「看起来就是刚发生的」这个感知里;5 秒往上就开始像卡了一下才补上;
- 够粗:一次静默重拉是两个请求(refresh 里还跟着一次角色目录),而播出方是按每条
  消息推帧的 —— 3 秒能把人手连打 5 条(约 2~4 秒)或一轮回复的连续落库收进同一个
  窗口,收到 1 秒就会漏成两三次请求;
- 不另立尺子:3 秒正是本项目已有的「等够久才值得为一次等待开口」那个量级
  (ChatScreen 的 SHARE_WAIT_NOTICE_MILLIS)。

trailing 怎么保证:窗口内的第二条起不拉,但排一次「窗口到点补拉」(等待时长 =
窗口 - 已过时间),到点直接拉一次并把窗口重开;同一时刻最多一个补拉任务(已排上时
后续成因只更新账本、不重排),所以最后一条之后必定还会再拉一次,红点不会停在旧值。
另外「正好等于窗口」算窗口已过(>= 而不是 >),补拉到点那一刻不会被自己刚开的窗口
挡回去。

哪几种情况不拉:当前打开的那个会话落新消息(那条路本来就有「看到即已读」,多拉一次
列表就是白花两个请求);同一窗口内的后续消息;窗口内且补拉已经排上。另外切会话那条
路刻意不接进来 —— select 紧接着就发推进已读游标的请求,此时重拉会与还没落地的游标
抢跑、把刚清掉的角标闪回来(抽屉打开时那一次紧跟在游标之后,本来就会补上这一拉)。

判据抽成纯函数(新增 InactiveSessionPull.kt):persistedRoute 决定这一帧走哪条路,
silentRefreshStep 决定这一刻该不该拉、拉完账本怎么走;时钟由调用方喂,不用 sleep
也就能穷举边界。

顺带把身份变化(重登成另一个账号 / 换了服务器地址)也接进这条路径,用 ContextChanged
绕过节流窗口 —— 那份列表换了主人,等满一个为「别的会话又攒了几条」而设的窗口没有
道理;登出时把账本与补拉任务一起作废(否则补拉会带着一个已经没有令牌的身份发请求)。
抽屉打开时那次 refresh、activeSessionId 的推进、已读游标的既有行为都没动。

验证:gradlew.bat assembleDebug testDebugUnitTest —— BUILD SUCCESSFUL;
1171 条单测 failures 0 / errors 0 / skipped 0,其中新增 InactiveSessionPullTest 13 条。
2026-10-05 22:28:19 +08:00
AskaEth e589ab303d merge: W48 —— 聊天页 VM key 带上账号指纹(修换环境后仍显示上个环境的会话)+ 注释纠错 + 把 WS 拦截代理挪进 devtools 2026-10-05 22:26:44 +08:00
AskaEth 40dac87dda docs(05): 补记二十四附——本轮两个决定(P1 主动消息消费我方接、tool_execution 选 a 并要 toolCalls[] 回看面带输出)与推送暂不接的口径 2026-10-05 22:26:09 +08:00
AskaEth 766843fdbd docs(02): 客户端答复回执二十二·补的四问——主动消息「不会两次」但真缺口是「我们没消费那帧 ⇒ 实时看不到」;tool_execution 选 (a) 并附取舍;纠正「每屏一条 WS」过时前提;推送暂不接并说明「应用内提示」在客户端零实现 2026-10-05 22:25:59 +08:00
AskaEth 4f860aa499 fix(chat): W48 聊天页 VM key 带上账号指纹,修换环境后仍显示上个环境的会话
缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 ChatScreen 里那个
ChatViewModel 的 owner 是 Activity 级的 ViewModelStore,而登出只是把 ChatHomeScreen
移出组合、并不清理那个 store(ChatViewModel.onCleared 是空的,全仓也没有 ViewModelStore.clear())。
key 里只有 sessionId 时,换环境再进来 viewModel() 会把上一个环境那个还活着的实例原样取回来,
它继续收着那个环境的帧,屏幕上就是别的服务器的消息。主会话的 id 恒为 web,两次登录必然撞上
同一个 key,所以这不是理论风险。

为什么 VM key 要带账号指纹:指纹回答的是「这份本地状态属于哪个环境」。把它抽成纯函数
accountFingerprint(baseUrl, username),与 SessionPreferences 判断快照作废、
SessionsViewModel 落盘快照用的是同一份口径(SessionsViewModel 原本就地拼的那串改成调用
同一个函数,免得两套判据分叉——分叉的那一侧就是漏出去的那一侧)。同一个环境里切会话,
key 未变,各会话的状态照旧各留一份(这正是「切回来状态还在」依赖的);环境一变(换了服务器
或换了账号),key 就变,取到的一定是新实例。

会话内搜索那个小 VM 是同一类残留:它持有的命中就是消息正文,换环境后在主会话上重开搜索,
面板里就是别的服务器的消息。它的 key 同样带上指纹。

为什么本轮不回收旧 VM:要真正回收掉上一个环境那个实例,得自定义 ViewModelStoreOwner 或直接
清 store,而「切回来状态还在」这条体验正绑在 store 不清这件事上,属于要主人拍板的设计取舍。
本轮只保证换环境后取到的是新 VM,回收这件事不动。

顺带的零成本纠错(只改注释的成因描述,行为一字未动):ChatHomeScreen / ChatScreen /
ChatViewModel 里几处「onSessionGone 一被调用,那个会话的 VM 连同它的状态立刻被销毁」是错的——
变的是 key,被销毁的只是承载横幅的那段组合;VM 实例仍在 Activity 级的 store 里活着并继续收帧。
「横幅必须挂在上一层」那条结论本身是对的,原样保留;改的只是它前面的理由。
同时把 devtools 里那个窄代理的路径写对(此前注释指向一个并不存在的路径)。

开发工具挪出会被 clean 扫掉的位置:app/build/e2e-shots/ws_block_proxy.py 躺在 gitignored 的
app/build/ 里,一次 gradlew clean 就没了。挪进 devtools/ws-block-proxy/(与 fake-backend
同级同风格)并配 README,写清用途、用法(python ws_block_proxy.py 18098 10942,配
adb reverse tcp:10942 tcp:18098)与它最有价值的判据:调试时 Web 前端直连后端、不经过
adb reverse,所以代理日志里的每一行都只可能来自 App。脚本行为未改,也没有硬编码任何凭据。

新增单测 ChatViewModelKeyTest 钉住拼法:同账号同地址 => 同 key;换地址 / 换账号 / 换会话
=> 不同 key;username 缺失时退化成 baseUrl 一段,换地址仍然分得开。

验证:gradlew.bat assembleDebug 与 gradlew.bat testDebugUnitTest,
BUILD SUCCESSFUL;单测 1164 条,failures 0、errors 0、skipped 0。
2026-10-05 22:25:57 +08:00
AskaEth a4ac11a1c1 docs(05): 锚点补记二十四——流程教训:动 02 之前除 git status 还要 diff 它最新几十行(我差点在没读后端回执的情况下给后端写信);并登记后端正在等我们回的四件事与「每屏一条 WS」这个过时前提 2026-10-05 22:23:44 +08:00
AskaEth 7d1c56e03b docs(02): 给后端一份「我方实际依赖的线形状」清单(clientMsgId 幂等是我们敢重发的唯一安全基础、REST stream 回执必须仍是小 JSON、鉴权失败只认 401…)+ 两条已登记缺口的提醒 + 告知客户端已不再调 /health + 提供 CyreneRoute 日志作为联调取证 2026-10-05 22:22:58 +08:00
AskaEth c09c40388b docs(05): 锚点补记二十三——登记在飞的两个 worktree(W48 修 S1+注释纠错+工具归位;W49 修 S6 客户端自愈)与合并纪律 2026-10-05 22:21:49 +08:00
AskaEth 324be57c7a docs(02): 客户端登记——「非激活会话红点不实时增长」缺一个未读增量面(message_persisted 不带 unreadCount),并说明我方先用客户端自愈顶着、它不能替代增量面 2026-10-05 22:21:48 +08:00
AskaEth df8df67742 docs(05): 锚点补记二十二——③ 结案:阶梯机制正常(两次有效观测 route=Rest 且落库),W45 那轮异常未复现也因当时无 CyreneRoute 日志而无法定论;并写死一套可靠的设备驱动协议(确认前台→确认文字落点→从 dump 取发送键坐标) 2026-10-05 22:12:16 +08:00
AskaEth f2075c1f49 docs(05): 锚点补记二十一——移除键补验有效(附我复用旧坐标点空的教训与布局回流说明);③ 的第一次尝试没跑成(CyreneRoute 为空、代理无 POST、库无该条 ⇒ 发送没触发),并写明下一轮必须三步确认的做法 2026-10-05 22:10:00 +08:00
AskaEth df20a21fb5 docs(05): 锚点补记二十——W42 图片多选设备验收通过(多选器是真多选、计数「添加(2 项)」正确、输入栏出现两枚带移除键的缩略图),并登记移除键位置这处观感待主人定 + 两个未验的邻近格 2026-10-05 22:05:21 +08:00
AskaEth 669d05b781 docs(05): 锚点补记十九——子会话流程变成清单(S1~S9,每条带文件:行号与归类),先纠正我自己两条错前提,附 12 步设备验收骨架、优先级建议与 ws_block_proxy.py 不在 git 里的提醒 2026-10-05 22:01:20 +08:00
AskaEth ff699f5079 docs(05): 锚点补记十八——W47 静默按事故复位(清零点收进一个纯判据、事故=队列空到空),含为什么不选「只在成功时清零」与三条验收体验 2026-10-05 20:25:58 +08:00
AskaEth 3a07e94180 merge: W47 —— 队列静默改为按事故复位(队列排空即清零),不再只在补发成功时清零 2026-10-05 20:25:23 +08:00
AskaEth ce50bd2c24 feat(chat): 静默告知改为按事故复位,队列排空即清零
W43 的静默规则只在「补发真的成功一次」时把 queuedResendFailures 清零。若一段坏网络的
队列以回滚或被收编收场(一次都没成功发出去),这一格就永远挂着 ⇒ 之后连接恢复、再断
一次、用户又发消息时,那句解释再也说不出口。那不是减少噪音,是把解释永久关掉(主人
2026-10-06 裁决:哪个体验好用哪个)。

改成按事故复位:一个「事故」= 队列从空到非空、再回到空,结束就复位,不管它是怎么结束
的——发出去了、回滚了(内容退回输入栏)、还是被收编了(气泡换成权威消息)。清零点因此
有两个:补发成功一次(原样保留),以及队列排空(既有钩子 forgetQueuedNoticeIfDrained,
回滚那条路也走这里,它是队列清空的唯一两个入口之一)。

这样不会在长期断网时刷屏:阈值仍是 3;「一批从空到非空只说一次」仍由 queuedNoticeTold
管着;纯网络故障下消息会堆积在队列里 ⇒ 队列不清空 ⇒ 不算新事故、计数不复位 ⇒ 不会每条
消息都弹一次横幅。而事故一旦结束,下一次断线又能重新解释。

判据提为纯函数 queuedResendFailuresAfterDelivery(VM 与测试共用,跟
queuedSendNoticeAllowed 同一个写法),单测钉住:队列排空即复位(哪怕这一批一条都没成功
发出去)、队列还有货则计数保留、补发成功照旧清零、一个事故里失败 0/1/2 次仍允许告知而
到阈值静默、下一个事故重新告知。静默不动队列:那条既有用例(判定那一刻队列仍在、下一步
仍是发这条)一字未改。

只动告知判据与它的记账。队列与出队逻辑、sendRouteFor / 三档路由 / W38 分享落地 / W45
直接试 REST / W46 的 CyreneRoute 日志全部未碰。

验证:gradlew.bat clean assembleDebug 与 gradlew.bat testDebugUnitTest,
failure / error 均为 0。
2026-10-05 20:25:00 +08:00
AskaEth 798c6ee907 docs(05): 锚点补记十七——W45 阶梯设备验证通过(route=Rest、代理见 200、落库 1 条),附我连续三次误判的共同点与三条设备验证纪律,以及工具卡缺口的根因与登记位置 2026-10-05 19:53:16 +08:00
AskaEth 16699c4958 merge: W46 —— 发送路由判据带上原因,并加一行 CyreneRoute 诊断日志(DEBUG) 2026-10-05 19:50:59 +08:00
AskaEth d546f31755 feat(W46): 发送路由带上原因,并加一行 CyreneRoute 诊断日志
为什么要把「原因」也带出来
- 结果只有 Ws / Rest / Queue 三档,而「为什么是这一档」在设备上完全看不见。W45 那轮真机回归
  就卡在这里:那条消息进了补发队列、网络侧一条请求都没有,而「零请求 + 进队列」在判据里只可能
  来自 Queue 那一档;可顶栏同时显示「重连中…」。两者都只是结论,事后无法分辨「判据读到
  Offline」与「判据读了别的值」。
- 于是把判据抽成 sendRouteDecisionFor(wsDispatched, connection) -> (route, reason):reason 就是
  原来那三条规则各自的分支标识(FrameDispatched / ConnectionOffline / ConnectionNotReady)。
  sendRouteFor 现在读它的 route,所以两处不可能分叉。判据、优先级、行为逐字未改。

那一行日志(tag = CyreneRoute,Log.d,只在 DEBUG 构建打)
- 打点:ChatViewModel.sendThroughLadder 一进入就一笔,覆盖阶梯的三个分支(帧交出去 / 试 REST /
  直接进队列),一行 key=value、空格分隔:
  wsDispatched=... socket=<ChatSocket.state.value,权威值> mirror=<顶栏读的那个镜像>
  wsObject=<present|absent> route=... reason=... outcome=... fromQueue=...
- socket 与 mirror 必须同时打出来:mirror 是 collect 过来的一格,只会滞后、不会超前,两者不一致
  就说明顶栏那一刻显示的是上一格 —— 这正是「顶栏重连中…」与「这条进了队列」能同时出现的形态。
- 这是开发期诊断仪,不是产品行为:字段增删不构成兼容问题、不为它写测试(要钉的是纯函数那张全表)。
  不打 token / 请求体 / 正文 / localId。

顺带(同一轮的可观测性)
- ChatSocket 新增只读属性 hasLiveSocket(webSocket != null),只喂日志,不是产品逻辑的输入。
- 复核 WsState.Offline 的写点:ChatSocket 里只有两处(runConnectionLoop 的 ConfigMissing,以及
  endWithAuthFailure),close() 写的是 Idle;非 401 的握手失败(代理回 503)走 Closed ->
  Reconnecting,进不了这一档。

测试
- SendFallbackTest 新增两例:五档连接状态 × wsDispatched 真假的全表逐个钉住结果 + 原因,并钉住
  sendRouteFor 与判定同值;另一个钉住三个 reason 都可达、没有第四种组合。全量 1155 例,
  0 失败 0 错误。
2026-10-05 19:50:28 +08:00
AskaEth b8399b5949 docs(02): 客户端登记——工具调用记录在回合结束后消失,根因是落库消息没有工具字段(数据面缺失),并给出要补的四点与影响面
排查证据:助手行落库时客户端会为流式思维链做「打捞 + 滞留」,而工具没有字段可打捞、连同流式路一起被丢弃;后端其实已把工具调用落进 flow_graphs,缺的是取出来给聊天端。
2026-10-05 19:44:53 +08:00
AskaEth b18d864145 docs(05): 锚点补记十六——W45 把探测换成直接试 REST(判据/两个超时/被证伪的那条担心/删除与测试承接),并把本文件里所有关于探测的描述显式标为作废 2026-10-05 18:34:56 +08:00
AskaEth 1d548ddcda merge: W45 —— 发送阶梯改成直接试 REST,删掉 W41 的连通性探测(少一个端点、一套超时预算与一个失败面) 2026-10-05 18:34:15 +08:00
AskaEth ec8d54f3a5 feat(W45): 发送阶梯改成直接试 REST,删掉 W41 的连通性探测
为什么换成直接尝试
- W41 的阶梯是「WS 未就绪 ⇒ 先探 GET /api/v1/health ⇒ 通才走 REST」。
- 真机回归(devtools/ws_block_proxy.py 只掐 /ws/chat,HTTP 全放行)发现消息进了队列而不是回落
  REST;而 ProbeClientTest 证明那条链在 JVM 上是通的,Unreachable 生在连接之前,三种成因
  (建 client 抛 / 连上但一个字没写 / ConnectException)在外同形,那一轮无法定论。
- 结论:不该有探测。探测只是「能不能连上」的替身,而真正的 REST 请求本身就是最直接的检验
  ——试成了消息还顺手发出去了;试败了进队列(内容一个字节不丢,重发仍是同一个 clientMsgId)。
  探测唯一省下的是「完全无网时少发一个注定失败的请求」,代价却是一个独立 client、一套独立超时
  预算和一整套新的失败面。那个探针 client 还不复用连接池,每次都要现做一次冷握手,预算比主链路
  紧 4 倍(2.5 秒整通 vs 10 秒连接)——今天这个 bug 的形态就长在这上面。

改了什么
- 路由:wsDispatched ⇒ Ws(成功路径逐字不变);Offline ⇒ Queue;其余三档未就绪 ⇒ 直接试 REST。
- 直接试 REST 走既有的 REST 发送路径与请求体(同一份 chatRequest / 同一个 clientMsgId):服务端
  收下(200 stream:true / 202 queued / 200 duplicate)⇒ 完成、不进队列;网络类失败与没等到裁决
  ⇒ 进队列;服务端给过裁决(409 / 500 / 503 / 400 与 401)⇒ 回滚 + 既有文案,口径不变。
- 判据纯函数化:sendRouteFor 去掉探测入参;新增 restAttemptLanding(Result<ChatAck>? ⇒ 四档落点);
  ChatAck.isAccepted 从 ChatViewModel 的私有方法搬到判据文件。
- 删掉探测那一套:ConnectivityRepository / HealthApi / HealthDto / ApiClientFactory.createProbe /
  PROBE_TIMEOUT_MILLIS / Reachability / reachabilityOf / ChatViewModel.probeReachability /
  AppContainer 的接线(删前逐个确认过没有别的调用方)。
- 超时:一个 client、两个超时。client 上的连接超时 10 秒改成 2.5 秒;回落那一次投递的整通上限仍然
  是 10 秒(REST_FALLBACK_TIMEOUT_MILLIS 一个数都没动)。
- 测试:SendFallbackTest 按新事实重写;ProbeClientTest 删掉,其中仍然成立的事实搬到新的
  RestAttemptClientTest(真 OkHttp + 真 Retrofit + JDK HttpServer)。

为什么是这两个超时值
- 连接超时 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级,2.5 秒是「人开始觉得卡」之前的
  量级,也与 W41 那 2.5 秒的探针预算对齐 ⇒ 换成直接试 REST 之后「无网时用户最多等多久」没有变长
  (完全无网时 ≤ ~2.5 秒进队列)。绝不许回到 10 秒:那会让每一条离线消息的用户都白等十倍的时间。
- 整通上限 10 秒:它管的是另一档——「连上了但一直不回话」(半死的代理、接住连接却不回应的
  服务端),到点也按网络类失败进队列。它同时是用户干等时间的上界,不许拉长。
- 整通上限放在调用点而不是 client 的 callTimeout:这个 client 是共用的,长文本 / 内联图片那两条
  本来就该走 REST 的路用的是同一个,client 级 callTimeout 会把它们一并框住,等于给一条本轮不打算
  碰的路引入新失败面。

为什么 Offline 仍然不试(保留 W41 的决定)
- 该档的语义是「握手 401 且续期也失败」(终态、不自动重连),会话已知是死的:试 REST 只会拿到
  401 并落到「回滚 + 一句误导人的鉴权错误」,把「已排队不会丢」换成更差的结果。W41 的理由
  (不鉴权的健康端点必然回 200 ⇒ 假通)随探测一起消失了,但结论换到直接试这边一字不用改。

验证
- gradlew.bat assembleDebug:BUILD SUCCESSFUL
- gradlew.bat testDebugUnitTest:1153 个用例,failures=0,errors=0
  (SendFallbackTest 14 例、RestAttemptClientTest 6 例全绿)

未做
- 没有 push。devdocs/05 的 W41 / W44 段落仍写着探测(那份由主会话维护,本轮只在回复里点出过时
  的句子,没有改它)。
2026-10-05 18:33:32 +08:00
AskaEth 2bee0b52b0 merge: W44 —— 判定 W41 缺口:A(503 判成 Offline)已否;B 只到形态,那个 Unreachable 生在连接之前,需一次设备取证(含 ProbeClientTest 与 createProbe 的 DEBUG 日志) 2026-10-05 15:50:19 +08:00
AskaEth 06028cc352 test(probe)+docs: W44 判定 W41 缺口——A(把 503 判成 Offline)已否;B 只到形态(那个 Unreachable 生在连接之前,三种成因在外面同形,需一次设备取证)
判定 A:否。ChatSocket.isAuthFailure 只认 HTTP 401(ChatSocket.kt:320-323),非 101 的握手响应
(代理回 503)走 SessionEnd.Closed ⇒ Reconnecting 退避重连(ChatSocket.kt:301-304 / 230-235);
而顶栏「重连中…」正是 Reconnecting 的文案(ChatScreen.kt:918,Offline 那档是「离线」)。
⇒ 那一刻 socket 真值就是 Reconnecting,sendRouteFor 在这一档会看探测结论,镜像也没滞后。

判定 B:形态成立,但「这条链根本发不出请求」不成立。
- 调用点是无条件求值的实参(ChatViewModel.kt:1614)⇒ Queued 这一档必然调用探测,
  连 Offline 那档也会(sendRouteFor 只是把结论丢掉,与本轮前的 KDoc 说法相反,已改)。
- 新增 ProbeClientTest(6 例,真 OkHttp + 真 Retrofit + JDK HttpServer):这条链能在真 socket 上
  留下 GET /api/v1/health、不带 Authorization、200 ⇒ Reachable、503 ⇒ Unreachable、
  2xx 但回执解析不出来 ⇒ Unreachable、黑洞服务端在 2.5 秒整通上限处失败
  ⇒「探测压根发不出去」不是代码事实。
- 真机代理日志里一条 health 都没有 ⇒ 那个 Unreachable 生在连接之前。三种成因在外面完全同形:
  (甲) 建 client 就抛(baseUrl 空/非法,Retrofit.baseUrl 直接抛);
  (乙) 连上了但一个字都没写(callTimeout 是整通上限含连接,而探测不复用连接池 ⇒ 每次冷握手,
       预算只有 2.5 秒,比共用客户端的 10 秒连接预算紧 4 倍);
  (丙) 请求出去了、回执非 2xx(只有这一档在对端留痕)。
  ⇒「代理日志没有 health 那一行」推不出「App 没探过」;(甲)(乙) 都不留痕。

本轮改动(除一处 DEBUG 日志外全是文档与测试,无行为变化):
- 新增 app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt(6 例)。
- ApiClientFactory.createProbe:DEBUG 下挂 BASIC 日志(与通用 create 同一约定)。这是本轮要的
  诊断仪——--> 打在连接之前,让「到底有没有探」与「死在连接还是死在回执」当场可分;探测不带令牌、
  没有请求体,日志不涉凭据。
- KDoc 修正「连探测都不做」这处失真(SendFallback 的 sendRouteFor、ChatViewModel 的调用点与
  probeReachability),把「三种成因 + logcat 判据」写进 ConnectivityRepository 类注释,
  并给 WsState.Offline 补上「非 401 的握手失败不属于这一档」。
- sendRouteFor 的 fail-closed、探测的 2.5 秒上限、三档路由一个字都没改;下一步要设备取证,
  步骤写在 devdocs/05 的 W44 一节。

验证:assembleDebug 成功;testDebugUnitTest 1156 例 / 0 失败 / 0 错误(新增 6 例全绿)。
2026-10-05 15:49:48 +08:00
AskaEth 866a3c37cd docs(05): 补记十五·补——W41 回归重跑成功并找到一个真缺口:WS 断/HTTP 通时消息进了队列而不是回落 REST,且探测请求根本没发出(含 dump 决定性判据与下一步该查的位置) 2026-10-05 15:29:54 +08:00
AskaEth 4460ca9277 docs(05): 锚点补记十五——W43(中性文案逐字 + 重试静默的阈值/记账/清零口径与一处待过目边界)、主人四条裁决的落点、以及一次没跑成的 W41 回归(如实记为未验证,附可复用的 WS 拦截代理与教训) 2026-10-05 15:23:04 +08:00
AskaEth 3c1d3fd44c merge: W43 —— 超限告知改入口中性措辞,补发反复失败就静默(静默不等于丢弃) 2026-10-05 15:22:34 +08:00
AskaEth 5556573986 feat(chat): W43 超限告知改入口中性措辞,补发反复失败就静默
① 超限告知(ShareRules.truncatedImagesNotice)改成**入口中性**措辞
原句以「分享的图片」开头,那是一句只对其中一条路成立的限定:W42 起这个函数同时被图片入口
用着(图片按钮已改多选),而图片入口里根本没有「分享」这件事。所以句子不再指认动作的来处,
只说两条路都成立的三件事:**有上限(4 张)、超出的没发、发出去的是前 N 张**。新句逐字:
「一次最多 4 张,超出的没发,只发送前 N 张」,N 就是真发出去的张数。
刻意不写「失败」「错误」:被上限截掉不是一次错误,是上限本身,那族措辞会让用户以为丢了东西。
两条路仍然共用同一句(只有一个出处),没有为此分裂成两份文案。

② 补发「多次重试失败就静默」(主人 2026-10-06 定案)
W40/W41 之后未连接时普通发送会进补发队列并给一句柔和告知,连接恢复后自动补发;主人新加的
规矩是**不要一直刷屏**:同一条(或同一批)反复重试都失败时,停止再弹告知,静静地等。
- 判据抽成纯函数 queuedSendNoticeAllowed(toldThisRound, failedResends):**静默闸优先**,其次是
  既有的「一轮从空到空只说一次」(W40 那条一字未改)。它只回答该不该说,从不碰队列。
- 阈值 QUEUED_RESEND_SILENCE_THRESHOLD = 3。理由:不许取 1~2(那等于根本没告知);第 1 次失败
  就是「刚进队列」那一刻,必须说;第 2、3 次仍允许开口(连接抖动后还是没连上,用户需要重新
  知道);第 3 次仍未成功说明已在反复失败里,再说只是把同一件事刷第二遍,从此停下。
- 记账口径:本会话的一个**补发批次**(= 队列从空到非空的那一段),与告知本身的记账同口径,
  不新造第二个概念;按单条记账会漏掉「每条只失败一两次、整批却一直没发出去」这种要静默的情形。
  落在 ChatViewModel 的实例字段上:VM 是按会话建的,所以天然按会话,也不引入全局单例——别的
  会话的反复失败不该把本会话的告知掐掉。
- 计什么:只计「补发没能上路、内容仍留在队列里」那种失败(它是唯一会反复发生的一种);已被
  服务端裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不计数。
- 清零时机:**补发真的成功一次**就归零(WS 帧交出去、或回落 REST 被服务端收下)⇒「静默之后
  连接恢复并补发成功」之后,下一次离线**又能重新告知**,不会永久闭嘴。
- 静默不等于丢弃:这一个数只影响说不说,队列内容与补发的下一步一个字节都没动,连接一好照旧
  按序补发(W38/W40/W41 那条硬要求照旧成立);气泡上「排队中 · 还没连上」的小标也一直挂着。

既有的两句队列告知(排队中那一句 / 离线那一句)一字未改。

测试:ShareNoticeTest 与 ImageSendPlanTest 的超限断言改成新事实(不删断言);新增
QueuedSendSilenceTest 钉住那条规则的四个边(第一次失败告知、阈值前仍告知、阈值后静默、成功后
清零)以及「阈值不许取 1~2」「静默不动队列」两条性质。
assembleDebug 成功;testDebugUnitTest 1150 测试 / 0 失败 / 0 错误(基线 1143,本批新增 7)。
2026-10-05 15:21:24 +08:00
AskaEth f3ee2e0d7b docs(05): 锚点补记十四——W42 图片入口改多选(N 张按既有切分落成 N 条消息、切分抽成唯一纯函数由输入栏与分享共用),并登记两处待主人过目(N>=2 时偏好只套第一条、超限文案带「分享的」字样) 2026-10-05 14:42:20 +08:00
AskaEth 4355ebddea merge: W42 —— 图片入口改多选,N 张图按既有切分落成 N 条消息 2026-10-05 14:41:46 +08:00
AskaEth 5a9f52a069 feat(chat): 图片入口改多选,N 张图按既有切分落成 N 条消息
主人 2026-10-05 定案「那肯定要多选」:图片按钮从 PickVisualMedia(单选、回一个 Uri)换成
PickMultipleVisualMedia(系统 Photo Picker 的多选形态,仍然零权限,不退回落 SAF / 存储权限)。
文件按钮那条 OpenMultipleDocuments 一个字没动。

为什么复用分享路径那一段(不另起一套发送逻辑):
分享落地(ChatViewModel.sendShared)早就把「一条消息只能带一张图 ⇒ N 张图 = N 条消息」做完了,
那段切分本来内联在它自己的 forEach 里,不是可复用的纯函数。这次把判据抽成纯函数 imageSendPlan
(ui/chat/ImageSendPlan.kt,跟 SharePayload.kt 与 SendQueue.kt 一样的写法,可单测),投递抽成
emitPlannedMessages(私有),输入栏那条路与分享落地那条路共用同一段:两处各写一份循环,迟早会在
「哪条带正文 / 哪条带附件 / 哪条清草稿 / 超过上限怎么办」上分叉。每张图各自的解码 / 缩放 / 重编码
与「内联还是上传」的取舍仍旧逐张走既有实现(deliver 与 deliverImage),一行没改。

为什么 N 张 = N 条(而不是一条带多张):
契约里 chat.image 是单图字段,一次上行只能带一张,所以多选进来的 N 张只能落成 N 条消息:
第一条带正文 + 第一张图 + 全部附件,其余每条一张图,顺序 = 用户在多选器里选的顺序。契约与服务端
一个字没动。

上限与告知沿用分享路径的口径:
上限仍是 ShareRules.MAX_IMAGES(4)。超限那几张不静默丢,如实告知;那句文案抽成
ShareRules.truncatedImagesNotice,分享入口与选图入口共用同一句(逐字未改,ShareNoticeTest 钉住)。
系统选择器自己会把可选张数卡在 4 上,只有回落成 ACTION_OPEN_DOCUMENT 的设备(它表达不了最多几张)
以及「草稿里已有几张、再选几张」这两种情形会真超,那时由入口与投递两处说出同一句。

其余口径与今天一致:
输入栏最多攒 4 张(到顶后不再显示图片入口),每张各自可移除;取消选择什么都不做;
发送时第一条带正文并清草稿(N 条是一次发送,草稿只清一次),失败的那一条只把自己那一张还给输入栏。
一次发送切出的多条里只有第一条套用「她忙时发消息」偏好——第 2 条起声明打断就是打断自己刚起的
上一轮(与分享路径不声明同一条理由),N = 1 时与今天逐字节相同。

顺带:
- ChatUiState.draftImage(String?)改为 draftImages(List<String>),rollbackPending 把失败那张补回
  待发列表(不再覆盖),输入栏渲染成每张各自带移除键的一排预览;
- QueueFullOutcomeTest 里断言单图的四处改成新事实(断言一条没删);
- 新增 ImageSendPlanTest,覆盖 N = 0 / 1 / 2 / 4 / 5(超限),并钉住附件只跟第一条、顺序不重排、
  超限那句与分享路径同一句。
2026-10-05 14:41:21 +08:00
AskaEth 97992bdb6b docs(05): 锚点补记十三——W41 三级阶梯(未连接先探 health,通就回落 REST 发出去、不通才进队列),含 Offline 为何不探测、gapCursor 那个被补掉的洞、以及图/附件走不到这条路的边界 2026-10-05 14:35:14 +08:00
AskaEth eb22521269 merge: W41 —— 未连接时先探 health,通就回落 REST 发出去,不通才进队列(三级阶梯) 2026-10-05 14:34:12 +08:00
AskaEth 9f77fb469c feat(chat): W41 未连接时先探一次健康端点,通就回落 REST 发出去,不通才进队列
主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点
验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。

## 为什么先探测再决定,而不是直接排队
网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发
Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成
「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。

## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt)
1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事
   仍然是 sendChat,前面不加任何判断、不挂任何起点。
2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律
   按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走
   REST);不通 → 进队列。
3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误
   (证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。
4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。

Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的),
而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会
发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。

## 为什么重发是安全的
回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。
服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate /
duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经
收下」这种最难判的情况不需要本端猜。

## 回复怎么回来
那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp →
loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以
回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。

## 其他
- 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测
  都不发。
- 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上
  所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论
  之后。
- 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒);
  回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。
- 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、
  探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。
- devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。
2026-10-05 14:33:34 +08:00
AskaEth 7b981dd88a docs(05): 补记十二补齐——W40「未连接进队列」的真机回归结果(断线时不退回不报错、恢复后只补发一条),并记下新 AVD 的实测点击坐标 2026-10-05 14:08:28 +08:00
AskaEth 3611455357 docs(05): 锚点补记十二——AVD 调参第二轮(缩小到 1080x2340、vCPU 降到 5、删 skin 才让 hw.lcd 生效),并记下「无窗口会降级成软件 GL,反而更慢」这条与直觉相反的实测 2026-10-05 14:00:56 +08:00
AskaEth 31e8e31679 merge: W40 —— 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发 2026-10-05 14:00:10 +08:00
AskaEth 67b2e58fdf docs(05): 锚点补记十一——W40 普通发送未连接时进队列(主人定案落地),并登记 REST 未覆盖与三条待办
记下这一批的口径与证据,供下一个会话接续:

- 分支 feat/W40-send-queue-offline(基线 e60ae7a),代码与测试提交 df9fc85;未合并、未推送。
- 证据:assembleDebug 成功;testDebugUnitTest 1118 / 0 失败 / 0 错误 / 0 跳过(83 套件)。
  本批没有真机回归。
- 判据:wsSendOutcome 三档(Dispatched / Queued / Failed);补发触发点是连接状态流变成
  Connected 那一刻,无轮询;顺序 = 用户发送先后顺序;只发一次的保证点是队头加出队恰好一次,
  第二道是复用同一个 clientMsgId。
- 状态表达:不新增第七态(等连接仍是 ③ 排队中),气泡小标加成「排队中 · 还没连上」,
  另有一条柔和的 sendQueueNotice 告知(一轮队列只说一次,Offline 那一档说的是
  重新登录 / 检查服务器地址)。
- REST 通道(长文 / 图片 / 附件)本批未覆盖,仍走回滚加既有网络错误文案,理由与影响面写在
  补记里。
- 待办里更新了四条:真机回归、REST 要不要也排队(需主人定案)、Offline 立刻告知、
  以及那一处刻意未动的失败文案字面量。
2026-10-05 13:58:20 +08:00
AskaEth df9fc85f76 feat(chat): 普通发送未连接时进队列(不再回滚),连接恢复后按发送顺序自动补发
主人 2026-10-05 裁决:普通发送在「未连接」时进队列。

此前 deliver 拿到 sent=false 就 rollback:气泡摘掉、正文退回输入栏,外加一句「还没连上,
这条没发出去,请稍后手动重试」。内容是没丢,但把「网络还没好」说成了一次要用户自己负责的
失败,而且他还得对着输入栏再发一遍。这一批按 W38「分享落地等连接」那套语义重做普通发送:
不报错、内容不丢、连接恢复后自动补发。W38 的落地时机、PendingShareHolder.take() 的原子语义、
ChatSocket 的连接状态机一个字都没动。

① 三档处置:未就绪就排队(新纯函数 wsSendOutcome,ui/chat/SendQueue.kt)
判据的输入只有两个:sendChat 的回值,以及这一刻 socket 自己的连接状态。sent=true ⇒ Dispatched
(帧已经交给 socket,成功路径一个字没动:不打标记、不说告知、不写任何状态);sent=false 且
连接就绪 ⇒ Failed(连接是好的,那 false 只可能来自 socket 对象已空 / 已被 cancel / 序列化抛
异常,「发送失败」在这一刻是事实 ⇒ 走今天那条 rollback + SEND_FAILED_MESSAGE);sent=false 且
未就绪 ⇒ Queued(ChatSocket.send 第一行就按状态挡掉,帧连序列化都没做、服务端一个字节都收不到,
所以既不回滚也不进 error 横幅)。未就绪四档(Idle / Connecting / Reconnecting / Offline)
一视同仁:主人的原话是「未连接就进队列」,Offline 只是不会自己变好,内容仍然一个字节都不会丢,
两档的差别只在告知措辞(这一点与 W39 给分享落地定的规矩一致)。

② 队列与补发:顺序与「只发一次」的保证点
deliver 的 WS 分支只改了「未就绪」这一支:queueForConnection 把这条留在待发态
(ChatUiState.queuedForConnection,条目只有 localId 与投递那一刻定下的并行声明;正文 / 图片 /
附件仍从那条气泡上现取,同一件事不留第二份真相源,也就不存在两条真相分叉)。触发点只有一个:
socket 状态变成 Connected 那一刻(onConnectionChanged,由状态流驱动、没有任何轮询),外加
入队之后的一次竞态兜底(连接恰好在投递与入队之间就绪时,那一次补发会扑空)。补发从队头开始,
FIFO 顺序 = 用户发送的先后顺序。
「只发一次」的保证点是「队头 + 出队恰好一次」:队头只在这一步有了结论(发出去了 / 真失败 /
气泡已经不在了)之后才被 dropQueuedSend 摘掉,所以重复的触发拿到的是同一个键、不会多出第二次
出队;flushQueuedSends 是同步函数(一个挂起点都没有),两个触发点在主线程上不可能交错。
第二道保证在服务端:补发复用同一个 clientMsgId(绝不铸第二个),真重复到达也只会被回
chat_duplicate,不会落成第二条消息。补发又失败时按今天的失败路径处理(回滚 + 文案),
绝不静默吞掉。已知边界(写在 KDoc 里):打断声明不随补发重放——补发时在跑的那一轮早就不是
用户按发送时看到的那一个了,隔半分钟再去打断可能毁掉他没见过的一轮。

③ 状态表达:仍是六态,③ 里多一句更准的话
不新增第七态:等连接的这条走六态里的 ③ 排队中(phaseOf 的判据从「收到过入队回执」扩为
「收到过回执,或本端因未连接把它留住了」),两类排队的区别由气泡小标说清——新纯函数
pendingBubbleFooter 给 ③ 加四个字:「排队中 · 还没连上」。为什么挂在气泡上而不只是那句告知:
告知八秒就收走,而这是个会持续几十秒的状态,牌子撤掉之后光看「排队中」分不出是「她已经收下、
前面还排着队」还是「连都没连上、一个字节都没上路」。柔和告知走新通道 ChatUiState.sendQueueNotice
(与 shareNotice 分开:两条描述的是不同的动作,可以同时挂着),文案 SEND_QUEUED_NOTICE 是
「还没连上,这条排队等着,连上后自动发出」;Offline 那一档换 SEND_QUEUED_OFFLINE_NOTICE
「现在处于离线,需要重新登录或检查服务器地址;这条还没发出去,已排队不会丢」——不提「连上」
「自动发」,那一档没有会自动到来的连接。不刷屏:一轮「队列从空到空」只说一次(连续几条离线
消息不会每按一次发送就弹一遍),记账在 queueForConnection。

④ 未覆盖:REST 通道(长文 / 图片 / 附件)保持今天的行为
POST /api/v1/chat 那条路仍是失败回滚 + 既有网络错误文案,本批不动。两条理由:一是它给不出
「未连接」这个信号(拿到的只是一次网络错误,而请求可能已被服务端收到、也可能没有),
判据不可靠;二是要补发就得重跑图片重编码与附件上传换存储名,而本地 URI 的读授权可能已经失效。
代价:未连接时发长文 / 图片 / 附件仍要用户手动重发一次;影响面仅这一条通道,WS 那条(短文本,
日常绝大多数消息)已经覆盖。

⑤ 未连接时不再有的那句话
「还没连上,这条没发出去,请稍后手动重试」(W39 加的 SEND_NOT_CONNECTED_MESSAGE)与它的判据
sendFailureMessage 一并删除:回滚那条路现在只在连接就绪时才走,这句话已经没有出口,留着就是
一段描述旧行为的死文案。SendFailureMessageTest 的断言改成新事实(未就绪 ⇒ 排队,而不是回滚
加那句话)并连同新的队列判据并入 SendQueueTest,一条断言都没删。另把「空输入什么都不发生」
这条边界抽成纯函数 hasSendableContent,输入栏的 send 与投递口的 emitAndDeliver 共用同一条判据
(行为不变,只是原本写在两处的同一个 if 现在可单测,也不会再分叉)。

测试:新增 SendQueueTest(21 条:三档判据穷举 WsState.entries、两档告知措辞、队列顺序与出队
恰好一次、气泡小标与六态、空输入边界)。assembleDebug 成功;testDebugUnitTest 1118 条
(1101 减 4 加 21),failures 0 / errors 0。
2026-10-05 13:57:54 +08:00
170 changed files with 24471 additions and 4787 deletions

No files matched your search

+6
View File
@@ -10,3 +10,9 @@ local.properties
captures/
.externalNativeBuild/
.cxx/
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
/IMG_*.png
# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。
# 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。
# 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。
devdocs/
+49
View File
@@ -1,6 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
「后台保持连接」用到的三个权限(设置页那个开关,默认关):
- FOREGROUND_SERVICE:起前台服务本身。
- FOREGROUND_SERVICE_DATA_SYNC:前台服务的类型权限(API 34+ 与 android:foregroundServiceType
配合)。选 dataSync 是因为这条服务保的是一条**长连接**——保持与后端的实时同步,
既不是 mediaPlayback 也不是 location,声明成别的类型才是说谎。
- POST_NOTIFICATIONS:前台服务必须有一条通知,而 minSdk 33 ⇒ 它恒是运行时权限。
用户拒绝时开关**打不开**(判据见 core.keepalive.shouldKeepAliveRun),本端不偷偷跑。
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
联网权限**显式**声明,不靠任何传递依赖。
事实(W52K 跑 `:app:processReleaseMainManifest` 核过):本端一直能联网,是靠
`com.squareup.okhttp3:okhttp-android:5.5.0` 的清单把 `INTERNET` 合并进来的;而 debug 那边
AGP 也注入同一份 ⇒ **本地怎么试都看不出来**。一旦换掉/排除那个依赖,release 会**静默失去**
网络权限(不报错、只是所有请求都失败)。显式写在这里,从此与第三方清单无关。
-->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".app.CyreneApplication"
android:allowBackup="false"
@@ -65,6 +99,21 @@
android:resource="@xml/file_paths" />
</provider>
<!--
「后台保持连接」的前台服务(设置页开关控制,默认关)。
exported="false":它没有任何对外接口(onBind 返回 null),只由本进程 start/stop。
foregroundServiceType="dataSync":这条服务的职责是让进程活着,从而让那条**进程级共享**
的实时连接(AppContainer.chatSocket)不被 Android 12+ 的 cached 进程冻结掐断——
语义上就是「后台数据同步」。它不自己去建立 / 关闭任何连接,连接的主人是
SessionsViewModel(见 KeepAliveService 的类注释)。
-->
<service
android:name=".core.keepalive.KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -3,20 +3,28 @@ package top.yeij.cyrene.app
import android.content.Context
import top.yeij.cyrene.core.auth.AuthManager
import top.yeij.cyrene.core.auth.SessionStore
import top.yeij.cyrene.core.notify.AppPresence
import top.yeij.cyrene.core.notify.ProactiveNotifier
import top.yeij.cyrene.core.notify.postProactiveNotification
import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.core.prefs.EnumPreferences
import top.yeij.cyrene.core.prefs.SessionPreferences
import top.yeij.cyrene.core.session.ChatSessionSlots
import top.yeij.cyrene.core.session.GoneSessions
import top.yeij.cyrene.data.api.AdminApi
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ChatApi
import top.yeij.cyrene.data.api.DocumentsApi
import top.yeij.cyrene.data.api.MediaApi
import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.PersonaApi
import top.yeij.cyrene.data.api.SessionsApi
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.DocumentRepository
import top.yeij.cyrene.data.repository.MediaRepository
import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.PersonaRepository
@@ -36,6 +44,60 @@ class AppContainer(context: Context) {
val appSettings: AppSettings = AppSettings(context.applicationContext)
val authManager: AuthManager = AuthManager(sessionStore)
/**
* 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。
*
* 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是
* **每一个还活着的** `ChatViewModel`(它们在 [chatSessionSlots] 没回收之前都活着、都订阅着
* 那条共享连接)。它拦下的是那一轮注定 `404 session_not_found` 的补拉 —— 理由与
* 「为什么不持久化」见 [GoneSessions] 的类注释。
*/
val goneSessions: GoneSessions = GoneSessions()
/**
* 「App 在不在前台」「聊天页显示着哪个会话」(**进程级**,与 [chatSocket] / [goneSessions]
* 同一层):写它的是 `MainActivity`(`onStart` / `onStop`)与 `ChatScreen`(组合进出),
* 读它的是 [proactiveNotifier]。为什么不引 `ProcessLifecycleOwner` 见 [AppPresence] 的类注释。
*/
val appPresence: AppPresence = AppPresence()
/** 「点通知进来要看主会话」那笔待落地的意图(写:`MainActivity`,消费:`ChatHomeScreen`)。 */
val proactiveTapIntake: ProactiveTapIntake = ProactiveTapIntake()
/**
* 「她主动说的话」的通知通路(W53N):判据、去重与落地都在里面,Android 那一步从这里注入。
*
* 它**不订阅任何东西**(帧由 [proactiveFrameIntake] 喂进来),因此与连接的所有权完全无关
* ——本端那条共享连接的生死仍然只在登录态那一处表达。
*/
val proactiveNotifier: ProactiveNotifier = ProactiveNotifier(
presence = appPresence,
post = { text, notificationId ->
// 「免打扰」**每次现读**(`AppSettings.current` 是那份热副本):开关一改,下一条通知就
// 换一条渠道,不必重启 App。为什么不能在这里挂起读一次、以及那个毫秒级窗口是怎么回事,
// 都写在 `AppSettings.current` 的 KDoc 里。
postProactiveNotification(
context = appContext,
text = text,
notificationId = notificationId,
quiet = appSettings.current.value.proactiveQuiet,
)
},
)
/**
* 会话槽位账本(**进程级**):本端为一个账号留着的聊天状态,一格一份 `ViewModelStore`。
*
* 它把 S4 那笔技术债收在一处:`ChatViewModel` 是按会话建的,此前访问过多少个会话就有多少份
* 常驻内存(消息列表含内联 base64 图片)与多少份对共享连接的订阅;现在最多留
* [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,超出的按「最后一次成为当前会话」
* 从旧到新交出去(判据是纯函数 `core.session.chatSessionEvictions`)。
*
* 与 [goneSessions] 同一层、同一批读者(写它的是 `ChatScreen` 与 `SessionsViewModel`)。
* 主会话 id 在这里注入,是为了让 `core.session` 那两层不认识 `ui.chat` 的那个常量。
*/
val chatSessionSlots: ChatSessionSlots = ChatSessionSlots(SessionSummaryDto.MAIN_SESSION_ID)
private var cachedBaseUrl: String? = null
private var cachedSessionsApi: SessionsApi? = null
@@ -136,6 +198,34 @@ class AppContainer(context: Context) {
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
private var cachedMediaApi: MediaApi? = null
private var cachedMediaBaseUrl: String? = null
/**
* 语音副本端点(`GET /api/v1/media/{name}`,契约 §1.2 **U19**);与 [documentsApi] **同一套门**:
* `owner|admin` **必须 Bearer**(与图片读侧「名字即凭据」刻意相反 —— 语音是私密内容)。
*
* 同样与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理):`voice.url` 是**服务端相对路径**,
* 只有落在**同一台后端**上才取得到那份副本。
*/
val mediaApi: MediaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedMediaApi
if (cached != null && cachedMediaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MediaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedMediaBaseUrl = baseUrl
cachedMediaApi = created
return created
}
val mediaRepository: MediaRepository get() = MediaRepository(mediaApi)
private var cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = null
@@ -216,9 +306,12 @@ class AppContainer(context: Context) {
* 每个 `ChatSocket` 都自带一个 `OkHttpClient`(各自的连接池 / ping / 重连循环)。改前
* `ChatViewModel`(**每个会话一个 VM**)、[top.yeij.cyrene.ui.sessions.SessionsViewModel]、
* [top.yeij.cyrene.ui.admin.AdminViewModel] 各自建了一条(`ChatViewModel.start()`、
* 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`;而 VM 活在 Activity 级
* `ViewModelStore` 里,**切走 / 删会话都不回收**。现在这里就是唯一的持有者(`by lazy`,只此一份),
* 调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
* 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`。现在这里就是唯一的
* 持有者(`by lazy`,只此一份),调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
*
* **每个会话那个 VM 常驻的那一半**(S4)由 [chatSessionSlots] 收:本端最多留
* [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,多出来的整格交出去。这一条与
* 本段说的是**两件事**:连接只有一条,而订阅它的 VM 有几格 —— 前者与本类同一层,后者在账本里。
*
* ## 开与关只有一个主人
* [top.yeij.cyrene.ui.sessions.SessionsViewModel] 的登录态订阅:`LoggedIn → connect()`、
@@ -234,4 +327,22 @@ class AppContainer(context: Context) {
onAuthFailed = authManager::onSessionExpired,
)
}
/**
* 「她主动说的话 ⇒ 弹通知」那条通路在**帧这一层的消费点**(W57,**进程级**):把 [chatSocket]
* 上那两种帧交给 [proactiveNotifier]。构造即订阅,此后与进程同寿(没有 start / stop 的理由
* 见 [ProactiveFrameIntake] 的类注释)。
*
* 改前这份订阅挂在 `SessionsViewModel` 的 `viewModelScope` 上(它随 Activity 一起结束),于是
* 「从最近任务划掉 App、进程还被 `KeepAliveService` 撑着」那一档:帧到了 [chatSocket]、
* 一个订阅者都没有,而 `events` 是 `replay = 0` 的 `SharedFlow` ⇒ 当场丢掉,通知永远不弹。
*
* 声明在这里(类的末尾)有一个顺序上的理由:它要同时用到 [proactiveNotifier] 与 [chatSocket],
* 而 [chatSocket] 是本类最后声明的那个(Kotlin 按声明顺序初始化)。读 [ChatSocket.events] 会把
* 那条连接对象提前建出来——本容器与它同时诞生、同时活着,这一步没有任何代价。
*/
val proactiveFrameIntake: ProactiveFrameIntake = ProactiveFrameIntake(
events = chatSocket.events,
notifier = proactiveNotifier,
)
}
@@ -5,8 +5,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.core.keepalive.KeepAliveAction
import top.yeij.cyrene.core.keepalive.KeepAliveService
import top.yeij.cyrene.core.keepalive.keepAliveAction
import top.yeij.cyrene.core.keepalive.notificationsAllowed
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.ui.chat.ChatHomeScreen
import top.yeij.cyrene.ui.common.LoadingScreen
import top.yeij.cyrene.ui.login.LoginScreen
@@ -14,12 +20,44 @@ import top.yeij.cyrene.ui.login.LoginScreen
@Composable
fun CyreneApp(container: AppContainer) {
val state by container.authManager.state.collectAsState()
val prefs = LocalAppPrefs.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
container.authManager.bootstrap()
}
/*
* 后台保持连接:**全 App 唯一一处**决定那条前台服务的起停。
*
* 这里是合适的位置,因为它是「开关(App 偏好)与登录态碰头」的最小汇合点:偏好由
* CyreneRoot 读出来经 LocalAppPrefs 下发,登录态本来就订阅在本函数里。判据本身是纯函数
* (core.keepalive.keepAliveAction),两种状态各自变化都会把这一次重跑一遍,于是
* 「启动时开关开着且已登录 ⇒ 服务在跑」「登出 ⇒ 停」「开关关掉 ⇒ 停」都不用另外写路径。
*
* 权限每次**现读**(不缓存在 remember 里):会话自举完成那一刻读到的就是当下的真实授权,
* 而授权变化只有两条路——本端刚申请完(设置页那次写入会连带把这里重跑一遍)、或用户在系统
* 设置里改过(下次启动必然重跑)。
*/
// ⚠️ key 里必须有「回到前台」这一位(W53S 登记的那一格):通知权限是**现读**的,而用户在系统
// 设置里授权后回到 App 时,只有这个状态会变 —— 少了它,UI 会说「已开」而服务其实没起。
val appInForeground by container.appPresence.appInForegroundFlow.collectAsState()
LaunchedEffect(prefs.keepAliveEnabled, state, appInForeground) {
when (
keepAliveAction(
enabled = prefs.keepAliveEnabled,
sessionKnown = state !is SessionState.Unknown,
loggedIn = state is SessionState.LoggedIn,
notificationsAllowed = context.notificationsAllowed(),
)
) {
KeepAliveAction.Start -> KeepAliveService.start(context)
KeepAliveAction.Stop -> KeepAliveService.stop(context)
KeepAliveAction.Leave -> Unit
}
}
when (state) {
SessionState.Unknown -> LoadingScreen()
SessionState.LoggedOut -> LoginScreen(container)
@@ -7,6 +7,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
private val container: AppContainer get() = (application as CyreneApplication).container
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -17,22 +20,47 @@ class MainActivity : ComponentActivity() {
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
// 冷启动不带——这就是判据。
if (savedInstanceState == null) {
intent?.let { ShareIntake.accept(this, it) }
intent?.let {
ShareIntake.accept(this, it)
// 点她那条通知冷启动进来:记一笔「要看主会话」(见 ProactiveTapIntake)。
// 与分享同一个判据:重建时系统会把同一个 intent 再送一遍,那时不能重复记账
// (重复没有害处,但「冷启动才接」这条纪律只有一处,别在两处各写一半)。
container.proactiveTapIntake.accept(it)
}
}
val container = (application as CyreneApplication).container
setContent {
CyreneRoot(container)
}
}
/**
* 本 App 只有**一个** Activity(`singleTop`),所以它的 start / stop 就是「App 有没有在被看」
* ——这是「她主动说的话该不该弹通知」的两半之一(见 `core.notify.shouldNotifyProactive`)。
*
* 报的是**实例**而不是一个布尔量:旋转 / 系统深浅色切换会让新实例的 `onStart` 先于旧实例的
* `onStop`,布尔量在那一刻必然被记成「后台」,于是用户正看着聊天页她却弹一条通知出来。
* 集合由 [AppPresence] 自己持有,这里只报自己。
*/
override fun onStart() {
super.onStart()
container.appPresence.onActivityStarted(this)
}
override fun onStop() {
container.appPresence.onActivityStopped(this)
super.onStop()
}
/**
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有,而
* 「点她的通知 → 回到已经开着的 Cyrene(可能停在设置页)」同理。
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
setIntent(intent)
ShareIntake.accept(this, intent)
container.proactiveTapIntake.accept(intent)
}
}
@@ -0,0 +1,136 @@
package top.yeij.cyrene.app
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.core.notify.ProactiveNotifier
import top.yeij.cyrene.data.ws.WsEvent
private const val LOG_TAG = "ProactiveFrameIntake"
/** 一帧「她主动说的话」剥出来的东西:[ProactiveNotifier.onProactiveFrame] 的四个入参 + 会话归属。 */
internal data class ProactiveFrame(
val sessionId: String?,
val id: String?,
val messageId: String?,
val timestamp: String?,
val content: String,
)
/**
* 这一帧算不算「她主动说的话」,以及那四个入参各取帧上的哪个键——**纯函数**(离线可穷举)。
*
* 只有两种帧是她主动说的话,它们对应服务端两条路(见 [ProactiveNotifier]):
* 1. [WsEvent.ProactiveMessage](实时帧 `proactive_message`)——`id` 取落库行键 `data.id`,`messageId`
* 取 IM 侧消息号,两者**不是同一个 id**(见那个事件的 KDoc);
* 2. [WsEvent.MessagePersisted] **且** `proactive == true`(IM 桥那条路补发的落库帧)——这一档只有
* 落库行的键(`message.id`),另外两个键帧上就没有,所以恒给 `null`,一个字都不许编。
*
* 其余帧一律 `null`:普通落库广播、流式增量、别的事件都不是「她主动找我说话」。判据错在这一层的
* 两个方向都很难在真机上发现——要么她开口了却什么都不弹(要等下一次主动消息才看得见),要么普通
* 消息也弹(每一次她正常回话都响)。
*/
internal fun proactiveFrameOf(event: WsEvent): ProactiveFrame? = when (event) {
is WsEvent.ProactiveMessage -> ProactiveFrame(
sessionId = event.sessionId,
id = event.id,
messageId = event.messageId,
timestamp = event.timestamp,
content = event.content,
)
is WsEvent.MessagePersisted -> {
if (!event.proactive) {
// 普通落库广播(她回话、别的端发的消息都走这一帧):主人只批了 proactive 这一档。
null
} else {
ProactiveFrame(
sessionId = event.sessionId,
id = event.message?.id,
messageId = null,
timestamp = null,
content = event.message?.content.orEmpty(),
)
}
}
else -> null
}
/**
* 「她主动说的话 ⇒ 弹系统通知」那条通路的**帧消费点**(W57,**进程级**)。
*
* ## 为什么必须提到这一层(改前那一档是坏的)
* 这条通路的判据与去重一直是对的,错的是**订阅挂在哪**。改前它是 `SessionsViewModel` 里那句
* `viewModelScope.launch { socket.events.collect(::onEvent) }`。连接倒是进程级的——划掉 Activity
* 之后它照样连着、照样在收帧——但 `viewModelScope` 随 Activity 一起结束,而 `ChatSocket.events`
* 是 `replay = 0` 的 `SharedFlow`:**没有订阅者的 `tryEmit` 就是当场丢掉**(它不替将来的订阅者
* 留任何东西)。于是「从最近任务把 App 划掉、进程还被前台服务撑着」那一档,她的话进了连接、
* 然后没了。
*
* 本类因此只做一件事:**在进程级订那份帧流**,把 [proactiveFrameOf] 认出的帧交给 [ProactiveNotifier]。
* 它不建连接、不关连接、不认识登录态——连接的所有权一个字节都没动,仍然只在登录态那一处表达
* (`SessionsViewModel` 的 `LoggedIn → connect()` / `LoggedOut → close()`)。
*
* ## 为什么没有 start() / stop()
* 它的生命周期**就是进程**:`AppContainer` 在 `Application.onCreate` 里构造它,此后一直活着。
* 刻意不提供 `stop()` 正是这次要修的那个 bug——**唯一会想到去调 stop 的调用方(某个 ViewModel)
* 会随 Activity 一起死**,那等于把这条通路又绑回 Activity 上;留一个可以被忘掉调用的 `start()`
* 也只是多一种坏法(忘调 = 通知整个哑掉,且没有任何报错)。
*
* 它不需要停:帧只有连接活着才可能到,而连接在登出(含令牌失效那条经 `onAuthFailed` 的路径)时
* 由登录态关掉。「登出期间收不到帧」这件事由**连接**保证,不靠这里取消订阅。去重账本也**刻意不**
* 在这里清:它是 `ProactiveNotifier` 里的进程级账本,而换了账号之后同一条消息再推一次,仍是
* 同一个人的同一句话,不该弹第二遍。
*
* ## 它现在是这条通路上唯一的帧消费点
* 改前 `SessionsViewModel` 里那两处转交已经删掉(留两处就等于靠去重去替一次重复消费擦屁股,
* 而且「谁在喂通知」会有两个答案)。所以这条订阅一旦死掉,通知就**整个**哑掉——兜底见 [deliver]。
*
* @param events 进程内唯一那条连接的帧流(`ChatSocket.events`)。
* @param notifier 判据 + 去重 + 落地那一处。本类只喂帧,不替它判断任何事(那两个入参是它自己的
* 事实与动作,见它的 KDoc)。
* @param dispatcher 收集用的调度器;单测传 `Dispatchers.Unconfined`,让帧在同一次 `emit` 里走完。
*/
class ProactiveFrameIntake(
events: SharedFlow<WsEvent>,
private val notifier: ProactiveNotifier,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
/**
* 订阅本身那个 scope:私有,没有第二个人拿得到它,也就没有人能取消这条订阅——本类的全部意义
* 就在这里。`SupervisorJob` 只为 [deliver] 那条兜底留余地。
*/
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
/** 构造即订阅(理由见类注释:启动时刻只有一个,留个能忘调的 `start()` 只会多一种坏法)。 */
init {
scope.launch { events.collect { event -> proactiveFrameOf(event)?.let(::deliver) } }
}
private fun deliver(frame: ProactiveFrame) {
try {
notifier.onProactiveFrame(
sessionId = frame.sessionId,
id = frame.id,
messageId = frame.messageId,
timestamp = frame.timestamp,
content = frame.content,
)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// 落地那一步(建渠道 / notify)万一抛,**这条订阅必须活下来**:它是进程级那唯一一处、
// 不会再被重建,而不接住的话异常会打到线程的未捕获处理器(Android 上就是进程当场挂)。
// 代价封顶在「这一条通知没弹」——去重账本已经把它记下了,不会为了补它再弹一次。
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "这条主动消息的通知没能落地", e)
}
}
}
@@ -0,0 +1,56 @@
package top.yeij.cyrene.app
import android.content.Intent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import top.yeij.cyrene.core.notify.EXTRA_PROACTIVE_OPEN_MAIN_SESSION
/**
* 「点她那条通知进来 ⇒ 要看主会话」这条**待落地的意图**(W53N)。
*
* ## 为什么它必须活在进程里,而不是直接在那次 Intent 里做完
* 通知可能在**任何**时刻被点:App 还没起来(冷启动)、已经开着但停在设置页、甚至还没登录。
* 而真正能「切到主会话」的只有一处——`ChatHomeScreen` 里那个 `SessionsViewModel`(`select()`),
* 它要被组合出来才存在,登录态没定时**根本没有**。所以这里只记一笔「用户要求看主会话」,
* 由 `ChatHomeScreen` 在能落地的那一刻取走(与 `ShareIntake` 同一种做法:待落地的意图活在
* Compose 与 ViewModel 之外,消费方自己挑时机认领)。
*
* ## 为什么是 StateFlow 而不是一个布尔
* 消费方(`ChatHomeScreen`)在 App 已经开着的时候**已经在组合里**:一个普通布尔量被改成 true
* 不会叫醒它,那一次点击就会静默丢掉(用户点了通知,进来还停在设置页)。`StateFlow` 一改值就能
* 让订着它的那段组合重跑判据。
*
* ## 不清理会怎样
* 没人消费时它一直是 true(例如点了通知但登录失败)——代价是「重登之后落在主会话」。
* 主会话本来就是默认落点(`resolveActiveSession` 在会话没了时也收敛到它),所以这里**刻意不**
* 在登出时清掉:留一个不会做错事的意图,好过为了干净而多一条「谁负责清它」的路径。
*/
class ProactiveTapIntake {
private val _openMainSession = MutableStateFlow(false)
/** 是否有一笔待落地的「切到主会话」。消费方拿到 true 后必须调 [consume]。 */
val openMainSession: StateFlow<Boolean> = _openMainSession.asStateFlow()
/**
* 从一次启动 / 热启动的 Intent 里认领这条意图(`MainActivity` 的两条入口都走这里)。
*
* 判据是这个 extra 的**字面量 true**:别的 Intent(分享、桌面图标)里它缺席,什么都不做。
*/
fun accept(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, false) == true) {
requestMainSession()
}
}
/** 记下「用户要求看主会话」(与 `accept` 分开,是为了这一半能脱离 Android 单测)。 */
fun requestMainSession() {
_openMainSession.value = true
}
/** 取走这笔意图(只有真正落地了「切到主会话 + 回到聊天页」的那一次才调)。 */
fun consume() {
_openMainSession.value = false
}
}
@@ -48,6 +48,17 @@ import java.util.UUID
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
*
* ## 四个 extra 为什么都读「原始值」
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` / `EXTRA_STREAM` 一律用 [Intent.rawExtra] 取原始值,
* 再由本文件按**形态**分派([shareStreamExtrasOf] / [shareTextExtrasOf])。两个理由:逐类型的
* `getXxxExtra` 遇到别的形态只会往 logcat 打一句 `ClassCastException` 再把值扔掉(那正是「分享点下去
* 什么都没发生」的来处),而本端还要知道「这个 extra 里**到底有没有躺着值**」——那件事纯函数看不到,
* 只能由这里如实报进去(两个 `present` 参数)。
*
* ⚠️ **正文的回落链起点也在这里**:`EXTRA_TEXT` 读不出东西时,`EXTRA_SUBJECT` → `EXTRA_TITLE` 依次
* 顶上([ShareRules.extract]),来源记进 `SharedContent.textSource`。三个都空 / 都读不出来时,这一次
* 分享照样要往下走(走「读不进来」那条如实告知),**绝不静默**。
*
* ## 清理
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
@@ -90,14 +101,24 @@ object ShareIntake {
*/
fun accept(context: Context, intent: Intent) {
val appContext = context.applicationContext
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
val rawStream = intent.streamExtraValue()
// 四个 extra 一律读**原始值**、按**形态**分派(见 [shareStreamExtrasOf] / [shareTextExtrasOf]):
// 挨个 getXxxExtra 去问的话,遇到别的形态只会拿到一行 `ClassCastException` 加一个 `null`
// ——那正是「分享进来什么都没发生」的来处。读不出形态时本端既不猜、也不闭嘴(见下面两个
// `present`):那照样是一次「带着内容迹象却什么都没读进来」。
val rawStream = intent.rawExtra(Intent.EXTRA_STREAM)
val streamExtras = shareStreamExtrasOf(rawStream)
val textExtras = shareTextExtrasOf(
text = intent.rawExtra(Intent.EXTRA_TEXT),
subject = intent.rawExtra(Intent.EXTRA_SUBJECT),
title = intent.rawExtra(Intent.EXTRA_TITLE),
)
val extracted = ShareRules.extract(
action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
// 正文走回落链:EXTRA_TEXT → EXTRA_SUBJECT → EXTRA_TITLE(有些发送方不填 EXTRA_TEXT,
// 内容只在后两个里;只读第一个会让那次分享整条静默)。来源记在 `textSource` 里。
text = textExtras.text,
subject = textExtras.subject,
title = textExtras.title,
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
stream = streamExtras.uriForms.firstOrNull(),
streams = streamExtras.uriForms,
@@ -105,6 +126,7 @@ object ShareIntake {
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
// 否则「分享点下去什么都没发生」会从那条缝里回来。
streamPresent = rawStream != null,
textPresent = textExtras.present,
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
@@ -139,8 +161,12 @@ object ShareIntake {
/**
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
*
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
* 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条
* (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上
* ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在
* `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与
* Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉:
* 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。
*/
fun take(): SharedContent? = holder.take()
@@ -276,14 +302,27 @@ object ShareIntake {
}
/**
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
*
* ## 为什么读原始值而不是 `getXxxExtra`
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
* `ClassCastException` 再把值扔掉(`String[]` 形态的 `EXTRA_STREAM` 就是这么静默丢掉的),而那
* 正是「分享点下去什么都没发生」的来处。形态分派见 [shareStreamExtrasOf] / [shareTextExtrasOf]。
*
* ## 为什么要 `runCatching`
* `Bundle.get` 第一件事是 `unparcel()`,它会解析别的进程给的字节:里面有个解析不出来的
* `Parcelable` 时它抛 `BadParcelableException`(核对过 AOSP 的 `android/os/BaseBundle.java`:
* defuse 关着时**直接抛出**,开着时也还要看 `Flags.deprecateBundleDefuse()` 那个 flag)。那是
* `RuntimeException`,从 `accept` 冒出去就是 `onCreate` 里的一次崩溃。
*
* ⚠️ 与它相对的是「类型不符」那种:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`
* 并回 `null`(同一个 AOSP 文件),所以那一档**不会**抛异常——但也**读不到值**,于是它只能靠
* 本函数给出的原始值被记成「里面躺着值」(见 [ShareTextExtras.present]),不会被静默当成
* 「什么都没分享」。两件事一起挡住:畸形 bundle 不当崩,读不出来的值不当没发生。
*/
@Suppress("DEPRECATION")
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
private fun Intent.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
/**
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
@@ -344,3 +383,54 @@ internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
}
/**
* `EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE` 三个**原始值** ⇒ 能当正文用的那几段。
*
* ## 为什么也读原始值(而不是 `getCharSequenceExtra` 挨个问)
* 两个理由,第一个是「读得回来」,第二个是「读不回来也要知道有东西」:
*
* 1. `getCharSequenceExtra` 只回「读到 / 没读到」,而本端要按**形态**分派:值可能是 `String`、
* `Spanned`(富文本分享的常态)、`SpannedString`……它们都是 `CharSequence`,但按 `String` 读就
* 直接是 `null`(那正是「分享进来什么都没发生」的来处之一)。这里用 `as?` 一次判完:是
* `CharSequence` 就收下,不是就当没有——**不猜、也不抛**。
* 2. 「三个里**有没有躺着值**」这件事纯函数看不到(它只收到几个 `CharSequence?`),而
* 「有值、只是本端一段都读不出来」与「用户什么都没分享」是**两件事**:前者必须能说出口,
* 否则那三种 extra 一起落空就是一次静默。所以 [present] 由这里如实报进去
* (见 [ShareRules.extract] 的 `textPresent`)。
*
* ## 它抛不抛异常(核对过 AOSP 源码,`sources/android-36.1/android/os/BaseBundle.java`)
* - **类型不符不抛**:`Bundle.getCharSequence` 自己 `catch` 了 `ClassCastException`(`unparcel()` 之后
* 直接 `(CharSequence) o`,失败就 `typeWarning` 记一句 log 再回 `null`)⇒ 所以
* `intent.getCharSequenceExtra(...)` 不会因为「值不是 CharSequence」把 App 打崩,它只是**读不到**。
* 那一档在这里落成 [present] 为真、[text] 为 `null`:既不崩、也不再静默。
* - **畸形 bundle 会抛**:它内部的 `unparcel()` 在别的进程给的 bundle 解析不出来时抛
* `BadParcelableException`(`RuntimeException`)。那一路由 [Intent.rawExtra] 的 `runCatching` 挡住
* ——读不到就当没有,**绝不让异常从 `accept` 冒出去**(那会是 `onCreate` 里的一次崩溃)。
*/
internal data class ShareTextExtras(
/** `EXTRA_TEXT` 读出来的那一段(读不回来是 `null`;空白**原样留着**,由纯函数判它算不算正文)。 */
val text: String?,
/** `EXTRA_SUBJECT` 读出来的那一段。 */
val subject: String?,
/** `EXTRA_TITLE` 读出来的那一段。 */
val title: String?,
/**
* 三个 extra 里**至少一个躺着原始值**——含**读不出类型**的那一种,也含**只是一段空白**的那一种。
*
* 空白也算,是刻意的:它说的是「对方**给了**这个字段」,与「这个字段里有没有可用的正文」是两件
* 事。而 `EXTRA_STREAM` 那边早就是这个口径(原始值只要非 null 就算 `streamPresent`),两个 extra
* 用同一条判据,才不会出现「流的空值会说话、文本的空值却闭嘴」这种要靠运气才发现的差别。
*/
val present: Boolean,
)
/** 见 [ShareTextExtras]:把三个文本 extra 的原始值读成能当正文用的那几段(纯逻辑,可单测)。 */
internal fun shareTextExtrasOf(text: Any?, subject: Any?, title: Any?): ShareTextExtras = ShareTextExtras(
text = (text as? CharSequence)?.toString(),
subject = (subject as? CharSequence)?.toString(),
title = (title as? CharSequence)?.toString(),
// 读不出类型(`42`、别种 `Parcelable`……)也算「里面躺着值」:本端不猜它是什么意思,但不该因此
// 把整次分享当成「什么都没发生」。`null`(连值都没有)才是真的没有。
present = text != null || subject != null || title != null,
)
@@ -0,0 +1,82 @@
package top.yeij.cyrene.core.keepalive
/**
* 「后台保持连接」该不该跑(**纯函数**,UI 与测试共用)。
*
* 判据单独拎出来的理由与先例一致(`ui.sessions.silentRefreshStep`、`ui.chat.reconnectPull`):
* 起服务那半边要 `Context`、要系统 API,在 JVM 单测里起不来;而「什么时候该跑」只有几个布尔,
* 能穷举、能断言。糊进 UI 之后就再也说不清「登出之后到底还会不会留着一条前台服务」——
* 那恰恰是这个功能最容易被写错的一半。
*
* 三个条件缺一不可,每一个都有自己的理由:
* - [enabled]:用户的选择,**默认关**。没打开就什么都不该常驻。
* - [loggedIn]:实时连接是登录态的产物(`SessionsViewModel` 在登出时关掉那条共享连接)。
* 没有连接还留一条前台服务,就只剩「白占一条通知 + 一点电」。
* - [notificationsAllowed]:`POST_NOTIFICATIONS` 被拒时前台服务**照样能跑**(系统不再拦它),
* 但那就等于**背着用户常驻一个不可见的进程**。本功能卖的就是「我明说我在跑」,
* 偷偷跑比不跑更糟——所以权限被拒判「不该跑」,由设置页那行字如实说出来。
*
* minSdk 33 ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,不存在「低版本不用问」的那一档。
*/
internal fun shouldKeepAliveRun(
enabled: Boolean,
loggedIn: Boolean,
notificationsAllowed: Boolean,
): Boolean = enabled && loggedIn && notificationsAllowed
/** 这一刻该对那条前台服务做什么([keepAliveAction] 的取值)。 */
internal enum class KeepAliveAction {
/** 该在跑:起服务。重复起是安全的,见 [keepAliveAction]。 */
Start,
/** 不该在跑:停服务。对一条没在跑的服务喊停同样是安全的。 */
Stop,
/** 什么都不做:**此刻还不该有主张**(登录态未定,见 [keepAliveAction])。 */
Leave,
}
/**
* 这一刻该对那条前台服务做什么(**纯函数**,判据本体是 [shouldKeepAliveRun])。
*
* ## 为什么不只回一个布尔
* 只回「该不该跑」的话,调用方每次都得转成一次 `start` 或 `stop`,而**启动自举那一段**
* (`SessionState.Unknown`)两种都不该做:冷启动时进程里根本没有服务,但**热启动**(Activity
* 重建、进程还活着)时上一次那条还好端端跑着,此时若按「还没登录 ⇒ 停」收掉它,用户会看到
* 「通知消失 → 自举完成 → 通知又回来」这一闪,而中间那段(App 刚被切到后台时)进程正好没有任何
* 保护。所以那一档是 [KeepAliveAction.Leave]。
*
* ## 为什么**不**看一眼「现在跑着没有」
* 那样能省掉几回重复调用,但代价是引入一份会漂移的进程内状态:系统完全可能**在进程还活着的前提下**
* 把服务收掉而不走 `onDestroy`(比如用户在系统的「正在运行的应用」里按停),于是那份「跑着」的记忆
* 停在 true,下一轮判据就会「Leave」——**该起的时候一声不响地没起**,而这正是本功能最不能出的错。
* 重复调用则没有任何代价:`startForegroundService` 打到一条已经在跑的服务上只是刷新同一条
* (同 id、静默渠道的)通知,`stopService` 打到没在跑的服务上是空操作。
*
* @param enabled 设置页那个开关(持久化在 `AppPrefs.keepAliveEnabled`)。
* @param sessionKnown 登录态**已经定了**(`Unknown` 之外的一切)。
* @param loggedIn 现在处于登录态。
* @param notificationsAllowed `POST_NOTIFICATIONS` 已授权。
*/
internal fun keepAliveAction(
enabled: Boolean,
sessionKnown: Boolean,
loggedIn: Boolean,
notificationsAllowed: Boolean,
): KeepAliveAction = when {
// 开关关着:没有第二种解释,收掉。**不等登录态**——用户刚表达过「不要」,用不着等自举。
!enabled -> KeepAliveAction.Stop
// 登录态未定(启动自举的那一段):这时谁都说不准该不该跑,别动。
!sessionKnown -> KeepAliveAction.Leave
// 明确登出:连接都没了,留着没意义。开关本身不动(`AppSettings` 是 App 级的、不随登出清空,
// 下次登录仍按用户当初的选择来)。这里读的是**登录态**而不是「用没用上连接」——本服务不碰连接
// (见 [KeepAliveService]),它对连接的了解就只有「现在有没有一个登录着的用户」这一件事。
!loggedIn -> KeepAliveAction.Stop
// 到这儿只剩「开关开着 + 已登录」这一种情形,跑不跑由通知权限说话。
shouldKeepAliveRun(enabled, loggedIn, notificationsAllowed) -> KeepAliveAction.Start
else -> KeepAliveAction.Stop
}
@@ -0,0 +1,162 @@
package top.yeij.cyrene.core.keepalive
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.app.MainActivity
/** 通知渠道 id。恒定不变:改了它等于换一条渠道,用户对旧渠道做的静音设置会失效。 */
internal const val KEEP_ALIVE_CHANNEL_ID = "keep_alive"
/**
* 渠道重要级。**只许是 LOW / MIN**([NotificationManager.IMPORTANCE_LOW] = 不响铃、不震动、
* 不弹横幅):这是「保活」不是「提醒」,用户没要求被叫住。
*
* 注意重要级是**渠道创建时定死的**:应用后改不了(只有用户能改),所以这个值必须是最终值。
*/
internal const val KEEP_ALIVE_CHANNEL_IMPORTANCE = NotificationManager.IMPORTANCE_LOW
/** 通知 id。常驻的那一条始终用它,重复起服务只更新不叠加。 */
internal const val KEEP_ALIVE_NOTIFICATION_ID = 1
private const val LOG_TAG = "KeepAliveService"
/**
* `POST_NOTIFICATIONS` 授没授(minSdk 33 ⇒ 它恒是运行时权限)。
*
* 它既是 [shouldKeepAliveRun] 的入参,也是设置页那行字要说的事,所以两边共用这一处口径。
*/
internal fun Context.notificationsAllowed(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
/**
* 「后台保持连接」的前台服务。
*
* ## 它的职责只有一条:让进程活着
* 实时连接是**进程级共享**的那一条(`AppContainer.chatSocket`),所有权在 `SessionsViewModel`
* ——登录连、登出关。所以本服务**绝不**去 `connect()` / `close()` 它,也**不订阅任何帧**:
* 换了它的死活不等于换了连接的死活,两者各有各的主人。
*
* 它要挡的是 Android 12+ 对 cached 进程的冻结:进程一旦被冻,那条连接的读写就不再被调度,
* 久了连接就断(消息不丢,服务端会落库,回前台靠 history + `after` 补洞恢复,但那就不是
* 「保持连接」了)。前台服务把本进程从 cached 那一档里抬出来,这件事就到此为止。
*
* ## 起停只有一个主人
* [keepAliveAction] 判、`CyreneApp` 那个 `LaunchedEffect` 执行(全 App 唯一一处):
* 谁都不许自己在别处 start / stop 它,否则「登出后还留着一条服务」这类不一致没有一处说得清。
*
* ## 它被系统杀掉怎么办
* [onStartCommand] 回 `START_STICKY`:被内存压力回收后系统会重建它。用户主动关开关走的是
* [stop],那是显式停止,粘性不会把它拉回来。
*/
class KeepAliveService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ServiceCompat.startForeground(
this,
KEEP_ALIVE_NOTIFICATION_ID,
buildNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
return START_STICKY
}
/**
* Android 15(API 35)起,`dataSync` 型前台服务在 24 小时内累计跑满 6 小时,系统会回调这里,
* 并给几秒钟让应用自己收手——**不接管就会 ANR 崩掉整个 App**(本端 targetSdk 37,躲不掉)。
*
* 所以这里照办:`stopSelf` 收掉自己,进程回到普通后台状态。刻意**不绕**这条限制
* (绕它要么换前台服务类型、要么加心跳,都超出这个功能的边界)。代价如实说:同一台设备
* 24 小时内累计只能保活 6 小时;额度用完之后能不能再起、什么时候能再起由系统说了算,
* 本端不替它猜(这条限制的真机表现留给验收)。
*/
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf(startId)
}
/**
* 常驻通知。文案**刻意克制**:它只是为了让「我在后台跑着」这件事在系统里是可见的
* (前台服务必须有一条通知,这是本功能对用户的代价,设置页那行字把它写明了)。
*
* `setSilent` + `setOngoing` + `setOnlyAlertOnce` + 渠道 LOW:不响、不震、不亮屏、划不掉。
* `setOnlyAlertOnce` 是给**重复起服务**留的(判据不看「现在跑着没有」,见 [keepAliveAction]):
* 第二次 `startForegroundService` 打到同一条通知上时,用户那边必须**一点动静都没有**。
*/
private fun buildNotification(): Notification {
// 点一下回到 App:一条点不动的常驻通知会让人以为卡住了。
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, KEEP_ALIVE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("保持与昔涟的连接")
.setContentText("Cyrene 正在后台维持与昔涟的连接")
.setContentIntent(open)
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createChannel() {
val channel = NotificationChannel(
KEEP_ALIVE_CHANNEL_ID,
"后台保持连接",
KEEP_ALIVE_CHANNEL_IMPORTANCE,
).apply {
description = "开启「后台保持连接」期间常驻的通知:让系统不要冻结 Cyrene 的进程"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
companion object {
fun start(context: Context) {
// Android 12+ 对「从后台起前台服务」是受限的,系统直接抛异常;这里吞掉它,理由有两条:
// ① 这个失败用户既看不懂也无从处置,而把异常放出去就是一次崩溃——崩溃比「这次没起成」
// 严重得多;
// ② 状态是自愈的:下一个判据触发点(开关 / 登录态变化、下次启动)会再试一次,
// 而这次没起成除了「进程暂时少一层保护」之外不留任何后遗症。
runCatching { ContextCompat.startForegroundService(context, intent(context)) }
.onFailure {
if (BuildConfig.DEBUG) Log.d(LOG_TAG, "系统拒绝了这次前台服务启动", it)
}
}
fun stop(context: Context) {
context.stopService(intent(context))
}
private fun intent(context: Context): Intent = Intent(context, KeepAliveService::class.java)
}
}
@@ -0,0 +1,92 @@
package top.yeij.cyrene.core.notify
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 进程级的两个「我此刻在哪儿」的事实(W53N):**App 在不在前台**、**聊天页正显示哪个会话**。
*
* ## 为什么是这一层,而不是 ProcessLifecycleOwner
* 本仓没有 `lifecycle-process` 依赖,而为一个「要不要弹通知」的判断新增依赖不值当(主人明令不加)。
* 既有的 `MainActivity` 生命周期已经足够说清第一件事——本应用只有**一个** Activity
* (`singleTop`),它的 `onStart` / `onStop` 就是「App 有没有在被看」。第二件事由 `ChatScreen`
* 的组合报告(组合在 = 那一页在屏幕上,见 [onChatSurface])。两条都在手写 DI 容器
* (`app.AppContainer`)那一层汇合——与那条共享连接、`goneSessions` 那几件进程级事实同一层。
*
* ## 为什么前台用「活着的 Activity 集合」而不是一个布尔
* 配置变更(旋转 / 系统深浅色切换)时,**新实例的 `onStart` 会先于旧实例的 `onStop`**:
* 一个布尔量在那里必然被旧实例的 `onStop` 记成「后台」,于是用户正看着聊天页,她一句话却弹出
* 通知来。按实例登记就没有这个方向性问题——新实例先加进来、旧实例后移出去,集合始终非空。
* 集合按 `ConcurrentHashMap` 语义(`Activity` 不重写 `equals` ⇒ 就是**同一实例**),
* 且读写可能不在同一线程上(她的话那条通路是在**进程级订阅自己的调度器**上读它的,见
* `ProactiveFrameIntake`),所以用并发容器而不是普通 `MutableSet`。
*
* ## 它**不是**「App 有没有活着」
* 后台保活(前台服务)让进程活着、连接继续收帧,而本类在这里恒报「不在前台」——那正是通知要
* 出现的那一档。两者是两件事,别把它们揉在一起。
*/
class AppPresence {
private val startedActivities: MutableSet<Any> =
Collections.newSetFromMap(ConcurrentHashMap<Any, Boolean>())
/**
* 聊天页此刻显示着的会话 id;`null` = 聊天页不在屏幕上(设置页 / 管理页 / 未登录 / 已销毁)。
*
* 由 [onChatSurface] 写、通知判据读。**刻意不带「前台」的含义**:App 退到后台时这段组合在
* Android 上并不会被销毁,这个值会原样留着——前台那一半由 [appInForeground] 单独表达,
* 两个事实的合取才是「这条消息此刻会不会正好被用户看到」(见 [shouldNotifyProactive])。
*/
@Volatile
var chatSurfaceSessionId: String? = null
private set
/** App 是否正在被看(有任何一个 Activity 处在 started 之后、stopped 之前)。 */
val appInForeground: Boolean
get() = startedActivities.isNotEmpty()
private val _appInForeground = MutableStateFlow(false)
/**
* [appInForeground] 的**可订阅**形态(W53S 登记的那一格要它)。
*
* 为什么需要第二条出口:`CyreneApp` 那个「起 / 停前台服务」的 `LaunchedEffect` 的 key 里没有
* 通知权限,于是**用户在系统设置里授权后回到 App** 时它不会重跑 —— UI 已经显示「已开」,
* 服务却没起。权限本身必须**现读**(不该缓存成状态),能当 key 的是「刚好会在这件事发生时变化」
* 的信号:**回到前台**(从系统设置返回必经 `onStart`)。所以这里把「在不在前台」补成一条流。
* 判据本身仍然只读 [appInForeground] 那个布尔——两条出口算的是**同一份事实**(同一个集合)。
*/
val appInForegroundFlow: StateFlow<Boolean> = _appInForeground.asStateFlow()
/** 一个 Activity 进入「可见」这一档(`onStart`)。 */
fun onActivityStarted(owner: Any) {
startedActivities.add(owner)
_appInForeground.value = appInForeground
}
/**
* 一个 Activity 离开「可见」这一档(`onStop`)。
*
* 只移除**报上来的那一个实例**:照抄成「一停就整集合清空」会把正在起的新实例一起算掉
* (那条路径见类注释里的配置变更)。
*/
fun onActivityStopped(owner: Any) {
startedActivities.remove(owner)
_appInForeground.value = appInForeground
}
/** 聊天页刚显示(或刚换成)[sessionId];`null` = 它离开了屏幕。 */
fun onChatSurface(sessionId: String?) {
chatSurfaceSessionId = sessionId
}
/**
* 聊天页此刻显示的是不是 [sessionId] 这个会话(等值比较,不是前缀:`web` ≠ `web:xxx`)。
* `sessionId` 为空白时恒假——「没显示任何会话」不等于「显示了一个空 id 的会话」。
*/
fun isShowingChatSurface(sessionId: String): Boolean =
sessionId.isNotBlank() && chatSurfaceSessionId == sessionId
}
@@ -0,0 +1,277 @@
package top.yeij.cyrene.core.notify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import top.yeij.cyrene.app.MainActivity
import top.yeij.cyrene.core.keepalive.notificationsAllowed
/**
* 「她主动说的话」那条通知的渠道 id —— **弹横幅**的那一条([PROACTIVE_IMPORTANCE])。
*
* ⚠️ **这里是 `proactive_v2`,不是最初的 `proactive`**:渠道的**重要级一旦创建就改不了**
* (Android 的设计 —— 只有用户能改,App 改不动)。2026-10-07 主人明确要求「要横幅」,而把重要级
* 从 DEFAULT 提到 HIGH **对已经建过旧渠道的设备不生效** ⇒ 只能换一个新 id,让系统按新重要级
* 重新建一条。旧渠道由 [ensureProactiveChannel] 顺手删掉(它的重要级改不动,留着只会让系统设置里
* 多一条永远不再出现的条目)。
*
* **刻意与保活那条分成两个渠道**:保活那条是 LOW(不响不震、用户没要求被叫住),这一条是
* [PROACTIVE_IMPORTANCE]。两者混在一条渠道里,就只能在「保活开始响铃」与「她的话不响铃」之间
* 二选一——那正是「一个渠道只能是其中一件事」的由来。
*
* 与 [QUIET_PROACTIVE_CHANNEL_ID] **也是两条**,理由与上面那条一模一样:横幅由渠道重要级决定,
* 而重要级建完就改不了 ⇒ 设置页那个「免打扰」开关**不可能**靠运行时改设置实现,只能按开关选一条
* 渠道发(见 [proactiveChannelId])。
*/
internal const val PROACTIVE_CHANNEL_ID = "proactive_v2"
/**
* 「她主动说的话」在**免打扰**下走的那条渠道 id:重要级是 [QUIET_PROACTIVE_IMPORTANCE]
* (有提示音、通知栏有条目、**不弹横幅**)。
*
* 为「免打扰」单独建**第二条**渠道,是因为**重要级建完就改不了**(理由见 [PROACTIVE_CHANNEL_ID])
* ——想「只关横幅」就只有换渠道这一条路。
*
* ⚠️ 刻意**不用** `setSilent(true)`:那是**完全静音**(连提示音都没),而用户要的语义是
* 「只在通知栏提示,别在屏幕顶部弹横幅」——两者之间差着一个提示音,不是同一件事。
*/
internal const val QUIET_PROACTIVE_CHANNEL_ID = "proactive_quiet"
/**
* 上一个渠道 id([PROACTIVE_CHANNEL_ID] 的前身)。**只在建渠道时用于删除**:它的重要级是
* DEFAULT 且改不动,留着毫无用处。
*/
internal const val LEGACY_PROACTIVE_CHANNEL_ID = "proactive"
/**
* 弹横幅那条渠道的重要级:**`HIGH`**(屏幕顶部弹横幅 + 提示音)。
*
* 这一条**是**来提醒人的 —— 她主动找我说话的那一刻,我显然不在跟她聊天(App 不在前台),
* 正是最需要在屏幕顶部弹一下的场景。DEFAULT 只会响一声、安静躺在通知栏里,很容易错过
* (主人 2026-10-07 的原话:「我在其他 APP 时没看到横幅」)。
*
* 想安静的用户可以在系统设置里单独把这条渠道调低、或关掉它的横幅 —— 那是用户的权利,
* 不是我们对他的默认;应用内的那条捷径是设置页的「免打扰」,它把通知改发到
* [QUIET_PROACTIVE_CHANNEL_ID]。
*
* ⚠️ 与保活那条同理:重要级在**建渠道**时定死,应用之后改不了(只有用户能改)⇒ **改这个常量
* 对已建过渠道的设备不生效**,必须同时换 [PROACTIVE_CHANNEL_ID]。`ProactiveNotificationShapeTest`
* 把这两个值钉住。
*/
internal const val PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_HIGH
/**
* 免打扰那条渠道的重要级:**`DEFAULT`**(提示音 + 通知栏条目,**没有横幅**)。
*
* `HIGH`(4) 才是屏幕顶部弹横幅的门槛,所以 DEFAULT 正是「她的话照旧说、只是不盖住我正在做的事」
* 那一档。
*
* ⚠️ 与上面那条同理:**改这个常量必须同时换 [QUIET_PROACTIVE_CHANNEL_ID]**,否则对已经建过它的
* 设备一个字节都不生效。这个方向(往安静改)出错时用户的感受只是「开关按了没反应」——不会有任何
* 报错,所以这条纪律只能靠常量与测试钉住。
*/
internal const val QUIET_PROACTIVE_IMPORTANCE = NotificationManager.IMPORTANCE_DEFAULT
/** 通知标题:**是谁在说话**。正文是她的原话(见 [ProactiveNotifier])。 */
internal const val PROACTIVE_NOTIFICATION_TITLE = "昔涟"
/**
* 渠道名(系统设置的渠道列表里那一行)。与通知标题分开是为了让它说清**这条渠道是什么**,
* 而不是重复一遍说话人的名字。
*/
internal const val PROACTIVE_CHANNEL_NAME = "她主动说的话"
/**
* 免打扰那条渠道的渠道名。**必须一眼看出与 [PROACTIVE_CHANNEL_NAME] 的区别**:系统设置的渠道列表
* 里这两行会并排出现,用户就是靠这一行判断「哪条会弹横幅」的——同名的话两条渠道就白分了
* (他只能点进去、一条条试着看)。
*/
internal const val QUIET_PROACTIVE_CHANNEL_NAME = "她主动说的话(免打扰:只在通知栏)"
/** 弹横幅那条渠道的描述(点进渠道详情才看得到)。 */
internal const val PROACTIVE_CHANNEL_DESCRIPTION = "她主动找你说话时的通知"
/**
* 免打扰那条渠道的描述。把「不弹横幅」与「还有提示音」都说清:用户点进来想知道的是**它到底静到
* 什么程度**,而「免打扰」四个字很容易被读成「完全没有声音」——那是另一件事(也正是用户会去系统
* 设置里翻、翻完还是不确定的那一件事)。
*/
internal const val QUIET_PROACTIVE_CHANNEL_DESCRIPTION =
"她主动找你说话时的通知(只在通知栏提示、不弹横幅;仍有提示音)"
/**
* 一条 proactive 渠道的完整定义:**id / 渠道名 / 重要级 / 描述一次给全**。
*
* 为什么要打包成一个值:这四样必须**成对地**跟着「免打扰」这个布尔走(换了 id 却没换重要级 =
* 开关没生效;换了重要级却没换 id = 对老设备没生效)。散成四个各自 `if (quiet)` 就是四份口径,
* 而它们分叉时的症状全都是「用户按了开关,什么都没变」——最难被发现的那种坏法。
*/
internal data class ProactiveChannelSpec(
val id: String,
val name: String,
val importance: Int,
val description: String,
)
/**
* 「免打扰」→ 这一次该用哪条渠道 —— **纯函数**(不含任何 Android 调用,两档可穷举)。
*
* @param quiet 设置页那个「免打扰」开关**当下的值**(`false` = 都弹,也是默认)。
*/
internal fun proactiveChannelSpec(quiet: Boolean): ProactiveChannelSpec = if (quiet) {
ProactiveChannelSpec(
id = QUIET_PROACTIVE_CHANNEL_ID,
name = QUIET_PROACTIVE_CHANNEL_NAME,
importance = QUIET_PROACTIVE_IMPORTANCE,
description = QUIET_PROACTIVE_CHANNEL_DESCRIPTION,
)
} else {
ProactiveChannelSpec(
id = PROACTIVE_CHANNEL_ID,
name = PROACTIVE_CHANNEL_NAME,
importance = PROACTIVE_IMPORTANCE,
description = PROACTIVE_CHANNEL_DESCRIPTION,
)
}
/**
* 发通知那一刻用哪条渠道 id —— **纯函数**,就是「同一个开关、两条渠道」这件事的全部落地形状。
*
* 单独再露一层是因为调用方要的正好只是这个 id(`NotificationCompat.Builder(context, ???)`),
* 而测试要问的也只是这一个问题:「开关两档各发去哪条」。
*/
internal fun proactiveChannelId(quiet: Boolean): String = proactiveChannelSpec(quiet).id
/**
* 点通知进来时那个「切到主会话」的 Intent extra。
*
* 它是本端**手写布尔路由**里的一次「带意图的冷启动 / 热启动」:`MainActivity` 读它 →
* `ProactiveTapIntake` 记一笔 → `ChatHomeScreen` 落成一次 `select(主会话)`。刻意**不引导航库**
* (本项目没有导航库,路由就是 `ChatHomeScreen` 里那几个布尔量)。
*/
internal const val EXTRA_PROACTIVE_OPEN_MAIN_SESSION = "top.yeij.cyrene.extra.PROACTIVE_OPEN_MAIN_SESSION"
/**
* 弹一条「她主动说的话」的通知。
*
* 只做四件事:按 [quiet] 选渠道、建渠道(惰性,第一次真要用时才建)、拼通知、`notify`。**判据、
* 去重与 id 的派生都不在这里**(那是 [ProactiveNotifier] 与 [proactiveNotificationId] 的事)
* ——本函数被调用就意味着「这一次确实该弹,且这条消息的号已经定好了」,它只负责把号用在通知上。
*
* ## 为什么 id 是入参而不是这里恒定一个
* 同 id 的 `notify` 是**替换**:恒用一个号就等于「后来的覆盖先来的」——她连说几句,用户只看到
* 最后一句。入参那个号由 [proactiveNotificationId] 按消息派生(输入就是去重键),于是同一句
* 无论到几次都落在同一条通知上,而不同的句子各占一条。
*
* ## 免打扰只换渠道,别的一个字节都不动
* [quiet] 为真时整条通知除了「发去哪条渠道 / 7.x 的优先级」以外**逐字节不变**:同一个标题、同一
* 段正文、同一个通知号、同一次点击行为。用户要的是「别在我正做事的时候盖住屏幕」,不是「别让我
* 知道」——所以什么都不该少,只是她的话安静地躺在通知栏里。
*
* ## 没授通知权限时什么都不做
* `minSdk 33` ⇒ `POST_NOTIFICATIONS` 恒是运行时权限,而系统在未授权时**静默丢弃**通知。与其发一条
* 必然无声无息的东西,不如在这里就收手(判据复用保活那处 [notificationsAllowed],全 App 只有
* 一份「授没授」的口径)。用户之后在设置页授权、或直接开通了保活那个开关,下一句话就能弹出来
* ——本函数每次都**现读**权限,不缓存。
*
* ## 小图标用的是平台资源
* 工程里没有为通知准备单色小图标(`@mipmap/ic_launcher` 是彩色的,拿它当通知小图标在通知栏上
* 会显示成一块白斑)。所以用平台自带的 `stat_notify_chat`——与保活那条用 `stat_notify_sync` 是
* 同一个做法。要换成本应用的图标时,只需换下面这一行。
*
* @param notificationId 这条消息的通知 id,同时也是它点击用的 request code(两者必须一起动:
* 只换 id 不换 request code,点后到的那条通知会串到先到那条的 `PendingIntent` 上)。
* @param quiet 「免打扰」开关**当下的值**:`true` ⇒ 发到 [QUIET_PROACTIVE_CHANNEL_ID](有提示音、
* 不弹横幅);`false` ⇒ 发到 [PROACTIVE_CHANNEL_ID](弹横幅)。**刻意不给默认值**:调用方忘写
* 这一位时症状是「开关按了没反应」,那种坏法没有任何报错——让编译器拦住它。
* 值由调用方每次**现读**(见 `AppContainer` 里 `post` 那个 lambda),所以开关一改不必重启 App,
* 下一条通知就走新渠道。
*/
internal fun postProactiveNotification(
context: Context,
text: String,
notificationId: Int,
quiet: Boolean,
) {
if (!context.notificationsAllowed()) return
// 选渠道与建渠道用的是**同一个** spec:两处各自 `if (quiet)` 就会有一条渠道永远建不出来,
// 而它的症状是「开了免打扰之后她再也不通知我了」(Android 8+ 对不存在的渠道是静默丢弃)。
val channel = proactiveChannelSpec(quiet)
ensureProactiveChannel(context, channel)
// request code 随消息走(`proactiveNotificationId` 的下界远高于保活那条用的 `0`,所以不会
// 撞上它):两个 Intent 都是「启动 MainActivity」、没有 action / data / categories ⇒
// `Intent.filterEquals` 判它们**相等**,而 `PendingIntent` 的相等判据里**不含 extras**。
// 同 request code 时系统直接复用**先建的那一个**,后建这枚的 extra 会被悄悄丢掉。
val openApp = PendingIntent.getActivity(
context,
notificationId,
Intent(context, MainActivity::class.java).putExtra(EXTRA_PROACTIVE_OPEN_MAIN_SESSION, true),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(context, channel.id)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(PROACTIVE_NOTIFICATION_TITLE)
.setContentText(text)
// 展开态显示完整正文(`setContentText` 那一行放不下 120 字,而截断只解决「过长」,
// 解决不了「一行的宽度」)。
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(openApp)
// 点掉即消失:这条通知说的是一次刚发生的事,用户点开就等于看到了。
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// 横幅:Android 8+ 由**渠道重要级**决定(上面那条 spec 选出来的那条渠道),这一句管的是
// 7.x 及更早(那边还没有渠道,优先级就是全部)。两处必须同向——免打扰那一档在这里也得
// 降回 DEFAULT,否则老设备上「开着免打扰却照样弹横幅」。
.setPriority(if (quiet) NotificationCompat.PRIORITY_DEFAULT else NotificationCompat.PRIORITY_HIGH)
.build()
// 一事一条:号随消息走。撞号会发生,代价与改前一样是「后来的把先来的替换掉」,
// 理由与概率写在 `proactiveNotificationId` 的 KDoc 里。
NotificationManagerCompat.from(context).notify(notificationId, notification)
}
/**
* 建(或更新)**这一次要用的那一条**渠道。惰性创建:一个从没收到过她主动消息的用户,系统设置的
* 渠道列表里不会平白多出一条「她的话」。
*
* ## 为什么只建「这一次要用的那一条」,而不是两条都建
* 「免打扰」决定的是**下一条**通知发去哪条渠道,而渠道只需要在 `notify` 之前存在。两条都建的话,
* 一个从没开过免打扰、将来也不打算开的用户,系统设置里会平白多出一条他从没见过的「她主动说的话
* (免打扰:只在通知栏)」——那是我们替他做的选择,也算脏数据。所以这里只 ensure 被选中的那一条。
*
* ⚠️ 但**必须在 `notify` 之前** ensure:Android 8+ 上目标渠道不存在时,那条通知是被系统**静默
* 丢弃**的(不报错、不打日志、只是什么都没有)。这正是「只建一条」这个省法的代价 —— 它靠
* [postProactiveNotification] 里「选渠道」与「建渠道」用的是**同一个** [ProactiveChannelSpec]
* 兜住(两处各自 `if (quiet)` 就会有一条渠道永远建不出来,而症状是「开了免打扰之后她再也不
* 通知我了」——用户只会以为是她不说话了)。
*
* ## 另一条渠道**不删**
* 它是「这一次没选」,不是「以后不再用」:用户来回切开关时,他在系统设置里对另一条调过的横幅 /
* 静音设置得留着(删掉 = 把他的选择一起删掉,还得重新建一条空白的)。唯一会删的是
* [LEGACY_PROACTIVE_CHANNEL_ID] —— 那条 id 本端**再也不会发**,而它的重要级又改不动,留着只会
* 在系统设置里多一条永远不再出现的条目(用户可能在那儿调半天、却发现怎么调都没变化)。
*
* 重复调用是幂等的(同 id ⇒ 更新那条渠道,不会建出第二条)。**注意重要级只在第一次创建时生效**
* ——它已经在 [PROACTIVE_IMPORTANCE] / [QUIET_PROACTIVE_IMPORTANCE] 上定死(渠道名与描述此后仍
* 可以改,重要级改不了)。
*
* @param spec 这一次要用的那条渠道(由 [proactiveChannelSpec] 按「免打扰」开关选出来)。
*/
private fun ensureProactiveChannel(context: Context, spec: ProactiveChannelSpec) {
val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
description = spec.description
setShowBadge(true)
}
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
// 旧的 DEFAULT 渠道删掉:重要级改不动,留着只会在系统设置里多一条永远不再出现的条目
// (用户可能在那儿调半天、却发现怎么调都没变化)。删一条不存在的渠道是幂等的。
manager.deleteNotificationChannel(LEGACY_PROACTIVE_CHANNEL_ID)
}
@@ -0,0 +1,107 @@
package top.yeij.cyrene.core.notify
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
/**
* 一帧「她主动说的话」进到本类之后**发生了什么**(纯判定的结果,可单测)。
*
* 分成五档而不是一个布尔,是为了让「为什么这次没弹」在日志与测试里都能一眼看穿:这五条各自
* 对应一个完全不同的理由,而在通知这一档上,「没弹」与「弹了」一样需要说得清。
*/
enum class ProactiveNotifyOutcome {
/** 弹了。 */
Posted,
/** 用户正看着主会话(前台 ∧ 聊天页显示主会话):她的话就出现在他眼前,不打扰。 */
SeenOnScreen,
/** 同一条已经到达过(实时帧与落库帧两条路,或服务端重推)。 */
Duplicate,
/** 帧里没有可显示的正文(坏帧 / 落库失败的空帧)。 */
NothingToSay,
/**
* 不属主会话。她主动说的话落的是主会话(服务端 `history.MainSessionID`);帧上若明说了别的
* 会话,那不是这件事,绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。
*/
OtherSession,
}
/**
* 「她主动说的话」的通路(W53N):**判据 + 去重 + 落地**收在一处,Android 那一步由 [post] 注入。
*
* ## 谁把帧喂进来(W57 起)
* 后端两条路(实时帧 `proactive_message`、落库帧 `message_persisted{proactive:true}`)在客户端
* 汇于一处:**进程级**的帧消费点 `app.ProactiveFrameIntake` 订着那条共享连接,把这两种帧折成下面
* [onProactiveFrame] 的四个入参喂过来(同一条消息无论走哪条路、到几次,都只经过这一处)。
* 本类因此只是一个**纯逻辑 + 一个回调**,不订阅任何东西、不碰连接、不认识 Android——测试里传个
* 假的 [post] 就能把它整条驱动起来。
*
* ## 它绝不碰连接
* 连接的所有权在登录态(`SessionsViewModel` 那处 `LoggedIn → connect()`)。本类只在帧**已经到达
* 之后**做判断——帧没到就没有任何事发生,它不会去重连、不会去补拉、不会去订阅。
*
* ## 一次帧到这里的顺序(每一步的顺序都有理由)
* 1. [ProactiveNotifyOutcome.OtherSession]:不是主会话,连账本都不必碰;
* 2. [ProactiveNotifyOutcome.NothingToSay]:先看有没有可显示的字。**这一步不记账**——同一句话的
* 另一条路上正文可能是齐的(实时帧空、落库帧有字的形状漂移),记了就会把那条能弹的挡成重复;
* 3. [ProactiveNotifyOutcome.Duplicate]:记账与查重。**先记账再判「会不会被看到」**,因为「用户
* 正看着」那一次也算这条已经到达:他当场看过了,另一条路再补一帧时不该为一句**看过的话**弹
* 通知(他这会儿可能已经切走了);
* 4. [ProactiveNotifyOutcome.SeenOnScreen] / [ProactiveNotifyOutcome.Posted]:最后才问时机。
*
* @param presence 「App 在不在前台 / 聊天页显示着谁」,由 `MainActivity` 与 `ChatScreen` 报告。
* @param post 真正的落地动作(Android 那边是建渠道 + `notify`)。第二个入参是**这条消息派生出来的
* 通知 id**(见 [proactiveNotificationId]):落地侧必须用它,别再恒用一个号——恒用一个号时
* `notify` 是替换,她连说几句就只剩最后一句。测试传一个记录用的假实现即可。
* @param mainSessionId 主会话 id。默认取 [SessionSummaryDto.MAIN_SESSION_ID],测试可换。
*/
class ProactiveNotifier(
private val presence: AppPresence,
private val post: (text: String, notificationId: Int) -> Unit,
private val mainSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
private val recent: RecentProactiveNotices = RecentProactiveNotices(),
) {
/**
* 一帧到了(实时帧与落库帧**共用**这一处,所以同一条消息无论到几次都只弹一次)。
*
* @param sessionId 帧上的会话归属;`null` / 空白 = 帧壳没给(实时帧就是这样:服务端构造它时
* 不设 `sessionId`)⇒ 按「无法判定」放行,由 [mainSessionId] 那条事实兜住归属。
* @param id 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路的公共键。
* @param messageId IM 侧消息号(实时帧才有;落库失败那一档它是最后的服务端键)。
* @param timestamp 服务端给的时间**字面量**(只作去重兜底,排序与本类无关)。
* @param content 她的原话(原始文本,标记由 [proactiveNotificationText] 剥)。
*/
fun onProactiveFrame(
sessionId: String?,
id: String?,
messageId: String?,
timestamp: String?,
content: String,
): ProactiveNotifyOutcome {
if (!sessionId.isNullOrBlank() && sessionId != mainSessionId) {
return ProactiveNotifyOutcome.OtherSession
}
val text = proactiveNotificationText(content) ?: return ProactiveNotifyOutcome.NothingToSay
val key = proactiveNotifyKey(id, messageId, timestamp, text)
val firstArrival = recent.markIfNew(key)
if (!firstArrival) return ProactiveNotifyOutcome.Duplicate
// 判据只回答「弹不弹」,「用户会不会正好看到」是它的反面——别在接线层另写一遍这个取反
// (两份口径早晚会分叉,而这里分叉的代价是「她的话在用户眼前弹出来」)。
val seenOnScreen = !shouldNotifyProactive(
appInForeground = presence.appInForeground,
viewingTargetSession = presence.isShowingChatSurface(mainSessionId),
)
if (seenOnScreen) return ProactiveNotifyOutcome.SeenOnScreen
// id 与去重键同源,正是为了让「同一条 ⇒ 同一个号」与「同一条 ⇒ 只弹一次」永远不会分叉。
post(text, proactiveNotificationId(key))
return ProactiveNotifyOutcome.Posted
}
}
@@ -0,0 +1,192 @@
package top.yeij.cyrene.core.notify
import kotlin.math.absoluteValue
import top.yeij.cyrene.domain.parse.parse
/**
* 「她主动说的话」该不该弹一条系统通知 —— 纯函数(W53N)。
*
* 主人对时机的原话是「**App 不在前台,或者不在聊天页**」。写成一个判据(而不是直接写成
* `!viewing`)是为了让两个事实**各自**可见、可穷举、可测试:它们是两个方向完全不同的东西,
* 合成一行以后「为什么这一档不弹」就再也说不清了。
*
* 语义(**判断标准只有一条:这条消息此刻会不会正好被用户看到**):
*
* | [appInForeground] | [viewingTargetSession] | 结论 | 场景 |
* |---|---|---|---|
* | true | true | **不弹** | 用户正看着主会话,她的话就出现在他眼前 |
* | true | false | 弹 | 用户在看设置页 / 别的会话(甚至另一个会话的聊天页) |
* | false | false | 弹 | App 退到后台 / 熄屏(进程被保活着,帧还在到) |
* | false | true | 弹 | **Android 上真实存在**:Activity `onStop` 之后组合并没有销毁,
* 聊天页仍「显示」着主会话。此时的用户根本看不见它,所以照弹 |
*
* 最后一行是「两个事实不许互相顶替」的那一半:只看聊天页显示着谁,就会在 App 退到后台以后
* 静默漏掉**所有**她的话。
*
* @param appInForeground 此刻有没有 Activity 在「可见」这一档(见 [AppPresence.appInForeground])。
* @param viewingTargetSession 聊天页此刻显示的是不是**她说这句话的那个会话**。注意它**不含**
* 「前台」的含义(见 [AppPresence.chatSurfaceSessionId])——它只说「屏幕上摆着哪一页」。
*/
fun shouldNotifyProactive(
appInForeground: Boolean,
viewingTargetSession: Boolean,
): Boolean = !(appInForeground && viewingTargetSession)
/**
* 通知正文的长度上限(字符)。超出的部分截断加 `…`。
*
* 取值理由:通知在折叠态只显示一行(约 20~30 字),展开态(`BigTextStyle`)能显示四五行。
* 这个上限是**展开态**的预算——正文不砍到一行那么短(那样会看不出她在说什么),但也不许整屏
* 铺开。真正的长文在聊天页里一字不少。
*/
const val PROACTIVE_NOTIFICATION_MAX_CHARS = 120
/**
* 去重账本的容量。同一条她的话最多两条路会到(实时帧 + 落库帧),容量只需要覆盖「最近一小段」;
* 取 64 是给「她连说几句 + 两条路交错」留的余量,同时**有界**——进程活一整天也不会涨。
*/
const val RECENT_PROACTIVE_CAPACITY = 64
/**
* 最近**已经到达过**的 proactive 消息的键(进程级、容量有限、先进先出淘汰)。
*
* ## 为什么必须有它
* 同一条消息会走**两条**路到本端:实时帧 `proactive_message`(带**落库行** `id`)与落库帧
* `message_persisted{proactive:true}`(带 `message.id`,同一个值)。不去重就是同一条消息弹两次。
*
* ## 为什么记的是「到达过」而不是「弹过」
* 因为「用户正看着主会话」那一次也要记:他当场看过这条了,随后另一条路再补一帧时**不该**为一句
* 已经看过的话弹通知(他这会儿可能已经切到别的会话了)。两个方向都要求同一件事:**先到的那一次
* 说了算**。判据见 [ProactiveNotifier.onProactiveFrame]。
*
* 有界:超过容量按插入顺序丢掉最老的(`LinkedHashSet` 的迭代顺序就是插入顺序)。
* 只活在本进程里,不持久化——重启之后最多重复弹一条「刚刚到达过、还没被看过」的话,那比
* 为一个通知判据在磁盘上留一份状态诚实得多。
*/
class RecentProactiveNotices(private val capacity: Int = RECENT_PROACTIVE_CAPACITY) {
private val seen = LinkedHashSet<String>()
/**
* 记下这个键,并回「它是不是**第一次**来」。返回 false = 这条已经到达过,调用方必须闭嘴。
*
* 空键恒回 false(「没有键」不是一个可去重的身份,绝不把它当成一条新消息的凭据)。
*/
@Synchronized
fun markIfNew(key: String): Boolean {
if (key.isBlank()) return false
if (!seen.add(key)) return false
while (seen.size > capacity.coerceAtLeast(1)) {
val oldest = seen.firstOrNull() ?: break
seen.remove(oldest)
}
return true
}
/** 账本里现有多少个键(测试与诊断用)。 */
@Synchronized
fun size(): Int = seen.size
}
/**
* 一条 proactive 消息的去重键(纯函数)。
*
* 按**服务端给的 id 优先级**取,最后才退到正文:
* 1. 落库行 id(实时帧的 `data.id` / 落库帧的 `message.id`)——两条路**唯一的公共键**,
* 也是服务端唯一保证「同一条消息同一个值」的键;
* 2. IM 侧消息号(只有实时帧有:落库失败那一档它仍然在,`id` 缺席);
* 3. 时间字面量(同上,最后的服务端键);
* 4. [fallback](调用方传**正文**)——服务端一个键都没给时的兜底。
*
* 第 4 条是刻意的取舍:它会让「没有 id、正文又一个字不差」的两条消息被并成一条(少弹一次),
* 而反过来(不兜底 ⇒ 无键可查 ⇒ 每来一帧都弹)会把服务端重推的同一条话弹成两条。**少弹**
* 是这两边更轻的那一侧——通知只是提醒,话本身在聊天页里一条都不少。
*/
fun proactiveNotifyKey(
id: String?,
messageId: String?,
timestamp: String?,
fallback: String,
): String = listOf(id, messageId, timestamp)
.firstOrNull { !it.isNullOrBlank() }
?: fallback
/**
* 派生 id 的取值**下界**(见 [proactiveNotificationId])。
*
* 通知 id 与 `PendingIntent` 的 request code 都是**全 App 共用**的命名空间,而它们现有的占用者
* (保活那条常驻通知 `KEEP_ALIVE_NOTIFICATION_ID` = 1、它点击用的 request code = 0)全在低位。
* 从远高于它们的地方起步,「派生 id 不撞既有 id」就是**结构性**成立的,不是靠「碰巧没撞上」。
*/
private const val PROACTIVE_NOTIFICATION_ID_FLOOR = 1_000
/**
* 派生 id 的取值**宽度**(见 [proactiveNotificationId])。
*
* 取这么宽是为了让 id 空间尽量大(撞号的概率随宽度下降),同时**贴着 `Int.MAX_VALUE` 留出下界的
* 位置**:「下界 + 宽度」一旦越过 `Int.MAX_VALUE`,末尾的 `.toInt()` 就会折回负数。
*/
private const val PROACTIVE_NOTIFICATION_ID_SPAN = 2_000_000_000
/**
* 一条 proactive 消息对应的**通知 id(同时也是它点击用的 request code)**——纯函数,由去重键
* [proactiveNotifyKey] 的输出派生。这就是「一事一条」的全部落地形状(W54A)。
*
* ## 为什么必须与去重键同源
* 改前所有她的话共用一个固定 id,而 `notify` 遇同 id 是**替换**:她连说几句,用户只看到最后
* 一句,前面那几句在他拿起手机之前就已经没了。按消息派生要回答的第一个问题是「凭什么是同一条」
* ——那正是 [proactiveNotifyKey] 已经回答过的(实时帧与落库帧共用它)。**两处口径必须永远同源**:
* 去重说「这是同一条」、派生说「这是同一条 ⇒ 同一个号」;分成两套,就会出现「认得出是同一条、
* 却排出两条 id」的形状漂移。
*
* ## 它会撞号,后果如实说
* `String.hashCode()` 只有 32 位,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 这一段 ⇒ **不同消息
* 派生出同一个 id 是会发生的**(约 2×10⁹ 的空间,按生日问题算,通知栏里装几十条时概率可忽略)。
* 撞上的后果与改前**一模一样**:后来那条替换先来那条,用户少看见一条提醒。这是刻意接受的那一侧
* ——通知只是「她找你了」这一下,她的话本身在主会话里一条不少;反过来要绝对不撞,就得在服务端
* 唯一号之外再维护一套映射并持久化它,代价比「极少数情况下少一条提醒」大得多。
*
* ## 两个坑都在这里堵上
* 1. **号必须为正**:通知 id 是一个有符号 `int`,而这个命名空间里正数是纪律(本仓保活那条常驻通知
* 是 1;平台对前台服务的 id 更直接写明「must not be 0」)。而 `hashCode()` 可以是负的、甚至
* 正好是 `Int.MIN_VALUE`——后者连 `abs` 都兜不住(在 int 域里 `abs(Int.MIN_VALUE)` 仍是它自己)。
* 所以这里**先展宽到 `Long` 再取绝对值**:`Int.MIN_VALUE.toLong()` = 2147483648,取绝对值是安全
* 的,再折进 [PROACTIVE_NOTIFICATION_ID_SPAN] 后落在正区间。
* 2. **不撞既有 id**:见 [PROACTIVE_NOTIFICATION_ID_FLOOR]。
*/
internal fun proactiveNotificationId(key: String): Int {
val magnitude = key.hashCode().toLong().absoluteValue
return (PROACTIVE_NOTIFICATION_ID_FLOOR + magnitude % PROACTIVE_NOTIFICATION_ID_SPAN).toInt()
}
/**
* 通知正文:她的原话,**剥掉标记、折成一行、过长截断**;没有可显示的字时回 `null`。
*
* ## 为什么不直接发原始 `content`
* 她的内容里带 `<p>` / `<action>` / `<narration>` 标记(服务端原样落库),直接放进通知会把标签
* 露给用户(本仓在会话列表那一行上早就踩过同一件事,见 `ui.sessions.toPreviewText`)。
* 这里**复用权威解析器** [parse] 而不是再写一份剥标签的正则:同一件事有两份口径早晚会分叉,
* 而分叉的那一侧就是通知里露标签的那一侧。解析出的全部片段按原顺序拼回(旁白/动作也留着——
* 那也是她说的话),片段之间补空格,免得「正文紧贴旁白」时两侧的字粘成一个词。
*
* 截断按 UTF-16 码元走,但**不劈开代理对**(emoji 这类字符由两个码元组成,劈开会显示成半个
* 乱码方块):末尾正好落在高代理项上时整体后退一位。
*/
fun proactiveNotificationText(
content: String,
maxChars: Int = PROACTIVE_NOTIFICATION_MAX_CHARS,
): String? {
val flat = parse(content)
.joinToString(" ") { bubble -> bubble.parts.joinToString(" ") { part -> part.text } }
.replace(WHITESPACE_RE, " ")
.trim()
if (flat.isEmpty()) return null
// maxChars <= 0 = 「不截断」(调用方明确要全文);其余情况只在真的超长时才动手。
if (maxChars <= 0 || flat.length <= maxChars) return flat
var end = maxChars
if (Character.isHighSurrogate(flat[end - 1])) end -= 1
return flat.substring(0, end).trimEnd() + "…"
}
private val WHITESPACE_RE = Regex("\\s+")
@@ -8,8 +8,14 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
@@ -72,6 +78,23 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
else -> InterruptMode.AppendQueue
}
/**
* 「免打扰」([AppPrefs.proactiveQuiet])没有落盘值时的取值 —— **`false` = 都弹**(横幅 + 提示音
* + 通知栏条目)。老安装、或从没碰过这个开关的用户走的都是它。
*
* 两条路必须落在**同一个**值上:[AppPrefs] 那个数据类的默认参数(进程刚起来、还没读到盘时用),
* 与 [toProactiveQuiet](盘上没有这个键时用)。分叉的症状是「开关显示关着、行为却是安静的」
* ——一个默认就安静的 App 会让人以为通知坏了。
*/
internal const val PROACTIVE_QUIET_DEFAULT = false
/**
* 落盘布尔 → 免打扰开关(`AppSettings` 的 `proactive_quiet` 键读取口;抽成顶层函数是为了能单测
* ——`AppSettings` 本体要 Android + DataStore,单测里起不来)。缺省 / 盘上没有这个键
* ⇒ [PROACTIVE_QUIET_DEFAULT]。
*/
internal fun Boolean?.toProactiveQuiet(): Boolean = this ?: PROACTIVE_QUIET_DEFAULT
/**
* App 级偏好(非机密)。
*
@@ -79,12 +102,20 @@ fun String?.toInterruptMode(): InterruptMode = when (this) {
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
* @param keepAliveEnabled 后台保持连接(常驻一条前台服务,**默认关**;判据见
* `core.keepalive.shouldKeepAliveRun`)
* @param proactiveQuiet 免打扰:她主动找你说话**只在通知栏提示,不在屏幕顶部弹横幅**(**默认
* `false` = 都弹**)。⚠️ 它的语义**不是静音**:提示音与通知栏条目照旧,只是不盖住用户正在做的
* 事。落地形状是**换一条渠道**(见 `core.notify.proactiveChannelId`)——横幅由渠道重要级决定,
* 而重要级建完就改不了,所以只能两条渠道切着用
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
val keepAliveEnabled: Boolean = false,
val proactiveQuiet: Boolean = false,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
@@ -94,15 +125,45 @@ class AppSettings(context: Context) {
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
/**
* 与 `AppSettings` 同寿的收集 scope —— 也就是**与进程同寿**(`AppContainer` 在
* `Application.onCreate` 里建它)。刻意**不取消**:[current] 的下一个读者可以是任何时刻的
* 任何一次通知落地,没有比进程结束更晚的时刻需要它。
*/
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val data: Flow<AppPrefs> = store.data.map { prefs ->
AppPrefs(
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
keepAliveEnabled = prefs[KEY_KEEP_ALIVE] ?: false,
proactiveQuiet = prefs[KEY_PROACTIVE_QUIET].toProactiveQuiet(),
)
}
/**
* [data] 的**热副本**:进程里任何地方都读得到「当下是什么偏好」。
*
* ## 为什么需要它
* 发通知那一刻**没有挂起上下文**:帧的落地是一次同步回调(`ProactiveNotifier.post` 不是
* `suspend`,也不该是——它是连接那条已建立的流水线上的最后一跳),而 DataStore 的读取是挂起
* 的。所以只能由这里常驻订阅、把最近一次读数摆在一个同步读得到的地方(`StateFlow.value`)。
*
* ## 为什么是 `Eagerly`
* 它的读者(发通知那条路)随时可能来,而「等有人订阅了再开始读」意味着第一次落地时读到的是
* 初始值。`Eagerly` 让这一次读盘在设置对象构造时就发生 —— 与 UI 那边(`CyreneRoot` /
* `SettingsScreen` 各自 `collectAsState`)读的是同一份上游数据,没有多出一次磁盘 IO 的量级。
*
* ⚠️ 它有一个**毫秒级**的不准确窗口:进程刚起来、第一次读盘还没回来时,[StateFlow.value] 是
* [AppPrefs] 的默认值(免打扰 = false ⇒ 弹横幅)。要撞上它,得在「本对象构造完 → DataStore
* 第一次读完」之间就有一条她的主动消息走完建连、认证、收帧(`ProactiveFrameIntake`)——
* 实际上到不了那么快。**代价封顶是「开了免打扰的用户在冷启动瞬间多看到一次横幅」**,
* 而不是丢通知。
*/
val current: StateFlow<AppPrefs> = data.stateIn(scope, SharingStarted.Eagerly, AppPrefs())
suspend fun setThemeMode(mode: ThemeMode) {
store.edit { it[KEY_THEME_MODE] = mode.name }
}
@@ -119,6 +180,19 @@ class AppSettings(context: Context) {
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
}
suspend fun setKeepAliveEnabled(enabled: Boolean) {
store.edit { it[KEY_KEEP_ALIVE] = enabled }
}
/**
* 写「免打扰」。**写这一处就够了**:发通知那条路每次现读 [current](见它的 KDoc),所以这里
* 一落盘、DataStore 一发出新值,**下一条**通知就走新渠道——不必重启 App,也不必在这里再去
* 通知谁。
*/
suspend fun setProactiveQuiet(enabled: Boolean) {
store.edit { it[KEY_PROACTIVE_QUIET] = enabled }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
@@ -130,5 +204,7 @@ class AppSettings(context: Context) {
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
val KEY_KEEP_ALIVE = booleanPreferencesKey("keep_alive_enabled")
val KEY_PROACTIVE_QUIET = booleanPreferencesKey("proactive_quiet")
}
}
@@ -20,7 +20,7 @@ private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataS
*
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
@@ -35,6 +35,20 @@ data class SessionPrefs(
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
}
/**
* 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。
*
* 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符
* 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。
* 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key
* (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。
*
* 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名**
* 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。
*/
internal fun accountFingerprint(baseUrl: String, username: String?): String =
"$baseUrl|${username.orEmpty()}"
class SessionPreferences(context: Context) {
private val json = ApiClientFactory.json
@@ -0,0 +1,100 @@
package top.yeij.cyrene.core.session
/**
* 本端**同时**为一个账号留几个会话槽位(N=3,[chatSessionEvictions] 的默认 `keep`)。
*
* 为什么是 3(**主人拍板的口径**):它要在「切回去时那几屏东西还在」与「不留着一堆没人再看的
* 消息列表」之间取一个数。驱动这个数的是**内存**——每个槽位留着一整页已落库消息(次级会话里可能
* 是内联 base64 图片),而 S4 登记的正是「访问过的会话一个都不回收」。不跟着会话数走的理由也在
* 这里:它是内存上限,不是「用户可能有多少会话」。
*/
const val CHAT_SESSION_SLOT_KEEP = 3
/**
* 一格槽位在**被判定那一刻**的样子([chatSessionEvictions] 的入参,**纯值**)。
*
* 三项都由调用方现读现喂,判据自己不看 VM、不读表、不碰时钟(先例:`ui.sessions.silentRefreshStep`
* 只回结论,账本与时钟都由调用方喂)——所以它在 JVM 上可以穷举。
*
* @param key 槽位的稳定标识(`账号指纹|会话 id`)。它就是**结论的载体**:判据只把它原样列出来,
* 「拿这个 key 去哪里找槽位」是调用方的事。
* @param sessionId 会话 id。**它和 [key] 不是同一件事**:key 里还带着账号指纹(换账号之后同一个
* 会话 id 是另一格),而这一位只回答一个问题——**它是不是主会话**(主会话永不回收,见
* [chatSessionEvictions] 第 1 条)。两样都留着是刻意的:把「主会话」这件事换个说法搬出判据,
* 它就再也钉不住了(那是本任务的红线之一)。
* @param lastUsedAt **最后一次成为当前会话**的时刻(毫秒)。「最近」的口径就是它,理由见
* [chatSessionEvictions]。`0` = 建出来还没打开过(组合被丢弃时留下的空槽位)。
* @param holdsUnsentWork 这一格里还有**本端唯一一份**的内容(没发出去的草稿 / 没上路的发送)。
* 它必须一路传到这里,是因为「回收」是一条**回不来**的动作:判据要在动手之前就能拦下
* 「把用户还没发出去的字一起收掉」——那比「多留一格」严重得多(红线:不许静默丢内容)。
*/
internal data class ChatSessionUsage(
val key: String,
val sessionId: String,
val lastUsedAt: Long,
val holdsUnsentWork: Boolean,
)
/**
* 这一刻该交出去哪几格(**纯函数**,[ChatSessionSlots] 与测试共用)。返回**要回收的槽位 key**,
* 最该走的在最前(最旧的最先);没有要回收的就返回空表。
*
* ## 「最近」的口径:最后一次**成为当前会话**的时刻
* 三选一里选的它,理由是另外两个都答不对这个问题:
* - 「最后一次收到这个会话的帧」**不是用户的活动**:共享连接上别的会话的帧一直在来(服务端不按
* 会话订阅),一条后台广播就能把一个用户根本没再打开过的会话顶成「最近」。
* - 「最后一次有新消息」同理,而且是**别人**的活动(她在次级会话里说一句,本端就要为它留一格)。
* - 「最后一次成为当前会话」才是「用户切回去时希望看到什么」这件事本身:槽位存在的意义就是
* 那一屏不用重新加载,而**用户看得见哪个会话**只有这一个时刻能表达。
*
* 它由 `ChatScreen` 在**组合到某个会话时**写入(那正是「它成了当前会话」这一刻),所以判据这边
* 只需要比大小,不需要任何时钟:谁的时刻新,谁就留下。同一毫秒并列时按**账本里的先后**退回
* ([usages] 的顺序 = 槽位被建出来的顺序,见 [ChatSessionSlots]),也就是「谁先建谁先走」。
*
* ## 判据与优先级(自上而下,先命中先返回)
* 1. **主会话永不回收**:它是抽屉与主流程的锚——用户随时会退回它,而它那一格正是「一进 App
* 就有东西看」的来处。这一条**不看** `keep`,也不占「最近」的名额:主会话就是主会话,
* 不看它多久没被打开过。
* 2. **屏幕上那一格永不回收**(`key == [currentKey]`):它的 VM 一死,屏幕上那一段就再也不会更新
* (帧收不到、`viewModelScope` 已经取消 ⇒ 连发送都会静默不发),要等下一次重组才会自己修回来
* —— 那是「回收」绝不能带来的一种状态。这一条与主会话同属「按身份豁免」,与时刻无关
* ([currentKey] 那一格通常本来就是最新的,但不靠这件事成立)。
* 3. **还有没交代完的事的槽位不回收**([ChatSessionUsage.holdsUnsentWork]):那些内容只在本端
* 这一份(草稿、排队等连接的发送),回收掉就**回不来**了。这一条压过 `keep` 的语义——
* 它宁可让本端多留一格,也不做一件不可逆的事。
* 4. 其余按 [ChatSessionUsage.lastUsedAt] 从新到旧留 `keep` 格,**连上面三条占住的一起算**:
* `keep` 是「本端同时保留几格」这个内存目标,所以被豁免的那些**占名额**——不然「最多 3 格」
* 在豁免生效时会变成 3 + 豁免数,那个数就没有上限了。(真实使用里豁免的那些几乎总是
* 「刚刚还在用的」,本来就在最近 3 个里,这条只在极端情况下才真的挤掉谁。)
*
* 判据里除了这些入参什么都不看,才能穷举、才能在 JVM 上单测(先例:`ui.chat.reconnectPull`、
* `ui.sessions.silentRefreshStep`)。
*
* ## ⚠️ 它**只**回答「交出去哪几格」
* 「交出去」具体是什么动作(清掉那份 `ViewModelStore`、于是这一格的 VM 随 `onCleared` 一起走)
* 是调用方的事,而且**必须**是那份 store 自己的 `clear()`:连接是全 App 共享的那一条,它的生死
* 只由登录态表达(主人是 `SessionsViewModel`),回收一格**绝不能**碰它——回收一格的状态与
* 「别人的流、别人的未读」是两件事,这条判据里连一个字节的连接概念都没有。
*
* @param usages 账本里的全部槽位(顺序有意义,见「同一毫秒并列」那一句)。
* @param mainSessionId 主会话的 id(由装配处注入,判据不认识那个常量本身)。
* @param currentKey **此刻屏幕上**那一格的 key([ChatSessionSlots] 刚记完账的那一格)。
* @param keep 本端**同时**保留几格(含主会话、屏幕上那一格与「还有没交代完的事」的那些),
* 默认 [CHAT_SESSION_SLOT_KEEP]。
*/
internal fun chatSessionEvictions(
usages: List<ChatSessionUsage>,
mainSessionId: String,
currentKey: String,
keep: Int = CHAT_SESSION_SLOT_KEEP,
): List<String> {
val reclaimable = usages
.filter {
it.sessionId != mainSessionId && it.key != currentKey && !it.holdsUnsentWork
}
.sortedBy { it.lastUsedAt }
val budget = keep - (usages.size - reclaimable.size)
val surplus = reclaimable.size - budget
if (surplus <= 0) return emptyList()
return reclaimable.take(surplus).map { it.key }
}
@@ -0,0 +1,184 @@
package top.yeij.cyrene.core.session
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
/**
* 本端留着的**会话槽位**(`账号指纹|会话 id` → 那一格的聊天状态)。S4 的解药。
*
* ## 它回答的是哪一个问题
* `ChatViewModel` 是**按会话**建的(`sessionId` 是构造参数),于是「切走 / 删会话都不回收」意味着
* 访问过 N 个会话就常驻 N 份消息列表(含内联 base64 图片)、N 份对共享连接的订阅、以及**每次重连
* 各自一轮** `after` 补拉。这里就是那个「N」的闸门:**一格一个 `ViewModelStore`**,超出的按
* [chatSessionEvictions] 的结论交出整格。
*
* ## 为什么一格一个 `ViewModelStore`(而不是给 key 加一代号)
* 回收的本体动作是「让那一格的 VM 真正死掉」:`ViewModelStore.clear()` 会走 `ViewModel.onCleared()`
* 并取消 `viewModelScope`——于是**订阅、补拉、正在飞的那些请求一起停下**。此前每一格都塞在
* Activity 级那个 store 里,而它**只有整体 `clear()`**,没有「按 key 交出一格」的入口:那种情况下
* 只能让 key 换一代、把旧实例**留在 store 里**(它照样活着、照样收帧——那正是 S4 的另一半没修掉
* 的样子)。一格一份 store 之后,`clear()` 的粒度就正好是「这一格」。
*
* ## 回收**不碰**共享连接
* 连接是全 App 唯一的那一条(`AppContainer.chatSocket`),它的生死只由登录态表达
* (主人是 `SessionsViewModel`)。本类碰的只有「自己那一格的 store」:掐掉的是这一格的订阅,
* 别的会话的流、别的会话的未读、以及那条连接本身一个字节都不动。
*
* ## 谁碰它、什么时候(全部在主线程上)
* - **`ChatScreen`**:组合到某个会话时 [slotFor] 取(或建)槽位——VM 必须有地方建,而槽位就是
* 那一格的存放处,所以这一步没法挪到组合之后;组合完成之后 [onOpened] 记账 + 回收。
* - **`SessionsViewModel`**:本端**唯一**的删除入口,删成功后 [reclaim](那一格跟着会话一起走);
* 登出 / 换账号时 [clear](与快照 / 已读游标 / [GoneSessions] 同一档:换代之后本端什么都不知道)。
* - 三者都在主线程上(组合、`viewModelScope` 的默认派发器),所以账本本身不加锁——它的每一个
* 读写点都在同一个线程上。
*
* ## 为什么不持久化
* 槽位里装的是**进程内存里的状态**(消息列表、草稿、流式缓冲),它没有可以落盘的形式,而它的
* 内容全部另有来源(服务端的 `GET /messages` 那一页 + 服务端的幂等键)。重启之后本端重新拉,
* 与 [GoneSessions] 「不持久化」是同一个理由。
*/
class ChatSessionSlots(private val mainSessionId: String) {
/**
* 账本。用 `LinkedHashMap` 只为一件事:**同一毫秒并列**时 [chatSessionEvictions] 的稳定排序会
* 退回这个插入顺序(先建出来的先走)。除此之外顺序不表达任何东西——「谁最近」一律看
* [ChatSessionSlot] 里的 `lastUsedAt`。
*/
private val slots = linkedMapOf<String, ChatSessionSlot>()
/**
* 取这个会话的槽位(没有就建一个)。**组合期间**调用。
*
* 它**不记账**:谁最近用过由 [onOpened] 写。所以就算这一段组合被丢弃(组合没走完),留下的也
* 只是一个 `lastUsedAt` 还是 `0` 的空槽位,下一次回收就会把它扫掉(它必然是最旧的那一格)。
*/
fun slotFor(account: String, sessionId: String): ChatSessionSlot =
slots.getOrPut(keyOf(account, sessionId)) { ChatSessionSlot(sessionId) }
/**
* 这个会话**刚刚成为当前会话**:记下它是「最近」的那一格,然后按 [chatSessionEvictions] 回收。
*
* 记账与回收在同一处,是因为回收的判据就是这份账本本身(先例:`ui.sessions.silentRefreshStep`
* 把「该做什么」与「账本走成什么样」一起回):拆开就会给出「判了却忘了推进账本」的机会,
* 而那正好会让最该留下的那一格被当成最旧的收掉。同一处也才拿得到那个 key —— 判据要
* **按身份**豁免的就是刚记完账这一格(`chatSessionEvictions` 的 `currentKey`)。
*
* @param holdsUnsentWork 现读的一格真相(见 [ChatSessionUsage.holdsUnsentWork])。收的是一个
* **探针**而不是一个布尔快照,是因为回收发生在**别的**会话被打开的那一刻 —— 而那一刻正是
* 用户可能刚在前一个会话里打了草稿、还没切走的时候。快照会把它读成「干净」,把没发出去的
* 字一起回收掉(那是一条回不来的动作)。
*/
fun onOpened(
account: String,
sessionId: String,
holdsUnsentWork: () -> Boolean,
nowMillis: Long,
) {
val key = keyOf(account, sessionId)
slotFor(account, sessionId).opened(holdsUnsentWork, nowMillis)
sweep(currentKey = key)
}
/**
* 定点回收一格(删会话):把那份 store 交出去,那个 VM 随 `onCleared` 一起走,本类不再引用它。
*
* 与 [GoneSessions] 的关系:那是「本端已知它没了」这件事的**第一半**(让还活着的 VM 别再去发
* 注定 404 的补拉),这里是**第二半**(那一格本来就该跟着会话一起消失)。两半分开写在两处,
* 是因为它们回答的问题不同——一个答「还值不值得发请求」,一个答「还留不留这一格状态」,
* 而后者要动 VM 的生命周期,不该被前者拽住(`ui.chat.reconnectPull` 只读前者)。
*/
fun reclaim(account: String, sessionId: String) {
slots.remove(keyOf(account, sessionId))?.clear()
}
/**
* 换账号 / 登出:本端**一格都不留**。
*
* 与快照 / 已读游标 / [GoneSessions] 是同一档纪律(`SessionsViewModel` 的登出分支一处清完):
* 槽位里的东西全部属于**上一个环境**,留着只会在下一次登录后变成一份没人认领的常驻内存
* (而它带着的那个 VM 还在订阅连接、还会在重连时补拉)。这一条也顺带把 [ChatSessionSlot] 的
* 那条「主会话永不回收」的例外收干净:换账号时连主会话那一格也不留。
*
* 代价说清楚:登出(含令牌失效那条路径)之后**本端未发出的草稿会跟着没了**。这与既有口径一致
* ——登出时快照、已读游标、已删会话的结论都是当场清掉的(那些也都没落盘)。
*/
fun clear() {
slots.values.forEach { it.clear() }
slots.clear()
}
/**
* 按 [chatSessionEvictions] 的结论交出超出的那几格(「最近」的口径见那个函数的 KDoc)。
*
* @param currentKey 刚记完账、此刻在屏幕上那一格的 key(它按身份豁免,见判据第 2 条)。
*/
private fun sweep(currentKey: String) {
val usages = slots.map { (key, slot) -> slot.usage(key) }
chatSessionEvictions(usages, mainSessionId, currentKey)
.forEach { key -> slots.remove(key)?.clear() }
}
/** 与聊天页的 VM key 无关:这是**槽位**的身份,账号指纹在里面是为了换账号时天然分格。 */
private fun keyOf(account: String, sessionId: String): String = "$account|$sessionId"
}
/**
* 一个会话的槽位:**它自己的那份 `ViewModelStore`**,外加这项回收策略要的两个记账位。
*
* 装在这里的 VM 由 `ChatScreen` 经它取出(`viewModel(viewModelStoreOwner = 槽位, key = <账号指纹|会话 id>)`):
* key 仍然是 `chatViewModelKey` 那一份口径,只是现在**配着一份按会话分的 store** 用。
*
* 它**不持有** VM 的类型:本类只知道「怎么把这一格交出去」([clear])与「它还有没有没交代完的事」
* ([opened] 收下的那个探针)。这样 `core.session` 不必认识 `ui.chat` 的任何类型,而两件事
* ——「槽位怎么算最近」与「VM 里哪一位算没交代完」——各自待在自己那一层。
*/
class ChatSessionSlot internal constructor(
/** 本槽位的会话 id(槽位身份的一半:账号指纹在 [ChatSessionSlots] 的账本 key 里)。 */
val sessionId: String,
) : ViewModelStoreOwner {
private val store = ViewModelStore()
override val viewModelStore: ViewModelStore
get() = store
/**
* 最后一次成为当前会话的时刻(毫秒);`0` = 建出来还没打开过。**只增不改**。
*
* 由 `ChatSessionSlots.onOpened` 写(那里是「它成了当前会话」的唯一表达处),本类不自己读时钟。
* 不进 [usage] 以外的任何地方——「最近」的判据只认它,而判据只吃 [ChatSessionUsage]。
*/
private var lastUsedAt: Long = 0L
/**
* 这一格里「还有没有没交代完的事」的**现读**探针(见 [ChatSessionSlot.opened] 的说明)。
* 默认恒 `false`:还没打开过的一格当然没有。
*/
private var holdsUnsentWork: () -> Boolean = { false }
/** 这一格成了当前会话:记下该问谁「还有没有没交代完的事」,并更新「最近」这一位。 */
internal fun opened(holdsUnsentWork: () -> Boolean, nowMillis: Long) {
this.holdsUnsentWork = holdsUnsentWork
lastUsedAt = nowMillis
}
/** 交给判据的样子(判据不认识本类,见 [ChatSessionUsage])。 */
internal fun usage(key: String): ChatSessionUsage = ChatSessionUsage(
key = key,
sessionId = sessionId,
lastUsedAt = lastUsedAt,
holdsUnsentWork = holdsUnsentWork(),
)
/**
* 交出这一格:`ViewModelStore.clear()` ⇒ 那一格的 `ViewModel.onCleared()` + `viewModelScope`
* 取消(订阅、补拉、在飞的请求一起停),随后**松开探针**。
*
* 松开探针不能省:它捕获着那个 VM(否则就成了一份「已经交出去、却还被人拎着」的引用,
* 那一格的消息列表会跟着这条引用活到进程结束——正是本类要断掉的那根线)。
*/
internal fun clear() {
store.clear()
holdsUnsentWork = { false }
}
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.core.session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。
*
* ## 它回答的是哪一个问题
* 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 是按会话建的,本端**同时**留着其中几格
* (W52A 起是 `ChatSessionSlots` 的 N 格),它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉
* 一轮**。会话在本端被删掉之后,那个 VM 自己并不知道(它没有别的渠道知道),于是**每一个**重连
* 都发一次注定 `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。
*
* 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了,
* 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」
* 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求
* 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。
*
* W52A 又给这件事加了一半:删会话时那一格**当场就被回收**(`ChatSessionSlots.reclaim`),所以
* 大多数情况下这里拦的已经是一个不会再发生的补拉。它仍然留着,因为两件事回答的不是一个问题
* ——这一位答「还值不值得发请求」,槽位答「还留不留这一格状态」,而**本端删掉一个会话**与
* **那个会话在别处被删掉**(只能靠 404 发现)是两条不同的路,后者压根不经过这里。
*
* ## 为什么是进程级、为什么**不**持久化
* - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel`
* (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。
* - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉
* ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」
* 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。
* - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端
* 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符
* 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。同一处也清聊天页的槽位
* (`ChatSessionSlots.clear`),两件事属同一档纪律。
*/
class GoneSessions {
private val ids = MutableStateFlow<Set<String>>(emptySet())
/** 记下「服务端已经没有这个会话了」。空串当无效值丢掉(那不是任何会话的 id)。 */
fun mark(sessionId: String) {
if (sessionId.isBlank()) return
ids.update { it + sessionId }
}
/** 本端是否**已经知道**它没了。只回答「知道不知道」,不回答「在不在」——不知道的一律 `false`。 */
fun contains(sessionId: String): Boolean = sessionId in ids.value
/** 换账号 / 登出:本端重新回到「什么都不知道」。 */
fun clear() {
ids.value = emptySet()
}
}
@@ -15,6 +15,32 @@ import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/**
* **连接超时**(毫秒):解析 DNS + 建立 TCP 连接(以及 TLS 握手)这一段的预算,**全端共用这一个值**。
*
* ## 为什么从 10 秒缩到 2.5 秒(W45)
* 它就是「**完全无网时快速进队列**」这条性质的唯一依据。W41 把这条性质寄托在一个 2.5 秒的探针上
* (W41 那个探针 client 的 `callTimeout`),而那个探针**从不复用连接池**、每次都要现做一次冷握手
* ——于是真机上出现了「探了,但请求一个字节都没写出去」,而它与「压根没探」在外部完全同形,推断
* 不出是哪一档。W45 删掉探测、改成**直接试 REST** 之后,这条性质改由**连接超时**承担:连不上就在 2.5 秒
* 内失败,消息进队列(内容一个字节都不丢,重发仍是同一个 `clientMsgId`)。
*
* 取值 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级(局域网、ZeroTier / Tailscale 也远达
* 不到秒级),2.5 秒是「人开始觉得卡」之前的量级,也与 W41 那 2.5 秒的探针预算**对齐**——换成直接
* 试 REST 之后,「无网时用户最多等多久」因此没有变长。**绝不许**再回到 10 秒:那会让每一条离线
* 消息的用户都白等十倍的时间,换来的只是「极端慢网下多一次成功的机会」。
*
* ## 它不管什么
* 「连上了但服务端一直不回话」(半死的代理、接住连接却不回应)不是它的事——那是读 / 写超时
* ([READ_TIMEOUT_SECONDS])与回落那一次投递自己的整通上限
* ([top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])的事。两个闸各管一段,合起来才是
* 「没网时用户最多等多久」。
*/
const val CONNECT_TIMEOUT_MILLIS = 2_500L
/** 读 / 写超时(秒):真请求(长文本、内联图片、文档字节)的耐心,与 [CONNECT_TIMEOUT_MILLIS] 分开。 */
private const val READ_TIMEOUT_SECONDS = 60L
object ApiClientFactory {
val json: Json = Json {
@@ -30,7 +56,20 @@ object ApiClientFactory {
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
/** 通用入口:按需构建任意 Retrofit service。 */
/**
* 通用入口:按需构建任意 Retrofit service。
*
* 超时**只有两个值**,都由文件顶上的常量解释:连接 [CONNECT_TIMEOUT_MILLIS](短,保证「完全
* 无网时快速失败」)、读 / 写 [READ_TIMEOUT_SECONDS](长,真请求的耐心)。**刻意不设
* `callTimeout`**:那会把文档字节下载与图片上传一并框住,而它们本来就是长请求;哪一次投递需要
* 整通上限,就写在哪一次投递的调用点上(例:回落那一次的
* [top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])。
*
* DEBUG 下挂 BASIC 日志(`logcat -s OkHttp`):`-->` 打在 `chain.proceed()` **之前**,所以
* 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分——回落那条路下一轮要靠它取证。
* 不动 `Authorization`(BASIC 根本不打印任何头),级别**绝不许**再往上调:`/auth/refresh` 与
* `/auth/logout` 把 refresh token 放在请求体里。
*/
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
@@ -38,9 +77,11 @@ object ApiClientFactory {
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): T {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
// 连接这一段刻意短(理由见 [CONNECT_TIMEOUT_MILLIS]):它是「无网时消息快速进队列」的
// 唯一依据,所以它**不含**任何整通上限的意味。
.connectTimeout(CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
.apply {
if (BuildConfig.DEBUG) {
@@ -0,0 +1,41 @@
package top.yeij.cyrene.data.api
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url
/**
* 语音副本的字节读取(契约 **v0.79 已实现**,api-client.md §1.2 **U19**
* `GET /api/v1/media/{name}`)。
*
* ## 鉴权:**必须** Bearer `owner|admin`
* 与 `GET /api/v1/documents/{name}` 同一套门,与图片读侧「128-bit 名即凭据」**刻意相反**
* ——语音是私密内容,那个 URL 不能直接贴进 `<audio src>`。Bearer 不在本接口上做任何事:
* [ApiClientFactory.create] 按 **OkHttpClient** 挂 `AuthInterceptor`,只要本接口的实例是从
* 那里建的(`AppContainer.mediaApi` 的写法),请求就自带令牌、并享受 401 单飞续期。
*
* ## 为什么参数是一整条 URL 而不是 `@Path name`
* 契约把相对路径 `/api/v1/media/<name>` 定成**服务端唯一的派生字段**(`voice.url`),并明写
* 客户端**绝不自己拼路径**。所以调用方把**服务端给的那条路径接到当前服务器地址上**
* (纯函数 `domain.chat.voiceMediaUrl`)再传进来——本接口一个字都不拼,只发出去。
*
* @see MediaRepository
*/
interface MediaApi {
/**
* 取一条语音副本的字节。
*
* 响应**不是信封**(就是音频本身),所以返回 [ResponseBody] 由仓库层读字节并负责关闭。
* [Streaming] 让 Retrofit 不把响应体一次性读进内存。
*
* ⚠️ 服务端的 `416`(`Range` 不可满足)是**标准库直写**的 `text/plain` + `Content-Range`,
* **不套** `{"error":{…}}`(契约 §0.3 / §4.5)——本端不发 `Range`、也不拿 `error.code`
* 去解析它,`ApiErrorMapper` 对这种非信封响应的处理是「说不出就不编」。
*/
@Streaming
@GET
suspend fun download(@Url url: String): Response<ResponseBody>
}
@@ -0,0 +1,67 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Streaming
/**
* 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。
*
* ## 只有两个字段,是**刻意的**
* 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**:
* - `voice`:接受但不生效 —— 发音人**不由它决定**;
* - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]);
* - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。
*
* 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发
* 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析
* (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。
*
* [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`),
* 而不是发一个 `"sessionId": null`。
*/
@Serializable
data class SpeechRequestDto(
@SerialName("text") val text: String,
@SerialName("sessionId") val sessionId: String? = null,
)
/**
* 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。
*
* ## 鉴权:owner-only,双凭据门
* `Authorization: Bearer <owner JWT>` **或** `X-API-Key`,两个分支在服务端落到同一个
* `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由
* [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。
* 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。
*
* ## 响应**不是信封**
* 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层
* 读字节并负责关闭,[Streaming] 让它不一次性读进内存。
*
* ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里
* 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。
*
* 失败时的错误码(全部是 `{error:{code}}` 信封):
* `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、
* `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、
* `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。
*
* @see top.yeij.cyrene.data.voice.SpeechClient
*/
interface SpeechApi {
/**
* 把 [body] 合成为一段 WAV 字节。
*
* 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾,
* 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。
*/
@Streaming
@POST("api/v1/audio/speech")
suspend fun speech(@Body body: SpeechRequestDto): Response<ResponseBody>
}
@@ -8,6 +8,10 @@ import kotlinx.serialization.Serializable
*
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
*
* [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、
* `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带
* (前提与理由写在 [voice] 的 KDoc 里)。
*/
@Serializable
data class HistoryMessageDto(
@@ -59,6 +63,38 @@ data class HistoryMessageDto(
* 回放 / `channel_persisted`(本端不消费最后一条)。
*/
val turnId: String? = null,
/**
* 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]);
* **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事。
*
* ## 判据是**两个**问题,别再合成一个
* - 「这条**来自**一段声音吗」= 这个键在不在(`domain.chat.hasVoice`);
* - 「要不要画语音形态」= 键在 **且** `status != "failed"`(`domain.chat.isVoiceMessage`)。
* 后端 M57-M59 的出站合成降级行(`{source:"tts", status:"failed", fallbackReason}`)与入站的
* 转写失败行都是**键在、但没有可播的语音**:正文照常按文字渲染,另加一行灰色小标
* (`domain.chat.voiceFallbackNote`),**不画**语音形态。按旧的单条判据(键在即语音)它们会
* 被画成一个点下去必然播不了的气泡。
*
* ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带
* `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、
* `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与
* `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` /
* `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为
* **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice`
* (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。
*
* 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底:
* `voice` 在就按 `status` 分流,不在就不是语音。**不要为它加重试 / 轮询 / 延迟判定**。
*
* ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify;
* ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回
* 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。
*
* ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN),
* 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一
* 诚实的表达(与 [documents] 回一条 `unreadable` 不同)。
*/
val voice: VoiceDto? = null,
)
/**
@@ -0,0 +1,86 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* 一条消息的**语音元数据**(契约 **v0.67 登记、v0.79 已实现(切片 L45)**,
* `Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」与 §1.2 U19)。
*
* ## 🎤 判据 = 本键存在 **且** `status != "failed"`
* `voice` 是 [HistoryMessageDto] 上的**指针**:非语音消息**整键缺席**(不是 `{}`,也不是 `null`)。
* 客户端**只认这个键**,**绝不**从 `content` 正文里认媒体类型——QQ 语音正文里那个 `[语音]`
* 占位标记自 v0.67 起**不再下发**,而正文里**也可能**出现用户自己打出的 `[语音]` 字样。
*
* 键在只说明「这条来自一段声音」;要不要画语音形态还看 `status`(见下)。
* 判据落在一处:`domain.chat.isVoiceMessage`(`domain.chat.hasVoice` 答的是前半个问题)。
*
* ## 三态 `status`(闭集)
* `transcribed`(正文即转写文本;出站方向 = 合成成功)/ `empty`(转写成功但没识别出文字)/
* `failed`(**没有可播的语音**——入站没拿到转写文本,出站是合成降级回文字)。客户端**按 `status`
* 分支,绝不按正文文案分支**;设计上刻意不下发 `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 /
* 限流 / 超时 / 格式不支持 / 无字节一律只进服务端日志),所以本端**不猜**细分。
*
* ## `fallbackReason` 只在 `failed` 时有
* 出站方向(`source = "tts"`)合成降级时,服务端在这个键里给出降级原因;本端**原样**小字展示
* (`domain.chat.voiceFallbackNote`),**不猜闭集**——取值集合后端尚未定稿,见那个函数的 KDoc。
*
* ## `name` / `mime` / `size` / `url` 四者同生共死
* 有音频副本时四个键全在,**无副本时全缺席**(服务端保证不会出现 `url` 指着一个不存在的 `name`)。
* 反过来说:**四个键没凑齐就不要画播放控件**——不能给一个点下去必然失败的按钮。
* 判据落在一处:`domain.chat.voiceAudio`。一个真实的无副本例子是微信(`weixin-ilink`):
* 平台自带转写就**不白下载那份音频**,于是 `source` 是 `platform` 而四个键全缺席
* (**只有 🎤 标记**,不可回放)。
*
* ## `durationMs` 只有渠道如实给出时才有
* 服务端「读不出来就省略键,**绝不估算**」。本端同理:**缺席就不显示时长**
* (不拿 size / 码率反算,也不显示 `0:00` 冒充)。所以它是可空的,且 `<= 0` 一并当缺席
* (见 `domain.chat.voiceDurationLabel`)。
*
* ## `url` 是**服务端相对路径**,客户端绝不自己拼
* 取值形如 `/api/v1/media/<name>`,由服务端一处拼出(**唯一派生字段**)。
* 客户端要取字节时把它接到**当前服务器地址**后面(`domain.chat.voiceMediaUrl`)——
* 与图片那条「保留 path、替换 host」同一取向,但这里服务端给的就是相对路径,不需要换 host。
*/
@Serializable
data class VoiceDto(
/**
* `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的
* `voice_item.text`)/ `"tts"`(我们自己合成的出站语音,后端 M57-M59)。
* 见 `domain.chat.VoiceSource`。**开放取值**:认不出的原样保留。
*
* 接收侧**不按 `source` 分支**:合成成功的那条与入站语音同形(`status = transcribed` +
* 四键副本),走同一条渲染路径。
*/
val source: String = "",
/**
* `"transcribed"` / `"empty"` / `"failed"`(闭集三态,见 `domain.chat.VoiceStatus`)。
*
* 默认空串而非某个态,是因为**认不出就不要装认识**:显示层对「不是 `empty`」的所有取值
* (含服务端将来新增的态)一视同仁地不额外说话(见 `domain.chat.voiceNote`)。
*
* ⚠️ 只有 `"failed"` 是**没有可播的语音**的那一态(`domain.chat.isPlayableVoice`);认不出的
* 取值一律当可播(fail-open,理由写在那个函数的 KDoc 里)。
*/
val status: String = "",
/** 音频副本的存储名(内容寻址,`<32hex>.<ext>`);**无副本 ⇒ 键缺席**。 */
val name: String? = null,
/** 副本的 MIME(按存储名扩展名映射)。与 [name] / [size] / [url] 同生共死。 */
val mime: String? = null,
/** 副本的字节数。与 [name] / [mime] / [url] 同生共死;**不用它反算时长**。 */
val size: Long? = null,
/** 时长(毫秒)。**只有渠道如实给出时才有**;缺席 = 本端不显示时长。 */
val durationMs: Int? = null,
/**
* 合成降级的原因(后端 M57-M59 预告,`devdocs/02` 已登记;契约 **v0.98 的正式回执尚未到**)。
*
* 只在 `status == "failed"` 时有意义,其余状态下服务端不会下发(`omitempty`);入站转写失败
* 也不带它——入站的失败原因由正文那行诚实提示承载。
*
* 本端**原样**小字展示(`domain.chat.voiceFallbackNote`),**不猜闭集、不做映射**:取值集合
* 后端还没定(`tts_timeout` 之类是我们看到的预告示例,不是契约)。缺席 / 空白时那一行小标
* 退化成固定的一句(`VOICE_FALLBACK_NOTE`),不白屏。
*/
val fallbackReason: String? = null,
/** 副本的服务端相对路径(`/api/v1/media/<name>`)。**不要自己拼这条路**,也不要当绝对地址用。 */
val url: String? = null,
)
@@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext
* 三条读取出口共用同一个端点,只是给的查询参数不同:
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
* - [search]:`q` 会话内搜索(也是倒序);
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6);[loadIncrement] 是它在
* `after` 方向上的翻页版(一直追到尾巴,追不到就如实说 `complete = false`)。
*
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
*/
@@ -87,7 +88,9 @@ class MessageRepository(
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
*
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
* 落在页边界上的那一毫秒里剩下的行**再也追不回来**,因为服务端只给严格 `>`。调用方按 `id`
* 去重,并且**不能把满页之后的一页(空页、不满页都一样)当作追到了尾巴**:要翻页又要把边界
* 说清楚,用 [loadIncrement],不要自己拿这一页凑。
*/
suspend fun loadAfter(
sessionId: String,
@@ -100,6 +103,87 @@ class MessageRepository(
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* **增量追尾**(W77):从 [after] 起把某会话的新消息一直翻到尾巴,最多 [maxPages] 页。
*
* 与裸的 [loadAfter] 的差别只有一件:**它负责把「还没翻完」这件事如实说出来**。
*
* ## 什么时候只能说 `complete = false`
* 游标是本页**最新**一条的 `createdAt`,而服务端只给 `created_at > after`(**严格**大于:
* `list_after.go` 的 `ListAfter` 与 `devtools/fake-backend` 两处一致)。于是**落在页边界上被
* 劈开的那一毫秒**里的行永远追不回来——它们的 `createdAt` 恰好等于游标,再问一次也问不到
* (契约 §A5 的已知边界:纯 `createdAt` 游标没有毫秒内的次序)。
*
* 要紧的是**切点**:只有当服务端的 `LIMIT` 在某一毫秒中间切过一刀,才会留下追不回来的尾巴。
* 客户端看不见那一刀落在哪儿,只知道自己**拿过满页**就等于「发生过一次切点」。所以这里只有一条
* 不变式,下面几档都是它的推论:
*
* > `complete = true` **当且仅当整个追尾从未拿过满页**。
*
* - **第一页就是空页 ⇒ `true`**:一条新消息都没有,没有可少给的;
* - **第一页就不满 ⇒ `true`**:`LIMIT` 没切到任何东西,`> cursor` 的行全在这一页里;
* - **拿过整页之后,下一页是空页 ⇒ `false`**:那次空页分不出「干净的尾巴」与「空页底下还压着
* 同一毫秒的几条」;
* - **拿过整页之后,下一页不满 ⇒ 也是 `false`**:不满只说明「`> cursor` 的行就这么多了」,对
* 切点那一毫秒还有没有行**一无所知**。例如积压 `m1@500 m2@500 m3@500 m4@600`、页宽 2:
* 第一页给 `m1 m2`(切点正好劈开 500 那一毫秒),第二页 `after=500` 只给 `m4@600`、不满页,
* 而 `m3@500` 既不 `> 500`、也就永远拿不到;
* - **翻满 [maxPages] ⇒ `false`**。
*
* 代价:积压一到页宽(默认 50 条)就回落整表。这与「翻满 [maxPages] ⇒ 回落」同类——那时增量
* 已经取回 ≥50 行,多问一次整表是边际成本,也正合 [MAX_INCREMENT_PAGES] 那句「积压多到增量不再
* 便宜」。主路径(几条新消息、第一页就不满)不受影响。
*
* 这里**不猜**、也不悄悄少给几条:说不完整,调用方还能去问唯一权威。
*
* 不传 `platform`:未读面上的口径是服务端 `count(created_at > read_at)`(`unreadCountQuery`,
* 不带平台过滤),按平台过滤会让本地数出来的条数**与服务端的 `unreadCount` 不同源**。
*
* @param after 本端**已见**的最大 `createdAt`(毫秒);服务端返回严格新于它的消息,升序。
* @param maxPages 最多翻几页(默认 [MAX_INCREMENT_PAGES])。用尽即 `complete = false`:
* 一次静默刷新不该为了补一个角标翻十几页。
*/
suspend fun loadIncrement(
sessionId: String,
after: Long,
pageSize: Int = DEFAULT_PAGE_SIZE,
maxPages: Int = MAX_INCREMENT_PAGES,
platform: String? = null,
): Result<MessageIncrement> = withContext(dispatcher) {
val size = pageSize.coerceIn(1, MAX_PAGE_SIZE)
val byId = LinkedHashMap<String, HistoryMessageDto>()
var cursor = after
// 只要翻到过一整页,就说明服务端的 LIMIT 切过一刀,那一刀可能劈开了一毫秒(见 KDoc)。
// 此后无论下一页是空页还是不满页,都说不出「追到了尾巴」。
var chasedFullPage = false
fun done(at: Long, complete: Boolean) = Result.success(
MessageIncrement(messages = byId.values.sortedWith(MessageOrder), cursor = at, complete = complete),
)
var page = 0
while (page < maxPages.coerceAtLeast(1)) {
page++
val raw = apiCall { api.listMessages(sessionId, size, before = null, platform = platform, after = cursor) }
.getOrElse { return@withContext Result.failure(it) }
val paged = toPagedMessages(raw, size)
// 空页有两种含义,客户端分不出来:第一页就空 = 真的没有新的;拿过一整页之后空 = 可能还有
// 同一毫秒的几条卡在游标上(服务端只给严格大于)。后者只能说「没追完」。
if (paged.messages.isEmpty()) {
return@withContext done(cursor, complete = !chasedFullPage)
}
// 去重只是防御:严格 `>` 下相邻两页不可能重叠(下一页只要 > 本页最新那条)。
paged.messages.forEach { byId[it.id] = it }
val newest = paged.newestCreatedAt ?: return@withContext done(cursor, complete = false)
// 不满页只说明「> cursor 的行就这么多了」,对切点那一毫秒一无所知 —— 与空页档同一个洞,
// 同样是拿过整页之后就不作数。
if (!paged.hasMore) return@withContext done(newest, complete = !chasedFullPage)
cursor = newest
chasedFullPage = true
}
done(cursor, complete = false)
}
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。
@@ -152,6 +236,15 @@ class MessageRepository(
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
const val DEFAULT_SEARCH_PAGE_SIZE = 30
/**
* [loadIncrement] 一次最多翻几页(500 条)。
*
* 这个上限只为一件事存在:一次**后台**的顺手对齐不该翻十几页。越过它就说明积压已经多到
* 「增量」不再便宜,那时整表(`GET /sessions` 一次拿回权威的 `unreadCount` 与预览)
* 反而更划算——调用方据此回落,见 `SessionsViewModel.flushPendingIncrements`。
*/
const val MAX_INCREMENT_PAGES = 10
private val EMPTY_PAGE = PagedMessages(
messages = emptyList(),
hasMore = false,
@@ -190,3 +283,18 @@ private fun toPagedMessages(
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)
}
/**
* [MessageRepository.loadIncrement] 的结论:升序增量消息 + 推进后的游标 + 有没有真的追到尾巴。
*
* [complete] 为 `false` 时 [messages] 仍是**已拿到的全部**(不丢),但调用方不能把它当成
* 「这之后就没有了」——未读面上那种情况要回落到整表,见 `SessionsViewModel.flushPendingIncrements`。
*
* 放在这里而不是 `PagedMessages.kt`:`PagedMessages` 是纯分页形状(一页的元数据),
* 这个类型是 [MessageRepository] 翻页循环的产物,跟着它的生产者走。
*/
data class MessageIncrement(
val messages: List<HistoryMessageDto>,
val cursor: Long,
val complete: Boolean,
)
@@ -0,0 +1,164 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.MediaApi
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.VoiceAudio
import top.yeij.cyrene.domain.chat.readBounded
import top.yeij.cyrene.domain.chat.voiceMediaUrl
import java.io.File
/** 本端一次读得下、也放得动的语音副本上限,超过就如实说「放不了」(见 [VoiceTooLargeException])。 */
class VoiceTooLargeException : Exception("voice copy too large")
/** 服务端只给了相对路径,而本端目前拿不到一个能接上去的服务器地址(未登录 / 地址为空)。 */
class VoiceUrlUnavailableException : Exception("voice media url unavailable")
/**
* 语音副本的取字节层(`GET /api/v1/media/{name}` 的第一个调用方)。
*
* 与 [DocumentRepository] 同一个形状:响应不是信封(就是音频本身),所以不用 `apiCall`——
* 成功即读字节,失败才走 [ApiErrorMapper](`404 media_not_found` / `401`)。
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
*
* [url] 由调用方拼好(服务端给的**相对路径** + 当前服务器地址,纯函数
* [voiceMediaUrl]):本层**不自己拼路径**——契约把那条路径定成服务端唯一的派生字段。
* 因此这里也不做形态校验:拼不出可信地址的调用方根本不会走到这里([VoiceStore] 先判)。
*/
class MediaRepository(
private val api: MediaApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取回一条语音副本的字节。
*
* 读到超过 [MAX_VOICE_BYTES] 就判 [VoiceTooLargeException]:语音副本本就不该大,
* 而「一次读进内存」这条路上必须有一把尺子(与文档 / 图片同一个做法)。
* **0 字节不算成功**——那会让下一次播放「读缓存命中」一份什么都没有的文件。
*/
suspend fun download(url: String): Result<ByteArray> = withContext(dispatcher) {
try {
val response = api.download(url)
val body = response.body()
when {
response.isSuccessful && body != null -> body.use { stream ->
val bytes = readBounded(stream.byteStream(), MAX_VOICE_BYTES)
when {
bytes == null -> Result.failure(VoiceTooLargeException())
bytes.isEmpty() -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
else -> Result.success(bytes)
}
}
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
response.isSuccessful -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
else -> Result.failure(
CyreneException(ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string())),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private companion object {
/**
* 上限取附件那一把尺子(20 MB):语音副本远小于它,而这里要防的只是「一次读进内存」
* 这条路上没有上界。**不新造一个数字**——多一个自定义上限就多一处会漂移的口径。
*/
val MAX_VOICE_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
}
}
/**
* 语音副本的「取字节 + 落盘缓存」层(播放前那一步的**唯一**取数入口)。
*
* ## 缓存:`<cacheDir>/media/`,复用 [DocumentCache] 的三条规则
* - **名字规则 / 命中判定 / 原子落盘**全部复用 [DocumentCache](同一条 `isSafeName`、
* `isHit`(非空才算命中)、`write`(先写 `.part` 再改名))——只在缓存根下换一个目录名。
* - **特意不落进 `documents/`**:那个目录是 `FileProvider`(`res/xml/file_paths.xml`)**唯一**
* 暴露出去的根,「用其它应用打开 / 保存到下载目录」两条出口都从它取文件。语音本片只做
* 收 / 放,不提供「交给别的 App」这条出口,所以**不把暴露面扩大一个字节**。
* - 目录名是 `media`,与 `voice.url` 里的 `/api/v1/media/` 同源,便于排查时对得上号。
*
* ## 为什么缓存(而不是每次现下)
* 服务端副本是**内容寻址**的(名字由内容决定)+ `Cache-Control: private, max-age=86400,
* immutable` ⇒ 同一个名字的字节永不改变,「文件在且非空」就是永远有效的命中。重复播放因此
* **一个请求都不发**,离线也能重听刚听过的那条。代价是缓存目录里多占几十 KB~几百 KB 一份,
* 而它在 `cacheDir` 下:系统需要空间时可以回收,本端被回收之后下次播放重新下载(**不是**错误)。
*
* ## 它不做什么
* 不拼路径([voiceMediaUrl] 在调用前判,拿不到可信地址就直接失败)、不做形态校验、不猜 MIME、
* **不拿 `size` 反算时长**(时长只认服务端给的 `durationMs`)。
*/
class VoiceStore(
/**
* 取一条副本的字节(未命中缓存时调一次)。
*
* 收成一个 lambda 而不是直接收 [MediaRepository]:本类因此可以在单测里用临时目录 + 假下载器
* 跑完,不必造一个 HTTP 替身(与 [ImageCacheStore] 的 `download` 参数同一手法)。
* 生产路径上它就是 `container.mediaRepository::download`。
*/
private val download: suspend (String) -> Result<ByteArray>,
private val cacheDir: File,
private val baseUrlProvider: () -> String,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 取这条语音的字节并确保它已落进缓存;返回**可直接交给 `MediaPlayer` 的那份文件**。
*
* 顺序是刻意的:**先拼地址、再查缓存**。地址拼不出来(未登录 / 地址为空)就如实失败,
* 绝不因为「本地碰巧缓存过」而放行——那会让一条本端根本没有服务器归属的语音照旧能放,
* 而调用方拿不到任何异常信号。
*/
suspend fun load(audio: VoiceAudio): Result<File> = withContext(dispatcher) {
val url = voiceMediaUrl(audio.url, baseUrlProvider())
?: return@withContext Result.failure(VoiceUrlUnavailableException())
// ⚠️ 刻意**不用** `DocumentCache.fileFor`:那个函数的缓存根写死成 `documents/`
// (FileProvider 那一层),传别的目录进去会拼成 `<cacheDir>/media/documents/<name>`。
// 这里只用它的**名字判据**,路径自己拼。
if (!DocumentCache.isSafeName(audio.name)) {
// 名字不安全 = 服务端给了一个本端不敢拼路径的名字;判解析失败,绝不退回别的名字。
return@withContext Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
}
val file = File(directory(cacheDir), audio.name)
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
val bytes = download(url).getOrElse { return@withContext Result.failure(it) }
try {
DocumentCache.write(file, bytes)
Result.success(file)
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——**绝不**退回「把字节直接喂给
// 播放器」那条路:那需要另一条落盘位置,而缓存目录写不进去时多半写得进的地方也没有。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
}
companion object {
/** 缓存根下的目录名(与 `voice.url` 里的 `/api/v1/media/` 同源,便于对号)。 */
const val DIRECTORY_NAME: String = "media"
/** 语音副本的缓存目录:`<cacheDir>/media`。 */
fun directory(cacheDir: File): File = File(cacheDir, DIRECTORY_NAME)
}
}
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.SpeechApi
import top.yeij.cyrene.data.api.SpeechRequestDto
import top.yeij.cyrene.data.repository.DocumentCache
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.readBounded
import java.io.File
import java.security.MessageDigest
/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */
class SpeechNotAudioException : Exception("speech response is not wav")
/**
* 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗?
*
* 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺
* `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上:
* 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。
*
* 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常,
* 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种
* 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档,
* 而不是在播放器里炸成一个没人认识的东西。
*/
fun isWavBytes(bytes: ByteArray): Boolean =
bytes.size >= WAV_HEADER_BYTES &&
bytes[0] == 'R'.code.toByte() &&
bytes[1] == 'I'.code.toByte() &&
bytes[2] == 'F'.code.toByte() &&
bytes[3] == 'F'.code.toByte() &&
bytes[8] == 'W'.code.toByte() &&
bytes[9] == 'A'.code.toByte() &&
bytes[10] == 'V'.code.toByte() &&
bytes[11] == 'E'.code.toByte()
/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */
private const val WAV_HEADER_BYTES = 12
/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */
private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
/**
* 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md
* §1.2 **U6**,v0.96 增量 + v0.97 补写)。
*
* ## 凭据:只走 Bearer,不设 API Key
* 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个
* `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的
* `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。
* 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据,
* 而本端本来就已经有 owner 会话。
*
* ## 发音人从哪来:`sessionId`,不是 `voice`
* 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的
* **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
*
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
* `documents/` 根下,不扩大暴露面一个字节)。
*
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
*
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
* (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求,
* **不是**错误。
*
* ## 错误怎么分类
* 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒
* [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` /
* `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。
* 分类成「柔和提示」还是「明确降级」是纯函数的事,在
* [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。
*
* @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。
* @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的
* 实例,而不必造一整套依赖注入。
*/
class SpeechClient(
private val baseUrlProvider: () -> String,
private val tokenProvider: () -> String? = { null },
private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
private val cacheDir: File,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
private val apiFactory: (String) -> SpeechApi = { baseUrl ->
ApiClientFactory.create(
baseUrl,
SpeechApi::class.java,
tokenProvider,
unauthorizedPolicy,
)
},
) {
/** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */
private var cachedApi: SpeechApi? = null
private var cachedBaseUrl: String? = null
private fun api(): SpeechApi {
val baseUrl = baseUrlProvider()
val cached = cachedApi
if (cached != null && cachedBaseUrl == baseUrl) return cached
return apiFactory(baseUrl).also {
cachedApi = it
cachedBaseUrl = baseUrl
}
}
/**
* 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。
*
* [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」,
* 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。
*/
suspend fun speak(text: String, sessionId: String?): Result<File> = withContext(dispatcher) {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
// 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。
return@withContext Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
}
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
if (DocumentCache.isHit(file)) return@withContext Result.success(file)
try {
val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId))
val body = response.body()
when {
response.isSuccessful && body != null -> body.use { stream ->
val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES)
when {
// 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。
bytes == null -> Result.failure(SpeechNotAudioException())
// 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。
bytes.isEmpty() -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
// 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。
!isWavBytes(bytes) -> Result.failure(SpeechNotAudioException())
else -> cache(file, bytes)
}
}
// 2xx 却没有响应体:与 apiCall 同一口径。
response.isSuccessful -> Result.failure(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
)
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
private fun cache(file: File, bytes: ByteArray): Result<File> = try {
DocumentCache.write(file, bytes)
Result.success(file)
} catch (_: Exception) {
// 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器
// 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。
file.delete()
Result.failure(DocumentCacheUnavailableException())
}
private companion object {
/** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */
const val NAME_PREFIX = "tts_"
/** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */
const val NAME_HEX_LENGTH = 32
/**
* 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
*
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
*
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
*/
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
)
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
return NAME_PREFIX + hex + ".wav"
}
}
}
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.chat.VoiceClip
/** 录音机的两个可见态(界面只认这两个)。 */
sealed interface VoiceRecorderState {
/** 没在录。 */
data object Idle : VoiceRecorderState
/**
* 正在录。
*
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
*
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
*/
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
}
/**
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
*
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
* 由 [release] 明确收掉。
*
* ## 满了不自动发送,只置位
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
*
* ## 权限
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
*/
class VoiceRecorder(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
private var record: AudioRecord? = null
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
private var collected: ByteArrayOutputStream? = null
private var reader: Job? = null
/**
* 录音代次:每次 [start] 自增。
*
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
*/
private var generation = 0
@Volatile
private var collecting = false
/**
* 开始录音。
*
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
* 调用方必须**如实告知**(不得假装在录)。
*/
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (collecting) return false
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
if (minBuffer <= 0) return false
val recorder = runCatching {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(ENCODING)
.setSampleRate(SAMPLE_RATE_HZ)
.setChannelMask(CHANNEL_MASK)
.build(),
)
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
.setBufferSizeInBytes(minBuffer * 2)
.build()
}.getOrNull() ?: return false
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
runCatching { recorder.release() }
return false
}
val started = runCatching { recorder.startRecording() }.isSuccess
if (!started) {
runCatching { recorder.release() }
return false
}
record = recorder
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
collected = sink
collecting = true
val current = ++generation
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
reader = scope.launch { readLoop(recorder, sink, current) }
return true
}
/**
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
*
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
*/
suspend fun stop(): ByteArray? {
if (!collecting) return null
collecting = false
reader?.join()
reader = null
releaseRecorder()
_state.value = VoiceRecorderState.Idle
val sink = collected ?: return null
collected = null
return sink.toByteArray().takeIf { it.isNotEmpty() }
}
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
fun cancel() {
collecting = false
reader?.cancel()
reader = null
releaseRecorder()
collected = null
_state.value = VoiceRecorderState.Idle
}
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
fun release() = cancel()
/**
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
* (见类注释「满了不自动发送」)。
*
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
* 已经收到的部分仍然有效([stop] 照常交出它们)。
*
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
*/
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
val buffer = ByteArray(READ_BYTES)
try {
while (collecting) {
val read = recorder.read(buffer, 0, buffer.size)
if (read < 0) break
if (read == 0) continue
val used = sink.size()
if (used < VoiceClip.MAX_PCM_BYTES) {
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
}
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
_state.value = VoiceRecorderState.Recording(
elapsedMs = elapsed,
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
)
}
} finally {
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
// 会把新一轮的「录音中」擦掉。
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
_state.value = VoiceRecorderState.Idle
}
}
}
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
private fun releaseRecorder() {
val current = record ?: return
record = null
runCatching { current.stop() }
runCatching { current.release() }
}
private companion object {
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
/**
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
*/
const val READ_BYTES: Int = 2048
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
}
}
@@ -88,6 +88,16 @@ class ChatSocket(
/** 连接状态机;见 [WsState]。 */
val state: StateFlow<WsState> = _state.asStateFlow()
/**
* 诊断用:此刻底层连接对象还在不在(`webSocket != null`)。
*
* 只读、不改变任何行为,也**不是**产品逻辑的输入(`send` 判的是 [state] 与这个对象本身)。
* 它存在只为一件事:让「这一帧为什么没走 WS」在 logcat 里可读——[state] 与它**可能不一致**
* (状态还是 `Connected` 而对象已经被 `finally` 清空、或者反过来),而那个不一致正是
* `WsSendOutcome.Failed` 与 `Queued` 的分水岭。tag 见 `ChatViewModel.SEND_ROUTE_LOG_TAG`。
*/
val hasLiveSocket: Boolean get() = webSocket != null
private val _events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
@@ -170,7 +180,9 @@ class ChatSocket(
* @param parallel 要不要声明 `parallel:true`(忙时并行规范 §2.2)——**默认 `false` = 一个字节
* 都不带该键**,线上帧与加入本参数之前**逐字节相同**。调用方只有一处:`ChatViewModel.deliver`,
* 它按 App 偏好(`InterruptMode`)与「会话是否正忙」算出来(判据是纯函数 `parallelForSend`)。
* @return 未连接或写失败返回 `false`(本层不做离线队列,由上层标红重试)。
* @return 未连接或写失败返回 `false`(本层不做离线队列;上层按**这一刻的连接状态**分档处置:
* 未就绪 ⇒ 记住这条、连接恢复后按序补发,连接就绪却仍发不出去 ⇒ 回滚 + 诚实文案——
* 判据是 `ChatViewModel.deliver` 里的纯函数 `wsSendOutcome`)。
*/
fun sendChat(
sessionId: String?,
@@ -197,15 +209,51 @@ class ChatSocket(
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
*
* **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**
* 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
* 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。
* **调用方有两处,两处都只发 `"current"`**(本端从不发 `all`,见 `devdocs/02` 的 A2 补记):
* 1. `ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**按 App 偏好
* (`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
* 2. `SessionsViewModel.stopGenerating(sessionId)`——抽屉里那一行的「停止」(W52B):它是一个
* **独立的动作**(不读偏好),停的是**指定会话**正在跑的那一轮。`WsOutbound.Interrupt`
* 本来就带 `sessionId`,所以「停某一个会话」这条既有出口表达得了,不需要新帧。
*
* 返回 false 只有一种原因(WS 未连接):两处调用方都**静默**,理由见各自的 KDoc。
*/
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
/**
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
*
* ## 为什么没有「离线队列」
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
*
* @param audio base64(单行,`Base64.NO_WRAP`)。
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
* @param deviceId 与 `chat` 帧同一份来源标识。
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
* @return 未连接 / 写失败返回 `false`。
*/
fun sendVoiceInput(
sessionId: String?,
audio: String,
format: String,
deviceId: String?,
clientMsgId: String?,
): Boolean = send(
WsOutbound.VoiceInput(
audio = audio,
format = format,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
@@ -190,10 +190,19 @@ sealed class WsEvent {
override val type: String get() = "tool_execution.update"
}
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
/**
* 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。
*
* [resultSummary] 是**执行结果摘要**(新契约切片,与回看面 `toolCalls[].resultSummary` 同一份预算:
* 已经脱敏、已经截断 ≤500 字符的成品,本端只展示)。它的语义是**缺席即「不知道」**:
* 只有这一轮的执行结果**真的拿到了**才在场,失败 / 取消 / 超时 / 未落图时后端**整个键省略**
* (且承诺不用空串顶位)。所以 `null` 与 `""` 在本端都读作**不知道**——绝不解成「没有输出」,
* 因为那是替服务端下一个它没下的结论。当前服务端**尚未部署**该键 ⇒ 真机上它恒缺席。
*/
data class ToolExecutionEnd(
val tool: String,
val phase: String,
val resultSummary: String? = null,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
@@ -459,9 +468,21 @@ sealed class WsEvent {
override val type: String get() = "session_read"
}
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
/**
* 主会话(`web` / `web:<uuid>`)消息落库后广播。
*
* [proactive] = **这句话是她主动说的**(v0.72 起,服务端走 IM 桥那条路由时会补发这一帧,
* 并在帧上打这个判据)。它此前被本端**丢掉**了:`data` 只解 `message`,而生产 `Json` 是
* `ignoreUnknownKeys = true` ⇒ 这个键既没被解、也不会报错,于是「她主动说的话走了落库这条路」
* 这件事在客户端**完全不可见**(W53N 的通知判据正是靠它)。
*
* 判据的两种落点都接受(见 [parse]):`data.proactive` 与帧壳顶层的 `proactive` 取**或**。
* 契约只说「帧顶带它」,没说清是帧壳还是载荷,而**猜错就是整个功能静默失效**——所以两处都读。
* 缺席(`false`)= 普通消息,绝不弹通知(主人只批了 proactive 这一档)。
*/
data class MessagePersisted(
val message: HistoryMessageDto?,
val proactive: Boolean = false,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
@@ -579,12 +600,30 @@ sealed class WsEvent {
override val type: String get() = "observability_log"
}
/** 语音合成回复(定向回请求连接)。 */
/**
* 语音合成回复(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* ## 会话归属**只在 `data` 里**
* 契约明写这帧的帧壳**没有** `sessionId`(与其它结构化流帧不同),会话 id 在载荷上。
* 所以解析层用既有口径 [`resolveSessionId`] 把它提到 [sessionId] 上:帧壳有就用帧壳,
* 否则用 `data` 里那一份。少了这一步,`isForSession` 会把帧**放行给每一个会话的 VM**
* (空值 = 无法判定),于是切到别的会话时也会响一段本属于别的会话的语音。
*
* ## 只定向回发起 `voice_input` 的那条连接、断线不补投
* 这是契约的明确边界,不是缺口:本端**不**为它做任何「重连后拉取」的补救(历史靠 REST,
* 音频只在这一次连接里存在)。所以这一帧只有「当场播放 / 当场显示」这一种用途。
*
* [text] 是**转写**(那段音频被识别成什么),[reply] 是**她说的话**——两者的取舍见
* `domain.chat.voiceReplyText`。[provider] 是降级信号(`silent` = 这一轮没有真音频),
* 判据见 `domain.chat.voiceOutputHasAudio`。
*/
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
val deviceId: String? = null,
val provider: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
@@ -592,7 +631,21 @@ sealed class WsEvent {
override val type: String get() = "voice_output"
}
/** 自主主动推送(仅 owner/admin 连接收到)。 */
/**
* 自主主动推送(仅 owner/admin 连接收到)。
*
* v0.72 起她主动说的话**同时是主会话历史里的真消息**(`web` / `assistant` / `turn_id` 空),
* 载荷因此多带两个键:[id] / [createdAt] = 那条**落库行**的权威 id 与时间戳。
* 它与 [messageId](IM 侧的消息号)是**两个不同的 id**,拿后者当去重键永远对不上历史行。
* 落库失败时这两个键**一起缺席**——那是「这条只有实时帧、没有历史行」的唯一标志,
* 消费方据此退化(见 [top.yeij.cyrene.ui.chat.proactiveIngestFor])。
*
* [timestamp] 是服务端侧这条统一消息**自身**的时间。线上是 Go `time.Time` ⇒ **RFC3339 字符串**
* (数字形状也容忍);它只作诊断,排序与去重一律用 [createdAt](Unix 毫秒)。
*
* ⚠️ 帧壳**不带** `sessionId`(服务端构造这一帧时没设它,且该键是 `omitempty`)——所以
* [isForSession] 会把这帧**放行给每一个会话的 VM**,归属只能由消费方自己钉死(它属于主会话)。
*/
data class ProactiveMessage(
val platform: String?,
val channelId: String?,
@@ -600,8 +653,10 @@ sealed class WsEvent {
val content: String,
val contentType: String?,
val sender: String?,
val timestamp: Long?,
val timestamp: String?,
val messageId: String?,
val id: String? = null,
val createdAt: Long? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
@@ -651,7 +706,16 @@ sealed class WsEvent {
val seq = root["seq"].asStringOrNull()?.toLongOrNull()
val sessionId = root["sessionId"].asStringOrNull()
val data = root["data"]?.takeIf { it !is JsonNull }
return runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
val event = runCatching { dispatch(type, turnId, seq, sessionId, data) }.getOrNull()
?: return null
// 帧顶那一位 `proactive`(见 [MessagePersisted])。这一层只对落库帧有意义,载荷里的那一份
// 由 [PersistedData] 解出来,两边**取或**:契约写的是「帧顶带它」而没说是帧壳还是载荷,
// 而猜错的方向是「功能静默失效」,所以两处都认。
return if (event is MessagePersisted && root.frameProactive()) {
event.copy(proactive = true)
} else {
event
}
}
private fun dispatch(
@@ -723,7 +787,7 @@ sealed class WsEvent {
"tool_execution.update" -> decode<ToolExecutionData>(data)
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
"tool_execution.end" -> decode<ToolExecutionData>(data)
.let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) }
.let { ToolExecutionEnd(it.tool, it.phase, it.resultSummary, it.id, turnId, seq, sessionId) }
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
"stream_end" -> decode<StreamEndData>(data)
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
@@ -824,8 +888,15 @@ sealed class WsEvent {
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
"session_read" -> decode<SessionReadData>(data)
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
"message_persisted" -> decode<PersistedData>(data)
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
"message_persisted" -> decode<PersistedData>(data).let {
MessagePersisted(
message = it.message,
proactive = it.proactive.asTrue(),
turnId = turnId,
seq = seq,
sessionId = sessionId ?: it.message?.sessionId,
)
}
"channel_persisted" -> decode<PersistedData>(data)
.let { ChannelPersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
// 消息编辑(D3 §3.8):`data` **就是**编辑后的完整消息 DTO——**没有** `message`
@@ -837,12 +908,27 @@ sealed class WsEvent {
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
"message_deleted" -> decode<DeletedData>(data)
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data).let {
VoiceOutput(
audio = it.audio,
format = it.format,
text = it.text,
reply = it.reply,
deviceId = it.deviceId,
provider = it.provider,
turnId = turnId,
seq = seq,
// 帧壳**没有** sessionId(契约 v0.96),归属在 data 里 ⇒ 走既有口径提到帧上。
sessionId = resolveSessionId(sessionId, it.sessionId),
)
}
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
"proactive_message" -> decode<ProactiveData>(data).let {
ProactiveMessage(
it.platform, it.channelId, it.channelType, it.content, it.contentType,
it.sender, it.timestamp, it.messageId, turnId, seq, sessionId,
it.sender, it.timestamp.asStringOrNull(), it.messageId, it.id, it.createdAt,
turnId, seq, sessionId,
)
}
// 定向错误帧(含队列满的伴生帧):编码是开放集合,原样带上,未知码不做语义处置。
@@ -903,6 +989,22 @@ private fun JsonElement?.asStringOrNull(): String? {
return primitive.content
}
/**
* 帧壳顶层的 `proactive` 是否为真。
*
* 只认**字面量** `true`(布尔或字符串 `"true"`),其余(键缺席、`false`、`JsonNull`、数字、对象)
* 一律按假——这个键的方向必须偏「不打扰」:判错成真会给一条普通消息弹出通知,而主人只批了
* proactive 那一档;判错成假的最多是她主动说的话没弹通知(不打扰人,也不撒谎)。
*/
private fun JsonObject.frameProactive(): Boolean = this["proactive"].asTrue()
/**
* `proactive` 那个键的**宽容**读法(只认字面 `true`;`JsonNull` / 别的形状一律假)。
* 帧壳与载荷两处共用这一份口径,免得同一个键在两处各判一次、各自漂移。
*/
private fun JsonElement?.asTrue(): Boolean =
(this as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true
@Serializable
private data class IndexData(val index: Int = 0)
@@ -920,6 +1022,10 @@ private data class ToolCallData(val id: String = "", val index: Int = 0, val del
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
* 也没有 `streamId`(它们不属于某一次模型流)。
*
* [resultSummary] 只由 `.end` 携带(后端在新契约切片里只补了 `.end`)。它放在这份共用载荷上
* 是因为三帧形状同源、共用一个 DTO;`.start` / `.update` 即便被塞了这个键也**不读**
* (它们的领域事件上根本没有这个字段),所以契约没变宽——变宽的只是「多一个键不再算坏帧」。
*/
@Serializable
private data class ToolExecutionData(
@@ -927,6 +1033,7 @@ private data class ToolExecutionData(
val phase: String = "",
val id: String? = null,
val delta: String = "",
val resultSummary: String? = null,
)
@Serializable
@@ -1018,8 +1125,24 @@ private data class ChatDuplicateData(
@Serializable
private data class SessionReadData(val readAt: Long = 0L)
/**
* `message_persisted` / `channel_persisted` 载荷:`{message:{…}}`。
*
* [proactive] 是「这句话是她主动说的」(见 [WsEvent.MessagePersisted])。它此前**不在**这里,
* 于是被 `ignoreUnknownKeys = true` 静静吃掉——这正是 W53N 要修的那一条:判据键读不回来,
* 「她主动说的话」在客户端就只剩 `proactive_message` 那一条路可见。
*
* ⚠️ 声明成 [JsonElement] 而不是 `Boolean` 是**修一类能把整帧弄没的解码缺陷**(同
* [ProactiveData.timestamp] 那条教训):契约没说这个键的类型,而线上只要给的是 `"true"` 这种
* 字符串形状,`Boolean` 解码就会抛、被 `parse` 的 `runCatching` 吞成 `null` ⇒ **整帧消失**,
* 连普通消息的落库广播都跟着受影响。收成元素之后按字面量读(见 [asTrue]),一个判据键从此
* 不可能让一整类帧消失。
*/
@Serializable
private data class PersistedData(val message: HistoryMessageDto? = null)
private data class PersistedData(
val message: HistoryMessageDto? = null,
val proactive: JsonElement? = null,
)
/**
* `message_deleted` 载荷(§3.8):`{id}`。缺键 / 空串解成空 `id`,上层据此整帧跳过
@@ -1036,14 +1159,34 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
@Serializable
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
/**
* `voice_output` 载荷(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* `sessionId` 在这里**不是可选的**(契约「帧壳没有 sessionId(在 data 里)」),但解成缺省空串
* 并交给 [WsEvent.resolveSessionId]:坏帧(真没给)时宁可退化成「无法判定」(放行、由消费方
* 兜底),也不把整帧按坏帧丢掉——丢了就是「她回了语音但屏幕上什么都没有」。
*/
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
val sessionId: String = "",
val deviceId: String? = null,
val provider: String? = null,
)
/**
* `proactive_message` 载荷(v0.72;字段名对着服务端 `gateway.proactivePayload`)。
*
* [timestamp] 声明成 [JsonElement] 而不是 `Long` 是**修一个把整帧弄没的解码缺陷**:
* 服务端那是 Go `time.Time`,线上是 RFC3339 **字符串**,而本端此前按 `Long?` 解 ⇒ 解码抛异常、
* 被 `parse` 的 `runCatching` 吞成 `null`,帧**在成形之前就没了**(`onEvent` 连 `else` 都到不了)。
* 收成元素之后按字面量取(数字形状也照样读出它),一个只作诊断的键从此不可能让整帧消失。
*
* [id] / [createdAt] 是落库行的权威 id 与时间戳,落库失败时**一起缺席**。
*/
@Serializable
private data class ProactiveData(
val platform: String? = null,
@@ -1052,8 +1195,10 @@ private data class ProactiveData(
val content: String = "",
val contentType: String? = null,
val sender: String? = null,
val timestamp: Long? = null,
val timestamp: JsonElement? = null,
val messageId: String? = null,
val id: String? = null,
val createdAt: Long? = null,
)
/**
@@ -105,12 +105,26 @@ sealed interface WsOutbound {
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
/**
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
*
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
* 本端不做本地转写 ⇒ 恒省略。
*
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
*
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
*/
data class VoiceInput(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val sessionId: String? = null,
val deviceId: String? = null,
val clientMsgId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "voice_input"
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
format?.let { put("format", it) }
text?.let { put("text", it) }
sessionId?.let { put("sessionId", it) }
deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
}
}
@@ -20,6 +20,16 @@ enum class WsState {
/** 连接断开,正在按退避策略等待重连。 */
Reconnecting,
/** 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。 */
/**
* 不可恢复(握手 401 / 缺少 token 或地址),不会自动重连。
*
* ⚠️ **握手拿到「非 101 且非 401」的响应不属于这一档**:那种失败(例如中间设备/代理直接回
* `503`、`502`,或握手被打断)在 [ChatSocket.isAuthFailure] 里判不出鉴权失败 ⇒ 走
* [Reconnecting] 按退避**继续重连**。判据只认 `response.code == 401`(拿不到 response 时再退化
* 成消息文本里含 `401`),所以「代理回 503」时 socket 的真值是 [Reconnecting],不是 [Offline]。
* 这一条在发送阶梯上很要命:它决定了「WS 未就绪」时**要不要**去试一次 REST
* ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上判进队列、不试;W41 起如此,
* W45 起同一条判据仍然成立,只是那一步从「探一次健康端点」换成了「直接试 REST」)。
*/
Offline,
}
@@ -130,7 +130,8 @@ data class ConclusionParagraph(val parts: List<ContentPart>)
*
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签由 [parse] 兜底
* (落单的段落标签剥掉,其余按普通文本回落)。
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
* `null` / 纯空白输入返回空列表。
@@ -4,7 +4,7 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream
/**
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImage` 平行的概念)。
* 一份**已选好、还没发出去**的待发附件(与 `ChatUiState.draftImages` 平行的概念)。
*
* 与 [DocumentFile] 的分工:[DocumentFile] 是「发送那一刻的字节」——上传需要它;
* 本类型是「还没发送的引用」——输入栏上方的芯片要显示它,而**绝不能**把最多 5×20 MiB
@@ -40,12 +40,21 @@ data class EditableMessage(
)
/**
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器会**改掉**的那几个标签——行内标签不在其中,
* 见下):`<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
*
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
*
* **行内标签(`<b>` / `<code>` …)故意不在**这份容差里:它们是渲染用的标记,但编辑框
* 编辑的是**源码本身**,而 `parts`(这一份的来源)是**逐字原文**——所以 `日落 <b>18:35</b>` 在
* 编辑框里连 `<b>` 一起显示、也一起保存,这里一个字符都不动它。
*
* **`<br>` 同理**(契约 v0.89 更正后它是**气泡分隔符**、不是行内标签):只含 `<br>` 的消息在这份
* 容差里仍是纯文本 ⇒ 编辑框里逐字恒等(`<br>` 原样显示、原样保存);只有原文**另有**已知标记时
* 才走剥标记那条路,那时 `<br>` 与 `<p>` / `</p>` 一样被换成它本来就在表示的**空行分隔**
* (保存后渲染结果不变,且弹层已经明说标记会被替换)。
*/
private val KNOWN_TAG_RE = Regex(
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
@@ -0,0 +1,207 @@
package top.yeij.cyrene.domain.chat
import java.util.Locale
/**
* 预览内容**下方**那一行灰色小字的组装(纯函数,逐档穷举单测在 `PreviewMetadataTest`)。
*
* ## 这一行是什么,不是什么
* 它只放**关于这份文件本身**的格式事实:有多少行、按什么口径解码、多大、多少像素、什么时候拍的。
* 它**不是**状态栏、也不是错误提示——「读不到 / 打开失败 / 保存失败」各有自己的去处(芯片行、
* 正文区那句人话、弹层下方那行告知),混进来会让这一行读起来像出了什么事。
*
* ## 纪律:拿不到的项就不出现
* 每一项都在**有证据时才产出**,没有证据就整项缺席——**不留占位符、不写「未知」、不写 `--`**。
* 一屏小字里堆三个「未知」换不来任何信息,却会让人以为本端查过了。三档证据强度各自成条:
* - **字节**(本端真的持有那一份):行数、像素、EXIF 拍摄日期、文件体积;
* - **服务端结论**(DTO 里的字段):注入字符数、注入是否被截断、嗅探所得的 mime;
* - **本端口径**(本端自己做了什么):`UTF-8` 只是**本端解码口径**,不是文件的真实编码(见下)。
*
* ## 「编码」为什么敢写 UTF-8(且只敢这么写)
* 客户端**拿不到文件的真实编码**:磁盘上那份字节是按什么字符集写的,没有任何字段说过,本端也没有
* 可靠的方式判定(猜 GBK / Shift-JIS 就是在编)。能确定的只有一件事:正文区那段文字是
* [DocumentPreview.slice] 用 `String(bytes, Charsets.UTF_8)` 解出来的——所以这一项写的是
* **「本端解码口径」**,即「你正在读的这段文字是怎么来的」。**表述里必须带这个限定语**:
* 裸写一个「UTF-8」会被读成「这份文件是 UTF-8 编码的」,那是本端不知道的事。
*
* ## 与芯片行重合的两处:一处保留、一处省略
* 预览弹层顶部那枚芯片已经写着「类型 · 体积 · 字数 · 状态 · 注入已截断」([DocumentChipModel.detail]),
* 因此这一行刻意**不重复**:
* - **省略**「体积」与「服务端状态」:[DocumentChips.sizeLabel] / [DocumentChips.statusLabel] 那两个
* 数字/词与芯片行**逐字相同**、且没有口径可补,再写一遍只是噪音(图片那一路没有芯片行,所以
* 体积在 [imageItems] 里是要显示的);
* - **保留**「字符数」并补上口径:芯片只写「8,000 字」,读者无法知道那是**服务端注入上下文**的
* 字数还是这份文件的字数——这正是一句会把人带偏的话,补上「(服务端注入口径)」才算说清。
*
* ## 本端的截断为什么不在这里再说一遍
* [PreviewSlice.truncated](本端只预览了前 256 KB / 5,000 行)已经让 [PreviewSlice.notice] 在**正文
* 上方**明说了,本行不再重复同一件事;而**服务端**的「注入被预算截断」是另一件事([DocumentRefDto.truncated],
* 正文照样进了上下文),它没有别的去处,所以在 [textItems] 里单列一条。
*/
object PreviewMetadata {
/** 这一行各项之间的分隔符。 */
const val ITEM_SEPARATOR: String = " · "
/** 把各项拼成能直接交给 `Text` 的那一行;空列表拼成空串(调用方据此不画这一行)。 */
fun join(items: List<String>): String = items.joinToString(ITEM_SEPARATOR)
/**
* 文本类预览的那几项:行数 · 字符数(带口径)· 解码口径 · 服务端的截断标记。
*
* @param slice 正文区的切段结果([DocumentPreview.slice] 的产物)。
* @param serverCharsLabel 服务端下发的注入字符数**已格式化**的那一份
* (`documents[].chars` → [DocumentChips.charsLabel],如 `"8,000 字"`);`null` = 服务端没报
* (`chars` 整键缺席,契约 v0.59 §1.2),此时改报本端预览内容的字数。
* @param contextTruncated 服务端是否报告了「注入被预算截断」(`documents[].truncated`)。
* ⚠️ 与 [PreviewSlice.truncated](**本端**预览截断)是两件事,见本对象 KDoc。
*/
fun textItems(
slice: PreviewSlice,
serverCharsLabel: String?,
contextTruncated: Boolean,
): List<String> = buildList {
add(linesItem(slice))
add(charsItem(slice.text, serverCharsLabel))
// 本端解码口径:正文就是 String(bytes, UTF_8) 的产物,说得出、也只在说这一件事。
add(DECODED_AS_UTF8)
if (contextTruncated) add(TRUNCATED_FOR_CONTEXT)
}
/**
* 非文本类(PDF 这类二进制)的那几项:只有服务端**字节嗅探**所得的 mime。
*
* 体积与状态刻意不在这里(芯片行已经写着,见本对象 KDoc);服务端说「读不到」时下发的
* **原因原话**也不在这里——它已经被 [DocumentPreviewGate.plan] 送进正文区那句人话里**原样显示**,
* 这里是同一屏的第二个位置,重复一遍只会让那句话看着像两条不同的消息。
*
* @param serverMime DTO 的 `mime`(服务端嗅探所得基类型);本地待发附件**没有**这个结论
* (还没上传过),传 `null` —— 这一项因此缺席,**不由本端按扩展名补一个**。
*/
fun fileItems(serverMime: String?): List<String> =
listOfNotNull(serverMime?.trim()?.takeIf { it.isNotEmpty() })
/**
* 图片的那几项:格式(mime)· 分辨率 · 体积 · 拍摄日期。
*
* 图片这一路没有附件芯片行,所以体积在这里是要显示的。每一项都可能在拿不到时缺席:
* 格式(嗅探与平台声明都判不出来时)、分辨率(本端解不出边界时)、拍摄日期(EXIF 里没有
* `DateTimeOriginal`,或那个值不是能认的日期时)——见各自的参数说明。
*
* @param mime 交给别的应用的类型([ImageOpenTypes.openType] 的产物)。⚠️ 退化到「图片通配」
* 时**必须传 `null`**:`image/` 通配([ImageOpenTypes.ANY_IMAGE])不是格式,写出来等于给了一个什么都没说的答案。
* @param width / @param height 本端按 `inJustDecodeBounds` 读到的像素;任一侧 ≤ 0 / 缺席即整项不出现。
* @param sizeBytes 本端实际持有的字节数;[UNKNOWN_BYTES] 即整项不出现。
* @param capturedAt EXIF `DateTimeOriginal` 的**原始字符串**(`yyyy:MM:dd HH:mm:ss`,
* 没有时区、按本地墙钟读,见 [formatExifDateTime])。
*/
fun imageItems(
mime: String?,
width: Int? = null,
height: Int? = null,
sizeBytes: Long = UNKNOWN_BYTES,
capturedAt: String? = null,
): List<String> = buildList {
mime?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
if (width != null && height != null && width > 0 && height > 0) add("$width × $height")
DocumentChips.sizeLabel(sizeBytes).takeIf { it.isNotEmpty() }?.let { add(it) }
formatExifDateTime(capturedAt)?.let { add(CAPTURED_AT_PREFIX + it) }
}
/**
* EXIF 的拍摄时间 → 人能读的样子(`"2019:07:04 20:31:05"` → `"拍摄于 2019年7月4日 20:31"`);
* 认不出就回 `null`(宁可不显示,也不显示一个错的日期)。
*
* ## 两个必须说清的取舍
* 1. **没有时区**。EXIF 的 `DateTimeOriginal` 是纯墙钟字符串(`yyyy:MM:dd HH:mm:ss`),规范里
* 根本没有偏移量字段。所以这里**不做任何时区换算**,按字面数字当日历时间读——把它当 UTC
* 再转本地,只会让一个原本正确的日期凭空错几个小时。
* 2. **严格到不近人情**。只有逐位对上 `yyyy:MM:dd HH:mm:ss`(19 个字符、分隔符固定)且各段落在
* 合法范围内才认。相机的「值未写入」占位符 `"0000:00:00 00:00:00"` 因此落在月份 0 上被判非法
* ——这是对的,它本来就表示「不知道」。宽松解析(补零、猜分隔符)换来的是**看起来正常的错日期**,
* 比不显示糟得多。
*
* 秒刻意丢掉:这一行是给人看的摘要,分钟已经足够定位(同秒的两张图对用户没有区别)。
*/
fun formatExifDateTime(raw: String?): String? {
val value = raw?.trim().orEmpty()
val groups = EXIF_DATE_TIME.matchEntire(value) ?: return null
// 正则已保证每段都是纯数字,`toInt()` 在这里不会抛。
val (year, month, day, hour, minute, second) = groups.destructured
val y = year.toInt()
val mo = month.toInt()
val d = day.toInt()
val h = hour.toInt()
val mi = minute.toInt()
val s = second.toInt()
if (y < MIN_YEAR) return null
if (mo !in 1..12) return null
if (d !in 1..31) return null
if (h !in 0..23) return null
if (mi !in 0..59) return null
// 60 是闰秒,相机会写出来,不该判成坏日期。
if (s !in 0..60) return null
return String.format(Locale.ROOT, "%d年%d月%d日 %02d:%02d", y, mo, d, h, mi)
}
/**
* 行数那一项。
*
* 显示量按 [DocumentPreview.lineCount] **从正文本身重算**(不是取 [PreviewSlice.shownLines]):
* 这一行的标题就是「你看到的这段有多少行」,重算让它与正文永远自洽。被本端截断时补上全文行数
* ([PreviewSlice.totalLines] 是按整份字节数出来的,本端确实持有那份字节)——只说「显示 N 行」
* 会让人以为文件就这么长。
*/
private fun linesItem(slice: PreviewSlice): String {
val shown = grouped(DocumentPreview.lineCount(slice.text))
return if (slice.truncated) {
"显示 $shown 行 / 共 ${grouped(slice.totalLines)} 行"
} else {
"$shown 行"
}
}
/** 字符数一项:服务端报了就报它那句(补口径),没报才用本端的计数(另一句口径)。 */
private fun charsItem(text: String, serverCharsLabel: String?): String =
if (serverCharsLabel != null) {
serverCharsLabel + SERVER_CHARS_SCOPE
} else {
grouped(codePointCount(text)) + " 字" + LOCAL_CHARS_SCOPE
}
/**
* 「字」数按 **Unicode 码点**算(`codePointCount`):一个 emoji / 一个汉字都算一个字。
* 直接取 `String.length` 会把 BMP 之外的字符算成两个(UTF-16 代理对),与「字数」这个说法不符。
*/
private fun codePointCount(text: String): Int =
text.codePointCount(0, text.length)
/** 千分位分组(只用于计数,纯展示)。 */
private fun grouped(value: Int): String = String.format(Locale.ROOT, "%,d", value)
// —— 每一项的措辞都收在这里,测试锚的也是这几句 ——
/** 本端解码口径。**限定语不许省**:省了就成了对文件真实编码的断言(见本对象 KDoc)。 */
const val DECODED_AS_UTF8: String = "UTF-8(本端解码口径)"
/** 服务端 `chars` 的口径:那是**注入上下文**的字数,不是这份文件的字数。 */
const val SERVER_CHARS_SCOPE: String = "(服务端注入口径)"
/** 本端自己数出来的字数口径:只数了预览内容(截断时就不是全文)。 */
const val LOCAL_CHARS_SCOPE: String = "(本端预览内容)"
/** 服务端的 `truncated`:注入被预算截断,**正文照样进了上下文**(与「读不到」是两件事)。 */
const val TRUNCATED_FOR_CONTEXT: String = "正文被截断(进上下文的部分)"
/** 拍摄日期前缀:一个光秃秃的日期在这一行里会被当成「文件修改时间」之类的东西。 */
const val CAPTURED_AT_PREFIX: String = "拍摄于 "
/** 字节数拿不到时的占位:交给 [DocumentChips.sizeLabel],它会回空串(于是整项不出现)。 */
const val UNKNOWN_BYTES: Long = -1L
private const val MIN_YEAR = 1
/** EXIF 规范里的日期时间形状;只认这一种,见 [formatExifDateTime]。 */
private val EXIF_DATE_TIME =
Regex("""(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})""")
}
@@ -0,0 +1,238 @@
package top.yeij.cyrene.domain.chat
/**
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
*
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
*
* ## 采样参数:16 kHz / 单声道 / 16-bit
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
*
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
*
* ## 体积闸门(为什么是 5 MiB)
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
*
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
*
* ## 时长下限
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
*/
object VoiceClip {
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
const val SAMPLE_RATE_HZ: Int = 16_000
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
const val CHANNEL_COUNT: Int = 1
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
const val BITS_PER_SAMPLE: Int = 16
/** 每采样的字节数。 */
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
const val HEADER_BYTES: Int = 44
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
const val FORMAT: String = "wav"
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
/**
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
*/
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
/**
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
*
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
*/
const val MAX_RECORDING_MILLIS: Int = 60_000
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
const val MIN_RECORDING_MILLIS: Int = 1_000
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
fun pcmBytesFor(durationMillis: Int): Int =
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
fun durationMillisOf(pcmBytes: Int): Int =
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
/**
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
*
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
*/
fun wavHeader(pcmBytes: Int): ByteArray {
val data = ByteArray(HEADER_BYTES)
putAscii(data, 0, "RIFF")
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
putAscii(data, 8, "WAVE")
putAscii(data, 12, "fmt ")
putIntLe(data, 16, 16)
putShortLe(data, 20, AUDIO_FORMAT_PCM)
putShortLe(data, 22, CHANNEL_COUNT)
putIntLe(data, 24, SAMPLE_RATE_HZ)
putIntLe(data, 28, BYTES_PER_SECOND)
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
putShortLe(data, 34, BITS_PER_SAMPLE)
putAscii(data, 36, "data")
putIntLe(data, 40, pcmBytes)
return data
}
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
fun toWav(pcm: ByteArray): ByteArray {
val header = wavHeader(pcm.size)
val out = ByteArray(HEADER_BYTES + pcm.size)
header.copyInto(out, 0)
pcm.copyInto(out, HEADER_BYTES)
return out
}
/**
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
*
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
* - 太短:见 [MIN_RECORDING_MILLIS]。
*/
fun plan(pcm: ByteArray): VoiceClipPlan {
if (pcm.isEmpty()) return VoiceClipPlan.Empty
val total = totalBytesFor(pcm.size)
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
val duration = durationMillisOf(pcm.size)
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
return VoiceClipPlan.Ready(toWav(pcm), duration)
}
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
private const val AUDIO_FORMAT_PCM: Int = 1
private fun putAscii(target: ByteArray, offset: Int, text: String) {
for (index in text.indices) target[offset + index] = text[index].code.toByte()
}
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
}
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
}
}
/** [VoiceClip.plan] 的四档结论。 */
sealed interface VoiceClipPlan {
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
/** 一个字节都没录到。 */
data object Empty : VoiceClipPlan
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
data class TooShort(val durationMs: Int) : VoiceClipPlan
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
}
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
/** 太短的如实告知。 */
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
/**
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
*/
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
is VoiceClipPlan.Ready -> null
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
}
/**
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
*
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
*/
fun voiceElapsedLabel(elapsedMs: Int): String {
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
}
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
/**
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
*
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
* 换了新键就是让服务端把同一句话再答一遍。
*
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
*/
data class VoiceDraftInfo(
val clientMsgId: String,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 这一段该用哪个幂等键(纯函数):
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
*/
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
@@ -0,0 +1,311 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.api.dto.VoiceDto
import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException
import top.yeij.cyrene.data.repository.VoiceTooLargeException
import top.yeij.cyrene.data.repository.VoiceUrlUnavailableException
/**
* `voice.source` 的闭集(契约 §4.4):`asr` = 服务端(含云端)语音识别所得;
* `platform` = 渠道平台自带转写(微信 iLink 的 `voice_item.text`);
* `tts` = **我们自己合成的出站语音**(她的 `speak` 工具产出的那一段,后端 M57-M59 新增)。
*
* 三个取值在**接收侧**没有分叉:合成成功的那条与入站语音同形(`status = transcribed` + 四键
* 副本齐全),照同一条路渲染;合成降级的那条是 `status = failed`(见 [VoiceStatus.FAILED])。
*/
object VoiceSource {
const val ASR: String = "asr"
const val PLATFORM: String = "platform"
const val TTS: String = "tts"
}
/**
* `voice.status` 的闭集(契约 §4.4,**三态**)。
*
* 与 [DocumentStatus] 同一条纪律:**只认闭集里的取值,认不出的不猜**——但两者收尾的方式不同。
* 文档的未知 `status` 要**原样显示**(那是给用户看的结论),语音的未知 `status` 只是
* 「本端不多说一句话」(正文由服务端给,见 [voiceNote])。
*/
object VoiceStatus {
/** 正文**就是**转写文本(出站方向 = 合成成功、有副本可播)。 */
const val TRANSCRIBED: String = "transcribed"
/** 转写成功但**没识别出文字** ⇒ 正文多半是空的,本端补一句([VOICE_EMPTY_NOTE])。 */
const val EMPTY: String = "empty"
/**
* 这一条**没有可播的语音**,正文是一句话(见 [isPlayableVoice])。
*
* 两个方向同形、语义统一(后端 M57-M59 预告,契约 v0.98 待正式回执):
* - **入站**:没拿到转写文本 ⇒ 正文是服务端给的那行中文诚实提示;
* - **出站**:她本想用语音、合成降级回文字 ⇒ 正文是普通文字,另带 `fallbackReason`
* (见 [voiceFallbackNote])。
*
* 入站方向设计上**刻意不下发**失败细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 /
* 无字节一律只进服务端日志),所以本端**一个细分都不许猜**,也不替服务端改写那句话。
*/
const val FAILED: String = "failed"
}
/**
* 「转写成功但没识别出文字」时本端补的那一句。
*
* 为什么这句是**本端的**而不是服务端的原话:这一态的语义由契约定义(§4.4 的三态之一),而
* `empty` 的正文按定义就是空的——不补一句的话,一条「微信语音、没有音频副本、也没转出文字」
* 的消息在屏幕上**只剩一个「语音」标签**,看的人无从知道发生了什么。这不是替服务端改文案
* (那句话说的是**这一态的语义**,不是失败原因),也**不覆盖**任何正文。
*/
const val VOICE_EMPTY_NOTE: String = "没有识别出文字"
/**
* `status == "failed"` 的那一行**灰色小标**的固定前半句(后面可能跟服务端给的 `fallbackReason`)。
*
* 为什么要有这一行:这一态的消息 `voice` 键**在**、但**没有可播的语音**(见 [isPlayableVoice]),
* 所以屏幕上不会有语音形态。少了这一行,一条「她本想发语音、合成降级成文字」的消息与一条普通文字
* 消息**逐像素相同**——看的人无从知道这条本来是一段声音。它只说本端能确定的那件事(这条没有可播的
* 声音),**不解释原因**(原因归服务端,见 [voiceFallbackNote] 里那段原话)。
*
* ⚠️ 措辞要同时站得住两个方向(入站转写失败 / 出站合成降级),所以**不写**「转写失败」也**不写**
* 「合成失败」:本端分不清,也不该替服务端选一个说法。
*/
const val VOICE_FALLBACK_NOTE: String = "这条语音没有可播放的声音"
/**
* 解码 / 播放阶段失败(`MediaPlayer` 报错或起播抛异常)时的文案。
*
* 与取字节失败([voicePlaybackFailureMessage])分开:两者要用户做的事不一样——字节没拿到是
* 「网络 / 过期 / 服务端没有这份副本」,可以再点一次;这一段是「字节在手上,本机解不开」
* (格式不支持、文件损坏),再点一次多半还是同样的结果。
*/
const val VOICE_UNPLAYABLE_MESSAGE: String = "这段语音本机播不了"
/**
* 一条语音的**可播放副本**:四个键(`name` / `mime` / `size` / `url`)齐全时才构造得出来。
*
* 存在的理由是把「能不能播」这件事**收成一个类型**,而不是让 UI / 播放器各自去测四个 `!= null`:
* 拿到 [VoiceAudio] 就等于「有副本、可以画播放控件、可以发请求」;拿不到就**一个播放控件都不画**
* (不能给一个点下去必然失败的按钮)。
*
* @property durationMs 从 [VoiceDto] 原样带过来;`null` = 服务端没给,**不显示时长**。
*/
data class VoiceAudio(
val name: String,
val mime: String,
val size: Long,
val url: String,
val durationMs: Int? = null,
)
/**
* `voice` 键在不在(纯函数)—— 「这条消息**来自**一条语音」。
*
* 这是契约 §4.4 给的**唯一**来源判据:非语音消息整键缺席(不是 `{}` 也不是 `null`),
* `content` 里有没有 `[语音]` 这类字样与本判据无关(QQ 的占位标记自 v0.67 起不再下发,
* 别人也可以自己打出一模一样的字)。
*
* ⚠️ 它与「要不要画语音气泡」**不是同一件事**了:合成降级 / 转写失败的那些行 `voice` 键照样在,
* 但**没有可播的语音**(见 [isPlayableVoice])。判「是不是语音消息」用本函数,判「画不画语音
* 形态」用 [isVoiceMessage]——两个问题分开问,混用会让降级行被画成一个必然播不了的气泡。
*/
fun hasVoice(message: HistoryMessageDto): Boolean = message.voice != null
/**
* 这条语音**有没有可播的语音形态**(纯函数):除了 `voice` 键要在,`status` 还不能是 `failed`。
*
* ## 为什么判据是「不是 `failed`」而不是「是 `transcribed` / `empty`」
* 后端 M57-M59 预告(契约 v0.98 待正式回执)只点名了 `failed` 这一态**明确没有可播的语音**:
* 入站是没拿到转写文本、出站是合成降级回文字,两个方向的正文都是一句话而不是一段语音。
*
* 反过来,认不出的 `status` **一律当可播**(fail-open)。理由是这个方向错得便宜、另一个方向错得
* 贵:后端将来若新增一个成功态(比如 `transcribed` 之外的 `truncated`),fail-open 只是多画一枚
* 语音形态(最坏是点了播不出来,有既有的失败文案兜着);fail-closed 则会把一条**真的有副本**的
* 语音静默吞成纯文字,用户再也点不到那段声音——而且没有任何界面提示说明为什么。所以这里只把
* 契约明确说过「没有语音」的那一态排除掉,其余交给「四键齐全才画播放控件」([voiceAudio])。
*/
fun isPlayableVoice(voice: VoiceDto): Boolean = voice.status != VoiceStatus.FAILED
/**
* 🎤 **要不要画语音形态的判据** = `voice` 键存在 **且** `status != "failed"`(契约 §4.4 +
* 后端 M57-M59 预告)。
*
* 只有这两个条件,而且**永远只有这两个**:
* - `voice` 键缺席 ⇒ 不是语音消息([hasVoice]);
* - `status == "failed"` ⇒ 这条**没有可播的语音**(入站转写失败 / 出站合成降级),正文照常按文字
* 渲染,另加一行灰色小标([voiceFallbackNote])——**不**画语音形态,因为画出来就是一个必然
* 播不了的控件。
*
* 入站语音(`asr` / `platform`)与合成成功的出站语音(`tts`)走的是**同一条**路:判据只看
* `status`,不看 `source`。
*/
fun isVoiceMessage(message: HistoryMessageDto): Boolean {
val voice = message.voice ?: return false
return isPlayableVoice(voice)
}
/**
* 这条语音的**可播放副本**;四个键没凑齐(或名字 / MIME / URL 是空白)就回 `null`。
*
* 服务端保证「四者同生共死」,本端仍然逐项检查:**这条判据的下游是「给不给播放控件」**,
* 而契约里那份保证一旦被某个部署打破,唯一的后果就是用户点到一个必然失败的按钮
* ——宁可少画一个控件,也不要画一个假的。
*
* `size` 只要求在(**不要求 > 0**):契约说它恒在,本端也不拿它推导别的任何东西
* (尤其**不反算时长**,见 [voiceDurationLabel])。
*/
fun voiceAudio(voice: VoiceDto?): VoiceAudio? {
if (voice == null) return null
val name = voice.name?.takeIf { it.isNotBlank() } ?: return null
val mime = voice.mime?.takeIf { it.isNotBlank() } ?: return null
val url = voice.url?.takeIf { it.isNotBlank() } ?: return null
val size = voice.size ?: return null
return VoiceAudio(name = name, mime = mime, size = size, url = url, durationMs = voice.durationMs)
}
/**
* 时长文案(纯函数):`m:ss`(如 `0:03` / `1:05`)。
*
* **缺席就不显示**是契约的硬要求(服务端「读不出来就省略键,绝不估算」,本端同理):
* - `null` ⇒ 回 `null`(调用方因此**一个字符都不画**,更不会出现 `0:00` 那种冒充);
* - `<= 0` ⇒ 也回 `null`:`0` 毫秒的语音不存在,把服务端的一个异常取值显示成 `0:00`
* 就是拿一个编出来的数字冒充事实;
* - **不足一秒**(`1..999`)⇒ 也回 `null`:`m:ss` 只能把它显示成 `0:00`,而 `0:00` 与
* 「没有时长」在屏幕上是同一个样子——与其显示一个看起来像没读到的时间,不如什么都不显示
* (渠道也不会真的发这种语音);
* - 不设上限:语音消息不会长到需要小时制,超过 99 分钟就照实显示 `124:03`(**不折算**成
* 「2:04:03」——那是另一种自己发明的表达)。
*/
fun voiceDurationLabel(durationMs: Int?): String? {
if (durationMs == null || durationMs < 1_000) return null
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
/**
* 语音形态**之外**还要多说的一句话;不需要多说就回 `null`(纯函数)。
*
* 只对 `status == "empty"` 且**正文为空**时成立:
* - `transcribed`:正文**就是**转写文本,多说一句都是噪声(所以什么都不补);
* - `empty` 而正文非空:正文本身就是要说的话,此时再说「没有识别出文字」会**自相矛盾**
* (这一形状按契约不该出现,但真出现了也不该让屏幕上出现两句互相顶嘴的话);
* - 未知 `status`(含空串):**不猜**,只显示正文。
*
* ⚠️ `failed` 那一态**不走这里**(它连语音形态都不画):正文由服务端给,本端另加一行小标
* ([voiceFallbackNote])。本函数仍对 `failed` 回 `null`,因为「语音形态旁边多说一句」这个位置
* 对那一态根本不存在。
*/
fun voiceNote(status: String, content: String): String? =
if (status == VoiceStatus.EMPTY && content.isBlank()) VOICE_EMPTY_NOTE else null
/**
* `status == "failed"` 时那行灰色小标该写什么;别的状态回 `null`(纯函数)。
*
* 两段拼成一句话:**固定前半句**([VOICE_FALLBACK_NOTE],说的是本端能确定的事实)+ 服务端给的
* `fallbackReason`(**一个字符都不改**,原样放进括号里)。
*
* ## 为什么原样放、不做映射
* `fallbackReason` 的取值集合**后端还没定**(`devdocs/02` 里我们已经问了「是闭集还是自由文本」,
* 尚未答复;契约 v0.98 的正式回执也还没到)。在有答案之前,本端**不猜**它是不是闭集、更不替它
* 编一套友好文案:认不出的取值被映射成一句话,比原样显示更难排查——用户报「显示成『语音超时』」
* 时,我们连服务端到底给了什么都不知道。原样显示,最坏是屏幕上出现一个 `tts_timeout` 这样的词,
* 而它**正是**我们和后端对账时要的那个字符串。
*
* ## `fallbackReason` 缺席时
* 只有固定前半句。这一档同时是**空正文 + `failed`** 的兜底:这一态的消息正文按契约应当非空
* (服务端那行诚实提示 / 降级后的原文本),但本端**不靠**这条保证——真收到一条空正文的 `failed`
* 行时,屏幕上至少还有这行小标,而不是一个**什么都没有**的空白(那正是空气泡那种最糟的呈现)。
*/
fun voiceFallbackNote(voice: VoiceDto?): String? {
if (voice == null || voice.status != VoiceStatus.FAILED) return null
val reason = voice.fallbackReason?.takeIf { it.isNotBlank() } ?: return VOICE_FALLBACK_NOTE
return "$VOICE_FALLBACK_NOTE($reason)"
}
/**
* 把服务端给的**相对路径** [url] 接到当前服务器地址 [baseUrl] 上(纯函数)。
*
* 契约明写 `url` 是唯一的派生字段(`/api/v1/media/<name>`),**客户端绝不自己拼路径**——所以
* 本函数只做「保留 path、换成当前 host」这一件事,与 [ImageHostRebase] 同一取向
* (图片那条要换的是**别人给过的绝对地址**里的 host,这里要接的是**服务端给的相对路径**)。
*
* 判据取最小容忍度,任何一条不满足就回 `null`(调用方据此如实报「暂时播不了」,**绝不**
* 退回「自己拼一条 `/api/v1/media/<name>`」——那正是契约禁止的那件事):
* - [url] 必须以 `/api/v1/media/` 开头,且后面**恰好**是一段名字:非空、不是 `.` / `..`、
* 不含 `/` `\` `%` `?` `#` 与 NUL;
* - [baseUrl] 必须能解出 `scheme://authority`(空串 / 没有 host 都解不出)。
*
* 用的是 [baseUrl] 的 scheme + authority(所以 `http → https`、端口变更都跟着走),**不用**
* baseUrl 里的路径:这条地址挂在根路径上,部署形态本来就没有路径前缀这一说。
*/
fun voiceMediaUrl(url: String, baseUrl: String): String? {
val name = url.removePrefix(MEDIA_PREFIX)
if (!url.startsWith(MEDIA_PREFIX) || !isSafeMediaName(name)) return null
val origin = originOf(baseUrl) ?: return null
return origin + url
}
/**
* 取字节失败时的文案(纯函数,可单测)。
*
* 与 `documentLoadFailureMessage` / `imageLoadFailureMessage` 同一纪律:服务端能说清的用它的
* 原话,本端能分类的分开说,**绝不把失败渲染成「静音」**——一条点了没反应的语音消息在用户眼里
* 与「坏了」没有区别。
*
* 三档本地失败分开写,因为它们要用户做的事不一样:地址拼不出来 = 本端环境不对(重新登录 / 换
* 服务器);太大 = 这条本端处理不了;缓存写不进去 = 本端的问题,重试多半还是这样。
*/
fun voicePlaybackFailureMessage(e: Throwable): String = when (e) {
is VoiceUrlUnavailableException -> "当前服务器地址不可用,暂时播不了这条语音"
is VoiceTooLargeException -> "这条语音太大,本机放不了"
is DocumentCacheUnavailableException -> "本地缓存不可用,暂时播不了这条语音"
is CyreneException -> when {
// ⚠️ 刻意**不**走 `toDocumentFailure()`:那个映射只认文档那四个码(`document_not_found`),
// `media_not_found` 在它眼里是「未知码」⇒ 会把「服务端上已经没有这条语音了」说成
// 「语音加载失败,media not found」。这一档本端说得准,就说得准一点。
e.error.isMediaNotFound() -> "服务端上已经没有这条语音了"
else -> "语音加载失败," + e.error.userMessage
}
else -> "语音加载失败,请稍后重试"
}
/** 服务端对「名字非法 / 穿越 / 不存在」统一回这一个码(U19:**不区分**,不给探测面)。 */
private const val MEDIA_NOT_FOUND_CODE = "media_not_found"
private fun CyreneError.isMediaNotFound(): Boolean =
this is CyreneError.Server && code == MEDIA_NOT_FOUND_CODE
/** 服务端相对路径的前缀(契约 §1.2 U19)。 */
private const val MEDIA_PREFIX = "/api/v1/media/"
/**
* 媒体名是不是「恰好一段」:不许有分隔符、百分号编码、查询串,也不许是 `.` / `..`。
*
* 与 `ImageHostRebase` 的同名判据同一条理由:这个字符串来自**别人给的**数据,
* 看不懂的形态就不该被我们拼进一条请求地址。
*/
private fun isSafeMediaName(name: String): Boolean {
if (name.isEmpty() || name == "." || name == "..") return false
return name.none { it == '/' || it == '\\' || it == '%' || it == '?' || it == '#' || it == '\u0000' }
}
/** `scheme://authority`;解不出来回 `null`(空串 / 没有 host / 不是 http(s) 都是解不出来)。 */
private fun originOf(baseUrl: String): String? {
val rest = when {
baseUrl.startsWith("http://", ignoreCase = true) -> baseUrl.substring("http://".length)
baseUrl.startsWith("https://", ignoreCase = true) -> baseUrl.substring("https://".length)
else -> return null
}
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
val authority = rest.substring(0, end)
if (authority.isBlank()) return null
return baseUrl.take(baseUrl.length - rest.length) + authority
}
@@ -0,0 +1,93 @@
package top.yeij.cyrene.domain.chat
/**
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
*
* 帧形状(后端 `回执三十六`):
* ```json
* { "type": "voice_output",
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
* ```
*
* ## `text` 与 `reply` 是两个东西(本端怎么用)
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
*
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
*
* ## `provider` 是**降级信号**,不是装饰
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
*
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
*
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
*/
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
object VoiceProvider {
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
const val SILENT: String = "silent"
}
/**
* 这一帧是不是「没有真音频」那一档(纯函数)。
*
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
*/
fun isSilentVoiceProvider(provider: String?): Boolean =
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
/**
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
*
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
*/
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
/**
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
*/
fun voiceReplyText(reply: String?, text: String?): String? =
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
/**
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
*
* 两档**都要解释,但说的不是同一件事**:
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
*/
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
silent -> VOICE_SILENT_NOTE
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
else -> null
}
/**
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
*/
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
/**
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
*
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
*/
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
@@ -0,0 +1,121 @@
package top.yeij.cyrene.domain.chat
/**
* 语音播放的**状态**(纯数据,可单测)。
*
* ## 为什么要有它,而不是让每个气泡各自 `remember { mutableStateOf(false) }`
* 同一时刻**只能有一条语音在响**(一台设备一个喇叭,两条语音叠着放一定是 bug),而
* 「哪一条在响」是**跨气泡**的事实:用户点了另一条,前一条必须停下来并释放解码器。
* 于是这份状态由**一条播放通道**持有,气泡只是它的视图。四个态刻意只有四个:
*
* - [Idle]:什么都没在放(也是「停下来之后」唯一的终点);
* - [Loading]:字节在取(首次播放要先下载;第二次起走本地缓存,一闪而过);
* - [Playing]:真的在响(`MediaPlayer` 已经 `start()` 过,还没走完 / 还没被停);
* - [Failed]:**这一次尝试**没成功,[Failed.message] 是给用户看的那句话。
*
* [Failed] 携带的是**这条消息**的失败(不是全局横幅):用户点的是这一条,出问题的是这一条。
* 它**不粘**:再点一次就从 [Loading] 重新开始(见 [pressed])。
*
* ## 每条转换都带 `messageId`,因为回调会迟到
* `MediaPlayer` 的回调(prepared / completion / error)与取字节的协程都可能**在用户已经改主意
* 之后**才到达。所以每一次转换都要**比对目标**:过期的回调**一个都不许**改状态
* ([prepared] / [failed] / [finished] 的同一条纪律)。这不是防御性编程的洁癖——
* 少了它就会出现「播放器已经换了、界面还显示上一条在响」。
*/
sealed interface VoicePlaybackState {
/** 没有被这条通道占着的消息(初始态,也是停 / 出错 / 放完之后的终点)。 */
data object Idle : VoicePlaybackState
/** 正在取这一条的字节(下载 / 读缓存)。 */
data class Loading(val messageId: String) : VoicePlaybackState
/** 这一条正在响。 */
data class Playing(val messageId: String) : VoicePlaybackState
/** 这一条的上一次尝试没成;[message] 是给用户看的那句话(见 `voicePlaybackFailureMessage`)。 */
data class Failed(val messageId: String, val message: String) : VoicePlaybackState
}
/**
* 这条消息此刻正被这条播放通道占着吗([VoicePlaybackState.Loading] 或
* [VoicePlaybackState.Playing])。
*
* [VoicePlaybackState.Failed]**不算**:它表示这条已经不在放,用户再点一下就是「重试」。
*/
fun VoicePlaybackState.isActiveFor(messageId: String): Boolean = when (this) {
is VoicePlaybackState.Loading -> this.messageId == messageId
is VoicePlaybackState.Playing -> this.messageId == messageId
else -> false
}
/** 这条消息**正在响**吗(只认 [VoicePlaybackState.Playing],加载中不算)。 */
fun VoicePlaybackState.isPlayingFor(messageId: String): Boolean =
this is VoicePlaybackState.Playing && messageId == this.messageId
/**
* 这条消息**正在取字节**吗(只认 [VoicePlaybackState.Loading])。
*
* 与 [isPlayingFor] 分开是必须的:「正在加载」与「正在响」对用户是两件事,界面上那句
* 「加载中…」只能在**真的在加载**时出现——混着用会让一条 30 秒的语音在整段播放期间都写着
* 「加载中…」。
*/
fun VoicePlaybackState.isLoadingFor(messageId: String): Boolean =
this is VoicePlaybackState.Loading && messageId == this.messageId
/**
* 这条消息上一次尝试失败时那句提示;不是它 / 没失败过就回 `null`(气泡据此决定画不画那一行)。
*/
fun VoicePlaybackState.failureOf(messageId: String): String? =
(this as? VoicePlaybackState.Failed)?.takeIf { it.messageId == messageId }?.message
/**
* 用户点了这条消息的播放 / 暂停。
*
* - 点的是**正在放 / 正在加载的那一条** ⇒ 回 [VoicePlaybackState.Idle](= 停,调用方据此停下
* 并释放;「再点一下取消」对加载中同样成立——用户不该被一个慢下载困住);
* - 点的是别的(或上一次失败的)那一条 ⇒ 换成它的 [VoicePlaybackState.Loading]。
*
* 有一条**调用方职责**不在状态里:进入 [VoicePlaybackState.Loading] 之前必须把上一条真的停掉
* (见 `ui.chat.VoicePlayer.toggle`)。纯函数只说「状态应该变成什么」。
*/
fun VoicePlaybackState.pressed(messageId: String): VoicePlaybackState =
if (isActiveFor(messageId)) VoicePlaybackState.Idle else VoicePlaybackState.Loading(messageId)
/**
* 字节到手、`MediaPlayer` 已经 `start()` 过:`Loading(同一条)` ⇒ `Playing(同一条)`。
*
* 目标不对(用户已经点了别的 / 点了停)或当前不是 `Loading` 时**原样返回**——迟到的回调不许
* 把界面从「另一条在响」拽回来。
*/
fun VoicePlaybackState.prepared(messageId: String): VoicePlaybackState =
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
VoicePlaybackState.Playing(messageId)
} else {
this
}
/**
* 取字节 / 起播失败:`Loading(同一条)` ⇒ `Failed(同一条, message)`;其余情况原样返回。
*
* 只从 [VoicePlaybackState.Loading] 出发是刻意的:**正在响的那一条**不会被一个迟到的失败
* 回调说成失败(`Playing` 阶段的解码错误走 [finished] 那条路,由播放器自己先停下再报)。
*/
fun VoicePlaybackState.failed(messageId: String, message: String): VoicePlaybackState =
if (this is VoicePlaybackState.Loading && this.messageId == messageId) {
VoicePlaybackState.Failed(messageId, message)
} else {
this
}
/** 放完了:`Playing(同一条)` ⇒ [VoicePlaybackState.Idle](播完就回初始外观,不留痕迹)。 */
fun VoicePlaybackState.finished(messageId: String): VoicePlaybackState =
if (isPlayingFor(messageId)) VoicePlaybackState.Idle else this
/**
* 停下来(用户点了停 / 换了另一条 / 离开会话 / 组件销毁)⇒ [VoicePlaybackState.Idle]。
*
* **任何态都收**,包括 [VoicePlaybackState.Failed]:失败那句话的生命周期与这一次尝试绑定,
* 换了目标或页面重进就不该再挂着。
*/
val VoicePlaybackState.silenced: VoicePlaybackState get() = VoicePlaybackState.Idle
@@ -0,0 +1,167 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
/**
* 「把这条消息念出来」这条路上,**本端要说的那几句话**,以及失败该分到哪一档。
*
* 这个文件里全是纯函数 / 常量,没有 Android、没有网络、没有状态:单测直接调(见
* `VoiceSpeakTest`)。真正的取字节在 `data.voice.SpeechClient`,播放复用 `ui.chat.VoicePlayer`。
*/
/**
* 从一条消息解析出来的气泡里抽出**要念的那段纯文本**。
*
* ## 为什么只念 `body`
* [ParsedBubble] 把一条气泡拆成三段:`head`(说话人抬头)、`body`(正文)、`tail`(旁白 / 动作)。
* 念出来时**只念正文**:
* - `head` 是「昔涟:」这种抬头,念出来像在念字幕;
* - `tail` 里的旁白(`*她笑了笑*`)与动作(`[动作]`)是**舞台指示**,语音合成念它只会变成一串
* 莫名其妙的字面量。
*
* ## 为什么按 `\n` 拼、且空就回 `null`
* 一条消息可能被切成多个气泡(连续说两段);`\n` 让合成的停顿落在段落之间,比空格自然。
* 全都为空(纯附件消息、纯旁白消息、或者解析完什么都不剩)时回 `null` —— 调用方据此**不显示**
* 朗读入口,而不是让用户点一个必然拿到 `400` 的按钮。
*
* 取完 `trim`:首尾空白会变成合成音频开头 / 结尾的静音,没有任何用处。
*/
fun ttsTextOf(bubbles: List<ParsedBubble>): String? =
bubbles.asSequence()
.map { it.body }
.filter { it.isNotBlank() }
.joinToString("\n")
.trim()
.takeIf { it.isNotEmpty() }
/**
* 一条消息(`content` + `mode`)里**要念的那段文本**;`null` = 没有可念的正文。
*
* 这是「这条消息能不能朗读」的**唯一判据**:`ChatViewModel.speak` 用它决定发不发请求,
* `MessageBubble` 用它决定画不画那枚图标。两处必须是同一个答案 —— 画了却发不出去、或者能发
* 却不给入口,都是 bug。
*
* ## 为什么要看 `mode`
* `chat`(或缺席)是「按 markup 切气泡的聊天式渲染」;其余 mode 下气泡里放的是**原文**,
* 一条 markup 都不解析(`MessageBubble` 就是这么画的)。朗读要念的显然也是**用户看到的那段字**:
* - 聊天式 ⇒ 走 [ttsTextOf] 剥掉抬头 / 旁白;
* - 其余 ⇒ 原文原样,只 `trim`。
*
* 判据本身与 `MessageBubble` 里那句 `isChat` 一字不差,刻意留在这里一份:多写一份就会出现
* 「界面按一种切法、朗读按另一种切法」的分叉。
*/
fun ttsTextOf(message: HistoryMessageDto): String? =
if (message.mode.isNullOrBlank() || message.mode == "chat") {
ttsTextOf(parse(message.content).filterNot { it.isEmpty })
} else {
message.content.trim().takeIf { it.isNotEmpty() }
}
/**
* 一次朗读失败该归到哪一档 —— 判据只看**服务端到底说了什么**,不看 HTTP 码的表面。
*
* ## 为什么分两档
* 这两档对用户的意思完全不同,混成一句就等于什么都没说:
* - [Soft]:**这会儿**没读出来(网络断了、令牌过期、网关抽风)。过一会儿再点是**有意义**的,
* 所以文案只陈述事实、不诱导、也不吓人;
* - [Unconfigured]:**配置**上就没这条路的。服务端明说了是哪种缺(`tts_persona_unmapped`
* 没有发音人映射 / `tts_no_character` 连生效人格都没有),重试**永远不会**成功 —— 所以文案要说
* 清「缺什么」,并且**不提重试**(提了就是在骗人点)。
*/
sealed interface TtsFailure {
/** 要说给用户的那一句话。 */
val message: String
/** 再点一次**可能**有用吗?([Unconfigured] 恒 `false`:重试永远不会成功。) */
val retryable: Boolean
/** 这会儿没读出来:不解释原因(本端确实不知道是哪一段断了),也不诱导重试。 */
data object Soft : TtsFailure {
override val message: String = TTS_SOFT_FAILURE_MESSAGE
override val retryable: Boolean = true
}
/**
* 服务端明说了配置缺一块。[code] 就是服务端那个错误码(原样留着,排查时对得上号)。
*/
data class Unconfigured(val code: String) : TtsFailure {
override val message: String = when (code) {
TTS_CODE_PERSONA_UNMAPPED -> TTS_PERSONA_UNMAPPED_MESSAGE
TTS_CODE_NO_CHARACTER -> TTS_NO_CHARACTER_MESSAGE
// 兜底:服务端回了一个本端还不认识的「配置缺失」类错误码。不猜它的意思,
// 但也不装作是临时故障——它毕竟带着一个明确的 code。
else -> TTS_UNCONFIGURED_MESSAGE
}
override val retryable: Boolean = false
}
}
/**
* 把一次朗读的异常分到 [TtsFailure] 两档里。
*
* 只有「服务端明确回了一个**配置缺失**类的 `code`」才进 [TtsFailure.Unconfigured];其余一切
* ——网络失败、解析失败、401(令牌过期,续期后还能用)、`voice_unavailable` / `tts_failed` /
* `tts_empty`(对面这会儿不行,不是本端的配置问题)——都进 [TtsFailure.Soft]。
*
* `tts_empty`(静默空音频)刻意归到柔和档:那是**这一次**合成出来的东西不能用,换个时间再试
* 完全可能成功,说成「配置没配好」就是错的。
*/
fun ttsFailureOf(error: Throwable): TtsFailure {
val cause = (error as? CyreneException)?.error ?: return TtsFailure.Soft
return when (cause) {
is CyreneError.Server -> when (cause.code) {
TTS_CODE_PERSONA_UNMAPPED,
TTS_CODE_NO_CHARACTER,
-> TtsFailure.Unconfigured(cause.code)
else -> TtsFailure.Soft
}
is CyreneError.Unauthorized -> TtsFailure.Soft
is CyreneError.Local -> TtsFailure.Soft
}
}
/**
* 朗读在某一条播放通道上的**键**:`tts:<messageId>`。
*
* 播放通道只有一条(`ui.chat.VoicePlayer` 的那个实例,与语音副本共用),所以键必须与语音副本
* (键 = `voice` 的 `messageId`)分得开:前缀就是那道分界线。同一条消息的朗读与它的语音副本
* 因此**互斥**(点其中一个会停掉另一个)——这是对的,同一时刻只该有一个声音。
*/
fun ttsClipIdOf(messageId: String): String = TTS_CLIP_PREFIX + messageId
/** 朗读入口那枚图标的读屏名字(也是设备验收时 `uiautomator` 的 `content-desc`)。 */
const val TTS_SPEAK_DESCRIPTION: String = "朗读这条消息"
/** 正在朗读时那枚图标的读屏名字(点它 = 停)。 */
const val TTS_STOP_DESCRIPTION: String = "停止朗读"
/** 正在取合成音频时那枚图标的读屏名字。 */
const val TTS_LOADING_DESCRIPTION: String = "正在准备朗读"
/** 柔和档:这会儿没读出来。**不说原因**(本端确实不知道断在哪一段),也不提重试。 */
const val TTS_SOFT_FAILURE_MESSAGE: String = "这会儿没能把这段读出来"
/** `tts_persona_unmapped`:人格有,但这个人格没有配发音人。 */
const val TTS_PERSONA_UNMAPPED_MESSAGE: String = "这个角色还没配好发音人"
/** `tts_no_character`:连生效人格都没有(会话没绑定、也没有全局默认人格)。 */
const val TTS_NO_CHARACTER_MESSAGE: String = "这个会话还没有配好发音人"
/** 服务端回了配置缺失类错误码、但本端还不认识那一个时的兜底文案。 */
const val TTS_UNCONFIGURED_MESSAGE: String = "这个角色还没有配好朗读的发音人"
/** 朗读播放通道的键前缀(与语音副本的键 `messageId` 区分开)。 */
const val TTS_CLIP_PREFIX: String = "tts:"
/** 服务端错误码:人格没有发音人映射。 */
const val TTS_CODE_PERSONA_UNMAPPED: String = "tts_persona_unmapped"
/** 服务端错误码:连生效人格都没有。 */
const val TTS_CODE_NO_CHARACTER: String = "tts_no_character"
@@ -14,15 +14,48 @@ import kotlin.math.min
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<br>`(`<br>` / `<br/>` / `</br>` 三种写法等价,大小写不敏感,属性不解析):**与 `<p>` 同级
* 的气泡/消息分隔符**(契约 v0.89 的更正:`回执三十一`「对消息内的 `<br>` 始终处理为多条消息
* 隔断」)。它**不是行内标签**:`<p>甲<br>乙</p>` ⇒ 两条气泡,`甲<br>乙`(无 `<p>`)⇒ 同样两条;
* 中间的空气泡照旧丢弃(`<p>甲<br><br>乙</p>` ⇒ `甲`、`乙`)。切分在 [tokenize] 的**段/块这一层**
* 完成([appendRegion]),所以行内解析层(`InlineMarkup`)几乎看不见它。
* ⚠️ `回执三十三 §三②` 把「**始终**分隔」收紧了:**只有普通文本层的 `<br>` 才分隔**——
* `<code>` / `<action>` / `<narration>` **内部**的不分隔([appendRegion] 的保护区,
* 口径与理由写在它和 [protectedSpans] 的 KDoc 里)。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* 注记内部**不做行内渲染**(既有口径),所以里面的 `<br>` 与 `<b>` 一样只是字面文本。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
* - 行内标签(`InlineMarkup.kt`,契约 v0.89):`<b>`/`<strong>`、`<i>`/`<em>`、`<code>`、`<u>`
* (**六项**标签名——`u` 由 `回执三十三 §一` 加回、`s` 仍不在内),成对才认、**标签名大小写不敏感**、
* **属性一律不解析**(`<b class=x>` 就是粗体)、白名单外按纯文本原样显示、
* **结构不平衡(未闭合 / 落单闭标签 / 交错)⇒ 该气泡的正文整段降级为原文**
* (口径与理由见 `parseInlineMarkup`)。
* 它在**段落与 `<br>` 都定稿之后**才做([toBubble] 里对最终正文做一次,**逐气泡各自一份**),
* 所以既不参与、也改不动上面的分段与归属。渲染文本落 `body` + `runs`,而片段 `parts` 仍是
* 逐字原文——编辑框、搜索片段、通知文案读的都是 `parts`,那些地方一个字都不许改。
* - 落单标签容错:服务端确实会产出不配对的段落标签(实测形态 `<p><p>正文</p>`:多一个 `<p>`、
* 只有一个 `</p>`),块外也可能出现孤立的旁白标签。这类标签一律**剥掉**,绝不落进正文——
* 它们本就是服务端产出的标记、不是她说的话;渲染成正文等于往气泡里塞一串用户既看不懂、
* 也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字都不动。
* 这只是客户端的容错,不代表服务端这样发是对的。
* 判据([stripLooseTags])分两种情形:消息里**已有完整块**时,段落结构已被完整块定死,块内
* 块外多出来的每个 `<p>` / `</p>`、以及块外孤立的旁白标签,都是冗余噪声,一律剥掉(块内的
* 落单旁白标签例外,见最后一条);消息里**一个完整块都没有**时,按上一条整段回落字面文本,
* 只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。
* 只认「确定是标签」的形状:`<p>` / `<p 属性>` / `</p>` / `<action>` 等。`1 < 2`、`<3`、
* `a<b`、`<p2>`、`<P>`(词表只有小写,与 `MessageEditText` 同一口径)、`<p`(残缺,没有 `>`)
* 都原样保留——见到 `<` 就吃,才是真正会丢字的那种错。
* - 未闭合/畸形的其余标签一律回落为普通文本,绝不抛异常。块内没配对的 `<action>` / `</action>`
* 也保持字面文本,这是既有契约(`unclosedActionFallsBackToText` / `strayClosingTagStaysText`)。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `body` :气泡正文的**渲染文本**(仅 Text 部分按原顺序拼接后 trim,再消费掉行内标签:
* 白名单标签字符已去掉,白名单外与属性照旧逐字留着),已剔除全部旁白/动作块;
* 整段只有标记没有字、或行内标签**结构不平衡**时回落/降级为字面原文;
* - `runs` :`body` 的行内样式切分(非空时拼起来就是 `body`;空列表 = 按字面画);
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
@@ -34,9 +67,28 @@ import kotlin.math.min
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 行内开标签同样入栈扣留(词表见 [INLINE_TAG_NAMES],判据见 [parseInlineTag]):不扣的话
* 中间态会把 `<b>` 当字面吐上屏(`日落 <b>18`)、等 `</b>` 到了又被吃掉,屏幕上已经显示过的
* 两个字就改口了。**大小写与属性也走同一份判据**(v0.89):`<B class=x>` 在扣留侧同样是
* 开标签,否则 `日落 <B class=x>18` 会先按字面吐上屏、收口时再变成粗体。
* `<br>` 是分隔符、不成对,**不入栈**(入了就永远弹不出来);它只在残缺(`<br`、`<br/`、
* `</br`)时按词表扣留([isPotentialTagPrefix] → [couldBeTagName]),完整的 `<br>` 直接放行
* ——分隔符一出现,前缀就已经可解析了。
* **保护区内的 `<br` 另外还有一道**:`<code>` / `<action>` / `<narration>` 的开标签本来就在
* 栈里,所以整个作用域(连同里面还没写完的 `<br`)一起被扣到闭合为止,残缺的 `<br` 不会
* 在半截作用域里先露一次脸(由 `streamingWithholdsPartialLineBreakInsideProtectedScopes` 钉着)。
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`、`<B`、`<br`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 落单标签的剥离**不需要**额外的扣留规则,这是「剥标签与扣留共用同一组标签正则」的直接结果:
* 扣留点取「第一个没闭合的开标签」,而落在前缀里的每个 `<p>` / `<action>` 开标签必定在
* **同一个前缀内**配到闭合标签(否则它就是那个没闭合的,扣留点会落在它前面)。所以
* 「这个标签是不是落单的」在它被产出的那一刻就已经定死,中间态剥掉、收口时也不用再改口。
* - 反过来放宽扣留是不行的:把重复的 `<p><p>` 当「外层冗余」让前缀继续推进,`<p>甲<p>乙`
* 就会在中间态把 `<p>甲` 当字面文本吐上屏,等 `</p>` 到了又变成 `甲乙`——字会跳。
* 剥标签与扣留的标签词表因此必须逐字一致(共用 OPEN_P_TAG_RE / CLOSE_P_TAG_RE /
* OPEN_ROLE_TAG_RE / CLOSE_ROLE_TAG_RE,无大小写、无词表扩展),两边一旦分叉,
* 就会出现「中间态当正文、收口时又剥掉」的跳动。
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
*/
@@ -61,6 +113,16 @@ data class ParsedBubble(
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
/**
* `body` 的行内样式切分(`<b>` / `<i>` / `<code>` / `<u>`,见 [parseInlineMarkup]):非空时
* 拼起来**就是** [body]。空列表 = 这段正文按字面画——**绝大多数消息都走这一档**(没有标签、
* 或者结构不平衡被整段降级、或者整段只有标记没有字)。
*
* 有默认值,是因为只有 [toBubble] 才该填它:其余构造点要么本来就该按字面画(`plainBubble`、
* 分享预览的兜底 `sharePreviewBlocks`),要么是测试里的手搓气泡。这也让「加一个字段」不必
* 惊动任何一个既有调用方。
*/
val runs: List<InlineRun> = emptyList(),
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
@@ -91,12 +153,43 @@ private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
private const val FULL_STAR_MAX_LEN = 40
private val TAG_NAMES = listOf("action", "narration", "p")
/**
* 段落/旁白标签名(**只有小写**,与 `MessageEditText` 同一口径):流式边界判定 [isPotentialTagPrefix]
* 补全残缺标签时用;[stripLooseTags] 判「是不是标签」用的是下面那组同词表的正则,两边必须同源。
*
* `<br>` 不在这个名单里:它是分隔符、且**大小写不敏感**(见 [BR_TAG_RE]),判据单独走
* [couldBeLineBreakNamePrefix],否则 `<BR` 会被漏扣、中间态把 `<BR` 当正文吐上屏。
*/
private val STRUCTURE_TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** `<br>` 分隔符的标签名([BR_TAG_RE] 与流式扣留侧共用,别在两处各写一遍)。 */
private const val LINE_BREAK_TAG_NAME = "br"
/**
* `<br>` **分隔符**(契约 v0.89 更正:`<br>` 不是行内标签,而是与 `<p>` 同级的「气泡/消息分隔符」)。
*
* 认下来的形状:`<br>` / `<br/>` / `<br />` / `</br>` / `<br class=x>` …——
* - **三种写法等价**,前导 `/` 与自闭合 `/` 都照收(HTML5 口径,契约写死);
* - **大小写不敏感**(`<BR>` / `<Br>` 同样算):v0.89 那句「标签名大小写不敏感」把 `br` 一起罩住了,
* 且后端 IM 出站 `channelbridge/sanitize.go` 的正则本来就是 `(?is)`(`回执三十` 的原话);
* - **属性不解析**(`<br class=x>` 照样分隔),但名字后面必须紧跟 `>`、空白或 `/`:
* `<br2>` / `<boolean>` / `<br-x>` 因此**不是**分隔符,只是普通文本(这一条是「字符一个不丢」的闸门)。
*
* ⚠️ 已知边界:它**不看外层标签的属性结构**——所以写成 `<b title="<br>">甲</b>` 这种「属性里带 `<br>`」的
* 文本会被切开(`回执三十三 §三③` 登记为两端口径相同、不修)。`<p …>` 开标签是唯一例外,因为它在更早
* 一层就被 [P_BLOCK_RE] 整体吃掉了,属性里的 `<br>` 根本走不到这里。真实产出里没有这种形状
* (属性本身就不是产出侧的东西)。
*
* ⚠️ 它只认**普通文本层**的那些:`<code>` / `<action>` / `<narration>` **内部**的 `<br>` 由
* [appendRegion] 按保护区跳过(`回执三十三 §三②`)——所以 `findAll` 的结果**不再是**「所有分隔点」,
* 用它时要么走 [appendRegion](会跳保护区),要么自己判一遍(别处没第二个消费者)。
*/
private val BR_TAG_RE = Regex("<\\s*/?\\s*${LINE_BREAK_TAG_NAME}(\\s[^<>]*|/\\s*)?>", RegexOption.IGNORE_CASE)
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
@@ -144,29 +237,339 @@ private sealed interface Tok {
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
/**
* 先切 `<p>` 块(决定气泡边界),再按区剥落单标签与切 `<br>` 分隔符,最后在每个文本区做旁白/星号解析。
*/
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
// 完整块必然以 `</p>` 收尾,所以最后一个 `</p>` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话,
// 每个 `<p` 候选点都会带着懒惰的 `([\s\S]*?)</p>` 一路回溯到串尾——正文里 N 个落单标签时
// 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见
// [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在
// 被切下的前缀里,正则的尝试顺序也没变)。
val lastClose = raw.lastIndexOf("</p>")
val pMatches =
if (lastClose < 0) {
emptyList()
} else {
P_BLOCK_RE.findAll(raw.substring(0, lastClose + "</p>".length)).toList()
}
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
appendAnnotations(raw, out, splitBlankLines = true)
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
// 落单时同理(`MessageEditTextTest.没闭合的 action 同样原样留着`)。此处只剥掉连「被截断的
// 结构」都算不上的东西:落单的 `</p>`,与后面什么都没有的空开标签。判据见 [stripLooseTags]。
appendRegion(raw, out, markupMode = false, stripStrayRoleTags = false, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
if (lead.isNotEmpty()) {
appendRegion(lead, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
}
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
if (inner.isNotEmpty()) {
// 块内只剥 `<p>` 族:`<action>` / `</action>` 落单时保留字面文本,是既有契约
// (unclosedActionFallsBackToText / strayClosingTagStaysText 钉住的行为)。
appendRegion(inner, out, markupMode = true, stripStrayRoleTags = false, splitBlankLines = false)
}
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
if (trail.isNotEmpty()) {
appendRegion(trail, out, markupMode = true, stripStrayRoleTags = true, splitBlankLines = false)
}
return out
}
/**
* 解析一个文本区:先剥落单标签([stripLooseTags],`markupMode` / `stripStrayRoleTags` 由调用方给),
* 再按 `<br>` 分隔符切成若干段,每段各自做旁白/星号解析([appendAnnotations]),段与段之间补一个
* [Tok.Break](气泡边界)。
*
* **为什么 `<br>` 在这一层切**:契约 v0.89 的更正把 `<br>` 定成**与 `<p>` 同级的分隔符**
* (`回执三十一`「对消息内的 `<br>` 始终处理为多条消息隔断」)。它既然与 `<p>` 同级,就该与 `<p>`
* 走**同一套机制、同一个粒度**:产生 [Tok.Break]、由 `buildBubbles` 分组、空气泡照旧丢弃。
* 行内层(`InlineMarkup`)因此几乎看不见 `<br>` —— 那里只剩六个白名单标签(唯一例外见下面的保护区:
* `<code>` 内部的那个 `<br>` 会随正文交到行内层,在那里按「内容」字面显示)。
*
* **顺序:先切分、再逐段各自做行内解析**(`<b>甲<br>乙</b>` ⇒ 两段各按「不平衡 ⇒ 不渲染」处理,
* 两段都回落原文)。理由:
* 1. 「与 `<p>` 同级」这句话本身就规定了层序——分隔符先于行内解析,行内解析永远只看到**一段**;
* 跨过分隔符的标签在结构上不可能成立(一个气泡 = 一条消息,IM 出站也是先按气泡切开再剥标签,
* 后端 `sanitize.go` 的「成对平衡 ⇒ 剥标签留文本 / 不平衡 ⇒ 原样保留」同样是在**单条消息内**
* 判平衡的)⇒ 两端对同一段文本的结论天然一致;
* 2. 反过来(先整体行内解析、再按 `<br>` 切)会让「渲染文本」与「气泡」互相依赖:`<b>甲<br>乙</b>`
* 会先变成一个粗体片段再被切开,切点落在片段内部,`body` / `runs` 的一致性(`runs` 拼起来
* **就是** `body`)当场就没了;
* 3. 实现上也最省:行内那层一个字节都不用改(它本来就逐气泡跑一次)。
*
* 剥标签**先于**切分(判据逐字不变):[stripLooseTags] 里「这个落单 `<p>` 后面还有没有正文」看的是
* 整段原文,`<br>` 在那里只是一个普通字符,所以同一输入下它的判定与 W68 完全相同。
*
* ## 保护区:`<code>` 与角色注记**内部**的 `<br>` 不分隔(`回执三十三 §三②`)
* `回执三十一` 说的是「**始终**分隔」,`回执三十三` 把它收紧了:**只有普通文本层的 `<br>` 才分隔**。
* 后端给的两条理由在我们这边一字不差地同样成立,所以照改:
* 1. **会漏注记正文**:`<action>她<br>点点头</action>` 若切成两段,就成了「她」「点点头」**两条消息**
* ——注记本来是不该当正文露出来的那一层,切开等于把它摆到台面上(后端原话:「那等于把注记正文
* 发给了对方」);
* 2. **`<code>` 会被切坏**:它是**成对**元素,从中间切开必然两半都不平衡,按「不平衡 ⇒ 不渲染」的
* 硬口径**整段降级为字面量**,用户直接看到裸标签。
*
* 实现:先扫出保护区区间([protectedSpans]),切分时**跳过**落在区间里的 `<br>`(它留在原文里,随该
* 作用域自己的语义走)。扫的是**已经剥过落单标签**的那一份文本,与切分用的是同一个对象。
*
* ### 作用域内部算什么语义(我们定的口径):**普通字符、逐字原样**(不换行、也不消掉)
* - `<code>`:这是它**既有的**口径——「里面连白名单标签也按内容处理」,`<code><b>甲</b></code>`
* 显示字面的 `<b>甲</b>`(`codeContentIsNotParsed` 钉着)。`<br>` 在这一层本来不是标签,用不着
* 另立规矩;`<code>` 也正好是「想原样显示尖括号怎么办」的现成答案。
* - `<action>` / `<narration>`:**必须是字面**。注记那一行画的是 `parts` 里的原文
* (`MessageBubble.AnnotationLine` 直接 `parts.joinToString`),而 `parts` = **逐字原文**是红线
* ——换成 `\n` 或删掉,就是在静默改用户内容(编辑框、搜索、通知读的都是它)。顺带也就与既有
* 口径对齐:`<action>笑<b>了</b></action>` 里的 `<b>` 同样是字面(`narrationTextIsLeftExactlyAsWritten`)。
* - 两个作用域取**同一条**规矩:它们是同一件事(`<br>` 落在作用域内部),各给一种读法才是真的
* 「各端各说各话」。
* - 为什么不把它当换行(`<code>甲<br>乙</code>` ⇒ `甲\n乙`):那必须在一处破例——要么在这里改掉
* 片段文本(`parts` 当场不再是原文),要么在行内层给 `<br>` 单开一条「代码里它算换行」的规则
* (W68 刚把「`<br>` → `\n`」从行内层整体删掉,而且那会让**同两个字符**在 `<code>` 与 `<action>`
* 里各是一种意思)。两条都不值当,所以不做。
*
* ### 未闭合的作用域:**不算作用域**,里面的 `<br>` 照旧分隔
* 判据只看「**成对**」,且与各自的消费侧同一把尺子:`<code>` 要找到它的 `</code>`(大小写不敏感、
* 可带属性——行内层就这么认),`<action>` / `<narration>` 要找到**同名**闭合(只认小写、不带属性
* ——`ROLE_RE` 就这么认)。找不到闭合的开标签不是作用域,于是 `<code>甲<br>乙` 照旧切成两条。
* - 理由一:**消费侧本来就不认它**。未闭合的 `<code>` 在行内层不是作用域(整段降级为原文),
* 未闭合的 `<action>` 在 `ROLE_RE` 那里也配不出旁白。去保护一个**消费侧根本不认**的作用域,
* 等于让「切分层」与「消费层」对同一段文本有两种结构判断——正是本项目一路在防的那种分叉
* (W68/W69 的教训:消费侧与扣留侧的词表必须同源)。
* - 理由二:**爆炸半径**。若把「未闭合 ⇒ 保护到这一段文本结尾」也算上,一个漏掉 `</code>` 的笔误
* 就会把**后面所有** `<br>` 一起吞掉、把本该分开的多条消息并成一条。「一个畸形标签改变整段的
* 分段」比「照旧分隔」坏得多。
* - 两种读法都不渲染、都不丢字(未闭合那一档本来就是「不平衡 ⇒ 原样显示」),差别只在气泡怎么切;
* 我们选与消费侧一致的那种。
* - **两支各算各的**([protectedSpans] 用两个独立状态):`<code>` 与角色注记各自配对,因为那本来
* 就是两个消费侧各自的尺子——行内层在 `<code>` 里只认自己的闭合、别的一律当内容;`ROLE_RE`
* 则完全不看 `<code>`(它在代码里照样配对)。所以「某一支没闭合」只影响**那一支**:
* `<code>甲<action>她<br>点点头</action>乙` 里那个**已经配对好**的 `<action>` 照样保护它的
* `<br>`(不产生区间的只是没闭合的 `<code>` 那一支)。
*/
private fun appendRegion(
text: String,
out: MutableList<Tok>,
markupMode: Boolean,
stripStrayRoleTags: Boolean,
splitBlankLines: Boolean,
) {
val stripped = stripLooseTags(text, markupMode, stripStrayRoleTags)
// 保护区只在「这一区里真有作用域」时才扫:绝大多数消息连 `<` 都没有(一个字节都不多花)。
val protected = if (stripped.indexOf('<') < 0) emptyList() else protectedSpans(stripped)
var last = 0
var span = 0
for (m in BR_TAG_RE.findAll(stripped)) {
val at = m.range.first
// 区间按起点升序,跟着 `<br>` 的推进往前走一个游标就够(不回头、不重扫)。
while (span < protected.size && protected[span].last < at) span++
if (span < protected.size && at in protected[span]) continue
val piece = stripped.substring(last, at)
if (piece.isNotEmpty()) appendAnnotations(piece, out, splitBlankLines)
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = stripped.substring(last)
if (tail.isNotEmpty()) appendAnnotations(tail, out, splitBlankLines)
}
/**
* 扫出这一区里**已配对**的保护区区间(左闭右开,按**起点**升序):`<code>…</code>` 与
* `<action>…</action>` / `<narration>…</narration>`——[appendRegion] 据此跳过落在里面的 `<br>`
* (口径与理由写在它的 KDoc 里)。
*
* 一趟线性扫描,判据全部借现成的尺子:「哪儿是一个标签」用行内层的 [inlineTagEnd];`code` 用
* 行内层的 [openInlineTagName] / [closeInlineTagName](大小写不敏感、可带属性);角色注记用
* [openNoteTagName] / [closeNoteTagName](与 `ROLE_RE` 同口径:只认小写、不带属性、找**同名**闭合)。
*
* **两支各自独立配对**,因为那本来就是两个消费侧各自的尺子:`<code>` 那一支只认自己的开/闭
* (里面别的标签都是内容,与行内层一致);角色那一支同理(与 `ROLE_RE` 的非贪婪配对一致——它
* 在 `<code>` 里照样配对,两边各算各的)。任一支没闭合 ⇒ **那一支**不产生区间。
*
* `indexOf('<')` + 逐段推进的扫描区间互不重叠(见 [inlineTagEnd]),每个 `<br>` 的判定是两个游标
* 各走一次,因此整体仍是 O(n):成串的尖括号或成串的作用域都不会让它退回 O(N²)。
*/
private fun protectedSpans(text: String): List<IntRange> {
val codeSpans = ArrayList<IntRange>(2)
val noteSpans = ArrayList<IntRange>(2)
var openCode = -1
var openNoteName: String? = null
var openNote = -1
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是标签:往后挪**一个字符**(与 `claimTags` 同一把尺子,
// 不能跳到某个「下一个 `>`」——那会把中间真正的标签一起跳过)。
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
val end = gt + 1
if (openCode < 0) {
if (openInlineTagName(tag) == CODE) openCode = lt
} else if (closeInlineTagName(tag) == CODE) {
codeSpans.add(openCode until end)
openCode = -1
}
val noteOpen = if (openNoteName == null) openNoteTagName(tag) else null
if (noteOpen != null) {
openNoteName = noteOpen
openNote = lt
} else if (openNoteName != null && closeNoteTagName(tag) == openNoteName) {
noteSpans.add(openNote until end)
openNoteName = null
}
i = end
}
// 两支各自升序,合起来也按**起点**升序——[appendRegion] 的包含判定(「第一个右端 ≥ 这个 `<br>`
// 的区间」)以「按起点升序」为前提。合并后两支的区间可能相互嵌套(`<code>` 套注记、注记套
// `<code>`),那不影响判定:嵌套的那些都在同一个升序序列里。
return when {
noteSpans.isEmpty() -> codeSpans
codeSpans.isEmpty() -> noteSpans
else -> (codeSpans + noteSpans).sortedBy { it.first }
}
}
/** 角色注记的**开**标签名(只认小写的 `action` / `narration`、不带属性——与 `ROLE_RE` 同口径)。 */
private fun openNoteTagName(tag: String): String? = OPEN_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
/** 角色注记的**闭**标签名(同上;配对要求**同名**,与 `ROLE_RE` 的 `</\s*\1\s*>` 一致)。 */
private fun closeNoteTagName(tag: String): String? = CLOSE_ROLE_TAG_RE.find(tag)?.groupValues?.get(1)
/**
* 剥掉「落单标签」,其余字符逐字保留(返回原对象:没剥到东西时不做新分配)。
*
* [markupMode] = 这条消息里**已有完整 `<p>…</p>` 块**,段落结构已被完整块定死,于是块内/块外
* 多出来的每一个 `<p>` / `</p>` 都是冗余噪声,一律剥掉。
* 没有完整块时(`parse` 会整段回落字面文本,见 `unclosedParagraphStaysLiteralText`;编辑框也靠
* 这条保证不静默改字,见 `MessageEditTextTest.没闭合的 p 不抛异常`)只再剥两类不可能是正文的东西:
* 落单的 `</p>`,以及后面什么都没有的**空**开标签——它们连「被截断的段落」都算不上。
*
* [stripStrayRoleTags] 只对**块外**文本区为 true:那里的 `<action>` / `</action>` 若没被 [ROLE_RE]
* 认领(即没配成对),就是落单标签,剥掉。已配对的必须留给 [ROLE_RE] 解析成 Action/Narration,
* 提前剥掉等于把旁白降级为正文(`textOutsideParagraphsIsPreserved` 钉住的行为)。
*/
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
if (text.indexOf('<') < 0) return text
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
// 「这个落单 `<p>` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见
// [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `<p>` 开标签)一次都不建表。
val contentAfter = ContentAfterQuery(text)
val out = StringBuilder(text.length)
var last = 0
var i = 0
while (i < text.length) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = text.indexOf('>', lt)
if (gt < 0) break
val tag = text.substring(lt, gt + 1)
val drop = when {
OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1)
CLOSE_P_TAG_RE.matches(tag) -> true
!stripStrayRoleTags -> false
roleRanges.any { lt in it } -> false
else -> OPEN_ROLE_TAG_RE.matches(tag) || CLOSE_ROLE_TAG_RE.matches(tag)
}
if (drop) {
out.append(text, last, lt)
last = gt + 1
i = last
} else {
i = lt + 1
}
}
if (last == 0) return text
out.append(text, last, text.length)
return out.toString()
}
/**
* [stripLooseTags] 判落单 `<p>` 用的「[from] 之后还有没有正文」查询:空白跳过,`<p>` / `</p>`
* 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `<p>` 族标签后仍非空
* 即为有。判据与原实现逐字一致,只多了记忆化。
*
* **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个**
* 落单 `<p>` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `<p>` 的输入实测这条判据
* 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的
* `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
*
* **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的
* 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次,
* N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。
*
* **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套
* 「什么算 `<p>` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条
* 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是
* 标签里的字符、判有正文,[next] 在这里同样返回 null)。
*
* 惰性:没遇到落单 `<p>` 开标签的常规消息一次也不建表,一个字节都不多花。
*/
private class ContentAfterQuery(private val text: String) {
/** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `<p>` 族标签、[HAS_CONTENT] = 有正文。 */
private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) {
ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY }
}
/** 本次走链途经的位置,用来把 [answers] 一次落表。 */
private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) }
/** 与原先「每次现扫到串尾」的判定逐字等价。 */
fun hasContentAfter(from: Int): Boolean {
var walked = 0
var i = from
while (true) {
val known = answers[i]
if (known != UNKNOWN) return finish(walked, known)
path[walked++] = i
val step = next(i)
if (step == null) return finish(walked, HAS_CONTENT)
i = step
}
}
/** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */
private fun finish(walked: Int, answer: Byte): Boolean {
for (k in 0 until walked) answers[path[k]] = answer
return answer == HAS_CONTENT
}
/**
* 原扫描的一步:空白 → 下一位;`<p>` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`,
* 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。
*/
private fun next(i: Int): Int? {
val c = text[i]
if (c.isWhitespace()) return i + 1
if (c == '<') {
val gt = text.indexOf('>', i)
if (gt >= 0) {
val tag = text.substring(i, gt + 1)
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1
}
}
return null
}
private companion object {
const val UNKNOWN: Byte = 0
const val NOISE_ONLY: Byte = 1
const val HAS_CONTENT: Byte = 2
}
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
var last = 0
@@ -307,9 +710,21 @@ private fun toBubble(tokens: List<Tok>): ParsedBubble? {
tail.add(part)
}
}
val bodyText = body.toString().trim()
// 行内标签在**段落与 `<br>` 都定稿之后**才消费(就在这一行,且是**逐气泡各自一份**):上面那套
// 分段与归属一个字都不动,分段判据因此完全看不见行内标签(`<br>` 已经在 [appendRegion] 那层
// 变成气泡边界了)。runs 为空 = 这段正文按字面画(没有可渲染的行内标签、或行内标签结构不平衡
// 被整段降级),一个字都不会变。
val rawBody = body.toString().trim()
val runs = parseInlineMarkup(rawBody)
val bodyText = if (runs.isEmpty()) rawBody else runs.joinToString("") { it.text }
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
return ParsedBubble(
body = bodyText,
parts = parts.toList(),
head = head.toList(),
tail = tail.toList(),
runs = runs,
)
}
// ---------------------------------------------------------------------------
@@ -354,28 +769,62 @@ private fun stablePrefixEnd(buf: String): Int {
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
if (OPEN_P_TAG_RE.matches(tag)) return "p"
// 行内开标签也入栈(`<B class=x>` 这类形态由 [parseInlineTag] 判,与解析侧同源)。
// `<br>` 是自闭合的,**不入栈**(入了就永远弹不出来,扣留点会永久停在它前面,后面的话再也
// 显示不出来)——[openInlineTagName] 对 `<br>` / `<br/>` / `</br>` 一律返回 null。
return openInlineTagName(tag)
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
if (CLOSE_P_TAG_RE.matches(tag)) return "p"
return closeInlineTagName(tag)
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
/**
* 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。
*
* 词表分两半,每一半都与**自己的消费侧**同源,两条口径都在这里逐字对齐:
* - 段落/旁白只认小写(`<P>` / `<Action>` 原样显示,既有契约)——名字里出现大写就与它无关;
* - 行内标签与 `<br>` 分隔符**大小写不敏感**(契约 v0.89,[parseInlineTag] / [BR_TAG_RE])
* ——`<B` 与 `<b`、`<BR` 与 `<br` 一样要扣留。
* 少扣了就是「中间态把标签当正文吐上屏、收口时才吃掉」,屏幕上显示过的字会改口。
* 名字之后是空白或 `/` 都算「还可能补全」(属性 / 自闭合,v0.89 允许带属性,所以 `<b class=x>`
* 与 `<br/` 这两个中间态也必须扣住);其它字符说明它压根不是标签(`<boolean>`、`<b2`)。
*/
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
while (j < buf.length && isTagNameLetter(buf[j])) j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return j >= buf.length
if (!couldBeTagName(name)) return false
if (j >= buf.length) return true
return buf[j].isWhitespace() || buf[j] == '/'
}
/**
* [name] 是否可能是某个已知标签名的前缀;三半各按自己的大小写口径(见 [isPotentialTagPrefix]):
* 段落/旁白小写、`<br>` 与行内标签大小写不敏感。
*/
private fun couldBeTagName(name: String): Boolean {
if (name.all { it in 'a'..'z' } && STRUCTURE_TAG_NAMES.any { it.startsWith(name) }) return true
val lower = name.lowercase()
if (couldBeLineBreakNamePrefix(lower)) return true
return couldBeInlineTagNamePrefix(lower)
}
/**
* [name](已小写)是否可能是 `<br>` 分隔符的标签名前缀——`<br` / `</br` / `<BR` 这种残缺形态要不要
* 扣留由它说了算。判据与 [BR_TAG_RE] 同口径(大小写不敏感的 `br`):两边一分叉,
* `甲<br` 会先把字面 `<br` 吐上屏,等 `>` 到了再变成两条气泡。
*/
private fun couldBeLineBreakNamePrefix(name: String): Boolean =
name.isNotEmpty() && LINE_BREAK_TAG_NAME.startsWith(name)
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
@@ -0,0 +1,461 @@
package top.yeij.cyrene.domain.parse
/*
* InlineMarkup —— 气泡正文里「一小撮行内标签」的解析(纯 Kotlin,无 Android / Compose 依赖)。
*
* 口径 = 后端 2026-10-07 定下的**契约 v0.89** 及其同日更正(跨端必须逐字一致,否则同一个 `<b>`
* 在她那台设备上粗、在这台上不粗)。四条硬口径:
* 1. 白名单 = `b` / `strong` · `i` / `em` · `code` · `u`(**成对**,六项标签名);标签名**大小写不敏感**;
* 2. **属性一律不解析**:`<b class=x onclick=y>` 只取标签名,属性整段丢掉、绝不落成任何视图属性;
* 3. **白名单外一律按纯文本原样显示**:`<script>` 显示成字面量,字符一个不丢;
* 4. **结构不平衡 ⇒ 整段降级为原文**(未闭合 / 落单闭标签 / 交错)——粗粒度是有意的,换来
* 「字符一个不丢」可以被逐字断言。
* 样式映射:`b`/`strong` → 粗体,`i`/`em` → 斜体,`code` → 等宽 + 轻微底色(且**里面的标签不解析**),
* `u` → 下划线。`s` **不在白名单里**:它按第 3 条逐字原样显示(见下)。
*
* ## `br` 不在这里(契约同日更正)
* `回执三十一`:「对消息内的 `<br>` **始终处理为多条消息隔断**」⇒ `<br>` **不是行内标签**,而是与
* `<p>` 同级的**气泡/消息分隔符**,切分在 [tokenize](ChatContentParser.kt) 的段/块那一层
* (`ChatContentParser.appendRegion`)完成,`<br>` / `<br/>` / `</br>` 三种写法等价。
* 这一层因此**只认成对标签**:`<br>` 到了这里只是几个普通字符,与 `<br2>`、`<boolean>` 同类。
* 它**真的会**走到这里,只是只有一条路:`<code>` 作用域内部——分隔符切分层有一个**保护区**
* (`ChatContentParser.protectedSpans`,`回执三十三 §三②`),落在 `<code>` / `<action>` / `<narration>`
* 内部的 `<br>` 不分隔,于是 `<code>` 里的那个 `<br>` 会随正文一起交到这一层,在代码作用域里按
* 「内容」处理(字面显示,与 `<code><b>甲</b></code>` 里的 `<b>` 同一规矩)。
*
* ## 白名单六项(`u` 加回、`s` 不做)
* `回执三十` 先把白名单收窄到五项、撤掉 `u`/`s`(理由是「多支持 = 制造跨端不一致」),但
* `回执三十三 §一` 更正:**那份「收窄到五项」是后端的疏漏**——主人随后裁定下划线保留、后端把 `u`
* 加回了契约(`1107b2a`)与 IM 出站(`74c9f2d`),只是**忘了发回执登记**。最终白名单 = **六项**:
* `b` / `strong` / `i` / `em` / `code` / **`u`**,`u` 在这里渲染成下划线。
* **`s` 仍不在内**(逐字原样)——「多支持反而制造跨端不一致」这条理由对它依然成立:Web 不渲染它。
*
* ## 为什么是「整段降级」而不是「只降级那一处」
* W67 那版是**局部**降级:`甲<b>粗</b>乙<b>没闭合` 里 `粗` 照旧加粗,只有后面那半个 `<b>` 留着字面。
* 契约把「结构不平衡」定成**整段不渲染**:一处不平衡,这一整段就一个标签都不认、原文逐字显示。
* 代价是「好好的一对 `<b>` 会被邻居的落单标签连累」,换来的是**可以逐字断言**的粗粒度:
* 「渲染文本 == 原文」或者「渲染文本 == 原文消费掉全部配对标签」,只有这两种形状,没有中间态
* (局部降级那种「一半认、一半留」在跨端对齐时最容易各说各话)。见 [parseInlineMarkup]。
*
* ## 「整段」是哪一段(我们采用的口径 + 理由)
* **整段 = 该气泡的正文**,也就是 [toBubble](ChatContentParser.kt) 里 `body.toString().trim()`
* 出来的那一段(`ParsedBubble.body` 对应的文本,通常正好是一个 `<p>…</p>` 块的正文,也可能是
* 块外文本并入相邻气泡后的那一份)。不是整条消息,也不是单个 `<p>` 块:
* - **不是整条消息**:一条消息可以有多个分隔(`<p>` / `<br>`)= 多条气泡,一个气泡里写坏了一个
* 标签,不该把**别的**气泡一起按字面画出来——那既不是契约想说的「这一段」,也会让「她写坏了
* 一个标签」这种局部事故看起来像整条消息都坏了。
* - **不是单个 `<p>` 块**(虽然大多数时候两者重合):本端渲染的**最小单位就是气泡**,
* `body` / `runs` 也都是按气泡算的。降级粒度与渲染粒度取同一个,实现上才有一条清晰的边界
* (`toBubble` 一次调用 = 一次判定),断言也才写得出「这条气泡是原文、那条气泡照常渲染」。
* - 后端已认可这一点(`回执三十`:「契约只承诺『不平衡 ⇒ 不渲染、字符一个不丢』,**具体粒度算
* 实现细节**」)。
* - 因为切分(`<p>` / `<br>`)**先于**行内解析,跨分隔符的标签天然分属两段:
* `<b>甲<br>乙</b>` ⇒ 两段各不平衡 ⇒ 两段都回落原文。
*
* ## 为什么不改 `parts`,只在 `ParsedBubble` 上加一个字段
* `ContentPart.Text` 的 `text` 必须保持**逐字原文**:编辑框(`MessageEditText.visibleTextOf`)、
* 搜索片段(`SessionSearchModel.plainSearchText`)、通知文案(`ProactiveNotify`)读的都是 `parts`,
* 在那里把标签改掉就是**静默改用户内容**。所以行内解析不改片段,只在 `toBubble` 里对**已经定稿的
* 正文**算一次,结果落在 `ParsedBubble.runs`;`body` 变成正文的**渲染文本**(白名单标签已消费),
* 而 `parts` 仍是原文——两侧各拿自己该拿的那一份。
*
* ## 与段落解析的分工(为什么不在 `tokenize` 里做)
* 行内解析发生在**段落/分隔符边界已经定稿之后**(`toBubble` 里,对最终 `body` 做一次):`<p>` 与
* `<br>` 分气泡、空行回落、旁白归属、落单标签剥离全部照旧,行内标签既不参与、也改不动它们。
* 这也让 W63 在 `tokenize` 里去掉的两处 O(N²) 一行都不受影响:这里是对最终正文的一趟线性扫描。
*
* ## 大小写不敏感(v0.89)与「扣留侧必须一起改」
* `<B>` / `<STRONG>` 都认——但**消费侧与流式扣留侧([ChatContentParser.stablePrefixEnd])
* 必须同源**,否则两边分叉:`<B` 这种残缺形态若只在消费侧认,中间态就会把它当正文吐上屏、等 `</B>`
* 到了再吃掉,**屏幕上已经显示过的字就改口了**。所以标签名的字母判据([isTagNameLetter])与
* 「这个名字还可能补全吗」([couldBeInlineTagNamePrefix],以及 `ChatContentParser.isPotentialTagPrefix`
* 里的 `couldBeTagName`)都收在**这一份文件**里,两边引用同一份名单。
* 段落/旁白标签(`<p>` / `<action>` / `<narration>`)仍然**只认小写**:这是既有契约
* (`plainAngleBracketsAreNeverEaten` 钉着 `<P>` 原样、`MessageEditText` 同一口径),
* 且契约明确「`<action>`/`<narration>` 的既有行为逐字未改」。
*
* ## 明确不做(逐条理由)
* 1. **块级(`<p>` 之外的 `<div>` / `<h1>` / 列表…)不做**:契约写死「段落仍走 `<p>`」。
* 2. **`<a href>` 不做**:它不是一个样式,是一个**点击行为**(要唤浏览器),必须配一套安全策略
* (只认 http/https、不许 `intent://` / `javascript:` 之类)。契约把它划成独立安全面,实测 0 命中。
* 3. **Markdown 的 `*` / `_` 一律不做**:星号这个字形在本项目**已经被占走了**——`ROLE_RE` 把全角
* `*…*` 与半角 `*(动作|旁白|内心|神态|轻声)…*` 判成旁白/动作(`*笑*` 是动作,不是斜体),
* 既有测试钉着这条语义。再给同两个星号一个「斜体」的说法,同一条消息就有两种读法,还要先猜
* 哪一种是她的本意——那是改既有语义,不是加渲染。
* 4. **`#` 标题 / `- ` 列表 / 表格 / ``` 围栏 / `[链接](url)` / `~~删除~~` 一律不做**:真后端最近
* 100 条实测 0 命中;而且这些是**行级**规则,会把「正文里真有一行以 `- ` 开头」这种普通文本
* 改写成别的东西——0 命中 + 有改写风险 = 不做。
* 5. **HTML 实体(`&lt;` `&amp;` `&nbsp;` …)不做**:实测 0 命中;实体是一张**开放的清单**
* (`&#39;` / `&#x27;` / 各种命名实体……),做一半比不做更糟——会出现「`&amp;` 解了、`&#x27;`
* 却原样显示」这种谁也说不清的口径。想在正文里显示尖括号,今天有 `<code>` 这一档。
* 6. **`<script>` / `<style>` 之类只会「原样显示」,绝不会被当 HTML 注入**:渲染侧是
* `AnnotatedString` + `SpanStyle`(结构化),正文从来没有、也不会走任何 HTML 解析/注入路径。
*
* 契约与边界口径见 [parseInlineMarkup]。
*/
/**
* 一段行内样式,可叠加(`<b><i>粗斜</i></b>` 同时是粗与斜)。
*
* 只有这四种:换行不是样式——`<br>` 是气泡分隔符(切在 `ChatContentParser.tokenize` 那一层),
* 它只会在 `<code>` 作用域内部作为**内容**走到这里(按文件头那条口径字面显示),永远不成样式。
*/
enum class InlineStyle {
Bold,
Italic,
Code,
/** `<u>`:下划线(渲染成 `TextDecoration.Underline`)。`s` 不在白名单里,所以没有对应项。 */
Underline,
}
/**
* 正文里**样式一致的一段连续文本**:片段按顺序拼起来就是渲染文本,样式区间由「起始下标 + 长度」
* 就地累加得到(第 n 段的区间 = 前面各段长度之和 until 加上自己)。
*
* 相邻且样式相同的片段由 [parseInlineMarkup] 合并,所以同一段文本只有一种片段形状——
* 断言起来就是在断言「哪段字是什么样式」。
*/
data class InlineRun(val text: String, val styles: Set<InlineStyle>)
/**
* `<code>` 的标签名(解析里要单独判它:里面的标签不再解析)。
* `internal` 是因为 `ChatContentParser.protectedSpans` 也要判它(保护区扫描),
* 名字只写这一处、两边不许各写一遍。
*/
internal const val CODE = "code"
/**
* 成对标签的名字(契约 v0.89 最终版的**六项**,见文件头):`strong` / `em` 是 `b` / `i` 的同义写法,
* `u` 是 `回执三十三 §一` 加回来的下划线。
* 只写这一处,[parseInlineTag] 与 [couldBeInlineTagNamePrefix] 都由它推出来。
* `br` 不在这里(它是分隔符,见文件头);`s` 也不在(逐字原样)。
*/
private val PAIRED_TAG_NAMES = listOf("b", "strong", "i", "em", CODE, "u")
/**
* 行内标签词表。**流式扣留侧(`ChatContentParser.stablePrefixEnd`)与这里必须同源**:
* 同一份名单既判「这是个行内标签」(消费侧),也判「这个标签名会不会继续补全」(扣留侧)。
* 两边一分叉,中间态就会把标签当正文吐上屏、收口时才吃掉——屏幕上已经显示过的那几个字就改口了。
*/
internal val INLINE_TAG_NAMES = PAIRED_TAG_NAMES
/**
* 白名单行内标签认下来的两种形态。**认不出来的(白名单外、形状不对)一律返回 null**,
* 由调用方当普通文本原样显示(契约第 3 条:字符一个不丢)。
*/
internal sealed interface InlineTag {
val name: String
/** 成对的开标签(`<b>`、`<STRONG>`、`<b class=x onclick=y>`…):要入栈等它的闭合。 */
data class Open(override val name: String) : InlineTag
/** 成对的闭标签(`</b>`、`</EM>`…)。 */
data class Close(override val name: String) : InlineTag
}
/** 标签名里允许出现的字母:ASCII 大小写,消费侧与扣留侧共用这一把尺子。 */
internal fun isTagNameLetter(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
/**
* 把一段 `<…>` 文本判成白名单行内标签(不是则 null)。**一处判据,两个消费者**:解析侧用它认标签,
* 流式扣留侧用它算「这个开标签要不要入栈扣留」——同源才不会分叉。
*
* 认下来的形状(`<` 与名字之间可以有空白,与既有 `</\s*(action|narration)\s*>` 同一口径):
* - 名字 = ASCII 字母串,**大小写不敏感**,落成小写后必须在 [INLINE_TAG_NAMES] 里;
* - 名字之后紧跟的必须是 `>`、空白或 `/`——`<boolean>` / `<br2>` / `<b-x>` 因此都不是标签
* (否则 `b` 会把一大票以 b 开头的普通单词吃成粗体),这是「字符一个不丢」的第一道闸门;
* - 再往后到 `>` 之间的一切(属性、`/`、空白)**整段丢掉**:契约第 2 条,只取标签名。
* 丢掉的只是白名单标签自己那对尖括号里的东西,正文一个字符都不动。
*
* `<br>` 在这里返回 null(它不再是行内标签,是气泡分隔符)——所以它落到「白名单外」那条路:
* 它只是几个普通字符。真实链路上**只有一条路**能把它送到这一层:`<code>` 作用域内部
* (分隔符切分层的保护区不分隔它,见 [appendRegion] 与 `ChatContentParser.protectedSpans`)。
*/
internal fun parseInlineTag(tag: String): InlineTag? {
val end = tag.length - 1
if (end < 2 || tag[0] != '<' || tag[end] != '>') return null
var i = 1
val closing = tag[i] == '/'
if (closing) i++
while (i < end && tag[i].isWhitespace()) i++
val nameStart = i
while (i < end && isTagNameLetter(tag[i])) i++
if (i == nameStart) return null
val name = tag.substring(nameStart, i).lowercase()
if (i < end && !tag[i].isWhitespace() && tag[i] != '/') return null
if (name !in PAIRED_TAG_NAMES) return null
return if (closing) InlineTag.Close(name) else InlineTag.Open(name)
}
/** 开标签名(不是行内开标签则 null)。 */
internal fun openInlineTagName(tag: String): String? =
(parseInlineTag(tag) as? InlineTag.Open)?.name
/** 闭标签名(不是行内闭标签则 null)。 */
internal fun closeInlineTagName(tag: String): String? =
(parseInlineTag(tag) as? InlineTag.Close)?.name
/**
* [name](已小写)是否可能是某个行内标签名的**前缀**——流式扣留侧判「残缺的 `<b` 还可能补全吗」
* 用它。大小写不敏感与 [parseInlineTag] 一致,名单也是同一份。
*/
internal fun couldBeInlineTagNamePrefix(name: String): Boolean =
INLINE_TAG_NAMES.any { it.startsWith(name) }
/** 标签名 → 样式;`strong` / `em` 落到与 `b` / `i` 同一个样式上。 */
internal fun inlineStyleOf(name: String): InlineStyle = when (name) {
"i", "em" -> InlineStyle.Italic
CODE -> InlineStyle.Code
"u" -> InlineStyle.Underline
else -> InlineStyle.Bold
}
/**
* 把一段**已经定稿的正文文本**解析成行内片段(纯函数)。
*
* 契约(都由单测钉着):
* 1. **返回空列表 = 这段文本按字面渲染**(正文一个字符都不动)。三种情形都落在这里:
* 没有可渲染的行内标签、**结构不平衡**(契约第 4 条,整段降级)、以及整段只有标记没有字。
* 这是绝大多数消息的路径(实测 100 条里只有 2 条带 `<b>`),也是 `plainBubble` 那条路。
* 2. 返回非空 ⇒ 片段拼起来**就是**渲染文本:白名单标签已被消费,且两端空白已去(见下)。
* **除此之外逐字等于原文**——白名单外的标签(`<script>`)、属性、`<code>` 里的内容全都原样在
* 片段里。
* 3. **绝不把一段非空正文渲染成空**:整段只有标记没有字(`<b></b>`)时回落成第 1 条,
* 把标记原样显示——宁可让她的话里多几个尖括号,也不能给出一条空白气泡。
*
* ## 口径一:配成对且正确嵌套,否则**整段**降级为原文(契约第 4 条)
* 判定只看白名单标签的结构(白名单外的尖括号一律是普通字符,不参与判定):
* - **未闭合的开标签**(`<b>没有闭合`、`日落 <b>18:35`)、**落单的闭标签**(`没有闭合</b>`、
* 只有 `</b>`)、**交错**(`<b>甲<i>乙</b>丙</i>`:`</b>` 关不掉栈顶的 `i`)——三者任一出现,
* 这一整段**一个标签都不认**、原文逐字显示。
* - 代价与收益都写清:`甲<b>粗</b>乙<b>没闭合` 里的 `粗` 也不再加粗(粗粒度),
* 换来的是渲染文本与原文之间只有两种关系(相等 / 消费掉全部配对标签),跨端可以逐字对齐。
* - 判据只有这一条,不发明「宽容关闭」(`</b>` 顺手把里面没关的 `<i>` 也关掉)、也不按行/按句
* 局部生效:那种写法每端都能自圆其说,合起来就是跨端不一致。
* - **跨分隔符的标签各自算**:`<b>甲<br>乙</b>` 里的 `<br>` 在更早一层就把气泡切开了,
* 到这里是两段文本(`<b>甲` 与 `乙</b>`),各自不平衡 ⇒ 各自回落原文。层序与理由见
* `ChatContentParser.appendRegion` 的 KDoc。
*
* ## 口径二:`<code>` 里不再解析(与契约第 3 条一致,不是例外)
* `<code><b>甲</b></code>` 显示字面的 `<b>甲</b>`(等宽 + 底色),`<code>1 < 2 && a<b</code>`
* 原样显示。它与契约第 3 条(白名单外按纯文本显示)**不冲突、方向一致**,但**不是同一条规则**:
* - 第 3 条管的是**白名单外的标签名**(`<script>`、`<div>`)——它们从来不是「标签」;
* - `<code>` 是一条**作用域**规则:在白名单标签 `<code>` 内部,连白名单标签也按内容处理。
* `code` 表达的是「这段是代码」,代码里的 `<b>` 就是四个字符——这是 W67 就定下的口径,
* 契约没有就「`<code>` 内部」单独措辞,我们**保留原行为**(换 HTML 那种「元素照常嵌套解析」的
* 语义,改动只是删掉 [claimTags] 里的跳过分支,等后端确权再动);
* - 两者的结果都落在同一条硬口径上:**字符一个不丢**。`<code>` 也正好是「想原样显示尖括号
* 怎么办」的现成答案。
* 内部的东西**不参与结构判定**:`<code><b>甲</code>` 里的 `<b>` 不算未闭合(它只是内容),
* 所以不会因为它把整段拖成字面——判定与显示用的都是同一套「里面不算标签」的口径。
* 代价是代码里的 `<b>` 不生效(实测 0 命中,也不存在「代码里也要粗体」这个需求)。
*
* ## 为什么两端空白要去掉
* 被标签让出来的空白(`<b> 甲 </b>` 里那两个字)留着的唯一效果是「气泡里看起来多了两个空格」,
* 而正文两端的空白本来就被去掉了(`toBubble` 里那句 `body.trim()`)。这一步只吃空白——一个可见
* 字符都不会掉;它同时兜住契约第 3 条:整段只有空白(`<b> </b>`)时回落字面,绝不给出一条
* 看起来空白的怪气泡。
*
* 复杂度 O(n):两趟线性扫描(每个 `<` 只看一扇「到 `>` 或下一个 `<` 为止」的窗,绝不
* `indexOf('>')` 一路找配对);正文里没有 `<` 时连集合都不建,一次就返回。
*/
fun parseInlineMarkup(text: String): List<InlineRun> {
if (text.indexOf('<') < 0) return emptyList()
// 结构不平衡 ⇒ 整段降级为原文(消费侧返回空列表 = 调用方按字面渲染)。
val claimed = claimTags(text) ?: return emptyList()
if (!claimed.any) return emptyList()
// 整段被吃空(`<b></b>` 这类只有标记没有字的形状)⇒ 回落字面路径,见契约第 3 条。
return runsOf(text, claimed).trimEdges().takeIf { it.isNotEmpty() } ?: emptyList()
}
/** 一趟扫下来的「哪些标签被认领」:开/闭标签的起始下标。 */
private class ClaimedTags {
val openStarts = HashSet<Int>()
val closeStarts = HashSet<Int>()
val any: Boolean
get() = openStarts.isNotEmpty() || closeStarts.isNotEmpty()
}
/**
* 第一趟:扫出配成对且正确嵌套的那些标签(起始下标落在 [ClaimedTags] 的两个集合里)。
*
* 返回 **null = 结构不平衡**(未闭合 / 落单闭标签 / 交错),调用方据此整段降级为原文。
* 因为不平衡时整段都不认,所以有返回值时**每一个**白名单成对标签都是配好的(闭标签只关得掉栈顶
* 那个同名开标签,严格嵌套)。
*
* 白名单外的尖括号**完全不参与判定**:它们是普通字符(契约第 1、3 条),既不入栈、也不会因为
* 「没闭合」把整段拖成字面——`甲<b>粗</b><script>` 里粗体照常生效,`<script` 原样显示。
*
* `<code>` 开着的期间**整段跳过**:里面的标签一个都不认、也不算结构(见 [parseInlineMarkup] 的
* 口径二)。因为跳过的是「认不认」,所以 `<code>` 自己那一对仍照常被认领。
*/
private fun claimTags(text: String): ClaimedTags? {
val claimed = ClaimedTags()
val open = ArrayList<Pair<String, Int>>()
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) break
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签:往后挪**一个字符**再找。不能跳到某个「下一个 `>`」——
// 那会连同中间真正的标签一起跳过(`1 < 2 <b>甲</b>` 里的 `<b>` 就是这么丢的)。
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
val parsed = parseInlineTag(tag)
if (open.lastOrNull()?.first == CODE) {
// `<code>` 里的一切都是它的内容:`<b>` 是四个字符,也都不算结构(所以里面那个「未闭合的
// `<b>`」不会把整段拖成字面)。只有闭合它的 `</code>` 例外。
if (parsed == InlineTag.Close(CODE)) {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
} else {
when (parsed) {
is InlineTag.Open -> open.add(parsed.name to lt)
is InlineTag.Close ->
// 落单的闭标签(栈空)、或交错(`</b>` 关不掉栈顶的 `i`)⇒ 整段降级。
if (open.lastOrNull()?.first != parsed.name) return null
else {
claimed.openStarts.add(open.removeAt(open.lastIndex).second)
claimed.closeStarts.add(lt)
}
null -> Unit
}
}
i = gt + 1
}
// 还有开标签没配到闭合 ⇒ 整段降级。
if (open.isNotEmpty()) return null
return claimed
}
/**
* [lt] 处这个 `<` 起头的**行内**标签在哪结束(不是行内标签则 -1)。
*
* 一路走到 `>` 或**下一个 `<`** 为止:契约要求「属性一律不解析」,标签多长因此不再有上界
* (`<b class="…" onclick="…">` 可以很长),W67 那版「只看一扇定宽窗」的做法行不通了。
* 仍然刻意**不用** `indexOf('>', lt)` 一路找过去:正文里 N 个落单的 `<` 时那是 O(N²)
* (W63 在同一片区域刚踩过这个坑),而且它还会把中间真正的标签一起跳过。
* 逐段推进仍是线性的:每次从 `lt + 1` 扫到「下一个 `<`」或串尾,这些区间互不重叠。
*
* `internal` 是因为**标签边界只该有一把尺子**:分隔符切分层扫 `<br>` 保护区
* (`ChatContentParser.protectedSpans`)时也用它,两边若各写一套,「哪儿是一个标签」就会分叉。
*/
internal fun inlineTagEnd(text: String, lt: Int): Int {
var i = lt + 1
while (i < text.length) {
when (text[i]) {
'>' -> return i
'<' -> return -1
}
i++
}
return -1
}
/**
* 第二趟:把文本切成片段——被认领的标签消费掉(开/闭标签不入字),其余**逐字**进片段
* (白名单外的标签、`<code>` 里的内容、没有被认领的整段标记,都在这里原样吐回)。
* 因为第一趟只收严格嵌套的配对,这里的栈与它逐字对应,不会弹错人。
*/
private fun runsOf(text: String, claimed: ClaimedTags): List<InlineRun> {
val runs = ArrayList<InlineRun>()
val piece = StringBuilder()
val open = ArrayList<String>()
var styles = emptySet<InlineStyle>()
fun flush() {
if (piece.isEmpty()) return
val text0 = piece.toString()
piece.setLength(0)
val last = runs.lastOrNull()
if (last != null && last.styles == styles) {
runs[runs.lastIndex] = last.copy(text = last.text + text0)
} else {
runs.add(InlineRun(text0, styles))
}
}
var i = 0
while (true) {
val lt = text.indexOf('<', i)
if (lt < 0) {
piece.append(text, i, text.length)
break
}
if (lt > i) piece.append(text, i, lt)
val gt = inlineTagEnd(text, lt)
if (gt < 0) {
// 这一处起头的东西不是行内标签(`1 < 2` 里那个 `<`、残缺的 `<b`、属性里带 `<` 的…):
// 把这个字符原样收下、从下一个字符继续。**必须与第一趟用同一把尺子**——这里若改用
// `indexOf('>', lt)` 找标签边界,第一趟认下的那对标签就会在这里被当成一个字面整段吞掉
// (`1 < 2 <b>甲</b>`)。
piece.append('<')
i = lt + 1
continue
}
val tag = text.substring(lt, gt + 1)
when {
claimed.openStarts.contains(lt) -> {
flush()
open.add(openInlineTagName(tag) ?: closeInlineTagName(tag).orEmpty())
styles = stylesOf(open)
}
claimed.closeStarts.contains(lt) -> {
flush()
open.removeAt(open.lastIndex)
styles = stylesOf(open)
}
else -> piece.append(tag)
}
i = gt + 1
}
flush()
return runs
}
private fun stylesOf(open: List<String>): Set<InlineStyle> =
if (open.isEmpty()) emptySet() else open.mapTo(LinkedHashSet(), ::inlineStyleOf)
/** 去掉渲染文本两端的空白,并同步裁掉因此变空的片段——只吃空白,一个可见字符都不会掉。 */
private fun List<InlineRun>.trimEdges(): List<InlineRun> {
var head = 0
for (run in this) {
val leading = run.text.takeWhile { it.isWhitespace() }.length
head += leading
if (leading < run.text.length) break
}
var tail = 0
for (i in indices.reversed()) {
val trailing = this[i].text.takeLastWhile { it.isWhitespace() }.length
tail += trailing
if (trailing < this[i].text.length) break
}
val total = sumOf { it.text.length }
val from = minOf(head, total)
val to = total - minOf(tail, total - from)
return cut(from, to)
}
/** 按字符下标从两端裁掉(只用于裁空白,所以裁点必然落在片段边界或空白中间)。 */
private fun List<InlineRun>.cut(from: Int, to: Int): List<InlineRun> {
if (from == 0 && to == sumOf { it.text.length }) return this
val out = ArrayList<InlineRun>(size)
var pos = 0
for (run in this) {
val start = pos
pos += run.text.length
val head = maxOf(start, from)
val end = minOf(pos, to)
if (head < end) out.add(run.copy(text = run.text.substring(head - start, end - start)))
}
return out
}
@@ -12,9 +12,27 @@ package top.yeij.cyrene.domain.share
*
* 还有第三件要说的:[contentUnreadable]——对方给了东西、本端**连地址都没认下来**。它连丢件计数
* 都留不下(本端一条都没接收),所以单独一格;否则「分享点下去什么都不发生」正是它造成的。
*
* 正文那一格也多带一件要看的东西:[textSource]——这一段是从 `EXTRA_TEXT` 来的,还是从
* `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的。有些发送方根本不填 `EXTRA_TEXT`,本端为此把取正文
* 改成一条回落链([ShareRules.extract]);而回落来的正文与真正文长得一模一样,用户只能靠这一格分辨。
*/
data class SharedContent(
/**
* 这次分享的正文(两侧空白已去掉;全空白算没有正文,见 [ShareRules.extract])。
*
* 它可能是从 `EXTRA_SUBJECT` / `EXTRA_TITLE` **回落**来的(有些发送方根本不填 `EXTRA_TEXT`),
* 来源记在 [textSource] 里:回落来的那一段与真正文长得一模一样,用户只能靠那一格分辨。
*/
val text: String = "",
/**
* 正文是从哪个 extra 拿到的(默认 [ShareTextSource.Text] = 正文本来就是正文)。
*
* ⚠️ 它**不参与发送的内容**:这条消息发出去的正文只有 [text] 本身。它的唯一去处是**本端渲染层**
* 的那一句如实小字(见 `shareTextOriginNotice`)——回落来的正文必须能让用户察觉,否则「预览里只有
* 一句话」会被读成「对方发来的就是这句正文」。
*/
val textSource: ShareTextSource = ShareTextSource.Text,
val images: List<String> = emptyList(),
/**
* 非图片的分享文件。MIME 以 `image/` 开头者走 [images],其余一律走这一条
@@ -31,13 +49,14 @@ data class SharedContent(
/** 已接收但**拷贝失败**的文件条数。 */
val failedDocuments: Int = 0,
/**
* 这次分享**带着内容迹象**(`EXTRA_STREAM` 有值、或 `ClipData` 有条目),却一条可用内容都没
* 提取出来。
* 这次分享**带着内容迹象**(三个文本 extra 里躺着值、或 `EXTRA_STREAM` 有值、或 `ClipData`
* 有条目),却一条可用内容都没提取出来。
*
* 与 [failedImages] / [failedDocuments] **不是**同一件事:那两个说的是「本端已经认下来的地址
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连地址都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态,或用的是本端不读的 scheme)。它是**唯一**一种既没有
* 内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是「用户点了分享、界面毫无反应」。
* 读不到」(对方给的东西本端拿不动),这里说的是「本端连可用的内容都没认下来」(多为发送方把
* `EXTRA_STREAM` 塞成了本端认不出的形态、用本端不读的 scheme,或把正文放在本端认不出的地方)。
* 它是**唯一**一种既没有内容、也没有丢件计数的失败形态,所以要单独记一格,否则结局就是
* 「用户点了分享、界面毫无反应」。
*/
val contentUnreadable: Boolean = false,
) {
@@ -55,13 +74,43 @@ data class SharedContent(
*
* 判据只此一处:`ShareIntake.accept` 用它决定要不要往下接,`shareNoticeFor` 用它守卫「什么都没有
* 的分享一个字都不说」那条边界——两处必须同一条,否则会出现「接了却不说」或「说了却没接」。
* 整条分享里什么都没有(`EXTRA_TEXT` 没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。
* 整条分享里什么都没有(三个文本 extra 都没有值、`EXTRA_STREAM` 也没有值、`ClipData` 也没有条目)
* 时它是 `false`:那次分享确实无话可说,静默才是对的。而「有值、只是本端一段都读不出来」(含
* 空白与类型不符)**不**属于「什么都没有」:它由调用方以 `textPresent` 如实报进来,正是
* [contentUnreadable] 那一格要接的。
*/
val worthDelivering: Boolean
get() = !isEmpty || droppedImages > 0 || droppedDocuments > 0 || contentUnreadable
}
/**
* 这次分享的正文**是从哪个 extra 拿到的**。
*
* ## 为什么要把来源留下来
* 真实世界里有些发送方不填 `EXTRA_TEXT`:内容在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。本端
* 此前只读 `EXTRA_TEXT`,那条分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句解释
* (正是本项目最忌讳的那种失败)。所以取正文改成一条**回落链**([ShareRules.extract])。
*
* 但「这段字其实只是对方给的标题」必须能让用户分辨:预览里只有一句话时,他会以为对方发来的就是这句
* 正文。于是一并记下**来源**,由渲染层如实标一句(`shareTextOriginNotice`)——那句话只出现在界面上,
* **绝不**进正文(来源标记不许拼进用户正文是项目红线,见 [ShareRules] 的「为什么正文一个字都不加」)。
*
* ## 为什么分成三档而不是一个布尔
* 三档是**如实登记**对方把内容放在哪个字段里:排查时「这条是从 SUBJECT 还是 TITLE 来的」是要看得见
* 的事实,而这两种形态在真实发送方里的出现频率差得很远(SUBJECT 常见、TITLE 罕见)。但两者对用户
* 说的是同一句话——标题与主题在用户眼里本来就是同一件事。
*/
enum class ShareTextSource {
/** `EXTRA_TEXT`:规范路径,也是唯一一条「正文本来就是正文」的路。 */
Text,
/** 从 `EXTRA_SUBJECT` 回落的(对方没给 `EXTRA_TEXT`,或给的是一段空白)。 */
Subject,
/** 从 `EXTRA_TITLE` 回落的。 */
Title,
}
/**
* 一份分享进来的**非图片**文件。
*
@@ -120,10 +169,19 @@ fun classifySharedStream(mime: String?): SharedStreamKind {
* ⚠️ **字符串形态只认 `content://`**(判据见 [stringStreamUri]):那是本端**新开**的一条读入口,
* 松不得。被拒的条目**逐条跳过**——一次分享里有几条坏的,不该把整次分享废掉。
*
* ## 正文的回落链(本模块补上的另一条兼容读取)
* 真实世界里有发送方**不填** `EXTRA_TEXT`:内容只在 `EXTRA_SUBJECT`(少数在 `EXTRA_TITLE`)里。
* 本端此前只读 `EXTRA_TEXT`,那样一次分享因此**整条静默**——用户点了分享,界面毫无反应、也没有一句
* 解释。所以取正文改成 `EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE` 的**回落链**([extract]),
* 来源记在 [SharedContent.textSource] 里,由渲染层如实标一句(见 [ShareTextSource])。
*
* ## 为什么正文一个字都不加
* **绝不**往用户正文里拼「[用户分享]」之类的来源标记:来源标识走后端的独立 `RoleSystem`
* 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`),人类可见的标记只能放在本端
* 渲染层。把标记拼进正文 = 让用户正文冒充系统指令,是项目红线。分享进来的正文**原样**发出。
*
* 回落来的正文(从 `EXTRA_SUBJECT` / `EXTRA_TITLE` 取到的那一段)同样**原样**发出:来源提示是界面
* 上的一句小字,**不是**正文的一部分——所以面板里那段预览与真正发出去的那条消息逐字相同。
*/
object ShareRules {
@@ -151,9 +209,68 @@ object ShareRules {
*/
const val MAX_DOCUMENTS = 5
/**
* 图片超限的那一句(**纯函数**):**分享入口与图片选择入口共用同一句**,所以它只有一个出处。
*
* 两个入口的上限本来就是同一个数([MAX_IMAGES])⇒ 告知也必须是同一句:各写一句,迟早会出现
* 「分享说只发前 4 张、选图说只发前 4 张(措辞不同)」这种同一件事的两种说法。
*
* ## 措辞**入口中性**(主人 2026-10-06 拍板)
* W42 把图片按钮改成多选之后,这一句不止在分享那条路上出现——图片入口里根本没有「分享」
* 这件事。原句以「**分享的**图片」开头,那是一句只对其中一条路成立的限定(在图片入口里读着
* 像在说别人的事),所以句子不再指认动作的来处:只说三件两条路都成立的事实——**有上限**
* ([MAX_IMAGES] 张)、**超出的没发**、**发出去的是前 N 张**。也刻意不写「失败」「错误」:
* 被上限截掉不是一次错误,是上限本身。
*
* @param kept 这次真正发出去的张数(`SharedContent.images` 的长度 / `ImageSendPlan.imagesToSend` 的长度)
* @param dropped 被上限截掉的张数;0 ⇒ `null`(一张没少就没什么要说的,说话才是噪声)
*/
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
/**
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
*
* ## 两个事实合起来只剩两种答案
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
* (后端口径 `02` F4:他们那边不需要做任何事)。
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
* 这样的意思。
*
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
*
* ## 为什么提成一个纯函数
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
*
* @param fromShare 这条(这一批)是不是分享进来的
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
*/
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
/**
* 从分享 Intent 的原始字段提取内容。
*
* ## 正文的回落链:`EXTRA_TEXT` → `EXTRA_SUBJECT` → `EXTRA_TITLE`
* 取**第一个非空白**的那一段当正文(口径与原先只读 `text` 时**完全一致**:两侧去空白、全空白算
* 没有);来源记在 [SharedContent.textSource] 里,好让渲染层如实说一句(见 [ShareTextSource])。
*
* 为什么要有这条链:真实世界里有些发送方**不填** `EXTRA_TEXT`,内容只在 `EXTRA_SUBJECT`
* (少数在 `EXTRA_TITLE`)里。此前只读 `EXTRA_TEXT` ⇒ 那样一次分享**整条静默**——用户点了分享,
* 界面毫无反应,后端零请求,也没有一句解释。前后两种口径的对外差别只有一个:**`EXTRA_TEXT`
* 非空时回落链一定不生效**(它是第一站),所以既有情形的结果一个字都没变。
*
* ## 三个文本 extra 都没换来正文时**不许闭嘴**
* 只要三个里**任意一个躺着原始值**(哪怕是一段空白、哪怕类型读不出来),这一次就不是「用户什么都
* 没分享」,而是「本端什么都没读出来」——[SharedContent.contentUnreadable] 如实告知,绝不静默。
* 「里面躺着值」这件事只有调用方知道(纯函数看不到原始值),所以由 [textPresent] 报进来,与
* [streamPresent] 同一个形态。
*
* ## 优先顺序与去重(一次分享里多种来源并存时)
* 顺序 = `EXTRA_STREAM` 的规范形态([stream] 在前、[streams] 在后)→ 字符串兼容形态(保持原顺序)
* → `ClipData`(只在前两者一条都没认下时兜底)。同一个地址串被两处各给一次时**只留先到的那一条**:
@@ -165,7 +282,10 @@ object ShareRules {
*
* @param action `Intent.action`;只认 [ACTION_SEND] / [ACTION_SEND_MULTIPLE]
* @param text `EXTRA_TEXT`——按 `CharSequence` 收,因为它常常是 `Spanned`(带样式的
* HTML 富文本),读成 `String` 会直接拿到 `null`
* HTML 富文本),读成 `String` 会直接拿到 `null`。**回落链的第一站**
* @param subject `EXTRA_SUBJECT`——第二站。发送方「对象 / 标题」那个字段,正是有些 App 拿来放
* 正文的地方(`EXTRA_TEXT` 一个值都没有)
* @param title `EXTRA_TITLE`——第三站(少见,但真实发送方里确实有)
* @param stream `EXTRA_STREAM` 的单值形态([ACTION_SEND] 那个只放一条的字段)
* @param streams `EXTRA_STREAM` 的列表形态([ACTION_SEND_MULTIPLE] 的字段)
* @param streamStrings `EXTRA_STREAM` 的**字符串兼容形态**(`String` / `String[]` /
@@ -176,6 +296,10 @@ object ShareRules {
* @param streamPresent `EXTRA_STREAM` 里确实躺着**原始值**——由调用方读了原始值判出来(本函数
* 看不到那个值),含本端**认不出形态**的那种。它只有一个用处:形态认不出时本端既不猜、也不该
* 因此闭嘴,那照样是一次「带着内容迹象却什么都没读进来」。
* @param textPresent 三个文本 extra([text] / [subject] / [title])里**至少一个躺着原始值**——
* 同样由读了原始值的调用方判出来,含**读不出类型**与**只是一段空白**的那两种(`textPresent`
* 与 [streamPresent] 一样是「原料里有」「认下来的内容里没有」,用后者判会把「有值但读不出来」
* 误当成「什么都没分享」)。正文一旦读出来(哪怕是从标题回落的)它就不起作用了。
* @param clipUris `ClipData` 各条目的 URI,纯文本条目是 `null`(本端**不读** `ClipData` 里的
* 文本,所以「只有纯文本条目」等同于「读不进来」,用来判 [SharedContent.contentUnreadable])
* @param mimeOf 取一条 URI 的 MIME(Android 侧就是 `ContentResolver.getType`)。本函数是
@@ -185,17 +309,30 @@ object ShareRules {
fun extract(
action: String?,
text: CharSequence? = null,
subject: CharSequence? = null,
title: CharSequence? = null,
stream: String? = null,
streams: List<String>? = null,
streamStrings: List<String> = emptyList(),
streamPresent: Boolean = false,
textPresent: Boolean = false,
clipUris: List<String?> = emptyList(),
mimeOf: (String) -> String? = { null },
): SharedContent {
val recognized = action == ACTION_SEND || action == ACTION_SEND_MULTIPLE
if (!recognized) return SharedContent()
val body = text?.toString().orEmpty().trim()
// 回落链(见 KDoc):三段一起按同一个口径归一(`toString` → 两侧 trim → 非空才算有),
// 取先到的那个**非空白**值。`EXTRA_TEXT` 非空时它一定赢 ⇒ 既有情形逐字不变。
val picked = listOf(
ShareTextSource.Text to text,
ShareTextSource.Subject to subject,
ShareTextSource.Title to title,
).firstNotNullOfOrNull { (source, value) ->
value?.toString()?.trim()?.takeIf(String::isNotEmpty)?.let { source to it }
}
val body = picked?.second.orEmpty()
val textSource = picked?.first ?: ShareTextSource.Text
// 两个字段一并收:单值 / 列表是历史形态(当年靠 getParcelableExtra 与
// getParcelableArrayListExtra 两个读法分开取,才不得不按 action 二选一),
@@ -220,9 +357,9 @@ object ShareRules {
}
}
// 「带着内容迹象」的判据取的是**原料**(EXTRA_STREAM 里躺着的原始值 / ClipData 的条目数),
// 不是「认下来的地址」——「有值但一条都认不出」正是要说的那种情形,用后者判反倒会把它当成
// 「什么都没分享」而闭嘴。
// 「带着内容迹象」的判据取的是**原料**(三个文本 extra 里躺着的原始值 / EXTRA_STREAM 里躺着的
// 原始值 / ClipData 的条目数),不是「认下来的内容」——「有值但一条都认不出」正是要说的那种
// 情形,用后者判反倒会把它当成「什么都没分享」而闭嘴。
val hadStreamValue = streamPresent ||
!stream.isNullOrBlank() ||
streams.orEmpty().any(String::isNotBlank) ||
@@ -230,6 +367,7 @@ object ShareRules {
return SharedContent(
text = body,
textSource = textSource,
images = images.take(MAX_IMAGES),
// 提取阶段还不知道显示名与大小(要 ContentResolver),先放原样 URI;
// ShareIntake 拷进 cache 后再换成 file:// + 显示名 + 真实字节数。
@@ -237,7 +375,7 @@ object ShareRules {
truncatedImages = (images.size - MAX_IMAGES).coerceAtLeast(0),
truncatedDocuments = (documents.size - MAX_DOCUMENTS).coerceAtLeast(0),
contentUnreadable = unique.isEmpty() && body.isEmpty() &&
(hadStreamValue || clipUris.isNotEmpty()),
(hadStreamValue || textPresent || clipUris.isNotEmpty()),
)
}
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.delay
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.ui.admin.AdminHomeScreen
import top.yeij.cyrene.ui.common.MiuixDrawer
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
@@ -27,8 +28,10 @@ private const val SESSION_GONE_NOTICE_MILLIS = 8_000L
* 会话在别处被删掉时那句如实告知。
*
* 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用,
* `activeSessionId` 就会被收敛回主会话,那个会话的 VM 连同它的状态**立刻被销毁**,
* 挂在上面的横幅一个字都来不及显示(真机实测过)。这一层的 `remember` 活得比它久。
* `activeSessionId` 就会被收敛回主会话,ChatScreen 的槽位(与 VM key 里的会话 id)随之变化 ——
* 于是**承载横幅的那段组合被销毁重建**;而那个 VM 这会儿正被交出去(本端删除那条路走
* `SessionsViewModel.deleteSession` 里的 `ChatSessionSlots.reclaim`,别处删除这条走 404 收敛),
* 挂在它状态上的横幅连这一帧都活不过。这一层的 `remember` 活得比它久。
*/
private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话"
@@ -52,7 +55,7 @@ fun ChatHomeScreen(
var showAbout by remember { mutableStateOf(false) }
// 当前会话在别处被删掉时那句告知(见 [SESSION_GONE_MESSAGE])。放在这一层是因为承载它的
// 会话 VM 会在切回主会话的瞬间被销毁。
// 那段组合(ChatScreen)会在切回主会话的瞬间被销毁重建,而这里的 remember 活得比它久。
var sessionNotice by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sessionNotice) {
@@ -62,6 +65,22 @@ fun ChatHomeScreen(
}
}
// 「点她那条通知进来 ⇒ 看主会话」(W53N)。意图活在进程里(通知可能在 App 还没起来、甚至还没
// 登录时就被点,见 ProactiveTapIntake),这里只负责把它落地成用户能看见的两件事:
// 回到聊天页、并切到主会话。走的是用户点抽屉里那一行的同一条路(select),不另造机制。
val pendingProactiveTap by container.proactiveTapIntake.openMainSession.collectAsState()
LaunchedEffect(pendingProactiveTap) {
if (!pendingProactiveTap) return@LaunchedEffect
container.proactiveTapIntake.consume()
// 通知说的是「她刚在主会话里说话」,所以落点必须是**那一页**:设置页 / 管理页 / 关于 /
// 抽屉还开着的话,用户点进来看到的仍是它们,这条通知就等于没兑现。
drawerOpen = false
showAbout = false
showSettings = false
showAdmin = false
sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID)
}
BackHandler(enabled = showAdmin) { showAdmin = false }
// 「关于」嵌在设置页里,所以两个条件必须**互斥**:在关于页时设置页的处理器关掉,
// 返回才只退一层(关于 → 设置),不会一步跳回聊天。
@@ -102,12 +121,21 @@ fun ChatHomeScreen(
},
// 当前会话在别处被删了(`404 session_not_found`)⇒ 先立起那句如实告知,再重拉列表:
// `refresh` 里面会把「指向已删会话」的 activeSessionId 收敛回主会话
// (`resolveActiveSession`),抽屉里那条也跟着消失。所以这里不需要自己判「该切到哪儿」。
// (`resolveActiveSession`),抽屉里那条也跟着消失。
//
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key、
// 把那个会话的 VM 销毁掉,晚一步就什么都看不到了。
// 顺序很重要:告知必须**先**立起来再刷新——刷新会让本层的 ChatScreen 换 key,
// 那段组合连同挂在 VM 状态上的东西一起被丢弃重建,晚一步就什么都看不到了。
//
// ⚠️ 但**不能只靠 `refresh` 把用户送回去**:它只在**拉成功**时才做回落
// (`resolveActiveSession`,且列表为空时刻意不回落;`.onFailure` 那条更是什么都不动)。
// 于是「网络正不稳 / 服务端正好抖一下」时,屏幕上那句「已切回主会话」就成了一句
// 做不到的话——用户被留在一个已经没了的会话上,没有任何出路。这正是这个告知
// 想要修掉的那一幕,所以这里**先在本端收敛**(`select` 是用户点抽屉里那一行走的
// **同一条**路,不另造机制),`refresh` 退回它本来的职责:与服务端对齐、把抽屉
// 里那条去掉。副作用与用户手点主会话进入时逐字相同(就地清未读 + 推进游标)。
onSessionGone = {
sessionNotice = SESSION_GONE_MESSAGE
sessionsViewModel.select(SessionSummaryDto.MAIN_SESSION_ID)
sessionsViewModel.refresh(silent = true)
},
sessionNotice = sessionNotice,
@@ -124,6 +152,7 @@ fun ChatHomeScreen(
onDelete = { sessionsViewModel.deleteSession(it.id) },
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
onStopGenerating = { sessionsViewModel.stopGenerating(it.id) },
onOpenIot = { /* P2:IoT 页面 */ },
onOpenAdmin = {
drawerOpen = false
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.chat
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -30,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -46,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
@@ -58,8 +61,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.app.ShareIntake
import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.core.session.ChatSessionSlots
import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.data.voice.VoiceRecorder
import top.yeij.cyrene.data.voice.VoiceRecorderState
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DocumentDelivery
@@ -72,7 +80,10 @@ import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
@@ -81,6 +92,7 @@ import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
@@ -88,6 +100,7 @@ import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
@@ -105,9 +118,36 @@ private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
private const val SEARCH_NOTICE_MILLIS = 8_000L
/**
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
*
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
*/
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
/**
* 聊天页那个 [ChatViewModel] 的 key(纯函数):**账号指纹 + 会话 id**。
*
* 它现在是**一格槽位之内**的名字(owner 是那一格自己的 `ViewModelStore`,见 [ChatSessionSlots]),
* 但它要挡的那件事一个字没变:owner 换了(换账号 / 换服务器之后再进来)而 key 不变的话,
* `viewModel()` 会把**上一个环境**那个还活着的实例原样取回来——它会继续收那个环境的帧,
* 屏幕上摆的就是别的服务器的消息。所以指纹**必须**在里面。
*
* 两道闸门现在是**各管一段**的:账号指纹进**槽位**的 key(`ChatSessionSlots.keyOf`,换账号天然
* 分格),同一个指纹再进**这一格 store 里**的 key(本函数)——同一格 store 只会装一个 VM,
* 但两处都写着同一份口径,任何一处将来被单独改掉都还挡得住(先例:`SessionPreferences` 的快照
* 与 `SessionsViewModel` 的 `pulledForIdentity` 也是同一份指纹的两处用法)。
*
* [accountFingerprint] 与 [SessionPreferences] / `SessionsViewModel` 是**同一份**口径:
* 别在这里另拼一套字符串——两套判据早晚会分叉,而分叉的那一侧就是漏出去的那一侧。
*/
internal fun chatViewModelKey(accountFingerprint: String, sessionId: String): String =
"$accountFingerprint|$sessionId"
/**
* 选文件时交给系统选择器的 MIME 列表。
*
@@ -147,21 +187,67 @@ fun ChatScreen(
onSessionGone: () -> Unit = {},
sessionNotice: String? = null,
) {
// 账号指纹:**必须进下面那两个 VM 的 key**(聊天那个 VM 还要进它那一格槽位的 key,见
// `ChatSessionSlots.keyOf`)。登出只是把本组合移出(见 CyreneApp 的 when),key 里不带指纹的话,
// 换账号/换服务器再进来时 `viewModel()` 会把**上一个环境**那个还活着的实例原样取回来,屏幕上
// 摆的就是别的服务器的消息(下面那个搜索 VM 就是这样:它没有人回收,只靠 key 分开)。
// `remember` 每次进入本组合重算一次(换环境必经登出 ⇒ 本组合被销毁重建),所以不会拿着上一份
// 指纹;不在每次重组时读加密存储是因为流式期间重组非常频繁。
val account = remember {
accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username)
}
// 这个会话的**槽位**:这一格的聊天状态住在这里面那份 `ViewModelStore` 里,所以它能被**单独**
// 回收(S4;此前每格都塞在 Activity 级那一个 store 里,而它只有整体 clear,收回一格的办法
// 只能是「让 key 换一代、旧实例继续留着」——那正是 S4 没修掉的那一半)。
//
// 组合期间只做这一件与账本有关的事:取(或建)槽位。VM 必须在组合里就拿出来,而槽位就是它的
// 存放处,所以这一步没法挪到组合之后;「谁最近」与「超了要不要收」都在下面那个 effect 里。
val sessionSlot = remember(account, sessionId) {
container.chatSessionSlots.slotFor(account, sessionId)
}
val viewModel: ChatViewModel = viewModel(
key = sessionId,
viewModelStoreOwner = sessionSlot,
key = chatViewModelKey(account, sessionId),
factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } },
)
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
// 「聊天页此刻正显示着这个会话」——「她主动说的话要不要弹通知」的两半之一
// (判据 `core.notify.shouldNotifyProactive`:前台 ∧ 这一半 才不弹)。
//
// 报在这里是因为**组合就是「这一页在不在屏幕上」**:设置页 / 管理页时上层那段 `when` 根本不会
// 组合本函数,会话一换本函数也整个换掉——两个方向都自动成立,不需要谁去同步一份状态。
// 退出时记 `null`(不是「记成别的会话」):那一刻屏幕上没有聊天页。
DisposableEffect(container, sessionId) {
container.appPresence.onChatSurface(sessionId)
onDispose { container.appPresence.onChatSurface(null) }
}
// 这个会话**刚刚成为当前会话**:这是「最近」的唯一表达处(口径见 `chatSessionEvictions`),
// 顺手按账本回收超出的格数。放在组合之后是刻意的——槽位可能刚在这一帧建出来,而账本不该在
// 组合期间被改动(组合被丢弃时不该留下「它被打开过」的结论)。
//
// `holdsUnsentWork` 收的是**现读的探针**:回收发生在别的会话被打开的那一刻,而那正是用户
// 可能刚在前一个会话里打了草稿、还没切走的时候(理由写在 `ChatSessionUsage`)。
LaunchedEffect(account, sessionId) {
container.chatSessionSlots.onOpened(
account = account,
sessionId = sessionId,
holdsUnsentWork = { viewModel.holdsUnsentWork },
nowMillis = System.currentTimeMillis(),
)
}
// 这个会话在服务端已经没了(`404 session_not_found`,契约 v0.63):交给上层把用户带回主会话。
// 只有**非主会话**会置这个位(主会话回 404 说明部署出了问题,那时该显示的是普通错误横幅),
// 所以这里不会出现「已经主会话还往上跳一次」的回环。
//
// ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回
// 主会话,`key = sessionId` 随之变化 ⇒ **这个 VM 连同它的状态一起被销毁**,挂在上面的横幅
// 一个字都来不及显示(真机实测过:切回主会话干净利落,用户完全不知道为什么)。
// 所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
// 主会话,VM 里的会话 id 随之变化 ⇒ **承载它的那段组合被销毁重建**(换的是槽位与 key,不是
// 那一格 store 本身:它这会儿正被上一层的删除路径 `ChatSessionSlots.reclaim` 交出去)。
// 挂在 VM 状态上的横幅活不过这一帧,一个字都来不及显示(真机实测过:切回主会话干净利落,
// 用户完全不知道为什么)。所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。
LaunchedEffect(state.sessionGone) {
if (state.sessionGone) onSessionGone()
}
@@ -175,7 +261,9 @@ fun ChatScreen(
// 待执行的定位请求。命中在窗口里时先关面板再滚,交给这个 effect 在组合完成之后做。
var pendingScroll by remember { mutableStateOf<Int?>(null) }
val searchViewModel: SessionSearchViewModel = viewModel(
key = "session-search:$sessionId",
// 与上面那个 VM 同样的理由:搜索命中也是**上一个环境的聊天内容**,key 里不带账号指纹的话,
// 换环境后在主会话(id 恒为 web)上重开搜索,面板里就是别的服务器的消息。
key = "session-search:${chatViewModelKey(account, sessionId)}",
factory = viewModelFactory {
initializer { SessionSearchViewModel(container, sessionId) }
},
@@ -315,11 +403,145 @@ fun ChatScreen(
}
var imageTarget by remember { mutableStateOf<ImageSource?>(null) }
// 系统相册选择器(PickVisualMedia 走 Photo Picker,**不需要任何存储权限**)。
// —— 语音播放(接收侧)——
// 取字节的缓存层:服务端相对路径(`voice.url`)接到**当前服务器地址**上再发请求,字节落在
// `cacheDir/media/`(按存储名内容寻址 ⇒ 重复播放一个请求都不发)。`baseUrlProvider` 每次现读:
// 换服务器必然重进本组合,而 `mediaApi` 那条客户端也按 baseUrl 重建(见 AppContainer)。
val voiceStore = remember(container) {
VoiceStore(
download = container.mediaRepository::download,
cacheDir = container.appContext.cacheDir,
baseUrlProvider = { container.authManager.currentBaseUrl },
)
}
// 一条播放通道归**这一个会话**:换会话 = 换实例 = 上一条立刻停并释放;离开聊天页(本组合被
// 丢弃)时 `onDispose` 释放(MediaPlayer 不 release 会一直占着解码器,见 VoicePlayer 的 KDoc)。
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
val voicePlayback by voicePlayer.state.collectAsState()
// —— 语音输入(契约 v0.96 §3.15 ①「用户按住麦克风说话」)——
// 录音机归**这一个会话**:换会话 = 换实例(与上面那条播放通道同一个生命周期纪律),
// 离开聊天页时 `onDispose` 收掉麦克风——一段没发出去的录音不该在后台继续占着它。
//
// ⚠️ 录音中**转屏**会重建组合 ⇒ 这一段丢失。本端不为此加一个「录音仓」:录音是几秒钟的
// 动作,转屏 / 分屏重建时丢掉一次比在 VM 里维护一条 AudioRecord 生命周期便宜得多。
val recorder = remember(sessionId) { VoiceRecorder() }
DisposableEffect(recorder) {
onDispose { recorder.release() }
}
val recording by recorder.state.collectAsState()
val recordingState = recording as? VoiceRecorderState.Recording
// 麦克风起不来 / 没授权时的如实告知(本端的问题,不走 VM 的 `error` 通道:
// 那条通道的语义是「服务端 / 发送」,而这里一次都没上过路)。
var voiceFailure by remember { mutableStateOf<String?>(null) }
LaunchedEffect(voiceFailure) {
if (voiceFailure != null) {
delay(VOICE_FAILURE_NOTICE_MILLIS)
voiceFailure = null
}
}
val requestAudioPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
// 授权刚拿到手就直接开录(用户按麦克风的意思就是「现在说」,不必再按第二次)。
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = VOICE_PERMISSION_DENIED_MESSAGE
}
}
// 开始录音:有权限就直接起;没有就申请。起不来时**如实说一句**,绝不假装在录。
fun startVoiceRecording() {
// 已经在录就不重入(录音中麦克风那枚按钮本来就不在屏幕上,这里是第二道闸)。
if (recorder.state.value is VoiceRecorderState.Recording) return
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = null
requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
// 「这一次收尾已经在飞」:防双击「发送」,也防它和「录满 60 秒自动收尾」撞在同一帧上。
// 第二次进入时录音机已经不在录了,`stop()` 会回 `null`——那会被误读成「没有录到声音」。
var finishingVoice by remember { mutableStateOf(false) }
// 结束录音并交给 VM:准入判据(空 / 太短 / 超限)全在 `VoiceClip.plan` 里,这里一个字节
// 都不自己判——空录音也照旧交给它,好让用户拿到那句「没有录到声音」。
fun finishVoiceRecording() {
if (finishingVoice) return
finishingVoice = true
scope.launch {
try {
val pcm = recorder.stop()
viewModel.sendVoice(pcm ?: ByteArray(0))
} finally {
finishingVoice = false
}
}
}
// 录满 60 秒:录音机只置位不自动发送(见 VoiceRecorder 的 KDoc),收尾由这一层做。
val reachedLimit = recordingState?.reachedLimit == true
LaunchedEffect(reachedLimit) {
if (reachedLimit) finishVoiceRecording()
}
// `voice_output` 到了就放(契约:这一帧只定向回本连接、断线不补投 ⇒ 不播就永远听不到)。
// 键里带 `file`:音频先落成本地文件再进状态,落好那一刻才是真正能播的时刻。
// `autoPlay` 只用一次:这一帧到达时用户可能在别的会话,切回来时不该突然响一段旧语音
// (判据见 VoiceReply.autoPlay)。「已经在放 / 正在载入」时不重新触发——否则第二次状态
// 到达会把正在响的那条停掉。
val voiceReply = state.voiceReply
LaunchedEffect(voiceReply?.id, voiceReply?.file) {
val reply = voiceReply ?: return@LaunchedEffect
val file = reply.file ?: return@LaunchedEffect
if (!reply.autoPlay) return@LaunchedEffect
viewModel.consumeVoiceReplyAutoPlay()
if (!voicePlayer.state.value.isActiveFor(reply.id)) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
}
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
// 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的
// 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。
//
// ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变**
// (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮
// 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。
//
// 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的
// 类 KDoc)。
val ttsClip = state.ttsClip
LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) {
val clip = ttsClip ?: return@LaunchedEffect
val file = clip.file ?: return@LaunchedEffect
if (!clip.autoPlay) return@LaunchedEffect
viewModel.consumeTtsAutoPlay()
if (!voicePlayer.state.value.isActiveFor(clip.clipId)) {
voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
}
}
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
DisposableEffect(viewModel) {
onDispose { viewModel.dropPendingTtsAutoPlay() }
}
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
// (老系统 / 没有 Photo Picker 时它退化成 `ACTION_OPEN_DOCUMENT`,那份 Intent 只能表达
// 「可多选」、表达不了「最多几张」)。真超了由 VM 如实告知、**绝不静默截断**(见 onImagesPicked)。
// 只把 URI 交给 VM:真正的解码 / 缩放 / base64 在发送时于 IO 线程做。
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let { viewModel.onImagePicked(it.toString()) } }
ActivityResultContracts.PickMultipleVisualMedia(ShareRules.MAX_IMAGES),
) { uris -> viewModel.onImagesPicked(uris.map { it.toString() }) }
// 文件选择器(SAF `OpenMultipleDocuments`):同样不需要任何存储权限——授权是**逐次**给的,
// 因此这条 URI 的读授权只活到本 Activity 任务结束;而发送可能发生在登录之后(分享落地)
@@ -330,7 +552,9 @@ fun ChatScreen(
) { uris -> viewModel.onDocumentsPicked(uris.map { it.toString() }) }
// 「分享到 App」的落地时机。待落地的内容活在 Compose 之外(`ShareIntake`),这里只做三件事:
// 挑时机、取走一条、交给既有发送路径。判据本身是纯函数 [shareLanding](可单测)。
// 挑时机、请 VM 认领一条、交给**确认面板**(`ShareConfirmPanel`:内容一进来就发的那条老路已废,
// 2026-10-06 主人要求像其他 IM 一样先确认——只有点「发送」才真的发)。
// 判据本身是纯函数 [shareLanding](可单测)。
// - **取走必须晚于「连接就绪」**:短文本走 WS,而冷启动时握手还没完成(分享冷启动必然撞上),
// 抢在那之前发只换来一次 `sendChat` 回 false 的「发送失败,请重试」;而 [ShareIntake.take] 是
// 原子出队、没有归还路径 ⇒ 那一次「其实一次都没发出去」的失败会把内容从待发队列里抹掉,
@@ -341,10 +565,16 @@ fun ChatScreen(
// **不可恢复**的 `Offline` 那一档时说 [SHARE_OFFLINE_NOTICE](「重新登录 / 检查服务器地址」)——
// 那一档不会自动重连,「连上后自动发出」等的是一个永远不会到来的连接。哪档配哪句是纯函数
// [shareWaitingNotice],两档的落地时机一模一样。
// - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级
// ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。
// - **只有当前会话的 ChatScreen 在组合**:别的会话的 VM 也可能还活着(各自那一格槽位里,
// 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。
// - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个
// effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。
// - **取走只发生一次,而且只发生在「手上什么都没有」的那一帧**:取到的那一份落进**VM 的状态**
// ([ChatUiState.shareConfirm],不是这里的一格 `remember`),面板靠它渲染。effect 的键里有连接
// 状态 ⇒ 它会反复重跑,而重跑**绝不会**再取一份、也绝不会重复发送:闸门在 `take()` **之前**,
// 而闸门的依据就是 VM 里那一格(`ChatViewModel.claimSharedContent` 的 KDoc 写着它为什么在
// `take()` 之前)。面板开着时新来的分享**原地在队列里等**——键里的 `shareConfirm == null`
// 保证面板一关(发送 / 取消)就会再跑一次判据,把它接上。
val pendingShares by ShareIntake.pending.collectAsState()
val shareConnection = state.connection
// 「这一轮等待已经等够久了」。计时只跟**「队列里有东西」与「连上了没有」这两个事实**走:
@@ -354,6 +584,13 @@ fun ChatScreen(
// 「这一轮等待已经说过了」。它与上面那个标志的生命周期刻意不同:连接抖动(effect 因键变化重跑)
// 不把它归零,否则同一句话会跟着抖动反复弹——刷屏。内容被取走、或队列清空,才算了结这一轮。
var shareToldWaiting by remember(sessionId) { mutableStateOf(false) }
// 「一份分享已经取走、正在等用户确认」+ 用户补的那句描述。**它们刻意不在 Compose 里**:那一份
// 已经出队、没有归还路径,而这一格 `remember` 会随转屏 / 深浅色切换整份丢掉——丢掉的后果是
// 那份分享既不在队列里、也不在界面上,**静默消失**(用户什么都没看到,也没有任何提示)。
// 所以它住在 VM 的状态里(面板输入框那句描述与正文绑在同一个值里,见 [ShareConfirmDraft]):
// `ChatSessionSlots` 的那一格 `ViewModelStore` 由 `AppContainer` 持有,比 Activity 活得久。
// ⚠️ 别把它挪回这里——「比 Activity 活得久」正是它存在的全部理由。
val shareConfirm = state.shareConfirm
LaunchedEffect(sessionId, pendingShares.isEmpty(), shareConnection == WsState.Connected) {
if (pendingShares.isEmpty() || shareConnection == WsState.Connected) {
shareWaitedLong = false
@@ -363,8 +600,10 @@ fun ChatScreen(
shareWaitedLong = true
}
// 这个 effect 的键里有连接状态 ⇒ 连接后来就绪时会重跑,内容因此**一次都不会漏发**(也不需要
// 任何轮询:状态一变就重来一遍判据)。
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong) {
// 任何轮询:状态一变就重来一遍判据)。键里还有 `shareConfirm == null`——面板一关(发送 / 取消)
// 它就翻回来、这个 effect 随之重跑,把面板开着那段时间里攒下的下一条分享接上(不加这个键,
// 那一条会一直躺在队列里,直到连接状态碰巧再变一次)。
LaunchedEffect(sessionId, pendingShares, shareConnection, shareWaitedLong, shareConfirm == null) {
val landing = shareLanding(
hasPending = pendingShares.isNotEmpty(),
connection = shareConnection,
@@ -373,9 +612,15 @@ fun ChatScreen(
)
when (landing) {
// 取走之后这一轮等待就结束了:下一批内容(或下一次断线)重新计时、重新有资格说那句话。
//
// **取走只在这一处发生**,而且闸门在 `take()` **之前**(判据是纯函数 [shareConfirmClaim],
// 落在 `ChatViewModel.claimSharedContent` 里):手上已经有了那份未确认的分享时,这一帧
// 一个字节都不动队列——面板开着时来的新分享原地留在待发队列里,等这一份确认完再接,
// 那样才不会出现「用户正看着 A,面板里的内容被换成了 B」。转屏重建之后新的一帧照样走到
// 这里,而那时**手上已经有东西**(它活在 VM 的状态里),所以也不会再取一次。
ShareLanding.Send -> {
shareToldWaiting = false
ShareIntake.take()?.let { share -> viewModel.sendShared(share, shareNoticeFor(share)) }
viewModel.claimSharedContent()
}
// 两个告知档只差措辞(时机一致):说哪句由纯函数 [shareWaitingNotice] 定,
@@ -451,17 +696,38 @@ fun ChatScreen(
bottomBar = {
InputBar(
value = state.draft,
imageUri = state.draftImage,
images = state.draftImages,
documents = state.draftDocuments,
busyText = busyLabel(state),
recordingMs = recordingState?.elapsedMs,
voiceDraft = state.voiceDraft,
voiceReply = state.voiceReply,
voicePlayback = voicePlayback,
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onStartRecording = { startVoiceRecording() },
onStopRecording = { finishVoiceRecording() },
onCancelRecording = { recorder.cancel() },
onRetryVoice = viewModel::retryVoice,
onDiscardVoice = viewModel::discardVoice,
// 播放 / 停止与关闭都在这一个动作里收口:关掉卡片之后再响一秒是明确的 bug。
onToggleVoiceReply = {
val reply = state.voiceReply
val file = reply?.file
if (reply != null && file != null) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
},
onDismissVoiceReply = {
voicePlayer.stop()
viewModel.dismissVoiceReply()
},
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
onClearImage = viewModel::clearDraftImage,
onRemoveImage = viewModel::removeDraftImage,
onPickDocument = { documentPicker.launch(DocumentPickerMimeTypes) },
onRemoveDocument = viewModel::removeDraftDocument,
onPreviewDocument = { index ->
@@ -480,7 +746,23 @@ fun ChatScreen(
) {
when {
state.loading && state.items.isEmpty() -> CenterHint("加载中…")
state.items.isEmpty() -> CenterHint("还没有消息")
// 空列表这一档有两种真相,屏幕上必须分得开(W52/E):**真的还没有消息**,
// 与**消息没能加载出来**(首屏 REST 失败)。后者此前也念「还没有消息」,
// 于是唯一的出路只剩「切走再回来」——现在给一枚显式「重试」。
state.items.isEmpty() -> {
val retry = shouldOfferRetryOnEmpty(
loading = state.loading,
hasError = state.error != null,
// ⚠️ 语义是「**有没有**东西」而不是「是不是空」——W52/E 第一版这里传了
// `isEmpty()`,极性反了,于是「列表为空」这个唯一会求值的场景里结论**永远为假**、
// 按钮永远不出现(判据单测用的是正确语义,所以全绿;只有真机才露出来)。
hasItems = state.items.isNotEmpty(),
)
CenterHint(
text = if (retry) FIRST_PAGE_FAILED_HINT else EMPTY_CONVERSATION_HINT,
onRetry = if (retry) viewModel::retryFirstPage else null,
)
}
else -> LazyColumn(
state = listState,
reverseLayout = true,
@@ -501,6 +783,14 @@ fun ChatScreen(
// 自己的外观,①②⑤ 与今天逐字相同。判定是纯函数,接线层不自己猜。
phase = state.phaseOf(item.message),
legBadge = state.legBadgeForMessage(item.message),
// 语音播放(接收侧):状态来自**这一个会话**的那条播放通道,
// 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。
voicePlayback = voicePlayback,
// 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。
// 播放状态仍是同一条通道——朗读的键是 `tts:<messageId>`,所以两枚
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id },
ttsPlayback = voicePlayback,
onDocumentClick = { ref ->
previewNotice = null
previewTarget = DocumentTarget.of(ref)
@@ -515,11 +805,29 @@ fun ChatScreen(
// 各自用同一份产品口径的纯函数判定。
onEdit = viewModel::startEdit,
onDelete = viewModel::startDelete,
onVoiceToggle = voicePlayer::toggle,
// 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
// 这里分(判据是纯函数 [ttsSpeakIntent])。直通 `viewModel::speak`
// 会让「停止朗读」变成空操作(P1-1):播放中 `TtsClip.pending` 是假
// ⇒ VM 的防抖放行、整格换成新 pending、缓存命中秒回,而下面那个起播
// effect 又因为「已经在放同一条」跳过 `toggleClip`——三处各自都对,
// 合起来点了没反应。唯一带停止语义的入口在播放通道上,所以在这一层分。
onSpeak = { message ->
val clip = state.ttsClip
?.takeIf { it.messageId == message.id }
when (ttsSpeakIntent(voicePlayer.state.value, message.id, clip)) {
TtsSpeakIntent.Stop -> voicePlayer.stop()
TtsSpeakIntent.Speak -> viewModel.speak(message)
}
},
)
is ChatDisplayItem.Pending -> PendingBubble(
item = item.item,
phase = state.phaseOf(item.item),
// ③ 里更细的那一档(W40):**本端**因为没连上把这条留住了。
// 判据是纯函数(队列成员关系),接线层不自己猜。
awaitingConnection = state.isAwaitingConnection(item.item.localId),
onDocumentClick = { draft ->
previewNotice = null
previewTarget = DocumentTarget.of(draft)
@@ -609,6 +917,20 @@ fun ChatScreen(
state.parallelLimitNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 「这条还没连上、已排队等连接」(W40):与上面三条同档的如实告知(**不是错误**,
// 所以不用红色)。它与 `shareNotice` 分开两块牌子,因为说的是不同的动作;
// 而「这条还在等连接」这个**持续**状态由气泡上的小标一直挂着(牌子会到点收走)。
state.sendQueueNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 语音输入被本端挡下(没录到 / 太短 / 太大):与上面几条同档的如实告知。
state.voiceNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 麦克风起不来 / 没授权:这是真错误(功能整个不可用),所以用红色。
voiceFailure?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
searchNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
@@ -690,6 +1012,24 @@ fun ChatScreen(
)
}
}
// 分享进来的内容的**确认面板**(W53X:「一到就发」那条老路已废)。面板开着时**一个字节都还
// 没发出去**:点「发送」才走既有发送路径,点「取消」这一份就作废。这一处只做接线——内容与
// 描述都在 **VM 的状态**里(`state.shareConfirm`,理由见那里与 `ChatViewModel.claimSharedContent`
// 的 KDoc),面板本身与它的判据都在 `ShareConfirmPanel.kt`。转屏重建之后面板照旧在这里长出来,
// 因为 VM 比 Activity 活得久;**它绝不能被挪回这个组合里**。
//
// ⚠️ 取走是不可逆的(`ShareIntake.take()` 没有归还路径),所以「取消」**不把内容塞回队列**:
// 塞回去会让「用户刚作废的那一份」与「又来了新分享」变成同一件事,两种状态就再也分不开了。
shareConfirm?.let { draft ->
ShareConfirmPanel(
share = draft.share,
caption = draft.caption,
onCaptionChange = viewModel::onShareCaptionChange,
onSend = viewModel::sendShareConfirm,
onCancel = viewModel::cancelShareConfirm,
)
}
}
/**
@@ -744,16 +1084,45 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
}
}
/**
* 输入栏:文本 / 图片 / 附件 / **语音**(契约 v0.96 §3.15 ①)。
*
* ## 三段语音形态各占一行(从上到下)
* 1. [VoiceReplyRow]——她的语音回复(`voice_output` 的产物)。放在最上面,因为它说的是
* 「刚刚那一轮的答复」,而下面几行是用户还要做的动作;
* 2. [VoiceDraftRow]——一段录好但没发出去的语音(重发 / 丢弃);
* 3. **录音中**:整行换成 [VoiceRecordingRow](录音中 / 取消 / 发送)。
*
* ## 录音中为什么把整行换掉
* 录音是一条有去无回的动作:那一行里同时留着一个「发送」按钮(文本的)与一个「结束录音」按钮,
* 手指落错就是把半句话当成文本发出去。换掉之后**文本发送那条路一个字节都没变**(不录音时
* 逐字与之前相同),而录音中屏幕上有且只有与该动作有关的两个选择。
*
* ## 语音入口为什么放在「发送」左边
* 与图片 / 附件同一侧(都是「加内容」),发送键仍在最右(肌肉记忆不变)。麦克风常驻——
* 它是与打字并列的**另一种输入方式**,不是某个附加能力。
*/
@Composable
private fun InputBar(
value: String,
imageUri: String?,
images: List<String>,
documents: List<DraftDocument>,
busyText: String,
recordingMs: Int?,
voiceDraft: VoiceDraftInfo?,
voiceReply: VoiceReply?,
voicePlayback: VoicePlaybackState,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onCancelRecording: () -> Unit,
onRetryVoice: () -> Unit,
onDiscardVoice: () -> Unit,
onToggleVoiceReply: () -> Unit,
onDismissVoiceReply: () -> Unit,
onPickImage: () -> Unit,
onClearImage: () -> Unit,
onRemoveImage: (Int) -> Unit,
onPickDocument: () -> Unit,
onRemoveDocument: (Int) -> Unit,
onPreviewDocument: (Int) -> Unit,
@@ -764,7 +1133,26 @@ private fun InputBar(
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
imageUri?.let { DraftImagePreview(uri = it, onClear = onClearImage) }
// 她的语音回复:最上面一行,因为它说的是「刚刚那一轮的答复」。
voiceReply?.let { reply ->
VoiceReplyRow(
reply = reply,
playback = voicePlayback,
onTogglePlay = onToggleVoiceReply,
onDismiss = onDismissVoiceReply,
modifier = Modifier.padding(bottom = 6.dp),
)
}
// 录好但没发出去的那一段:只在 `voice_input` 没能上行时出现。
voiceDraft?.let { draft ->
VoiceDraftRow(
draft = draft,
onRetry = onRetryVoice,
onDiscard = onDiscardVoice,
modifier = Modifier.padding(bottom = 6.dp),
)
}
DraftImagePreviews(images = images, onRemove = onRemoveImage)
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
DraftDocumentChips(
@@ -772,45 +1160,64 @@ private fun InputBar(
onRemove = onRemoveDocument,
onClick = onPreviewDocument,
)
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口只在没有待发图片时出现:已经选好一张就没必要再添一张(一次一条消息一张图)。
if (imageUri == null) {
IconButton(onClick = onPickImage) {
if (recordingMs != null) {
VoiceRecordingRow(
elapsedMs = recordingMs,
onCancel = onCancelRecording,
onSend = onStopRecording,
)
} else {
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.width(4.dp))
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 附件可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || imageUri != null || documents.isNotEmpty(),
) {
Text("发送")
Spacer(Modifier.width(4.dp))
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
IconButton(onClick = onStartRecording) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = VOICE_RECORD_DESCRIPTION,
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
}
}
}
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
@@ -845,45 +1252,107 @@ private fun DraftDocumentChips(
)
}
/** 待发图片的本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。 */
/**
* 待发图片的一排本地预览(`content://` URI,直接由 Coil 读本地文件,不联网)。
*
* 多选之后不再是「一张 + 一枚移除键」:每一张各自带一枚移除键,**顺序就是发送顺序**
* (第一条消息带第一张,见 [imageSendPlan])。刻意不做横向滚动——上限 4 张,一排放得下;
* 也不做「只显示前 N 张 + 一个计数」,那会让人以为自己选的某张不见了。
*/
@Composable
private fun DraftImagePreview(uri: String, onClear: () -> Unit) {
private fun DraftImagePreviews(images: List<String>, onRemove: (Int) -> Unit) {
if (images.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
AsyncImage(
model = uri,
contentDescription = "待发送的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = onClear,
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
images.forEachIndexed { index, uri ->
Box {
AsyncImage(
model = uri,
contentDescription = "待发送的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
IconButton(
onClick = { onRemove(index) },
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
imageVector = MiuixIcons.Basic.Close,
contentDescription = "移除图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
/**
* 空列表这一刻该不该给一个显式「重试」(**纯函数**,W52/E)。
*
* 三件事同时成立才给:**不在加载中**(在飞的时候给重试只会把同一趟请求发两遍)、
* **有一个错误挂着**、**列表是空的**。任一条不成立就不给——正常空会话(服务端真的没消息)
* 与正在加载首屏那两个时刻,屏幕上都不该多出一枚按了没什么用的按钮。
*
* ## 它**不**声称「这就是首屏失败」
* 「首屏有没有成功过」这件事只有 VM 里的 `firstPageState` 说得准,而按 W51 的纪律那个字段
* **只许由 `loadFirstPage()` 的两个出口写**([reconnectPull] 拿它当判据),所以这里刻意不去读它、
* 也不去镜像一份。这里只看**屏幕上成立的三件事**:什么都没显示、而且刚出过错。这三件事一起成立时,
* 用户能做的下一件事就是「再试一次」——按下去走的正是首屏那一份实现(`ChatViewModel.retryFirstPage`)。
*
* ⚠️ 已知的一处不精确:错误横幅上的那个 X 会把 `error` 清掉(`dismissError`),
* 于是按钮**跟着一起消失**。这是判据只看公开状态的代价,本轮按主人拍的口径留着。
*
* @param hasItems 列表里有没有东西(含乐观气泡与流式气泡——它们在也算「有东西」,
* 那时候屏幕不是空的,不该出现这枚按钮)。
*/
internal fun shouldOfferRetryOnEmpty(
loading: Boolean,
hasError: Boolean,
hasItems: Boolean,
): Boolean = !loading && hasError && !hasItems
/** 空列表 + 没出错:服务端就是没有消息,如实说一句(既有文案,一个字没改)。 */
private const val EMPTY_CONVERSATION_HINT = "还没有消息"
/**
* 空列表 + 挂着一个错误:消息没能加载出来(首屏 REST 失败那一档)。
*
* 与 [EMPTY_CONVERSATION_HINT] 分开是**如实**的要求:「还没有消息」说的是这个会话里什么都没有,
* 而这一刻本端其实**不知道**——它只是没拿到。原因由顶部那行错误横幅说,这里只说结论,
* 并配一枚「重试」。
*/
private const val FIRST_PAGE_FAILED_HINT = "消息没能加载出来"
/**
* 屏幕正中那句提示;[onRetry] 非空时在它下面多一枚 Miuix 的 `TextButton`。
*
* 按钮用的就是本文件输入栏「发送」那枚 `Button` 的同一个 Miuix 控件族里的 `TextButton`
* (编辑 / 删除弹层用的也是它):项目里没有 Miuix 的菜单类组件,而「凭空去猜一个没见过的控件」
* 是刻意避开的做法(见 `MessageActionMenu` 的 KDoc)。底部居中放一枚文字按钮,不抢正文区。
*/
@Composable
private fun CenterHint(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
private fun CenterHint(text: String, onRetry: (() -> Unit)? = null) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
if (onRetry != null) {
Spacer(Modifier.height(12.dp))
TextButton(text = "重试", onClick = onRetry)
}
}
}
@@ -986,7 +1455,9 @@ fun shareNoticeFor(share: SharedContent): String? {
if (!share.worthDelivering) return null
val parts = buildList {
if (share.truncatedImages > 0) {
add("分享的图片超过 ${ShareRules.MAX_IMAGES} 张,只发送前 ${share.images.size} 张")
// 这一句只有一处出处([ShareRules.truncatedImagesNotice]):图片选择入口与分享入口的
// 上限是同一个数,所以告知也共用同一句。
ShareRules.truncatedImagesNotice(share.images.size, share.truncatedImages)?.let(::add)
}
if (share.truncatedDocuments > 0) {
add("分享的文件超过 ${ShareRules.MAX_DOCUMENTS} 个,只发送前 ${share.documents.size} 个")
File diff suppressed because it is too large. Load diff
@@ -22,7 +22,9 @@ import androidx.compose.ui.window.Dialog
import top.yeij.cyrene.data.repository.CachedDocument
import top.yeij.cyrene.domain.chat.DocumentPreview
import top.yeij.cyrene.domain.chat.DocumentPreviewPlan
import top.yeij.cyrene.domain.chat.DocumentSource
import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.PreviewMetadata
import top.yeij.cyrene.domain.chat.PreviewSlice
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
@@ -85,6 +87,15 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false
* [PreviewSlice.notice] 非空时在正文上方明写「只显示前 N」并指路另外两条出口——**绝不静默
* 截断**:用户看到一段像是完整的正文却其实是前 256 KB,是最容易被误读的一种失败。
*
* ## 正文下方那一行元数据
* 正文(非文本时是那句人话)**下方**还有一行灰色小字,写的是「这份文件是什么」的格式事实:
* 文本给行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记,非文本给服务端嗅探所得的 mime。
* 组装全在纯函数 [PreviewMetadata] 里(**「为什么某一项不显示」也写在那里**),这里只取证据、画一行。
*
* 它与上方那枚芯片刻意**不重复**:体积与服务端状态芯片上已经写着(同一个数字、同一个词,再写
* 一遍只是噪音);字符数虽然也在芯片上,但芯片那个「8,000 字」没说清是谁的口径——本行补上
* 「(服务端注入口径)」,这才不算一句会把用户带偏的话。
*
* @param target 被点开的那一枚芯片(来源判定见 [DocumentTarget.of],弹层不自己判断)。
* @param plan 正文区能不能按文本渲染([DocumentTarget.previewPlan] 的产物,弹层不自己判断)。
* @param notice 「用其它应用打开 / 保存」这一步的如实告知(没有 App 能开、保存成功或失败);
@@ -135,22 +146,25 @@ fun DocumentPreviewDialog(
TextButton("重试", onClick = onRetry)
}
is DocumentPreviewState.Ready -> when (plan) {
DocumentPreviewPlan.Text -> {
state.slice.notice?.let { text ->
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(bottom = 8.dp),
)
is DocumentPreviewState.Ready -> {
when (plan) {
DocumentPreviewPlan.Text -> {
state.slice.notice?.let { text ->
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(bottom = 8.dp),
)
}
PreviewBody(state.slice)
}
PreviewBody(state.slice)
}
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
// 非文本:**正文区留空**,只留那句人话。字节其实已经取回来了(两条出口要用),
// 只是本端不会渲染它——把 PDF 的字节按 UTF-8 显示出来就是一屏乱码。
is DocumentPreviewPlan.NotText -> PreviewHint(plan.message)
}
PreviewMetadataLine(state = state, plan = plan, target = target)
}
}
@@ -199,6 +213,50 @@ fun DocumentPreviewDialog(
}
}
/**
* 正文**下方**那一行灰色小字:关于这份文件的格式元数据([PreviewMetadata] 的产物)。
*
* 组装全在纯函数里(逐档穷举单测),这里只做两件事:**取证据**、**画一行**。
* 取证据的两处来源都不许在 UI 层临场发挥:
* - 服务端那两件事实(注入字数 / 注入是否被截断)从[芯片模型][DocumentChipModel]里取——
* 它就是 DTO 那几字段的唯一换算落点([DocumentChips.of]);
* - 嗅探所得的 mime 只从 [DocumentSource.Server.mime] 取。本地待发附件**没有**这个结论
* (那时还没上传过),所以它这一项缺席——**不拿扩展名编一个**(`DocumentTarget.openType()`
* 对本地来源回的是 `application/octet-stream` 这个兜底值,拿它当「类型」会是本端编的话)。
*
* 拼出来是空串时**整行不画**(一项证据都没有就没有这一行),绝不留一个空行或一排「未知」。
*/
@Composable
private fun PreviewMetadataLine(
state: DocumentPreviewState.Ready,
plan: DocumentPreviewPlan,
target: DocumentTarget,
) {
val items = remember(state.slice, plan, target) {
when (plan) {
// 文本:行数 / 字符数(带口径)/ 解码口径 / 服务端的截断标记。
// `truncatedLabel` 非空 ⟺ DTO 的 `truncated == true`(见 DocumentChipModel)。
DocumentPreviewPlan.Text -> PreviewMetadata.textItems(
slice = state.slice,
serverCharsLabel = target.chips.charsLabel,
contextTruncated = target.chips.truncatedLabel != null,
)
// 非文本:只有服务端嗅探所得的 mime(体积与状态已在上面那枚芯片里)。
is DocumentPreviewPlan.NotText ->
PreviewMetadata.fileItems((target.source as? DocumentSource.Server)?.mime)
}
}
val line = PreviewMetadata.join(items)
if (line.isEmpty()) return
Spacer(Modifier.height(10.dp))
Text(
text = line,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
/** 正文区:按行分块、逐块可选中,块数上限由 [DocumentPreviewLimits] 保证。 */
@Composable
private fun PreviewBody(slice: PreviewSlice) {
@@ -54,6 +54,7 @@ import top.yeij.cyrene.domain.chat.ImageOpenTypes
import top.yeij.cyrene.domain.chat.ImageSize
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.ImageZoom
import top.yeij.cyrene.domain.chat.PreviewMetadata
import top.yeij.cyrene.domain.chat.ZoomPan
import top.yeij.cyrene.domain.chat.imageLoadFailureMessage
import top.yeij.cyrene.ui.common.PreferenceCard
@@ -145,6 +146,28 @@ fun ImagePreviewDialog(
)
}
// 预览内容下方那一行元数据要用的事实:像素与 EXIF 拍摄日期都得先读字节,所以放到 IO 上算。
// 键是**那一份缓存**:换一张图(或重试拿回新的字节)才重算,重组不会。
val facts by produceState(
initialValue = PreviewImageFacts(),
key1 = (state as? ImagePreviewState.Ready)?.cached,
) {
val cached = (state as? ImagePreviewState.Ready)?.cached ?: return@produceState
value = withContext(Dispatchers.IO) { readPreviewImageFacts(cached.bytes) }
}
val ready = state as? ImagePreviewState.Ready
val metadata = PreviewMetadata.join(
PreviewMetadata.imageItems(
// 「图片通配」不是格式:嗅探与平台声明都判不出来时这一项就缺席,
// **不拿 `image/*` 冒充一个答案**。
mime = ready?.openType?.takeIf { it != ImageOpenTypes.ANY_IMAGE },
width = facts.width,
height = facts.height,
sizeBytes = ready?.cached?.bytes?.size?.toLong() ?: PreviewMetadata.UNKNOWN_BYTES,
capturedAt = facts.capturedAt,
),
)
// 保存到下载目录:与附件那条路同一取舍——MIME 恒 */*,落盘的是缓存里那一份**原始字节**
// (不是重新编码过的位图,JPEG 存下来还是 JPEG)。
val createDocument = rememberLauncherForActivityResult(
@@ -237,7 +260,8 @@ fun ImagePreviewDialog(
}
ExitPane(
ready = state as? ImagePreviewState.Ready,
ready = ready,
metadata = metadata,
notice = notice,
note = nameNoteFor(source),
onOpen = { openElsewhere() },
@@ -344,12 +368,16 @@ private fun ZoomableImage(file: File, onUndecodable: () -> Unit) {
/**
* 底部那一列出口(与附件弹层同一形态)。
*
* 最上面那一行是**预览内容下方**的格式元数据([PreviewMetadata] 的产物,可能是空串——
* 那就整行不画,绝不留一个空行或一排「未知」);再下面是 [notice](刚才那个动作的结果)。
*
* 字节还没到位([ImagePreviewState.Loading] / [ImagePreviewState.Failed])时两条出口**不给点**:
* 点了也只能失败,不如让它们明摆着还没准备好。但**关闭**任何时候都在(右上角那个按钮)。
*/
@Composable
private fun ExitPane(
ready: ImagePreviewState.Ready?,
metadata: String,
notice: DocumentPreviewNotice?,
note: String,
onOpen: () -> Unit,
@@ -360,6 +388,14 @@ private fun ExitPane(
.fillMaxWidth()
.padding(top = 10.dp, bottom = 12.dp),
) {
if (metadata.isNotEmpty()) {
Text(
text = metadata,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(horizontal = EXIT_TEXT_MARGIN, vertical = 2.dp),
)
}
notice?.let { line ->
Text(
text = line.message,
@@ -0,0 +1,70 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.ShareRules
/**
* 一次发送切出来的一条消息([imageSendPlan] 的元素)。
*
* [imageUri] 是这条消息要带的那张图(契约里 `chat.image` 是**单图字段**);[documents] 是这条
* 消息要带的非图片附件(`chat.documents` 是数组,但一次只挂在一条消息上)。
*/
data class PlannedMessage(
val text: String,
val imageUri: String?,
val documents: List<DraftDocument>,
)
/** 一次发送的切分结果([imageSendPlan] 的返回值)。 */
data class ImageSendPlan(
/** 要投递的消息,顺序 = 用户选图的顺序。**至少一条**:没有图时它就是承载正文与附件的那条。 */
val messages: List<PlannedMessage>,
/** 超过 [ShareRules.MAX_IMAGES] 而**不会**发出去的张数(0 = 一张没少)。 */
val truncatedImages: Int,
) {
/** 这次真正会发出去的图片(顺序不变)。超限告知里说的「前 N 张」,N 就是它的长度。 */
val imagesToSend: List<String> get() = messages.mapNotNull { it.imageUri }
}
/**
* 「一次发送(正文 + N 张图 + 若干附件)要切成几条消息、每条带什么、超限几张」——**纯函数**,
* 与 Android 无关、可单测(见 `ImageSendPlanTest`)。
*
* ## 为什么 N 张图 = N 条消息
* 契约里 `chat.image` 是**单图字段**:一次上行只能带一张。所以多选进来的 N 张只能落成 N 条消息,
* 顺序 = 用户选图的顺序,而**第一条**带上正文与全部附件——正文是用户打的这一段字、附件是挂在
* 这条消息上的数组,两者本来就只该出现一次。第二条起每条只有一张图、正文为空:空正文的那几条
* 不会凭空替用户多说一个字。
*
* ## 上限只在这里判一次
* 上限 [ShareRules.MAX_IMAGES] 与分享路径用**同一个数**(4):两条路都是「一次最多接住几张图」。
* 多出来的张数由 [ImageSendPlan.truncatedImages] 如实报出来,调用方据此说一句
* (文案 [ShareRules.truncatedImagesNotice])——**绝不静默截断**。
*
* ## N = 0 为什么也回一条
* 没有图时这一次发送仍然是一条普通消息(正文 + 附件),它是同一条规则的空形态。把「有没有东西
* 可发」留给 [hasSendableContent] 判,这里只回答「要切几条、每条带什么」——两个判据各管一件事,
* 谁也不替谁下结论。空串 / 重复项由调用方在入口处清掉(草稿与分享两条入口本来就各自清了)。
*/
fun imageSendPlan(
text: String,
images: List<String>,
documents: List<DraftDocument>,
): ImageSendPlan {
val kept = images.take(ShareRules.MAX_IMAGES)
val messages = if (kept.isEmpty()) {
listOf(PlannedMessage(text = text, imageUri = null, documents = documents))
} else {
kept.mapIndexed { index, uri ->
PlannedMessage(
text = if (index == 0) text else "",
imageUri = uri,
documents = if (index == 0) documents else emptyList(),
)
}
}
return ImageSendPlan(
messages = messages,
truncatedImages = images.size - kept.size,
)
}
@@ -24,24 +24,46 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.data.api.dto.DocumentRefDto
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.canDeleteMessage
import top.yeij.cyrene.data.repository.canEditMessage
import top.yeij.cyrene.domain.chat.DocumentChipModel
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageHostRebase
import top.yeij.cyrene.domain.chat.TTS_LOADING_DESCRIPTION
import top.yeij.cyrene.domain.chat.TTS_SPEAK_DESCRIPTION
import top.yeij.cyrene.domain.chat.TTS_STOP_DESCRIPTION
import top.yeij.cyrene.domain.chat.TurnPhase
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.hasVoice
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.isVoiceMessage
import top.yeij.cyrene.domain.chat.ttsClipIdOf
import top.yeij.cyrene.domain.chat.ttsTextOf
import top.yeij.cyrene.domain.chat.voiceFallbackNote
import top.yeij.cyrene.domain.chat.voiceNote
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.InlineRun
import top.yeij.cyrene.domain.parse.InlineStyle
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yukonga.miuix.kmp.basic.Icon
@@ -50,7 +72,13 @@ import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.More
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/** 流式进行中的光标。 */
private const val CURSOR = "▍"
@@ -64,6 +92,11 @@ private val MESSAGE_ACTION_ICON_SIZE = 18.dp
/** 「⋯」与来源角标之间的间距。 */
private val MESSAGE_ACTION_GAP = 4.dp
/**
* 气泡外侧那枚时间与气泡之间的空隙:时间贴气泡,但不贴成一体(也不必去够屏幕边)。
*/
private val MESSAGE_TIME_GAP = 6.dp
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
@@ -84,6 +117,15 @@ private const val CONTENTLESS_MESSAGE_NOTE = "这条消息的内容没有被传
*
* 判据刻意**窄**,只有 `user` 消息才可能成立:
* - 任何一项非空都不成立——`content` 里哪怕只有一个空格也算有内容(`isBlank` 只吃掉纯空白);
* - **`voice` 键在就一律不成立**([hasVoice]):这一条问的是「这条里**有没有东西可看**」,不是
* 「有没有语音形态」,所以两档都要挡住:
* - `status = empty` / `transcribed`(语音形态画得出来):转写文本可能为空,但那条消息**不是
* 什么都没有** —— 它有一个语音形态(`VoiceMessageRow` 会把它画出来)。少了这一档,一条
* 「微信语音、没认出发音、也没有音频副本」的消息会被扣上「内容没有被传过来」这顶帽子,
* 而那句话是**假的**;
* - `status = failed`(入站转写失败 / 出站合成降级):**没有**语音形态,但有一条正文
* (服务端那行诚实提示 / 降级后的原文本)和那行降级小标([voiceFallbackNote] 的产物)——
* 同样不是「什么都没有」;
* - `assistant` 一律不成立:她的空消息是另一个话题(可能是流式还没到、也可能是有意不说),
* 顺手改会波及所有助手气泡,不在本次范围内。
*
@@ -96,7 +138,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
message.content.isBlank() &&
message.image.isNullOrBlank() &&
message.documents.isEmpty() &&
message.thinking.isNullOrBlank()
message.thinking.isNullOrBlank() &&
// 判据只有一处(domain.chat.hasVoice):绝不在这里另写一个 `voice != null`。
// ⚠️ 用 hasVoice 而不是 isVoiceMessage:failed 行(转写失败 / 合成降级)也**不是**什么都没有
// —— 它有正文(服务端那行诚实提示 / 降级后的原文本)与那行降级小标(voiceFallbackNote)。
!hasVoice(message)
/**
* 一条落库消息渲染成一个或多个气泡:
@@ -105,11 +151,34 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* - 其它 mode 原样整条渲染,不解析标签;
* - `thinking` 非空时(来自 REST `/messages`,owner 可见)在气泡上方显示思维链卡片;
* - `documents` 非空时在气泡上方显示附件芯片(绝大多数消息是空列表,此时一个像素都不画);
* - `voice` 键在时(🎤 判据 = **这个键存在且 `status != "failed"`**,契约 §4.4 + 后端 M57-M59
* 预告,见 `isVoiceMessage`)在正文之外多画一枚语音形态([VoiceMessageRow]:播放 / 暂停 + 时长,
* 有副本时才有播放控件);正文照常渲染——转写文本 / 服务端那行诚实提示都在里面,一个字都不改;
* `status = "failed"` 那一档(入站转写失败 / 出站合成降级)**没有**语音形态可画,改为在正文之外
* 多画一行灰色小标([VoiceFallbackNote],文案由 `voiceFallbackNote` 给:固定前半句 +
* 服务端 `fallbackReason` 原样);
* - `image` 非空时在气泡上方显示图片,**点它进全屏查看**([onImageClick]);渲染前先过
* [ImageHostRebase](见 [imageBaseUrl]);
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
*
* ## 外侧那枚时间(W69)
* 每条**落库**消息在气泡外侧再画一枚灰色小字(主人:用户的气泡靠右 ⇒ 时间贴气泡左边;她的气泡
* 靠左 ⇒ 时间贴气泡右边):文案由纯函数 [messageTimeLabel] 给(今天 `14:30` / `昨天 09:05` /
* `3月7日 09:05` / 跨年 `2025年12月31日`),布局与宽度取舍见 [BubbleWithTime]。
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
*
* 挂在哪一块由纯函数 [messageTimeCarrier] 给:先找**最后一个有正文的气泡**(W69 原本那一档);
* 这条**一个正文气泡都没有**时,改挂到**最后画出来的那个附件块**上(附件芯片排 / 图片 / 语音那一块
* ——语音形态或 `failed` 行的降级小标,`voice` 键在就必画其一)
* ——它旁边那一块就是这时唯一的位置参照物。少了这一档,一条只有附件的消息会因为
* `bubbles` 里那个 `body` 为空的占位气泡(`Bubble` 见空 body 就什么都不画)而**整条没有时间**
* (W72 补的就是它)。
*
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
*
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
*
@@ -122,8 +191,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 恰恰可能不响。因此多给一个看得见、点得着的入口,长按照旧保留(气泡内边距、附件芯片、
* 图片这些不参与选字的区域按下去就是菜单)。
*
* 哪些动作可用由**产品口径**的纯函数给出([canEditMessage] / [canDeleteMessage]),
* 与 VM 里那道闸是同一份判据;两个都不成立时(未知 role)两个入口都不出现。
* 哪些动作可用由纯函数给出([canEditMessageOnServer] / [canDeleteMessageOnServer]:角色口径
* 加上「这条在服务端存在吗」),与 VM 里那道闸是同一份判据;两个都不成立时两个入口都不出现
* ——未知 `role` 是一档,**本端自铸的降级气泡**(`proactive-local:` 前缀,服务端根本没有这一行)
* 是另一档。后者只是不给入口:气泡的**选字**照旧(它是 `SelectionContainer` 那条独立的路,
* 与动作菜单无关)。
*
* @param imageBaseUrl 当前的服务器地址(`AuthManager.currentBaseUrl`)。只用来把历史图片里**绑死在
* 上传那一刻 host 上的同源绝对地址**换到现在的 host(`02/F6①`:`POST /upload` 的 `url` 按请求时的
@@ -135,6 +207,15 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
* @param onDelete 用户选中「删除」:交给上层打开删除确认(不可恢复,必须二次确认)。
* @param voicePlayback 那条**播放通道**此刻的状态(`ui.chat.VoicePlayer` 的 `state`)。只有指出
* 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认
* [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。
* @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。
* @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。
* 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。
* @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是
* `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。
* @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。
*/
@Composable
fun MessageBubble(
@@ -142,10 +223,15 @@ fun MessageBubble(
imageBaseUrl: String = "",
phase: TurnPhase = TurnPhase.Sent,
legBadge: String? = null,
voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle,
ttsClip: TtsClip? = null,
ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle,
onDocumentClick: (DocumentRefDto) -> Unit = {},
onImageClick: (String) -> Unit = {},
onEdit: (HistoryMessageDto) -> Unit = {},
onDelete: (HistoryMessageDto) -> Unit = {},
onVoiceToggle: (HistoryMessageDto) -> Unit = {},
onSpeak: (HistoryMessageDto) -> Unit = {},
) {
val isUser = message.role == "user"
// 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同:
@@ -157,8 +243,8 @@ fun MessageBubble(
parsed.ifEmpty { listOf(plainBubble(message.content)) }
}
val editable = canEditMessage(message)
val deletable = canDeleteMessage(message)
val editable = canEditMessageOnServer(message)
val deletable = canDeleteMessageOnServer(message)
val canAct = editable || deletable
// 每个气泡各自记住菜单开着没有:重建 / 滚动复用不会把上一个气泡的菜单带过来。
var menuOpen by remember(message.id) { mutableStateOf(false) }
@@ -167,6 +253,34 @@ fun MessageBubble(
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
val gray = phase == TurnPhase.Answering
// 气泡**外侧**那枚时间(W69)。它在组合期按当下时刻算一次,**不 remember**:文案是
// (时间戳, 现在, 时区) 的函数,而「现在」只在重组时才有机会更新——冻结它就等于让跨天之后
// 还挂着昨天的口径。代价是「页面开着不动、跨度又恰好跨过零点」时它不会自己变,
// 不为这一件事加一个 ticker(见 [messageTimeLabel])。
val outsideTime = messageTimeLabel(
createdAt = message.createdAt,
nowMillis = System.currentTimeMillis(),
)
// 时间挂在哪一块:判据全在纯函数里([messageTimeCarrier],可单测)。原来是「最后一个有正文的
// 气泡」(而不是「列表最后一项」——解析出的末段偶尔只有旁白,那一项不画正文框,挂上去时间就
// 没有落脚点),于是**纯附件消息**(有 documents 或 image、`content` 为空 ⇒ 一个正文气泡都没有)
// 那一行根本没被画出来。
val timeCarrier = messageTimeCarrier(message, bubbles)
// —— 朗读(W75)——
// 「这条消息有没有可念的正文」:判据与 VM 发请求用的是**同一个纯函数**([ttsTextOf]),
// 所以「画了入口」与「发得出去」永远不会分叉。这里喂的是**已经解析好的** `bubbles`:
// 它们就是屏幕上这些气泡,比让纯函数再解析一遍更贴近「用户看到的那段字」。
// 只给她的消息(`assistant`)——主人的话不该用她的声音念回来,VM 里还有第二道闸。
val speakable = !isUser && remember(bubbles) { ttsTextOf(bubbles) != null }
// 本条自己的那份朗读状态:`messageId` 对不上的(用户点了别的一条)一律当没点过。
val ownClip = ttsClip?.takeIf { it.messageId == message.id }
val ownClipId = remember(message.id) { ttsClipIdOf(message.id) }
val speakActive = ttsPlayback.isActiveFor(ownClipId)
// 「正在准备」有两个来源:VM 那边请求还在飞(网络 / 合成,绝大多数时间在这里),以及播放通道
// 正在取字节(本端落好的文件是直接给的,这一档只是一闪而过)。
val speakPending = ownClip?.pending == true || ttsPlayback.isLoadingFor(ownClipId)
MessageGroup(
isUser = isUser,
footer = bubbleFooter(
@@ -176,19 +290,80 @@ fun MessageBubble(
shared = isSharedMessage(message),
),
onOpenMenu = openMenu,
onSpeak = if (speakable) { { onSpeak(message) } } else null,
speakActive = speakActive,
speakPending = speakPending,
ttsFailure = ownClip?.failure,
) {
message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) }
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
// 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文
// 气泡都没有时**,它们中的一个就是时间的落脚点。不是那一个时 `time = null`,
// [BubbleWithTime] 连 `Box` 都不建 ⇒ 与 W69 之前逐像素相同。
if (message.documents.isNotEmpty()) {
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Attachments },
isUser = isUser,
) { side ->
DocumentChipRow(
chips = message.documents.map(DocumentChips::of),
modifier = side,
alignEnd = isUser,
onClick = { index -> message.documents.getOrNull(index)?.let(onDocumentClick) },
)
}
}
message.image?.takeIf { it.isNotBlank() }?.let { ImageHostRebase.apply(it, imageBaseUrl) }
?.let { ChatImage(it, onImageClick) }
?.let { image ->
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Image },
isUser = isUser,
) { side ->
ChatImage(model = image, onClick = onImageClick, modifier = side)
}
}
message.voice?.let { voice ->
// 语音形态画在正文**之外**(正文那条路一个字都不动):转写文本 / 服务端那行诚实提示
// 照常由下面的气泡渲染。「无副本就只留文本、不画播放控件」与「durationMs 缺席就不显示
// 时长」两条判据都在 VoiceMessageRow 里(它自己调纯函数),这里不重复判一次。
if (isVoiceMessage(message)) {
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
isUser = isUser,
) { side ->
VoiceMessageRow(
voice = voice,
messageId = message.id,
playback = voicePlayback,
note = voiceNote(voice.status, message.content),
alignEnd = isUser,
onToggle = { onVoiceToggle(message) },
modifier = side,
)
}
} else {
// status == "failed":这条**没有可播的声音**(入站转写失败 / 出站合成降级)。
// 正文照常按文字渲染(下面那条路),这里只多画一行灰色小标 —— **不**画语音形态:
// 画出来就是一个必然播不了的控件(见 isVoiceMessage 的 KDoc)。
// 没有这一行,这一条与普通文字消息逐像素相同,看的人无从知道它本来是一段声音。
voiceFallbackNote(voice)?.let { note ->
BubbleWithTime(
time = outsideTime.takeIf { timeCarrier == MessageTimeCarrier.Voice },
isUser = isUser,
) { side ->
VoiceFallbackNote(text = note, alignEnd = isUser, modifier = side)
}
}
}
}
if (isContentlessUserMessage(message)) {
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
ContentlessNote(onLongPress = openMenu)
BubbleWithTime(time = outsideTime, isUser = isUser) { side ->
ContentlessNote(onLongPress = openMenu, modifier = side)
}
} else {
// 时间挂在**最后一个有正文的气泡**外侧。`null` = 这条的落点在附件块上(或这条一个字都不画),
// 此时正文气泡一个都不带时间——时间挂在这条消息上一次,不跟着每个气泡重复。
val carrierIndex = (timeCarrier as? MessageTimeCarrier.TextBubble)?.index
bubbles.forEachIndexed { index, bubble ->
Bubble(
bubble = bubble,
@@ -197,6 +372,7 @@ fun MessageBubble(
showCursor = gray,
onLongPress = openMenu,
gray = gray,
sideTime = outsideTime.takeIf { index == carrierIndex },
)
}
}
@@ -228,6 +404,39 @@ fun MessageBubble(
*/
const val SHARED_MESSAGE_BADGE = "[用户分享]"
/** 六态 ③ 的角标文案(规范 §1.1:「排队 ≠ 被受理」,语气与「发送中…」并列、不与「已读」混同)。 */
const val QUEUED_BADGE = "排队中"
/**
* 「这条还没连上、留在本端等连接」的小标(W40):接在 [QUEUED_BADGE] 后面,合成
* 「排队中 · 还没连上」。
*
* 为什么要在气泡上(而不只是那一句柔和告知):告知八秒就收走了,而「这条还在等连接」是个会持续
* 几十秒的**状态**——牌子撤掉之后,光看「排队中」分不出这是「她已经收下、只是前面还排着队」
* 还是「连都没连上、一个字节都没上路」。两者对用户意味着不同的事(前者会照常回复,后者要等网络),
* 所以状态本身要一直挂在气泡上。
*
* 语气刻意与 [QUEUED_BADGE] 一致(都是陈述,不是警告):没连上不是错误,内容也一条没丢。
*/
const val AWAITING_CONNECTION_BADGE = "还没连上"
/**
* 乐观气泡下方那一行小标的文案(**纯函数**,可单测)。
*
* [awaitingConnection] 只在 ③ 上成句,而且是**加在既有小标之后**的四个字(见
* [AWAITING_CONNECTION_BADGE]):这条消息在服务端**还没有任何身份**(没入队、没落库),
* 与「她忙、这条已受理排队」必须看得出区别。刻意**不新增第七态**([TurnPhase] 的六个取值是
* W30 定下的口径,见 `ChatUiState.phaseOf`),所以这里只是给同一个 ③ 补一句更准的话。
*
* ① 发送中时它一定是 false(没连上就不可能是「发送中」),这里也按 `when` 的顺序把它挡在
* ③ 之外:真的传进 `true` 也只会说「发送中…」——不为了一个不该发生的组合编一句话。
*/
fun pendingBubbleFooter(phase: TurnPhase, awaitingConnection: Boolean): String = when {
phase != TurnPhase.Queued -> "发送中…"
awaitingConnection -> "$QUEUED_BADGE · $AWAITING_CONNECTION_BADGE"
else -> QUEUED_BADGE
}
/**
* 气泡下方那一行角标的文案(纯函数,可单测)。
*
@@ -256,7 +465,7 @@ fun bubbleFooter(
val parts = buildList {
legBadge?.let { add(it) }
when (phase) {
TurnPhase.Queued -> add("排队中")
TurnPhase.Queued -> add(QUEUED_BADGE)
TurnPhase.Answering -> add("已读,正在回答…")
else -> Unit
}
@@ -266,6 +475,147 @@ fun bubbleFooter(
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* 一条消息**外侧**那枚灰色时间的文案(纯函数,可单测);`null` = 这条一个字都不画。
*
* 四档,全部按**本地日历日**分(不是「相差不到 24 小时」那种算法:`23:59` 与次日 `00:01` 只差
* 两分钟,却已经跨天,这一档必须是「昨天」):
* - **今天**(与 `nowMillis` 落在同一个本地日期)→ `14:30`;
* - **昨天** → `昨天 09:05`;
* - **今年内更早** → `3月7日 09:05`(月日不补零,时分补零);
* - **跨年** → `2025年12月31日`。**刻意不带时分**:一年前的消息精确到分没有意义,年月日已经
* 把「多久远」说清了,多四个字符反而更难扫。
*
* ## 未来时间:按「今天」那一档显示(不造「明天」这个词)
* 服务端时钟略快 / 本机时钟略慢时会出现几秒到几分钟「之后」的时间戳,它没有任何要表达的语义
* (这条就是刚刚到的),所以只显示 `HH:mm`——那正是这条消息发生的那一刻。判据因此写成
* `!day.isBefore(today)` 而不是 `day == today`:顺手也把「服务端时钟整整偏了一天 / 一年」这种
* 坏时钟吃掉——宁可只显示一个时刻,也不在用户眼皮底下编出一个他从没见过的「明天」或假日期。
*
* ## 非法 / 缺失(`<= 0`):整条不画
* 这个字段是**毫秒 Unix 时间戳**;`0` / 负数只可能来自「这个键根本没传过来」或解析事故,不是
* 一个真实时刻。画成 `1970年1月1日` 或 `--:--` 都是在替服务端编一个它没说过的答案;而一条没有
* 时间的消息并不缺什么(正文、来源角标、动作入口都还在,见 [MessageBubble])。
*
* ## 为什么在 UI 层算,而不是让 VM 把文案算好塞进 item
* 它只依赖(这条的时间戳,现在,时区)——前两个 UI 层本地就有。为了一个「现在」去动 item 模型与
* 列表的 key 不划算,而且那样还会把文案**冻住**:同一秒里算出来的字符串被 diff 当成同一个 item,
* 跨天之后那一屏就永远停在旧口径。
*
* `zone` 可注入(默认本机时区),`nowMillis` 由调用方喂:这条映射自己不读表,所以可单测、可复现
* (与 [searchHitTime] 同一条写法)。
*/
fun messageTimeLabel(
createdAt: Long,
nowMillis: Long,
zone: ZoneId = ZoneId.systemDefault(),
): String? {
if (createdAt <= 0L) return null
val at = Instant.ofEpochMilli(createdAt).atZone(zone)
val day = at.toLocalDate()
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
return when {
// 今天**与未来**同一档(见 KDoc:未来只可能是时钟漂移,不造「明天」这个词)。
!day.isBefore(today) -> at.format(MessageClockFormat)
day == today.minusDays(1) -> "$YESTERDAY_PREFIX ${at.format(MessageClockFormat)}"
day.year == today.year -> at.format(MessageDayFormat)
else -> at.format(MessageDateOnlyFormat)
}
}
/**
* 「今天」那一档,同时也是其余各档拼时分的零件:24 小时制、补零。
*
* `Locale.ROOT`:刻意不跟随设备 `locale`——这几档文案的形状是固定的(「3月7日」不会因为系统语言
* 变成别的写法),不给本地化留口子。
*/
private val MessageClockFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
/**
* 「今年内更早」那一档:`3月7日 09:05`(月日**不**补零,与 [messageTimeLabel] 的例一致)。
*
* 两处模式字母都要盯住:**`d`** 才是「月里的第几天」,大写 **`D`** 是「一年里的第几天」
* (`M'月'D'日'` 会把 8 月 14 日排成「8月226日」——W69 的单测就是在这一步拦下的)。
*/
private val MessageDayFormat = DateTimeFormatter.ofPattern("M'月'd'日' HH:mm", Locale.ROOT)
/** 「跨年」那一档:`2025年12月31日`(刻意不带时分)。 */
private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'd'日'", Locale.ROOT)
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
private const val YESTERDAY_PREFIX = "昨天"
/**
* 这条消息的时间**落在哪一块**上([messageTimeCarrier] 的返回值)。
*
* 之所以要给它一个名字,是因为「时间挂在正文气泡上」与「时间挂在附件块上」在 [MessageBubble] 里
* 是**两处不同的调用点**(前者由 [Bubble] 经 `sideTime` 代画,后者各自包一层 [BubbleWithTime]),
* 而某条消息只能落一个地方。判据本身是纯函数,所以「纯附件消息也有时间」这件事能被单测直接钉住,
* 不必去跑一个 Compose 测试。
*/
sealed interface MessageTimeCarrier {
/** 正文气泡:`bubbles` 里的下标([MessageBubble] 解析出来的第几个气泡)。 */
data class TextBubble(val index: Int) : MessageTimeCarrier
/** 附件芯片排(`documents` 非空才会画)。 */
data object Attachments : MessageTimeCarrier
/** 图片(`image` 非空才会画)。 */
data object Image : MessageTimeCarrier
/** 语音那一块(`voice` 键在才会画:语音形态,或 `failed` 行的降级小标)。 */
data object Voice : MessageTimeCarrier
}
/**
* 这条消息外侧那枚时间该挂在哪一块上(纯函数,可单测);`null` = 什么都不挂。
*
* ## 判据 = 渲染顺序里**最后一块真的画得出来**的东西
*
* [MessageBubble] 那一列从上到下的顺序是:思维链 → 附件芯片排 → 图片 → 语音那一块 → 正文气泡。
* 时间贴在**最后一块**旁边,读起来就是「这条消息发生在什么时候」的落款。
*
* 1. **先找最后一个有正文的气泡**(W69 原本那一档,一个字都不改)。为什么不是「列表最后一项」:
* 解析出来的末段偶尔只有旁白(`<action>` / `<narration>`),那一项不画正文框,挂上去时间就
* 没有落脚点。为什么不是第一个:一条多段消息的时间该落在它**结束**的地方。
* 2. **一个正文气泡都没有**时,按「谁最后画」往回退:语音那一块 → 图片 → 附件芯片排。这一档就是
* **纯附件消息**(`documents` 非空、`content` 为空):`content` 空 ⇒ 解析出来的那个占位气泡
* `body` 也是空 ⇒ [Bubble] 见空 body 什么都不画,于是 W69 的时间**整条没被画出来**
* (主人 2026-10-07 验收时发现的缺口)。
* 「语音那一块」按 `voice` 键判([hasVoice])而**不**按 [isVoiceMessage]:`failed` 行不画语音
* 形态,但会画那行降级小标([VoiceFallbackNote])——它也占着一块,也照样能挂时间。
* 3. 三块也都没有 ⇒ `null`:这条消息确实一个可视的块都没有。
*
* ## 谁**不**走这一档
* - **真的空气泡**([isContentlessUserMessage] 成立):上面三块按判据一定都不在,所以这里返回
* `null`;但那种消息**仍然有时间**——[MessageBubble] 把 [ContentlessNote] 自己包了一层
* [BubbleWithTime]。两处刻意分开:那块的落脚点由 `ContentlessNote` 那条路自己交代,不必让这个
* 函数认识它(它只答「附件块和正文气泡里挂哪个」)。
* - **思维链卡片**:它永远画在最上面,不可能是「最后一块」(有别的块时被别的块盖过,没别的块时
* 这条消息的真问题也不是时间)。不把它算进来 ⇒ 只有思维链的助手消息与 W69 之前逐字相同。
* - **流式 / 待发气泡**:它们根本不调这个函数(见 [BubbleWithTime] 的 KDoc)。
*
* 与角色无关:她侧(`assistant`)也会带附件与图,同一条规则照用。贴哪一侧由 [BubbleWithTime]
* 按 `isUser` 定,不在这里判——这一层只回答「挂在哪一块」,不回答「挂在它的哪一边」。
*
* @param bubbles [MessageBubble] 已经解析好的气泡列表(本函数不去 `parse` 一遍:那一份判据在
* `isChat` 上,重复算一次会多出「两份解析结果不一致」的可能)。
*/
fun messageTimeCarrier(
message: HistoryMessageDto,
bubbles: List<ParsedBubble>,
): MessageTimeCarrier? {
val lastBody = bubbles.indexOfLast { it.body.isNotEmpty() }
if (lastBody >= 0) return MessageTimeCarrier.TextBubble(lastBody)
// 与 [MessageBubble] 里画它们的顺序**相反**:最后画的那一块才是时间的落点。
return when {
hasVoice(message) -> MessageTimeCarrier.Voice
!message.image.isNullOrBlank() -> MessageTimeCarrier.Image
message.documents.isNotEmpty() -> MessageTimeCarrier.Attachments
else -> null
}
}
/**
* 乐观发送中的用户消息(还没落库)。
*
@@ -277,17 +627,22 @@ fun bubbleFooter(
* [phase] 只会是 ① 发送中或 ③ 排队中(乐观气泡还没有服务端 id,绑不到任何一路——
* 见 [ChatUiState.phaseOf])。③ 的角标文案按规范 §1.1 用「排队中」,语气与「发送中…」并列而不
* 与「已读」混同:**排队 ≠ 被受理**(规范 §1.3)。
*
* [awaitingConnection] 是 ③ 里更细的那一档(W40:**本端**因为没连上把这条留住了,一个字节都还
* 没上路),文案由纯函数 [pendingBubbleFooter] 给:接上「还没连上」四个字。判据在 VM 状态里
* ([ChatUiState.isAwaitingConnection]),这里只负责画。
*/
@Composable
fun PendingBubble(
item: PendingMessage,
phase: TurnPhase = TurnPhase.Sending,
awaitingConnection: Boolean = false,
onDocumentClick: (DraftDocument) -> Unit = {},
onImageClick: (String) -> Unit = {},
) {
MessageGroup(
isUser = true,
footer = if (phase == TurnPhase.Queued) "排队中" else "发送中…",
footer = pendingBubbleFooter(phase, awaitingConnection),
) {
DocumentChipRow(
chips = item.documents.map(DocumentChips::of),
@@ -295,6 +650,9 @@ fun PendingBubble(
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
)
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
// 语音:正文是空的(本端不转写,用户说的话要等服务端 STT 之后才落库),
// 所以这一条消息至少要有「这是语音、多长」这一枚可见凭据——与接收侧的语音形态同一个外观。
item.voiceDurationMs?.let { durationMs -> VoiceClipLabel(durationMs) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
@@ -458,6 +816,10 @@ private fun MessageGroup(
isUser: Boolean,
footer: String? = null,
onOpenMenu: (() -> Unit)? = null,
onSpeak: (() -> Unit)? = null,
speakActive: Boolean = false,
speakPending: Boolean = false,
ttsFailure: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
@@ -470,7 +832,7 @@ private fun MessageGroup(
content()
// 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。
// 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。
if (footer != null || onOpenMenu != null) {
if (footer != null || onOpenMenu != null || onSpeak != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP),
@@ -483,6 +845,31 @@ private fun MessageGroup(
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
// 「朗读」紧挨「⋯」:两枚都是这条消息的动作,分开放会让人以为是两个话题。
// 与「⋯」同一个尺寸、同一条 `Icon + clickable` 纪律(理由见下面那段注释)。
if (onSpeak != null) {
Icon(
imageVector = if (speakActive) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = when {
speakActive -> TTS_STOP_DESCRIPTION
speakPending -> TTS_LOADING_DESCRIPTION
else -> TTS_SPEAK_DESCRIPTION
},
// 正在响 ⇒ 主色(与语音形态那枚图标同一口径);其余是角标行那档灰。
tint = if (speakActive) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
// 正在准备时**点不动**:这时候再发一次请求没有任何意义,用户想要的是
// 「别再等了」——而那条路(取消在飞的合成)本端没有做,所以不给一个
// 点了没反应的入口(见报告「未做」)。
modifier = Modifier
.size(MESSAGE_ACTION_ICON_SIZE)
.clip(CircleShape)
.clickable(enabled = !speakPending, onClick = onSpeak),
)
}
if (onOpenMenu != null) {
// 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的
// 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了
@@ -499,9 +886,104 @@ private fun MessageGroup(
}
}
}
// 朗读没成功时那一句。挂在角标行**下面**、自成一行:塞进那一行会把来源角标挤成两截,
// 而这句话是给用户看的一整句,不该跟角标抢横向空间。
// 颜色用 `error` 而不是灰:它是一条**如实告知**(不是装饰),但也不弹窗、不打断——
// 用户的眼睛本来就在他刚点的那条消息上。
if (ttsFailure != null) {
Text(
text = ttsFailure,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
modifier = Modifier.padding(start = 6.dp, end = 6.dp),
)
}
}
}
/**
* 气泡本体 + 它**外侧**那一枚灰色时间(主人 W69):**用户**的气泡靠右 ⇒ 时间贴气泡**左边**;
* **她**的气泡靠左 ⇒ 时间贴气泡**右边**。
*
* ## 时间那一条的宽度是「量」出来的,不参与宽度分配
* 气泡可用宽度有限([BubbleBody] 自己封了 `300.dp` 的上限),而时间与气泡**同一行**,必然分掉
* 一点横向空间。这里**不让两者去抢 weight**,而是先用 [rememberTextMeasurer] 把这一行字按
* **它将要用的那个 [MiuixTheme.textStyles.footnote2]** 量一遍,拿到它排出来的**精确宽度**,
* 再用 `padding` 把这条宽度从气泡的可用宽度里**先扣掉**:
* - 时间量得准(同一份 `TextStyle`、同一份字体缩放与字体回退,量出来的就是排出来的那一串字——
* `Text` 内部只是在它自己那份 `style` 上 merge 显式参数,不参数字段一律不动),所以它**既不会
* 溢出屏幕、也不会被压成两行**;
* - 气泡吃**剩下的全部**宽度(`padding` 是在约束里扣的,气泡自己的 `widthIn(max = 300.dp)` 照旧
* 生效 ⇒ 实际可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`),所以长消息照旧用满能用的宽度。
* 代价写在明处:**有时间的消息,气泡比加这一块之前最多窄一条时间的宽度**(这是「时间与气泡同行」
* 的必然结果,也是主人要的那件事)。只有「极窄屏 + 超大字号」这一条极端组合下气泡才会明显变窄
* ——那时我们选择**如实把日期显示完整**:截断「2025年12月31日」比窄一点的气泡更亏。
*
* 为什么不用「把时间排在子节点前面、让它先占住宽度」这种零测量的写法:`Row` 给非 weight 子节点的
* 宽度是**按子节点顺序**递减的,所以「气泡在前」的那一侧(她那一侧)必然先把宽度吃光、把时间挤成
* 一条竖线;而 Compose 的 `Row` 只有「顺序摆放」一种排布,做不到「先量、后摆」。自写 `Layout` 当然
* 可以,但要自己接管窄屏下的钳制,而且那份宽度就是同一笔账——量一次是这里最小的一步。
*
* ## 摆在哪(三条都是「不许动气泡」的具体落实)
* - 时间在**气泡外边**:`padding` 在 `clip` / `background` 之外,所以它不是气泡的一部分——底色、
* 圆角、[SelectionContainer] 的选字范围、长按的点击区全都与加它之前逐像素 / 逐行为相同;
* - 时间与气泡**底对齐**:贴着气泡最后一行(气泡很高时它也不会跑到中间去);
* - 与气泡之间有 [MESSAGE_TIME_GAP] 的空隙,免得那串灰字和气泡贴成一体。
*
* 整块(时间 + 气泡)仍由 [MessageGroup] 那一列按角色对齐(主人靠右、她靠左),所以**气泡自己那
* 一侧**离屏幕的边距与没有时间时**逐像素相同**——时间填的正是原本空着的那条区域。
*
* @param time `null` = 这条消息不显示时间(时间戳缺失 / 非法,见 [messageTimeLabel]):此时**连
* `Box` 都不建**,整个气泡与加这一块之前逐像素相同——流式气泡与待发气泡走的正是这一档
* (后者本端还没有任何时间戳)。
*/
@Composable
private fun BubbleWithTime(
time: String?,
isUser: Boolean,
bubble: @Composable (Modifier) -> Unit,
) {
if (time == null) {
bubble(Modifier)
return
}
val measurer = rememberTextMeasurer()
val style = MiuixTheme.textStyles.footnote2
val density = LocalDensity.current
val strip = remember(measurer, density, time, style) {
with(density) {
measurer.measure(text = time, style = style, maxLines = 1, softWrap = false).size.width.toDp()
} + MESSAGE_TIME_GAP
}
Box {
bubble(
Modifier
.then(if (isUser) Modifier.padding(start = strip) else Modifier.padding(end = strip))
// 气泡贴回它自己那一侧:`strip` 已经吃掉了可用的余量,这里的对齐在正常宽度下是
// 恒等的(气泡就是最宽的那个孩子),留着是为了「万一条比气泡还宽」时也仍在原位。
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart),
)
Text(
text = time,
style = style,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
// 宽度已经量过、条也留够了,正常情况下一行必放得下;这两个参数是防「量完到排完之间
// 密度变了」这类不该发生的事——真发生也只是这串字自己被截,不会去挤气泡。
maxLines = 1,
softWrap = false,
modifier = Modifier.align(if (isUser) Alignment.BottomStart else Alignment.BottomEnd),
)
}
}
/**
* 一条消息在气泡层渲染成一个 / 多个气泡。
*
* [sideTime] 只由**携带时间的那个气泡**收到([MessageTimeCarrier.TextBubble] 指的那一个),
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。`body` 为空的气泡
* (比如纯附件消息解析出来的那个占位气泡)一个像素都不画,**连 [BubbleWithTime] 都不进**:
* 这也是纯附件消息会整条没有时间的原因(判据见 [messageTimeCarrier])。
*/
@Composable
private fun Bubble(
bubble: ParsedBubble,
@@ -510,10 +992,24 @@ private fun Bubble(
showCursor: Boolean = false,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
sideTime: String? = null,
) {
AnnotationLine(bubble.head)
val body = if (showCursor && isLast && bubble.body.isNotEmpty()) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) BubbleBody(body, isUser, onLongPress, gray)
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
val body = if (withCursor) bubble.body + CURSOR else bubble.body
if (body.isNotEmpty()) {
BubbleWithTime(time = sideTime, isUser = isUser) { side ->
BubbleBody(
text = body,
runs = bubble.runs,
isUser = isUser,
onLongPress = onLongPress,
gray = gray,
trailingCursor = withCursor,
modifier = side,
)
}
}
AnnotationLine(bubble.tail)
}
@@ -543,7 +1039,14 @@ private fun LegBadge(text: String, onClick: (() -> Unit)?) {
}
}
/** 旁白/动作:灰斜体小字,依附在气泡上/下。 */
/**
* 旁白/动作:灰斜体小字,依附在气泡上/下。
*
* 这一行**不做行内解析**(正文那四类标签在这里照原样显示)。理由是它读的是 `parts` 里的原文,
* 而分享预览那行读的是同一批原文(`ShareConfirmPanel.ShareConfirmAnnotation`)——那边是红线、
* 本次一个字都不能动。只在这边渲染,就等于让「预览里看到的 == 发出去之后看到的」在旁白上又裂一次,
* 而 W65 刚把这道缝补上。要连带做,得两处一起改并单独拍板(实测 `<action>` 里带 `<b>` 的 0 命中)。
*/
@Composable
private fun AnnotationLine(parts: List<ContentPart>) {
if (!LocalAppPrefs.current.showNarration) return
@@ -571,11 +1074,14 @@ private fun AnnotationLine(parts: List<ContentPart>) {
* ## 长按仍然开动作菜单
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`:
* 和 [BubbleBody] 一样,它是**气泡外边**的留白,不参与这张灰底方块的绘制与点击区。
*/
@Composable
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
private fun ContentlessNote(onLongPress: (() -> Unit)? = null, modifier: Modifier = Modifier) {
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = 300.dp)
.clip(BubbleShape)
.background(MiuixTheme.colorScheme.surfaceContainer)
@@ -609,16 +1115,35 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
* 这一态的语义是「这条已经在被处理,但还没有答案」,它既不是用户说的话(不该是主人的青色),
* 也不是她的回答(不该是助手的正常底色)。正文一个字都不改,只是**褪色**,让人一眼看出
* 「没丢,只是在等」。
*
* [runs] 非空时**只画 [runs]**(拼起来就是 `text` 去掉末尾光标那一截,见 [inlineBody]):
* 同一段正文不在这里再拼一遍,免得拼出两个版本的字。[runs] 为空 = 这段正文没有可渲染的行内标签,
* 走原来的 `Text(text)` 那条路(**绝大多数消息都是这一档,一个像素都不变**)。
*
* [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文,
* 不该继承她最后那一段的粗体/等宽底色。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
* 它排在这条链的**最前面**,所以那一层留白在 `clip` / `background` / `combinedClickable` 之外:
* 气泡自己的样子、选字范围、长按的点击区都还是原来的那一块。
*/
@Composable
private fun BubbleBody(
text: String,
runs: List<InlineRun>,
isUser: Boolean,
onLongPress: (() -> Unit)? = null,
gray: Boolean = false,
trailingCursor: Boolean = false,
modifier: Modifier = Modifier,
) {
val contentColor = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
}
Box(
modifier = Modifier
modifier = modifier
.widthIn(max = 300.dp)
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
@@ -646,19 +1171,74 @@ private fun BubbleBody(
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
SelectionContainer {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = when {
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
isUser -> MiuixTheme.colorScheme.onPrimary
else -> MiuixTheme.colorScheme.onSurfaceContainer
},
)
if (runs.isEmpty()) {
Text(
text = text,
style = MiuixTheme.textStyles.main,
color = contentColor,
)
} else {
Text(
text = inlineBody(
runs = runs,
codeBackground = if (isUser) {
MiuixTheme.colorScheme.onPrimary.copy(alpha = INLINE_CODE_ON_USER_BACKGROUND)
} else {
MiuixTheme.colorScheme.surfaceContainerHigh
},
trailingCursor = trailingCursor,
),
style = MiuixTheme.textStyles.main,
color = contentColor,
)
}
}
}
}
/**
* `<code>` 那点底色在**主人那侧**(青色气泡)的透明度:`onPrimary` 压一层浅底。
* 昔涟那侧直接用 `surfaceContainerHigh`——它本来就是比气泡底(`surfaceContainer`)高一档的填充色,
* 同一套配色里现成的一档,不另造颜色。
*/
private const val INLINE_CODE_ON_USER_BACKGROUND = 0.18f
/**
* 行内片段 → 一段 `AnnotatedString`:一个 `Text` 就同时承载粗体 / 斜体 / 下划线 / 等宽底色。
*
* **为什么不拼一串 `Text` 节点**:一条气泡本来就是**一整段可选中的文本**([SelectionContainer]
* 的行为一个字都不用改),折行位置也由同一个排版器一次定完——拆成多个节点会让可选性与折行
* 各自为政。`<br>` 已经不在这一层(它是气泡分隔符,切在解析层),所以这里不再有「插一个换行」
* 这种活。
*
* 样式只给**有**的那几项(`null` = 继承气泡自己的字重/字体/装饰),所以非 `<code>` 的片段与原来逐像素相同。
*
* `u` 落到 `TextDecoration.Underline`(六项白名单里的那一项,见 `InlineMarkup.kt` 的文件头);
* `s` 不在白名单里,所以这里**没有**删除线分支——写 `<s>甲</s>` 时那四个字符是按字面显示的正文,
* 走的是 `runs` 为空的那条路(`BubbleBody` 里的裸 `Text`)。
*/
private fun inlineBody(
runs: List<InlineRun>,
codeBackground: Color,
trailingCursor: Boolean,
): AnnotatedString = buildAnnotatedString {
for (run in runs) {
val code = InlineStyle.Code in run.styles
withStyle(
SpanStyle(
fontWeight = if (InlineStyle.Bold in run.styles) FontWeight.Bold else null,
fontStyle = if (InlineStyle.Italic in run.styles) FontStyle.Italic else null,
textDecoration = if (InlineStyle.Underline in run.styles) TextDecoration.Underline else null,
fontFamily = if (code) FontFamily.Monospace else null,
background = if (code) codeBackground else Color.Unspecified,
),
) {
append(run.text)
}
}
if (trailingCursor) append(CURSOR)
}
/**
* 气泡里的图片(缩略)。
*
@@ -668,13 +1248,17 @@ private fun BubbleBody(
* 涟漪不会溢出圆角);读屏也需要知道点它会发生什么,所以带上 `contentDescription`。
*
* 高度不设限:图片按比例撑开,气泡那一列本来就是纵向排列。
*
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
* 它排在这条链的**最前面**:那一层留白在 `widthIn` / `clip` / `clickable` 之外,所以图片自己的
* 尺寸上限、圆角与点击区都与加它之前逐像素 / 逐行为相同(与 [BubbleBody] 同一条纪律)。
*/
@Composable
private fun ChatImage(model: String, onClick: (String) -> Unit) {
private fun ChatImage(model: String, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = model,
contentDescription = "查看图片",
modifier = Modifier
modifier = modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(14.dp))
.clickable { onClick(model) },
@@ -0,0 +1,43 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.repository.canDeleteMessage
import top.yeij.cyrene.data.repository.canEditMessage
/**
* 这条消息在**服务端**有没有对应的一行(纯函数)。
*
* 判据只有一条:`id` 带不带 [PROACTIVE_LOCAL_ID_PREFIX]。那个前缀是本端给**降级气泡**
* (`proactive_message` 落库失败那一档,见 [ProactiveIngest.Bubble])自铸的,服务端的 id
* 永远不会长这样([proactiveIngestFor] 的 KDoc 说明了它是怎么来的)。
*
* 为什么这件事要单独成判据:一条消息「在服务端存在」是**打服务端那类动作的前提**。
* 拿一个服务端没见过的 id 去 `PATCH` / `DELETE`,最好的结果是 404,其余是一句用户看不懂的报错——
* 而这一切仅仅因为他长按了一下一条屏幕上看起来很正常的消息。
*
* **空 `id` 不在本判据的处理范围内**(今天的行为逐字不变):那属于「形状漂移」的坏帧,
* 是另一件事,不在本轮收口的范围里。
*/
fun isServerBackedMessage(message: HistoryMessageDto): Boolean =
!message.id.startsWith(PROACTIVE_LOCAL_ID_PREFIX)
/**
* 这条消息**能不能编辑**——在角色口径([canEditMessage])之上再叠一道「它在服务端存在吗」。
*
* 两道合起来才是界面与 VM 该用的判据:角色那一道回答「产品上允不允许改她说过的话」,
* 本判据回答「这一趟请求有没有意义」。任一道不成立都是**入口不出现**(fail-closed)。
*
* **只有会打服务端的动作走这里**:气泡里的文本选择 / 复制是纯本端的事
* (`SelectionContainer`,与动作菜单是两条独立的路),不归这两个函数管,也不该被它们挡住。
*/
fun canEditMessageOnServer(message: HistoryMessageDto): Boolean =
isServerBackedMessage(message) && canEditMessage(message)
/**
* 这条消息**能不能删除**——在角色口径([canDeleteMessage])之上再叠一道「它在服务端存在吗」。
*
* 降级气泡恰恰是个必须被挡住的例子:它是 `assistant` 行 ⇒ [canDeleteMessage] 会说「能删」,
* 但服务端根本没有这一行,删它只会用本地 id 打出一趟注定失败的请求(见 [isServerBackedMessage])。
*/
fun canDeleteMessageOnServer(message: HistoryMessageDto): Boolean =
isServerBackedMessage(message) && canDeleteMessage(message)
@@ -0,0 +1,74 @@
package top.yeij.cyrene.ui.chat
import android.graphics.BitmapFactory
import android.media.ExifInterface
import java.io.ByteArrayInputStream
/**
* 从本端持有的那份字节里**现读**出来的图片事实:像素与拍摄日期。读不到的就是 `null`。
*
* ## 为什么这一层不在 `domain/` 里
* [PreviewMetadata][top.yeij.cyrene.domain.chat.PreviewMetadata] 是纯函数(JVM 单测里逐档穷举),
* 而这里调的两个都是 Android 平台类(`android.graphics.BitmapFactory` / `android.media.ExifInterface`),
* 在 JVM 单测里是**抛异常的 stub**。所以切口落在这里:读平台 → 得到两个可能为 `null` 的数 →
* 交给纯函数组装成那一行。判断「显示什么」的逻辑一个字都不在平台类里,测试因此能覆盖全部档位。
*
* ## 分辨率为什么用 `inJustDecodeBounds`
* 为了一行小字把 20 MiB 的大图解成位图是不可接受的(内存 + 主线程耗时)。`inJustDecodeBounds`
* 只读文件头,`outWidth` / `outHeight` 就是答案,`decodeByteArray` 返回 `null` 是有意的。
*
* ## EXIF 用的是**系统 API**,不引新依赖
* `android.media.ExifInterface`(`InputStream` 构造自 API 24 起可用,本工程 `minSdk = 33`)。
* 它认不出的容器(PNG / GIF / WebP / BMP 这些没有标准 EXIF 的格式)只会让 `getAttribute` 回
* `null`——**那正是「拿不到就整项不缺席」要的答案**,不是错误。⚠️ 它**只读 JPEG 与 RAW**,
* 所以「所有图片都有拍摄日期」是错的预期,能报出来的基本只有相机/相册里的 JPEG。
*
* ## 日期为什么不在这里格式化
* EXIF 的 `DateTimeOriginal` 是**没有时区**的墙钟字符串(`yyyy:MM:dd HH:mm:ss`),本层**原样**
* 交给 [PreviewMetadata.formatExifDateTime][top.yeij.cyrene.domain.chat.PreviewMetadata.formatExifDateTime]
* 去严格校验与格式化——那一步是纯函数,能逐档单测,也能把「不做时区换算」这条取舍写在明面上。
*/
data class PreviewImageFacts(
val width: Int? = null,
val height: Int? = null,
/** EXIF `DateTimeOriginal` 的原始字符串;缺席即 `null`。 */
val capturedAt: String? = null,
)
/**
* 读出这两个事实。**任何一步失败都只是少一项**(`runCatching`):这一行是锦上添花,
* 它自己绝不许成为「图片打不开」的原因。
*
* 只从**已经在内存里**的那份字节读([top.yeij.cyrene.data.repository.CachedDocument.bytes],
* 预览与两条出口用的都是它),不再碰盘、不再联网。
*/
fun readPreviewImageFacts(bytes: ByteArray): PreviewImageFacts {
val bounds = readBounds(bytes)
return PreviewImageFacts(
width = bounds?.first,
height = bounds?.second,
capturedAt = readCapturedAt(bytes),
)
}
/**
* 文件头里的像素:任一侧 ≤ 0(解不出来 / 不是图片)就是 `null`。
*
* 与 `ZoomableImage` 里 Coil 报上来的那个尺寸是两回事:那个是**解码后**的位图尺寸,要等图片
* 真的解出来才有;这里是**打开弹层时立刻**就有(只看文件头)。两者一致是常态,不一致时以这里
* 为准——它描述的是文件本身。
*/
private fun readBounds(bytes: ByteArray): Pair<Int, Int>? = runCatching {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
val width = options.outWidth
val height = options.outHeight
if (width > 0 && height > 0) width to height else null
}.getOrNull()
/** EXIF 的 `DateTimeOriginal`;没有这个标签 / 不是 EXIF / 读不动,一律 `null`。 */
private fun readCapturedAt(bytes: ByteArray): String? = runCatching {
ByteArrayInputStream(bytes).use { stream ->
ExifInterface(stream).getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
}
}.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
@@ -0,0 +1,121 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.ws.WsEvent
/**
* 一帧 `proactive_message`(她主动说的话)**该怎么进本端**(纯函数 [proactiveIngestFor] 的结果)。
*
* 独立成一个类型而不是「返回一条 `HistoryMessageDto?`」,是因为两条并入路**外观相同、真相不同**:
* [Persisted] 那条是服务端历史里的行(按 id upsert,刷新后仍在),[Bubble] 那条只活在本端内存里。
* 上层要能对它们做不同的说明,测试也要能分别钉住。
*/
sealed interface ProactiveIngest {
/**
* **不并入本 VM**:这一帧不是给这个会话的(见 [proactiveIngestFor] 的归属判据),
* 或帧里没有任何可显示的东西。
*/
data object Skip : ProactiveIngest
/**
* **并入一条已落库的 assistant 行**(帧带落库行的权威 id)。
*
* 这就是 v0.72 之后她这句话的真相:它既是实时帧,也是主会话历史里的一行。走 [Persisted]
* 而不是画一个气泡,客户端才有「刷新 / 重开 / 切会话拉历史之后她的话还在」这条性质——
* 而重复也**不会**发生:并入走的是以 [HistoryMessageDto.id] 为键的 upsert
* (`mergeMessages`),同一条(实时帧 + 握手回放 + 分页)无论到几次都只有一行。
*/
data class Persisted(val message: HistoryMessageDto) : ProactiveIngest
/**
* **降级路径**:帧**不带** `id`(服务端那一步落库失败了,它自己就说了「这条没有历史行」)
* ⇒ 画一条只活在本端内存里的气泡,也好过什么都不显示。
*
* ⚠️ 它的代价必须说清:这条气泡**不在**服务端历史里,所以**刷新 / 重开 App / 切走再回来
* 都不会再有它**(那些时刻显示的是服务端的历史,而服务端没有这一行)。这是「如实显示一条
* 可能留不住的记录」而不是「假装它已落库」——正因如此它才与 [Persisted] 分开。
*/
data class Bubble(val message: HistoryMessageDto) : ProactiveIngest
}
/**
* 本端给降级气泡铸的 id 前缀。
*
* 一眼可辨(日志 / 截图上能认出这是本地行,不是服务端 id),也**永不**与服务端的 id 撞。
*/
internal const val PROACTIVE_LOCAL_ID_PREFIX = "proactive-local:"
/**
* 一帧 `proactive_message` 该不该并进 [sessionId] 这个 VM、以什么身份并入(纯函数,判据可单测)。
*
* ## 1. 归属:**只进主会话**(这一步不能省)
* 这一帧的帧壳**不带** `sessionId`(服务端构造时没设它,且 `ws_hub.go` 那个键是 `omitempty`),
* 而 [top.yeij.cyrene.data.ws.isForSession] 对空 `sessionId` 的口径是**放行**(空 = 无法判定,
* 不是「不属于本会话」)⇒ 同一批帧会投给**每一个**活着的 `ChatViewModel`。归属因此只能在这里
* 自己钉死:她主动说的话落的是**主会话**(服务端 `history.MainSessionID` = 客户端的
* [MAIN_SESSION_ID])。不钉就会**在每个次级会话里都冒出一条她的话**——比「看不到」更糟。
*
* 比较是**等值**、不是前缀:主会话 `web` 与次级会话 `web:xxx` 是两个会话。
*
* ## 2. 身份:有 `id` 就是已落库行,没有就是本地气泡
* 服务端**先落库、再发帧**,并把落库行的 `id` / `createdAt` 放进载荷;落库失败时这两个键
* 一起缺席。所以 `id` 的有无就是「这条在历史里存不存在」的判据,不需要别的猜测。
*
* ## 3. `createdAt` 的退化
* `id` 在、`createdAt` 却缺席或非正数(形状漂移)时用 [nowMillis]:帧是刚刚到的,按「现在」
* 排在最末就是它该在的位置;而服务端那一行是真的存在,下一次拉历史会用同一 id 把它连同真正的
* 时间戳一起覆盖回来(`mergeMessages` 的 incoming 胜出)。
*
* ## 4. 无 `id` 且无正文 ⇒ [ProactiveIngest.Skip]
* 这种帧里没有任何可显示的东西(落库失败的空帧),画一条空气泡只会让人以为出了别的事。
*
* 时钟与本地 id 都由调用方喂(判据自己不读表、不自铸随机数),所以这里可单测、可复现。
*/
fun proactiveIngestFor(
event: WsEvent.ProactiveMessage,
sessionId: String,
nowMillis: Long,
): ProactiveIngest {
if (sessionId != MAIN_SESSION_ID) return ProactiveIngest.Skip
val content = event.content
val id = event.id?.takeIf { it.isNotBlank() }
val createdAt = event.createdAt?.takeIf { it > 0 }
if (id != null) {
return ProactiveIngest.Persisted(
HistoryMessageDto(
id = id,
sessionId = MAIN_SESSION_ID,
role = ASSISTANT_ROLE,
content = content,
platform = event.platform?.takeIf { it.isNotBlank() },
createdAt = createdAt ?: nowMillis,
),
)
}
if (content.isBlank()) return ProactiveIngest.Skip
// 本地 id 用**帧里已有的稳定键**(IM 侧的消息号,其次是它的时间字面量)而不是随机数:
// 同一个键第二次到达就是一次 upsert(仍只有一条),而且日志 / 截图上能追回它是哪一帧来的。
// 随机 id 做不到这两件事——同样的帧再投一次(服务端重推 / 客户端重放)就会多出一个一模一样的气泡。
val localKey = event.messageId?.takeIf { it.isNotBlank() }
?: event.timestamp?.takeIf { it.isNotBlank() }
?: nowMillis.toString()
return ProactiveIngest.Bubble(
HistoryMessageDto(
id = PROACTIVE_LOCAL_ID_PREFIX + localKey,
sessionId = MAIN_SESSION_ID,
role = ASSISTANT_ROLE,
content = content,
platform = event.platform?.takeIf { it.isNotBlank() },
createdAt = createdAt ?: nowMillis,
),
)
}
/** 她主动说的话恒是 assistant(服务端 `persistProactiveToMain` 定死)。 */
private const val ASSISTANT_ROLE = "assistant"
@@ -0,0 +1,88 @@
package top.yeij.cyrene.ui.chat
/**
* 本会话的**首屏**(`GET /messages` 最新一页,[ChatViewModel.start] 那一次)走到哪儿了。
*
* 它存在的唯一理由是让「重连时要不要再拉一次首屏」这件事**判得出来**,而这里必须能分辨
* **三个**状态、不能只用一个「有没有数据」的位:
*
* - [Loading]:首屏那次**还在飞**。这一刻再发一次同样的请求就是白拉一次,更要紧的是它会把
* 「首屏还没回来」这件事伪造成「首屏失败了」——而那正好是 W49 / S3 那条窄窗口的时序,
* **本轮一个字节都不改**(见 [reconnectPull] 第 3 条)。
* - [Loaded]:成功过。哪怕服务端真的**就是**没有消息(`hasMore=false`、列表空),也是成功过——
* 所以「空会话」不该被读成「没拉到」(这正是「用有没有数据当判据」会踩的坑:每个空次级会话
* 都会在每次重连被整表重拉一次,而那正是 S4/S7 这一类问题的来源)。
* - [Failed]:确实没拿到(离线冷启动、超时、服务端错)。**只有这一档**才值得在连接恢复时补。
*/
internal enum class FirstPageState {
Loading,
Loaded,
Failed,
}
/** 连接恢复那一刻这一轮该做什么([reconnectPull] 的取值)。 */
internal enum class ReconnectPull {
/**
* 既有行为,一字未动:用掉线那一刻记下的 [ChatViewModel.gapCursor] 做一次 `after` 正向增量。
*/
Forward,
/**
* **本轮新增**:本端首屏从来没成功过,而这是**次级会话** —— 拉一次最新一页。
*
* 它补的就是 S2 登记的那个洞:进次级会话时 WS 未连(冷启动离线)⇒ 首屏那次 REST 也失败
* ⇒ 此后连接恢复**没有任何人补拉**,屏幕长期空着「还没有消息」,直到用户手动切走再回来。
*/
FirstPage,
/** 什么都不做。 */
Nothing,
}
/**
* 连接刚刚恢复(`WsState.Connected`)时这一轮该做什么(**纯函数**,VM 与测试共用)。
*
* ## 为什么次级会话需要它,而主会话不需要
* 主会话有**两条**安全网:握手回放 `history`(最近 50 条,服务端固定回放主会话,
* `ws_hub_conn.go` 写死 `replayHistory(MainSessionID)`)与 `after` 正向增量。次级会话**只有后者**
* ——`history` 帧里根本没有它的消息(`isForSession` 会把它们整帧滤掉)。于是「首屏没拿到 +
* 之后连接恢复」这个组合下,主会话被回放兜住,次级会话什么都没有。
*
* ⚠️ 所以这里的修法**不是**照抄主会话那条(给次级会话要 `history` 是改服务端的契约面),而是走
* **本项目自己那条 REST 补拉**:连接恢复时,如果本端首屏从来没成功过,就自己去拉一次最新一页。
*
* ## 判据与优先级(自上而下,先命中先返回)
* 1. [sessionKnownGone] ⇒ [ReconnectPull.Nothing]。**注定 404 的补拉一个都不发**(S7 ①):
* 这一位由两个来源合成——本端自己删过它(`GoneSessions`),或已经有了 `404 session_not_found`
* 的结论([ChatUiState.sessionGone])。前者让「每次重连一次注定失败的往返」变成零次;后者让
* 「发现之后还在反复撞」收敛成**恰好一次**。
* 2. `gapCursor != null` ⇒ [ReconnectPull.Forward]:既有行为,**一个字都不改**。有一个断线窗口
* 要补时,`after` 就是对的(它比首屏更精确:只补那个洞)。也正因为这一档在前面,
* 「本轮新增的那条路」只在今天**什么都不做**的那一刻才会走。
* 3. 首屏 [FirstPageState.Failed] **且**不是主会话 ⇒ [ReconnectPull.FirstPage]。
* - **Failed 才算**:`Loading` 时刻再拉一次会与首屏那次抢跑(并且会把 S3 那条「history 帧一到就
* `loading=false`」的时序改掉);`Loaded` 之后再拉就是「每次重连整表重拉」,正是 S4/S7 的成因。
* - **主会话除外**:它由握手 `history` 兜住(见上),在这里再补一条 REST 只会变成**第二套**
* 补口径——两套判据迟早分叉,而分叉的那一侧就是漏出去的那一侧。主会话的兜底行为因此
* **逐字不变**。
* 4. 其余 ⇒ [ReconnectPull.Nothing]。
*
* 判据里除了这四样什么都不看(读不到 VM 状态、不读表),才能穷举、才能在 JVM 上单测
* (先例:`ui.sessions.silentRefreshStep`、`ui.chat.shareLanding`)。
*
* @param sessionId 本 VM 的会话(比较是**等值**:主会话 `web` 与次级 `web:xxx` 是两个会话)。
* @param gapCursor 掉线那一刻记下的 `after` 游标;`null` = 没有需要补的洞。
* @param sessionKnownGone 本端已知「服务端没有这个会话了」——本地删过,或者已经 404 过。
*/
internal fun reconnectPull(
sessionId: String,
firstPage: FirstPageState,
gapCursor: Long?,
sessionKnownGone: Boolean,
): ReconnectPull = when {
sessionKnownGone -> ReconnectPull.Nothing
gapCursor != null -> ReconnectPull.Forward
firstPage == FirstPageState.Failed && sessionId != MAIN_SESSION_ID -> ReconnectPull.FirstPage
else -> ReconnectPull.Nothing
}
@@ -0,0 +1,134 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.allClosed
/**
* **连接重建**(不是第一次接上)时把本端的「进行中」一次性收口。
*
* ## 它修的是什么(主人 2026-10-06 报的现场)
* 后端卡在半路(回合在跑、或工具在跑)时被**重启**:那一轮在服务端**已经不存在了**,
* 所以本端**永远不会**再收到它的任何收口帧(`turn.end` / `stream_end` / 助手落库行)。
* 而本端「进行中」的每一条闸都只认**帧**:
*
* - 灰态(④「已读,正在回答…」)的唯一闸是 [top.yeij.cyrene.domain.chat.TurnBinding.closed],
* 它只由 `turn.end` / 同 `turnId` 的助手落库行驱动(`ChatViewModel.onEvent`);
* - 「思考中…」/「执行 X…」的唯一闸是流式路自己的 `thinkingActive` / `active` / 工具卡的 `phase`
* (收口只发生在 `stream_end` / 助手落库行);
* - 底栏那句「正在思考…」的唯一闸是 `session_busy` / `session_idle` 报的 [ChatUiState.busyMode]。
*
* 三条路都断在「没有帧」上 ⇒ **没有任何一条会自己收口**,界面于是永久停在那一格:气泡一直灰着、
* 卡片一直转、底栏一直说她在思考。三条里今天唯一被兜住的是 `session_idle` / `session_interrupted`
* 那两帧(`allClosed()` 的两个既有调用点)——而**后端重启时它们永远不会到**(说这话的进程已经没了)。
*
* 还有一处**不显眼但很要命**的连带:那条永远 `active` 的流式路永远是消息列表的**末项**
* ([ChatUiState.items] 的最后一项,`reverseLayout` 下就是**屏幕最下方**),而 `ChatScreen` 的
* 「跟随底部」判的正是 `items.lastOrNull()?.key`(`ChatScreen.kt:508`)——末项不再变化 ⇒
* **此后新到的消息不再把列表滚到底**,于是「最新一条」永远停在那一张转着的卡上。用户看到的就是
* 「拉不到新消息」(消息其实进得来,是屏幕不再跟着它走)。
*
* 收口之后这张卡变成**惰性**的(不再转、不再声称在思考):它仍留在列表里(正文一个字都不删,
* 见下面「不许碰的东西」),直到它自己那一路的落库行到达、或**下一轮** `message.start` 把它当作
* 「已结束的路」丢掉(`TurnStreams.beginLeg`)——那时末项恢复推进,跟随底部也就回来了。
* 这条残留如实登记:收口**不**替这张卡删内容,所以它自己要等一个后续事件才消失。
*
* ## 触发时机:只做「连接重建」((a)),**不做静默超时**((b))
* 判据落在 [isReconnect] 上,也就是 `WsState` **非 Connected → Connected** 的那一跳。三条理由:
* 1. **确定性**:后端重启必然重建连接,而重建连接**必然**让 `WsState` 走一遍 `Reconnecting →
* Connected`(`ChatSocket.runConnectionLoop`)。这条判据是**事件驱动**的,不需要时间常量;
* 2. **可验证**:假后端 `POST /__control/ws-close`(或直接重启进程)就能复现,设备上判据是
* 「一条已在飞的回合 + 一次真踢」,不依赖「等多久」这种主观量;
* 3. **与既有纪律一致**:本项目自 W38 起就定死「状态驱动,不按时间轮」(见
* `ChatViewModel.retryFirstPage` 的 KDoc),(b) 会在同一张表上引入第二个口径。
*
* (b)(某一路 N 秒没有任何帧就认为它结束了)**刻意没做**,代价与风险如实记在这里,留主人拍板:
* - 它要多一个**时间常量**,而那个数只能拍:太小会把「正常但慢」的回合(长思维链、长工具执行、
* 服务端排队)误杀成已结束,太大则回到「卡住很久」的老样子;
* - 它能在**连接没断**时兜底(后端进程活着但编排器死锁、或帧被中间设备悄悄丢掉),而 (a) 兜不住
* 这一档;但这一档今天**没有现场证据**(主人报的是「重启后端」,那是 (a)),按「先修有证据的」
* 不动它;
* - 两者叠加不会互相破坏(都是幂等收口),真要做时**先做 (a)、再把它当第二级**即可。
*
* ## 「第一次连接」不算重建
* 冷启动的 `Idle → Connecting → Connected` 必须**一个字节都不收口**,两条路都会踩到:
* - **刚进会话的 VM**:它订阅时连接可能**已经是** Connected,于是它看到的第一次 Connected 与
* `history` / `turn_begin` / `steer_ack` 那一批首帧**几乎同时**到达(帧走 `socket.events`、
* 状态走 `socket.state`,两条流各自投递)。这时候本端手里可能**已经有**一条正当地灰着的气泡
* (另一台设备起的那一轮),把它当成重建收口就是**在用户刚打开会话的瞬间**凭空把那一轮说成已答;
* - **冷启动**:`Idle → Connecting → Connected` 是每一个会话的第一跳,与「后端重启过」毫无关系。
*
* 所以判据必须是「**曾经**到过 Connected 之后又回到 Connected」,而不是「这一次是不是 Connected」。
*/
internal fun isReconnect(previous: WsState, current: WsState, everConnected: Boolean): Boolean {
// 不是「接上了」,谈不上重建(Connecting / Reconnecting / Offline / Idle 都不收口)。
if (current != WsState.Connected) return false
// 本 VM 见过 Connected ⇒ 这一次接上必然是**接回来**。这一条是主判据,而且**不受状态流合流影响**
// (理由与「为什么是一位记忆而不是看上一跳」见 `ChatViewModel.everConnected` 的 KDoc)。
if (everConnected) return true
// 没记过账时的第二证据:上一跳是**重连退避**(`ChatSocket.runConnectionLoop` 只在断开之后写
// `Reconnecting`;首次握手走的是 `Connecting`)。这一档只在一处是真的:**本 VM 是在离线期间
// 建起来的**(进次级会话时连接就是断的)⇒ 它自己的第一次 Connected 确实发生在一次重建之后。
// 它手里那时**什么都没有**(没有 turn / 没有流式路 / 没有 busyMode),所以 [settledAfterReconnect]
// 对它是恒等变换——判在这里不会误伤任何东西,反过来说也不必为它加第二条口径。
return previous == WsState.Reconnecting
}
/**
* 连接重建时把本端「进行中」的五个面一次性收口(纯函数,VM 与测试共用)。
*
* 收口的是**服务端那一轮留下来的本端投影**,一件一件说清它为什么该收:
* - [ChatUiState.busyMode]:「正在思考…」是**服务端报的**事实(`session_busy`)。旧连接没了,
* 这个事实就**不可知**了;继续显示它等于替服务端保证一件已经问不到的事;
* - [ChatUiState.activeTurns]:同上(「并行 N 路」);归 `0` = 「不知道」,正是
* [ChatUiState.activeTurns] 文档里那个「不知道」的取值;
* - [ChatUiState.queueDepth]:「已排队 N 条」也是服务端队列的事实,与 `session_idle` / 被打断
* 那两处的处置**逐字一致**(那两处也是 `busyMode = null, queueDepth = 0` + 全部收口);
* - [ChatUiState.turns] ⇒ `allClosed()`:未收口的绑定全部标 `closed`(气泡 ④ → ⑤);
* - [ChatUiState.streams] ⇒ `allFinished()`:每一路停止生长(思维链不再「思考中」、工具卡不再
* 「执行 X…」),**正文一个字都不删**。
*
* ## 收口之后**不许**碰的东西(可降级;也是本函数的红线)
* [ChatUiState.messages] / [ChatUiState.pending] / [ChatUiState.queuedKeys] /
* [ChatUiState.queuedForConnection] / 草稿与附件 / [ChatUiState.error] / [ChatUiState.sessionGone]
* **一律原样**:收口只改「进行中的外观」,**不删消息、不整表重拉、不给用户多加一条横幅**。
* 绑定表本身也**不删**(只标 `closed`)——并行路角标(`legBadgeForMessage`)靠它,删了等于事后
* 篡改历史。于是写死一条纪律:**收口是可降级的**,最坏的结果只是「少灰一会儿」。
*
* ## 「闪动」:收口会不会被随后到达的帧翻回进行中(如实回答)
* - 灰态**不会**:[top.yeij.cyrene.domain.chat.TurnBinding.closed] 是**单调**的既成事实
* (`withTurnBegin` 合并时也**保持原值**,见那里第 2 条),所以「已答」之后不会退回「回答中」。
* - 流式卡片**会**:那一帧正文/思维链真的又来了,`withLeg` 就会把它重新变成 `active` /
* `thinkingActive` —— 这是**如实**的(它确实又在生成了),而且是**自愈**的:正文不会因为
* 本端收过一次口而丢字(收口不删内容,后续 delta 照旧追加到同一路)。
* - `busyMode` **会**:服务端下一次 `session_busy` 一到就恢复。
*
* 也就是说:**代价落在「服务端那一轮其实还活着」(只是连接抖动过)这一档**——本端会提前一点点
* 把气泡显示成已答。这个代价是刻意选的,因为它比另一个方向小得多:今天的行为(永远灰着、
* 卡片永远转、底栏永远说她在思考、跟随底部永远失效)是**永久坏掉**,且用户唯一的出路是退出重进。
*
* ## 一条如实登记的行为变化
* 清掉 [ChatUiState.busyMode] 会**连带**改变下一次发送的声明:`ChatViewModel.emitAndDeliver` 用
* `busyMode != null` 算 `interruptScopeForSend` / `parallelForSend`(`ChatViewModel.kt:1793`)。
* 于是「她忙时发消息」偏好选「打断」的那一档,在重建后的**第一次**发送不再先发 `interrupt`。
* 如实说:连接重建之后本端**确实不知道**那一轮还在不在,而不打断的后果(这一条被服务端排在后面
* 或并行处理)比对着一个已经不存在的回合发 `interrupt` 小得多。服务端下一次报 `session_busy`
* 就恢复原样。
*/
internal fun ChatUiState.settledAfterReconnect(): ChatUiState {
val closedTurns = turns.allClosed()
val finishedStreams = streams.allFinished()
// 没有任何「进行中」可收时返回**同一个实例**:每次「接回来」都会调它一次,而无变化时制造新
// 实例会让 StateFlow 白白发一轮(也让测试里的 `assertSame` 失去意义)。
if (closedTurns === turns && finishedStreams === streams &&
busyMode == null && activeTurns == 0 && queueDepth == 0
) {
return this
}
return copy(
busyMode = null,
activeTurns = 0,
queueDepth = 0,
turns = closedTurns,
streams = finishedStreams,
)
}
@@ -0,0 +1,314 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.ws.WsState
/**
* 回落 REST 那一次投递的**整通上限**(毫秒)——`POST /api/v1/chat` 从发出到拿到回执,连接、写、
* 读全算在内。
*
* ## 一个 client、两个超时(W45)
* 它和 client 上的 [top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS] 是**两个分开的闸**,管的是
* 两件不同的事:
* - **连接超时(2.5 秒,挂在 client 上)**管「连不上」:完全无网、死网、对端不在听时 2.5 秒内
* 失败 ⇒ 进队列。它是「无网快速进队列」这条性质的**全部**依据;
* - **本值(10 秒,挂在这一次投递上)**管「连上了但一直不回话」:半死的代理、接住连接却不回应
* 的服务端,到点按网络类失败处理 ⇒ 进队列。
*
* 本值是 W41 就写在纸上的那一个,**不许拉长**:它直接是用户按完发送键之后的干等时间上界。正常
* 网络下这一次本该毫秒级拿到回执(「服务端收下了」的判据见 [restAttemptLanding]),10 秒是
* 「回得慢」与「这条路已经死了」之间的分界。
*
* ## 为什么它不写成 client 的 `callTimeout`
* 这个 client 是**共用**的:长文本、内联图片那两条本来就该走 REST 的路用的是同一个
* ([ChatViewModel.sendViaRest])。client 级 `callTimeout` 会把它们一并框住——一条慢但合法的
* 请求会被判成失败,那是给一条本轮不打算碰的路引入新失败面。放在调用点则「哪一次投递有整通上限」
* 是显式的,就写在 [ChatViewModel.sendViaRestFallback] 那一行上。
*
* 超时**不当作「服务端拒绝」**:那条消息可能已经被服务端收下(只是回执没回来)⇒ 按网络类失败
* 处理 ⇒ 进队列,重发时仍用同一个 `clientMsgId`,服务端按 `(session_id, client_msg_id)` 判重后
* 回 `chat_duplicate` / `{duplicate:true}`,不会落成第二条消息。
*/
const val REST_FALLBACK_TIMEOUT_MILLIS = 10_000L
/**
* 一条消息这一刻该怎么走([sendRouteFor] 的取值)。
*
* 与 [top.yeij.cyrene.domain.chat.SendChannel] **不是一回事**,两者正交、同时存在:
* - [top.yeij.cyrene.domain.chat.SendChannel] 判的是「**这条内容**该走哪条上行通道」——静态的、
* 只看文本字节数 / 有没有图片 / 有没有附件,与网络状况无关([top.yeij.cyrene.domain.chat.ChatSendRouting]);
* - 本枚举判的是「**这一刻这个动作**该怎么做」——动态的、只看「帧交出去了没有」与连接状态。
*
* 阶梯的**第一步**永远是 `SendChannel` 那次判定(有图 / 有附件的消息根本进不到下面这两档,
* 它们本来就是 REST);本枚举只在「内容是短文本、本该走 WS」那条路上生效。
*/
enum class SendRoute {
/** 帧已经交给 socket(或连接就绪、就该走 WS):照今天的样子继续等回执。 */
Ws,
/**
* WS 没把帧交出去、而连接不是 [WsState.Offline] ⇒ **直接试 REST** 把这条发出去
* (短文本也走 REST:`POST /api/v1/chat` 从第一天起收的就是同一种消息)。
*
* 这里**没有任何探测**(W45 删掉了 W41 那道 `GET /api/v1/health`):真正的 REST 请求本身就
* 是最直接的检验——试成了这条还顺手发出去了,试败了才进队列。理由见 [sendRouteFor]。
*/
Rest,
/** 连接是 [WsState.Offline] ⇒ **不试 REST**,直接进补发队列等连接。 */
Queue,
}
/**
* [sendRouteFor] 那条判据**依据的是哪一条**([sendRouteDecisionFor] 的取值,W46)。
*
* 为什么把「原因」也变成返回值:结果只有三档,而「为什么是这一档」在真机上是**看不见的**。W45
* 那轮回归就卡在这儿——界面上那条消息进了队列(`queuedForConnection`)、网络侧一条请求都没有,
* 而「零请求 + 进队列」在判据里只可能来自 [SendRoute.Queue];可顶栏同时显示「重连中…」。事后只看
* 结果与顶栏,两种可能(判据读到 [WsState.Offline] vs 判据读了另一个值)**在外同形**。于是这一行
* 日志把依据本身也打出来(tag 见 `ChatViewModel.SEND_ROUTE_LOG_TAG`),矛盾就能当场分辨。
*
* 三档**与 [sendRouteFor] 的三条规则一一对应**,没有第四种可能:它是那条 `when` 的分支标识,
* 不是另算一遍的近似值([sendRouteFor] 就是本函数的 `.route`)。
*/
enum class SendRouteReason {
/** 规则 1:`wsDispatched` ⇒ 帧已经交给 socket,连接状态不参与判断。 */
FrameDispatched,
/** 规则 2:连接是 [WsState.Offline](会话已知是死的)⇒ 进队列,**一个请求都不发**。 */
ConnectionOffline,
/** 规则 3:其余未就绪档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])⇒ 直接试 REST。 */
ConnectionNotReady,
}
/**
* 一次路由判定的**结果 + 依据**([sendRouteDecisionFor] 的取值)。
*
* 两者必须同出一次:分开问两次会把「判据看到的事实」与「结论」割开,而那正是排查时最容易错的地方。
*/
data class SendRouteDecision(val route: SendRoute, val reason: SendRouteReason)
/**
* 同 [sendRouteFor],但把「依据」一并带出来([SendRouteReason],W46 可观测性)。
*
* 判据、优先级、行为**逐字未改**:下面这个 `when` 就是 [sendRouteFor] 原来那一个,只是每一支把
* 自己标了出来。[sendRouteFor] 现在读它的 [SendRouteDecision.route],所以两处不可能分叉。
*/
fun sendRouteDecisionFor(wsDispatched: Boolean, connection: WsState): SendRouteDecision = when {
wsDispatched -> SendRouteDecision(SendRoute.Ws, SendRouteReason.FrameDispatched)
connection == WsState.Offline -> SendRouteDecision(SendRoute.Queue, SendRouteReason.ConnectionOffline)
else -> SendRouteDecision(SendRoute.Rest, SendRouteReason.ConnectionNotReady)
}
/**
* 回落 REST 宣告失败之后的落点([restFailureLanding] 的取值)。
*/
enum class RestFailureLanding {
/** 这一条**还没上过路**(服务端没给过任何裁决)⇒ 进队列,连接一好就补发。 */
Queue,
/** 服务端**给过裁决**(拒绝 / 未授权 / 协议对不上)⇒ 走今天那条回滚 + 文案。 */
Rollback,
}
/**
* 「这条 `chat` 帧交出去了没有 + 这一刻的连接状态」⇒ 该走哪条路(**纯函数**,VM 与测试共用)。
*
* 判据里**没有任何探测输入**(W45):路由只看这两个事实,`GET /api/v1/health` 那一层连同它的
* 客户端、独立超时预算与失败面一起删掉了。
*
* ## 为什么是「直接试 REST」(W45 主人的定案)
* W41 的阶梯是「WS 未就绪 ⇒ **先探 `GET /api/v1/health`** ⇒ 通才回落 REST」。真机回归
* (`devtools/ws-block-proxy/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落
* REST**;而 `ProbeClientTest` 证明那条链在 JVM 上是通的,`Unreachable` 生在**连接之前**,三种
* 成因(建 client 抛 / 连上但一个字没写 / ConnectException)**在外同形** ⇒ 那一轮无法定论。
*
* 结论是**不该有探测**:探测只是「能不能连上」的**替身**,而真正的 REST 请求本身就是最直接的
* 检验——试成了消息还顺手发出去了,试败了进队列(内容一个字节不丢,重发仍是同一个
* `clientMsgId`)。探测唯一省下的是「完全无网时少发一个注定失败的请求」,代价却是一个独立
* client、一套独立超时预算和一整套新的失败面;而那个探针 client **从不复用连接池**,每次都要现做
* 一次冷握手,预算还比主链路紧 4 倍(2.5 秒整通 vs 10 秒连接)——今天这个 bug 的形态就长在
* 这上面。
*
* 直接试也**不会**把「无网快速进队列」弄丢:那一条靠的是 client 上的短连接超时
* ([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS],2.5 秒),不是探针。
*
* ## 判据与优先级
* 1. `wsDispatched == true` ⇒ [SendRoute.Ws]。帧已经交给 socket,连接状态**不再参与**判断
* (`sendChat` 判的就是自己的状态)。**成功路径零改动**:不打标记、不说告知、不写任何状态。
* 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue]。这一档的语义是「握手 401 且续期也失败」
* (终态、不会自动重连),也就是说**会话已知是死的**;这时候去试 REST 只会拿到 401 并落到
* 「回滚 + 一句会误导人的鉴权错误」,把 W40 那句「已排队不会丢」换成更差的结果。**保留 W41
* 这个决定**:它当时的理由(健康端点不鉴权、必然回 200,于是判成「通」)随探测一起消失了,
* 但结论换到直接试这边**一字不用改**——不鉴权的探针回 200 是「假通」,带令牌的 chat 端点回
* 401 是**真不通**,两条路都指向同一件事:这一档不该发。
* 3. 其余三档未就绪([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])
* ⇒ [SendRoute.Rest]:**直接试 REST**,不等任何探测。
*
* 判据只认 `Connected` **一个值**(不是「不等于 Offline」之类的近似)——连接状态的另外四档
* 都意味着帧没有被交给任何服务端。
*
* 本函数只回三个结果;**「为什么是这一档」在 [sendRouteDecisionFor]**(W46 加的可观测性,判据本身
* 一字未动):真机上看不见「判据读到的是哪个值」,而那正是排查「发送为什么进了队列」时唯一要分的
* 东西。
*/
fun sendRouteFor(
wsDispatched: Boolean,
connection: WsState,
): SendRoute = sendRouteDecisionFor(wsDispatched, connection).route
/**
* 这份回执照的是「**服务端已经收下这条**」(受理起回合 / 忙时续接排队 / 早就收过这条幂等键)。
*
* 只有这一族才算了结:被拒的那三种(队列满 / 接不住 / 明确拒绝)不算——那条消息没有上路,走
* [ChatAck] 那六支里的回滚 + 文案。用 `when` 穷举而不是 `!=` 某个值:回执面将来多一档时,
* 这里必须有人做决定。
*
* 它是[直接试 REST][restAttemptLanding] 那一步「**收下了没有**」的**唯一**判据。W45 把它从
* `ChatViewModel` 的私有方法搬到这里:这条判据决定了一条消息会不会进补发队列(收下了就不该进),
* 不该只活在 VM 里、只被组合着一个界面才测得到。
*/
fun ChatAck.isAccepted(): Boolean = when (this) {
ChatAck.Accepted, is ChatAck.Queued, is ChatAck.Duplicate -> true
ChatAck.QueueFull, is ChatAck.Unavailable, is ChatAck.Rejected -> false
}
/**
* 一次「直接试 REST」的落点([restAttemptLanding] 的取值)。
*
* 四档**穷举**「服务端有没有收下 / 有没有给过裁决」这两个问题的组合,每一档对应一个明确的处置,
* 没有「默认继承前一档」的余地。判据决定的是**一条消息的去向**(进队列还是回滚),所以两档的
* 代价不对称:误判成 [Accepted] 会让一条没上路的消息凭空消失,误判成 [Unsent] 只是多补发一次
* (幂等键兜住,不落第二条)——拿不准一律往 [Unsent] 倒。
*/
sealed interface RestAttemptLanding {
/**
* **完成**:服务端收下了这条,[ack] 是它的回执(原样交给既有那六支)。
*
* 这一档**不进队列**:收下了还排队会白补发一次(幂等键虽然兜得住,但那是浪费,而且会让界面上
* 那句「排队中 · 还没连上」变成假话)。
*/
data class Accepted(val ack: ChatAck) : RestAttemptLanding
/** **进队列**:这条还没上路(分档见 [restAttemptLanding]),等连接一好按同一个键补发。 */
data object Unsent : RestAttemptLanding
/** **回滚**:服务端给过裁决(拒绝),回滚与文案都在既有那六支里([ack] 原样交给它们)。 */
data class Refused(val ack: ChatAck) : RestAttemptLanding
/** **回滚**:本端异常(401 / 配置类 / 映射不出来的服务端错误),用 [error] 自己的文案。 */
data class Errored(val error: CyreneError) : RestAttemptLanding
}
/**
* 这一次「直接试 REST」的产物 ⇒ 落点(**纯函数**,VM 与测试共用)。
*
* ## 怎么判定「服务端收下了」(不需要等整轮生成完)
* `POST /api/v1/chat` 在 `stream:true` 下的回执**不是**内容流,而是一个小 JSON:服务端把这一轮
* 交给编排器之后就写回执返回(`Cyrene_v2/internal/services/gateway/chat_stream_dispatch.go:201`
* 的 `writeChatStreamAck`:`200 {"stream":true}` / `202 {stream:true,queued:true}` /
* `200 {duplicate:true}` / `409 chat_busy` / `503 chat_dispatch_failed`),真正的回合跑在它自己
* 派生的 goroutine 上(同文件 `submitChatStream` 的注释:「streamChat 同步返回提交判定,因此这里
* 不会阻塞在模型调用上」)。内容帧只走**已连接的 WS**(见 [ChatViewModel.sendViaRestFallback] 的
* 「回复为什么收不到、又怎么补回来」一段)。
*
* 所以本端要判的只是「**回执到了没有、它说的是不是收下了**」:[ChatRepository] 拿到 HTTP 响应并
* 把那个小 JSON 解析成 [ChatAck] 就算到了(`ChatApi.sendChat` 返回的是
* `Response<ApiEnvelope<ChatAckDto>>`,Retrofit 读完这一小段 body 即返回),**不用**等任何生成。
* 也就是说这次调用等的是「**服务端收下了**」这个事实,不是那一轮的产出。
*
* @param attempt `withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { chatRepository.send(request) }`
* 的**原样**产物:`null` = 整通上限到点(本端没等到任何回执);`Result.success` = 服务端回了
* 回执(受理与拒绝都在里面,靠 [ChatAck.isAccepted] 分开);`Result.failure` = 本端异常
* (网络 / 配置 / 401)。
*
* ## 判据与优先级
* 1. `null`(整通上限到点)⇒ [RestAttemptLanding.Unsent]。本端**没等到任何裁决**:那条消息可能
* 已经被服务端收下(只是回执没回来),也可能压根没到——本端分不出来,而**分不出来就往队列
* 倒**(fail-closed)。重发还是同一个 `clientMsgId`,服务端若其实已经收下,只会回
* `duplicate`(不会落成第二条)。
* 2. `Result.success` 且 [ChatAck.isAccepted] ⇒ [RestAttemptLanding.Accepted]:**服务端收下了**,
* 就是发出去了,**不进队列**。
* 3. `Result.success` 且不被受理 ⇒ [RestAttemptLanding.Refused]:服务端**给过裁决**(`409` 队列满 /
* `500` 接不住 / `503` 派发失败 / `400` 别的明确拒绝)⇒ 走今天那条回滚 + 文案,绝不偷偷重发。
* 4. `Result.failure` ⇒ 交给 [restFailureLanding](**W41 那套口径一字未改**):网络族
* (`NoNetwork` / `Dns` / `Timeout` / `ConnectFailed`)与「异常映射不出来」⇒
* [RestAttemptLanding.Unsent];服务端裁决类([CyreneError.Server] / [CyreneError.Unauthorized])
* 与配置族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` / `Unknown`)⇒
* [RestAttemptLanding.Errored]。
*
* ## fail-closed 落在哪里
* 只有第 2 条不进队列,而它要求**回执明确说了「收下了」**。其余每一档不是进队列就是回滚——
* 没有任何一条会因为「说不定通了」把一条没上路的消息当成发出去了,也没有任何一条会因为
* 「反正会重试」把服务端的裁决吞掉。
*/
fun restAttemptLanding(attempt: Result<ChatAck>?): RestAttemptLanding = when {
attempt == null -> RestAttemptLanding.Unsent
attempt.isSuccess -> {
val ack = attempt.getOrThrow()
if (ack.isAccepted()) RestAttemptLanding.Accepted(ack) else RestAttemptLanding.Refused(ack)
}
// 异常不是 CyreneException(映射不出来)= 本端说不清服务端有没有收下 ⇒ 与网络族同一档。
else -> when (val error = (attempt.exceptionOrNull() as? CyreneException)?.error) {
null -> RestAttemptLanding.Unsent
else -> when (restFailureLanding(error)) {
RestFailureLanding.Queue -> RestAttemptLanding.Unsent
RestFailureLanding.Rollback -> RestAttemptLanding.Errored(error)
}
}
}
/**
* 回落 REST 失败之后该去哪(**纯函数**):进队列,还是走今天那条回滚 + 文案。
*
* ## 为什么网络类失败进队列是安全的(主人 2026-10-05)
* 重发用的还是**同一个** `clientMsgId`(就是那条乐观气泡的 `localId`,
* [QueuedSend.localId]),而服务端按 `(session_id, client_msg_id)` 唯一索引仲裁:重复到达只回
* `chat_duplicate`(WS)/ `{duplicate:true}`(REST),**不会再落库、不会再起一个回合**(契约 v0.56
* §3.13)。所以「超时了但服务端其实收下了」这种最难判的情况不需要本端猜:进队列、以后重发一次,
* 服务端自己会把重复吃掉。这也是「网络类失败可以自动补发」的**全部**依据——没有幂等键,这里只能
* 回滚。
*
* ## 分档
* - `null` ⇒ [RestFailureLanding.Queue]。`null` 的语义是「本端**没等到任何回执**」(自己设的
* [REST_FALLBACK_TIMEOUT_MILLIS] 到点了,或抛了一个映射不出来的异常):服务端没给过裁决,
* 这条就是「还没上路」,与网络类失败同一档。
* - [CyreneError.Local] 里的网络族([CyreneError.LocalKind.NoNetwork] / [CyreneError.LocalKind.Dns] /
* [CyreneError.LocalKind.Timeout] / [CyreneError.LocalKind.ConnectFailed])⇒
* [RestFailureLanding.Queue]:请求根本没被服务端读到,等一下多半会好。
* - 其余一切 ⇒ [RestFailureLanding.Rollback]:
* - [CyreneError.Server] / [CyreneError.Unauthorized]:服务端**回了话**,那就是它的裁决(拒绝 /
* 令牌失效)。盲目重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话;
* - [CyreneError.Local] 里的配置与协议族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` /
* `Unknown`):**等下去不会变好**,它们要的是用户改设置或换地址,回滚时那句 `userMessage`
* 正是他需要看到的东西。这几档在回落这条路上本来就极少出现——完全无网时 client 上的短连接
* 超时([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS])先就把它拦下了。
*
* 用 `when` 穷举:错误模型将来多一档时,这里必须有人做决定,编不出档位就不该静默继承前一档。
*/
fun restFailureLanding(error: CyreneError?): RestFailureLanding = when (error) {
null -> RestFailureLanding.Queue
is CyreneError.Local -> when (error.kind) {
CyreneError.LocalKind.NoNetwork,
CyreneError.LocalKind.Dns,
CyreneError.LocalKind.Timeout,
CyreneError.LocalKind.ConnectFailed,
-> RestFailureLanding.Queue
CyreneError.LocalKind.Tls,
CyreneError.LocalKind.CleartextBlocked,
CyreneError.LocalKind.InvalidUrl,
CyreneError.LocalKind.Parse,
CyreneError.LocalKind.Unknown,
-> RestFailureLanding.Rollback
}
is CyreneError.Server, is CyreneError.Unauthorized -> RestFailureLanding.Rollback
}
@@ -0,0 +1,266 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.ChatSendRouting
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.SendChannel
/**
* 「没连上、还没发出去」的一条待发消息([ChatUiState.queuedForConnection] 的元素)。
*
* 队列里只放两样东西:那条乐观气泡的 `localId`(= 上行幂等键 `clientMsgId`)与投递那一刻定下的
* 并行声明。正文 / 图片 / 附件**不在这里再存一份**——它们已经在 [ChatUiState.pending] 那条气泡上,
* 补发时从那里现取([ChatUiState.nextQueuedSend])。同一件事只留一个存放处:队列里再存一份正文,
* 就多了一个会跟气泡分叉的真相源。
*
* [parallel] 是**投递那一刻**按「她忙时发消息」偏好算出来的
* ([top.yeij.cyrene.domain.chat.parallelForSend]),补发照它发出、**不重读偏好**:补发的是同一条
* 消息的**同一次投递**,排队途中用户切档不该让这条变成另一种投递,而重读还会让「这条消息的投递
* 参数」随重连的时刻漂移。
*/
data class QueuedSend(
/**
* 乐观气泡的 `localId`,同时就是**上行幂等键** `clientMsgId`:补发**复用同一个键**,
* 绝不铸第二个(契约 v0.56 §3.13,服务端按它判重、重复到达不会落成第二条消息)。
*/
val localId: String,
/** 并行声明:只有 `true` 才会在帧上带 `parallel` 键([top.yeij.cyrene.domain.chat.parallelWireValue])。 */
val parallel: Boolean,
)
/**
* 这次发送**有没有东西可发**(**纯函数**,输入栏的 `send()` 与投递口 `emitAndDeliver` 共用同一次判定)。
*
* 三样里任意一样在就算有:正文(按 `isNotBlank`——调用方喂进来的本来就是 trim 过的,
* 空白串与空串是同一件事)、图片、附件。三样都没有 ⇒ **什么都不发生**:不上屏乐观气泡、
* 不进补发队列、不发任何帧、也不说一句话(用户按了个空发送键,没什么可说的)。
*
* 为什么把它从两处 `if` 里提出来做成一个函数:这条边界此前写在两个地方(输入栏的 `send()` 与
* 投递口的 `emitAndDeliver`),两处写法一致但**没有一处能单测**——而「空输入不该发生任何事」正是
* 一条要一直成立的边界。合成一个判据之后,入口与投递口用的**必须是同一条**:否则会出现
* 「入口认为有内容、投递口认为没有」(或反过来)这种分叉。
*/
fun hasSendableContent(
text: String,
imageUri: String?,
documents: List<DraftDocument>,
): Boolean = text.isNotBlank() || imageUri != null || documents.isNotEmpty()
/** 一次 WS 投递的结论([wsSendOutcome] 的取值)。 */
enum class WsSendOutcome {
/** 帧已经交给 socket(`sendChat` 回 `true`):照今天的样子继续等回执,这里什么都不做。 */
Dispatched,
/**
* 未就绪 ⇒ 这条**不是失败**。W41 起它不再直接等于「进队列」:调用方(`ChatViewModel.sendThroughLadder`)
* 接着往下判一次——**直接试 REST** 把这条发出去(W45 起不再先探连通性,理由见
* [top.yeij.cyrene.ui.chat.sendRouteFor]),[WsState.Offline] 那一档与试不成的情况才留在待发态、
* 等连接恢复后按发送顺序补发。无论哪一种,**都不回滚、不报错误横幅**。
*/
Queued,
/** 没发出去,而这一刻连接**就绪** ⇒ 真失败:走今天那条回滚 + 文案。 */
Failed,
}
/**
* 「`sendChat` 的回值 + 这一刻的连接状态」⇒ 这条消息该怎么处置(**纯函数**,VM 与测试共用)。
*
* ## 为什么主人要的是队列而不是回滚(2026-10-05 裁决)
* W39 之前,`deliver` 在拿到 `sent == false` 时一律 `rollback(...)`:气泡摘掉、正文退回输入栏,
* 而全链路没有自动重发 ⇒ 用户看到的是一句「这条没发出去,请稍后手动重试」。断网 / 弱网下那句话
* 把「网络还没好」说成了一次要用户负责的失败,而内容还得他自己看着输入栏再发一遍。主人的裁决是
* **未连接就进队列**:不报错、内容不丢、连接恢复后自动补发。W38 给「分享落地」做的正是同一件事
* ([ShareLanding]:等连接就绪才取走、恢复后自动补发)——这里沿用同一套语义,只是对象从
* 「分享进来的内容」换成了「输入栏发出的这条」,所以两处的措辞与纪律也刻意保持一致。
*
* ## 判据与优先级
* 1. `sent == true` ⇒ [WsSendOutcome.Dispatched]。帧已经交给 socket,连接状态那一格不再参与判断
* (`sendChat` 判的就是它自己的状态)。**成功路径一个字都没动**:不打标记、不说告知、不写任何
* 状态——回执(`pending_ack` / REST ack)、落库(`message_persisted`)、队列满(`pending_full`)
* 各有各的路。
* 2. `sent == false` 且连接**就绪**([WsState.Connected])⇒ [WsSendOutcome.Failed]。连接是好的,
* 那 `false` 只可能来自另外两种成因(`webSocket` 对象已空 / 已被 cancel,或 `toJson()` / `send()`
* 抛了异常),这时候「发送失败」是事实 ⇒ 走 [SEND_FAILED_MESSAGE] 那条既有回滚。
* 3. 其余四档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline])
* ⇒ [WsSendOutcome.Queued]。`ChatSocket.send` 的**第一行**就按连接状态挡掉,帧连序列化都没做、
* 服务端一个字节都收不到 ⇒ 这不是「发送失败」,是**还没上路**:既不回滚,也不进 `error` 横幅。
* (W41 起这一档由 `sendThroughLadder` 接着往下判:能直接试 REST 发就发,发不了才真进队列。
* 本函数只回答「帧交出去了没有、连接是不是好的」,不掺那一次尝试——它要发请求,不能是纯函数。)
*
* ## 为什么 Offline 也进队列(而不像告知那样另分一档)
* 主人的原话是「未连接就进队列」,四档一视同仁:[WsState.Offline] 只是**不会自己变好**,
* 内容仍然一个字节没发出去、也一个字都不会丢;两档的差别只在**告知措辞**
* ([queuedSendNoticeFor]:这一档说的是「重新登录 / 检查服务器地址」,而不是一个永远等不到的
* 「连上后自动发出」)。队列照旧留着:用户重新登录 / 修好地址之后连接一旦就绪,它与其余三档
* 一样按序补发。
*
* 判据只认 `Connected` **一个值**(不是「不等于 `Offline`」之类的近似)——见第 3 条:连接状态的
* 另外四档都意味着帧没有被交给任何服务端。
*/
fun wsSendOutcome(sent: Boolean, connection: WsState): WsSendOutcome = when {
sent -> WsSendOutcome.Dispatched
connection == WsState.Connected -> WsSendOutcome.Failed
else -> WsSendOutcome.Queued
}
/**
* 记下「这条没发出去、留在本端等连接」(**纯函数**,返回新状态)。
*
* 队列是**有序**的:新条目追加在末尾,顺序就是**用户发送的先后顺序**,补发照这个顺序走
* ([ChatUiState.nextQueuedSend] 只看队头)。调用点只有一处(`deliver` 的「未就绪」分支),
* 一条消息只会走到那里一次,所以不需要额外的去重表。
*/
fun ChatUiState.withQueuedSend(localId: String, parallel: Boolean): ChatUiState =
copy(queuedForConnection = queuedForConnection + QueuedSend(localId, parallel))
/**
* 出队(**纯函数**)。出队**恰好一次**正是「同一条不会被补发两次」的结构性保证:补发的每一步都
* 只看队头,而队头只在这次投递有了结论(发出去了 / 真失败 / 气泡已经不在)之后才被摘掉
* (见 [ChatUiState.nextQueuedSend] 与 `ChatViewModel.flushQueuedSends`)。
*
* 键不在队列里时**原样返回同一个实例**(Compose 的 key 稳定性靠它):回滚、收编、落库归一路过
* 这里时,队列通常本来就是空的。
*/
fun ChatUiState.withoutQueuedSend(localId: String): ChatUiState =
if (queuedForConnection.none { it.localId == localId }) {
this
} else {
copy(queuedForConnection = queuedForConnection.filterNot { it.localId == localId })
}
/** 这条待发气泡是不是「还没连上、留在本端等连接」的那一类(判据 = 它在补发队列里)。 */
fun ChatUiState.isAwaitingConnection(localId: String?): Boolean =
!localId.isNullOrBlank() && queuedForConnection.any { it.localId == localId }
/** 补发的下一步([ChatUiState.nextQueuedSend] 的取值)。 */
sealed interface QueuedSendStep {
/** 就发这条:键仍是 [localId](幂等键,不铸第二个),正文从那条气泡上现取。 */
data class Send(val localId: String, val text: String, val parallel: Boolean) : QueuedSendStep
/** 气泡已经以别的路径了结(回滚 / 落库归一 / 收编都会把它带走)⇒ **只出队、不发**。 */
data class Drop(val localId: String) : QueuedSendStep
/** 这条补发不了(带着图片或附件)⇒ 走今天那条失败路径:回滚 + 文案,内容一条不丢。 */
data class Failed(val localId: String) : QueuedSendStep
/** 队列空:没什么要补的。 */
data object Idle : QueuedSendStep
}
/**
* 补发的**下一步**(纯函数,VM 与测试共用):只看队头,**不出队**。
*
* 出队由调用方在这条有了结论之后做([ChatUiState.withoutQueuedSend]),于是「重复触发 ⇒ 不重复
* 补发」落在结构上,而不是落在某一次调用的运气上:
* - 同一批消息被**多次触发**(连接抖动、重组、重连之后立刻又断)时,每一步拿到的都是同一个键、
* 同一段正文——触发器重复**不会**产生第二次出队;
* - 出队之后再看,队头就是**下一条**,顺序因此严格等于入队顺序(用户发送的先后顺序);
* - 就算某一帧真的被重复交出去(本端判据失效、进程被杀前重放之类),服务端仍按同一个
* `clientMsgId` 判重(`chat_duplicate`,契约 v0.56 §3.13)⇒ 不会落成第二条消息。
* 两道保证各管一头:**出队**管本端不重发,`clientMsgId` 管服务端不落第二条。
*
* 两种边界也要说清:
* - 队头的键在 [ChatUiState.pending] 里找不到 ⇒ [QueuedSendStep.Drop]:气泡已经以别的路径了结
* (回滚把它退回了输入栏、落库归一把它换成了权威消息),这时候再补发就是凭空多出一条。
* - 气泡在、但它不是**纯文本**(带图 / 带附件)⇒ [QueuedSendStep.Failed]:WS 那条路只走纯文本
* (有图 / 有附件一律 REST,见 [ChatSendRouting.channelFor]),只发正文会把图与附件**静默丢掉**。
* 今天不可能出现(能进队列的仍然只有「本该走 WS 的短文本」那几条——W41 也没有放宽这一条:
* 回落 REST 失败时进队列的同样是短文本,有图 / 有附件的那几条走的是另一条分支、压根不进这层阶梯),
* 这道闸是给将来路由变化留的:真出现了宁可回滚、让用户自己看见并重发,也不要发出一条缺了图的消息。
*/
fun ChatUiState.nextQueuedSend(): QueuedSendStep {
val head = queuedForConnection.firstOrNull() ?: return QueuedSendStep.Idle
val item = pending.firstOrNull { it.localId == head.localId }
?: return QueuedSendStep.Drop(head.localId)
val channel = ChatSendRouting.channelFor(
text = item.text,
hasImage = !item.imageUri.isNullOrBlank(),
hasDocuments = item.documents.isNotEmpty(),
)
return if (channel == SendChannel.Ws) {
QueuedSendStep.Send(head.localId, item.text, head.parallel)
} else {
QueuedSendStep.Failed(head.localId)
}
}
/**
* 这一刻的未就绪该说哪句话(**纯函数**;`null` = 这一刻没什么要说的)。
*
* 两档**只有措辞不同**:都只在「这条刚进了补发队列」时说一次(判据与不刷屏的记账在
* `ChatViewModel.queueForConnection`),队列、补发时机、出队规则一个字都没改。
*
* - [WsState.Offline] ⇒ [SEND_QUEUED_OFFLINE_NOTICE]:这一档**不会自动重连**,对用户说「连上后
* 自动发出」等的是一个**永远不会到来**的连接——那是拿一句不会兑现的承诺糊住一个需要他动手的
* 状况(W39 给分享落地定过同一条规矩,见 [SHARE_OFFLINE_NOTICE])。所以这一档说的是他**能做的
* 事**:重新登录(会话过期的路)或检查服务器地址(配置不对的路)。
* - [WsState.Connected] ⇒ `null`:那一刻「排队等连接」这件事根本不成立——连接就绪时要么发出去
* 要么走真失败那条路(见 [wsSendOutcome]),没有第三档。
* - 其余三档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])⇒ [SEND_QUEUED_NOTICE]:
* 只是还没连上,连上就会自动发出。
*
* 用 `when` 穷举,而不是「不是 Offline 就用另一句」:连接状态将来多一档时,这里必须有人做决定
* (编不出文案就不该静默继承前一句)。
*/
fun queuedSendNoticeFor(connection: WsState): String? = when (connection) {
WsState.Offline -> SEND_QUEUED_OFFLINE_NOTICE
WsState.Connected -> null
WsState.Idle, WsState.Connecting, WsState.Reconnecting -> SEND_QUEUED_NOTICE
}
/**
* 同一条(或同一批)补发反复失败多少次之后**不再多说**(主人 2026-10-06 定案:多次重试失败就静默)。
*
* 取 3 的理由(**不许**取 1 或 2——那等于根本没告知):
* - 第 1 次失败就是「刚进队列」那一刻,那一句必须说:它是「已经排上队了、不用重打一遍」的全部
* 信息量所在;
* - 第 2、3 次仍允许开口:连接抖动之后这条又没能上路,用户确实需要重新知道「还是没连上」,
* 而不是以为早就发出去了;
* - 到第 3 次仍未成功,这条(批)已经明显处在「反复失败」里了,再说一遍只是把同一件事刷第二遍
* ——从此停下告知,静静地等(等的那件事没变:连接一好就自动补发)。
*
* 静默**不等于丢弃**:这个数只回答「还说不说」,一个字都不动补发队列本身——W38/W40/W41 那条
* 「内容一个字节都不丢」的硬要求照旧成立。
*/
const val QUEUED_RESEND_SILENCE_THRESHOLD = 3
/**
* 这一刻「这条(批)排队等连接」还该不该再说一句(**纯函数**,`false` = 静默)。
*
* ## 判据与优先级(两个条件同时成立才说)
* 1. **静默闸优先**:[failedResends] 达到 [QUEUED_RESEND_SILENCE_THRESHOLD] ⇒ `false`,与
* [toldThisRound] 是什么无关。这是主人 2026-10-06 的定案:反复重试都失败时停止刷屏。
* 2. 轮内去重:[toldThisRound] 为真 ⇒ `false`(一轮「队列从空到空」只说一次,连续发几条离线
* 消息不会每按一次发送就弹一遍——W40 既有的记账,一字不改)。
* 3. 其余(这一轮还没说过、失败次数还没到阈值)⇒ `true`。
*
* [failedResends] 是**这条(批)已经失败过的补发次数**,只把「补发没能上路、内容仍留在队列里」
* 那种失败计进来(见 `ChatViewModel.drainQueuedSends`):它是唯一会**反复发生**的一种;已被服务端
* 裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不在这条线上。
*
* 计数归零由调用方在做,判据是 [queuedResendFailuresAfterDelivery](**按事故复位**,主人
* 2026-10-06 裁决):一次成功的补发清零,队列**排空**(这一批了结)同样清零。
*/
fun queuedSendNoticeAllowed(toldThisRound: Boolean, failedResends: Int): Boolean =
!toldThisRound && failedResends < QUEUED_RESEND_SILENCE_THRESHOLD
/**
* 一次出队 / 回滚之后,「这一批已经失败过几次」该记成多少(**纯函数**,VM 与测试共用)。
*
* ## 为什么按事故复位,而不是只在成功时清零(主人 2026-10-06 裁决)
* 「事故」= 一次「队列从空到非空、再回到空」,与 [queuedSendNoticeAllowed] 的 `toldThisRound`
* 同一个口径。**事故只要结束就复位,不要求它是靠一次成功的补发结束的**:W43 的旧口径只在补发
* 成功时清零,于是当一段坏网络的队列**以回滚 / 被收编收场**(一次都没发出去)时,静默计数会一直
* 挂在那里 ⇒ 之后连接恢复、再断一次、用户又发消息时,那句话永远说不出口。那不是「少点噪音」,
* 是把解释**永久关掉**。
*
* 三种结束方式都是了结,都该复位:
* - [sent] = 这一次补发真的上路了(`WsSendOutcome.Dispatched`)⇒ `0`;
* - [drained] = 队列已经空了 ⇒ `0`(回滚把内容退回输入栏、被收编把气泡换成权威消息,都是了结);
* - 其余(队列还有货、这一次又没成功)⇒ 原值:**同一个事故里**的静默照旧生效,不刷屏的规矩不变。
*
* 它只算「还说不说」,**从不**碰队列内容:静默从来不是丢弃。
*/
fun queuedResendFailuresAfterDelivery(failures: Int, sent: Boolean, drained: Boolean): Int =
if (sent || drained) 0 else failures
@@ -48,8 +48,10 @@ data class SessionSearchUiState(
* 会话内搜索的小 ViewModel:**只属于聊天页的搜索态**,与 [ChatViewModel] 完全不相干——
* 搜索不改变消息窗、不碰 WS、不发消息,所以它不该住进那个正在被另一个模块改动的 VM 里。
*
* 作用域由调用方决定(`viewModel(key = "session-search:<id>")`),因此每个会话各有一份,
* 切回来时上一次的查询与结果还在。取数一律走既有的 `MessageRepository`。
* 作用域由调用方决定(`viewModel(key = "session-search:<账号指纹>|<id>")`),因此每个会话各有一份、
* 切回来时上一次的查询与结果还在;而**换账号/换服务器后指纹变了 ⇒ 取到的是新实例**,
* 面板里不会残留上一个环境的命中(那些命中就是别的服务器的消息正文)。
* 取数一律走既有的 `MessageRepository`。
*
* 并发:`generation` 是「谁的结果才算数」的代际号。搜索是**用户连打两次回车**就会并发的地方,
* 加上翻页与首批可以同时存在,没有代际号就会出现「先发的慢请求后到,把新关键词的结果顶掉」。
@@ -0,0 +1,51 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「一份**已经取走**、正等着用户确认的分享」,外加用户此刻补的那句描述。
*
* ## 为什么它必须活在 VM 里,而不是一格 `remember`
* 这份内容**已经**从待发队列里出队了([top.yeij.cyrene.app.ShareIntake.take] 是原子出队、
* **没有归还路径**),而它此前只挂在一格 `remember(sessionId)` 上——那格状态属于**那一次组合**。
* Activity 因转屏 / 深浅色切换重建时组合整份丢掉:那份分享既不在队列里(已经出队)、也不在界面上
* (那格状态没了),**静默消失**——用户什么都没看到,也没有任何提示。所以它待在
* [ChatUiState.shareConfirm] 里:VM 由 `AppContainer` 那一层持有(`core.session.ChatSessionSlots`
* 的 `ViewModelStore`),比组合与 Activity 都活得久。
*
* 正文([share])与描述([caption])**绑在同一个值里**是刻意的:描述说的是「你要补的那一句话」,
* 它只对这一次确认有意义——分成两格迟早会出现「描述换了一份、正文还是上一份」。
*
* [caption] 初值**恒为空**:面板上半是这次分享进来的东西,下半才是用户要附加的话,预填一次就会在
* 面板里把同一句话排两遍(W53X 的第一版正是这么错的,见 `ShareConfirmPanel` 的 KDoc)。
*/
data class ShareConfirmDraft(
val share: SharedContent,
val caption: String = "",
)
/**
* 「手上这一份 + 去待发队列认领一次」⇒ 面板这一刻该拿着哪一份(**纯函数**,可单测)。
*
* ## 它回答的是**不可逆那一步该不该走**
* [take] 就是 `ShareIntake.take()`(**不可逆**),所以「什么时候允许调它」是这里唯一的正事:
* **手上已经有东西时一个字节都不动**——连队列里新来的那条也不碰,它原地等着,面板上此刻摆的
* 仍是用户正在看的那一份,不会被后来的一条顶掉。这就是「同一份分享只被取走一次」的落点:
* Activity 重建之后新的一帧会再问一次同样的问题,而那时 [holding] 里已经装着那份内容
* (它活在 VM 的状态里,活过了重建)⇒ 不会再 [take] 第二次。
*
* ## 为什么不是「取完之后再拿结果比一比」
* 不能靠「第二次 [take] 拿到 null 就当没发生过」兜底:第二次跑起来拿到的要么是 null(白跑一趟),
* 要么是**队列里的下一条**——后者会把用户正看着的那份顶掉,那是一次静默丢失。所以闸门必须在
* [take] **之前**,而不是在它之后回滚。
*
* @param holding 这一刻面板手上那一份([ChatUiState.shareConfirm]);null = 手上没东西。
* @param take 去待发队列认领一条(`ShareIntake.take()`):**只在 [holding] 为 null 时被调用**,
* 且最多一次。返回 null(队列为空)时本函数也回 null——不造一个「什么都没有的面板」。
* @return 面板该拿着的那一份:手上原有的**原样**返回(连实例都不换——`ShareConfirmPanel` 用它做
* `remember` 的键,换一份等值的新对象会让预览白重建一次);手上没有时就是刚认领的那一条。
*/
internal fun shareConfirmClaim(
holding: ShareConfirmDraft?,
take: () -> SharedContent?,
): ShareConfirmDraft? = holding ?: take()?.let { ShareConfirmDraft(share = it) }
@@ -0,0 +1,757 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.parse
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.window.WindowDialog
/**
* 「这次分享进来的内容」的确认面板(主人 2026-10-06 拍板:**像其他 IM 一样,先确认再发**)。
*
* ## 它补上的那一步
* 在这之前,分享进来的内容一到连接就绪就被 `ChatViewModel.sendShared` 直接发了出去
* (见 `ChatScreen` 里那段落地的 effect),用户**没有任何确认机会**:一张图、一份文件、
* 一段从浏览器带来的链接,点完分享就已经在对话里了。面板把这一步补上——**只有点「发送」才真的发**,
* 「取消」则本次分享作废(不发送、也不塞回待发队列:那会让「用户刚刚作废的那一份」与「又来了
* 新分享」混成同一件事,见 [top.yeij.cyrene.domain.share.PendingShareHolder])。
*
* ## 取走只发生一次,面板只负责显示
* 这份 [share] **已经**从待发队列里被 `ShareIntake.take()` 取走了(原子出队、没有归还路径),
* 它现在活在 `ChatScreen` 的 Compose 状态里。所以本文件里的东西全是**无状态**的:面板不取内容、
* 不记账、也不判断「该不该弹」——那些判据与记账都在调用方,这里只把拿到的那一份画出来。
*
* ## 为什么用 Miuix 的 `WindowDialog`
* 项目里其余四个弹层(编辑 / 删除 / 附件预览 / 图片预览)用的是 Compose 的 `androidx.compose.ui.window.Dialog`
* + Miuix 内容,因为它当年在 Miuix 里找不到可用的弹层控件。Miuix 0.9.3 其实自带两族(**没有**旧版本的
* `extra.*` 包,别照那个去猜 import):`top.yukonga.miuix.kmp.window.*`(`WindowDialog` /
* `WindowBottomSheet`,真正的弹窗)与 `top.yukonga.miuix.kmp.overlay.*`(`OverlayDialog` /
* `OverlayBottomSheet`,在当前组合里当一层浮层)。
*
* 本面板用 **window 族**,两个理由:
*
* 1. **overlay 族在调用点上是走不通的**(不是偏好,是硬约束):`OverlayDialog` 靠
* `MiuixPopupUtils.DialogLayout` 把自己登记进 `LocalDialogStates` / `LocalRootDialogStates`,
* 而这两个 `staticCompositionLocalOf` **只有 Miuix 的 `Scaffold` 会 provide**(`basic/Scaffold.kt`)。
* 本面板的调用点在 `ChatScreen` 里那个 `Scaffold` 的**外面**(与附件预览 / 编辑 / 删除那三个弹层
* 同一处),拿不到宿主——而它的默认值是一个没人读的空列表,于是**不报错、也不显示**:
* 分享进来会变成「什么都没发生」,正是本项目最忌讳的那种静默失败。(同批的 W53S 用的是
* `OverlayDialog`,因为那个确认框就长在设置页的 `Scaffold` 里——两处调用点的处境不同。)
* 2. 它与既有那四个弹层是**同一个形态**:独立窗口、自带遮罩、点面板外或按返回键即取消
* (`onDismissRequest`,这里接的正是「取消」)。这里要的只是一次确认,不需要底部面板那套
* 可拖拽的手势。
*
* ## 预览与输入框的分工(W53X3 更正:输入框是「用户附加的描述」,**不是**正文的复刻)
* 上半是**预览**:这次分享进来的东西本身——图片缩略图 / 文件芯片 / **分享自带的正文**。那才是这次
* 要发出去的内容,留着它用户才知道「她将收到的是什么」。
* 下半是**输入框**:**你**要附加的描述,初值**恒为空**(`caption` 由调用方给空串),空框时显示
* 占位符 [SHARE_CONFIRM_CAPTION_PLACEHOLDER](与输入栏那句「和昔涟说点什么…」同一个写法)。
* 发送时真正发出去的正文由 [shareConfirmBody] 拼:分享正文(若有)+ 用户描述(若有),两段都在时
* **空行分隔**,只有一段就只用那一段。
*
* ⚠️ 这里曾经把两件事搞成一件(W53X 的第一版):输入框被预填成分享进来的正文,在真机上就渲染成
* 「同一句话排两遍」——预览的「分享的文字」块一遍、输入框里又一遍,而输入框里那句还看着像一段
* 只读展示。错在「这次要发什么」与「你想补一句什么」本来是两件事:前者归预览,后者才归这个输入框。
*/
@Composable
fun ShareConfirmPanel(
share: SharedContent,
caption: String,
onCaptionChange: (String) -> Unit,
onSend: () -> Unit,
onCancel: () -> Unit,
) {
val preview = remember(share) { shareConfirmPreview(share) }
// `show = true` 恒成立:这个面板只在有内容可确认时被调用方组合出来(`shareConfirm?.let { … }`),
// 关掉它靠的是调用方把那格状态清空(取消 / 发送后的清理都在那边)。
WindowDialog(
show = true,
title = SHARE_CONFIRM_TITLE,
onDismissRequest = onCancel,
) {
Column(modifier = Modifier.fillMaxWidth()) {
if (!preview.isEmpty) {
ShareConfirmPreviews(preview)
Spacer(Modifier.height(12.dp))
}
TextField(
value = caption,
onValueChange = onCaptionChange,
modifier = Modifier.fillMaxWidth(),
label = SHARE_CONFIRM_CAPTION_PLACEHOLDER,
useLabelAsPlaceholder = true,
maxLines = 4,
)
Spacer(Modifier.height(20.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
// 「取消」放在「发送」之前(与编辑 / 删除那两个弹层同一个排法):右端永远是那枚
// **会真的改动东西**的键。
TextButton("取消", onClick = onCancel)
Spacer(Modifier.width(8.dp))
TextButton(
text = "发送",
onClick = onSend,
// 什么都没有可发时这枚键不该可点:「发送」发出去的那段正文就是 [shareConfirmBody]
// 拼出来的那一段,而 `sendShared` 在「正文空白 + 没有图片 + 没有文件」时会**原地
// 返回**(一次发送都不会发生)。判据是纯函数 [shareConfirmCanSend],与那条早退同源。
enabled = shareConfirmCanSend(share, caption),
)
}
}
}
}
/**
* 这次分享**该展示成哪几样东西**(纯数据,UI 照着渲染)。
*
* 与 [SharedContent] 不是同一个东西:[SharedContent] 是「用户分享了哪些字段」(还带着丢件计数、
* 超限计数这些**告知**用的账),本类型只保留**看得见的那几样**。混用一个类型的话,渲染层迟早会去
* 读那些计数字段并顺手显示出来,而它们该说的话由 `shareNoticeFor` 统一成句(它是唯一一处出处)。
*/
data class ShareConfirmPreview(
/** 要显示的图片(本端 cache 里的 `file://` 地址,顺序即发送顺序)。 */
val images: List<String>,
/** 要显示的文件(显示名 + 体积由既有的 `DocumentChipRow` 负责换算)。 */
val documents: List<SharedDocument>,
/** 要显示的正文;空白已归一成空串。 */
val text: String,
) {
/** 这份分享带不带正文(空串与「全空白」都算不带——只显示一行空气不是预览)。 */
val hasText: Boolean get() = text.isNotEmpty()
/** 三样都没有 = 这次分享**没有可预览的内容**(此时预览区整块不出现)。 */
val isEmpty: Boolean get() = images.isEmpty() && documents.isEmpty() && !hasText
}
/**
* 把一份分享折算成要显示的那几样(**纯函数**,可单测)。
*
* 两处过滤是**空的也算没有**:一条空地址画出来就是一个空白缩略图(用户会以为图坏了),
* 一段全空白的正文占着一行什么也不说。正文两侧的空白一并去掉,与
* [top.yeij.cyrene.domain.share.ShareRules.extract] 取正文那一处同一个口径。
*
* 顺序原样保留:多张图的顺序**就是发送顺序**(第一条消息带第一张,见 `imageSendPlan`),
* 预览里换一个顺序就是在替用户改他要发的东西。
*/
fun shareConfirmPreview(share: SharedContent): ShareConfirmPreview = ShareConfirmPreview(
images = share.images.filter(String::isNotBlank),
documents = share.documents.filter { it.uri.isNotBlank() },
text = share.text.trim(),
)
/**
* 点「发送」时**真正发出去的那段正文**(**纯函数**,可单测):分享自带的正文 + 用户附加的描述。
*
* 三段穷举(这也是它可测的全部形状):
* - 两段都有 ⇒ `分享正文` + [SHARE_CONFIRM_BODY_SEPARATOR] + `用户描述`(**空行**分隔:两段话挨在
* 一起时读者分不清哪句是谁说的;空行是「这是两段」的最小表达,也让正文里本来换行的人不受影响);
* - 只有一段 ⇒ 就是那一段(**不补分隔符**:给一段话收个尾是凭空多出来的东西);
* - 都空 ⇒ 空串(此时能发的只剩图片 / 文件,正文那一栏本来就该空着)。
*
* **内容一字不丢**是红线,这一条覆盖三件事:
* 1. 分享正文按 [shareConfirmPreview] 的口径两侧去空白 —— 与 `ShareRules.extract` 取正文、
* 以及 `sendShared` 开头 `share.text.trim()` 是**同一个**口径,所以「这里非空」≙「那边发得出去」;
* 2. 用户描述同样只去两侧空白,**中间一个字不动**(用户自己换的行是他打的,不是我们排版的地方);
* 3. 一律**不加**来源标记 —— 不拼「[用户分享]」、不加引号、不套模板。来源标识走后端的独立
* `RoleSystem` 来源消息 + 结构化 `SenderName`(`02/F4` / `design/11 §10`);把标记拼进正文
* = 让用户正文冒充系统指令,那是项目红线。
*
* 顺序是「分享正文在前、用户描述在后」:先有「她收到的那份东西」,再有「你补的那句话」——反过来的
* 话,用户想给一条链接加注释就得先读注释再读链接,而他要注释的正是那条链接。
*/
fun shareConfirmBody(share: SharedContent, caption: String): String {
val shared = shareConfirmPreview(share).text
val own = caption.trim()
return when {
shared.isEmpty() -> own
own.isEmpty() -> shared
else -> shared + SHARE_CONFIRM_BODY_SEPARATOR + own
}
}
/**
* 两段正文之间的分隔(见 [shareConfirmBody]):**一个空行**(`\n\n`),不是空格、也不是一行 `——`。
*
* 只在**两段都在**时出现:`\n\n` 是任何聊天客户端都认的「两段」写法,而多出来的装饰性分隔行
* (`---`、`· · ·`)会被对面当成内容读进去。
*/
const val SHARE_CONFIRM_BODY_SEPARATOR = "\n\n"
/**
* 面板里那枚「发送」该不该可点(**纯函数**,可单测)。
*
* 判据与 `ChatViewModel.sendShared` 开头那条早退**是同一条**:那边算的是
* `share.text.trim()`(也就是本端 [shareConfirmBody] 会拼进去的那一段)非空、或图片非空、或文件非空;
* 三样都空时它原地 `return`——一次发送都不会发生。此时把键画成可点的,用户按下去只会看到面板关掉、
* 什么也没发生,而他会以为自己少发了一条。所以这里回 `false`。
*
* 正文那一支直接问 [shareConfirmBody](**同一个**出处,不是另写一遍 `text + caption` 的非空判断):
* 两条各写各的判据迟早会分叉,那时按钮会在「真发得出去」的时候变灰,或者反过来。
*
* ⚠️ 三样里**任意一样非空就可发**:纯图片 / 纯文件的回合服务端**支持**(契约:`text` / `image` /
* `documents` 任一项非空即可),本端也照旧只发得出去的那几样,不因为「没写文字」而拦下它。
*/
fun shareConfirmCanSend(share: SharedContent, caption: String): Boolean {
val preview = shareConfirmPreview(share)
return shareConfirmBody(share, caption).isNotEmpty() ||
preview.images.isNotEmpty() ||
preview.documents.isNotEmpty()
}
/** 面板标题(与顶栏那句「与昔涟」同一称呼)。 */
const val SHARE_CONFIRM_TITLE = "发送给昔涟"
/**
* 输入框的占位符(`useLabelAsPlaceholder`:**只在框里没字时**显示)。
*
* 与输入栏那句「和昔涟说点什么…」同一个写法、同一个语气:它说的是**你**要补一句什么。
* 框里的字是用户自己打的、默认没有(初值恒为空)——所以这句占位符是常态而不是罕见档。
*/
const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
/**
* 预览区(图片 / 文件 / 正文)。
*
* ## 高度上限与内部滚动
* Miuix 那两族弹层的 `content` **不是 `ColumnScope`**(是普通的 `@Composable () -> Unit`),
* 所以这里用不了 `Modifier.weight(1f)`(那是 `ColumnScope` 的扩展)——预览区没法「占剩下的高度」。
* 极端的一次分享(4 张图 + 5 份文件 + 一段长正文,键盘还弹着)会把这一个面板顶得很高,
* 而面板在手机上贴着屏幕底(`BottomCenter`):超出的部分会把下面的输入框与两枚按钮挤出屏幕。
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
* 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态
* [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
*
* ## 正文走**与气泡同一套解析**(W65),所以这一窗里排的是「气泡块」而不是一段原样的字
* 正文不是原样铺进去的:它先过 [top.yeij.cyrene.domain.parse.parse]——与 `MessageBubble` 里那条
* 完全同一个函数、同一个口径([sharePreviewBlocks] 只是给结果加了一道兜底)。于是在这里看到的就是
* 发出去之后气泡里的样子:每个 `<p>…</p>`(无标签时按空行分段)是**一个独立的块**,
* `<action>` / `<narration>`(以及星号那几种写法)不当正文、而是**灰斜体小字**的旁白块,标签本身
* 一个字符都不显示。
*
* 这一层修掉的是 W61 审查发现的一处「所见非所发」:预览此前把正文 `trim` 之后原样显示、**不过解析
* 器**,而发送后的气泡过解析器——分享一段 `"<p><p>正文</p>"` 进来,**预览里是一串标签、气泡里只剩
* 「正文」**。用户按发送之前看到的不是他将要发出的东西。
*
* ## 正文给三行,但三行之外是「滚」不是「省略号」
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
* 就成了一个阅读器。所以正文那一窗**恒为三行高**([SHARE_PREVIEW_TEXT_LINES]),面板不随正文长短
* 变化。
*
* 但三行之外**不再收成省略号**(W54D 主人拍板改掉了 W53X 的老行为):W53X3 之后**面板里再没有第二
* 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断
* 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗
* 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。
*
* ## 三行读不下时,再给一次「展开」
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
* (W56 拍板:滚动与展开**两个都要**)。所以这一窗**装不下**里面那几块时(判据是
* [sharePreviewTextOverflowsWindow]:量出来的内容自然高度比窗口高,正好放满不给)卡片右上一枚
* 「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。装得下时这一枚
* **不出现**——短正文上摆一枚点了没反应的「展开」比没有更糟。
*
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
*/
@Composable
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
// 展开是用户对**这一段正文**的选择,键在 `preview.text` 上:换一份分享就回到默认的收起,同一份
// 分享里重组(写描述、列表刷新…)保持用户选过的样子。
//
// ⚠️ 转屏(Activity 重建)会回到收起:跨转屏要活下来就得进 `ChatUiState`——W54C 给「已取走的
// 分享」与描述走的就是那条路,而那是 `ChatScreen` / `ChatViewModel` 的地盘(本批不许碰)。
// `rememberSaveable` 是本仓一次都没用过的新写法,而且弹层窗口有没有带上 saveable registry 没在
// 真机上验过,不在这里试——如实留给主人定。
var textExpanded by remember(preview.text) { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = sharePreviewMaxHeight(textExpanded))
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (preview.images.isNotEmpty()) {
// 与输入栏上方那排待发图片预览同一形态(同样 64dp、同样的圆角与间距),只是**没有**
// 逐张移除的叉:反悔的粒度是整份分享(「取消」),这里不另开一条「只丢掉其中一张」的路
// ——那条路要么改动待发队列、要么改动已取走的那一份,两种都会被「又来了新分享」搅浑。
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
preview.images.forEach { uri ->
AsyncImage(
model = uri,
contentDescription = "这次分享的图片",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp)),
)
}
}
}
if (preview.documents.isNotEmpty()) {
// 文件芯片就是气泡里那一排(显示名 + 体积),一枚都不藏:上限 5 份会占两到三行。
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
}
if (preview.hasText) {
// 「装不装得下」只有排完才知道(折行位置由字号与系统的大字体缩放决定,块数与旁白行数更是
// 解析出来的),所以它由正文那一窗量出来之后回调上来,标签这一行凭它决定要不要给出控件。
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MiuixTheme.colorScheme.surfaceContainer)
.padding(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "分享的文字",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
// 标签是这一行唯一的权重子项,`fill = false` 让这五个字只占自己需要的宽度;
// 真正保证展开控件不被挤掉的是 Row 的测量顺序——**非权重子项(控件与它前面
// 的间距)先量**,权重只分配剩下的空间(同 `SessionDrawerContent` 里那一行)。
modifier = Modifier.weight(1f, fill = false),
)
if (bodyOverflows) {
// 控件挂在**标签这一行**,不挂在正文末尾:展开后正文可能有好几屏,末尾那枚
// 「收起」就成了要滚到底才找得到的东西。
Spacer(Modifier.width(8.dp))
ShareConfirmExpandToggle(
expanded = textExpanded,
onToggle = { textExpanded = !textExpanded },
)
}
}
ShareConfirmBody(
text = preview.text,
expanded = textExpanded,
onOverflowChange = { bodyOverflows = it },
)
}
}
}
}
/**
* 「展开 / 收起」那一枚(见 [ShareConfirmPreviews])。
*
* 为什么是两个字而不是箭头图标:基础图标那一套(`MiuixIcons.Basic.*`)里没有展开 / 收起这一类字形
* (只有 `Close` / `Search` / `SearchCleanup` / `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`
* 七枚),而这两句话本身就说清了点下去会怎样。另一处折叠入口——`AdminOverviewScreen` 的路径组头
* ——用的是扩展图标里的 `ChevronForward` 加旋转,那是列表行里的形态;这里是一张阅读用的卡片,
* 一句话更省地方,也更好认。
*
* 用 `Text` + `clickable` 而不是 `TextButton`:后者自带按钮高度,会把这一行(以及整块预览)撑高
* 一截——同 `MessageBubble` 里那枚「更多」的取舍。
*/
@Composable
private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
Text(
text = if (expanded) SHARE_PREVIEW_COLLAPSE_LABEL else SHARE_PREVIEW_EXPAND_LABEL,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
// 内边距写在 `clickable` **后面**:命中的就是这块带内边距的区域——只包住字身的触点太小。
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/** 相邻两个气泡块之间的间距(见 [ShareConfirmBody])。块与块靠这一层间距分开,不靠气泡底色。 */
private val SHARE_PREVIEW_BLOCK_GAP = 6.dp
/** 一个块里旁白行与正文行之间的间距(气泡里那两行也挨得很近,与 `AnnotationLine` 的 2dp 同源)。 */
private val SHARE_PREVIEW_LINE_GAP = 2.dp
/**
* 正文那一窗:**高度恒为三行、再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
* 用户点了「展开」之后这一窗撤掉自己的上限,全部内容交给外层预览区那一层滚(W56)。
*
* 里面排的**不是一段原样的字**,而是 [sharePreviewBlocks] 给出的那几个「气泡块」(W65 起走与
* `MessageBubble` 同一个 [parse])。每个块自己排旁白行与正文行,块与块之间留
* [SHARE_PREVIEW_BLOCK_GAP]。
*
* ## 为什么量行高,而不是写一个 dp 常量
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
* 所以这里改为**量**:任一排版结果都能给出「一行多高」(`getLineBottom(0) - getLineTop(0)`),
* 窗口高度 = 它 × [SHARE_PREVIEW_TEXT_LINES]([sharePreviewWindowHeight])。
*
* **为什么不再取「第三行的底边」**(那是 W65 之前的量法):那时这一窗里只有一个 `Text`,
* `getLineBottom(2)` 就是那一个文本的三行底。现在里面是**多个块**,每块还有自己的旁白行与间距——
* 在任何一个块上取「第三行」都不再是这个窗口的第三行。行高则是唯一一个跨块也成立、且与块数无关的
* 量:窗口恒为「三行字高」,块与旁白占掉多少就从这三行里吃掉多少(这正是它的语义)。
*
* ## 「装不下」是**量**出来的,不是**数**出来的(W65 换掉的判据)
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > 3`)。多块之后行数不再是一个数得出来的量:
* 旁白行与块间距都占高度,却不属于任何一块的「行数」;字号缩放与折行位置也全落在行数之外。按行数算,
* 两块各三行加起来是 6、窗口只装得下 3 行——这条能对,但「一块三行 + 一条旁白 + 一层间距」这种更常见
* 的形状就数不准了,而这恰是解析之后最常见的形状。
*
* 所以现在比的是**高度**:内容排下来的自然高度 vs 这一窗的高度([sharePreviewTextOverflowsWindow])。
* 两个量都是排完才知道的:
* - 窗口高由上面的行高乘三给出;
* - 自然高由 [androidx.compose.ui.layout.onSizeChanged] 读到——它挂在 `verticalScroll` **里面**,
* 而 `verticalScroll` 给子项的是无界高度,所以读到的是内容的**自然高度**(不受这一窗上限影响的那
* 一个,收起态与展开态读到的是同一个数)。
*
* 「自然高度 > 窗口高度」就是「窗里还有东西没露出来」,正是那枚「展开」承诺的事;旁白、块间距、折行、
* 系统大字体缩放全被算在同一把尺子上——它可自证的地方在于:两边都是这一帧真实排出来的像素,没有一处
* 是我们估的行数或猜的 dp。
*
* ⚠️ 这个比较在**组合期**算完、由 [SideEffect] 报上去,**不**在排版回调里算:两个量都来自排版回调,
* 而在回调里读到的是**上一帧**组合里的值(`contentHeight` 与 `cap` 都不在本帧的组合里),在那里直接比
* 会永远差一帧——首帧报一次「不超窗」之后就再也不会报,长正文上那枚「展开」永远不出现。组合期读到的
* 一定是最新的一帧,代价只是一次幂等的重复上报([SideEffect] 每次组合都跑,值没变就是空写)。
*
* ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃)
* `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量,
* Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩**
* (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。
*
* 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它给出的**恒是一个有限值**;
* 而「这一层干脆不设上限」只发生在展开态——那时它连 `verticalScroll` 一起去掉(见下一节),
* 于是「无界高度 + 滚动」这个组合在两种状态下都不成立:
* - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限;
* - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层
* 有限的约束在管事(不是「没有约束」)。
*
* 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,块的内容、宽度
* 与样式都没变,行高与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行高一样)。
*
* ## 展开态为什么干脆不留这一层上限
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
* (手指先被内层吃掉、内层滚到底才轮到外层),而两层的上界还互相牵制——最容易出的错就是「明明展开
* 了、还剩半截读不到」。所以展开态**只留外层那一层**:预览区的上限抬到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
*
* ## 首帧的那次回流:留着,且确认它看不见
* 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次,
* 下一帧才收到三行。真机上**看不见**跳动,三条理由:
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
* 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化,
* 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现
* (自然高度是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,
* 行高不变。
* 3. 首帧那一次比出来的是「自然高度 vs 150dp 兜底」而不是「vs 三行」:那一帧的结论**可能是错的**
* (一段四五行的正文在 150dp 下看着「装得下」),但它在下一帧就被纠正,而这一枚控件本来就要到
* 第二帧才可能摆上——所以这条错判永远看不见。
*
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
* 为一个看不见的跳动换掉结构不划算,所以不做。
*
* ## 两层滚动怎么分工
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
* 就走嵌套滚动,不用额外接线。
*
* ⚠️ **展开态必须把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚。** 只去掉上限是不够的 ——
* 外层 [ShareConfirmPreviews] 自己就是一个 `verticalScroll`,它给内容的是**无界高度**;这一层若
* 留着 `verticalScroll`,那个 scroll 就会在无界高度下被测量 ⇒ 抛同一个
* `IllegalStateException`(W58 真机在「点展开」的那一下崩了第二次,就是这么来的)。
* 展开后读不读得到尾,只取决于外层那一层。
*/
@Composable
private fun ShareConfirmBody(
text: String,
expanded: Boolean,
onOverflowChange: (Boolean) -> Unit,
) {
val density = LocalDensity.current
val blocks = remember(text) { sharePreviewBlocks(text) }
// `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。
var cap by remember(text) { mutableStateOf<Dp?>(null) }
// 内容排下来的**自然高度**(见 KDoc:量在 `verticalScroll` 里面,拿到的是不受上限影响的那一个)。
var contentHeight by remember(text) { mutableStateOf(0) }
// 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。
val scroll = rememberScrollState()
val window = if (expanded) {
// 展开态:上限与滚动**一起**去掉(见 KDoc)。内容自然撑开,交给外层预览区那一层滚。
Modifier
} else {
Modifier
.heightIn(max = shareConfirmWindowCap(cap))
.verticalScroll(scroll)
}
// 一行有多高:正文行与旁白行同为 `footnote1`,所以第一个排完的文本就能给(不限是哪一块的哪一行)。
// 量到一次就定死——`cap` 只在这一个分支里被写、且只从 `null` 变成一个值,不会来回跳。
val measureLine: (TextLayoutResult) -> Unit = { layout ->
if (cap == null && layout.lineCount > 0) {
val line = layout.getLineBottom(0) - layout.getLineTop(0)
// 0 或负数(还没排稳)不记:150dp 的兜底比一扇零高的窗好(同 [shareConfirmWindowCap] 的取向)。
if (line > 0) cap = sharePreviewWindowHeight(with(density) { line.toDp() })
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.then(window)
.onSizeChanged { contentHeight = it.height },
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_BLOCK_GAP),
) {
blocks.forEach { block -> ShareConfirmBlock(block, measureLine) }
}
// 见 KDoc:在组合期算完、由 SideEffect 报上去(两个量在排版回调里读到的是上一帧的值)。
val overflows = sharePreviewTextOverflowsWindow(
contentHeightPx = contentHeight,
windowHeightPx = with(density) { shareConfirmWindowCap(cap).roundToPx() },
)
SideEffect { onOverflowChange(overflows) }
}
/**
* 预览里的**一个气泡块**:上依附的旁白 + 正文 + 下依附的旁白。
*
* 这个顺序就是 `MessageBubble` 里那个 `Bubble` 的顺序(`head` → 正文 → `tail`),用的也是同一个
* [parse] 给出的同一批块——「预览里长这样」与「发出去之后长那样」因此是**同一套判据**的两次渲染。
*
* 块与块之间只留一层间距(外层 [ShareConfirmBody] 的 `spacedBy`),**不画气泡底色**:这一栏是面板里
* 的一张卡片(`surfaceContainer` 底),而气泡底色在聊天里表示「谁说的」——用户那条是 `primary` 色、
* 她那侧是灰。在确认面板里复刻一层假气泡底色会让人以为这里已经是聊天记录;而且那层内边距要从三行窗
* 里再吃掉近一行,正是 W54D 之后最不该动的那点可见正文。
*/
@Composable
private fun ShareConfirmBlock(block: ParsedBubble, onTextLayout: (TextLayoutResult) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(SHARE_PREVIEW_LINE_GAP),
) {
ShareConfirmAnnotation(block.head, onTextLayout)
if (block.body.isNotEmpty()) {
Text(
text = block.body,
style = MiuixTheme.textStyles.footnote1,
onTextLayout = onTextLayout,
)
}
ShareConfirmAnnotation(block.tail, onTextLayout)
}
}
/**
* 预览里的旁白行:**灰斜体小字**——与气泡里那行(`MessageBubble` 的 `AnnotationLine`)同一个形态、
* 同一种拼法(多个片段用一个空格连起来),字全是 [parse] 给的那几个 `ContentPart` 的原文。
*
* ⚠️ 这里**不**读 `LocalAppPrefs.showNarration`(气泡那行会读)。那个偏好管的是**聊天记录里**要不要
* 显示旁白,而这块面板回答的是「她将收到的是什么」:按那个偏好把旁白藏起来,就等于让用户在按下发送
* 之前看不到这次要发出去的一部分内容——而 W61 审查发现的那处「预览里是标签、气泡里只剩正文」,错的
* 正是同一件事(看到的不是要发的)。要改成与气泡一样随时可以,但那是主人拍板的事,不在这里顺手做。
*/
@Composable
private fun ShareConfirmAnnotation(parts: List<ContentPart>, onTextLayout: (TextLayoutResult) -> Unit) {
val text = parts.joinToString(" ") { it.text }.trim()
if (text.isEmpty()) return
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
fontStyle = FontStyle.Italic,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
onTextLayout = onTextLayout,
)
}
/**
* **收起态**那一窗的高度上限(**纯函数**,可单测)。
*
* 量到过就用量的,还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 兜底必须是**有限值**,因为
* `heightIn` 与 `verticalScroll` 就在同一串 modifier 上。
*
* 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」
* 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。
*
* ⚠️ 返回值**永远是一个有限值** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而
* `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩
* (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。
*
* 所以非有限的那几个取值([Dp.Infinity] 与 [Dp.Unspecified],后者是 `NaN`)**与「还没量到」同等对待**,
* 一律回落 [SHARE_PREVIEW_MAX_HEIGHT]:这条 KDoc 写的是一条**判据**,而不该只是对调用方的请求——本函数
* 是这一层唯一的出口,它自己要能保证「给出去的上限总是有限」。今天唯一的调用方传的是 `null` 或
* [sharePreviewWindowHeight] 的结果(正数 × 三,恒有限),所以这一道守卫不改变任何现状;它挡的是
* **下一次**有人顺手把 `Dp.Infinity` 当「不设上限」传进来(那正是 W58 第一次崩溃的形状)。
*/
internal fun shareConfirmWindowCap(measured: Dp?): Dp =
measured?.takeIf { it.value.isFinite() } ?: SHARE_PREVIEW_MAX_HEIGHT
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
/**
* 预览区在**展开态**的高度上限(用户点了「展开」之后,见 [ShareConfirmPreviews])。
*
* 为什么要抬:展开是**用户自己的一个动作**,要的就是「一次多看几行」。上限不动的话这一窗永远只有
* 三行高,点开等于没点——正文那一窗顶到 [SHARE_PREVIEW_MAX_HEIGHT] 也只是把三行换成十来行里的一
* 小截。所以展开态必须真的给回高度。
*
* 为什么是 240dp 而不是更大:这个上限管的是**整个预览区**,而它下面还压着描述输入框(最多 4 行)
* 与两枚按钮,面板又贴着屏幕底(`BottomCenter`)、键盘一弹可用高度只剩半屏上下。150 → 240 在默认
* 字号档上把正文窗从三行抬到十行左右(`footnote1` 13sp,行高由系统字体决定、随机型与大字体设置一起
* 变),加上标题 / 输入框 / 按钮之后整块仍在半屏出头;再往上(例如给到整屏的六成)先被顶掉的会是
* 两枚按钮。
*
* ⚠️ 这个数**没有在真机上量过**,是按上面那笔账取的保守值,观感由主人验收。要调只改这一处:
* 预览区那一层的高度上限取的就是 [sharePreviewMaxHeight] 这一个出口。
*/
internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp
/**
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
*
* 它同时是窗口高度那笔账里唯一的数:窗口高 = **量到的行高** × 这个数([sharePreviewWindowHeight])。
*
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
*/
internal const val SHARE_PREVIEW_TEXT_LINES = 3
/**
* 展开控件上那两句话:点它就在这两句之间切换。
*
* 两句都必须是**动作**(「展开」/「收起」),不能是「更多」/「更少」——后者说的是内容有多少,读的人
* 不知道点下去会发生什么。
*/
internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开"
internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起"
/**
* 三行窗口有多高(**纯函数**,可单测):量到的一行高度 × [SHARE_PREVIEW_TEXT_LINES]。
*
* 行高只能是**量**出来的(见 [ShareConfirmBody] 的「为什么量行高,而不是写一个 dp 常量」),所以这里
* 只做乘法、不掺任何 dp 常量:大字体档下量到的行高更大,窗口就跟着更高,「三行」始终是三行。把这个数
* 留在唯一一处,改它只改 [SHARE_PREVIEW_TEXT_LINES]。
*/
internal fun sharePreviewWindowHeight(lineHeight: Dp): Dp =
lineHeight * SHARE_PREVIEW_TEXT_LINES
/**
* 预览正文区该渲染成哪几块(**纯函数**,可单测)——**走的就是气泡那一套解析**(W65 起)。
*
* 用的是 `MessageBubble` 里那同**一个** [parse],连兜底都是同一条:解析结果为空(或全是被丢掉的空块)
* 时回落到「整段原样显示」的那一块,与那边 `parsed.ifEmpty { listOf(plainBubble(content)) }` 逐字同义。
* 所以「预览里看不到那一串标签」不是本函数自己剥出来的,而是 [parse] 的既有语义(落单标签一律剥掉、
* 完整块切气泡、旁白另成一类)——这里一个字符都不改写、也不另写一套「去标签」。
*
* **兜底不是可选项**:`parse` 对某些输入会回空列表(纯空白是既有的一档,落单标签被剥光是另一档,
* 见 `ShareTextPipelineTest` 里的 `<p><p>` 那几条),而面板的 [ShareConfirmPreview.hasText] 只管
* 「正文非空白」,管不到解析结果。少了这条兜底,用户就会在一块**空白**前面按发送——而正文这一栏是他按
* 发送之前唯一能核对的东西。
*
* 正文在这里**不再 trim**:两侧空白早在 [shareConfirmPreview] 那一站去过一次(`share.text.trim()`,
* 与 `sendShared` 开头同一个口径),这里再来一次只会多一个说法不一的出处。
*/
internal fun sharePreviewBlocks(text: String): List<ParsedBubble> =
parse(text).filterNot { it.isEmpty }.ifEmpty {
listOf(ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList()))
}
/**
* 这一窗**装不下**里面的内容吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
*
* ## 判据是**高度**,不是行数(W65 换掉的)
* W65 之前这里数的是**一个 `Text` 的行数**(`lineCount > SHARE_PREVIEW_TEXT_LINES`)。正文改走解析
* 之后这一窗里是**多个块**,每块还各自带旁白行与块间距,于是行数不再是一个覆盖得住窗口的量:
*
* - 旁白行与块间距都占高度,却谁也不属于哪一块的「行数」;
* - 折行位置、字号与系统大字体缩放都落在行数之外:「两个块各占两行、中间一层间距」这种最常见的形状,
* 按行数只能说「4 > 3」,说不出它到底差多少,也说不出一块内部的折行吃掉了第二块的位置;
* - 反过来也漏:「一块两行 + 一条两行的旁白」数出来是 4 行,但它真正超出的高度取决于那两层间距与
* 旁白自己的行高——行数这个量从头到尾没有把它们算进去。
*
* 所以比的是**两边真实排出来的像素**:内容自然高度(由挂在 `verticalScroll` 里面的
* [androidx.compose.ui.layout.onSizeChanged] 读到,见 [ShareConfirmBody])vs 这一窗的高度
* ([sharePreviewWindowHeight] 那个口径:量到的行高 × 三)。「自然高度 > 窗口高度」**就是**「窗里还有
* 东西没露出来」,正是那枚「展开」承诺的事。它可自证的地方在于:两个量都是这一帧真实排出来的像素,
* 没有任何一处是我们估的行数或猜的 dp。
*
* 也不是 `hasVisualOverflow`:裁剪落在这一窗外面那层 `Column` 的 `heightIn(max = cap)` 上,而每个
* `Text` 自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的就是无上限约束),所以它们永远报
* 「没有溢出」——拿它当判据会把控件在最长的那种正文上漏掉。
*
* ## 边界与首帧
* 严格大于:**正好一样高**就是整段都在窗里(窗口底下一条不多),此时给一枚「展开」是点了没有反应的
* 假控件。
*
* 任何一边还没量到(`0`)都回 `false`:首帧摆上一枚点了没有反应的「展开」比晚一帧出现更糟。
*/
internal fun sharePreviewTextOverflowsWindow(contentHeightPx: Int, windowHeightPx: Int): Boolean =
contentHeightPx > 0 && windowHeightPx > 0 && contentHeightPx > windowHeightPx
/**
* 预览区当前该给多高(**纯函数**,可单测):收起态 [SHARE_PREVIEW_MAX_HEIGHT]、展开态
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
*
* 把「哪个状态用哪个上限」收在这一处,调用点([ShareConfirmPreviews])只读结论:单测钉住的就是这条
* 判据本身——两个状态与两个上限的对应关系要是被改反了(例如展开反而变矮),会在这里被抓住。
*/
internal fun sharePreviewMaxHeight(expanded: Boolean): Dp =
if (expanded) SHARE_PREVIEW_EXPANDED_MAX_HEIGHT else SHARE_PREVIEW_MAX_HEIGHT
/**
* 分享进来的一份文件 ⇒ 待发附件。
*
* `ChatViewModel.sendShared` 里也有同一段三字段的搬运(那边是发给上传链的),**不抽公共**是刻意的:
* 抽出来就要动那两个文件(一个是别的并行改动正在碰的 `ChatScreen` 附近,一个是发送路径),
* 而这一处只是给芯片显示用的**只读**形态——真正的发送仍然只经过 `sendShared` 那一条,
* 两条路各有各的用处。第三次复制出现时再抽(本项目对重复的硬规矩是三次起抽)。
*/
private fun SharedDocument.toDraftDocument(): DraftDocument = DraftDocument(
uri = uri,
displayName = displayName,
sizeBytes = sizeBytes,
)
@@ -20,7 +20,7 @@ enum class ShareLanding {
*/
TellOffline,
/** 连接就绪:取走一条并交给既有发送路径。 */
/** 连接就绪:取走一条并交给确认面板(点「发送」才真的发出去)。 */
Send,
}
@@ -41,9 +41,9 @@ enum class ShareLanding {
*
* ## 判据与优先级
* 1. **没有待发内容** ⇒ [ShareLanding.Nothing]:任何连接状态下都不做事、也不说话。
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并发送。它**压过**后面每一条——连接一到就该发,
* 哪怕此前已经等够久、那句「等连上会自动发」也说过了。返回值是**单值动作**,所以「发送」与
* 「告知」在类型上就不可能同时发生(不会既发了又报怨还没连上)。
* 2. **[WsState.Connected]** ⇒ [ShareLanding.Send]:取走一条并交给确认面板(点「发送」才真的发)。
* 它**压过**后面每一条——连接一到就该认领,哪怕此前已经等够久、那句「等连上会自动发」也说过了。
* 返回值是**单值动作**,所以「认领」与「告知」在类型上就不可能同时发生(不会既取了又报怨还没连上)。
* 3. 未就绪且**还没等够久**(`waitedLong = false`)⇒ [ShareLanding.KeepWaiting]:继续等,什么都不说
* (刚分享进来就弹一句「还没连上」是噪声,握手正常也就几百毫秒)。
* 4. 未就绪、已等够久、且**还没说过** ⇒ 说一次。说哪一句由连接状态分**两档**:可恢复的那三档
@@ -0,0 +1,43 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.domain.share.ShareTextSource
import top.yeij.cyrene.domain.share.SharedContent
/**
* 「这段正文是从对方给的**分享标题**回落来的」——面板上那句如实的小字(**纯函数**,可单测)。
*
* ## 它补的是哪一处
* 取正文的回落链([top.yeij.cyrene.domain.share.ShareRules.extract]:`EXTRA_TEXT` →
* `EXTRA_SUBJECT` → `EXTRA_TITLE`)让「对方只给了标题」的那次分享**不再静默**——用户可以确认、可以
* 取消。但回落来的那一段与真正文**长得一模一样**:预览里只有一句话时,用户会以为对方发来的就是这句
* 正文,其实本端只拿到了标题。所以这里给出一句**只出现在界面上**的说明。
*
* ## 红线:它绝不进正文
* 这句话是**本端渲染层**的信息,一个字都不许拼进将要发送的那段正文(`shareConfirmBody` 拼的是分享
* 正文 + 用户描述,与这里无关):把来源标记拼进正文 = 让用户正文冒充系统指令,是项目红线
* (见 `ShareRules` 的「为什么正文一个字都不加」)。所以这条消息的内容**永远只是正文本身**,
* 预览里那段字与发出去的那条消息逐字相同。
*
* ## 为什么 `Subject` 与 `Title` 共用一句
* 两档在**数据**上分得很清([ShareTextSource],排查时要看得见对方把内容塞进了哪个字段),但对用户说
* 的是同一件事:标题与主题在他眼里没有区别。各写一句只会多出一处迟早会走样的措辞。
*
* ## 不说「正文」的那一档为什么回 `null`
* [ShareTextSource.Text] 是规范路径(正文本来就是正文):一切照旧,**一个字都不说**——正常落地时
* 说话才是噪声(与 `shareNoticeFor` 的静默边界同一条纪律)。
*
* @return 该说的那句话;没有要说的(正文来自 `EXTRA_TEXT`)回 `null`。
*/
fun shareTextOriginNotice(share: SharedContent): String? = when (share.textSource) {
ShareTextSource.Text -> null
ShareTextSource.Subject, ShareTextSource.Title -> SHARE_TEXT_FROM_TITLE_NOTICE
}
/**
* 那句话说出口的样子。
*
* 两件事要在同一句里说清:**它是什么**(对方给的分享标题)与**缺了什么**(对方没有提供正文)——
* 只说前一半,用户仍会以为这句话就是对方发来的正文;只说后一半,他不知道预览里那行字是从哪冒出来的。
* 刻意**不说**「失败」「错误」:这一次分享的内容一个字节都没丢,只是它本来就只有标题。
*/
const val SHARE_TEXT_FROM_TITLE_NOTICE = "这段文字来自对方的分享标题(对方没有提供正文)"
@@ -88,10 +88,13 @@ fun ToolCard(
FoldCardLabel("参数")
FoldCardBody(tool.arguments)
}
if (tool.output.isNotBlank()) {
// 结果区是否出现**完全**由 [toolResultTextOf] 决定:`null` 就整块不画(连标签和
// 上方那道 Spacer 都不画),于是「摘要缺席」时这一屏与后端补这个键之前**逐字节相同**。
val result = toolResultTextOf(tool)
if (result != null) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("结果")
FoldCardBody(tool.output)
FoldCardBody(result)
}
}
}
@@ -108,6 +108,49 @@ fun List<StreamingLeg>.finishingLeg(
fun List<StreamingLeg>.withoutLeg(bucket: TurnBucket): List<StreamingLeg> =
if (none { it.bucket == bucket }) this else filterNot { it.bucket == bucket }
/**
* **连接重建时收口每一路**(纯函数,[top.yeij.cyrene.ui.chat.settledAfterReconnect] 用它)。
*
* 与 [finishingLeg] 的关系:同一件事(这一路不再生长、思维链与工具卡不再声称自己在跑),
* 区别只在**触发点**——[finishingLeg] 是「收到了这一路的 `stream_end`」,这里没有那一帧,
* 是「连接重建了,而服务端那一轮在旧连接上,永远不会再有后续帧」(判据见
* [top.yeij.cyrene.ui.chat.isReconnect],理由见它那一份 KDoc)。
*
* 两条纪律:
* 1. **正文与思维链一个字都不删**——用户可能正读到一半,而这已经不是服务端能再给一次的东西;
* 2. **没有任何可显示内容的那一路整条丢掉**:`active = true` 而什么都不画时,[StreamingBubble]
* 会画一个只有光标的空气泡,收口它等于把它变成一个**一个像素都不画、却仍占一个列表项**
* (也因此占着「最新一项」这个位置)的幽灵——那种路丢掉是本函数的**唯一**删除动作,
* 而它删的是「本来就没有的东西」。
*
* 无变化时返回**同一个实例**(与 [finishingLeg] / `withoutLeg` 同一条纪律)。
*/
fun List<StreamingLeg>.allFinished(): List<StreamingLeg> {
val next = mapNotNull { it.finishedLeg() }
return if (next == this) this else next
}
/**
* 这一路**收口后**的样子;收口之后**什么都画不出来**时返回 `null`(整路丢掉,见 [allFinished])。
*
* 三件事与 [finishingLeg] 逐字同义:不再 `active`(光标停、兜底路径不再当它是新回合)、
* 思维链不再 `thinkingActive`(标题从「思考中…」变回「思维链」),外加工具卡也一起收口
* ([settledAfterReconnect],标题从「执行 X…」变回「工具调用 · X」)。
*
* ⚠️ `bubbles` / `thinking` / 工具卡的参数与输出**一个都没动**:这是「不再声称在跑」,
* 不是「清空这一路」。
*/
private fun StreamingLeg.finishedLeg(): StreamingLeg? {
val settled = state.copy(
active = false,
thinkingActive = false,
tools = state.tools.settledAfterReconnect(),
)
val anythingToShow =
settled.bubbles.isNotEmpty() || settled.thinking.isNotBlank() || settled.tools.isNotEmpty()
return if (anythingToShow) copy(state = settled) else null
}
/**
* 这条**已落库**消息处在哪一态(六态判定,规范 §1.1)。
*
@@ -131,13 +174,25 @@ fun ChatUiState.phaseOf(message: HistoryMessageDto): TurnPhase = turnPhaseOf(
* 这条**乐观气泡**处在哪一态(六态判定)。
*
* 乐观气泡还没有服务端 id,所以绑定的路**永远查不到**(`turn_begin` 的 `messageIds` 是服务端 id)
* ——它要么是 ① 发送中,要么(收到过入队回执)是 ③ 排队中。这正是规范 §1.1 里 ① / ③ 的
* 那两种视觉:半透明「发送中…」与浅灰「排队中」。
* ——它要么是 ① 发送中,要么是 ③ 排队中。这正是规范 §1.1 里 ① / ③ 的那两种视觉:
* 半透明「发送中…」与浅灰「排队中」。
*
* ## ③ 的两个来源(W40 起)
* 「排队」这件事在本端有**两个来源**,都算 ③,但**来源不同、要看得出区别**:
* 1. 收到过**服务端**的入队回执([ChatUiState.queuedKeys])——她忙,这条已受理、排在别人后面;
* 2. **本端**因为没连上把这条留住了([ChatUiState.queuedForConnection],主人 2026-10-05 裁决)——
* 这条**一个字节都还没上路**,连接恢复后会自动补发。
*
* 两者的差别由气泡小标在**文案上**说清(`pendingBubbleFooter`:③+等连接 = 「排队中 · 还没连上」),
* 而**不新增第七态**:六态模型(规范 §1.1)是 W30 定下的口径,`TurnPhase` 的每一个取值都在
* 界面与测试里有明确形状——为「另一种排队」再加一态,等于让所有按六态穷举的地方(渲染、测试、
* 并行规范)都跟着改一遍,而它回答的问题(「这条还没送到她手上」)与 ③ 本来就是同一个。
*/
fun ChatUiState.phaseOf(item: PendingMessage): TurnPhase = turnPhaseOf(
local = true,
binding = null,
queued = isQueuedMarked(item.localId),
// ③ 的两个来源(见上):入队回执,或本端因未连接把它留住了。
queued = isQueuedMarked(item.localId) || isAwaitingConnection(item.localId),
rejected = false,
)
@@ -0,0 +1,219 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.VoiceDto
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failureOf
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.voiceAudio
import top.yeij.cyrene.domain.chat.voiceDurationLabel
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 语音形态里那枚「这是个语音」的字样。它说的是**媒体类型**(判据 = 这条画的是语音形态,
* 见 `isVoiceMessage`),不是内容。
*/
const val VOICE_LABEL: String = "语音"
/** 加载中那一句(与 [VOICE_LABEL] 同一行,替换掉时长那一格)。 */
private const val VOICE_LOADING_LABEL = "加载中…"
/** 播放控件那枚图标的读屏名字 —— 同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
const val VOICE_PLAY_DESCRIPTION: String = "播放语音"
/** 正在放 / 加载中时那枚图标的读屏名字(点它 = 停)。 */
const val VOICE_STOP_DESCRIPTION: String = "停止播放语音"
/** 图标尺寸:与消息角标那枚「⋯」同一档,整条语音形态因此不比一条角标高多少。 */
private val VOICE_ICON_SIZE = 18.dp
/**
* 一条语音消息在**转写文本之外**的那个「语音」形态:`[播放/暂停] 语音 · 时长`。
*
* ## 画什么由三条契约判据决定(都在这一个函数里,不散落)
* - **🎤 判据 = `voice` 键存在 **且** `status != "failed"`**:本组件只有在这条消息要画语音形态时
* 才会被调用(调用点在 [MessageBubble],判据是 `isVoiceMessage`),所以这里既不认识正文里有
* 没有 `[语音]` 字样、也不认识 `status`——`failed` 那一态(没有可播的声音)根本不走这条路,
* 它由 `VoiceFallbackNote` 单独画一行灰色小标;
* - **无副本 ⇒ 不画播放控件**([voiceAudio] 回 `null`):四个键(`name`/`mime`/`size`/`url`)
* 没凑齐时只留「语音」这一格 —— 不能给一个点下去必然失败的按钮。时长若服务端给了照样显示
* (时长与副本**无关**);
* - **缺席就不显示时长**([voiceDurationLabel] 回 `null`):绝不显示 `0:00` 冒充,也不拿
* `size` 反算。
*
* ## 三处文案各自归谁
* - **正文**(转写 / 服务端的诚实提示)由 [MessageBubble] 照常渲染,本组件一个字符都不改、
* 也不隐藏它;
* - [note](`status = empty` 且正文为空时那句「没有识别出文字」)由调用方用纯函数
* `voiceNote` 算好传进来 —— 本组件不判 `status`;
* - [playback] 里这条消息的失败提示([failureOf])**只在失败时出现一行**,颜色用
* `colorScheme.error`:可见但不夸张(不弹窗、不占整行、不挡住正文)。
*
* ## 图标从哪来(查过 AAR,不是猜的)
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚(`Close` / `Search` / `SearchCleanup` /
* `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`),**没有播放 / 暂停字形**;但 **extended**
* 包里有真的 `Play` / `Pause`(`javap` 确认过 `PlayKt.getPlay(MiuixIcons)`),而本模块本来就在用
* extended(`MessageBubble.kt` 的 `MiuixIcons.More`)。所以这里用真的字形,**不**自绘几何图形:
* 自绘一个三角形在扫读上并不比官方图标便宜,还要自己承担「像不像播放键」的风险。
* 官方文档写 `MiuixIcons.Xxx` 的用法对人不对包,以 AAR 为准 —— 这两枚就是 extended 包里的。
*
* ## 点哪儿
* **整枚可点**(不只是那 18dp 的图标):读屏与手指都不必瞄准;图标只是「这里能按」的可见凭据
* (与 [LegBadge] 「看得出可点」同一条思路)。
*
* @param note 正文之外要多说的一句(没有就传 `null`)。
* @param playback 这条通道的当前状态(由调用方用 [rememberVoicePlayer] 那一个实例驱动)。
* @param onToggle 用户点了这枚语音:交给上层真正去取字节 / 放 / 停(本组件不发请求)。
*/
@Composable
fun VoiceMessageRow(
voice: VoiceDto,
messageId: String,
playback: VoicePlaybackState,
note: String? = null,
alignEnd: Boolean = false,
onToggle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val audio = voiceAudio(voice)
val active = playback.isActiveFor(messageId)
val loading = playback.isLoadingFor(messageId)
val failure = playback.failureOf(messageId)
val duration = voiceDurationLabel(voice.durationMs)
Column(
modifier = modifier,
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.then(
if (audio == null) {
Modifier
} else {
Modifier
.clickable(onClick = onToggle)
// 可点的那一层(整枚)自己报「点它会发生什么」;里面的图标因此只是
// 一笔装饰(`contentDescription = null`),读屏不会读到第二个焦点。
.semantics {
contentDescription = if (active) VOICE_STOP_DESCRIPTION else VOICE_PLAY_DESCRIPTION
}
},
)
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (audio != null) {
Icon(
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier
.size(VOICE_ICON_SIZE)
.clip(CircleShape),
)
}
Text(
text = VOICE_LABEL,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
when {
// 「现在在干什么」与「点它会怎样」分开说:图标回答后者,这一格回答前者。
// ⚠️ 只有**真的在取字节**时才说「加载中…」:`active` 还包含「正在响」,
// 拿它当条件会让一条 30 秒的语音整段播放期间都写着「加载中…」。
loading -> Text(
text = VOICE_LOADING_LABEL,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
duration != null -> Text(
text = duration,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
note?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
failure?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
}
}
}
/**
* `status == "failed"` 那条消息**没有语音形态**,但要多说的一句话(`voice` 键在、没有可播的声音)。
*
* ## 为什么这一行必须存在
* 这一态的消息正文照常按文字渲染(入站是服务端那行诚实提示,出站是合成降级后的原文本)。少了这一行,
* 一条「她本想用语音、合成降级成文字」的消息与一条普通文字消息**逐像素相同**——看的人无从知道这条
* 本来是一段声音,也看不出它与别的话有什么不一样。所以这枚小标不是装饰,它是这一态**唯一**的可见
* 凭据([VoiceMessageRow] 在这里根本不画,见 `isVoiceMessage`)。
*
* ## 样式为什么抄 [VoiceMessageRow] 里 note 那一行
* `footnote2` + `onSurfaceVariantSummary`:与语音形态旁边那几句说明(「没有识别出文字」/ 加载中 /
* 时长)**同一档**。这三处说的都是「关于这段声音的一句脚注」,同一个外观才让人读成一类东西;
* 换成正文那一档字级会被误读成消息内容的一部分,换成 `error` 又会把一次**降级**说成一次**失败**
* ——降级本身是服务端做对的一件事(她的话一个字没丢,只是没变成声音)。
*
* ## 不判 `status`,也不拼文案
* [text] 由调用方用纯函数 `voiceFallbackNote` 算好传进来(固定前半句 + 服务端 `fallbackReason` 原样)。
* 本组件只负责画:判据与措辞都在 domain 层单测钉着,这里再判一次就多出「两处判据不一致」的可能
* ——与 [VoiceMessageRow] 对 `note` 的处理同一条纪律。
*
* @param text 已经算好的那一整句(本组件不改一个字符)。
* @param alignEnd 用户侧(气泡在右):整行贴右;否则贴左。
*/
@Composable
fun VoiceFallbackNote(
text: String,
alignEnd: Boolean = false,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start,
) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
@@ -0,0 +1,349 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failureOf
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.voiceElapsedLabel
import top.yeij.cyrene.domain.chat.voiceReplyNote
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 输入栏那几个**语音**形态(录音中 / 待重发 / 她的语音回复),以及乐观气泡里那一枚「语音」。
*
* ## 为什么单独一个文件
* 它们四个共用同一套尺寸与配色,而这个文件之外没人需要知道这些细节:`ChatScreen` 只负责接线,
* `MessageBubble` 只调用最后一枚。放在一起,改一眼就能看全。
*
* ## 图标从哪来(查过 AAR,不是猜的)
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚,**没有麦克风**;**extended** 包里有真的
* `Mic` / `MicSlash`(`javap` 确认过 `MicKt.getMic(MiuixIcons)`),而本模块本来就在用 extended
* (`VoiceBubble.kt` 的 `Play` / `Pause`、`ChatScreen.kt` 的 `File` / `Image`)。
*
* ## 读屏名字即设备验收的 `content-desc`
* 四枚可点控件的 `contentDescription` 都抽成了常量([VOICE_RECORD_DESCRIPTION] 等):
* `uiautomator dump` 靠它们定位,改文案等于改验收口径,两处一起改。
*/
/** 「开始录音」那枚麦克风的读屏名字。 */
const val VOICE_RECORD_DESCRIPTION: String = "按住说话(点按开始录音)"
/** 录音中那枚麦克风(点它 = 结束并发送)。 */
const val VOICE_RECORD_STOP_DESCRIPTION: String = "结束录音并发送"
/** 录音中的「取消」。 */
const val VOICE_RECORD_CANCEL_DESCRIPTION: String = "取消这段录音"
/** 待重发那一段的「重发」。 */
const val VOICE_DRAFT_RETRY_DESCRIPTION: String = "重发这段语音"
/** 待重发那一段的「丢弃」。 */
const val VOICE_DRAFT_DISCARD_DESCRIPTION: String = "丢弃这段语音"
/** 她的语音回复的播放 / 停止。 */
const val VOICE_REPLY_PLAY_DESCRIPTION: String = "播放她的语音回复"
const val VOICE_REPLY_STOP_DESCRIPTION: String = "停止播放她的语音回复"
/** 关掉她的语音回复那一张卡片。 */
const val VOICE_REPLY_DISMISS_DESCRIPTION: String = "关闭这条语音回复"
/** 乐观气泡里那枚「语音」的字样(与接收侧的 [VOICE_LABEL] 同一个词,不另造)。 */
const val VOICE_CLIP_LABEL: String = VOICE_LABEL
/**
* 麦克风**没授权**时的如实告知。
*
* 说「到系统设置里允许」而不是再申请一次:本端只在第一次点麦克风时申请(平台规范),
* 被拒之后重复申请只会被系统静默挡回(Android 不再弹框),那样用户点十次都毫无反应。
*/
const val VOICE_PERMISSION_DENIED_MESSAGE: String =
"没有麦克风权限,到系统设置里允许后再试"
/**
* 有权限、但麦克风**现在起不来**(被别的应用占用 / 设备不支持这组采样参数)。
*
* 与 [VOICE_PERMISSION_DENIED_MESSAGE] 分开:那一档用户要去设置页,这一档要等一会或换个应用;
* 说成同一句就是把人引向一个解决不了问题的动作。
*/
const val VOICE_UNAVAILABLE_MESSAGE: String = "麦克风现在打不开,可能被别的应用占着"
/** 图标尺寸:与接收侧语音形态同一档。 */
private val VOICE_ICON_SIZE = 18.dp
/** 卡片底色与圆角:与 `VoiceMessageRow` 那一枚同形(同一个产品里的语音长一个样)。 */
private val VOICE_CARD_SHAPE = RoundedCornerShape(12.dp)
/**
* 录音中那一行:`🎤 录音中 · 0:07 [取消] [发送]`。
*
* 时长由**已收到的字节数**反算(见 `VoiceRecorder`),所以屏幕上这个数字与发出去的音频长度
* 严格一致。到 60 秒时 `VoiceRecorder` 会置「满了」标记、由调用方直接收尾——这里不做倒计时:
* 一个还在数的计时器在「已经满了」那一刻是假话。
*
* 两枚按钮分开(不是「点头像就发」那种手势):录音是一条**有去无回**的动作(这段 bytes 一旦
* 发出就落进服务端编排),取消必须和发送一样容易按到。
*/
@Composable
fun VoiceRecordingRow(
elapsedMs: Int,
onCancel: () -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.error,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "录音中 · ${voiceElapsedLabel(elapsedMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onCancel,
modifier = Modifier.semantics { contentDescription = VOICE_RECORD_CANCEL_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
Button(onClick = onSend) { Text("发送") }
}
}
/**
* 一段**录好但没发出去**的语音:`🎤 语音 · 0:07 [重发] [丢弃]`。
*
* 它只在一件事之后出现:`voice_input` 没能上行(连接没就绪 / 写帧失败)。此时那段字节**只在
* 内存里**,界面上必须给两条出路——重发(**复用同一个 `clientMsgId`**,服务端据此判重)或丢弃。
* 不给「退回到输入栏」那种出路:语音没有可以退回到输入框的形态。
*/
@Composable
fun VoiceDraftRow(
draft: VoiceDraftInfo,
onRetry: () -> Unit,
onDiscard: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(draft.durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
TextButton(
text = "重发",
onClick = onRetry,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_RETRY_DESCRIPTION },
)
TextButton(
text = "丢弃",
onClick = onDiscard,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_DISCARD_DESCRIPTION },
)
}
}
/**
* 她的语音回复:`[▶] 她的语音回复 · 0:03 [×]` + 她说的话([VoiceReply.reply])。
*
* ## 只有文字那一档
* `provider == "silent"`(或这一轮根本没有 audio)时**不画播放控件**,只画文字 + 一句
* [voiceSilentNote](「这一轮她只回了文字,没有语音」)。契约的硬要求是「UI 别把它画成播放器」;
* 补那句解释是为了让「她选择只用文字」与「播放器坏了」在屏幕上分得开。
*
* ## 可播放但播不了
* 音频在、本机解码 / 落盘失败时**照样画**播放控件:点了会走到 [failureOf] 那一行如实说
* 「播不了」——这比一个看起来什么都没有的控件诚实。
*/
@Composable
fun VoiceReplyRow(
reply: VoiceReply,
playback: VoicePlaybackState,
onTogglePlay: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val active = playback.isActiveFor(reply.id)
val loading = playback.isLoadingFor(reply.id)
val failure = playback.failureOf(reply.id)
val text = reply.reply
Column(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (reply.playable) {
// 整枚可点(不只是 18dp 的图标),读屏名字说清「点它会怎样」。
Row(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onTogglePlay)
.semantics {
contentDescription = if (active) {
VOICE_REPLY_STOP_DESCRIPTION
} else {
VOICE_REPLY_PLAY_DESCRIPTION
}
}
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
} else {
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
// 「现在在干什么」与「点它会怎样」分开说(与接收侧语音形态同一条纪律):
// 只有真的在取字节时才说「准备中…」,正在响时不冒充加载。
if (loading) {
Text(
text = "准备中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = onDismiss,
modifier = Modifier.semantics { contentDescription = VOICE_REPLY_DISMISS_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
}
}
text?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
)
}
// 「只有文字」与「本机没准备好」是两件事,各说各的那句(判据是纯函数)。
voiceReplyNote(silent = reply.silent, hasAudioFile = reply.file != null)?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
failure?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
}
}
}
/**
* 乐观气泡里那一枚「语音」:`🎤 语音 · 0:07`。
*
* 用户按住说话之后,气泡**立刻**出现(与文本消息的乐观路径同一个 `pending` 列表、同一个回滚),
* 只是正文还没到——转写要等服务端。所以这里只画媒体类型与时长,**不画播放控件**:
* 本地已经发出去的那段音频不在播放通道里(它已经在服务端了,回放应当走历史消息那条路)。
*/
@Composable
fun VoiceClipLabel(durationMs: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
}
@@ -0,0 +1,230 @@
package top.yeij.cyrene.ui.chat
import android.media.AudioAttributes
import android.media.MediaPlayer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.domain.chat.VOICE_UNPLAYABLE_MESSAGE
import top.yeij.cyrene.domain.chat.VoiceAudio
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failed
import top.yeij.cyrene.domain.chat.finished
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.prepared
import top.yeij.cyrene.domain.chat.pressed
import top.yeij.cyrene.domain.chat.silenced
import top.yeij.cyrene.domain.chat.voiceAudio
import top.yeij.cyrene.domain.chat.voicePlaybackFailureMessage
import java.io.File
/**
* 「同一时刻只有一条语音在响」的那条**播放通道**(接收侧:播放别人发来的语音)。
*
* ## 用什么放(不引任何新依赖)
* 系统 **`android.media.MediaPlayer`**(平台 API,API 1 起就有)。选它而不是 ExoPlayer / Media3:
* 项目纪律是加依赖要先问,而这里要的只是「放一段 ogg / wav / mp3 的本地文件」,平台的解码器够用。
* 音源是**本地文件**([load] 的产物):字节由 `VoiceStore` 取一次、落在 `cacheDir/media/`,
* 于是播放本身不联网、不随时间抖动,也**不必**把 Bearer 头塞进播放器(`MediaPlayer` 的头部参数
* 不会走 401 续期那条路,那才是真的会出事的地方)。
*
* ## 生命周期:三处必须停下来并释放(`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路)
* 1. **开始播另一条**:[toggle] 在任何新播放之前先 [stop](旧的那条停下并释放,含正在下载的
* 那一次)——所以「一条没放完又点另一条」不会出现两条叠着响;
* 2. **离开会话 / 换会话**:调用方用 [rememberVoicePlayer](它按 `sessionKey` 换实例并在
* `onDispose` 里 [release]),换会话 = 换实例 = 旧的那条立刻停;
* 3. **组件销毁**:同上,`onDispose` 是组合被丢弃时唯一可靠的时刻([release] 幂等,可重复调)。
*
* ⚠️ **本片没做的两件事(如实)**:
* - **不申请音频焦点**(`AudioManager` 那套):需要 `Context` 与「焦点被抢走」的处理,属于
* 「播放器该有的完整行为」,与本期「收得到、放得出来」不是一档。后果是主人自己在放音乐时
* 语音会叠在上面;
* - **App 退到后台不停播**(要接 `LocalLifecycleOwner` 需要 `lifecycle-runtime-compose`,
* 本期不加依赖)。语音都很短,影响面是「切走那一两秒还在响」。
*
* ## 失败:可见、但不夸张
* 两档失败都会进 [VoicePlaybackState.Failed](气泡据此把按钮变回「未播」并显示**一行**短提示):
* 取字节失败(网络 / 404 / 地址拼不出来,文案来自 [voicePlaybackFailureMessage])与解码起播失败
* ([VOICE_UNPLAYABLE_MESSAGE])。**绝不静默**——一条点了没反应的语音在用户眼里就是坏的。
*
* ## 不缓存字节(这一层)
* 缓存是 [VoiceStore] 的事(按存储名落 `cacheDir/media/`,命中就不发请求)。本类只负责「拿到
* 文件 → 放 → 放完/停 → 释放」,因此它不认识网络,也不认识缓存策略。
*
* @param scope 驱动的协程作用域(调用方给,通常是组合的 `rememberCoroutineScope`)。
* @param load 取这条语音的**本地文件**(未命中缓存时内部会下载并落盘)。
*/
class VoicePlayer(
private val scope: CoroutineScope,
private val load: suspend (VoiceAudio) -> Result<File>,
) {
private val _state = MutableStateFlow<VoicePlaybackState>(VoicePlaybackState.Idle)
/** 这条通道此刻的状态(气泡与按钮都看它)。状态机本身是纯函数,见 `domain.chat.VoicePlayback`。 */
val state: StateFlow<VoicePlaybackState> = _state.asStateFlow()
/** 正在取字节的那次协程;停下 / 换目标时一并取消(用户不该被一个慢下载困住)。 */
private var loading: Job? = null
/** 当前这一台 `MediaPlayer`(null = 没有)。回调里的身份比对都以它为准。 */
private var player: MediaPlayer? = null
/**
* 用户点了这条消息的播放 / 暂停。
*
* 判据全部走纯函数:拿不到 [VoiceAudio](**无副本** ⇒ 四个键没凑齐)就**什么都不做**
* ——正常路径上那时气泡根本不画播放控件,这里是第二道闸(同一个判据,不在 UI 之外再猜一次)。
*/
fun toggle(message: HistoryMessageDto) {
val audio = voiceAudio(message.voice) ?: return
val id = message.id
// 再点一下 = 停(加载中也算:那表示「别下了」)。
if (_state.value.isActiveFor(id)) {
stop()
return
}
stop()
_state.value = _state.value.pressed(id)
loading = scope.launch {
load(audio).fold(
onSuccess = { file ->
// 这一帧还该不该放:用户可能已经点了停 / 点了别的(状态已不是 Loading(id))。
if (_state.value.isActiveFor(id)) play(id, file)
},
onFailure = { error ->
_state.value = _state.value.failed(id, voicePlaybackFailureMessage(error))
},
)
}
}
/**
* 放一段**已经在手上**的音频(`voice_output` 那条路:base64 就在帧里,不需要下载)。
*
* 与 [toggle] 走**同一条通道**、同一套身份比对与释放纪律(同一台喇叭上永远只有一条语音在响),
* 差别只有两个:音频不是「按消息取字节」而是由调用方现给([load]),而且键是
* `voice_output` 那一帧自己铸的 [clipId](它不是历史消息,没有 `messageId` 可用)。
*
* 同样遵守「再点一下 = 停」:`clipId` 正在放 / 正在载入时,这一下是**停**。
*
* @param load 取这份本地文件(写缓存 / 解码都在里面,调用方应切到 IO)。
*/
fun toggleClip(clipId: String, load: suspend () -> Result<File>) {
if (_state.value.isActiveFor(clipId)) {
stop()
return
}
stop()
_state.value = _state.value.pressed(clipId)
loading = scope.launch {
load().fold(
onSuccess = { file ->
if (_state.value.isActiveFor(clipId)) play(clipId, file)
},
onFailure = { error ->
_state.value = _state.value.failed(clipId, voicePlaybackFailureMessage(error))
},
)
}
}
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
fun stop() {
loading?.cancel()
loading = null
releasePlayer()
// ⚠️ `silenced` 而不是「只清 Playing」:失败那句话也要跟着走——换了目标 / 离开页面之后,
// 屏幕上不该还挂着上一条的失败。
_state.value = _state.value.silenced
}
/** 组件销毁 / 离开会话时调(等价于 [stop],单列一个名字是为了让调用点的意图看得见)。 */
fun release() = stop()
private fun play(messageId: String, file: File) {
val mp = MediaPlayer()
player = mp
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
mp.setOnPreparedListener { prepared ->
// 身份比对:已经换掉 / 已经 release 的那个实例,回调一律不改状态。
if (player !== prepared) return@setOnPreparedListener
prepared.start()
_state.value = _state.value.prepared(messageId)
}
mp.setOnCompletionListener { done ->
if (player !== done) return@setOnCompletionListener
releasePlayer()
_state.value = _state.value.finished(messageId)
}
mp.setOnErrorListener { broken, _, _ ->
if (player !== broken) return@setOnErrorListener true
releasePlayer()
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
// true = 这次错误已经处理(不让它再走 onCompletion,也不弹系统默认处理)。
true
}
try {
mp.setDataSource(file.absolutePath)
// prepareAsync 而不是 prepare:同步那一版会占着主线程等解码器,异步的失败同样会走到
// onError(format 不支持 / 文件损坏 / 已被清空都是这一档)。
mp.prepareAsync()
} catch (_: Exception) {
// setDataSource / prepareAsync 在状态不对、文件读不动时**同步抛**:如实报「播不了」。
releasePlayer()
_state.value = _state.value.failed(messageId, VOICE_UNPLAYABLE_MESSAGE)
}
}
/**
* 把当前那台播放器拆干净:先摘掉全部监听(免得 `stop()` / `release()` 触发的回调再改状态),
* 再 `stop()`(未 prepare 完时它会抛 `IllegalStateException`,所以包在 `runCatching` 里),
* 最后 `release()`。
*/
private fun releasePlayer() {
val current = player ?: return
player = null
current.setOnPreparedListener(null)
current.setOnCompletionListener(null)
current.setOnErrorListener(null)
runCatching { current.stop() }
runCatching { current.release() }
}
}
/**
* 为**当前这段组合**(通常是一个会话的聊天页)记住一条播放通道,并在它被丢弃时释放。
*
* 两条纪律写在这里,因为它们是本类唯一的使用方式:
* - **[sessionKey] 变了就换一条通道**:换会话 = 换通道 = 上一条立刻停(`remember` 换实例 →
* `DisposableEffect` 的旧 effect 走 `onDispose` → [VoicePlayer.release]);
* - **[load] 变化不重建通道**:它由 `rememberUpdatedState` 顶着(每次重组拿到最新那个 lambda),
* 否则重组期间的一次实例更替会把正在放的那条停掉。
*/
@Composable
fun rememberVoicePlayer(
sessionKey: Any?,
load: suspend (VoiceAudio) -> Result<File>,
): VoicePlayer {
val scope = rememberCoroutineScope()
val currentLoad by rememberUpdatedState(load)
val player = remember(sessionKey) { VoicePlayer(scope) { audio -> currentLoad(audio) } }
DisposableEffect(player) {
onDispose { player.release() }
}
return player
}
@@ -0,0 +1,49 @@
package top.yeij.cyrene.ui.chat
import java.io.File
/**
* `voice_output` 在界面上的那一份「她的语音回复」(只管**这一条实时应答**,不进历史)。
*
* ## 为什么它不在消息列表里
* 契约 v0.96 §3.15:这一帧**只定向回发起 `voice_input` 的那条连接**、**连接断了不补投**,
* 而且回合的正文本来就会经流式 / `message_persisted` 落进消息列表。所以它不是一条消息,
* 是「这一次语音回合的音频 + 她说的话」这一次性产物:放在输入栏上方一条卡片里,
* 用户随时可以关掉([ChatViewModel.dismissVoiceReply]),它不参与排序、不占历史。
*
* ## [hasAudio] 与 [file] 是两件事
* - [hasAudio]:**服务端**这一轮给了真音频(`provider != "silent"` 且 `audio` 非空白)。
* 为假 ⇒ 界面上**一个播放控件都不画**(画了就是点下去必然失败的按钮)。
* - [file]:音频在本机的那份文件。`hasAudio` 为真而它是 `null` = 本机解码 / 落盘失败
* ——这一档**照样画**播放控件(点了会如实报「播不了」),**绝不**把本机的故障说成
* 「她这一轮只回了文字」(见 `domain.chat.voiceReplyNote`)。
*
* @property id 播放通道的键:每条 `voice_output` 现铸一个(它不是历史消息,没有 `messageId`)。
* @property reply 她说的那一句(音频念的就是它),见 `domain.chat.voiceReplyText`。
* @property transcript 转写(载荷里的 `text`):那段音频被识别成什么;只作兜底与诊断。
* @property format 服务端给的音频格式;本端**不照抄它去解码**(`MediaPlayer` 按文件头嗅探)。
*/
data class VoiceReply(
val id: String,
val reply: String?,
val transcript: String?,
val format: String?,
val provider: String?,
val hasAudio: Boolean,
val file: File?,
/**
* 这一条还该不该**自动播放**(默认 true,播过就置假,见 [ChatViewModel.consumeVoiceReplyAutoPlay])。
*
* 为什么要这一位:这一帧**只在到达时**有意义(断线不补投),而到达的那一刻用户可能正在别的
* 会话 / 别的页面里。若只按「音频落好了就放」来触发,等他切回来时会**突然响一段旧语音**
* ——那不是他刚做的事。所以自动播放只认**第一次**落好的那一下,之后靠卡片上的播放键。
*/
val autoPlay: Boolean = true,
) {
/** 能不能画「播放」那枚控件:这一轮**有**音频,而且它已经落到本机。 */
val playable: Boolean get() = hasAudio && file != null
/** 这一轮是不是「没有音频」(= `provider == "silent"` 或 `audio` 缺席)。 */
val silent: Boolean get() = !hasAudio
}
@@ -0,0 +1,103 @@
package top.yeij.cyrene.ui.chat
import java.io.File
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.ttsClipIdOf
/**
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
*
* ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember`
* 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来
* 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、
* 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。
*
* ## 一次只有一个:`ttsClip` 是**单格**
* 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。
* 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。
*
* ## 播放不在这一格里
* 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是
* [clipId](`tts:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
* 本来就不是一件事。
*
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `messageId`)
* 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。
* @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。
* @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`);
* `null` = 没失败过 / 还没结果。
* @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标
* 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。
* @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见
* `ChatViewModel.consumeTtsAutoPlay`)。
*
* 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」
* 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认
* **第一次**落好的那一下,之后靠那枚图标。
*/
data class TtsClip(
val messageId: String,
val clipId: String,
val file: File?,
val failure: String?,
val retryable: Boolean,
val autoPlay: Boolean = true,
) {
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
val pending: Boolean get() = file == null && failure == null
}
/**
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
*
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
*/
sealed interface TtsSpeakIntent {
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
data object Stop : TtsSpeakIntent
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
data object Speak : TtsSpeakIntent
}
/**
* 这一下点击是「停」还是「(重新)念」。
*
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
* 各自解释「播放中」是什么意思。
*
* ## 为什么要 `file != null`
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
*
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
* @param messageId 被点的那条消息。
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
*/
fun ttsSpeakIntent(
playback: VoicePlaybackState,
messageId: String,
clip: TtsClip?,
): TtsSpeakIntent {
val file = clip?.takeIf { it.messageId == messageId }?.file
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
TtsSpeakIntent.Stop
} else {
TtsSpeakIntent.Speak
}
}
@@ -0,0 +1,312 @@
package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.repository.MessageIncrement
/**
* 一帧 `message_persisted` 属于哪条路([persistedRoute] 的取值)。
*
* 这一帧按下行的**每一个**会话广播(服务端不按会话订阅,见 `ChatSocket`),而收帧的
* `SessionsViewModel` 手里是**整个列表**,所以每一帧都要先回答这一个问题。
*/
internal enum class PersistedRoute {
/**
* 就是**当前打开的那个会话**:消息已经显示在屏幕上了,就地推进已读游标即可
* (契约 §3.9 的客户端处理建议:看不到它攒出角标)。
*
* 这条路**不发任何请求去拉列表** —— 「自己正在看的会话」从来不是未读的来源,
* 多拉一次列表只会白花两个请求(而抽屉里的未读本来也不会因为这一帧变多)。
*/
ActiveSession,
/**
* **别的**会话落了新消息:它攒未读是正确行为(红点该亮),而本端**数不出这个数**
* (`message_persisted` 帧不带 `unreadCount`)⇒ 节流地问服务端要。
*
* 这正是 W49 修的那一幕:**App 停在主会话时,次级会话来了新消息,抽屉红点不出现**。
* 此前帧在这一档直接 `return`,而列表更新的触发点只有抽屉打开 / 新建 / 删除 / init 四处,
* 于是红点必须靠用户开一次抽屉才补上。
*
* 「要」的是什么,W77 换过一次:原来是整表 `GET /sessions`,现在是**这条会话的消息增量**
* `GET /messages?after=`(见 [incrementCursors] / [applyIncrement]);整表退回成「增量给不出
* 可信答案」时的回落。判据本身不受影响——这一档该不该被窗口挡住,与问的是哪种口径无关。
*/
InactiveSession,
}
/**
* 这一帧该走哪条路(**纯函数**,VM 与测试共用)。
*
* 判据只有一句等值——它单独成一个函数,是因为**「激活会话那一档一定不拉列表」是要一直成立的边界**:
* 写成一个函数才断言得了(先例:`ui.chat.hasSendableContent`,同样是把「入口与投递口必须用同一条
* 判据」这件事拎出来)。`sessionId` 由调用方保证非空白(帧壳里没有会话 id 的坏帧在更外层就整帧跳过了:
* 那种帧连「是谁」都不知道,没有一条路可走)。
*/
internal fun persistedRoute(sessionId: String, activeSessionId: String): PersistedRoute =
if (sessionId == activeSessionId) PersistedRoute.ActiveSession else PersistedRoute.InactiveSession
/**
* 静默重拉的节流窗口(毫秒):**3 秒**。
*
* 为什么是这个数(三个约束互相顶出来的折中):
* - **要够快**,否则等于没修:那一幕是「App 停在主会话、次级会话来了新消息」,红点必须在用户还盯着
* 聊天页的时候就自己出现(今天那一眼只能看见标题栏,看不见抽屉)。3 秒落在「看起来就是刚发生的」
* 这个感知里;5 秒往上就开始像「卡了一下才补上」。
* - **要够粗**,否则节流没意义:一次静默重拉是**两个**请求([SessionsViewModel.refresh] 里还跟着
* 一次角色目录),而播出方是按**每条消息**推 `message_persisted` 的 —— 有人连发 5 条、或一轮回复
* 连续落库几条,都必须在同一个窗口里合并。3 秒能把「一次连发」稳稳收进来(人手连打 5 条约 2~4 秒,
* 同一轮落库通常在 1 秒内);窗口收到 1 秒就会漏成两三次请求。
* - **别另立尺子**:3 秒正是本项目已有的「等够久才值得为一次等待开口」的那个量级
* (`ChatScreen.SHARE_WAIT_NOTICE_MILLIS`),沿用同一套体感标尺。
*
* 它给的是**上限**保证(窗口内最多一次请求),**下限**由补拉那一档给
* ([SilentRefreshAction.ScheduleTrailing]:最后一条之后必定还会再拉一次)。两个一起才成立:
* 只有上限会把红点停在旧值上,只有下限等于没节流。
*/
internal const val SILENT_REFRESH_WINDOW_MILLIS = 3_000L
/**
* 一次静默重拉的成因([silentRefreshStep] 的入参之一)。
*
* 只分两类,是因为判据**只关心一件事**:这一次该不该被节流窗口挡住。窗口存在的唯一理由是
* 「把同一场连发合并成一次请求」,所以要能分辨「这场连发」与「跟连发无关、本来就必须马上对齐」的那类。
*/
internal enum class SilentRefreshCause {
/**
* 非激活会话落了新消息([PersistedRoute.InactiveSession]):**必须走窗口** ——
* 播出方按每条消息推帧,不节流就是连发几条打几个请求。
*/
InactiveSessionMessage,
/**
* 手里这份列表**换了主人**(账号 / 服务器地址变了,在登录态上报时判出):**不该被窗口挡住**。
*
* 窗口等的是「别的会话又攒了多少条」,与「这份列表还是不是当前这个账号的」毫无关系 ——
* 被它挡住的话,新身份会拿着一份属于上个身份(或已被清空)的列表过完整个窗口,而这时**根本没有
* 任何一场连发值得合并**。
*
* 会话切换(`SessionsViewModel.select`)在判据上属于同一类,但那条路**刻意不接进来** ——
* 理由写在那里:它紧接着就发了一次推进已读游标的请求,重拉会与还没落地的游标抢跑、把刚清掉的
* 角标闪回来。
*/
ContextChanged,
}
/**
* 静默重拉的节流账本(**纯值**:[silentRefreshStep] 的入参,也是它的出参)。
*
* 时钟由调用方喂([silentRefreshStep] 的 `nowMillis`),这里**不藏** `System.currentTimeMillis()`:
* 判据才可单测,也才不用为了「等 3 秒」写 sleep(先例:`domain.chat.RetryKeyMemory`)。
*
* @param lastPullAt 上一次**真的发出**那次静默重拉的时刻(毫秒);`null` = 本进程还没拉过。
* 只由**这条路径**记账:抽屉打开 / 新建 / 删除 / init 那几处的拉取不写它。刻意不写 —— 那几处是
* 用户动作或一次性事件,把它们的时刻算进来只会让本路径**少拉**一次,而少拉正是「红点停在旧值」
* 的成因本身。
* @param trailingPending 已经排上了一次「窗口到点补拉」。已排上时再来的成因只更新账本、不重复排
* ([SilentRefreshAction.Ignore])—— 「同一时刻最多一个补拉任务」是补拉不会滚成一串的结构性保证,
* 而不是某一次调用的运气。
*/
internal data class SilentRefreshThrottle(
val lastPullAt: Long? = null,
val trailingPending: Boolean = false,
)
/** 这一刻该做什么([silentRefreshStep] 的取值)。 */
internal enum class SilentRefreshAction {
/** 现在就拉,窗口从这一刻重新开始。 */
PullNow,
/** 窗口内:**现在不拉**,排一次窗口到点的补拉(trailing),见 [silentRefreshStep] 第 3 条。 */
ScheduleTrailing,
/** 什么都不做:窗口内,且补拉已经排上了 —— 那一次本来就是把窗口里所有成因合起来做掉。 */
Ignore,
}
/**
* [silentRefreshStep] 的结论:**该做什么** + **做完之后账本走成什么样**。
*
* 两半一起回是刻意的(先例:`domain.chat.RetryKeyDecision`):账本的另一半(窗口起点、有没有补拉)
* 由本次判定决定,拆开返回就给了调用方「判了却忘了推进窗口」的机会 —— 那正是节流失效最常见的写法。
*/
internal data class SilentRefreshStep(
val action: SilentRefreshAction,
/** 补拉还要等多久(毫秒);只有 [SilentRefreshAction.ScheduleTrailing] 有意义,其余恒 `0`。 */
val delayMillis: Long,
val throttle: SilentRefreshThrottle,
)
/**
* 静默重拉的时机判据(**纯函数**,VM 与测试共用)。
*
* ## 为什么需要这一层
* [PersistedRoute.InactiveSession] 那一档要自己拉一次列表,但**裸着重拉不行**:播出方按每条消息推帧,
* 连发 5 条就是 5 个请求。所以有这里。
*
* ## 判据与优先级(自上而下,先命中先返回)
* 1. [SilentRefreshCause.ContextChanged] ⇒ [SilentRefreshAction.PullNow]。**压过窗口**(理由见那个
* 枚举值)。这一拉已经把窗口里那条消息要的刷新一并做掉了,所以窗口从这一刻**重开**、不再补一次
* —— 落到第 3 条的话会 3 秒后再白拉一次同样的东西。
* 2. 窗口外(`lastPullAt` 为 `null`,或已过去 [SILENT_REFRESH_WINDOW_MILLIS])⇒
* [SilentRefreshAction.PullNow],窗口从这一刻重开。
* 3. 窗口内、**还没有**排上补拉 ⇒ [SilentRefreshAction.ScheduleTrailing],等「窗口减去已过去的时间」。
* 这一档就是 trailing 语义:**最后一条之后必定还会再拉一次**,红点不会停在旧值上。
* 4. 窗口内、补拉**已经排上** ⇒ [SilentRefreshAction.Ignore]:那一次补拉本来就是窗口里所有成因的合并。
*
* 判据里除了「成因」与「账本」什么都不看(不知道是哪个会话、也读不到 VM 状态),才能穷举、才能在 JVM
* 上单测(先例:`ui.chat.shareLanding`)。
*
* ## 边界口径:正好等于窗口**算窗口已过**(`>=` 而不是 `>`)
* 取这一边的理由很具体:补拉的等待时长就是按同一个常量算出来的(`窗口 - 已过去的时间`),它醒来
* 那一刻读到的钟通常**正好**是 `lastPullAt + 窗口` —— 判据这边若是 `>`,那一刻会被判成「还在窗口内」,
* 补拉就得再排一次(虽然最终仍会拉,但白绕一圈)。另一面也更好断言:测试直接拿
* [SILENT_REFRESH_WINDOW_MILLIS] 当「窗口刚过」的边界值,不必发明魔法数。(`RetryKeyMemory` 的边界
* 口径选的是**相反**的一侧,理由写在那边;两处不对称是各自的用途不同,不是笔误。)
*
* @param nowMillis 调用方喂的「现在」(`System.currentTimeMillis()`)。
* @param throttle 这份列表的节流账本,见 [SilentRefreshThrottle]。
*/
internal fun silentRefreshStep(
cause: SilentRefreshCause,
nowMillis: Long,
throttle: SilentRefreshThrottle,
): SilentRefreshStep {
if (cause == SilentRefreshCause.ContextChanged) {
return SilentRefreshStep(SilentRefreshAction.PullNow, 0L, throttleAfterPull(nowMillis))
}
val lastPullAt = throttle.lastPullAt
if (lastPullAt == null || nowMillis - lastPullAt >= SILENT_REFRESH_WINDOW_MILLIS) {
return SilentRefreshStep(SilentRefreshAction.PullNow, 0L, throttleAfterPull(nowMillis))
}
if (!throttle.trailingPending) {
return SilentRefreshStep(
action = SilentRefreshAction.ScheduleTrailing,
delayMillis = SILENT_REFRESH_WINDOW_MILLIS - (nowMillis - lastPullAt),
throttle = throttle.copy(trailingPending = true),
)
}
return SilentRefreshStep(SilentRefreshAction.Ignore, 0L, throttle)
}
/**
* 真的拉过一次之后的账本(**纯函数**):窗口从 [nowMillis] 重开,且不再有补拉 ——
* 这一拉已经把窗口里排队的成因一并覆盖,补拉再跑一次就是同一个请求白做一遍。
*
* 单独成一个函数,是因为**补拉任务到点时也要走这一步**(它照样是「真的拉过一次」,只是发起人不同);
* 两处各写一遍 `SilentRefreshThrottle(lastPullAt = …)` 就多了一个会分叉的真相源。
*/
internal fun throttleAfterPull(nowMillis: Long): SilentRefreshThrottle =
SilentRefreshThrottle(lastPullAt = nowMillis)
// ---------------------------------------------------------------------------------------------
// W77:非激活会话的静默刷新,从「整表重拉 GET /sessions」换成「前向增量 GET /messages?after=」
//
// 后端明确批了这一换(回执二十八第 3 条:`?after=` 毫秒 `createdAt` 早就在册)。换掉的理由是**量**:
// 整表一次要回**所有**会话(每条一行预览 + 未读计数),而这里只需要回**真的变了的那一个**会话的新消息。
// 下面三个纯函数就是这一换的全部判据,放在这里而不是 VM 里,理由与 [silentRefreshStep] 一样:
// 纯函数才穷举得了、才不用在 JVM 上摆一台假服务器。
// ---------------------------------------------------------------------------------------------
/**
* 某个会话现在该从哪个 `createdAt` 起问增量(纯函数);`null` = **这一行问不了**。
*
* 游标就取 [SessionItem.lastMessageAt]——最近一次权威 `GET /sessions` 给的、本端已知最新一条的
* `createdAt`——**不另立账本**:
* - 服务端的 `after` 是 `created_at > ?`(**严格**大于),而 [SessionItem.lastMessageAt] 正是
* 「已经见过的最新一条」⇒ 两边语义天然对齐,多立一个账本只会多一个会漂移的真相源;
* - 增量自己会把 [SessionItem.lastMessageAt] 推到新游标([applyIncrement]),于是账本自洽地滚动,
* 不需要第二处推进。
*
* 两种「问不了」都落到 `null`,它们其实是同一件事——**本端不知道这条会话看到哪儿了**:
* - 行不在列表里(会话在别处新建、或本端那份列表还没拉到它)⇒ 没有游标可用;
* - 行在但 `lastMessageAt` 为 `null`(本端认为它一条消息都没有,却收到了它的落库广播)⇒
* 手里那份列表已经陈旧到不可信。
*
* 两种都由调用方回落到整表(唯一权威),见 `SessionsViewModel.flushPendingIncrements`。
*/
internal fun incrementCursorFor(item: SessionItem?): Long? = item?.lastMessageAt
/**
* 一批待问增量的会话 id → 各自的 `after` 游标(纯函数)。
*
* `null` = **这批里有一个问不了**([incrementCursorFor] 给了 `null`)⇒ 调用方**整批**回落整表。
* 不混着来是刻意的:一半靠增量、一半靠整表,得到的那份列表没有任何一处能说得清「它是什么时刻的」,
* 而未读面上最不能出的就是这种「说不清」。
*
* 返回 `emptyMap` = 这批本来就是空的(没有事要做),**不是**失败。
*/
internal fun incrementCursors(
sessionIds: Collection<String>,
sessions: List<SessionItem>,
): Map<String, Long>? {
if (sessionIds.isEmpty()) return emptyMap()
val byId = sessions.associateBy { it.id }
val cursors = LinkedHashMap<String, Long>(sessionIds.size)
for (id in sessionIds) {
cursors[id] = incrementCursorFor(byId[id]) ?: return null
}
return cursors
}
/**
* 把一次增量折进列表(纯函数):**只动那一行**,其余原样返回。
*
* ## 未读数怎么算:先过已读游标,再加
* 增量条数**不是**直接往角标上加的,要先按本地已知的已读游标 [readAt](`readCursors[sessionId]`)筛一遍。
* **过滤口径**与服务端同源:`session_read.go` 的 `unreadCountQuery` 是 `count(created_at > read_at)`,
* 而 `read_at` 是**账号级单一游标**,可能比本端已知的最新消息还新——用户在这台机器上点开过它
* ([SessionsViewModel.select] 推的就是「现在」),随后别的设备又发了消息。那一刻
* `(lastMessageAt, readAt]` 之间的消息**服务端算已读**,一律加进角标就会多出来一个红点。所以只数
* `createdAt > readAt` 的那几条。
*
* 已有的 [SessionItem.unreadCount] 直接保留相加是安全的:它来自服务端,而这次增量里的每一条都严格新于
* [from](服务端的 `after` 就是 `created_at > ?`),两者没有交集,不会重复计数。
*
* ## 已知边界:同源的只是**过滤口径**,基数仍是上次整表的旧值
* 相加的底数 [SessionItem.unreadCount] 来自上一次整表刷新,而 `read_at` 是账号级的、会被别的设备改写。
* 断线期间他端把某个会话读掉时,那条 `session_read` 帧本地**永久收不到**(断线不补帧),于是底数偏大,
* 这一行会一直多算那几条。这是**已接受的边界**,不是漏网:红点本来就只承诺「大概有几条新的」,漂移
* 到下一次整表刷新(打开抽屉 / 初始化 / 任何一次回落)就自愈。
*
* 这里**不**去检测它(例如「[readAt] > [from] ⇒ 底数可疑 ⇒ 回落整表」):本端刚点开过这个会话时
* `readAt` 本来就被推到「现在」、必然大于 `lastMessageAt`,那是最常见的一档;拿它当异常信号会让增量
* 路径在正常使用下持续回落整表,而真正的「他端已读」它一样抓不全(断线时帧就丢了)。抓不准的闸门
* 不如不装。
*
* ## 并发的那一份真相
* [from] 不是「这次用了什么游标」的装饰:行上的 [SessionItem.lastMessageAt] **已经不等于** [from],
* 就说明在这次请求飞在天上的时候,另一份更权威的结果(一次整表刷新、或另一次增量)已经推进过这一行。
* 那些条数已经被别人数过一遍了,再加一次就是重复计数 ⇒ 整行不动。
*
* 一行都没被改动时返回**同一个列表实例**(与 `applyBusyFrame` 同一习惯):这条路径跑在后台,
* 没有必要为了「什么都没变」让整个抽屉重组一遍。
*
* @param from 这次请求用的 `after` 游标。
* @param readAt 本端已知的已读游标;`null` = 不知道(当作 `0`,即「一条都还没读过」)。
* @param increment 升序的增量(`MessageRepository.loadIncrement` 的产物)。
*/
internal fun List<SessionItem>.applyIncrement(
sessionId: String,
from: Long,
readAt: Long?,
increment: MessageIncrement,
): List<SessionItem> {
if (increment.messages.isEmpty()) return this
val floor = readAt ?: 0L
val unseen = increment.messages.count { it.createdAt > floor }
// 升序 ⇒ 最后一条就是最新的一条(`loadIncrement` 保证这一点)。
val preview = increment.messages.last().content.toPreviewText().takeIf { it.isNotBlank() }
var touched = false
val updated = map { item ->
if (item.id != sessionId || item.lastMessageAt != from) {
item
} else {
touched = true
item.copy(
unreadCount = item.unreadCount + unseen,
lastMessage = preview,
lastMessageAt = increment.cursor,
)
}
}
return if (touched) updated else this
}
@@ -0,0 +1,88 @@
package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.ws.WsEvent
/**
* 「这个会话正在跑」的本端账本(**纯逻辑**,`SessionsViewModel` 与测试共用)。
*
* 形状是 `Map<会话 id, 本端见过的、还没收到收尾帧的在途路 id>`。**空集合也是在跑**——
* `session_busy` 帧本身通常不带 `turnId`(它走编排器的状态通道),只凭它也知道那个会话忙。
*
* ## 只有活帧能点亮,而且绝不外推
* 唯二的点亮来源是 [WsEvent.SessionBusy](服务端说它忙)与 [WsEvent.TurnBegin](服务端刚起了
* 一路):两条都是**连接着的这一刻**推下来的帧。握手**不回放**该状态(S9 登记的那一格),所以
* 「本端连上之后还没收到过任何一条这样的帧」= **不知道** ⇒ 抽屉里什么都不显示(宁可没有)。
* 也不从别的线索外推——REST 列表不带这个状态,聊天页有没有在转也不带会话归属,
* 拿它们当证据就等于把「本端不知道」说成「它闲着」或「它忙着」。
*
* 把 [WsEvent.TurnBegin] 也算证据,是因为它与 [WsEvent.SessionBusy] **同源**:服务端启动一路时
* 两条一起发(一个是状态、一个是这一路的身份)。收到它就等于服务端在那一刻确认「这个会话在跑」,
* 不是本端的推断——而它比 `session_busy` 更频繁(每一路都有),所以 `session_busy` 那一帧如果
* 因为重连错过,下一路起来时灯还能亮回来。
*
* ## 收尾:三条帧,方向都是「宁可少显示」
* - [WsEvent.SessionIdle](规范 §1.5:**全部**在途路收尾且 pending 空)⇒ 无条件收掉。
* - [WsEvent.SessionInterrupted] ⇒ 只有 `activeTurns` 说没有别的路在跑时才收掉。`scope:"current"`
* 只取消在跑的那一路、别的路照跑,无条件收掉就会在并行时显示「已经停了」——那正是 v2 前端审计
* 记成「会错」的一条(`Cyrene_v2/DevDocs/audit/前端同步缺口-2026-10-05.md` 的 A7)。
* 键缺席(`null`,v0.65 之前的老服务端)⇒ 那一版还没有并行这回事,单路被打断就是真的闲了 ⇒ 收掉。
* - [WsEvent.TurnEnd] ⇒ 只收它**认得**的那一路([WsEvent.TurnBegin] 记下来过),且收掉之后一路不剩
* 才灭灯。帧壳 `turnId` 没见过(本端中途才接上、或那一帧的键缺席)就什么都不做:拿别的一路的
* 收尾去灭灯,会把还在跑的那一路报成空闲。
*
* `stream_end` **刻意不参与**:它只结束某一次模型流(一轮里有多个正文段、工具循环里还有多次),
* 而 `turn.end` 才结束整轮。按它灭灯就是上面 A7 那类错误的另一个入口。
*
* 幂等:同一帧重复到达(重连补拉、服务端重复广播)不改变结论。
*/
internal fun Map<String, Set<String>>.applyBusyFrame(event: WsEvent): Map<String, Set<String>> {
val sessionId = event.sessionId.sessionKey() ?: return this
return when (event) {
is WsEvent.SessionBusy ->
this + (sessionId to this[sessionId].plusTurn(event.turnId.sessionKey()))
is WsEvent.TurnBegin ->
this + (sessionId to this[sessionId].plusTurn(event.turnId.sessionKey()))
is WsEvent.TurnEnd -> {
val turnId = event.turnId.sessionKey()
val tracked = this[sessionId].orEmpty()
if (turnId == null || turnId !in tracked) {
this
} else {
val left = tracked - turnId
if (left.isEmpty()) this - sessionId else this + (sessionId to left)
}
}
is WsEvent.SessionIdle -> this - sessionId
is WsEvent.SessionInterrupted ->
if ((event.activeTurns ?: 0) > 0) this else this - sessionId
else -> this
}
}
/**
* 抽屉里那一行「正在跑」的字。
*
* 与聊天页输入栏下方那一行**逐字相同**(`ChatScreen.busyLabel`):同一类服务端帧说的是同一件事,
* 两处各写一句就会各自漂移。措辞不分 `mode`(`thinking` / `executing` / `subagent`)——聊天页
* 也只看「忙不忙」这一个事实,用户要的是「她在干活、可以停」,不是编排细节。
*/
internal const val SESSION_BUSY_LABEL = "正在思考…"
/**
* 停止入口上的字。用文字而不是图标:`MiuixIcons.Basic.*` 里只有 7 个(`Close` / `Search` / …),
* 没有一个读作「停止」,而**猜**一个语义相近的图标(`Pause`?`Remove`?)正是本项目禁止的动作
* (先例:`LoginScreen` 的「显示 / 隐藏」也是在 `IconButton` 里放两个字)。
*/
internal const val SESSION_STOP_LABEL = "停止"
/** 帧里的 id:空白 = 坏帧(不知道说的是谁)⇒ 整帧不消费,与 `SessionsViewModel.onSessionRead` 同一条口径。 */
private fun String?.sessionKey(): String? = this?.takeIf { it.isNotBlank() }
/** 把这一路记进去;`turnId` 缺席(帧壳没给)时只维持「在跑」这个事实,不记无法配对的路。 */
private fun Set<String>?.plusTurn(turnId: String?): Set<String> =
if (turnId == null) orEmpty() else orEmpty() + turnId
@@ -62,6 +62,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
* 两边签名逐字对应。
* @param onDismissError 收起列表下方那条错误([SessionsUiState.error])。它不会自己消失,
* 所以必须有个关闭入口;关掉的只是**本端已经显示的**这一条(边界见 `SessionsViewModel.dismissError`)。
* @param onStopGenerating 停掉这个会话正在跑的那一轮(只对 [SessionsUiState.busySessions] 里那些行
* 出现)。它落到的出口与「她忙时发消息 = 打断」那一档**是同一个**(见
* `SessionsViewModel.stopGenerating`)。
*/
@Composable
fun ColumnScope.SessionDrawerContent(
@@ -71,6 +74,7 @@ fun ColumnScope.SessionDrawerContent(
onDelete: (SessionItem) -> Unit,
onTogglePin: (SessionItem) -> Unit,
onRename: (String, String) -> Unit,
onStopGenerating: (SessionItem) -> Unit,
onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit,
@@ -111,11 +115,16 @@ fun ColumnScope.SessionDrawerContent(
// 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达
// 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。
personaLabel = personaLabelFor(session.personaId, state.personas),
// 「谁在跑」是**整个列表**共用的一份(`session_busy` 说的是别的会话时本端也要收下),
// 所以它挂在 state 上、按 id 逐行查,而不是塞进 SessionItem(那一份是 REST 快照的
// 投影,每次重拉都会被整表替换,塞进去反而要让重拉记得把它带回来)。
busy = session.id in state.busySessions,
active = session.id == state.activeSessionId,
onClick = { onSelect(session.id) },
onTogglePin = { onTogglePin(session) },
onRename = { pendingRename = session },
onDelete = { pendingDelete = session },
onStop = { onStopGenerating(session) },
)
}
}
@@ -327,16 +336,22 @@ private val PERSONA_LIST_MAX_HEIGHT = 320.dp
*
* @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。
* 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。
* @param busy 这个会话**本端已知**正在跑(S9)。它多带来两样东西:摘要行下面那行「正在思考…」,
* 以及把置顶按钮换成「停止」。判据与收尾规则见 `session_busy` 那一套(`SessionsViewModel`)。
* 注意它**不是**「服务端此刻在跑」——握手不回放该状态,没收到过帧的会话就是 `false`(宁可没有)。
* @param onStop 停掉正在跑的那一轮([SessionsViewModel.stopGenerating])。
*/
@Composable
private fun SessionRow(
session: SessionItem,
personaLabel: String?,
busy: Boolean,
active: Boolean,
onClick: () -> Unit,
onTogglePin: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onStop: () -> Unit,
) {
val titleColor = BasicComponentDefaults.titleColor().color
val summaryColor = BasicComponentDefaults.summaryColor().color
@@ -355,16 +370,30 @@ private fun SessionRow(
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
// 「停止」与置顶**同一个槽位**,不是并列的两个:上面那段注释算过,`endActions` 的
// 硬上限(内部可用宽度的 60%)连现有的三个 40dp 动作都快顶不住,再并一个就会把
// 图标挤到裁剪。正在跑的那几行里「停」比「置顶」要紧,而置顶**不丢**:这一轮结束
// (灯灭)它立刻回来。
if (busy) {
IconButton(onClick = onStop) {
Text(
text = SESSION_STOP_LABEL,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
} else {
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
}
}
if (!session.isMain) {
IconButton(onClick = onDelete) {
@@ -416,6 +445,18 @@ private fun SessionRow(
overflow = TextOverflow.Ellipsis,
)
}
// 第三行只给正在跑的会话。**另起一行而不是顶掉摘要**:摘要(最后一条消息)在这一刻
// 仍然有用——用户切走之后正是靠它认回「我刚才在哪个会话里说话」——而这一行是整行
// 宽度的普通文本,不需要从 `endActions` 或标题列里分走任何一个 dp。
if (busy) {
Text(
text = SESSION_BUSY_LABEL,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MiuixTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -2,6 +2,8 @@ package top.yeij.cyrene.ui.sessions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -10,10 +12,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.app.AppContainer
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.core.prefs.accountFingerprint
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState
data class SessionItem(
val id: String,
@@ -24,7 +28,8 @@ data class SessionItem(
val lastMessage: String?,
/**
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
* 两处用:已读游标比对(见 [SessionItem.withReadCursor]),以及增量追尾的 `after` 游标
* (见 `incrementCursorFor`,由 `applyIncrement` 推进到这次增量的 `cursor`)。不直接显示。
*/
val lastMessageAt: Long? = null,
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
@@ -55,6 +60,13 @@ data class SessionsUiState(
val personasLoaded: Boolean = false,
/** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */
val personaError: String? = null,
/**
* **本端已知正在跑**的会话 id(S9)。空 = 一个都不知道,抽屉里不显示任何「正在生成」。
*
* 它**不是**「服务端此刻在跑哪些会话」——握手不回放该状态,本端只知道**连上之后**收到过帧的
* 那些(判据与收尾规则见 [applyBusyFrame])。所以它是「宁可没有」的一侧:拿不准就不显示。
*/
val busySessions: Set<String> = emptySet(),
)
/**
@@ -69,6 +81,15 @@ data class SessionsUiState(
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
* 反向同步(别的端读了)靠下行 `session_read` 帧。
*
* **非激活会话攒未读**(别人发来、别的设备发来)本端**不自己数**——`message_persisted` 帧不带
* `unreadCount`,逐条累加会与编辑 / 删除 / 别端操作漂移。本端做的是**自己去问一次服务端**:
* 收到非激活会话的落库广播就**节流地问一次那条会话的消息增量**([requestSilentRefresh] →
* [flushPendingIncrements],窗口 [SILENT_REFRESH_WINDOW_MILLIS]),于是抽屉的红点**不需要用户先开一次
* 抽屉**就会出现(S6,登记见 `devdocs/05-开发进度与交接.md`)。
*
* 取数走的是 `GET /messages?after=<ms>`(前向增量)而不是整表 `GET /sessions`——W77 换的,理由与回落
* 规则都写在 [flushPendingIncrements] 上。整表仍是**唯一权威**,只是不再需要为一条新消息付它的价钱。
*
* ## WS 连接:全 App 共享一条,而**本类是它唯一的主人**
* `session_read` / `message_persisted` 都在 WS 上,这个连接就是**进程内共享**的那一条
* (`AppContainer.chatSocket`;为什么可以共享见那里的 KDoc——服务端广播 ⇒ 各 VM 各收全部帧
@@ -106,7 +127,49 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
/** 全 App 共享的那条连接;本类只订阅它,并且**是它唯一的主人**(登录时连、登出时关,见类注释)。 */
private val socket: ChatSocket = container.chatSocket
/**
* 静默重拉的节流账本(判据见 [silentRefreshStep])。只有两条路会写它:非激活会话的落库广播
* ([onMessagePersisted])与身份变化(登录态上报时的 [fingerprint] 比对)。
*/
private var silentRefreshThrottle = SilentRefreshThrottle()
/**
* 排上的那次「窗口到点补拉」([SilentRefreshAction.ScheduleTrailing])。
* 它保证**最后一条新消息之后还会再拉一次**,红点因此不会停在旧值上;登出时取消
* (否则它会带着一个已经没有令牌的身份去发请求)。
*/
private var trailingRefreshJob: Job? = null
/**
* 窗口里攒下的「要问增量的会话 id」(W77)。
*
* 为什么是一个**集合**而不是一个 id:节流窗口把同一段时间里的落库广播合并成一次拉取,而那几个
* 会话**未必是同一个**(主会话说一句、另一个会话也说一句)。只记住最后一条的话,前面那个会话的
* 红点就永远不会出现——那正是 W49 修过的同一类病,只是换了个成因。
*
* 只在 [requestSilentRefresh] / [flushPendingIncrements] 两处写;登出时清空(它属于上一个身份)。
*/
private val pendingIncrements = mutableSetOf<String>()
/**
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
*
* 与 [SessionsUiState.busySessions] 是**同一件事的两面**:这一份带配对用的路 id(`turn.end`
* 要靠它才知道收掉的是哪一路),推给界面的那一份只要 id 的集合。两份只在一处一起写
* ([onBusyFrame]),不会各自漂移。
*/
private var busyTurns: Map<String, Set<String>> = emptyMap()
/**
* 手里这份列表是**为哪个身份**拉回来的([fingerprint]);`null` = 这份列表没有主人
* (登出清空之后、换账号还没重拉)。
*/
private var pulledForIdentity: String? = null
init {
// 构造这一刻读到的身份就是 init 里那次 refresh 的身份(指纹只读本地已存的账号与地址,
// 是同步的)。先记下它,登录态第一次上报时才不会把「本来就是它拉的」误判成身份变了。
pulledForIdentity = fingerprint()
viewModelScope.launch {
val fingerprint = fingerprint()
val cached = prefs.data.first()
@@ -139,13 +202,45 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
socket.close()
readCursors.clear()
prefs.clear()
// 「本端已知已删」也一样作废:它说的是**上一个环境**里那些 id 的结论,
// 换账号之后本端什么都不知道(与快照 / 已读游标同一档,见 GoneSessions 的类注释)。
container.goneSessions.clear()
// 聊天页的槽位(W52A)同样一格不留:它们里面是上一个环境的消息列表与草稿,
// 留着就是一份没人认领的常驻内存(而那一格里的 VM 还在订阅连接、还会在重连时
// 补拉)。这一条把 `ChatSessionSlots` 那条「主会话永不回收」的例外也一起收干净
// ——登出连主会话那一格也不留(代价:没发出去的草稿跟着没了,与上面几项同档)。
container.chatSessionSlots.clear()
// 手里这份列表与它的节流账本都属于上一个身份,一起作废:留着账本会让
// 重登后那一次必要的重拉被上一轮的窗口挡住;留着补拉任务会让它带着一个
// 已经没有令牌的身份去发请求(必然 401,还会在抽屉里挂一行错误)。
trailingRefreshJob?.cancel()
trailingRefreshJob = null
silentRefreshThrottle = SilentRefreshThrottle()
// 攒下的会话 id 同理:它们是上一个身份的会话,重登后那些 id 要么不存在、
// 要么属于别人;留着只会让重登后的第一次窗口拿旧 id 去问增量。
pendingIncrements.clear()
pulledForIdentity = null
busyTurns = emptyMap()
_state.value = SessionsUiState()
}
// 连接只在登录后建立:baseUrl / token 都是每次重连现读的(换账号、续期都不需要
// 重建实例),connect() 幂等(已连接时直接返回)。这里订阅的是一份 StateFlow,
// 订阅即收到当前值,所以「重登接上」不需要额外的事件。
is SessionState.LoggedIn -> socket.connect()
is SessionState.LoggedIn -> {
socket.connect()
// 身份换了(重登成另一个账号、或换了服务器地址):手里这份列表属于上一个
// 身份,必须自己对齐一次。它**不该**被给「别的会话新消息」用的那个窗口挡住
// (见 [SilentRefreshCause.ContextChanged]:那场连发与「列表是谁的」无关)。
//
// 同一个身份重复上报时什么都不做:这里订阅的是一份 StateFlow(订阅即收当前
// 值,续期 / 重连都会再上报一次),每次都拉就成了凭空多出来的请求。
val identity = fingerprint()
if (identity != pulledForIdentity) {
pulledForIdentity = identity
requestSilentRefresh(SilentRefreshCause.ContextChanged)
}
}
SessionState.Unknown -> Unit
}
@@ -153,6 +248,18 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
viewModelScope.launch { socket.events.collect(::onEvent) }
// 「谁在跑」只在**连接着的这段时间**里成立:握手不回放该状态,掉线期间服务端那边起了
// 什么、结束了什么,本端一个字节都收不到(S9 登记的那一格)。所以一离开 Connected 就当场
// 作废——否则一次「掉线时正好跑完」会让那盏灯**永远**亮着,而它旁边那个「停止」也永远点
// 不动(发出去只会撞上服务端对一个空闲会话的 no-op):那是这个功能唯一会骗到人的地方。
// 代价是重连后要等下一路起来灯才回来(可能是这一轮、也可能是下一轮)——宁可没有。
viewModelScope.launch {
socket.state.collect { ws ->
if (ws == WsState.Connected || busyTurns.isEmpty()) return@collect
busyTurns = emptyMap()
_state.update { it.copy(busySessions = emptySet()) }
}
}
}
/**
@@ -226,6 +333,137 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
}
/**
* 节流地静默对齐一次 —— W49 修的「停在主会话时,次级会话来了新消息,抽屉红点不出现,
* 必须开一次抽屉才补上」(S6)。
*
* 列表此前只在抽屉打开 / 新建 / 删除 / init 四处更新,而 `message_persisted` 只处理
* 「正在看的那个会话」—— 别的会话攒未读**没有任何人会去发现**。
*
* ## 为什么是「问服务端」而不是「本地把未读数加一」
* `message_persisted` 帧**不带 `unreadCount`**:本端没有逐条时间线,靠帧自己数出来的数会与编辑 /
* 删除 / 别端操作漂移(这正是 [onMessagePersisted] 里那条纪律的由来)。所以这里**一个数都不造**,
* 只是把「服务端才是唯一真源」这件事提前做一次。
*
* ## W77:问的是**那一条会话的增量**,不再是整表
* 曾经的落点是 `refresh(silent = true)`(整表 `GET /sessions`),一次要为**所有**会话付一行预览 +
* 未读计数的价钱,而真正变了的只有一个会话。现在落到 [flushPendingIncrements]:
* `GET /messages?after=<已见最大 createdAt>`,只回那几条新消息。整表仍是唯一权威,但只在
* 「增量给不出可信答案」时才请它出场(回落规则写在 [flushPendingIncrements] 上)。
*
* ## 三条纪律
* 1. **节流**(判据 [silentRefreshStep],窗口 [SILENT_REFRESH_WINDOW_MILLIS]):一次连发只发一次
* 请求;同时保证**最后一条之后还会再拉一次**(trailing),红点不会停在旧值上。
* 2. **静默**:不闪「同步中…」——用户根本没开抽屉,不该有任何东西在动。失败时做什么**完全沿用既有
* 行为**(挂 [SessionsUiState.error] 那一行,只有打开抽屉才看得见;以及「网络异常,显示 x 分钟前
* 的结果」那一句),本方法不另加提示:一次后台的顺手对齐失败,不该比用户自己打开抽屉时的那次
* 刷新更吵。
* 3. **不抢跑**:不改 `activeSessionId`、不推进已读游标、不碰 [select]。回落那条路([refresh])
* 内部唯一会动 `activeSessionId` 的地方是 [resolveActiveSession](当前会话被别处删掉时收敛回
* 主会话),那是既有的、正确的收敛,不是重置。
*
* @param cause 这次为什么要拉;决定它走不走窗口(见 [SilentRefreshCause])。
* @param sessionId 这一帧说的是**哪个**会话;`null` = 没有具体会话([SilentRefreshCause.ContextChanged]),
* 那一档只能走整表。窗口里攒下的 id 由 [pendingIncrements] 记着,因为一次窗口可能合并**几个不同**会话。
*/
private fun requestSilentRefresh(cause: SilentRefreshCause, sessionId: String? = null) {
val step = silentRefreshStep(cause, System.currentTimeMillis(), silentRefreshThrottle)
silentRefreshThrottle = step.throttle
// 没有具体会话时不留 id:那一次整表会把所有会话都对上,攒着的 id 一个也不缺。
if (sessionId != null) pendingIncrements += sessionId
when (step.action) {
// 该拉了。先把**等在半路**的那次补拉撤掉:它要做的就是同一件事,留着会多拉一次。
// (只有 ContextChanged 压过窗口时才可能真的取消到一个活着的补拉任务——窗口外那一档
// 走到这里时 trailingPending 必为 false,补拉早跑完了。)
//
// 撤掉补拉**不等于**可以丢掉它攒下的 id:[pendingIncrements] 是独立的一份账本,
// 这一趟会把它们一并做掉。
SilentRefreshAction.PullNow -> {
trailingRefreshJob?.cancel()
trailingRefreshJob = null
flushPendingIncrements(fullTable = sessionId == null)
}
// 窗口内:排一次补拉。它是「最后一条之后还会再拉一次」的**唯一**保证——没有它,一串
// 广播的尾巴会被窗口吃掉,红点就停在旧值上(这正是节流最容易做错的那一半)。
//
// 到点时**直接拉**,不再回头问一次判据:那一刻读到的钟可能早一两毫秒,再问就会被自己
// 刚开的窗口挡回去、只能再排一次;而「窗口已经过去」是补拉自己等出来的事实,直接拉、
// 直接开新窗口更诚实,也少一条能自锁的路径。账本仍走纯函数那一份([throttleAfterPull]),
// 免得两处各写一遍、各自漂移。
SilentRefreshAction.ScheduleTrailing -> {
trailingRefreshJob = viewModelScope.launch {
delay(step.delayMillis)
silentRefreshThrottle = throttleAfterPull(System.currentTimeMillis())
flushPendingIncrements(fullTable = false)
}
}
// 窗口内、补拉已经排上:那一次本来就是把窗口里所有成因合起来做掉,这里不必再排。
// (但这一帧的会话 id 已经在上面记进 [pendingIncrements] 了 —— 补拉不会漏掉它。)
SilentRefreshAction.Ignore -> Unit
}
}
/**
* 把 [pendingIncrements] 里攒下的会话**逐个问一次消息增量**,问不了就整批回落整表(W77)。
*
* 这就是 W49 那条「非激活会话落了新消息 ⇒ 整表重拉 `GET /sessions`」的替代品:整表一次要回
* **所有**会话(每条一行预览 + 未读计数),而这里只回**真的变了的那一个**会话的新消息。
*
* ## 回落的三条理由(都落到同一条路:整表)
* 1. **问不了增量**:[incrementCursors] 给 `null` —— 行不在列表里,或本端不知道它看到哪儿了;
* 2. **追不到尾**:`complete = false` —— 整个追尾里拿过整页(服务端的 `LIMIT` 切过一刀,那一刀
* 可能劈开了同一毫秒,而服务端只给严格 `>`,被劈开的尾巴再也追不回来;此后的空页与不满页
* 都说不清),或翻满了 `MessageRepository.MAX_INCREMENT_PAGES`。那时增量只覆盖了积压的一部分,
* 拿它当「全部」就是把红点做小;
* 3. **请求失败**:含会话已在别处被删掉的 `404` —— 那一档要靠整表把这一行从列表里去掉。
*
* 三条都是「增量给不出可信答案」⇒ 去问唯一权威。**一次批次最多回落一次**:整批共用这一个失败
* 出口,否则 N 个会话同时失败就是 N 次整表。
*
* ## 刻意不做的一件事:不写快照
* [refresh] 成功时会 `prefs.saveSnapshot`,这里**不写**。快照的类型是 `List<SessionSummaryDto>`
* (整表响应),而增量只回了消息——拿它去拼一份 DTO 列表,缺的字段(`type` / `createdAt` /
* `pinned` / `personaId`)只能靠猜,那等于用一个更差的东西覆盖掉一份好快照。代价是冷启动第一帧
* 可能显示上一次整表时的未读与预览,紧接着 `init` 里那次 `refresh()` 就会把它修正。
*
* ## 也不顺带刷角色目录
* [refresh] 会捎带 `loadPersonas()`,这条路径不会。角色目录的刷新点是 init / 新建 / 删除 / 抽屉
* 打开(那几个都是用户动作或一次性事件),「别的会话来了条消息」从来不是它的刷新时机——此前它只是
* 跟着整表搭了便车。W77 把便车取消,角色目录少掉的是几次没人要的请求。
*
* @param fullTable `true` = 这一趟没有具体会话(或已判明必须整表),直接走 [refresh]。
*/
private fun flushPendingIncrements(fullTable: Boolean) {
val targets = pendingIncrements.toList()
pendingIncrements.clear()
if (fullTable) {
refresh(silent = true)
return
}
val cursors = incrementCursors(targets, _state.value.sessions)
if (cursors == null) {
refresh(silent = true)
return
}
// 空批次 = 没有事要做(不是失败):不回落、也不发请求。
if (cursors.isEmpty()) return
viewModelScope.launch {
for ((sessionId, from) in cursors) {
val increment = container.messageRepository.loadIncrement(sessionId, after = from).getOrNull()
if (increment == null || !increment.complete) {
refresh(silent = true)
return@launch
}
val readAt = readCursors[sessionId]
_state.update {
it.copy(sessions = it.sessions.applyIncrement(sessionId, from, readAt, increment))
}
}
}
}
/**
* 切换 / 进入会话。
*
@@ -236,6 +474,13 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
*
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
* 屏幕上(即将)显示的就是这个会话。
*
* **这里刻意不顺手静默重拉**(虽然「切会话」在判据上属于 [SilentRefreshCause.ContextChanged]
* 那一类):本函数紧接着就发了一次推进已读游标([pushReadCursor]),而**它还没落地**——
* 这一刻的 `GET /sessions` 很可能仍按旧游标算出未读,就地清零会被它覆盖成「又有未读」
* (本端此时也没有可以遮罩的游标:[readCursors] 只在响应 / 帧到达后才记),用户就会看到
* 刚点开的那个会话角标**闪回来**。列表本来也不缺这一拉:抽屉打开时那一次(`ChatHomeScreen`)
* 紧接着就到,而它发生在游标之后。
*/
fun select(sessionId: String) {
_state.update {
@@ -268,6 +513,20 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
container.sessionRepository.deleteSession(sessionId)
.onSuccess {
prefs.removeSession(sessionId)
// 本端**现在就知道**这个会话没了:那个会话的 ChatViewModel 还活着、还订阅着
// 共享连接,但它没有别的渠道知道这件事 —— 不记这一笔,它会在**每一次**重连
// 都发一次注定 404 的补拉(S7 登记的那一条:404 被静默吞掉,连一条线索都不留)。
// 记的是「本端已知」而不是「用户界面该怎么表现」:这不是要让谁弹一句告知
// (用户自己删的,他知道),只是让下一次补拉**事前**就判得掉。
container.goneSessions.mark(sessionId)
// 这个会话在本端从此没有行可显示 ⇒ 「谁在跑」里那条也当场作废(留着它只会让
// 账本永远不为空,而它对应的那一行早就不存在了)。
forgetBusy(sessionId)
// 同一件事的另一半(W52A):把那一格**交出去**。上面那一笔只让「还活着的 VM」
// 别再发补拉,而这一格本来就该跟着会话一起消失 —— 它的消息列表(含内联 base64
// 图片)没有任何理由继续占着内存。两半都在这里,是因为这里是本端**唯一**的删除
// 入口(别处删掉的会话只能靠 404 那一档发现,那一档的收敛在 VM 自己身上)。
container.chatSessionSlots.reclaim(fingerprint(), sessionId)
if (_state.value.activeSessionId == sessionId) {
select(SessionSummaryDto.MAIN_SESSION_ID)
}
@@ -331,18 +590,87 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
*
* 「谁在跑」(S9)比未读更早折一遍:[onBusyFrame] 的判据与上面的纪律**同一条**——它认的是
* 帧里那个会话 id,不是当前打开的那个(抽屉要回答的正是「别的会话在不在跑」)。这一折**只读**
* 帧、不碰下面的分支,所以两条既有路径(W49 的节流静默重拉、[persistedRoute] 的两档)语义
* 一个字节都没变。
*
* **通知不在这里**(W57 起):她主动说的话那两条路(实时帧 / 带 `proactive` 的落库帧)**曾经**
* 在本 VM 的帧订阅里转交给 `core.notify.ProactiveNotifier`,而这份订阅挂在 `viewModelScope` 上
* ——从最近任务把 Activity 划掉时它就没了,进程被前台服务保活着也收不到帧。那条通路现在由
* **进程级**的消费点喂(`app.ProactiveFrameIntake`,与那条共享连接同一层、同寿),本类**不再
* 判断通知的任何一件事**:这里只剩列表自己的两件事(未读、「谁在跑」)。
*/
private fun onEvent(event: WsEvent) {
onBusyFrame(event)
when (event) {
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
is WsEvent.SessionRead -> onSessionRead(event)
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
// 落了新消息。哪一个会话都有事做:正在看的那个推进游标(消息已经显示在屏幕上了,
// 否则「自己正看着的会话」会攒出一个未读角标,契约 §3.9 的客户端处理建议),
// 别的会话则去问一次服务端(红点不该等用户开抽屉才出现)——两半都在 onMessagePersisted。
is WsEvent.MessagePersisted -> onMessagePersisted(event)
// 她主动说的话(v0.72)。服务端**不为这条路补** `message_persisted`(那条只有 IM 路由
// 才广播),所以上面那一档永远不会被它触发——不在这里补一句,主会话的红点会停在她
// 开口之前,要等用户自己开一次抽屉(那次静默重拉)才补上。
is WsEvent.ProactiveMessage -> onProactiveMessage(event)
else -> Unit
}
}
/**
* 把一帧折进「谁在跑」的账本(判据与收尾规则全在纯函数 [applyBusyFrame] 里),并只在**结论
* 真的变了**时推一次状态。
*
* 「变了才推」不是优化而是必需:这个函数对**每一帧**都会跑一遍,而流式那一串(`message.delta`
* / `thinking.delta`)是刷新率级的。无关帧在 [applyBusyFrame] 里原样返回**同一张表**,这里据此
* 直接返回,`_state` 因此一帧都不会被这些帧碰到。
*/
private fun onBusyFrame(event: WsEvent) {
val next = busyTurns.applyBusyFrame(event)
if (next == busyTurns) return
busyTurns = next
_state.update { it.copy(busySessions = next.keys.toSet()) }
}
/** 本端从此不再(也不该再)关心这个会话在不在跑:它已经被删掉、本端再也没有可显示的那一行。 */
private fun forgetBusy(sessionId: String) {
if (sessionId !in busyTurns) return
busyTurns = busyTurns - sessionId
_state.update { it.copy(busySessions = busyTurns.keys.toSet()) }
}
/**
* 停掉**某一个会话**正在跑的那一轮(抽屉里那一行「停止」,W52B)。
*
* 走的是**既有的上行出口** [ChatSocket.sendInterrupt]:`interrupt{sessionId, scope}` 本来就带
* 会话归属(`WsOutbound.Interrupt` 的第一个参数就是它),所以「停某一个会话」既有的帧表达得了,
* **不需要新造帧**。
*
* ## 为什么恒发 `scope:"current"`
* 不读「她忙时发消息」那一档偏好:用户按的是一个**明确的动作**(停),不是「她忙时我该怎么办」
* 那档策略的延续。取值与 `InterruptMode.Interrupt.wireScope` 同一个,两条理由:
* 1. **本端从不发 `all`** 是一条既有不变式(`devdocs/02` 的 A2 补记、`devdocs/03` §4),
* `dropped > 0` 那条「已打断,丢弃 N 条」的告知面在本端**不存在**;
* 2. `all` 会连**待处理队列**一起清掉,而队列里那些是用户自己敲进去、还没发出去的东西。
* 静默丢掉它们比不给这个按钮更坏。
*
* 于是这个按钮的语义是「**停掉正在跑的这一轮**」:排队中的消息照常会跑、灯随后会再亮起来——
* 那是实情,不是没按到。
*
* ## 刻意不在这里乐观灭灯
* 灯由服务端帧驱动(`session_interrupted` / `turn.end` / `session_idle`,见 [applyBusyFrame])。
* 本端自己先灭,就等于替服务端宣布了一件还没发生的事;而帧一到,灯自然就灭了。
*
* 发不出去(WS 没连着)时**静默**:与 `ChatViewModel.interruptRunningTurn` 同一条纪律
* (那里写了三条理由)。而这一刻灯本来也不会亮——连接一断,账本当场作废(见 init 里那条订阅)。
*/
fun stopGenerating(sessionId: String) {
if (sessionId.isBlank()) return
socket.sendInterrupt(sessionId, STOP_GENERATING_SCOPE)
}
private fun onSessionRead(event: WsEvent.SessionRead) {
if (event.sessionId.isBlank()) return
recordReadCursor(event.sessionId, event.readAt)
@@ -350,22 +678,77 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
/**
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
* `message_persisted` 的两条出路(路由判据 [persistedRoute]):**当前打开的那个会话**推进游标;
* **别的会话**去问一次服务端。
*
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
* 而抽屉每次打开都会静默重拉,以服务端为准)。
* 两半共用同一条纪律:**本端哪里都不替服务端数未读**(逐条累加会与编辑 / 删除 / 别端操作漂移)。
* 区别只在「那它靠什么显示」:
* - [PersistedRoute.ActiveSession] ⇒ 就地清零 + 推进游标(这段一个字没改);
* - [PersistedRoute.InactiveSession] ⇒ **节流地问一次这条会话的消息增量**([requestSilentRefresh]
* 带上 `sessionId` → [flushPendingIncrements] → `MessageRepository.loadIncrement`),未读由
* 「增量里越过已读游标的条数」说话(W77;W49 那一档原本整表重拉 `GET /sessions`)。
* 这正是 W49 修的那一幕:此前这一档直接 `return`,于是「App 停在主会话、次级会话来了新消息」
* 没有任何人会去发现,红点要等用户打开一次抽屉才补上。
*
* 两档的**载荷要求不同**,是刻意的:推游标要用消息自己的 `createdAt`(缺了就只能什么都不做),
* 而「问一次增量」不需要载荷里的任何一个字段 —— **帧壳本身就是全部证据**(服务端只为「某条
* 消息已在某会话落库」才推这一帧)。所以非激活那一档**不检查 `event.message`**:真遇上载荷解不出的
* 坏帧(契约漂移的典型症状),宁可多问一次服务端,也不要让红点又停在旧值上。
*/
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
if (sessionId != _state.value.activeSessionId) return
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
// 别的会话:本端数不出它的未读(帧不带 `unreadCount`)⇒ 节流地问一次**这条会话**的消息
// 增量,红点于是在用户**没开抽屉**的时候也会出现。这一帧到此为止。
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是这个会话:就地清零 + 推进游标,**不发任何请求去拉列表**。
// 这里用 `when` 穷举而不是「不满足上面那条就走这段」:路由将来多一档时,
// 必须有人在这里做决定(先例:`ui.chat.queuedSendNoticeFor`)。
PersistedRoute.ActiveSession -> {
val message = event.message ?: return
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前
// 标成已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,
// 语义见 select()。
val before = message.createdAt.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
}
}
}
/**
* 她主动说的话(v0.72 `proactive_message`)落在**主会话**——所以这一帧在本 VM 里问的是
* 「主会话那一行现在有没有未读」,与 [onMessagePersisted] 的两条出路**同一套口径**,
* 判据也共用 [persistedRoute](只是会话 id 由这一帧自己钉:它不带 `sessionId`)。
*
* 为什么必须在这里补一句:服务端的主动推送 Web 路由只发这一帧,**不补** `message_persisted`
* (那条只有 IM 路由会广播),所以 W49 那条「非激活会话落新消息 ⇒ 节流刷新」根本不会被
* 触发——抽屉里主会话的红点就停在她开口之前,直到用户自己开一次抽屉。这里复用**同一条**路
* ([requestSilentRefresh] + 既有的 [SilentRefreshCause.InactiveSessionMessage],会话 id 取主会话),
* 不新写取数路径,也不动节流判据本身。
*
* `id` 缺席 = 服务端那一刻**没有落库行** ⇒ 历史里没有这一行,未读面上没有任何东西要更新
* (本端也不替服务端数未读)。这一档的可见性由聊天页那条降级气泡负责,不归这里。
*/
private fun onProactiveMessage(event: WsEvent.ProactiveMessage) {
if (event.id.isNullOrBlank()) return
val sessionId = SessionSummaryDto.MAIN_SESSION_ID
when (persistedRoute(sessionId, _state.value.activeSessionId)) {
PersistedRoute.InactiveSession ->
requestSilentRefresh(SilentRefreshCause.InactiveSessionMessage, sessionId)
// 屏幕上就是主会话:她这句话已经显示在聊天流里(ChatViewModel 那侧并入)⇒ 与
// message_persisted 的同一档一样「看到即已读」,免得抽屉为她刚说的话亮一个红点。
PersistedRoute.ActiveSession -> {
val before = event.createdAt?.takeIf { it > 0 }
before?.let { recordReadCursor(sessionId, it) }
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
pushReadCursor(sessionId, before)
}
}
}
/**
@@ -394,8 +777,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
}
/** 与聊天页的 VM key 用**同一份**口径([accountFingerprint]),不要就地另拼字符串。 */
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username)
}
internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
@@ -426,6 +810,17 @@ internal fun unreadBadgeText(count: Int): String? = when {
/** 角标显示的未读上限;超过就显示 `99+`。 */
internal const val UNREAD_BADGE_MAX = 99
/**
* 「停止」那一帧的 `scope`(见 [SessionsViewModel.stopGenerating])。
*
* 取值必须与 `InterruptMode.Interrupt.wireScope` 一致——本端**从不发 `all`** 是一条既有不变式
* (`devdocs/02` 的 A2 补记、`devdocs/03` §4),而 `all` 会静默清空待处理队列。
* 这里写字面量而不是引用那个枚举:那边的类型是 `String?`(`Parallel` 档就是 `null`),
* 直接传下去会让「取值一旦被改坏」退化成「发出一条省略 scope 的帧」= 服务端眼里的 `"all"`,
* 而那正是最坏的方向。两处一致由 `SessionBusyTest` 钉住。
*/
internal const val STOP_GENERATING_SCOPE = "current"
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
@@ -0,0 +1,205 @@
package top.yeij.cyrene.ui.settings
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.keepalive.notificationsAllowed
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 设置页的「后台保持连接」一行。
*
* ## 为什么单独一个文件
* 这一行自带状态与权限流程(授权状态、刚被拒的标记、发起申请、跳系统设置),是这块功能里唯一
* 会被改动的地方。`SettingsScreen.kt` 那边只留一行调用:改这里不会碰到别的行,改别的行也不会
* 碰到这里。
*
* ## 它只改一个偏好,**不起停服务**
* 起停只有一个主人(`CyreneApp` 里那个 `LaunchedEffect` + `core.keepalive.keepAliveAction`):
* 这里写 `AppPrefs.keepAliveEnabled`,偏好一变,那边自己判、自己动手。两处各自 start / stop
* 迟早会打架(iOS/Android 都一样:能表达同一件事的地方只留一处)。
*
* ## 权限被拒时不假装成功
* `POST_NOTIFICATIONS` 是前台服务的**可见性**来源(minSdk 33,它是运行时权限)。被拒时本端
* **不打开开关**,并把原因写在开关下面:一个「开着但其实没跑」的开关比一个关着的开关更坏。
* 话说到「到系统设置里允许」为止还不够——用户不该自己摸进系统设置去找那一栏,所以那两档下面
* 直接给一个按钮,替他跳过去(见 [notificationSettingsFallback])。
*
* @param settings 偏好写入口。
* @param prefs 当前偏好(开关的显示态)。
*/
@Composable
internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// 授权状态在本页内会变的只有两条路:我们刚发起的那次申请,以及我们刚把用户送去系统设置
// 又回来。后者靠 [openNotificationSettings] 的结果回调**直接复核**——从系统设置回来那一刻
// 才知道用户改成了什么,所以不需要再挂一个生命周期钩子去猜「是不是又回前台了」。
//
// ⚠️ 复核只纠正**这句话**,不改开关、也不起停服务:服务的起停判据在 `CyreneApp`,
// 而它的 `LaunchedEffect` 只认「开关」与「登录态」这两个键(权限是每次现读的入参)。
var allowed by remember { mutableStateOf(context.notificationsAllowed()) }
var justDenied by remember { mutableStateOf(false) }
val requestNotification = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
allowed = granted
justDenied = !granted
if (granted) scope.launch { settings.setKeepAliveEnabled(true) }
}
// 从系统设置回来:无论结果码是什么(那个页面一般回 RESULT_CANCELED),都现读一次真实授权。
val openNotificationSettings = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
allowed = context.notificationsAllowed()
// 刚回来时读到的就是最终答案,那句「刚刚被拒」已经过期了——留着它才是说假话。
justDenied = false
}
// 与 [keepAliveSummary] 里那两档**一一对应**:文案叫你「到系统设置里允许」,按钮就带你去
// 那里。这样两处不会各说各话(改了文案忘了按钮、或反过来,都会让用户觉得被耍)。
//
// 类型写全是有意的:`bottomAction` 收的是可空的可组合 lambda,写成 `if (...) null else {...}`
// 那种内联形状时要靠推导去猜哪一支是 `@Composable`,显式标出来省得编译器为难。
val settingsShortcut: (@Composable () -> Unit)? =
if (!allowed && (justDenied || prefs.keepAliveEnabled)) {
{
TextButton(
text = "去系统设置",
onClick = {
// 先跳正式做法;有的 ROM 把那个页面砍了(`launch` 会抛
// ActivityNotFoundException)⇒ 退到「应用详情页」,那里也有一栏「通知」。
// 两次都失败就等于点了没反应,此时宁可什么都不做,也不让它崩。
val direct = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
val launched = runCatching { openNotificationSettings.launch(direct) }.isSuccess
if (!launched) {
runCatching {
openNotificationSettings.launch(notificationSettingsFallback(context))
}
}
},
modifier = Modifier.fillMaxWidth(),
)
}
} else {
null
}
KeepAliveSectionTitle()
PreferenceCard {
SwitchPreference(
title = "后台保持连接",
summary = keepAliveSummary(
enabled = prefs.keepAliveEnabled,
notificationsAllowed = allowed,
permissionJustDenied = justDenied,
),
checked = prefs.keepAliveEnabled,
onCheckedChange = { wanted ->
justDenied = false
when {
!wanted -> scope.launch { settings.setKeepAliveEnabled(false) }
allowed -> scope.launch { settings.setKeepAliveEnabled(true) }
// 还没授过(或授过又被撤):按平台规范申请一次,结果由回调说话。
else -> requestNotification.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
bottomAction = settingsShortcut,
)
}
}
/**
* 「去系统设置」的兜底去处。
*
* 首选是 `ACTION_APP_NOTIFICATION_SETTINGS` + `EXTRA_APP_PACKAGE`,那是 API 26 起的正式做法;
* 本端 minSdk 33,所以旧版那对 `"app_package"` / `"app_uid"` extra 与 API 24/25 的差异都用不着管。
* 剩下的一处差异不是 API 而是**厂商**:这个页面不是每个 ROM 都留着,砍了就退到这里
* (应用详情页,多一层才能点到「通知」)。
*/
private fun notificationSettingsFallback(context: Context): Intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", context.packageName, null))
/**
* 分段标题。样式与 `SettingsScreen.SectionTitle` 逐字一致:那边那个是 `private` 的,而这里要
* 的只是同一段样式,为此把它的可见性放宽不划算。两处将来若分叉,先动的是这个注释。
*/
@Composable
private fun KeepAliveSectionTitle() {
Text(
text = "连接",
style = MiuixTheme.textStyles.subtitle,
color = MiuixTheme.colorScheme.primary,
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
)
}
/**
* 开关下面那行字(**纯函数**,测试钉住)。它同时是**代价说明**与**当下真实状态**:
* 「关着会怎样」「开着要付出什么」「为什么没打开」——三件事都得说,且顺序上后者优先
* (用户刚被拒的那一次是此刻最要紧的信息)。
*
* 代价**必须自己说出来**,而且是**两份**:
* - 用户付的那份:一条常驻通知 + 多耗一点电。不说的代价会变成他某天「莫名其妙多了条通知」
* 的意外,那比不开这个功能更伤信任。
* - 系统收走的那份:`dataSync` 型前台服务在 targetSdk 37 下,**24 小时内累计最多 6 小时**
* (`KeepAliveService.onTimeout` 到点自己收手,不硬撑到 ANR)。所以「全天候保活」做不到,
* 这句话不能只对他一个人藏着——一个以为买了全天候、结果 6 小时后悄悄断开的用户,
* 比一个从没开过这个开关的用户更亏。
*
* 因此「关 / 开」两档都带上了这条上限([CAP_NOTE]):关着的人要据此判断值不值得开,
* 开着的人要知道自己买到的到底是什么。
*/
internal fun keepAliveSummary(
enabled: Boolean,
notificationsAllowed: Boolean,
permissionJustDenied: Boolean,
): String = when {
permissionJustDenied ->
"系统通知权限被拒绝,没能打开——前台服务没有通知就等于瞒着你常驻,本端不做这种事。到系统设置里允许 Cyrene 发送通知后再来打开"
enabled && !notificationsAllowed ->
"通知权限现在是关的,服务起不来:到系统设置里允许 Cyrene 发送通知后才会真的生效"
enabled ->
"已开:会常驻一条静默通知(不响铃、不震动),并多耗一点电;换来的是切到后台久了,实时连接也不被系统掐断。$CAP_NOTE"
else ->
"关:切到后台久了,系统可能冻结本进程并断开实时连接;消息不会丢(服务端已落库),回到前台会自动补上。$CAP_NOTE"
}
/**
* 系统给这类前台服务的额度。措辞刻意**不带情绪**:它是一个客观上限,不是遗憾——
* 说清了,用户才知道这开关换来的是「更久」而不是「永远」。
*
* 数字与 [top.yeij.cyrene.core.keepalive.KeepAliveService.onTimeout] 的注释同源(那边是机制,
* 这边是用户可见的说法);改任何一处都得两处一起改。
*/
private const val CAP_NOTE = "不过系统对这种服务有上限:24 小时内累计最多 6 小时,用满会被停下"
@@ -8,13 +8,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@@ -25,15 +28,19 @@ import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.icon.extended.Remove
import top.yukonga.miuix.kmp.overlay.OverlayDialog
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -47,6 +54,8 @@ fun SettingsScreen(
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val scope = rememberCoroutineScope()
val settings = container.appSettings
// 登出的二次确认。纯 UI 的开关,不进任何 VM:它不表达应用状态,只表达「这一次手指落在哪」。
var confirmLogout by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -105,6 +114,27 @@ fun SettingsScreen(
)
}
/*
* 「免打扰」单独占一段,不塞进上面那个「聊天」里:它管的是**通知**(屏幕上会不会被
* 盖住),与「聊天页怎么渲染」是两件事。下一段「连接」也是同一个道理(那边问的是连接
* 会不会断)。
*
* 写法与「后台保持连接」那一行逐项对齐(`KeepAliveRow.kt`):`SwitchPreference` 外面
* 必须包一层 `PreferenceCard`(Miuix 的 Preference 系列自己没有背景与圆角),副文案
* 走一个纯函数(好单测,见 `proactiveQuietSummary`),改开关只写偏好、**不在这里做
* 任何通知相关的事**——发通知时自己会现读(见 `AppSettings.current`)。
*/
SectionTitle("通知")
PreferenceCard {
SwitchPreference(
title = "免打扰",
summary = proactiveQuietSummary(prefs.proactiveQuiet),
checked = prefs.proactiveQuiet,
onCheckedChange = { scope.launch { settings.setProactiveQuiet(it) } },
)
}
Spacer(Modifier.height(8.dp))
SectionTitle("她忙时发消息")
InterruptMode.entries.forEach { mode ->
PreferenceCard {
@@ -126,6 +156,8 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp))
}
KeepAliveRow(settings = settings, prefs = prefs)
SectionTitle("关于")
PreferenceCard {
BasicComponent(
@@ -149,24 +181,66 @@ fun SettingsScreen(
}
Spacer(Modifier.height(32.dp))
// 图标不能再用 Back(左箭头)——真机上它看着就是「返回」,这条曾经难找得主人翻了半天。
// Miuix 没有专门的登出/退出字形(Basic 只有 7 个图标),extended 里最贴切的是 Remove:
// 它画的是「箭头离开方框」,语义就是移出/离开。
PreferenceCard {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onLogout) {
BasicComponent(
title = "退出登录",
titleColor = BasicComponentDefaults.titleColor(color = MiuixTheme.colorScheme.error),
startAction = {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = "登出",
imageVector = MiuixIcons.Remove,
// 文字已经把这条读出来了,图标再说一遍只会让读屏重复。
contentDescription = null,
tint = MiuixTheme.colorScheme.error,
)
}
}
},
onClick = { confirmLogout = true },
)
}
Spacer(Modifier.height(32.dp))
/*
* 退出登录的确认框。
*
* 为什么非要有:这一条是**整行可点**,而它紧挨着上面那条「版本」——两条长得一样、
* 只差一行,误触的代价却是一次真登出(`AuthManager.logout` 会吊销服务端 refresh
* 并把本机凭据整份清掉,没有「撤销」)。一行可点的危险动作,必须再问一次。
*
* 用的是 Miuix 自己的 `OverlayDialog`(`top.yukonga.miuix.kmp.overlay`):它由
* 本页 `Scaffold` 自带的 `MiuixPopupHost` 承载,标题 / 说明 / 内容分槽。注意 Miuix
* 没有 `extra.*` 包(0.9.x 里对话框就在 `overlay` / `window` 两处),别照旧版本的
* import 去猜。
*
* 三处出口刻意一致:取消按钮、点框外、系统返回键全都只是关掉这个框,一个字都不改;
* 只有「退出登录」这一条路通向 [onLogout]——行为链路与加这个框之前一模一样。
*/
OverlayDialog(
show = confirmLogout,
title = "退出登录?",
// 如实说清代价(服务器地址也要重填):登出后回到登录页,那儿是空的。
summary = "退出后本机会清空登录信息(服务器地址也要重填),需要重新登录",
onDismissRequest = { confirmLogout = false },
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(text = "取消", onClick = { confirmLogout = false })
Spacer(Modifier.width(8.dp))
TextButton(
text = "退出登录",
onClick = {
// 先把框收掉再登出:登出会把整个设置页(连同这个框的 remember)从
// 组合里摘掉,留着它等于把一次「正在关闭」的动画交给一个正在消失的宿主。
confirmLogout = false
onLogout()
},
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
}
}
@@ -208,3 +282,19 @@ private fun InterruptMode.summary(): String = when (this) {
InterruptMode.Parallel ->
"她正忙时不打断当前这一句,另起一路同时处理这条;两路各算一遍,会多花一份算力与 token"
}
/**
* 「免打扰」开关下面那行字(**纯函数**,测试钉住)。它要说清的只有一件事:
* **这不是静音** —— 关掉的是横幅,不是消息、也不是提示音。
*
* 为什么非说不可:「免打扰」四个字在系统里到处都是,用户对它的第一印象常是「什么都不响」。而这个
* 开关实际做的是「她的话照旧响一声、躺在通知栏里,只是不再盖住我正在做的事」——两句话指向的期待
* 差得很远,一句不说就等于让用户自己猜(猜错的代价是他以为通知坏了,或以为自己买到了完全静音)。
*
* 两档都说出**当下会怎么弹**:用户据以决定的就是「现在这样够不够吵」,而只写「开着 = 安静」
* 等于让他自己去比较两种安静。
*/
internal fun proactiveQuietSummary(enabled: Boolean): String = when {
enabled -> "已开:她主动找你说话只在通知栏提示(有提示音),不在屏幕顶部弹横幅"
else -> "关(默认):她主动找你说话会在屏幕顶部弹横幅,同时有提示音与通知栏条目"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F8F8F8</color>
<color name="ic_launcher_background">#FFE6E0</color>
</resources>
@@ -0,0 +1,182 @@
package top.yeij.cyrene.app
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
import top.yeij.cyrene.core.notify.AppPresence
import top.yeij.cyrene.core.notify.ProactiveNotifier
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.WsEvent
/**
* 「她主动说的话」在**帧这一层**的消费点(W57)。
*
* 这里钉的是**接线**,不是判据(判据与去重仍在 `ProactiveNotifierTest`):
* 1. **它不需要任何 Activity 活着就能收帧**——改前那份订阅挂在 `SessionsViewModel` 的
* `viewModelScope` 上,从最近任务划掉 Activity 就没了,而 `ChatSocket.events` 是 `replay = 0`
* 的 `SharedFlow` ⇒ 没有订阅者的帧**当场丢掉**。本类的第一条测试就是那个 bug 的现场;
* 2. 哪些帧算「她主动说的话」、四个入参各取帧上的哪个键([proactiveFrameOf])——错在这两个方向
* 都是静默的(她开口了却不弹、普通消息也弹),所以逐键钉住;
* 3. 判据与去重在新的消费点上**没有被放松**:两条路仍只弹一次、看着主会话仍不弹、别的会话仍不弹。
*
* 收集用的调度器传 `Dispatchers.Unconfined`:`launch` 与 `tryEmit` 都在当前线程上把 collect 体
* 走完,于是测试是同步的(不需要等、也不需要 `runBlocking`)。
* **测不了的**:真·进程存活(Activity 没了而进程还在)这件事只有真机能造,本层只能钉住「订阅不挂
* 在任何会随 UI 结束的 scope 上」这个可测的部分。
*/
class ProactiveFrameIntakeTest {
private val presence = AppPresence()
private val posted = mutableListOf<String>()
/** 与 `ChatSocket` 同一档的订阅方式:无 replay、有缓冲、溢出丢最旧。 */
private val events = MutableSharedFlow<WsEvent>(
replay = 0,
extraBufferCapacity = EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** 真实的帧(键名与类型都对服务端):实时帧带落库行 `id` + IM 号 + RFC3339 时间戳。 */
private val liveJson = """
{"type":"proactive_message","data":{"platform":"cyrene-backthink","channelId":"chan-1","channelType":"private","content":"记得喝水哦","contentType":"text","sender":"Cyrene","timestamp":"2026-10-06T01:20:05.123+08:00","messageId":"im-7","id":"1371","createdAt":1791212405123}}
""".trimIndent()
/** 同一条消息的另一条路:IM 桥那条路由补发的落库帧(判据键在载荷里)。 */
private val persistedProactiveJson = """
{"type":"message_persisted","sessionId":"web","data":{"proactive":true,"message":{"id":"1371","sessionId":"web","role":"assistant","content":"记得喝水哦","createdAt":1791212405123}}}
""".trimIndent()
/** 普通消息的落库广播(她正常回话):**不是**她主动说的话。 */
private val ordinaryPersistedJson = """
{"type":"message_persisted","sessionId":"web","data":{"message":{"id":"1372","sessionId":"web","role":"assistant","content":"在的","createdAt":1791212405123}}}
""".trimIndent()
/** 她主动说的、但不属主会话:绝不弹(宁可漏,也不要把别处的消息说成是她在找我说话)。 */
private val liveJsonForAnotherSession = """
{"type":"proactive_message","sessionId":"sess-abc","data":{"content":"在吗","messageId":"im-9","id":"1373"}}
""".trimIndent()
private fun intake() = ProactiveFrameIntake(
events = events,
notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text }),
dispatcher = Dispatchers.Unconfined,
)
private fun parse(json: String): WsEvent = requireNotNull(WsEvent.parse(json)) { "帧本身要能成形" }
// —— 这一档就是那个 bug 的现场 ——
@Test
fun `her words still notify when no activity is alive`() {
// Activity 已经从最近任务里划掉(没有任何实例在 started 档、聊天页也不在),进程被
// KeepAliveService 撑着、连接照旧在收帧。改前这里是空的:订阅随 VM 一起没了。
assertFalse("这一档的前提就是前台没有任何 Activity", presence.appInForeground)
intake()
events.tryEmit(parse(liveJson))
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `the notification id comes from the notifier not from this layer`() {
// 号必须按消息派生(同一条 ⇒ 同一个号、不同的句子各占一条)。这一层只转交帧,不许自己
// 造号——这里用一个把号也记下来的假 post 确认它拿到的确实是「按消息派生」的那个值。
val ids = mutableListOf<Int>()
ProactiveFrameIntake(
events = events,
notifier = ProactiveNotifier(presence = presence, post = { _, id -> ids += id }),
dispatcher = Dispatchers.Unconfined,
)
events.tryEmit(parse(liveJson))
events.tryEmit(parse(ordinaryPersistedJson))
assertEquals(1, ids.size)
}
// —— 判据与去重在新的消费点上没有被放松 ——
@Test
fun `the live frame and the persisted frame of one message notify once`() {
intake()
events.tryEmit(parse(liveJson))
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再弹。
events.tryEmit(parse(persistedProactiveJson))
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `a frame that lands on screen while the main session is open does not notify`() {
// 用户正看着她说话的那个会话:话就出现在他眼前,不打扰(判据本身在 ProactiveNotify.kt)。
presence.onActivityStarted(Any())
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
intake()
events.tryEmit(parse(liveJson))
assertEquals(emptyList<String>(), posted)
}
@Test
fun `a frame for another session never notifies`() {
intake()
events.tryEmit(parse(liveJsonForAnotherSession))
assertEquals(emptyList<String>(), posted)
}
@Test
fun `an ordinary persisted frame never notifies`() {
intake()
events.tryEmit(parse(ordinaryPersistedJson))
assertEquals(emptyList<String>(), posted)
}
// —— 哪些帧算她的话、四个入参各取自帧上的哪里 ——
@Test
fun `only the two proactive shapes are frames of her words`() {
assertNull("普通落库广播不是", proactiveFrameOf(parse(ordinaryPersistedJson)))
assertNull("流式增量不是", proactiveFrameOf(WsEvent.MessageDelta(delta = "在的", index = 0)))
assertNull("开始帧不是", proactiveFrameOf(WsEvent.MessageStart(index = 0)))
}
@Test
fun `the live frame takes the history row id and the im id apart`() {
val frame = requireNotNull(proactiveFrameOf(parse(liveJson)))
// 帧壳不带会话归属 ⇒「无法判定」,由「她主动说的话落主会话」这条事实兜住(见 ProactiveNotifier)。
assertNull(frame.sessionId)
assertEquals("1371", frame.id)
assertEquals("im-7", frame.messageId)
assertEquals("2026-10-06T01:20:05.123+08:00", frame.timestamp)
assertEquals("记得喝水哦", frame.content)
}
@Test
fun `the persisted frame has only the row id and never invents the other two`() {
val frame = requireNotNull(proactiveFrameOf(parse(persistedProactiveJson)))
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, frame.sessionId)
assertEquals("1371", frame.id)
// 帧上就没有这两个键。编一个出来(比如把 `id` 也当 IM 号)就是伪造服务端键,去重会跟着漂。
assertNull(frame.messageId)
assertNull(frame.timestamp)
assertEquals("记得喝水哦", frame.content)
}
private companion object {
/** 与 `ChatSocket.EVENT_BUFFER` 同档:订阅方不设上限、发射方不阻塞。 */
const val EVENT_BUFFER = 8
}
}
@@ -0,0 +1,56 @@
package top.yeij.cyrene.app
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「点通知进来要看主会话」那笔待落地的意图(W53N)。
*
* 这里**刻意不测** [ProactiveTapIntake.accept](它要 `android.content.Intent`,单元测试里没有
* 真实现)——那条判断只有一条 extra 的字面量比较,而真正会出错的是这一半:**记账与消费的时机**
* (记账必须在通知被点的那一刻就发生,消费必须只发生一次、且只在真的落地之后)。
*/
class ProactiveTapIntakeTest {
@Test
fun `nothing is requested before a tap`() {
assertFalse(ProactiveTapIntake().openMainSession.value)
}
@Test
fun `a request survives until it is consumed`() {
// 通知可能在 App 还没起来、甚至还没登录时就被点:意图必须留着,等 ChatHomeScreen 能落地
// 的那一刻再来取(它一旦被丢掉,用户点了通知却什么都没发生)。
val intake = ProactiveTapIntake()
intake.requestMainSession()
assertTrue(intake.openMainSession.value)
}
@Test
fun `consuming clears it exactly once`() {
val intake = ProactiveTapIntake()
intake.requestMainSession()
intake.consume()
assertFalse(intake.openMainSession.value)
// 再消费一次也不该把「已经取走了」翻回成「还欠着」(它是个纯写入,没有副作用)。
intake.consume()
assertFalse(intake.openMainSession.value)
}
@Test
fun `a new tap after a consumed one is taken again`() {
// 第二次点通知必须照样生效:消费清的只是「这一笔」,不是把整个入口关掉。
val intake = ProactiveTapIntake()
intake.requestMainSession()
intake.consume()
intake.requestMainSession()
assertTrue(intake.openMainSession.value)
}
}
@@ -0,0 +1,143 @@
package top.yeij.cyrene.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.ShareTextSource
/**
* 三个文本 extra(`EXTRA_TEXT` / `EXTRA_SUBJECT` / `EXTRA_TITLE`)**原始值**的读取
* ([shareTextExtrasOf])。
*
* ## 为什么值得单测
* 这一层只有两件事要做,而两件都容易错:
*
* 1. **按形态读**(`as? CharSequence`):真实分享里这几段常是 `Spanned`(富文本),按 `String` 读会
* 直接拿到 `null` —— 那正是「分享进来什么都没发生」的来处之一。而「读不出类型」(值是个数字、
* 别种 `Parcelable`……)时本端**不猜**:那一站读不到就是读不到,交给下一站。
* 2. **「里面躺着值」要如实报上去**([ShareTextExtras.present]):值与正文是两件事。空白值、读不出
* 类型的值都算「对方给了这个字段」,于是三个 extra 一起落空时本端会走「读不进来」那条如实告知,
* 而不是静默(判据见 `ShareRules.extract` 的 `textPresent`)。
*
* ⚠️ 这里**不**覆盖 `Bundle.getCharSequence` 到底抛不抛异常:那要真安卓运行时(JVM 单测里 android.jar
* 的桩方法一调就抛 `Stub!`)。结论与出处写在 `ShareIntake.kt` 的 `rawExtra` / [ShareTextExtras] 的
* KDoc 里(核的是 AOSP 的 `android/os/BaseBundle.java`:类型不符它自己 catch 掉回 `null`,会抛的是
* `unparcel()` 的 `BadParcelableException`,那一路由 `runCatching` 挡住)。
*/
class ShareTextExtrasTest {
@Test
fun `a plain string text is read through`() {
val extras = shareTextExtrasOf(text = "正文", subject = null, title = null)
assertEquals("正文", extras.text)
assertNull(extras.subject)
assertNull(extras.title)
assertTrue(extras.present)
}
@Test
fun `a spanned like char sequence is read through`() {
// 分享链路里的 `EXTRA_TEXT` 常常是 `Spanned`(带样式的 HTML 富文本):它**不是** String,
// 按 String 读会直接拿到 null。(JVM 单测造不出 `android.text.Spanned`,用另一个
// `CharSequence` 实现把这条读法钉住。)
val raw: Any = StringBuilder("来自网页的一段富文本")
val extras = shareTextExtrasOf(text = raw, subject = null, title = null)
assertEquals("来自网页的一段富文本", extras.text)
assertTrue(extras.present)
}
@Test
fun `a value we cannot read as a char sequence yields no body but is still a value`() {
// 形态读不出来(这里是数字,真实世界是别种 Parcelable):本端不猜它是什么意思 —— 但也不许
// 因此把整次分享当成「什么都没发生」,所以它照样算「里面躺着值」。
val extras = shareTextExtrasOf(text = 42, subject = null, title = null)
assertNull(extras.text)
assertTrue(extras.present)
}
@Test
fun `a blank value is still a value`() {
// 空白也算「对方给了这个字段」:`EXTRA_STREAM` 那边早就是这个口径(原始值非 null 即
// `streamPresent`),两个 extra 用同一条判据才不会出现「流的空值说话、文本的空值闭嘴」。
val extras = shareTextExtrasOf(text = " ", subject = "", title = null)
assertEquals(" ", extras.text)
assertEquals("", extras.subject)
assertTrue(extras.present)
}
@Test
fun `an absent trio yields nothing and is not a value`() {
// 「连值都没有」才是真的没有:这条边界撑着「用户确实什么都没分享 ⇒ 一个字都不说」。
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
assertNull(extras.text)
assertNull(extras.subject)
assertNull(extras.title)
assertFalse(extras.present)
}
@Test
fun `a subject only pair of raw values lands as the body`() {
// 端到端那一步:读出来的三段交给纯函数之后,只有 SUBJECT 的那次分享真的变成了正文,
// 并且来源如实记着。
val extras = shareTextExtrasOf(text = null, subject = "对方只给了标题", title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertEquals("对方只给了标题", share.text)
assertEquals(ShareTextSource.Subject, share.textSource)
assertTrue(share.worthDelivering)
assertFalse(share.contentUnreadable)
}
@Test
fun `raw values that are all unusable speak instead of vanishing`() {
// 三个 extra 里躺着值、而一段正文都没读出来:内容照旧为「空」,但**必须**有话可说 ——
// 这是本次要修的另一种静默(此前只有 `EXTRA_TEXT` 那一路,后两个 extra 落空时全静默)。
val extras = shareTextExtrasOf(text = 42, subject = " ", title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertTrue(share.isEmpty)
assertTrue(share.contentUnreadable)
assertTrue(share.worthDelivering)
}
@Test
fun `nothing at all in the bundle is not a value`() {
// 与上一条成对:`ShareIntake` 拿到的原始值全是 null(误触分享、Intent 里什么都没有)时,
// 本端一个字都不说 —— 边界不许因为这批改动而放宽。
val extras = shareTextExtrasOf(text = null, subject = null, title = null)
val share = ShareRules.extract(
action = ShareRules.ACTION_SEND,
text = extras.text,
subject = extras.subject,
title = extras.title,
textPresent = extras.present,
)
assertFalse(share.worthDelivering)
assertFalse(share.contentUnreadable)
}
}
@@ -0,0 +1,32 @@
package top.yeij.cyrene.core.keepalive
import android.app.NotificationManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 常驻通知的**对外形状**(渠道 id / 重要级)。文案与图标测不了(要 Android 运行时),
* 但这两项能测,而且测它们是有意义的:
*
* - 重要级**建渠道那一刻就定死**了(应用改不动,只有用户能改)。它要是被写成 DEFAULT / HIGH,
* 这个「保活」功能就会开始响铃、震动、弹横幅——那不是保活,是骚扰,用户只能自己去关掉那条渠道。
* - 渠道 id 是用户静音设置的挂靠点:改了它等于换一条新渠道,用户之前对旧渠道的一切设置失效。
*/
class KeepAliveNotificationTest {
@Test
fun `the channel never rings or vibrates`() {
assertTrue(
"渠道重要级只许是 MIN / LOW,现在是 $KEEP_ALIVE_CHANNEL_IMPORTANCE",
KEEP_ALIVE_CHANNEL_IMPORTANCE == NotificationManager.IMPORTANCE_MIN ||
KEEP_ALIVE_CHANNEL_IMPORTANCE == NotificationManager.IMPORTANCE_LOW,
)
}
@Test
fun `the channel id is a stable literal`() {
// 恒定不变是刻意的:换 id = 换渠道 = 用户对旧渠道做的静音设置全部作废。
assertEquals("keep_alive", KEEP_ALIVE_CHANNEL_ID)
}
}
@@ -0,0 +1,93 @@
package top.yeij.cyrene.core.keepalive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.AppPrefs
/**
* 「后台保持连接」的判据(纯函数)。
*
* 钉的是四件事:
* 1. **默认关**——这个功能会常驻一条通知、多耗电,主人拍板默认关,任何人把它改成默认开都是一次
* 产品决定,不该悄悄发生;
* 2. 三个条件缺一不可(开关 / 登录态 / 通知权限),任何一条不成立都不许跑;
* 3. 几种状态各自该走哪一步(登出一定停、关开关一定停、**自举中一定不动**);
* 4. 四个入参的**全表 16 格**都与判据一致——尤其是「登录态未定 ⇒ 不动」这一条,写成穷举才排除得掉
* 「某个组合下热启动会把还跑着的服务收掉」这种走神。
*/
class KeepAlivePolicyTest {
@Test
fun `the feature is off until the user turns it on`() {
assertFalse("默认必须是关", AppPrefs().keepAliveEnabled)
}
@Test
fun `all three conditions are required`() {
assertFalse(shouldKeepAliveRun(enabled = false, loggedIn = true, notificationsAllowed = true))
assertFalse(shouldKeepAliveRun(enabled = true, loggedIn = false, notificationsAllowed = true))
assertFalse(shouldKeepAliveRun(enabled = true, loggedIn = true, notificationsAllowed = false))
assertTrue(shouldKeepAliveRun(enabled = true, loggedIn = true, notificationsAllowed = true))
}
@Test
fun `logging out stops the service even though the switch stays on`() {
// 登出只关连接,不改开关(AppSettings 是 App 级的,不随登出清空)——所以这里必须由判据
// 自己把服务收掉,不能指望开关被重置。
assertEquals(
KeepAliveAction.Stop,
keepAliveAction(enabled = true, sessionKnown = true, loggedIn = false, notificationsAllowed = true),
)
}
@Test
fun `an enabled switch with an undecided session is left alone`() {
// 热启动(Activity 重建、进程还活着)时自举会先经过 Unknown:这一刻把还跑着的服务收掉,
// 用户会看到通知消失又回来,而中间那段进程正好没人保护。
for (loggedIn in listOf(true, false)) {
for (allowed in listOf(true, false)) {
assertEquals(
"登录态未定(sessionKnown=false)时不许动手:loggedIn=$loggedIn allowed=$allowed",
KeepAliveAction.Leave,
keepAliveAction(enabled = true, sessionKnown = false, loggedIn = loggedIn, notificationsAllowed = allowed),
)
}
}
}
@Test
fun `switching off stops the service without waiting for a session`() {
assertEquals(
KeepAliveAction.Stop,
keepAliveAction(enabled = false, sessionKnown = false, loggedIn = false, notificationsAllowed = true),
)
}
@Test
fun `every combination of the four inputs agrees with the judgement`() {
val bools = listOf(true, false)
for (enabled in bools) {
for (known in bools) {
for (loggedIn in bools) {
for (allowed in bools) {
val wanted = known && shouldKeepAliveRun(enabled, loggedIn, allowed)
val expected = when {
wanted -> KeepAliveAction.Start
// 唯一「不许有主张」的一格:开关开着,但登录态还没定。
enabled && !known -> KeepAliveAction.Leave
else -> KeepAliveAction.Stop
}
assertEquals(
"enabled=$enabled known=$known loggedIn=$loggedIn allowed=$allowed",
expected,
keepAliveAction(enabled, known, loggedIn, allowed),
)
}
}
}
}
}
}
@@ -0,0 +1,98 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「App 在不在前台」这个事实的记账(W53N)。
*
* 为什么值得单测:它最容易做成一个布尔量,而布尔量在**配置变更**上必然出错——旋转 / 系统深浅色
* 切换时,**新实例的 `onStart` 先于旧实例的 `onStop`**。记成布尔量的话,用户正看着聊天页,
* 她一句话却会弹出一条通知来。下面第二条就是这一幕。
*/
class AppPresenceTest {
private val presence = AppPresence()
private class Screen
@Test
fun `a process with no started activity is not in the foreground`() {
assertFalse(presence.appInForeground)
}
@Test
fun `a config change keeps the app in the foreground`() {
val old = Screen()
val new = Screen()
presence.onActivityStarted(old)
// 旋转:新实例先起来,旧实例之后才停。
presence.onActivityStarted(new)
presence.onActivityStopped(old)
assertTrue("新实例还活着,就不算后台", presence.appInForeground)
}
@Test
fun `two activities keep it in the foreground until the last one stops`() {
val first = Screen()
val second = Screen()
presence.onActivityStarted(first)
presence.onActivityStarted(second)
presence.onActivityStopped(first)
assertTrue("还有一屏在被看", presence.appInForeground)
presence.onActivityStopped(second)
assertFalse(presence.appInForeground)
}
@Test
fun `a stop from an activity that never started does not flip anything`() {
// 「只删自己那一行」的等价纪律:登出 / 销毁路径上多报一次 stop 不该把别人算掉。
val alive = Screen()
presence.onActivityStarted(alive)
presence.onActivityStopped(Screen())
assertTrue(presence.appInForeground)
}
@Test
fun `the chat surface reports the session it shows, verbatim`() {
presence.onChatSurface("web")
assertTrue(presence.isShowingChatSurface("web"))
// 主会话 `web` 与次级会话 `web:xxx` 是两个会话:等值比较,不是前缀。
presence.onChatSurface("web:7f3a")
assertFalse(presence.isShowingChatSurface("web"))
assertTrue(presence.isShowingChatSurface("web:7f3a"))
}
@Test
fun `leaving the chat page shows no session at all`() {
presence.onChatSurface("web")
presence.onChatSurface(null)
assertFalse(presence.isShowingChatSurface("web"))
}
@Test
fun `a blank session id is never being shown`() {
// 「没显示任何会话」与「显示了一个空 id 的会话」不是同一件事——空 id 不是任何会话的键。
presence.onChatSurface("")
assertFalse(presence.isShowingChatSurface(""))
}
@Test
fun `the chat surface fact does not imply the foreground fact`() {
// 两件事各报各的:Android 上 App 退到后台时组合并不会销毁,聊天页那一半会原样留着。
presence.onChatSurface("web")
assertFalse("没有 Activity 在可见档,就是后台", presence.appInForeground)
}
}
@@ -0,0 +1,322 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
/**
* 通知通路的整体判据(W53N):**去重**与「什么时候弹」。
*
* 这里钉的都是**错了就肉眼可见**的边界:
* 1. 同一条消息走两条路(实时帧带落库行 `id`、落库帧带同一个 `message.id`)——**只许弹一次**;
* 2. 普通消息(不带 `proactive`)根本到不了这里,到了也不该弹(见 `WsEventPersistedProactiveTest`);
* 3. 后台 + 聊天页仍挂着主会话那一档**要弹**(Android 上真实存在);
* 4. 不在主会话的帧绝不弹。
*/
class ProactiveNotifierTest {
private val presence = AppPresence()
private val posted = mutableListOf<String>()
private val notifier = ProactiveNotifier(presence = presence, post = { text, _ -> posted += text })
/** 一个「Activity 实例」(身份即语义,见 [AppPresence])。 */
private val screen = Any()
/** 后台、聊天页不在(最常见的「她说了句话」):这一档必须弹。 */
private fun background() {
presence.onChatSurface(null)
}
/** 前台、正看着主会话:她的话出现在屏幕上,不该再有通知。 */
private fun onMainSessionInForeground() {
presence.onActivityStarted(screen)
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
}
// —— 两条路只弹一次 ——
@Test
fun `the live frame and the persisted frame notify once`() {
background()
// 实时帧:落库行 id 在 `data.id` 里(IM 侧消息号是另一个 id)。
val live = notifier.onProactiveFrame(
sessionId = null,
id = "1371",
messageId = "im-7",
timestamp = "2026-10-06T01:20:05.123+08:00",
content = "记得喝水哦",
)
// 落库帧:同一条的 `message.id`(服务端两侧同一个值)。
val persisted = notifier.onProactiveFrame(
sessionId = SessionSummaryDto.MAIN_SESSION_ID,
id = "1371",
messageId = null,
timestamp = null,
content = "记得喝水哦",
)
assertEquals(ProactiveNotifyOutcome.Posted, live)
assertEquals(ProactiveNotifyOutcome.Duplicate, persisted)
assertEquals(listOf("记得喝水哦"), posted)
}
@Test
fun `each message gets its own notification id while a replay does not`() {
// 「一事一条」在这一层就定好了:号由**去重键**派生,所以「认得出是同一条」与「落在同一条
// 通知上」是同一个判断的两面。第二条消息必须是另一个号——同一个号时 `notify` 是替换,
// 她连说两句用户就只看到最后一句(这就是改前的症状)。
background()
val ids = mutableListOf<Int>()
val recorder = ProactiveNotifier(presence = presence, post = { _, id -> ids += id })
recorder.onProactiveFrame(null, id = "1371", messageId = "im-7", timestamp = null, content = "记得喝水哦")
// 同一条的落库帧(另一条路):认得出是同一条 ⇒ 不再发一条,也就不该多出第二个号。
recorder.onProactiveFrame(
SessionSummaryDto.MAIN_SESSION_ID,
id = "1371",
messageId = null,
timestamp = null,
content = "记得喝水哦",
)
recorder.onProactiveFrame(null, id = "1372", messageId = null, timestamp = null, content = "在忙吗")
assertEquals(2, ids.size)
assertNotEquals("两条不同的消息必须占两条通知", ids[0], ids[1])
}
@Test
fun `a frame without a row id is deduplicated by the im message id`() {
// 落库失败那一档:`id` / `createdAt` 一起缺席,但 IM 侧消息号还在——服务端重推同一帧
// (或本端重放)不该变成两条通知。
background()
fun frame() = notifier.onProactiveFrame(
sessionId = null,
id = null,
messageId = "im-9",
timestamp = "2026-10-06T01:20:05+08:00",
content = "在忙吗",
)
assertEquals(ProactiveNotifyOutcome.Posted, frame())
assertEquals(ProactiveNotifyOutcome.Duplicate, frame())
assertEquals(1, posted.size)
}
@Test
fun `a frame with no server key at all falls back to the text`() {
// 服务端一个键都没给:兜底键取正文。**少弹**是刻意选的那一侧(多弹一次才是骚扰)。
background()
fun frame() = notifier.onProactiveFrame(
sessionId = null,
id = null,
messageId = null,
timestamp = null,
content = "想你了",
)
assertEquals(ProactiveNotifyOutcome.Posted, frame())
assertEquals(ProactiveNotifyOutcome.Duplicate, frame())
}
@Test
fun `a blank id is treated as absent`() {
// 形状漂移(空串)与「这个键不在」必须是同一个结论,否则一条空 id 会把后面的都判成重复。
background()
notifier.onProactiveFrame(null, id = " ", messageId = "im-3", timestamp = null, content = "甲")
val second = notifier.onProactiveFrame(null, id = null, messageId = "im-4", timestamp = null, content = "乙")
assertEquals(ProactiveNotifyOutcome.Posted, second)
assertEquals(2, posted.size)
}
// —— 什么时候弹 ——
@Test
fun `watching the main session in the foreground stays silent`() {
onMainSessionInForeground()
val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦")
assertEquals(ProactiveNotifyOutcome.SeenOnScreen, outcome)
assertTrue(posted.isEmpty())
}
@Test
fun `watching another session still notifies`() {
// 「不在聊天页」包括「在另一个会话的聊天页」:她的话进的是主会话,用户看不到它。
presence.onActivityStarted(this)
presence.onChatSurface("web:7f3a")
val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦")
assertEquals(ProactiveNotifyOutcome.Posted, outcome)
}
@Test
fun `the settings page still notifies`() {
// 前台但聊天页不在组合里(设置页 / 管理页 / 关于):那一刻聊天页报的是 null。
presence.onActivityStarted(this)
presence.onChatSurface(null)
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"),
)
}
@Test
fun `a backgrounded app notifies even though the chat page is still composed`() {
// 这是 AppPresence 的类注释说的那一档:Activity onStop 之后组合还挂着,「聊天页显示着主会话」
// 仍然为真,而用户根本看不见它。只看聊天页那一半就会把后台的每一句都漏掉。
presence.onChatSurface(SessionSummaryDto.MAIN_SESSION_ID)
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"),
)
}
@Test
fun `a message that was already seen on screen is not notified by the other route`() {
// 先到的那一次说了算:他当场看过了,随后落库帧再补一帧时他可能已经切走——
// 但**看过就是看过**,不该为一句看过的话弹通知。
onMainSessionInForeground()
assertEquals(
ProactiveNotifyOutcome.SeenOnScreen,
notifier.onProactiveFrame(null, "1371", "im-7", null, "记得喝水哦"),
)
presence.onChatSurface("web:7f3a")
assertEquals(
ProactiveNotifyOutcome.Duplicate,
notifier.onProactiveFrame(SessionSummaryDto.MAIN_SESSION_ID, "1371", null, null, "记得喝水哦"),
)
assertTrue(posted.isEmpty())
}
// —— 硬边界:不该弹的绝不弹 ——
@Test
fun `a frame from another session never notifies`() {
background()
val outcome = notifier.onProactiveFrame(
sessionId = "sess-9",
id = "1371",
messageId = "im-7",
timestamp = null,
content = "今天在忙什么",
)
assertEquals(ProactiveNotifyOutcome.OtherSession, outcome)
assertTrue("别处的消息不许说成是她在找我说话", posted.isEmpty())
}
@Test
fun `an absent - blank session id on the frame is treated as the main session`() {
// 实时帧的帧壳**不带** sessionId(服务端构造时不设)⇒「无法判定」放行,归属由主会话那条事实兜住。
background()
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(sessionId = null, id = "1", messageId = null, timestamp = null, content = "在吗"),
)
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(sessionId = "", id = "2", messageId = null, timestamp = null, content = "在吗"),
)
}
@Test
fun `a frame with nothing to show is skipped`() {
background()
val outcome = notifier.onProactiveFrame(null, "1371", "im-7", null, " ")
assertEquals(ProactiveNotifyOutcome.NothingToSay, outcome)
assertTrue(posted.isEmpty())
}
@Test
fun `a contentless frame does not burn the key of the message that does have content`() {
// 空帧**不记账**:同一条消息的另一条路上正文可能是齐的(形状漂移),记了就会把它挡成重复。
background()
notifier.onProactiveFrame(null, "1371", null, null, "")
assertEquals(
ProactiveNotifyOutcome.Posted,
notifier.onProactiveFrame(null, "1371", null, null, "记得喝水哦"),
)
}
@Test
fun `the posted text is her own words, not the raw markup`() {
background()
notifier.onProactiveFrame(null, "1", null, null, "<p>在的</p><action>轻轻笑</action>")
assertEquals(listOf("在的 轻轻笑"), posted)
}
@Test
fun `the main session constant has one spelling`() {
// `ui.chat.MAIN_SESSION_ID` 与 `SessionSummaryDto.MAIN_SESSION_ID` 是同一个事实的两处写法
// (判据里用的是后者)。两处一旦分叉,「主会话」这条归属就会静默判错——钉住它。
assertEquals("web", SessionSummaryDto.MAIN_SESSION_ID)
assertEquals(SessionSummaryDto.MAIN_SESSION_ID, top.yeij.cyrene.ui.chat.MAIN_SESSION_ID)
}
}
/**
* 去重账本本身(与 [ProactiveNotifierTest] 分开:一个是「弹不弹」,一个是「凭什么记得住」)。
*/
class RecentProactiveNoticesTest {
@Test
fun `the first arrival of a key is new and the second is not`() {
val recent = RecentProactiveNotices()
assertTrue(recent.markIfNew("1371"))
assertTrue(!recent.markIfNew("1371"))
assertTrue(recent.markIfNew("1372"))
}
@Test
fun `the ledger is bounded and drops the oldest first`() {
// 进程级账本**不许无限涨**:容量满了丢掉最老的那些(丢掉的代价最多是重复弹一次,
// 而那要等到「同一条又被重推」才可能发生)。
val recent = RecentProactiveNotices(capacity = 3)
recent.markIfNew("a")
recent.markIfNew("b")
recent.markIfNew("c")
recent.markIfNew("d")
assertEquals(3, recent.size())
assertTrue("最老的 a 已经被挤出去", recent.markIfNew("a"))
assertTrue("'b' 也早被挤出去(容量 3 只留 c/d/a)", recent.markIfNew("b"))
}
@Test
fun `a blank key is never considered new`() {
// 「没有键」不是一个可去重的身份:把它当新消息就等于每次都弹。
val recent = RecentProactiveNotices()
assertTrue(!recent.markIfNew(""))
assertTrue(!recent.markIfNew(" "))
assertEquals(0, recent.size())
}
@Test
fun `a zero capacity still remembers one key`() {
// 容量被误传成 0 / 负数时不许退化成「每条都弹」:至少记住最近一条。
val recent = RecentProactiveNotices(capacity = 0)
assertTrue(recent.markIfNew("1371"))
assertTrue(!recent.markIfNew("1371"))
assertEquals(1, recent.size())
}
}
@@ -0,0 +1,275 @@
package top.yeij.cyrene.core.notify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「她主动说的话」的通知判据(W53N)——**纯函数**,四档组合穷举在这里。
*
* 主人对时机的原话是「App 不在前台,或者不在聊天页」;这个判据是它唯一的落地处,
* 而那四种组合**各有各的真实场景**(尤其是「不在前台、聊天页却还显示着主会话」那一档:
* Android 上 Activity `onStop` 之后组合并不销毁,只看聊天页就会把后台收到的**每一句**都漏掉)。
*/
class ShouldNotifyProactiveTest {
@Test
fun `being on the main session in the foreground is the only silent case`() {
assertFalse(
"用户正看着主会话,她的话就出现在他眼前——再弹一条通知是打扰",
shouldNotifyProactive(appInForeground = true, viewingTargetSession = true),
)
}
@Test
fun `any other combination notifies`() {
assertTrue(
"前台但不在聊天页(设置页 / 管理页 / 别的会话)——这条消息他不会正好看到",
shouldNotifyProactive(appInForeground = true, viewingTargetSession = false),
)
assertTrue(
"退到后台:两个事实都假",
shouldNotifyProactive(appInForeground = false, viewingTargetSession = false),
)
assertTrue(
"退到后台而组合还挂着(聊天页仍「显示」主会话):用户根本看不见它,所以照弹",
shouldNotifyProactive(appInForeground = false, viewingTargetSession = true),
)
}
}
/**
* 通知正文(纯函数):剥标记、折成一行、过长截断。
*
* 这几条**每一条错了都肉眼可见**:露一个 `<p>` 出来、把两句话粘成一个词、或截断劈开一个 emoji
* (半个代理码元在通知里是一块乱码方块)。
*/
class ProactiveNotificationTextTest {
@Test
fun `the markup she writes is stripped instead of shown`() {
// 服务端原样落库她的标记,直接显示会把标签露给用户(会话列表那一行早就踩过同一件事)。
assertEquals(
"今天下午 悄悄戳了戳你 你有空吗",
proactiveNotificationText("<p>今天下午</p><action>悄悄戳了戳你</action><p>你有空吗</p>"),
)
}
@Test
fun `newlines collapse into one line`() {
assertEquals("第一句 第二句", proactiveNotificationText("第一句\n\n第二句"))
}
@Test
fun `a short line is left untouched`() {
assertEquals("记得喝水哦", proactiveNotificationText("记得喝水哦"))
}
@Test
fun `a long line is truncated with an ellipsis`() {
val text = proactiveNotificationText("她".repeat(200), maxChars = 10)
assertEquals("她".repeat(10) + "…", text)
}
@Test
fun `truncation never splits a surrogate pair`() {
// 😀 在 UTF-16 里是两个码元。maxChars = 2 正好落在它的**高代理项**上:不后退一位就会留下
// 半个字符(通知里显示成乱码方块)。
val text = proactiveNotificationText("a😀b", maxChars = 2)
assertEquals("a…", text)
assertFalse(
"结果里不许有孤立的代理码元",
text!!.any { it.isHighSurrogate() || it.isLowSurrogate() },
)
}
@Test
fun `nothing to say returns null rather than an empty notification`() {
assertNull(proactiveNotificationText(""))
assertNull(proactiveNotificationText(" \n "))
// 只有标记、没有一个字:解析器把它解成一串空白,也不该弹一条空通知。
assertNull(proactiveNotificationText("<p></p>"))
}
}
/**
* 通知 id / 渠道 id 的**对外形状**(与 `KeepAliveNotificationTest` 同一套理由)。
*
* 这两条只能在单元测试里钉住(文案与图标要 Android 运行时),而它们恰恰是最容易出大错的两处:
* 同 id 会**替换**掉保活那条常驻通知;同渠道会让「保活开始响铃」与「她的话不响铃」二选一。
*/
class ProactiveNotificationShapeTest {
@Test
fun `the proactive notification never replaces the keep-alive one`() {
// 同 id 的 notify 是替换:撞上就把「正在后台保持连接」那条常驻通知顶掉。
// 号现在按消息派生(见 `proactiveNotificationId`),所以这里钉的是「派生的号落在保活那条
// 之外」——扫一遍真实形状的键(服务端行 id、IM 消息号、时间字面量、正文兜底、空键)。
val keys = listOf(
"0",
"1",
"2",
"1371",
"im-7",
"4294967295",
"2026-10-06T01:20:05.123+08:00",
"记得喝水哦",
"",
)
keys.forEach { key ->
assertTrue(
"键「$key」派生出的号撞上了保活那条常驻通知",
proactiveNotificationId(key) != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_NOTIFICATION_ID,
)
}
}
@Test
fun `proactive has its own channel`() {
assertTrue(
"两条渠道各自表达一件事,混在一条里就只能二选一",
PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
// 🔴 这里是 `proactive_v2` 而**不是** `proactive`:渠道重要级一旦创建就改不了(只有用户能改),
// 2026-10-07 把它从 DEFAULT 提到 HIGH 对**已建过旧渠道**的设备不生效 ⇒ 只能换一个新 id
// 让系统按新重要级重新建。这条断言就是「以后谁要改重要级,必须同时换 id」的机器可读版本。
assertEquals("proactive_v2", PROACTIVE_CHANNEL_ID)
assertEquals("proactive", LEGACY_PROACTIVE_CHANNEL_ID)
assertTrue(
"新 id 必须不同于旧 id —— 相同就说明有人改了重要级却没换 id(对老设备无效)",
PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
)
}
/**
* 「免打扰」那条渠道:它必须是**另一条**,而且是**安静的那一条**。
*
* 这几件事各有各的坏法,且都很难在真机上发现:
* - 与 `PROACTIVE_CHANNEL_ID` 同 id ⇒ 开关两档发到同一条渠道,「开着免打扰照样弹横幅」;
* - 重要级不低于 HIGH ⇒ 它照样弹横幅,用户按了开关却什么都没变(而**不会有任何报错**);
* - 撞上旧 id ⇒ 建新渠道时会被顺手删掉(那条通知随后就被系统静默丢弃)。
*/
@Test
fun `the do-not-disturb channel is a second, quieter channel`() {
assertEquals("proactive_quiet", QUIET_PROACTIVE_CHANNEL_ID)
assertEquals(
"免打扰那一档只许是 DEFAULT(有提示音、不弹横幅):HIGH 会照样弹横幅",
android.app.NotificationManager.IMPORTANCE_DEFAULT,
QUIET_PROACTIVE_IMPORTANCE,
)
assertTrue(
"两条渠道必须真的不同 —— 同 id 就等于「开关两档没有区别」",
QUIET_PROACTIVE_CHANNEL_ID != PROACTIVE_CHANNEL_ID,
)
assertTrue(
"它也不许撞上旧 id(否则建新渠道那一步会把自己删掉)",
QUIET_PROACTIVE_CHANNEL_ID != LEGACY_PROACTIVE_CHANNEL_ID,
)
assertTrue(
"更不许撞上保活那条(否则用户对保活的静音设置会被她的话一起改掉)",
QUIET_PROACTIVE_CHANNEL_ID != top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_ID,
)
}
/**
* 开关 → 渠道 id 的**纯函数**(两档)。这是「免打扰」唯一的落地形状:横幅由渠道重要级决定,
* 而重要级建完就改不了 ⇒ 只能按开关选一条渠道发。
*
* 同时钉住 `ProactiveChannelSpec` 的**成对性**:id / 渠道名 / 重要级必须一起跟着开关走。四处
* 各写一份 `if (quiet)` 时的坏法是「换了 id 却没换重要级」,症状是「按了开关,什么都没变」。
*/
@Test
fun `the switch picks the channel and the whole spec moves with it`() {
assertEquals(PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = false))
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, proactiveChannelId(quiet = true))
val loud = proactiveChannelSpec(quiet = false)
val quiet = proactiveChannelSpec(quiet = true)
assertEquals(PROACTIVE_CHANNEL_ID, loud.id)
assertEquals(PROACTIVE_CHANNEL_NAME, loud.name)
assertEquals(PROACTIVE_IMPORTANCE, loud.importance)
assertEquals(QUIET_PROACTIVE_CHANNEL_ID, quiet.id)
assertEquals(QUIET_PROACTIVE_CHANNEL_NAME, quiet.name)
assertEquals(QUIET_PROACTIVE_IMPORTANCE, quiet.importance)
// 系统设置的渠道列表里这两行会并排出现,用户就是靠它判断「哪条会弹横幅」——同名等于白分两条。
assertNotEquals(
"两条渠道名必须一眼看得出区别",
PROACTIVE_CHANNEL_NAME,
QUIET_PROACTIVE_CHANNEL_NAME,
)
assertTrue(
"免打扰那条的渠道名要点明它是什么:$QUIET_PROACTIVE_CHANNEL_NAME",
QUIET_PROACTIVE_CHANNEL_NAME.contains("免打扰"),
)
}
@Test
fun `the channel is allowed to alert unlike the keep-alive one`() {
// 这一条**是**要来提醒人的;与保活那条(LOW)刻意相反,所以这里钉的是「它比 LOW 高」。
assertTrue(
"她要提醒人,重要级必须高于保活那条",
PROACTIVE_IMPORTANCE > top.yeij.cyrene.core.keepalive.KEEP_ALIVE_CHANNEL_IMPORTANCE,
)
// `HIGH`(4) 是**屏幕顶部弹横幅**的门槛:DEFAULT 只会响一声躺在通知栏里。
// 主人 2026-10-07 的原话「我在其他 APP 时没看到横幅」就是 DEFAULT 这一档造成的。
assertEquals(android.app.NotificationManager.IMPORTANCE_HIGH, PROACTIVE_IMPORTANCE)
assertTrue(
"免打扰那一档必须比它低 —— 否则「只关横幅」这件事就没有档位可用了",
QUIET_PROACTIVE_IMPORTANCE < PROACTIVE_IMPORTANCE,
)
}
}
/**
* 通知 id 的派生(W54A)——「一事一条」靠的就是它。
*
* 三条各自钉住一件**错了就肉眼可见**的事:
* 1. **同一条消息派生两次必须同号**:重放、或实时帧与落库帧两条路都到,落的是同一条通知;
* 2. **两条不同的消息必须不同号**:同号时 `notify` 是替换,她连说两句就只剩最后一句(改前的症状);
* 3. **任何输入都得落在合法区间**:Android 的通知 id 是 int,而 `hashCode()` 可以为负、甚至正好是
* `Int.MIN_VALUE`——那一个连 `abs` 都兜不住(`abs(Int.MIN_VALUE)` 在 int 域里仍是它自己)。
*/
class ProactiveNotificationIdTest {
@Test
fun `the same message derives the same id every time`() {
// 幂等:进程重启、两条路先后到达都不该改变「这条消息是哪一条通知」。
val key = proactiveNotifyKey("1371", "im-7", null, "记得喝水哦")
assertEquals(proactiveNotificationId(key), proactiveNotificationId(key))
// 键里那三个 id 的**优先级**也是同一件事的一半:同一条消息两条路给的是不同的入参形状,
// 落到同一个键上才算真的稳。
assertEquals(
key,
proactiveNotifyKey("1371", null, null, "记得喝水哦"),
)
}
@Test
fun `two different messages derive two different ids`() {
val first = proactiveNotificationId(proactiveNotifyKey("1371", "im-7", null, "记得喝水哦"))
val second = proactiveNotificationId(proactiveNotifyKey("1372", "im-8", null, "在忙吗"))
assertNotEquals("不同号是「先来的不被后来的顶掉」的全部前提", first, second)
}
@Test
fun `every derived id stays positive even when the hash is Int MIN_VALUE`() {
// 先钉住这条用例的前提:这个字符串的 hashCode **正好是** Int.MIN_VALUE(`abs` 兜不住的那个
// 值)。前提没了就等于这条用例什么都没验。
val extreme = "polygenelubricants"
assertEquals(Int.MIN_VALUE, extreme.hashCode())
val keys = listOf("", "0", "1371", "web", "她主动说的话", extreme)
keys.forEach { key ->
assertTrue("键「$key」派生出了非正号:${proactiveNotificationId(key)}", proactiveNotificationId(key) > 0)
}
}
}
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more