332 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
AskaEth e60ae7aecc docs(05): 锚点补记十——W39 三处措辞不诚实已修(告知不再两句叠加 / 未连接与发失败分开说 / Offline 不再承诺自动发出),并登记两处刻意未做与一条主人定案项 2026-10-05 13:43:01 +08:00
AskaEth b55d220e28 merge: W39 —— 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」 2026-10-05 13:42:15 +08:00
AskaEth a763592ace fix(chat): 分享告知不再两句叠加;未连接与发送失败分开说;Offline 不再承诺「连上后自动发出」
三处「对用户不够诚实」的措辞缺陷,各自独立修复。行为上只多了一处「少说一句」,
其余语义一字未改。

① 分享告知两句叠加(ChatScreen.shareNoticeFor)
实机读到的原文是「有 2 个文件读不到,未发送;分享的内容是空的,没有可发送的内容」,
读起来像两个不同的问题。可这两句说的是不同的免责,而「认下了地址却读不到」本身就蕴含
「没有可发送的内容」——叠上去一个字都没多说。故丢件计数为真时不再追加「内容是空的」那句;
其余组合保持今天的行为(只有丢件计数时只说丢件计数;「读不进来」与丢件计数说的是两件事,
照样并列)。KDoc 里记下了这一档今天的真实出口:worthDelivering 要求 isEmpty 的分享必须
有丢件计数或 contentUnreadable 才值得往下走,而后者已被「读不进来」那一档接走,
所以那句加了前提之后一次都不会再出现。

② deliver 的 WS 分支:连接未就绪不等于发送失败(ChatViewModel)
sendChat 回 false 有三种成因(连接状态不是 Connected / socket 对象已空或已被 cancel /
序列化抛了异常),此前共用一句「发送失败,请重试」,用户无从分辨「这条根本没上路」
与「发出去了但失败了」,而后者在连接未就绪时不是事实。现在按失败那一刻的连接状态分开说:
不是 Connected 时说「还没连上,这条没发出去,请稍后手动重试」,仍是 Connected 时逐字保持
「发送失败,请重试」。回滚照旧(摘掉气泡、内容退回输入栏、记下幂等键),没有自动重发,
普通发送的其它语义没动。判据抽成纯函数 sendFailureMessage(WsState),用例覆盖
WsState.entries 的每一个取值。连接状态取 socket 自己的 StateFlow——那正是 sendChat 判的值,
不用界面镜像过来的 ChatUiState.connection,免得晚一帧读到 Connected 反而说错话。

③ WsState.Offline 不再等一句永不到来的「连上」(ShareLanding / ChatScreen)
Offline 是终态(握手 401 且续不上、或缺少 token / 服务器地址),它不会自动重连。
对它说「连上后自动发出」,用户等的是一个永远不会到来的连接——拿一句不兑现的承诺糊住
一个需要他动手的状况,比不说更糟。新增 ShareLanding.TellOffline:等够久、同一轮只说一次
这些时机与可恢复的那几档完全一致,只有措辞不同:「现在处于离线,需要重新登录或检查
服务器地址;分享的内容一个字节都没发出去,不会丢」。W38 的落地时机一个字没改:
仍然不 take()、不发送,连接恢复后照旧自动补发。哪档配哪句由纯函数 shareWaitingNotice 定,
不再散在 Compose 的 when 里,于是「Offline 换口径」这件事不需要组合界面就能测。

测试:ShareNoticeTest 把「两句都在」的断言改成新事实并穷举同族组合;ShareLandingTest 新增
Offline 的差异、时机与文案用例;新增 SendFailureMessageTest 覆盖每一种 WsState。
assembleDebug 成功;testDebugUnitTest 1101 条,failures 0 / errors 0。
2026-10-05 13:41:52 +08:00
AskaEth 4eaee04255 docs(05): 补记九补齐 W38 的真机回归——断线分享得到柔和告知且内容不丢,恢复连接后自动补发且只发一次 2026-10-05 13:25:08 +08:00
AskaEth f15f0bf9bc docs(05): 锚点补记九——W38 分享「等连接」缺陷已修(四态纯判据 + 只在该发时才取走内容)、W37 设备验证通过、并行误标的设备证据、以及两条测量教训
顺带登记三处未修的小账:告知文案两句叠加、deliver 对未就绪与发失败仍不分、WsState.Offline 缺少专门的措辞。
2026-10-05 13:22:10 +08:00
AskaEth d8228fb315 merge: W38 —— 分享落地必须等到连接就绪:超时不再等于发送失败,内容不再从队列丢失,连接恢复后自动补发 2026-10-05 13:21:26 +08:00
AskaEth aeb37458e8 fix(chat): 分享落地必须等到连接就绪,超时不再等于发送失败
为什么超时不能等于发送失败
WS 还没连上时投递的分享(ACTION_SEND)此前走成了「发送失败,请重试」,内容还从待发队列里
永久消失。来处是「等连接」那句 withTimeoutOrNull 的返回值被丢掉了:超时与成功走同一条下游,
照样 take() 再 sendShared → deliver → ChatSocket.sendChat 回 false ⇒ ChatViewModel 落到
rollback(localId, "发送失败,请重试")。实机取证(2026-10-05 11:56):GET /ws/chat status=200
前后的窗口里投递分享,界面上是失败横幅 + 正文退回输入栏,而后端零请求(POST /api/v1/chat
一条都没有,也没有任何 error 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
ShareIntake.take() 是原子出队且没有归还路径:那一次「其实一次都没发出去」的失败之后,内容
既不在 holder 里、也不在服务端,只剩回滚进输入栏的那一份(多图分享里除首条之外,连输入栏
都回不去)。重试资格因此一起丢了。所以判据只有一条:没连上就一个字节都别动——不 take()、
不发送、不产生任何 error 横幅。

为什么取走时机必须晚于连接就绪
短文本走 WS chat 帧,而分享冷启动必然撞上握手还没完成;帧发不发得出去完全取决于握手结果。
所以等待里不再有「到点就照发」这一档,只有「连上才发」:连接恢复之后由连接状态自己把这条
落地重新触发一遍。

改法
- 判据抽成纯函数 shareLanding(ui/chat/ShareLanding.kt,可单测):没有待发 ⇒ Nothing;已连接
  ⇒ Send(压过后两条,且返回的是单值动作,所以发送与告知不可能同时发生);未就绪且还没等够
  ⇒ KeepWaiting;未就绪、已等够、还没说过 ⇒ TellWaiting;已经说过 ⇒ KeepWaiting(不刷屏)。
- ChatScreen 的那个 effect 把连接状态并进键,连接后来就绪会自动重跑、自动补发;「不会重复
  发送」仍然由 ShareIntake.take() 的原子性兜住——哪怕某一次拿着已经过期的快照进来,take()
  给出的也只是队列里的下一条或 null。
- 等太久只做一件事:经既有的柔和通道 ChatViewModel.showShareNotice 说一句「还没连上,分享的
  内容会留着,连上后自动发出」(不是带 X 的 error 横幅),同一次等待只说一次。
- SHARE_CONNECT_TIMEOUT_MILLIS 改名 SHARE_WAIT_NOTICE_MILLIS:它不再是发送的时限,只是
  「等多久才值得说那一句」;「等够久」的计时只跟「队列里有没有东西」与「连上了没有」走,
  连接在 Connecting / Reconnecting 之间抖动不重置它。
- 普通发送路径(输入框、图片、附件)一行未动。

测试
- ShareLandingTest(新,7 条):未就绪在任何连接状态下都恒不 Send(不取、不发);未就绪且没
  等够恒 KeepWaiting(也不告知);等够了只说一次、说过了就不再刷屏;已连接恒 Send(压过告知);
  无待发恒 Nothing(任何连接状态下都不做事);文案必须说清「还没连上」与「自动发出」,且不含
  「失败」「重试」。

assembleDebug 与 testDebugUnitTest 全绿(1091 tests / 0 failures / 0 errors)。
2026-10-05 13:21:09 +08:00
AskaEth 44b58db58a docs(05): 锚点补记八——分享路径设备验证成绩单(文本热/冷启动、超长走文件三条通过;图片受阻于工具)+ W37 说明 + 三条新环境坑(黑屏/adb root 冲掉 reverse/system_server 崩)+ 并行误标问题的取证结论
把「adb 造不出带授权的 URI intent,别再花时间」与「黑屏会让 input tap 全部落空、先确认屏幕是亮的」写成纪律。
2026-10-05 13:13:05 +08:00
AskaEth a2350a6d5f merge: W37 —— 分享认下字符串形态的 EXTRA_STREAM(String[] / ArrayList<String>),并在一条内容都没读进来时如实告知,不再静默 2026-10-05 13:09:22 +08:00
AskaEth 0764bc269c docs(02): 客户端答复回执十九——给出那次分享失败的精确时间锚点(11:56:08 握手成功后 ~10 秒内)与「无 error 帧、帧根本没发出」的证据链,并采纳「把允许发送绑在握手完成之后」
同时确认 kind/leg 的用法边界(kind 只做展示、归属只看 messageIds),以及语音按 v0.67「契约先行、未落地」处理。
2026-10-05 13:09:18 +08:00
AskaEth 6cdf111419 fix(share): 认下字符串形态的 EXTRA_STREAM,并在一条内容都没读进来时如实告知
为什么字符串形态必须认
真实世界里 EXTRA_STREAM 有三种形态:规范推荐的 Uri(多选时 ArrayList<Uri>)、String[]、
ArrayList<String>。Android 上确实有一部分发送方(历史遗留 / 跨平台壳应用)在用后两种,而本端
此前完全认不出:getParcelableExtra 遇到 String[] 只往 logcat 打一句 ClassCastException 再回
null,getParcelableArrayListExtra 同理。实测 am start ... --esa android.intent.extra.STREAM
uri1,uri2 投两张图进来就是这个样子。

为什么静默是 bug
上面那次分享的结局是:后端零请求、库里零新增、界面连一句软告知都没有。用户点了分享,没有任何
东西告诉他"没读进来"还是"在路上"——这是最糟的那种失败形态,所以本次一并消灭:带着内容迹象却
一条可用内容都没提取出来时,走既有的柔和通道说一句"这次分享的内容没能读进来,没有发送任何内容"。
EXTRA_TEXT 与 EXTRA_STREAM 都为空(用户确实没分享任何东西)时仍然保持静默,那条边界有测试钉住。

做了什么
- ShareIntake 改成读 EXTRA_STREAM 的原始值再按形态分派(Bundle.get 不做类型检查),Uri / Uri
  数组 / String / CharSequence / 数组 / 列表一并摊平;顺带不再往 logcat 丢那两行 ClassCastException。
- 字符串形态只认 content://:那是本端新开的一条读入口,送进来的只是一段文本,没有任何框架级担保。
  file:// 等于让发送方替本端指定一个本地路径、由本端拿自己的权限去读它;http(s):// 是网络地址,
  收下它等于让第三方的一行文本指定一个 URL 由本应用代取。被拒的条目逐条跳过,不让整次分享失败。
- 优先顺序与去重不变:EXTRA_STREAM 的规范形态 -> 字符串兼容形态 -> ClipData(仅前者全为空时兜底),
  trim / 去空 / 去重,先来的先算;重复项既不算截断,也不把真图挤到上限之外。
- 「带着内容迹象」的判据取 EXTRA_STREAM 的原始值(由调用方读了原始值判出来,含形态本端认不出的那种),
  不是「认下来的地址」——否则「有值但一条都认不出」恰恰会被判成「什么都没分享」而继续静默。
- SharedContent 新增 contentUnreadable 与 worthDelivering:后者是唯一判据,ShareIntake 用它决定
  要不要往下接,shareNoticeFor 用它守卫"什么都没有就一个字都不说"那条边界,两处不会走岔。
- 分类(图片 vs 文档)、落 cache、截断与拷贝失败的既有语义一概不动。

测试
- ShareExtractionTest:字符串形态的顺序与逐条跳过、两种形态并存去重、scheme 白名单表、
  读不进来 / 形态认不出 / 静默三条边界。
- ShareStreamExtrasTest(新):按真实对象验形态分派(String[] / ArrayList<String> / String /
  CharSequence / 认不出的形态不猜但照样算带着内容),并接到提取层验一次端到端。
- ShareNoticeTest(新):"没能读进来"成句且不说成发送失败、正常落地一个字不说、空白分享仍然静默。

assembleDebug 与 testDebugUnitTest 全绿(1084 tests / 0 failures / 0 errors)。
2026-10-05 13:05:20 +08:00
AskaEth ef257c3167 docs(05): 锚点补记七——并行两路实机验通(三条判据)+ leg 误报并行已修(W36)+ 假后端横幅两档验过 + 模拟器调参(iowait 196%→14%)+ 分享接入其实早已实现
把「别用 leg 判并行、只看 turn_begin.kind」写成纪律,并记下 ANR 判据的正确做法(拉 trace 看主线程栈,别先怀疑代码)。
2026-10-05 12:01:18 +08:00
AskaEth 405388e325 docs(02): 客户端答复——回执十八那两个问题的答案(都不解析正文标记、单段样式零差异)、把 turn_begin.kind 立为形状依赖(角标已改以 kind 为准)、并行两路实机验通、分享发送失败一次的观察
为什么不提交后端会话的在途追加:他们正往 02 里写回执十八(未提交),按 02 的协作纪律处理——先另存他们的补丁、把文件还原到 HEAD、只提交我方这一节,随后再把他们的内容原样贴回末尾。两边都不丢,也不把他人的在途工作算作我的提交。
2026-10-05 12:01:14 +08:00
AskaEth 39a63f8d30 fix(chat): 并行路角标改以 turn_begin.kind 为准,不再拿 leg 冒充并行
leg 是服务端**会话内的路序号**:beginLegLocked 对每一条新路都自增 legSeq,
普通串行回合照样占号——会话里发第二条普通消息,leg 就是 2。而角标旧判据是
leg >= 2,于是那条串行消息被挂上「并行路 2」,凭空声称存在并不存在的并行。
「是不是并行路」真正的判据是 turn_begin.kind(turn = 普通串行回合,
parallel = 忙时另起的一路);解析层本来就把它读出来了,只是绑定时丢掉了。

- TurnBinding 新增 parallel;withTurnBegin 收下 kind,由 isParallelLegKind
  判定:只有逐字等于 parallel 才算,turn、缺席、空串、大小写不同一律不算。
- 合并规则:新建按 kind 定;同 turnId 重复到达时取或(old.parallel || 本帧
  是并行),一旦为 true 就永远是 true。与 closed 同一条纪律——单调的既成事实
  不许被后到的帧翻回去;也顾及 steer_ack 可能先建一条没有 kind 的占位表、
  随后才由这一路自己的 turn_begin 补上并行的现实顺序。
- 降级:kind 不来的老服务端上 parallel 恒为 false,角标一枚都不挂——宁可少挂
  一枚,也不拿 leg 去猜、去多说一句假话。
- legBadgeOf 改成同时看 parallel 与 leg,三处调用点(提问侧、回复侧、流式气泡)
  都跟着传 parallel;文案仍是「并行路 N」。

测试:ParallelTurnsTest 补 kind=turn / kind=parallel / kind 缺席三档、重复帧的
合并(含「已收口的并行路不被重新打开也不被降级」);TurnStreamsTest 把
「leg>=2 就挂标」的断言改成以 kind 为准,并补上那条真机回归——会话里第二条
普通消息(leg=2、kind=turn)在提问侧与回复侧都不挂角标。
2026-10-05 11:59:21 +08:00
AskaEth 6e00697110 feat(chat): 分享进来的消息加一枚「用户分享」渲染层角标
背景:ACTION_SEND 分享进来的内容在会话里与手打的完全看不出区别,主人要求「可识别」。
与后端定过的红线(devdocs/02 的 F4、ShareRules 的「为什么正文一个字都不加」)要求人类可见
标记只能放在客户端渲染层,所以本次正文、content 与任何上行字段一个字节都没动。

改了什么:
- ChatViewModel 的 emitAndDeliver 多一个 localIdPrefix 参数(默认 local-,手打那条路逐字不变),
  sendShared 的每一条投递都传 share-:多张图会发多条消息,它们都是分享进来的,所以每条都带。
  前缀只加在幂等键前面,后面仍是随机 UUID,唯一性不受影响。
- MessageSource.kt 新增两个常量与两个纯函数:isSharedClientMsgId 只认 share- 且要求后面还有尾巴
  (恰好 share- 一律 false:本端铸键永远是 share-uuid,没有尾巴的键不是本端铸的,不能替一条
  来路不明的消息声明「这是你分享的」);isSharedMessage 在它之上加一道只认 user 侧的闸。
- MessageBubble 的 bubbleFooter 多一个 shared 参数,角标 [用户分享] 紧挨来源角标拼在角标行里,
  沿用这行既有的字级与颜色,不新造样式、不用 Material3,也只对用户侧气泡出现。
- 新增 SharedMessageBadgeTest:判据的六类边界取值与角标行里的落位。

为什么用幂等键而不是正文标记:clientMsgId 由服务端原样回填,是客户端读得回来的既有字段,
「这条怎么进来的」写在它上面不用新增任何协议,也不污染用户正文。
2026-10-05 11:30:56 +08:00
AskaEth 5d4888ab71 docs: 历史回填决策落地——做(带三条约束:先 dry-run / 留回滚点 / 一次性不留常驻逻辑),02 给后端请求、05 记决策 2026-10-05 11:17:15 +08:00
AskaEth 108feefac4 docs(05): 锚点补记六——文档已拆分(含新的文件地图与写回规则)+ <p> 规整上线(5de708a,我方零改动)+ 三件待办 + 一条待主人拍板(历史回填) 2026-10-05 11:15:53 +08:00
AskaEth 681ae96a9c docs(02): 收下后端会话的追加(回执十七·补2:<p> 规整已落地 5de708a,写入侧收口、我方零改动)+ 合并 W34 的拆分 2026-10-05 11:14:22 +08:00
AskaEth 95e657e704 merge: W34 —— 拆分 02/05 两份过长协作文档(历史部分归档,内容零丢失) 2026-10-05 11:14:16 +08:00
AskaEth 98b2274e68 docs(devdocs): 二次瘦身——02 里已确认完结的登记条目整块归档,05 收回两段纪律
02-前后端协同事项.md:860 行 -> 454 行;02-archive-历史回执.md:1274 行 -> 1681 行
- 把已确认完结的登记条目整块搬进归档:A(A1~A5)、B(B1~B6)、C(C1~C3)、D(D1~D5)、E(E1~E6)、F3~F5、G1~G4、H1,共 8 块 30 条,按原顺序插在归档前言之后、回执一之前
- 主文件保留:标题与用途、顶部 P0 置顶块、归档说明、登记规范、仍未闭环的登记(F1/F2/F6 与 G5)、附:状态一览表、最近 5 条往来
- 唯一改动原文处:状态表 C1 行的指针「见本文 §C1」改成「见 02-archive-历史回执.md §C1」
- 归档前言新增一行,说明这批条目的来路,以及 F/G 两节的主标题与未闭环条目仍在主文件

05-开发进度与交接.md:601 行 -> 624 行;05-archive-历史批次.md:437 行 -> 418 行
- 从归档里捞回两段纪律(本轮新增约定/坑、派 agent 进 worktree 的硬规矩),放进「坑与勘误」节末尾,各加一个小标题
- 锚点节里加一行指针:历史批次的细节已移到 05-archive-历史批次.md

零丢失的证据
- 逐行多重集比对(对基线 b0b73d0):02 的非空行 1560 -> 1568,正好 +8,即新增的说明行;C1 那处是同一行整行替换,一删一增净零
- 05 的非空行 846 -> 851,正好 +5,即 2 个小标题 + 1 行锚点指针 + 2 行归档前言
- 除上述新增行与空行计数漂移外,行集合与原文件完全一致,无任何内容行丢失或重复
- 12 个代表字符串的 grep -c 拆分前后相等,含跨主文件与归档的 pinned DESC 排序串、834ef36、title must not be blank 三条
- 搬迁全程用 sed 按行区间原样切割、未改写文字;行尾统一 CRLF

未跑 Gradle(纯文档搬运),未 push。
2026-10-05 11:13:28 +08:00
AskaEth cb5fc0c605 docs(devdocs): 拆分 02/05 两份过长协作文档,历史部分归档,内容零丢失
02-前后端协同事项.md:2126 行 → 860 行
- 历史回执(回执一~十六及同时段已闭环的登记)整体搬到新建 devdocs/02-archive-历史回执.md(1274 行)
- 主文件保留:标题与用途、顶部「待后端处理的 P0」置顶块、登记规范、A~I 全部登记条目、附:状态一览表、仍未闭环的登记(2026-10-04 深夜续那条待主人拍板的口径冲突)、最近 5 条往来
- 置顶块之后新增 5 行归档说明:历史回执去向 / 新回执照旧写在本文末尾 / 已闭环的老登记会定期挪进归档 / 本文旧指针去归档里找对应小节
- 不动置顶块、不动状态一览表、不动未闭环的登记,后端会话仍可照旧在本文末尾追加回执

05-开发进度与交接.md:1035 行 → 601 行
- 历史批次细节与已过时的验收现场记录搬到新建 devdocs/05-archive-历史批次.md(437 行):本轮追加(W4–W7)、W8–W10、W11–W12、W13–W14、W15–W16、装机验收轮、分享到 App 验收轮、W27
- 主文件保留全部「★ 恢复锚点」性质的节(当前状态与待办、锚点补记一~五、纪律补记)与当下要用的节(当前状态、构建、验证方式、凭据处理红线、环境要点、后端、模拟器、易忘的实现约定、管理区约定、C-2 验证结论、坑与勘误、后端协同、实机验收方法、三条待拍板项定案)

零丢失的证据
- 全部搬运用 sed 按行区间原样切割,未改写任何既有文字;新增内容只有 02 主文件那 5 行归档说明与两份归档文件开头的 2 行说明
- 逐行多重集比对(awk 计数 + diff):除上述新增行与个别空行外,原文件与新「主文件+归档」的行集合完全一致,无增无删
- 8 个代表字符串的 grep -c 拆分前后相等:679cee0 3=3、18381f6 1=1、guardrail_input 3=3、internal/config/runtimeconfig/models.go:300-311 1=1、回执十七标题 1=1、devtools/fake-backend/ 4=4、W15-retry-2-EDITED 3=3、ChatImageBudget.SERVER_MAX_CHARS 1=1
- 行尾统一为 CRLF(与 devdocs 下其余文件一致)

未跑 Gradle(本次纯文档搬运),未 push。
2026-10-05 11:10:03 +08:00
AskaEth b0b73d063b docs(05): 补一条硬纪律——合并 worktree 后当场清理(本批 8 个全残留、主人先发现的)+ 假后端进程同样是遗留 2026-10-05 10:44:27 +08:00
AskaEth 54e7b35e60 docs(02): 回执十七答复——p 半包裁定=b 写入侧规整(我方零改动、历史一起变好);思考流等「在哪开」;⏳ 帧已上线 ⇒ 视为阻塞解除,开始真机验证 2026-10-05 10:35:50 +08:00
AskaEth 9e6a6b395c docs(02): 把「标签包裹」从观察升级为明确请求——主人要求问「能不能修」,附三条可选路径与必须的裁定 2026-10-05 10:26:43 +08:00
AskaEth 83ea5ddff7 docs(02): 请后端确认两件事——思考流(reasoning.kind 在 anthropic 组上非法,开关在协议原生 thinking 形状里,谁决定?)+ 一条回复没拆气泡(产出侧 <p> 包裹不完整,非客户端解析问题) 2026-10-05 10:25:28 +08:00
AskaEth e1223fdee5 docs(05): 锚点补记五——忙时并行整批(W28-W33 + FB2)+ 思考流排查结论(reasoning 键在 anthropic 组上非法,开关在协议原生 thinking 形状里,待查)+ 环境与教训 2026-10-05 10:23:54 +08:00
AskaEth c3cfef75bb merge: FB2 —— 假后端加 --hold-replies(收到消息不自动落库),让「忙时被拒」的横幅可稳定复现 2026-10-05 10:23:40 +08:00
AskaEth c73dd532ff feat(devtools): 假后端加上行挂起开关 --hold-replies,让「忙时被拒 / 排队 / 队列满」可稳定复现
为什么
客户端手里还挂着一条没发出去的乐观气泡时,收到「队列满 / 忙时被拒」会怎样,判据是
ChatViewModel.queueFullOutcome(本会话待发 0 条 ⇒ 刻意什么都不说、1 条 ⇒ 回滚 + 横幅、
≥2 条 ⇒ 一个气泡都不动、只提示「可能没发出去」)。但假后端收到上行 chat 或
POST /api/v1/chat 时会立刻把那条用户消息落库并广播 message_persisted,客户端拿
clientMsgId 一比就把乐观气泡收走(consumePendingFor)—— 一个往返之后待发列表就空了,
那三档里只剩「0 条」能凑出来。真后端忙时是先排队或先拒绝、不会立刻落库,所以这是假后端的
建模缺陷,本次修它。

加了什么(只动 devtools/fake-backend/,不碰 app/ devdocs/ 构建文件)
- 启动开关 --hold-replies(默认关):收到上行消息只记录、不落库、不广播 message_persisted。
  上行帧照常进 out/upstream.jsonl、state.wsUpstreamByType 照常计数;回复路径
  (--stream-chunk / --auto-reply / /__control/reply 自己的 persist)一概不受影响 ——
  关的只是「上行那条的落库」这一件事。
- 运行期开关 POST /__control/hold-replies?on=1|0,便于在同一个进程里做「先挂着 → 再落库」两段。
- 显式补落库 POST /__control/persist?session=&id=<holdId|messageId|clientMsgId>&index=N:
  不给选择器就按 FIFO 落最旧那条;落库时才盖 createdAt、才认领幂等键(与「队列满的判定发生在
  认领之前」一致),广播的 message_persisted 与默认路径同形状。挂起列表在 GET /__control/state
  的 heldMessages 里。
- 挂起的消息在挂起时先占号(msg-xxxxxx),所以 REST 回执里的 messageId 与之后真正落库的是同一个。

三处 message_persisted 广播点各自改成了什么
- 原 :657(WS 上行 chat 之后那条)与 原 :1235(REST POST /api/v1/chat 之后那条):受本开关控制,
  挂起时整条跳过(仍记录上行帧)。
- 原 :773(send_reply_round 里助手消息的落库)刻意不动:那是回复路径,本来就有自己的 persist
  开关,本开关不去关它 —— 任务里也明说不要把回复路径一并关掉。已实跑验证:开关打开时
  reply?persist=0 不落库、reply?persist=1 照旧广播 message_persisted。

拒绝场景(任务第 2 项):不需要新造
/__control/pending-full(载荷 {sessionId,mode,max,clientMsgId?,messageId?},与客户端
PendingFullData 逐字对应)、/__control/error?code=chat_busy(带 clientMsgId/messageId)与能发
任意帧的 /__control/frame 早就在了,形状也不缺东西。本次只实跑复核(三帧都在探针那边看到原样帧体、
回执 delivered=1)并写进 README,没有改它们的形状。

默认行为未变
--hold-replies 默认关,state.holdReplies=false、heldMessages=[];关着时三处广播、add_message 的
id 分配(新增的 message_id 参数只在挂起补落库时给值)、REST 回执形状逐字与改动前一致。

验证(本机实跑,端口 18099/18098,绝不用真后端的 10942)
挂起档:ws_probe 发 chat 后 2 秒里零回帧、out/downstream.jsonl 里 message_persisted 计数 0,
而 upstream.jsonl 与 wsUpstreamByType.chat 都记到了;POST /__control/persist?id=local-1 之后才在
活着的连接上收到 message_persisted(delivered=1,id 正是挂起时先占的 msg-000001)。REST 路径同理:
/api/v1/chat 的 202 回执时刻下游流水没有那一帧,2 秒后 persist 才有(时间戳可对)。pending_full /
error{chat_busy} / __control/frame 三帧都投递成功。运行期 on=0 → 立刻落库、on=1 → 挂住,双向有效。
默认档回归:立即落库、灌消息分页、复位都照旧。

README
新增第 6 节「场景四:忙时被拒 —— 让『待发气泡还挂着』可复现(--hold-replies)」:写清乐观气泡被
一个往返收走这个机制、开关怎么用、一条可复制的命令序列(起服务 → 发 chat 挂住 → 注入 pending_full →
期望的横幅文案),以及帧上带不带 clientMsgId/messageId 时客户端四种不同的处置。原第 6-10 节顺延为
7-11 节,并在开关表、控制端点表、记录文件节、已知不确定节各补了对应内容。
2026-10-05 10:23:09 +08:00
AskaEth 1f6c16c590 merge: W33 —— parallel_limit 提示加「本端确在并行档」的闸 + 查证并发额度横幅(结论:代码无缺,是验证条件问题) 2026-10-05 10:13:08 +08:00
AskaEth f2fbdff9a7 W33:并行落回排队的告知加一道本端档位闸 + 忙时被拒那条横幅的只读查证
一、改了什么
加一道闸:pending_ack{reason:"parallel_limit"} 那句降级告知(「她正忙,这条没能另起
一路…」),只在本端当前的「她忙时发消息」档位确实处于「并行」档(InterruptMode.Parallel)
时才给。判据扩了既有纯函数 parallelLimitNoticeFor 的签名(reason + mode 一起判),
本端不在并行档时一律返回 null;调用点在 onEvent 的 PendingAck 分支,档位沿用
currentInterruptMode() 现读(偏好是用户在设置页当场切的东西,缓存住就留一个静默不生效
的窗口),因为 onEvent 不是 suspend 函数,这一句起一个协程去读(与 ensureMessageKnown
同一形态),不起常驻订阅。

二、为什么
pending_ack 是广播帧,每条已认证连接都收得到。极端情况下「别的端在同一会话声明并行
又落回排队、而本端并未选并行」时,本端也会收到这一帧并说那句话 —— 这就是误报。
可那件事与本端无关:用户没选并行时,他这条消息本来就是普通排队、他预期的就是排队
⇒ 落回排队不是意外 ⇒ 不说并不构成静默(与「绝不静默」不冲突)。反过来,在那个时刻
说一句「这条没能另起一路」会让用户以为是自己刚发的那条出了岔子;误报会教用户不信任
这条提示,代价比少说一句大。

三、只读查证(主人点名:忙时被拒那条横幅到底会不会出现)
结论:不缺,pending_full 不带 clientMsgId 时共三档落位,其中两档都写 error,横幅照常
渲染 ——
  · 恰好一条待发气泡 ⇒ 回滚档,error = QUEUE_FULL_MESSAGE(她正忙且队列已满,这条没
    发出去,请稍后手动重试);
  · 两条及以上 ⇒ 说不准档,error = QUEUE_FULL_UNKNOWN_MESSAGE(她忙不过来,这条可能
    没发出去),两个气泡一个都不动;
  · 两档都带 errorIsQueueFullNotice = true(柔和档配色),横幅由 ChatScreen 同一条
    error 通道渲染。
只有「零条待发气泡」那一档是 Ignore,按设计什么都不写:没有气泡会挂住,也就没有要说
的,不能凭空弹一句 —— 理由在 queueFullOutcome 的 KDoc 与 QueueFullOutcomeTest 的两条
测试里(既有,非本次新增)。即「没带 clientMsgId」不等于「说不准档」,本次查证没有
发现需要改代码的地方。

四、测试
新增 3 条(ParallelLimitNoticeTest):追加队列档 + 该 reason ⇒ 不提示;打断档 + 该
reason ⇒ 不提示;再加一条把三档一起摆出来的汇总断言(只有并行档给文案)。「并行档 +
该 reason ⇒ 提示」由既有的那条测试覆盖(现在显式传 InterruptMode.Parallel)。总测试
1030 → 1033,0 失败 0 错误。
2026-10-05 10:12:41 +08:00
AskaEth 79fcd803a0 merge: W32 —— 忙时被拒那条横幅调柔和(只调那一档,真错误仍红)+ 并行额度落回排队时如实说一句 2026-10-05 09:56:22 +08:00
AskaEth e85209eebb fix(chat): 忙时被拒那条横幅调柔和(只它,真错误照旧红)+ 补 pending_ack 的并行降级告知
一、忙时被拒的横幅改用柔和档颜色(它不是错误,不该刺眼)
改了什么:那句告知(她正忙且队列已满,这条没发出去,请稍后手动重试)一直走 state.error 这一条通道,
ChatScreen 里那条 Banner 的调用点对所有 error 一律用 colorScheme.error(正红)。现在给 ChatUiState
加一位 errorIsQueueFullNotice(只决定配色),忙时被拒的两条入口(WS 广播 pending_full 与定向
error{chat_busy} 的队列满处置、REST 409 chat_busy 的回滚)把这一位写成 true,ChatScreen 那一处调用
据此在 onSurfaceVariantSummary(同文件里分享/附件/定位三条告知用的那档柔和色)与 error 之间选色。
为什么:主人裁决形态不变(摘乐观气泡 + 顶部横幅如实告知 + 正文/图片/附件还给输入栏 + 重发复用幂等键),
但红色不要太醒目——忙时被拒不是坏了,是真没排上,红得像故障会吓人。而它与真错误(登录失效、网络失败、
服务端 4xx、图片与附件处理失败……)共用 error 这同一条通道,直接改那个调用点的颜色会把真错误一并调柔和,
所以先分档再选色:写 error 的每一处都显式带着这一位,边界写进了 ChatUiState.errorIsQueueFullNotice
与 Banner 的 KDoc,免得后来人统一改色。

二、消费 pending_ack 的 reason,如实告知「并行没排上、已按普通排队走」
改了什么:reason 字段在解析层早就原样保留(WsEvent.kt 的 PendingAck.reason),但一直没有任何地方消费它
——收到它时界面与普通排队毫无区别。新增纯函数 parallelLimitNoticeFor(reason)(已知值给一句话,未知值 /
缺席 / 空白给 null,不为不认识的 reason 编文案)、常量 PARALLEL_LIMIT_REASON 与 PARALLEL_LIMIT_NOTICE,
以及状态位 ChatUiState.parallelLimitNotice(与 shareNotice / attachmentNotice 同一套机制:8 秒自动收走、
用柔和色、不给关闭按钮),在 WS 的 PendingAck 分支里接上。
为什么:设置页新增并行档之后,开关 ON 不等于一定起第二路——服务端 CHAT_MAX_PARALLEL_TURNS 小于 2 或
额度已满时会让它落回排队,并回 pending_ack{reason:"parallel_limit"}。用户选了并行却看不出区别就是静默,
违反项目铁律「绝不静默」;而这条不是错误、是降级,所以措辞把三件事说清(并行没排上、这条没丢、她做完
手上这句就轮到它),配色与上面那条忙时告知同一档。

测试:新增 ParallelLimitNoticeTest 8 条(reason 的三档穷举 + 横幅温度的边界:忙时被拒为柔和、真错误不
继承那份柔和)。assembleDebug 与 testDebugUnitTest 全绿:1030 个测试,0 失败 0 错误(基线 1022,新增 8)。
2026-10-05 09:55:53 +08:00
AskaEth bb4bb583d5 docs(02): 收下回执十六(形态冲突裁决=调柔和 / turn_id 要加 / 并行开关归前端 / 三处更正经核对均不影响我方 / 文档搬家)+ 我方答复五条 2026-10-05 09:51:01 +08:00
AskaEth 4847d74d87 merge: W31 —— 设置栏「她忙时发消息」追加第三档「并行」(忙时上行 chat.parallel=true,绝不发 interrupt) 2026-10-05 09:38:00 +08:00
AskaEth a776e9f608 feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)
主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。

改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
  补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
  既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
  落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
  单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
  interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
  规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
  件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
  wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
  出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
  翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
  默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
  算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
  两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
  的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
  条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。

为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。

验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
2026-10-05 09:37:26 +08:00
AskaEth 82d3ca3953 docs(02): W30 客户端半边登记(结构性降级保证)+ 一条与规范的形态冲突请后端裁决(被拒消息:红气泡 vs 我方既有的横幅+回滚)+ 三条待确认 2026-10-05 09:12:20 +08:00
AskaEth 005720575a merge: W30 —— 忙时并行的客户端半边(六态 + 灰态 + (sessionId,turnId) 两级分桶),帧侧全部可降级 2026-10-05 09:11:19 +08:00
AskaEth 29299b190e feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)
依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。

一、改了哪些文件

新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
  两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
  六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
  消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
  allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
  StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
  六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
  路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。

修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
  session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
  activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
  新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
  助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
  正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
  WsEventTurnFramesTest、WsOutboundParallelTest。

二、为什么这么做

- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
  turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
  一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
  一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
  (既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
  前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
  (纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
  被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。

三、字段缺失时的降级保证(本次最要紧的一条)

- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
  见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
  那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
  ①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
  pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
  不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
  ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
  TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
  WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
  WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。

四、等 v0.65 的部分

- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
  pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
  按 turnId 单独核算用量(规范未决 14,可选)。

五、规范里与本端既有定案冲突的一处(如实登记)

规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。

验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
2026-10-05 09:10:45 +08:00
AskaEth d0bb43c87c docs(02): 收下后端回执十五(忙时并行规范已定稿 + 主人三条裁决 + 语音切片上线 + Docs 同步中)+ 我方答复:已开工,帧侧全部做成可降级 2026-10-05 08:58:44 +08:00
AskaEth 5e6dc76ae8 docs(02): 转达主人的第三种「忙时发消息」方案(并行两路)+ 流程要求(开工前先定行为与接口规范,客户端在此之前不动代码)+ 三条 API 文档过期的可核查证据 2026-10-05 08:28:58 +08:00
AskaEth 0d50bc82ca docs(02): 收下后端回执十四(IM 文件的 documents 缺口已修 679cee0 / viewer 永久不做 / 语音只差 SILK 样本)+ 我方答复五条 2026-10-05 08:26:10 +08:00
AskaEth ac3f63f8c8 merge: W29 —— stream_chunk 改整段语义 + 空壳消息如实说一句 + 分享超长走文件 + 图片 URL 兜 host 2026-10-05 08:15:27 +08:00
AskaEth be1c372d99 fix(W29): stream_chunk 语义订正、空壳消息如实说明、分享长正文走文件、图片 URL 兜住 host
一、stream_chunk 由「增量」订正为「整段」(后端回执十三 §1)
   后端确认三件事:生产里已无生产者、delta 是整段 res.Text、不会与结构化帧并存。
   W26 那版照 message.delta 把 delta 追加进同一气泡是错的——真到达会把同一段内容追加成重影。
   改法:同一回合内已经收到过结构化帧(message.start / message.delta)就整帧忽略;否则先用
   StreamingContentParser.reset 清缓冲,再把这一整段喂进去,即用它整段替换当前流式气泡的正文。
   兜底分支保留(后端明确不建议删:那是未知帧不丢内容的兼容路径)。顺带把「回退路径没有
   message.start、所以 freshTurnIfFinished 不触发」这条边界在同一个分支里补上(一行级别的收口)。

二、空壳消息不再渲染成空气泡
   主人报的 bug 根因在服务端(微信发来的文件没有挂到消息的 documents 上,content 也是空),
   已登记后端;但客户端拿到的确实是一条什么都没有的消息,此前渲染成空气泡,用户只能猜。
   新增纯函数 isContentlessUserMessage(role 是 user 且 content 空、无 image、无 documents、
   无 thinking 才成立,任何一项有内容都不成立,assistant 一律不成立),成立时在气泡位置改为
   一句如实说明「这条消息的内容没有被传过来」:灰底灰字(沿用文件里既有的灰态小字),措辞只陈述
   本端确定的事实,不猜原因。判据放渲染层(MessageBubble),因为它纯属显示,服务端说什么就存什么,
   不在组装层改 DTO。

三、分享进来的超长正文走文件(主人已拍板)
   分享的文本超过现成的 TEXT_AS_DOCUMENT_RUNE_LIMIT 时,落成一份 .txt:写进分享缓存目录、
   上传 POST /documents、随 chat.documents 发,正文那一栏留空。判据抽成纯函数
   DocumentDelivery.planSharedText(正文是否超阈值 + 附件位是否还有空)。
   附件位被分享进来的文件占满(一个回合 5 份)时不做转换:正文照正文发,并在提示里说清
   「本想作为文件发送,但附件位已占满」——绝不为了塞下 .txt 挤掉用户明确要发的文件。
   落盘失败同样退回内联并说一句。组合与图片的关系:那份 .txt 就落在正文原来所在的那一条上
   (第一条带图,其余每条一张)。
   明确不动的:用户自己在输入框里打的字那条路,长正文照旧按正文发(devdocs/05 的定案第 3 条),
   这两件事不是同一件。

四、历史图片的同源绝对地址按当前 baseUrl 兜住 host(F6①)
   POST /upload 的 url 是按请求时的 r.Host 拼的绝对地址且原样落库,换服务器地址后旧图取不回;
   后端明确认可客户端做「保留 path、替换 host」。新增纯函数 ImageHostRebase.apply:只认
   http(s)://任意host/api/v1/files/一段名字 这一种形态,替换 scheme 与 authority、path 逐字节保留;
   第三方 CDN、data:、content:// 与 file://、相对路径、裸文件名、带查询串或百分号编码、多一段路径的
   一律原样返回。渲染历史图片前在 ChatScreen 传入 AuthManager.currentBaseUrl,全屏查看拿到的也是
   替换后的地址。逆作用写进 KDoc:换了指向不同数据的 host 会 404,那是诚实的失败,比永远取不回好。
   不改落库数据、不改上传、不碰 documents。

测试:新增 ImageHostRebaseTest 与 ContentlessMessageTest,DocumentDeliveryTest 补分享长正文的判据;
stream_chunk 那条分支是 VM 里的状态机分支,没有硬抽成纯函数(不为测试拧弯架构),改动写在 KDoc 里。
2026-10-05 08:14:33 +08:00
AskaEth c0e78371cb docs(05): 锚点补记四——W28 共享 socket(含装机验收证据与两个固有代价)+ 假后端测试台 + 一条硬教训(问一句比猜一天值)+ 登录页 IME 结案 2026-10-05 08:08:19 +08:00
AskaEth 7b97440973 merge: W28 —— 进程内共享一条 ChatSocket(连接数 N+2 → 1),SessionsViewModel 为唯一 owner 2026-10-05 08:03:50 +08:00
AskaEth 276b01113b refactor(ws): 进程内共享一条 ChatSocket,访问 N 个会话不再留 N+2 条连接
改了什么
- AppContainer:删掉「每次调用都新建」的 createChatSocket(),改成进程内唯一的 val chatSocket
  (by lazy)。三个调用方全部改取它,工厂方法一并删掉——不留「还能再造一份」的入口。
- ChatViewModel / SessionsViewModel / AdminViewModel:原本各自建一条连接,现在都改为订阅同一条。
- 连接的开与关收敛成唯一一处主人:SessionsViewModel 的登录态订阅
  (LoggedIn 则 connect()、LoggedOut 则 close())。
  * ChatViewModel 不再 connect()(它随切会话就被销毁,不该掌握连接的生死);
  * AdminViewModel 不再 connect() / close(),包括原来「viewer 时关掉自己那条连接」那条分支——
    这三帧服务端本就 fail-closed,本类另有逐帧角色门,断开连接多挡不掉什么,却会掐掉别的屏幕。
  * AdminViewModel 登出仍然只丢日志基线(行为不变)。
- 三个 VM 的 onCleared 一律不再 close(共享连接不能被任何 VM 关闭,否则切会话 / 关管理页会把
  主会话流式、抽屉未读、管理页三帧一起掐断)。
- KDoc 同步改:AppContainer(为什么可以共享 / 为什么必须唯一 / 开与关的主人是谁)、ChatSocket
  (它不是某个屏幕的私有连接、谁都不许 close;events 的 replay 恒为 0 从「安全」升为「必需」)、
  三个 VM 的类注释与 onCleared。

为什么行为不变
- 服务端广播、不按会话订阅:改前 N 条连接各收全部帧,改后一条连接收全部帧,每个 VM 收到的帧集合
  逐帧相同——这是本次重构安全的唯一依据。isForSession、各事件分支、重连与退避逻辑、下行帧语义、
  UI 一个字都没动。
- baseUrl / token 每次重连现读,connect() 幂等(wantConnection 已置位即返回),所以登出重登、
  换账号都不需要重建实例。
- 连接数量:改前访问 N 个会话 = N+2 条连接、N+2 个 OkHttpClient(每个连接自带一个),改后恒为 1 条。

如实记一处非逐帧等价的细节(已核过,无功能影响):改前每条连接各自握手,服务端每次都会回一帧
history;改后只有首次连接会收到。晚于连接建立才创建的 VM 因此少收一帧 history,而该帧对非主会话
经 historyForSession 过滤后本来就是空(只用来收掉 loading),对主会话与 REST 首屏是同一份内容的
冗余来源,且新 VM 的 pending 为空、reconcilePending 是空操作。

验证:assembleDebug + testDebugUnitTest 全绿(891 个用例,0 失败 0 错误 0 跳过)。
2026-10-05 08:03:09 +08:00
AskaEth 27d5680d21 merge: FB —— 可编程假后端测试台(devtools/fake-backend/,纯 Python 标准库),解锁 after 补洞 / 打断帧 / stream_chunk 三条端到端验证 2026-10-05 08:03:03 +08:00
AskaEth 86a2217858 feat(devtools): 新增可编程假后端(devtools/fake-backend),用来造「特定服务端行为」的端到端场景
为什么要它
客户端有三类行为只在特定服务端行为下才验得到,而真后端不可编排、造场景的代价高到不可做:
1. after 正向增量补洞:要造「WS 握手只回放最近 50 条、库里却有 120 条」这个洞,用真后端得灌 51 条消息、
   每条跑一次模型回合。假后端可以精确、免费、可复现地造出来(--history-replay N)。
2. 打断帧:客户端在「设置=打断 + 会话正忙」时会发 interrupt{scope:"current"}。要验它真的发出去了,
   就得看上行帧——假后端把每条上行帧原样记进 out/upstream.jsonl,这是唯一的第一现场。
3. stream_chunk 回退路径:真后端可能永远不走那条路,假后端可以用 --stream-chunk 指定走。

加了什么
- devtools/fake-backend/fake_backend.py:纯 Python 3 标准库(无第三方依赖)。实现了客户端启动到渲染
  用得到的那一段 REST(login/refresh/logout、sessions 列表与增删改、messages 分页与搜索、read、
  messages 编辑删除、meta/enums、personas、chat)加一个手写 WebSocket(GET /ws/chat?token=…,
  握手后立刻发 history 帧)。鉴权头 X-API-Version / Authorization 一律不校验,但会记进 out/http.jsonl
  (Authorization 与 ?token 只留 sha256 指纹)。
- 控制端点 /__control/**(不碰 /api/v1/**):灌消息(不调模型)、下发 session_busy/idle/interrupted、
  触发一轮回复、pending_full、定向 error、任意帧、踢掉客户端 WS、404 session_not_found、复位、看状态。
  目的是让「造场景」与「看结果」都不需要人手点 UI。
- devtools/fake-backend/ws_probe.py:最小 WebSocket 探针(标准库手写握手 + 帧收发 + ping),
  用来证明握手返回 101 / 收到 history 帧,并往连接上发任意上行帧对账。
- devtools/fake-backend/README.md:起法(Windows + Git Bash)、客户端怎么指过来(adb reverse +
  http://127.0.0.1:18080)、每个场景的可复制命令序列、端点速查、形状出处表、以及「它不做什么」。

边界(README 里也写明了)
不校验鉴权、不落盘成持久数据、不调模型、不是产品的一部分、不实现契约全集(admin 面、附件上传、
observability 推送等一律 404),并且不要拿它当后端行为的事实来源——所有响应形状都是照客户端代码
读出来的(README 第 9 节逐条列出文件:行号),不代表真后端真的这么回。
默认端口 18080,刻意避开真后端的 10942。

形状来源(举几个关键例子):信封 ApiEnvelope.kt:8;鉴权头 ApiClientFactory.kt:115-118;
WS 地址 ChatSocket.kt:287-295;SessionSummaryDto 与排序 SessionDto.kt:16-50 加线上夹具
SessionDtoRealPayloadTest.kt:36-38;after 升序 / before 倒序 / q 与 after 互斥 SessionsApi.kt:71-101;
limit 默认 50 上限 200 MessageRepository.kt:149-150;枚举表逐字取自 MetaDtoRealPayloadTest.kt:24;
404 session_not_found ChatViewModel.kt:1802-1808;stream_chunk 回退 WsEvent.kt:205-228。

验证(本机实跑,输出见提交后的报告)
curl 打通 login/sessions/messages(limit/before/after/q 与三个 400 互斥面)、meta/enums、personas;
ws_probe 握手拿到 101 与匹配的 Sec-WebSocket-Accept,并收到 history 帧;灌 120 条后握手只回放最近 50 条
(msg-000071..msg-000120),用 after 游标正向走两页正好补回 msg-000021..msg-000120、第三页为空;
--stream-chunk 下下发的是 stream_chunk×3 + stream_end(没有 message.* 帧);打断帧原样落进
out/upstream.jsonl;ping/pong 与 HTTP keep-alive 均正常。
2026-10-05 08:01:48 +08:00
AskaEth a0085802dd docs(02): 把「微信文件任何客户端都看不到」提到文档最顶部的 P0 置顶块(主人亲自报的 bug)+ 补一个必须说清的分叉:字节到底落盘了还是只内联进上下文 2026-10-05 07:58:20 +08:00
AskaEth ae49853029 docs(02): 登记新需求——IM 渠道的语音消息要能被读到(前端那半我们做,IM 那半请后端比照 IM-1 做 ASR 入站) 2026-10-05 07:54:55 +08:00
AskaEth 691b073e32 docs(02): 回应后端回执十三(stream_chunk 改整段语义、F6① 客户端兜 host、viewer 摘项)+ 登记一条新 P0:IM 渠道的文件没有挂到消息的 documents 上 2026-10-05 07:54:45 +08:00
AskaEth 6c2cc57cdb docs(02): 状态表维护(C1/C3/D4 早已闭环)+ 新增一张「待后端回应」清单
主人早上问「需要后端做的事都写进文档了吗、本地还有没有独立事件」,我借这次盘点把协作文档收口:

1. **状态表维护**:C1(语音轮次不落库)、C3(voice_output 字段)、D4(无未读状态)三行原写
   「待确认」,其实**早已解决**——C1/C3 由契约 v0.59 §3.15(语音改走与文本同一条编排路径、
   voice_output 补 sessionId/deviceId),D4 由 v0.37(unreadCount + 已读游标,客户端已接入)。
   按正文依据打勾,并写明「客户端至今没有语音 UI,所以 C1/C3 我们从未验证过」这一诚实边界。
   F6 那行从「待确认」改成「问过、后端尚未回应」,让「谁在等谁」一眼可见。

2. **新增「客户端待后端回应的清单」**(文档末尾):只列我们问过、还没拿到明确答复的七条——
   stream_chunk 三点确认 / F6① upload url 绑 host / viewer 凭据路径或确认永远 501 /
   并行 tool_use 配对的 P0(他们说在修)/ llm_usage.session_id 接线(同上)/
   IM 入站文件的真机验证(只能主人做)/ 以及这次状态表维护本身请他们复核。

   每条都写了「要什么」与「我们这边的处置」:例如 F6① 明确**不做绕行**(不猜 base、不重写历史
   URL——那会把「服务端说是什么」变成「客户端以为是什么」);viewer 那条给了二选一
   (给铸造路径,或明确永远 501,那我们就不再把它当验收项挂着)。

本次提交只动文档。
2026-10-05 07:41:27 +08:00
AskaEth 219c50b7c2 docs: 自主推进一批的登记(假开关 / 静默丢正文 / 分享自打断 全修完 + 一条口径冲突要主人拍板)
主人睡前说「按主线继续推,或者先按模块检查」,我把两件都做了:先做了一次只读自审
(假开关 / 死代码 / UI 规范 / 文档漂移,四张带「文件:行号」的清单),然后按清单修了一批。
本批全部是「界面上或文档上声称能做到、代码里其实没接线」这一类问题。

devdocs/05:新增「锚点补记三」,写清——
- 本批 5 条落地(W25 打断模式真接线 / W26 stream_chunk 不再静默丢正文、错误横幅可关闭、
  分享不再自相打断、两处死代码 / W27 删只被测试养的 API、公式去重、03 四处更正 /
  WsEvent KDoc 更正);
- 三个「假开关 / 静默」的真相与各自的修法;
- 两条查完发现不是 bug 的(「抽屉最后消息预览不刷新」与「session_interrupted 丢弃 N 条」),
  附证据,免得下次返工;
- 装机验证:错误行的关闭叉验通(x2/x3 截图),以及一条我刻意没验的(登录页 IME ——
  要清空 App 数据才能回到登录页,万一登不回去今晚所有装机验证能力一起没了,风险不对称);
- 本批没做的:连接所有权(共享 socket)只写设计要点与七条验收清单,不动手;
  DocumentDelivery 那组「正文转文件」API 不删也不接(口径冲突);
- W26 留下的一条边界(回退路径没有 message.start,freshTurnIfFinished 不触发)。

devdocs/02:追加客户端登记三条——
1. 请后端确认 stream_chunk 今天还会不会发、delta 是增量还是整段、会不会与结构化帧并存
   (我们按契约的「非流式回退路径」实现,但那条路可能已经名存实亡);
2. 告知我们开始发 interrupt{scope:"current"} 了(窄触发条件、默认档零变化、分享不打断、
   以及「两条道无强序」这个我们坦承的边界);
3. 登记「超限走文件」的口径冲突(05 的定案说做的是「用户打的字自动变附件」不做,
   主人后来点名要做 —— 两者多半不是一件事),不替主人合并,所以那组 API 原样留着。

本次提交只动文档,无代码改动。
2026-10-04 22:40:43 +08:00
AskaEth 85a922c01b docs(ws): 更正 SessionInterrupted.dropped 的过时 KDoc(本端全部路径下它恒为 0)
原来的注释写「当前任务取消、pending 清空,dropped 为丢弃条数」——那是 `interrupt` 只有一种形状
(等价于今天的 `scope:"all"`)时的口径。契约 v0.56 起 `scope:"current"` 只取消在跑回合、**保留**
待处理队列,该档 `dropped` 恒为 0。

而本端「追加队列」档一个 interrupt 都不发、「打断」档发的是 `current`(见 feats/W25,cfdc094),
所以 `dropped` 在客户端的全部现实路径里恒为 0 —— 「已打断,丢弃 N 条」那句话在本端根本不可达。

注释里写明这一点,免得后来人照着一句不可达的文档去实现一个用不上的计数。

只改注释,无行为变化。
2026-10-04 22:40:07 +08:00
AskaEth b7ac6b8853 merge: W27 —— 删只被测试养的 availablePages + 图片 base64 上限公式去重 + 03 四处文档更正 2026-10-04 22:37:25 +08:00
AskaEth 3c0ed16ee7 merge: W26 —— stream_chunk 不再静默丢正文 + 错误横幅可关闭 + 分享不再自相打断 + 两处死代码 2026-10-04 22:37:24 +08:00
AskaEth eacc65acc9 fix(chat): 四件可见修复 —— 回退正文不再丢 / 错误横幅可关 / 分享不自杀 / 清两处死码
1) stream_chunk 不再被静默丢掉(整段正文丢失风险)
   - ChatViewModel.onEvent 新增 WsEvent.StreamChunk 分支,把 delta 照 MessageDelta 的同一形态喂进
     流式解析器。
   - 依据:后端契约写明「未映射/无法确定的事件仍走 stream_chunk + stream_end(非流式回退路径)」
     (Cyrene_v2/Docs/api-client.md §3.1)。原先它落到 onEvent 末尾的 else -> Unit 被静默吞掉 ——
     服务端一旦走进回退路径,整段回复正文一个字都不显示,而且没有任何信号说明正文丢了。
   - stream_chunk 不带 index(那是 message.start 的字段),所以不读 index;解析器与 MessageDelta
     一样按需创建,按「同一个当前流」处理。
   - 未改 WsEvent 的解析形状,只加消费分支。

2) 错误横幅可以关掉了(两个 dismissError 不再是死方法)
   - ChatViewModel.dismissError / SessionsViewModel.dismissError 原本全仓零调用方,现各接上关闭
     入口,并各补 KDoc 写明边界:清掉的是本端已经显示的那条错误,不代表服务端那边没事;不做自动
     消失。
   - ChatScreen.kt 的 private Banner 增加可选 onDismiss,有它时右侧挂 MiuixIcons.Basic.Close
     (带 contentDescription);只有 state.error 那条传 viewModel::dismissError,分享 / 附件 /
     定位那几条如实告知保持原样(它们自己会收走)。
   - SessionDrawerContent.kt 的错误行改成行内布局 + 右侧关闭按钮,样式随它已有的行(footnote2、
     同一水平内边距、同款 IconButton 灰度);新增参数 onDismissError,由 ChatHomeScreen 接
     sessionsViewModel::dismissError。
   - 错误内容本身、错误出现的时机、是否自动消失,一概未动。

3) 多图分享不再互相打断
   - 根因:W25 把「她正忙时按发送」接到发送路径后,deliver 每次投递前都会发一条 interrupt 帧
     (scope=current);而分享 N 张图 = N 次投递,第 2 条起就把前一条刚起的回合取消掉了,
     最后只有最后一张被回答。
   - 改法:emitAndDeliver 增加 allowInterrupt: Boolean = true(默认值保持现状),deliver 按它决定
     是否调 interruptRunningTurn();sendShared 的两条投递都传 false。
   - 只有一张图也传 false:分享是「一次把东西倒进去」的批量动作,自己打断自己一定是错的;
     「分享从不打断」是一句话,「超过一张才不打断」得先解释那个分界。
   - 单条发送(send)的口径一字未动。

4) 两处死代码
   - 删掉 ChatViewModel.clearDraftDocuments()(复核为全仓零调用方)。没选「在输入栏加清空全部入口」:
     附件芯片本来就每枚带叉、上限只有 5 枚,逐个移除已经覆盖全部场景;输入栏里再加一枚清空按钮是
     噪音,而留着一个没人接的公开方法只会让人误以为它是活的。
   - 删掉 ChatScreen 的 onLogout 参数(函数体内一次都没用到)与 ChatHomeScreen 的对应传参。
     真正的登出路径(CyreneApp -> ChatHomeScreen -> SettingsScreen)原样可用。

验证:assembleDebug 与 testDebugUnitTest 均 BUILD SUCCESSFUL;单测 892 条、0 失败 0 错误、0 跳过
(本次未新增测试:① 的判定是 when 的一个分支、抽不出自然的纯函数,不为测试把架构拧弯)。
2026-10-04 22:36:47 +08:00
AskaEth 805d8b530a refactor+docs(W27): 删掉只被测试养着的 AdminAccess.availablePages,让 ChatImageBudget 的公式只留一处,并就地更正 devdocs/03 四处过时表述
清理(零行为变化)
- 删 domain/admin/AdminAccess.kt 的 availablePages():全仓(含 app/src/test)调用点 0 处 ——
  main 里只有它自己的声明、测试里 2 处(AdminAccessTest 那 1 条用例一并删掉)。它早已被
  ui/admin/AdminHomeScreen.kt 的做法取代:遍历 AdminPage.entries + 每行 canAccess 决定是否禁用。
- ChatImageBudget.SERVER_MAX_CHARS 原来把 base64Chars 的 ((n+2)/3)*4 又内联重写了一遍,
  改为 base64Chars(SERVER_MAX_DECODED_BYTES) + PREFIX_ALLOWANCE_CHARS(一处公式、一处真相)。
  代价:该常量由 const val 变 val(const 不允许调用函数)。取值逐位不变 = 6990572,由
  ChatSendPolicyTest 的独立重算断言与 ChatDtoRealPayloadTest 对服务端真实文案的断言继续钉住。

文档(devdocs/03,一律删除线保留原文 + 更正括注,不动别人的回执段落)
- §4 重连补全:02/A5 的 after 正向增量已接入(loadAfter + ChatViewModel.catchUp,28c3142)。
- §4 忙碌/队列 UI 映射:客户端只发 scope:"current",该档 dropped 恒为 0、all 从不发,
  所以「已打断,丢弃 N 条」在今天的客户端里不可达。
- §5 mode 回落:mode 已落库(后端 b686d5f),MessageBubble 按 mode 分支。
- §4 打断模式:补上 W25 接线的依据 cfdc094 / feat/W25-interrupt-scope。

devdocs/05 追加 W27 小节;另记下 data/ws/WsEvent.kt:272 的同类过时 KDoc(不在本轮允许改动的
文件清单里,未动)。

验证:assembleDebug 通过;testDebugUnitTest 891 条 / 0 失败 / 0 错误(改动前 892,删掉 1 条)。
2026-10-04 22:35:20 +08:00
AskaEth e4c6c7c72a merge: W25 —— 把设置页的「打断模式」真正接到发送路径(默认档零行为变化) 2026-10-04 22:31:52 +08:00
AskaEth cfdc094c30 fix(chat): 设置项「她正忙时按发送」不再空转 —— 发送前按偏好决定是否先打断
设置页那个开关此前没有任何发送路径读它:interruptMode 全仓只出现在 AppSettings
(定义 / 读写)与 SettingsScreen(渲染文案),于是把它切成「打断」后行为与「追加队列」
一模一样,ChatSocket.sendInterrupt 更是零调用方(死 API)。这正是一直挂在文档
devdocs/02 协同项 A2 上的欠账(后端 v0.56 已支持 scope,客户端没接)。

现在按契约 v0.56 §3.1 接上,判据抽成与发送路由同类位置的纯函数
interruptScopeForSend(mode, busy: Boolean):String?

1. 追加队列(默认档)⇒ 恒定返回 null,一个 interrupt 帧都不发,线上请求与今天逐字节相同;
2. 打断(wireScope = "current")且会话正忙 ⇒ 这条消息之前先发 interrupt{scope:"current"}
   (只取消在跑回合、保留待处理队列,队列里的消息按编排器续跑,一条都不丢);
3. 打断但会话空闲 ⇒ 不发(命中空闲会话是 no-op,白发一条帧只多一次往返,还会在 logcat
   里制造「这里打断过」的假象)。

「正忙」只认服务端 session_busy 帧给的 busyMode != null,不用 queueDepth——队列深度
非零不等于有回合在跑(pending_ack / pending_drain 也会动它)。

接线点在 ChatViewModel.deliver() 的通道分支之前(app/src/main/kotlin/top/yeij/cyrene/
ui/chat/ChatViewModel.kt)。放在那里而不是 emitAndDeliver 开头是刻意的:上面每一段本端
拒绝(附件超限 / 图片处理失败 / 附件上传失败 / 引用不合法)都会「回滚、什么都不发」,
先打断她的回合再什么都不发是凭空的破坏。每次投递只经过那里一次,所以一次投递最多一条
打断帧,任何后续分支与重试都不会重复发。

偏好每次发送现读(不缓存、不订阅 Flow):「在设置页切成打断、然后立刻发的那一条」正是
本次要修的场景,缓存值要等 Flow 推到才更新,冷启动那一下必然没到——那就等于留了一个
静默不生效的窗口。读失败(IO / 损坏)回落默认档,也就是最少动作的那一档,与 AppSettings
把未知取值解成默认档是同一条纪律。

打断帧发不出去(只有「WS 未连接」一种原因)静默、不阻断本次发送:本次消息的成败与它
无关(REST 通道不看 WS 连接),且一直有既有的回执 / 回滚路径如实上报;反过来「打断失败」
没有对应得上的动作(重试等于把消息再发一遍,正是幂等键在防的事),喊一声只会误导。
于是打断没生效 = 行为退回默认档,是用户看得懂的状态,不是被藏起来的事。

测试:新增 InterruptScopeTest 8 条(两档 × 忙闲四格穷举 + 默认档零行为变化 + 偏好回落
方向),全仓 884 → 892。assembleDebug 与 testDebugUnitTest 全绿(0 失败 0 错误)。
设置页那两条文案原本就与 scope:current 的真实语义相符(「已排队的消息保留,继续依次
回答」),未改一个字。

文档:02 的 A2 状态表、05 的 A2 条目都写着「客户端尚未接入该字段」;03 里「打断模式 →
UI 限制一次只发一条」的前提(interrupt 会清空 pending)也随之失效,三处一并补记更正。
2026-10-04 22:29:57 +08:00
AskaEth b8f0833137 docs: 收下后端回执十二 + 登记本轮三条接入结论 + 05 锚点补记二(含两条新教训)
devdocs/02(与后端会话共用的协作文档):
- 收下他们的**回执十二**:knowledge 的根因(参数角色缺席 + 收尾静默分支)与修法、并行
  tool_use/tool_result 配对的新 P0(客户端不需要适配)、契约 v0.64 的只读
  GET /admin/llm/usage、IM 入站文件「能拿到字节」(真机验证只能由主人做)、
  llm_usage.session_id 恒为 NULL。
- 追加**我方登记**:v0.63 那两条(reason / 404)都已接入并推送;after 正向增量的两条口径;
  及一条要他们确认的**我方主动口径** —— unreadable 且 mime 是 text/ 时让 reason 压过正文渲染。
- 他们点出的三处旧口径(1049/1110/1309 行)我核过,**都在他们自己的回执段落里**,按同一条规矩
  请他们自己更正,我们没有动手。

devdocs/05(我方进度与交接):
- 锚点更新:HEAD、884 测试、本轮 5 条落地、待办重排、家务办完。
- 新增「本轮未验证的」一节:把**验过的**与**没验过的**分开写(404 兜底验通;after 只验到「真请求
  打出去了」、补洞端到端仍未验;W24/W23 的设备侧只验到能开的程度)。
- 两条新教训:① 那个「未响应弹窗」是模拟器/宿主机被拖垮(日志里系统自己的
  PointerEventDispatcher 卡了 37 秒),uiautomator dump 要省着用;② **一次性告知不能挂在会被销毁的
  状态上** —— 404 兜底第一版就是这么悄悄失效的,只有真机看一眼才能发现。

本次提交只动文档,无代码改动。代码侧本轮四个提交(28c3142 / 1794e53 / b5c0b13 / 2d382b1)都已推到
origin/android。
2026-10-04 22:22:51 +08:00
AskaEth b5c0b13210 fix(chat): 「会话已删」那句解释必须由不会被销毁的那一层持有
真机验收 404 兜底时发现的真问题:**切换本身是对的,但用户看不到任何解释**。

- logcat 链路完整:`GET /sessions/web:{已删}/messages?limit=50` → `404` →(约 0.9 秒后)
  `GET /sessions` → 界面切回主会话。也就是说「退回主会话 + 如实说一句」里的**前半句成了**。
- 但**后半句一个字都没显示**:那句横幅原来挂在 `ChatUiState.sessionGone` 上,而
  `onSessionGone` 一被调用,`activeSessionId` 就被收敛回主会话、`ChatScreen` 的 `key` 随之变化,
  承载它的那个 `ChatViewModel` 连同状态**立刻被销毁** —— 横幅来不及组合出来就没了。
  (这正是「把一次性告知放在会被销毁的状态里」的经典坑,值得单记一条。)

改法:
- `ChatUiState.sessionGone` 保留,但只当**触发信号**(KDoc 已改写说明这一点);
- 文案由 `ChatHomeScreen` 的 `remember` 持有(它活得比会话 VM 久),通过新的
  `sessionNotice: String?` 参数注入 `ChatScreen`,仍然走页面上那同一排 `Banner`,
  8 秒后自己消失(它描述一次刚发生的动作,不是持续状态);
- `onSessionGone` 里**先**立起告知、**再**刷新列表——顺序反了同样什么都看不到。

验证:装机复测通过,红字横幅如期出现在主会话页顶部
(证据 `app/build/e2e-shots/w7-gone-2.png`),logcat 链路 404 → `GET /sessions` 完整。
assembleDebug 与 testDebugUnitTest 全绿(884 测试 / 0 失败,与改动前同数:本次只动了接线与文案,
没有可新增的纯函数)。
2026-10-04 22:20:59 +08:00
AskaEth 1794e53d1c merge: W24 —— 编辑弹层只显示纯文本(原文含标记时如实告知会被替换,绝不静默丢旁白) 2026-10-04 22:08:08 +08:00
AskaEth 2d382b11cd merge: W23 —— 接入契约 v0.63 的 documents[].reason(只在下发时原样显示,不做整句判断) 2026-10-04 22:08:06 +08:00
AskaEth 66c2291312 feat(chat): 编辑框只显示纯文本 —— 真有标记时才剥,且弹层明说会替换掉什么
主人拍板:编辑单条消息时输入框里只放纯文本,不再让用户对着 <p> / <action> / <narration> 改字。
红线是「不许静默丢东西」,所以这次不是「把标记一剥了事」:

- 新增纯 Kotlin 模块 domain/chat/MessageEditText.kt(无 Android / Compose 依赖)。
  原文不含已知标签时逐字节恒等:不 trim、不折叠连续空行、不换换行符;含标签时用
  domain/parse(项目里唯一的解析器,不另写正则,免得两套口径分叉)按原始片段顺序拼出可见纯文本,
  正文与旁白/动作一并保留,气泡之间空一行;一个气泡都解析不出就回落成原文(宁可不剥)。
  hasMarkup 只在「保存真的会替换掉什么」时为 true。
- MessageActionState.Edit 新增 markupReplaced;startEdit 只算一次 editableMessageOf
  (不放进 state.update 的 lambda 里),draft 拿纯文本、markupReplaced 拿原文带不带标记。
- MessageEditDialog:markupReplaced 为 true 时在计数行下方多一行小字,写明「保存后这条消息只剩
  你写的这段文字,原来的旁白与动作会被替换掉」;为 false 时一行都不多显示。计数与保存禁用逻辑不变。
- 保存提交的就是 draft 本身,不把标记猜着装回去:任何回填都要先猜「旁白该插回哪一句之间」,
  猜错就是静默移动她说过的话,用户还看不出发生了什么(理由写在 submitEdit 与弹层的 KDoc 里)。
- 新增 MessageEditTextTest(21 例):纯文本恒等(空串、首尾空白、连续三个换行、CRLF、
  以及 1 < 2 / <div> / <P> / <p2> 这类「有尖括号但不是标记」的普通文本)、线上真实标记串、
  纯旁白、带属性的 p 标签、head/tail 旁白的先后顺序、气泡间只空一行、畸形未闭合不丢字。

为什么这仍然值得做:主会话 64 条 user 消息全是纯文本,所以对今天的真实数据这是一条恒等变换——
它是防御性口径,不是修一个正在发生的 bug;也正因如此,纯文本路径必须逐字节不变。
2026-10-04 22:07:40 +08:00
AskaEth e4915a6904 docs(02): 收下后端回执十一(四点确认 + knowledge 缺陷已立案 + IM-1/U1 在实施)
后端会话直接写进了本机 devdocs/02(与主树共用同一份文件),这里把它落成一次提交,
免得长期挂在工作区、也便于将来对时间线。

回执十一的四点:sess-* 不加 404 例外(与我们的答复一致);documents[].reason 他们不会擅自改文案
(要改会同时升契约并回执);404 session_not_found 的用法确认;日期更正已互相认账。

另两条与我们相关的:
- 我们登记的 knowledge 工具失败(E2E-DOC 那条,纯文本也复现)他们已按更正后的口径立案排查,
  修复推库后会补一条回执(含根因文件:行)。他们同时要求「无论回合因何结束都落一条可见的
  assistant 消息」,边界保留:被 interrupt 取消 / 被 stale gen 取代 / 静默内部流程仍不落库。
- IM-1(各适配器入站内联受支持格式的文件字节)与 U1(llm_usage 落库 + 只读汇总端点
  GET /api/v1/admin/llm/usage,后续契约 v0.64)在实施中 —— 后者对我们只有将来做管理页时才算增量。

本次提交只增文档,无代码改动。
2026-10-04 22:01:01 +08:00
AskaEth 28c314262e feat(chat): 接上 A5 正向增量 after,并用 404 session_not_found 把「会话已删」如实说出来
改了什么:
- SessionsApi.listMessages 加可选 after(契约 §1 行 6)。before / q / after 三者互斥是服务端的
  硬约束,本端三个调用点各只给一个;after 为 null 时 Retrofit 整个省掉这个参数 ⇒ 既有三条路径
  的线上请求逐字节不变。
- MessageRepository 加 loadAfter:同一个端点,服务端按【升序】返回(与 before 的倒序相反),
  仍统一转成升序的 PagedMessages。顺手去掉 toPagedMessages 参数名里那个已经不对的 newestFirst。
- ChatViewModel:
  · 新增 newestKnownAt(从状态现算的 after 游标)与 absorb(只并消息与乐观气泡,不碰
    loading / loadingOlder / hasMore / error —— 一次静默补拉不该把上拉位置和错误横幅掀掉)。
  · 掉线那一刻记下游标、重新连上时用它做一次正向增量。记录时机必须在「掉线」而不是「重连后」:
    重连的 history 帧会把最近 50 条灌进来、游标随即被推到「现在」,那时再取就取不到那个洞了。
    「再拉一页最新」永远补不回中间断掉的那一段,这正是 after 存在的理由(02/A5 的欠账)。
  · ensureMessageKnown 先用增量(只要新来的那几条),还认不出来才退回整页重拉。
  · 循环推进抽成纯函数 loadForwardPages:游标必须严格前进(同毫秒并列且超过一页时推不动,
    契约 §A5 明写的已知边界)+ 轮数上限 20,两道防死循环的闸都由单测钉住。
- 404 session_not_found(契约 v0.63):首屏 / 上拉 / 增量三条读路径都判,非主会话就置
  ChatUiState.sessionGone;ChatScreen 据此显示一句实话并回调 ChatHomeScreen 重拉会话列表
  (refresh 内部会把「指向已删会话」的 activeSessionId 收敛回主会话)。
  主会话不置位:那说明部署本身出了问题,该显示的是普通错误横幅,而不是一句假的「已切回主会话」。

为什么:
主人那次「图片凭空消失、别的端也看不到」的真相,就是会话在别处被删掉之后,客户端把它渲染成了
一条空会话 —— 让用户对着空白猜。现在有了机器可读的 404,就能如实说一句并把人带回主会话。
另一半是重连补洞:一直是「再拉一页最新」在顶着,断线期间超过 50 条必丢中间那一段。

验证:assembleDebug + testDebugUnitTest 全绿,846 测试 / 0 失败 / 0 错误。
较基线 831 新增 15 条(MessageAfterRepositoryTest 5 条走真 OkHttp 断言线上 URI;
ChatRecoveryTest 10 条钉住 404 判据与增量循环的两道闸)。
设备侧验证(点开看到那句实话、断线补洞)另行做,不在本次提交的断言范围内。
2026-10-04 22:00:55 +08:00
AskaEth e5fa1b871f 接入契约 v0.63 的 documents[].reason:把「读不到的原因」原样送到附件预览弹层
后端 902b6ee / 契约 v0.63 给 historyMessage.documents[] 的元素加了可选字段 reason:
只在 status == "unreadable" 时下发(Go omitempty,extracted 时整个键缺席),内容与后端
注入模型清单里的那 10 条是同一份字符串,最长的一条 40+ 字。

改了什么
- DocumentRefDto 加 reason: String? = null。documents[] 在 WS 握手 history、
  message_persisted、GET /messages 三处共用这一个 DTO,所以一处解析三条路全覆盖。
  KDoc 写明三条口径:可选、只在 unreadable 时下发、是后端原话所以客户端原样显示,
  且绝不做整句相等判断(需要分支只允许用稳定子串)。
- DocumentChipModel 加 reason,由 DocumentChips.of(ref) 取 ref.reason 去掉首尾空白后
  非空才留,否则归一成 null——一句空话会顶掉弹层里的通用说明,比没有更坏。
  芯片外观一个字没改:detail / statusLabel 保持现状(unreadable 仍显示「读不到」)。
  理由:那句话太长,塞进一行芯片只会被 Ellipsis 切掉半句,比不显示更糟。
- DocumentPreviewGate.plan 增加 reason 参数:非空白时直接作为正文区那句说明原样返回
  (NotText(reason)),压过本端按 mime / 扩展名的裁决;DocumentTarget.previewPlan 顺着
  chips.reason 把它带进裁决。预览弹层本身一行代码都没改——它渲染的就是 plan.message
  那句话,所以 reason 从 DTO 到用户看见的那句话整条链路上没有任何改写或截断。
- 配套单测 17 条。

为什么这么改
- 服务端那句原因已经断言「无法抽取正文」,它比本端按类型编的通用说明(只知道类型、
  不知道到底为什么)准确;替它改写、归纳或截断就是丢掉服务端唯一一次说清原因的机会。
- 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是
  正文闸要消灭的「看着像文件坏了」。
- reason 缺席时(契约 v0.63 之前的历史消息)产出与改之前逐字相同:兜底那句话、判据、
  文案全都不动。

测试(新增 17 条)
- DocumentDtoTest +5:unreadable 带 reason 逐字解出;extracted 元素没有该键 -> null;
  v0.62 风格的缺键载荷 -> null(不解码失败、不替服务端编一句话);清单外的未知分类原样
  保留;显式 null -> null。
- DocumentChipTest +5:reason 带进模型;句中标点与换行原样、只去首尾空白;空白串 ->
  null;绝不进芯片副行(detail 逐字不变);待发附件恒为 null。
- DocumentPreviewTest +7:reason 逐字成为正文区那句话(含未知分类,不丢不改不换兜底);
  只 trim 一次再原样显示;缺席时与改前逐字相同(拿 refusalMessage 对比);
  空白 reason 不改任何行为;text mime + reason 不再假装有正文。
- assembleDebug 通过;testDebugUnitTest 848 个测试 0 失败 0 错误 0 跳过。

已知代价(已写进 KDoc,供人拍板)
服务端判为读不到、但字节其实下得动的附件(例如超限的纯文本)会失去 App 内预览,
「用其它应用打开」与「保存到下载目录」两条出口照旧完整可用——与「未知 mime 判为非文本」
是同一笔不对称账。
2026-10-04 21:57:14 +08:00
AskaEth a84d473e2f docs: 收下后端回执十(契约 v0.63:documents[].reason + messages 对已删会话回 404)+ 答复 sess-* 提问 + 锚点补记两条新待办 2026-10-04 21:51:08 +08:00
AskaEth 9f92a8ade5 docs(05): 恢复锚点更新 —— 记上凭据落地位置(dev-secrets.local.md,gitignore)、新 HEAD、模拟器启动参数 2026-10-04 21:50:31 +08:00
AskaEth d2169c6174 chore(gitignore): 忽略 dev-secrets.local.md(本机开发凭据,主人授权落地但不进仓库) 2026-10-04 21:48:37 +08:00
AskaEth 92ee7fedbd docs(05): 落「恢复锚点」(当前状态 / 已完成模块清单 / 待办 / 家务)—— 便于上下文压缩后接续 2026-10-04 21:46:56 +08:00
AskaEth 647cf47cf2 docs(05): 更正 ① W16 搜索的结论 —— 本来就是好的,先前「设备上不可用」是我的测量假象(含教训:动代码前先用像素确认症状) 2026-10-04 21:46:26 +08:00
AskaEth 3ff4f45166 docs: 更正文档里的日期标注 —— 本轮都发生在 2026-10-04 深夜,之前误写成 10-05(含后端回执九那一行) 2026-10-04 21:42:37 +08:00
AskaEth c67aa2da48 docs(05): 存档本轮装机验收的设备证据(W18/W15/W19 通过、W16 确认不可用、四条新坑) 2026-10-04 21:41:45 +08:00
AskaEth e8c6389fde docs(02): 收下后端 GC 策略与回复 + 更正 knowledge 工具失败的范围(纯文本也复现)+ 一条 adb reverse 方法论 2026-10-04 21:41:20 +08:00
AskaEth df6378395e merge: W21 — 重试记忆加 10 分钟时间上界(多槽不会跨小时误撞旧键) 2026-10-04 21:40:23 +08:00
AskaEth 8938fffd08 feat(chat): 重试记忆的槽加 10 分钟时间上界 —— 别把用户新打的同内容当成重试
多槽让失败记录跨很多次发送存活,于是开了个单槽时代没有的窗口:用户几小时前失败过一句
「你好」,之后新打一遍「你好」(心里当的是一条新消息),指纹却与那条旧记录一字不差,
keyFor 命中旧键 ⇒ 服务端回 duplicate ⇒ 这条新消息根本不发出去,屏幕上看着像「按了没反应」。
给每个槽加一道时间上界,窗口就被限死在 10 分钟内;而「刚失败完,用户马上再按一次发送」
这个真正需要幂等的场景一秒都没被削掉。

- RetryKeyMemory: 新增公开常量 SLOT_TTL_MILLIS = 10 分钟(测试拿它做断言,不硬编码);
  RetryKeySlot 增 atMillis;remember(fingerprint, localId, atMillis) 与
  keyFor(fingerprint, nowMillis, mint) 都在入口先按时间淘汰过期槽、再做自己那件事,
  写入时最后才看条数上限(过期槽不占 8 条的额度);同指纹重复失败更新键与时刻而不是留两条;
  forget(localId) 与时间无关,签名与行为不变;FIFO 上限 8 与 TTL 两条规则同时生效。
- 边界口径:正好等于 TTL 不算过期(判据是 now - atMillis > TTL,严格大于),理由写在类注释里,
  测试把它钉住。
- ChatViewModel: 只改两个调用点,喂 System.currentTimeMillis()(emitAndDeliver 的 keyFor、
  rememberRetryKey 的 remember;forget 的三处不动),并按新语义更新相关 KDoc。
- RetryKeyMemoryTest: 既有用例补上时刻;新增 7 条覆盖「过期槽不复用且被淘汰」「TTL 边界」
  「remember 刷新时间戳」「过期的 A 不影响新鲜的 B」「TTL 先于 FIFO 上限生效」。
2026-10-04 21:37:45 +08:00
AskaEth 7a6f6b0863 chore: .gitignore 补 .kotlin/(Kotlin 构建产物,之前没忽略,git add -A 会误扫) 2026-10-04 21:29:14 +08:00
AskaEth ea16f48be2 merge: W19 — 单条消息的编辑与删除(D3) 2026-10-04 21:28:22 +08:00
AskaEth 351891714a feat(chat): 单条消息的编辑与删除(D3)
契约 §1 行 6a/6b 的两个端点与 §3.8 的两帧,整条链接完(不含「重发」——契约
明确没有重发端点,客户端用编辑后的内容再 POST /chat 即可,本模块不做)。

数据层
- SessionsApi 增 PATCH / DELETE api/v1/messages/{id}(与既有消息端点同源,因此
  MessageRepository 能直接复用、不必动 AppContainer),MessageRepository 增
  editMessage / deleteMessage,走既有 apiCall / Result 风格。
- 响应形状照契约不照旧文:PATCH 的 data 直接就是消息 DTO(没有 message 包一层),
  DELETE 回 {deleted: id};编辑内容按「非空、≤4000 rune(码点,不是 length)」
  预校验,不合格一个请求都不发。

帧解析
- WsEvent 增 message_edited / message_deleted,形态照 message_persisted 那一套:
  会话归属先取帧壳、其次取消息自身的 sessionId,未知键安全、未知帧与坏帧安静忽略、
  永不抛。
- message_edited 的 data 就是消息本身(与 message_persisted 的 data.message 不同形),
  照抄那一套会解出 null——这一点由单测钉住。

VM
- 按 id 原位改 content / 原位移除。两条路各自只有一个落地出口:本端 PATCH/DELETE 的
  成功回执与随后到达的广播都调同一对纯函数,而它们幂等(同一内容做两次返回同一实例)
  ⇒ 「响应 + 广播」双到达收敛成一次,不需要去重表、也不会出现两套状态源。
- 编辑只对 role == user 开放、删除对 user 与 assistant 都开放,判据是同一份纯函数,
  气泡(隐藏入口)与 VM(再挡一次)共用。
- 失败一律如实:弹层还开着就留在弹层里(输入不丢、可原地重试),弹层已被关掉就挂到
  页面顶部那行横幅,绝不静默。

UI
- 长按气泡打开动作菜单;由于正文包在 SelectionContainer 里、长按正文同时也是选字手势
  (两者抢同一个长按,谁先消费由运行时决定),另给一枚看得见的「⋯」
  (content-desc 消息操作)作为同一条入口,避免只有知道的人才会用。
- 编辑弹层:原始 content 作初值 + 码点计数 + 取消/保存;删除必须二次确认。
- ChatScreen 只加 24 行接线(两个回调 + 两个弹层的渲染)。

测试 +49(766 → 815,全绿):帧解析 14、纯逻辑 15、Retrofit 层 7、失败文案与落点 13。
2026-10-04 21:26:52 +08:00
AskaEth 5d8ab15c0e docs(02): 登记 unreadable 缺原因字段 + 文本层 PDF 的措辞别扭(均已如实处理,未替服务端编话) 2026-10-04 21:18:05 +08:00
AskaEth 1f9d1dde01 merge: W18 — App 内预览对非文本拒收(跟上 v0.61 的 .pdf) 2026-10-04 21:17:21 +08:00
AskaEth 48e41886ba feat(documents): App 内预览对非文本拒收 —— 跟上 v0.61 的 .pdf
后端 v0.61 把文档白名单 22 → 23 项(新增 .pdf)并改成(扩展名, 字节嗅探)成对裁决,
读侧 MIME 如实下发 application/pdf。而 W11 的附件预览是把字节当 UTF-8 文本渲染的
(那时白名单里除 .pdf 外全是文本类)⇒ 一旦点开别的端发来的 PDF 附件,预览就是一整屏
二进制乱码,像文件坏了。这次堵的是这个兼容洞,不是新功能。

- 选文件器加 application/pdf:原先刻意不列(那时必被后端 415 拒),现在 PDF 在选择器里
  不该是灰的,服务端收得下。
- 新增 DocumentPreviewGate(纯函数):mime 非空时只看服务端那次字节嗅探(以 text/ 开头
  判为文本,其余一律非文本,未知 mime 也判非文本——两侧代价不对称,误判成文本正是本闸
  要消灭的乱码);mime 缺失/空白时按扩展名兜底(白名单里只有 .pdf 是非文本),这条兜底
  服务的是待发附件(它只有本地 URI 与显示名)。大小写不敏感并剥掉 ; charset 参数。
- 弹层新增 plan 参数:判为非文本时正文区留空、只留一句人话并指路两条出口——弹层照常打开,
  文件名/体积/字数/状态与两条出口一个不少,也绝不假装加载失败(字节其实是取回来的,
  只是本端不渲染它)。
- 两条出口刻意未动:打开用 DTO 的 mime(PDF 就该交给 PDF 阅读器,不用通配符),保存继续
  传 */*(W11 实测结论:SAF 会按 mime 给文件名补扩展名)。
- 测试 766 → 775(DocumentPreviewTest 新增 9 条:闸的判定矩阵、扩展名兜底与大小写、
  未知 mime 拒收、待发附件接线、以及 PDF 的打开类型不是通配符)。

未做(缺口留给主代理登记 02):DTO 只有 status=unreadable,没有服务端那句原因文案
(含「图片型/无文本层 PDF,需要 OCR」)⇒ 客户端沿用既有通用文案「读不到」,
不自己拼一句假装是服务端说的。
2026-10-04 21:16:29 +08:00
AskaEth 020687a82b docs(02): 收下后端回执八(v0.60 存储端点 / v0.61 PDF 文本层 / K1 API Key 落盘 / 两条行为变化) 2026-10-04 21:13:14 +08:00
AskaEth 440e441b31 merge: W17 — 重试记忆改多槽(每条失败各留自己的幂等键) 2026-10-04 21:12:19 +08:00
AskaEth d3fc0c8005 feat(chat): 重试记忆改多槽 —— 每条失败各留自己的幂等键
RetryKeyMemory 从单槽改成多槽(slots: List<RetryKeySlot>,上限 MAX_SLOTS = 8,FIFO 淘汰最旧)。
单槽只记得最近一次失败那条,而用户的重试顺序不必和失败顺序一致:他重试 A 又失败、再回头去重试
更早失败的 B 时,B 的槽早被顶掉,那次重发只能铸新键 —— 而 B 那次失败完全可能是「服务端已经收下、
客户端没看见」,于是又落成第二条消息,正是本机制要消灭的重复。

- remember:同指纹先摘掉旧的、再追加到末尾(同一条又失败一次是更新它的键,不是并列两条),
  超过上限从头部丢最旧的,且刚写进去的那条一定留得住。
- forget:按 localId 清;一条都对不上就原样返回同一个实例;同 localId 万一有多条则一并清掉。
- keyFor:命中只消费那一条、其余槽全部保留;未命中铸新键且一条槽也不清 —— 单槽时代「未命中也要
  清空」在多槽下会把别的失败记录一起抹掉,这条已显式写进 KDoc 以防后人照着旧注释改回去。
- ChatViewModel:代码逻辑一个字节没动(三个调用点的签名都没变),只把提到「单槽」「下一次发送就
  被消费」的 KDoc 改成与新实现一致;值比较指纹、正文不归一、晚到的 message_persisted 刻意不清键
  这几条 W15 纪律原样保留。
- 测试:RetryKeyMemoryTest 由 10 条重写为 16 条。删掉「未命中即清空记忆」那两条旧断言(新语义下
  不再成立),新增:两条失败各自原样重试都落回自己的键(两种重试顺序各一条)、重试 A 不抹掉 B 的槽、
  同指纹重复 remember 只留一条且键是最新的、超上限 FIFO 淘汰最旧且刚写的仍在、未命中时全部槽保留、
  命中消费后其余槽保留。

验证:assembleDebug 与 testDebugUnitTest 均通过(--rerun-tasks 全量重跑):760 → 766 测试 / 0 失败 / 0 错误。
2026-10-04 21:11:45 +08:00
AskaEth b579732db6 docs: 落 W15/W16 批次(重试复键的取舍、会话内搜索的形状)+ 六条契约口径登记 2026-10-04 20:50:23 +08:00
AskaEth 46ac15a538 merge: W16 — 会话内消息搜索(顶栏入口 + 搜索面板 + 点结果定位) 2026-10-04 20:45:09 +08:00
AskaEth 23b46f9310 feat(chat): 会话内消息搜索 D2 —— 顶栏入口 + 搜索态面板 + 点结果定位
改了什么
- data/api/SessionsApi.kt:messages 端点加可选 q(契约 §1 行 6:q 是同一个端点上的
  查询参数,不是新端点)。不传 q 时 Retrofit 整个省掉该参数,既有调用线上请求逐字不变
  —— 这条用真 HTTP 服务端读原始 URI 钉住,而不是靠读代码相信。
- data/repository/MessageRepository.kt:新增 search()(走既有 apiCall/Result 风格,
  倒序返回统一转成升序的 PagedMessages),以及 normalizeSearchQuery(只 trim:
  不转义 % _ \、不改大小写 —— 这两件事服务端已做,两边都做必然错一边)。
  空查询不发请求(契约里空 q = 未传,那会把整段历史当成搜索结果回给调用方)。
- ui/chat/SessionSearchModel.kt(新):全部纯逻辑 —— 命中片段投影(先过 domain/parse
  这一处解析器再拼回纯文本,绝不把标记当富文本;标记里才有的词只显示不高亮)、
  结果合并去重排序(倒序,与聊天页的升序 MessageOrder 刻意相反)、游标推进、
  「这条在不在窗口里」的判定、失败文案。
- ui/chat/SessionSearchViewModel.kt(新):只属于聊天页搜索态的小 VM,带代际号,
  连打两次回车时慢的那个请求回来不会顶掉新关键词的结果。
- ui/chat/SessionSearchPanel.kt(新):全屏搜索面板 —— 自动聚焦输入框(IME 搜索键与
  放大镜都能提交)+ 结果列表(角色 / 时间 / 命中片段,BasicComponent 包 PreferenceCard)
  + 往下翻加载更早的命中 + 未搜/搜索中/空结果/失败 四态分开渲染。
- ui/chat/ChatScreen.kt:只加接线 —— 顶栏搜索入口、面板叠层、返回键先关搜索、
  点结果之后的如实告知。

为什么这么做
- 搜索态独立成小 VM:ChatViewModel 正被另一个并行模块改动,而搜索不改消息窗、不发消息、
  不碰 WS,没有任何理由挤进去。
- 面板叠在聊天页之上而不是替换它:底下的 LazyColumn 必须保持挂载,点一条命中要
  animateScrollToItem 定位,而 LazyColumn 一旦被移出组合,滚动就落空。
- 命中不在「已加载窗口」里时不插一条临时气泡,而是如实提示(比一个假的跳转诚实):
  聊天列表是升序倒过来喂给 reverseLayout 的,凭空插一条几千条之前的消息会出现在列表最底
  ——也就是「刚刚发出的位置」——与它自己的时间戳矛盾,还会带偏跟随底部的判断;
  而把消息真的并进窗口要改 ChatViewModel,本模块不许。
- 失败绝不渲染成「没有找到」:两者都是一张空列表,但一个该换关键词、一个该修网络或权限。
- 返回键先关搜索:新处理器注册在 ChatHomeScreen 那几条之后(优先级更高),
  且搜索没开时是 disabled,抽屉 / 管理页 / 设置页的既有层级不变。

测试 706 -> 737(+31:纯逻辑 25 + 数据层 6)
2026-10-04 20:44:03 +08:00
AskaEth 8b907036db merge: W15 — 手动重试同一条内容时复用原幂等键(改过的内容照旧铸新键) 2026-10-04 20:28:40 +08:00
AskaEth af11a94614 fix(chat): 手动重试同一条内容时复用原幂等键,改过的内容照旧铸新键
W13 顺手查出的「文档与实现不符」:PendingMessage 的 KDoc 一直写着「重试复用同一个
localId」,而 emitAndDeliver 每次发送都新铸 localId —— 若某次失败其实是「服务端已经收下、
客户端判成失败」(队列满 / 超时 / 回执丢失),用户手动重试就会铸新键、被服务端当成第二条
消息落库,用户看到重复。这正是幂等键(契约 v0.56 §3.13 / 协同 G4)要防的事。

- 新增 domain/chat/MessageFingerprint.kt:正文 + 图片本地 URI + 附件本地 URI 的内容指纹。
  用纯值相等而不是哈希(摘要碰撞会让两条不同消息共用一个键,后一条被服务端判重丢掉),
  正文一个字都不归一(只改一个空白字符也算改过 —— 归一正是会把两条真实消息并成一条的操作)。
  附件只看本地引用:displayName / sizeBytes 是元数据,同一份文件两次查询之间可能一次报得出
  一次报不出。
- 新增 domain/chat/RetryKeyMemory.kt:单槽重试记忆,三个纯函数(remember / keyFor / forget)
  加一个判定结果类型。remember 只在回滚时调用;keyFor 是唯一的消费点,指纹相等即复用、
  不等则铸新键(两支架都消费掉记录);forget 按 localId 清,避免两条同时在飞时互相踩。
- ChatViewModel:所有回滚路径(发送失败、队列满被拒、附件与图片上传失败、图片处理失败、
  附件超限……)本来就汇到一个 rollback,现在在那里按指纹记下那条的键;队列满判定的 Rollback
  档同样记、Adopt 档清。emitAndDeliver 改用 keyFor 取键。完成回执与 duplicate / 收编按
  localId 清(正常那一路在发送时已消费,这几步是兜底)。落库消息到达时刻意不清:一条晚到的
  message_persisted 恰恰是「服务端其实收下了」的证据,留着键才能让重试真正幂等。
- duplicate 路径复用既有 alignDuplicate / applyQueueFull 的 Adopt 档,没有另造对齐逻辑。
- 测试 706 → 729(+23,0 失败):指纹 13 条(正文空白 / 换图 / 增删与换序附件 / 元数据不算
  内容 / 快照语义),重试记忆 10 条(复用只在指纹相等时发生、用掉即消费、编辑后不再复用、
  forget 只清得动那一条、以及 duplicate 收口的端到端那条)。
2026-10-04 20:27:00 +08:00
AskaEth 76d49b73d1 docs(05): 落 W13/W14 批次与实机验收、W13 的两条文档不符、W14 取字节无 logcat 的证据改法、回执七要点 2026-10-04 19:52:57 +08:00
AskaEth f10984622c merge: W14 — 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」 2026-10-04 19:48:52 +08:00
AskaEth 557ef3f524 feat(chat): 点气泡里的图片进全屏查看,并接上「用其它应用打开 / 保存到下载目录」
气泡里的图以前点了没有任何反应。现在点图进一个铺满全屏的弹层:fit-center 渲染、双指捏合
缩放(以双指中点为不动点)与单指拖动平移、双击在 1x 与 2x 之间切换(以双击点为不动点)。
位移与缩放全部按内容边界钳制——fit 态下拖不动(内容没超出容器)、放大后拖不到露出空白、
缩放恒在 1x..3x(下界比任务书的 0.5x 更紧:1x 就是「整张图刚好放得下」,再缩只是变小加留白)。
双击或关闭即复位(缩放状态活在弹层的组合里)。退出有右上角关闭按钮与系统返回键。

弹层底部两条出口与 W11 的附件预览同源,缓存与落盘复用 DocumentCache:图片也落在
cacheDir/documents/ 下,所以 FileProvider 的暴露面一个字节都没变(file_paths.xml 只改了注释,
没有新增暴露路径)。
- 用其它应用打开:声明图片自己的 mime(字节嗅探优先,其次平台声明的 image/ 前缀、名字扩展名,
  兜底 image 通配),经 FileProvider 交出去并带 FLAG_GRANT_READ_URI_PERMISSION;系统里没有
  应用能开时如实说一句并指路保存,绝不静默失败。
- 保存到下载目录:CreateDocument 一律传通配类型,否则 SAF 会按具体 mime 给文件名补扩展名
  (W11 实机抓到的 .md 变 .md.txt 那个坑;图片这边同样会变成 x.jpg.png)。

字节来源分三条,判定只发生在一处(ImageSource.of):服务端能力 URL(GET /api/v1/files/{name},
读侧免鉴权,所以用一条裸 OkHttp 客户端,而不是挂了 AuthInterceptor 的那条链——把 token 发给
一个免鉴权端点只多一个泄漏面)、内联 data: URI(字节就在字符串里,不联网)、本地 content 或
file 协议 URI(现读并拷进缓存,因此自己刚发的那张本地图也能交给别的 App)。

缓存名从服务端 URL 推,但要先过安全闸:单段、非 . / ..、不含分隔符与 NUL、不含百分号、扩展名
在图片白名单里;任何一条不满足就回落到按字节内容寻址——绝不拿一个不可信的名字拼路径。

加载态 / 失败态 / 就绪三者分明:取字节失败给一句话加一个重试入口;字节拿到了但解码不出来
(例如服务端被代理换成了 HTML 错误页)另给一句话,绝不画成一张空白图。重试把两条路都重来一遍。

测试 634 到 676(+42):来源判定、缓存名的安全闸、字节嗅探、打开与保存的类型选择、失败文案、
缩放平移的全部数学,以及图片缓存层的命中 / 回落 / 失败不留残留文件 / 真的走一次本地 HTTP
(JDK 自带的 HttpServer,验 200 / 404 / 0 字节三种响应)。纯装配部分(Dialog、手势识别、
Intent 投递、SAF 落盘)没有单测,留给实机验收。
2026-10-04 19:48:18 +08:00
AskaEth 9e039863fa docs(02): 收下后端的回执七(大附件护栏修复上线、API Key 仅内存表、IM 文件边界、v0.60 预告) 2026-10-04 19:43:52 +08:00
AskaEth 9854cc477d merge: W13 — 队列满时定位并处置那条待发气泡(pending_full / 定向 chat_busy 的 clientMsgId) 2026-10-04 19:42:49 +08:00
AskaEth 3b4a790e30 feat(ws): 队列满时定位并处置那条待发气泡(契约 v0.59 / 协同 G5)
WS 上行没有 REST 那样的同步回执:队列满时服务端只广播一条 pending_full,而载荷里既没有
「被拒的是哪一条」也没有任何可对齐的键,于是那条乐观气泡永远挂「发送中…」,用户不知道它
其实根本没发出去。后端 v0.59(G5)已经把这两件事补齐:pending_full 载荷新增两个可选键
clientMsgId(被拒那条消息的幂等键,未携带则整键缺席)与 messageId(仅当该消息此前已落库 /
已认领时才回填,服务端绝不在此新铸 id),并新增一条只发发送者的定向帧
error{code:"chat_busy"},与 REST 的 409 chat_busy 同码。本端把这两条接上。

data/ws/WsEvent
- PendingFull 增 clientMsgId / messageId:缺席就是 null,绝不拍成空串(否则收编路径会去
  对齐一条不存在的消息)。
- Error 帧增 code / clientMsgId / messageId,并从 data.sessionId 取会话归属(定向帧的帧壳
  上没有 sessionId)。code 是开放集合:未知码原样保留,不抛、也不吞掉整帧。
  未给定向 error 另立事件类——它与既有 error 是同一个 type,一个 type 无法用两个类表达;
  两者语义的分叉放在下面的判定函数里,保持「一个帧类型一个事件类」的既有约定。
- PendingFullData / ErrorData 同步扩键,全部可选且默认 null。

ui/chat/ChatViewModel
- 新增纯函数 queueFullOutcome(判定)与 ChatUiState.applyQueueFull / rollbackPending
  (状态迁移),两者都不碰协程,逻辑不埋在协程里、可直接单测。既有 rollback 改为委托
  rollbackPending,行为逐字不变。
- 判定顺序即证据强度:clientMsgId 命中时,messageId 非空 => 收编(复用 alignDuplicate:那条
  其实早就落库了,不是失败、不回滚、不把正文/图片/附件退回输入栏),缺席 => 回滚那一条并把
  内容还给输入栏;clientMsgId 有值但不命中 => 放手(键只属于某一次具体发送,回滚任何一条都是
  误伤);clientMsgId 缺席(老服务端)=> 沿用 toolExecutionTargetIndex 的先例,只在待发恰好
  一条时回滚它,两条及以上不猜、但也不装作没事,只如实提示一句。
- 广播 pending_full 与定向 error{chat_busy} 会同时到达发送者,两者共用 onQueueFull;
  幂等靠状态本身——第一次已把气泡移出待发列表,第二次判定自然落到 Ignore,不会回滚两次、
  也不会提示两遍。
- REST 409 chat_busy 的文案与 WS 侧合并成同一句(契约把两条入口定为同一个 code),
  并点明「这条没发出去、请手动重试」:全链路没有自动重试。

测试 +30(634 -> 664,0 失败 / 0 错误)
- WsEventPendingFullTest:两个新键的有/无、老形状仍可解析、会话闸、无载荷与坏帧不抛。
- WsEventErrorFrameTest:chat_busy 定向帧全字段、可选键缺席为 null、未知 code 原样保留、
  只带 code 的帧仍可解、只有 message 的老帧行为不变、坏帧不抛。
- QueueFullOutcomeTest:三种事实各自的判定与状态迁移(收编不动输入栏 / 回滚归还正文图片附件 /
  不猜时一个气泡都不动),以及键对不上、空键、同一帧处理两次的幂等。

未验证:本端没有设备也没打过真后端,队列满的实机触发路径见交接报告;VM 里
「判定 -> 既有动作」的映射只由编译与纯函数单测覆盖(构造 ChatViewModel 需要 Android
环境的 AppContainer,单测里没有先例)。
2026-10-04 19:41:40 +08:00
AskaEth 8633ec9f06 docs(05): 落 W11/W12 批次与实机验收、SAF 双扩展名 bug 的修复、GMS ANR 与 dump 重试两个新坑 2026-10-04 19:28:53 +08:00
AskaEth ffa7ed2661 fix(chat): 保存附件时不要被 SAF 补成双扩展名
实机复现(2026-10-05):把服务端附件 `809320…e.md` 保存到下载目录,落盘成了
`809320…e.md.txt` —— 因为 `CreateDocument` 传的是服务端**字节嗅探所得的基类型**
(`.md` 也会报 `text/plain`),而 SAF 的 Downloads provider 会按该 mime 给文件名**补扩展名**。

改为传 `*/*`:没有可补的扩展名,原扩展名因此保真。
**「用其它应用打开」那条路刻意不改** —— 那里要的正是「能读懂这段内容的类型」,照旧用嗅探所得的 mime。

实机复核:同一枚附件保存后落盘为 `809320…e.md`(95 字节,与源一致),不再出现 `.md.txt`。
2026-10-04 19:27:53 +08:00
AskaEth 60b0eacafd merge: W11 — 附件芯片可点开预览 + 「用其它应用打开 / 保存到下载目录」两条出口(FileProvider) 2026-10-04 19:22:25 +08:00
AskaEth 27b6910009 feat(chat): 附件芯片可点开预览,并给「用其它应用打开 / 保存到下载目录」两条出口
点一枚附件芯片此前完全没有反应。现在点它会开一个 App 内预览弹层,弹层里再给两条出口。

- FileProvider(authority = ${applicationId}.fileprovider,res/xml/file_paths.xml 只暴露
  cacheDir/documents/):把 file:// 交给别的 App 在 API 24+ 会抛 FileUriExposedException,
  必须走 content://;因此 cacheDir 下真正要交出去的那一层才被开出来。
- 新增 data/repository/DocumentCache.kt:缓存路径推导 / 命中判定 / 原子落盘是可单测的纯逻辑;
  DocumentCacheStore 把「服务端按存储名下载」与「本地待发附件」两条来源汇成同一份文件 + 字节。
  服务端存储名是内容寻址(sha256[:32]+ext)且读侧响应 immutable,所以命中即永不失效、命中时
  一个请求都不发;先写 .part 再改名、失败删干净——半截文件被下一次「命中」当成完整内容,
  是最坏的一种静默错误。
- 新增 domain/chat/DocumentPreview.kt(纯逻辑):截断(首 256 KB 或 5000 行,先到者为准;
  字节切点不切坏多字节 UTF-8)、正文分块(200 行一块)、来源判定(服务端 vs 本地,只有一处)、
  打开用的 MIME(只用 DTO 里字节嗅探所得的 mime,绝不按扩展名猜)、「没有 App 能开」的裁决与
  文案、取字节失败的分类文案。
- 新增 ui/chat/DocumentPreviewDialog.kt:Compose Dialog + Miuix 内容。正文用 LazyColumn 分块渲染
  (5000 行不塞进单个 Text),每块各自 SelectionContainer;加载态与失败态分明(失败给重试,
  绝不渲染成空文件);截断明写「只显示前 N,完整内容请用…」,不静默截断;复用 DocumentChips
  显示类型/体积/字数/状态,不另写一套标签;status 为 unreadable 不挡预览(那是模型读不到,
  文件本体一直在)。
- ui/chat/MessageBubble.kt:芯片整枚可点(点击区是那枚芯片本身,不是里面的两行文字),
  并带 contentDescription「查看附件 <name>」。
- ui/chat/ChatScreen.kt:预览状态 + 取字节 + SAF 保存 + ACTION_VIEW 打开。没有 App 能开时
  如实告知并指路保存,绝不静默失败。

契约缺口(需登记 02):服务端按内容寻址落盘,DTO 里没有原始文件名,保存到下载目录只能用
<32hex>.<ext>——弹层里已把这一点写在界面上,没有替用户编一个假名字。

测试 567 → 610(+29 DocumentPreviewTest / +14 DocumentCacheTest),0 失败 0 错误。
2026-10-04 19:21:31 +08:00
AskaEth a0d6e3479f docs(02): logs REST 已接上(闭环)+ 五条契约口径建议补写 + 铸不出 viewer 账号的验收缺口 2026-10-04 19:20:15 +08:00
AskaEth a7dbee847e merge: W12 — 「最近日志」接上 logs REST 引导读取(补齐只显示进页面之后那段日志的缺口) 2026-10-04 19:19:18 +08:00
AskaEth 642bdb13f0 feat(admin): 「最近日志」接上 logs REST 引导读取 —— 补齐 W10 只显示进页面之后那段日志的缺口
W10 接上的 `observability_log` 帧以「订阅那一刻的最新条目」为锚、不回灌历史,而客户端没有
`GET /api/v1/admin/observability/logs` 的接入 ⇒ 概览页的「最近日志」只能显示进页面**之后**产生的
条目。本次把 REST 引导读取补上,并把 W10 那条取舍(「一帧都没到过 → 整段不渲染」,为的是不把
「没有推送」说成「没有数据」)改成契约要求的「**REST 成功即渲染、帧是增量**」。

- **取数**:`AdminApi` + `AdminRepository` 接上该端点,显式发 `limit=200`(与客户端保留窗口、
  帧的单帧上限同源);**不暴露** `?level=`——它是服务端「最低等级」过滤,只在服务端裁剪一次,
  会饿死界面上那排要来回切换的等级档,所以一律取全量、由客户端本地过滤(契约 §3.12 亦然)。
- **DTO 统一**:REST 与帧同形(后端同一个 `gateway.AdminLogEntry`),因此把
  `data/ws/ObservabilityLogEntry.kt` 挪进 `data/api/dto/AdminDto.kt` 并改名 `AdminLogEntryDto`
  (顺带与 `AdminMetricsDto` / `AdminTraceSpanDto` 对齐),删掉旧文件——不留下两份等价形状。
- **合并纯函数**:日志改成「去重 → 按 `time` 倒序 → 截窗口」。三步顺序不能换:窗口是**位置**
  截断,先截再排会让一条更旧的条目挤掉更新的那条;排序用解析出的 epoch 秒 + **稳定排序**保住
  同秒条目的到达顺序(服务端 `time` 只有秒精度);时间判不出来的条目落到尾部但**不丢**。
  trace 那条路径不动(span 有服务端写入序号,重排反而把顺序排乱)。
- **界面**:`logs` 改为可空——null = 还没有基线(不渲染);非 null(含空列表)= REST 读过且成功
  ⇒ 渲染,于是「加载中 / 不可用 / 暂无日志 / 列表」四种表现分明,未知等级条目照旧一律保留。
  三路的失败原因也分开记了:日志挂了不再让 trace 那块显示「不可用」(那是把一次成功说成失败)。
- **角色分档**:新增纯函数 `canReadObservabilityLogHistory`(日志历史与概览页同档,
  v0.40 的 viewerAuth 只读监控档 ⇒ **viewer 也读得到**),与只发 owner/admin 的帧门刻意不对称。
  ⇒ viewer 看得见历史、收不到增量,界面不得因此报错或报空(区块是否渲染只看历史这条门)。

测试 567 → 591(DTO 边界、合并去重/排序/窗口、角色分档、仓库线级请求与 501/403/坏载荷)。
`assembleDebug` 与 `testDebugUnitTest` 均通过(591 测试 / 0 失败 / 0 错误)。未做真机与真后端验收。
2026-10-04 19:18:42 +08:00
AskaEth c94ea09b59 docs(02): 登记纯附件回合(Android 端首测)没有助手消息 + 两次 knowledge 工具失败 2026-10-04 19:12:52 +08:00
AskaEth e59ef08cf9 docs(05): 落 W8–W10 批次状态、模拟器与该轮自动化的坑、契约 v0.59、附件预览欠账 2026-10-04 19:09:10 +08:00
AskaEth ca5596a732 fix(chat): 附件芯片改换行排布 —— 多附件不再被裁到屏幕外
主人 2026-10-04 在主会话里发现「用户消息显示有问题」:带 2~3 个附件的用户消息里,
第一枚芯片就吃掉了整排——服务端存储名是 32 位十六进制 + 扩展名(如 `36a4aba0…5524808.txt`),
一枚约 267dp,而芯片排是「横向可滚的单行 + `widthIn(max = 320.dp)`」,于是第二枚只剩一条边
露在屏幕外。看的人不知道还有别的附件,也看不出那一行可以滚。

改为 `FlowRow` 换行排布(用户消息整排贴右 `alignEnd`),N 枚芯片全部可见;代价是 5 枚会占
2~3 行——比「藏起来」值。去掉随之失效的 `CHIP_ROW_MAX_WIDTH` 与两个 import,
并同步 `ChatScreen` 里待发芯片排的注释(同一处行为)。

实机复核:主会话那条三附件消息现在渲染为三行,三枚芯片与各自的
「TXT · 4 B · 4 字 · 已读入」全部可见(截图 `app/build/e2e-shots/chips-after.png`)。
2026-10-04 19:08:26 +08:00
AskaEth 2369dd2115 meta(docs): devdocs/ 纳入版本库 —— 交接与协同文档不再只存在于本机
主人 2026-10-04 指示「项目仓库也可以写进去」。因此把 devdocs/ 从 .gitignore 移除并首次入库:

- `05-开发进度与交接.md` —— 跨会话交接锚点:当前状态、易忘的实现约定、踩过的坑、实机验收方法
- `02-前后端协同事项.md` —— 与后端会话的**交换文档**(两边都会读写:我们登记缺口,对面写回执)
- `01/03/04/06/07` + `DESIGN.md` —— 路线、客户端细化方案、构建环境、迁移说明、给后端会话的交接

**凭据扫描已过**:无账号口令、无 token/refreshToken 明文、无 API key 或私钥。
文档里只记「登录凭据不写进文档,问主人」这条口径本身。

(此前忽略它的理由已不成立:文档现在承担跨会话交接与前后端协同两件正事,
只留在本机磁盘会随机器丢失,而这批内容的价值恰恰在「后来人读得到」。)
2026-10-04 19:00:18 +08:00
AskaEth 29733914a4 docs(documents): 契约 v0.59 已发布 —— 更正 W6 那批「未发布/待发布」的过时注释
Cyrene_v2/Docs/api-client.md 已发布 v0.59(§1.2「文档存储与摄取契约」+ 端点表 16/17),
而 W6 那批文件的 KDoc 仍写着「契约尚未发布 / 待发布」,会误导后来人以为形状没有契约背书。

已逐条核对:我们实现的形状与已发布契约**完全一致** —— multipart 部件名 `file`、
201 首次 / 200 + `deduped:true` 去重、单文件 20 MiB、单回合 ≤5 条、上行只收 `name`、
`url` 是相对路径、`mime` 是嗅探基类型、`documents` 数组含 `chars`/`truncated`/`status`、
0 个附件时该键省略、4 个错误码。白名单在契约里也已更正为 22 项(与代码一致)。

纯注释更正,零行为变更。
2026-10-04 18:53:10 +08:00
AskaEth ab76798bff merge: W9 — 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流) 2026-10-04 18:48:42 +08:00
AskaEth 30180ab1e5 feat(chat): 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)
把 W6 做好的「文档进对话」数据层接上 UI 与发送路径,产品决定按 05 的定案执行:
**不做长正文自动转文件**、**附件与正文各自独立存在**、上行**只给服务端存储名 `name`、≤5 条**、
带附件一律走 REST。

改了什么(按清单):
- AppContainer:照 cachedUploadApi 的写法加 documentsApi + documentRepository,**与 chatApi 同
  baseUrl / 同 token·401 策略**(上传拿到的 name 要交给同一个后端的 /chat,不同源必然 404)。
- domain/chat/DocumentAttachment.kt(新):DraftDocument(待发附件,只带本地引用,**不带字节**)、
  admit()(去重 → 20 MiB 单份预检 → 5 份上限,被挡下的**都计数**、绝不静默裁)、
  readBounded()(超限回 null、IO 异常原样抛——「太大」与「读不到」必须分得开)。
- domain/chat/DocumentChip.kt(新):附件芯片的展示换算(类型 / 体积 / 注入字数 / 状态),
  unreadable(读不到)与 truncated(注入被预算截断)**两句文案分开**,未知 status 原样显示。
- domain/chat/DocumentDelivery.kt:加 planForCount(text, 份数)——同一套判定矩阵,但只看份数,
  因为 VM 手上还没有字节(最多 5×20 MiB 一次性读进内存不可接受)。plan() 委托给它,行为不变。
- ChatViewModel:ChatUiState 加 draftDocuments / attachmentNotice;send() → deliver() 走
  DocumentDelivery 判定 → 逐份上传(保留扩展名、200+deduped 当成功)→ payload(names) 为 null
  时**提示而不截断** → sendViaRest 带 documents;失败与正文、图片一起回滚(附件还给输入栏)。
- ChatScreen:SAF OpenMultipleDocuments 入口(MiuixIcons.File + contentDescription「添加文件附件」),
  MIME 列表贴近后端文本白名单的语义(含 octet-stream 以免「后端收得下、用户选不到」),
  输入栏上方一排可移除芯片;发送按钮在只有附件时也可用(纯附件回合服务端支持)。
- MessageBubble:documents 渲染成芯片(名字中略保住扩展名 / 类型·体积·字数·状态),
  unreadable 整枚转灰并明写「读不到」;空列表不占位。PendingBubble 显示本地待发件。
- ShareIntake + ShareRules:EXTRA_STREAM 按 ContentResolver.getType 分流——image/ 前缀走既有
  图片路,其余(含 null)走**文档路**(拷进 cache、带显示名与真实字节数)。此前一律当图片拷,
  一份 .md 只能得到「图片处理失败」。**Manifest 一行未改**:后端明令拒绝 html/SVG/zip/脚本,
  声明全通配会造出「点进去必然失败」的入口,text/plain 已能接住 .txt/.md/.log。

测试:485 → 536(+51,0 失败 0 错误)。
2026-10-04 18:44:23 +08:00
AskaEth 93941cd1ea merge: W10 — 接上可观测性下行帧(概览页实时刷新 metrics / trace / 日志) 2026-10-04 18:42:58 +08:00
AskaEth b5e6f78910 feat(admin): 接上可观测性下行帧,概览页实时刷新指标 / trace / 日志
契约 v0.55 §3.12 的三帧(`observability_metrics` / `observability_trace` / `observability_log`)
此前在本端没有 handler,落到未知帧被静默丢弃,概览页只能「进页面 / 手动刷新各拉一次 REST」。

- 解析(data/ws):三个事件类 + `dispatchObservability` 分支,载荷按契约逐字对齐
  (metrics = 扁平本体、trace = `{spans}`、log = `{entries}`),帧壳 `sessionId` 有就透传。
  载荷缺席时**整帧安全忽略**——metrics 回落成默认值就是一份全零快照,会把界面上的真实指标
  擦成 0;未知帧、坏帧仍安静丢弃(回归用例钉住,绝不抛)。
- 消费(ui/admin):AdminViewModel 自持一条 socket(照 SessionsViewModel 的做法:登录且
  角色够档时连、登出 / 角色降档 / onCleared 关),把三帧合并进概览状态。REST 仍是兜底真源:
  进页面与手动刷新照旧各拉一次全量;每次「连上」(含首次连接与断线重连)后再重读一次——
  断线窗口里的帧永久丢失,而服务端只为「有在线 owner/admin 连接」的凭据保留订阅,
  重读同时也重建订阅(契约 §3.12:读取即订阅握手)。
- 角色门在客户端也判(domain/admin 新增纯函数 `canReceiveObservabilityFrames`):这三帧服务端
  fail-closed 只发 owner + admin,与概览页自身的 Monitor 档是两条门,viewer 既不开连接也不合帧。
- 日志只有推送这一个来源(本端没有 logs REST 端点):一帧都没到过时整段不渲染——不写
  「暂无日志」,那等于把「没有推送」说成「没有数据」;等级过滤按契约在客户端本地做。
- 合并与过滤是 ui/admin 下的纯函数(新帧在前、按业务键去重、窗口 100/200),单测覆盖。

测试 485 → 514(+29),0 失败 0 错误;`assembleDebug` 通过。
2026-10-04 18:42:23 +08:00
AskaEth f24877d53b merge: W8 — 设置页「版本」行可点进「关于」页(含作者署名) 2026-10-04 18:35:11 +08:00
AskaEth 3ed2377b3c feat(设置): 「版本」行可点进「关于」页
设置页的「关于」分组里,「版本」此行此前点了没反应——它是全页唯一
没有 onClick 的行。现在它带 ChevronForward(与管理员入口行同一种「可
进子页」提示)并可点,进入新的「关于」页。

「关于」页只有四项:应用名(Cyrene)、版本、一句话简介、作者
(AskaEth),自己带 Scaffold + 返回按钮,观感与设置页一致。版本号复用
BuildConfig,不写死;页面里不放任何链接 / 邮箱 / 版权年份——这些信息
没有人提供过。

返回层级照项目既有的手写布尔状态 + 分层 BackHandler 模式(本项目无导航
库),新增 showAbout:
- 关于 → 设置 → 聊天;
- 设置页的处理器条件是 showSettings && !showAbout(与关于页互斥),
  所以在关于页按返回只退一层,不会一步跳回聊天;
- 抽屉 / 设置 / 管理页既有层级未动。

版本文本抽成纯函数 versionText(versionName, versionCode),设置页与关于页
共用一份格式,并由 VersionTextTest 钉住输出。

测试:485 → 487(新增 VersionTextTest 2 条),0 失败 / 0 错误;
assembleDebug 通过。
2026-10-04 18:31:03 +08:00
AskaEth a6b4bae2a0 merge: W4 — 概览页「按路径」分组折叠(可读性) 2026-10-04 18:09:18 +08:00
AskaEth a80dac7a7e merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状) 2026-10-04 18:08:20 +08:00
AskaEth f91a8558ea feat(admin): 概览「按路径」改成分组可折叠列表 —— 26 条路径不再挤成一行
`perPath`(真实响应 26 条)此前被 `formatCounts` 压成一行右对齐文本,手机上极密。
本次只改呈现层,取数(DTO / Retrofit / ViewModel)一字未动。

- domain/admin/AdminPathStats.kt(新增,纯函数):分组键 = **剥掉 `/api/v1` 后的首段**
  (`/api/v1/admin/llm/catalog` → `/admin`,`/sw.js` → `/sw.js`,`/` → `/`)。
  规则是纯字符串推导、**没有任何路径清单**:服务端加路由不用改客户端
  (`/api/v1/quantum/*` 自动成 `/quantum` 组);逐段比较,`/api/v1x/foo` 不会被误剥成 `/x`。
  剥离只作用于分组键,行里显示的仍是完整路径。
  排序:组按命中次数倒序、组内同样倒序,**两处同数都按路径升序**——指标页每次刷新都会重排,
  没有稳定次序读者就跟丢了。`hits`(次数)与 `pathCount`(条数)分开,是组头的两个数。
  长路径(段数 > 4)展示时折叠中间段、保留尾部 3 段:`…/admin/observability/metrics`;
  尾部才是区分同类路径的部分,整串在手机上会被尾部省略号吃掉、两条路径就长得一样了。
- ui/admin/AdminOverviewScreen.kt:「按路径」从 `countRows` 里挪出来单独成块——
  区块标题给「26 条 · 326 次」,卡片内一行一组,组头「N 条 · M 次」+ 可旋转箭头,
  整行可点即展开/收起;组内一行一条路径,右侧计数右对齐、路径等宽字体 + 中间省略。
  **条数 > 4 的组默认收起**(真实数据里 15 条 admin 路径收成一行组头),小分组默认展开省一次点击;
  折叠状态只记**用户动过的组**(`remember { mutableStateMapOf() }`,放在 LazyColumn 之外),
  刷新拿到新数据不会被上一轮点按锁死。
  只用 Miuix 现成组件(`PreferenceCard` + `BasicComponent` + `HorizontalDivider` +
  `MiuixIcons.ChevronForward` 旋转 90°),**没有自造折叠控件**;路径行用内容重载自己画 Text
  才能拿到 `maxLines = 1` + `MiddleEllipsis`(收 `String` 的那个重载只会尾部省略)。
  失败态不变:`metrics` 缺席时整块不渲染(上面那张「指标不可用」卡已经说明了原因),
  只有 `perPath` **请求成功且为空**时才给「暂无路径统计」。

测试:353 → 370 全绿(+17,AdminPathStatsTest):空输入 / 单条(不产生组头)/ 真实 26 条 →
10 组且总次数 326 = 同一份真实响应里的 `totalRequests`(分组不吞路径也不重算次数)/
未知前缀自动成组 / 基址逐段剥离的边界(`/api/v1x/foo`、基址本身)/ 组与组内的排序与并列次序 /
长路径折叠(两条兄弟路径折叠后仍不同)/ 折叠默认值的阈值边界(4 条展开、5 条收起)/
显式切换双向覆盖默认。

未验证(无模拟器、也不许碰 adb,只有构建 + 单测):
- 观感(组头取 `primary`、路径等宽字号取 `footnote1`、子行缩进 12dp、箭头旋转)**没有截图**,
  取舍待主人拍板;
- `BasicComponent` 对长路径会不会折行没有实机确认,所以路径文本额外做了「段级中间折叠」,
  不依赖库的省略行为;
- 深色模式未单独验证,颜色全部取自 `MiuixTheme.colorScheme`(与页面其它卡片同源)。
2026-10-04 18:07:22 +08:00
AskaEth 557cdb213d merge: W5 — 新建会话时选角色(POST /sessions {personaId}) 2026-10-04 18:07:03 +08:00
AskaEth 095d6e781e feat(data,domain): 文档进对话数据层(v0.59 待发布形状 + 该不该走文件判定)
契约 v0.59 尚未发布(api-client.md 最新仍是 v0.58),形状依据
devdocs/02 回执五「契约形状修正」,并用后端已落库切片逐字段核对:
407c934(S1 存储端点)、a4789ec(S3-B 网关入口)、5b2b385(S3.5 多值化)。

新增:
- data/api/DocumentsApi.kt:POST /api/v1/documents(multipart 字段 file)、
  GET /api/v1/documents/{name}(Bearer owner|admin;@Streaming 取字节)
- data/api/dto/DocumentDto.kt:文档形状的唯一落点(v0.59 若不同只改这里)
- data/repository/DocumentRepository.kt:200 + deduped:true 当成功;url 是相对
  路径故不做图片那套绝对化校验;不做本地白名单/大小上限(服务端才是裁决者)
- domain/chat/DocumentDelivery.kt:该不该走文件的判定 + chat.documents 载荷构造
- domain/chat/DocumentStatus.kt / DocumentFailure.kt:状态与四个错误码的可分支映射

改动:
- ChatDto.ChatRequest 增 documents(默认空列表 ⇒ 旧请求逐字节不变,有字面量钉子)
- MessageDto.HistoryMessageDto 增 documents(缺键与空数组都解成空列表,覆盖
  后端「0 个」尚未定死的两种形状;WS message_persisted / history 回放共用该 DTO)
- ChatSendRouting.channelFor 增 hasDocuments(默认 false):带附件强制 REST,
  因为 WS 帧 4096 字节硬上限超限是直接断连,且文档一律走 REST

为什么不做 UI/VM:UI 与 ViewModel 文件由并行的分享入口模块占用,接线由主代理统一做。

测试:353 → 413(+60:DTO 12 / Repository 17 / Delivery 21 / Failure 10),
assembleDebug 与 testDebugUnitTest 均通过。
2026-10-04 18:06:27 +08:00
AskaEth 4834905b24 merge: W7 — 分享到 App(ACTION_SEND / SEND_MULTIPLE + 进程级暂存,登录后落地) 2026-10-04 18:06:17 +08:00
AskaEth 217cfe3d5f W5: 新建会话时选角色(persona)
改了什么:
- `POST /api/v1/sessions` 支持可选 `personaId`:null(或全空白)走**不带 body** 的重载
  (与选角之前逐字节相同),非空才发 `{"personaId":"…"}`;新增 CreateSessionRequest /
  CreateSessionResponse.personaId / SessionSummaryDto.personaId。
- 新增**用户侧**角色目录取数:PersonaApi + UserPersonaDto + PersonaRepository(沿用 apiCall/Result)。
  刻意不用 admin 的 PersonaSummaryDto/AdminApi:用户侧不下发 admin 专属 thinking,admin 端点还要管理档。
- 抽屉「+」不再直接建会话,而是先开角色弹层(「默认角色」+ 各角色,角色名 + character 简述,
  失败/加载中各自有话说);确认后按所选创建。角色表拿不到时降级为「只有默认角色」,不挡建会话。
- 会话行摘要前缀该会话绑定的角色名:能拿到角色表就显示名字,拿不到就原样显示 id,未绑就不显示。

为什么:
- 此前 createSession 完全没有 body,新建的会话一律是默认角色,后端 v0.42 起的多角色能力用不上。
- 会话角色**创建后不可变**(`PATCH /sessions/{id}` 一旦收到 personaId 就 400),所以选角这一步
  只在建会话时问一次,也必须在建会话时问。

测试 353 → 385(+32),assembleDebug 与 testDebugUnitTest 均通过。
变异验证:把仓库改回「null 也发 body」后,SessionRepositoryCreateTest 有 2 条断言 FAIL。
2026-10-04 18:06:11 +08:00
AskaEth ae4e4e53f6 feat(share): 接入系统分享入口 —— 分享文本 / 图片进会话(未登录先暂存,登录后落地)
主人 2026-10-04 提出的「分享到 App」的客户端半边。触发面就是系统分享菜单的
ACTION_SEND / ACTION_SEND_MULTIPLE;发送侧**完全复用既有路径**(短文本 WS、
长文本与图片 REST、大图先 /upload),不另起一套。

- Manifest:MainActivity 加 SEND / SEND_MULTIPLE 的 intent-filter,MIME 只声明
  text/plain 与 image/*。刻意不声明 */*:对话路径上今天没有「文件」这个表示
  (协同 02/F1 三缺),声明 */* 等于给出一个点进去必然失败的入口;收窄之后非图片
  文件干脆不进分享菜单。另加 launchMode=singleTop 与 label(分享菜单里显示 Cyrene,
  图标沿用应用启动图标,不另设一份会漂移的来源)。
- MainActivity:冷启动(onCreate)与热启动(onNewIntent + setIntent)两条路都接。
  冷启动只在 savedInstanceState==null 时接 intent —— Activity 因旋转 / 深色切换重建
  时系统会把同一个 intent 再送一遍,照单全收就会把同一条分享发第二次。
- 新增 domain/share(纯逻辑,可单测):
  - ShareRules.extract:文本 / 单图 / 多图 / 空内容 / 只有 MIME 没有内容 /
    EXTRA_STREAM 缺席时回落到 ClipData;去重与空白条目在**截断之前**处理
    (重复项既不该挤掉真图,也不该被算成「截掉了」)。一次最多接 4 张,超出的
    条数如实计数,不静默丢。正文一个字都不加(来源标记走独立 RoleSystem 消息 +
    SenderName,拼进正文即违反红线,见 02/F4)。
  - ShareRules.cacheFileName:文件名只由本端 token + 序号 + MIME 白名单扩展名构成,
    外部 URI 的路径 / 显示名绝不参与(纯函数里连本端 token 都不信,非法字符折成 _)。
  - PendingShareHolder:待落地内容的队列,take() 是原子出队 —— 一条内容只能被消费
    一次(消费它的地方都会被重建:重组、切会话、Activity 重建),同时不把先后两条
    挤在同一窗口的那次分享丢掉。
- 新增 app/ShareIntake:进程级单例(不塞 AppContainer,避免碰别的并行改动)。
  图片在收到的那一刻就拷进 cacheDir 并换成**本端 file://**(content:// 的读授权活不过
  登录 / 切页,拷不动的那几张如实计入「未发送」);进程启动清一次缓存目录
  (那份副本只对产生它的进程有意义),运行期间不回收 —— 发送失败回滚到输入框之后
  仍要读它。
- ChatViewModel:sendShared 与输入框的 send 共用 emitAndDeliver(同一套乐观气泡 /
  幂等键 / 回滚),差别只有「不动用户草稿」(分享不是用户按的发送键)。一条消息只能
  带一张图 ⇒ N 张图 = N 条消息,第一条带分享的正文;顺序在同一个协程里串起来。
  长文本不截断,由既有 ChatSendRouting 判据改走 REST(WS 帧 4096 字节是断连不是报错)。
- ChatScreen:仅当**当前会话**的组合存在时认领(已访问过的会话 VM 都还活着,UI 层
  认证才不会让旧会话抢走),落地前先等 WS 连接、上限 3 秒(冷启动必然撞上握手;超时
  也照发,让「没网」退化成内容回到输入框这种看得见的结果,而不是停在看不见的地方)。
  截断 / 读不到 / 空内容用 shareNotice 如实告知(8 秒后自动收走),与 error 分开。
- 测试 +23(ShareExtractionTest 18 / PendingShareHolderTest 5):文本、单图、多图、
  空、只有 MIME、ClipData 兜底与去重、截断计数、超大文本不截断且落 REST、
  文件名不可穿越、原子出队(并发 64 取 64 条不重不漏)。assembleDebug 绿,
  testDebugUnitTest 353 → 376 全绿(failures/errors 皆 0)。
2026-10-04 18:05:27 +08:00
AskaEth 664fd8dcef merge: W3 — B1 /upload(大图走上传,历史只留 URL) 2026-10-04 17:44:23 +08:00
AskaEth b44e8d96b9 merge: W2 — A2 interrupt scope(传输层 + 设置项) 2026-10-04 17:44:21 +08:00
AskaEth 51ea890684 merge: W1 — D4 未读(抽屉角标 + 已读游标) 2026-10-04 17:44:21 +08:00
AskaEth f413f8db55 feat(sessions): 接上 D4 未读 —— 抽屉角标 + 已读游标(进入即读、看着就点已读)
后端 v0.37 起 `GET /sessions` 每条会话恒下发 `unreadCount`、`POST /sessions/{id}/read`
可推进**账号级**已读游标(只前进,`MAX(read_at, before)`)、成功后广播 `session_read`,
客户端一直没接。本次补齐:列表能看见未读、看完真的会消、别的端读了本端也消。

- data/api/dto/SessionDto.kt:`SessionSummaryDto.unreadCount`(**默认 0**——
  `SessionPreferences` 把整份列表原样持久化,非默认值会让升级后第一次启动整份快照
  解码失败、列表先空一下;默认 0 只会让角标少显示一会儿);
  新增 `ReadSessionRequest`(`before` 缺席 = 现在,`explicitNulls=false` 保证序列化成
  `{}`,正是服务端接受的形状)与 `ReadSessionResponse`
- data/api/SessionsApi.kt + data/repository/SessionRepository.kt:`markRead(sessionId, before)`
  —— 不传 0(服务端对 0/负数回 400),null 即「现在」
- data/ws/WsEvent.kt:新增 `SessionRead`(`{type:"session_read", sessionId, data:{readAt}}`)。
  会话归属**只在帧壳**上(data 里没有 sessionId);`readAt` 缺席回落 0,而 0 在上层是
  「无从判断」——坏帧因此天然退化成什么都不改
- ui/sessions/SessionsViewModel.kt:`SessionItem` 多出 `unreadCount` 与 `lastMessageAt`
  (后者只用于游标比对);两条调和规则方向相反且都写成纯函数——
  `withReadCursor(readAt)` **有依据**地清(游标只前进,本地最新消息不比游标新才算真已读;
  若有更新的消息则保留旧计数,不能把真实未读藏起来),`markRead(id)` 无条件清(用户就在这一行)
- 已读策略:① `select()`(点开 = 进入)以「现在」推进——聊天页紧接着会加载并显示最新一页,
  比推本地可能陈旧的 `lastMessage.createdAt` 更诚实,且先就地清零,角标不等网络往返;
  ② `message_persisted` 且会话是**当前打开**的那一个时,以该消息 `createdAt` 推进
  (只标它及其之前,同时落库的更晚消息仍算未读),否则「自己正看着的会话」会攒出角标。
  不该标的绝不乱标:别的会话攒未读是正确行为,本端不做「替服务端数未读」(增量会漂移,
  抽屉每次打开都会静默重拉,以服务端为准)。失败**静默**——服务端游标是唯一真源
- `session_read` 帧按帧里的会话 id **逐行**定位(刻意不用 `isForSession`:那道闸是给
  「一个 VM 只关心一个会话」的 ChatViewModel 用的,本 VM 持有整个列表,按 activeSessionId
  过滤会把「别的会话被读了」这一帧丢掉,那正是最该处理的一半)
- 另记一份本地已知游标(只取服务端口径:read 响应的 readAt / 帧的 readAt / 落库消息的
  createdAt),在整表替换时把「本地已知没有更新消息」的行压回 0——否则一次竞态中的
  GET(快照早于本端刚发起的 read)会把角标闪回来;登出时随快照一起清(游标是账号级的)
- ui/sessions/SessionDrawerContent.kt:角标用 Miuix 自带 `Badge`(错误色药丸 + onError 文字),
  且**放在标题行里**而不是 `endActions`:抽屉固定 300dp,卡片与内边距之后只剩约 244dp,
  `endActions` 已有三个 40dp 图标(120dp)且被库内 60% 硬上限卡着,再塞角标会把图标挤到裁剪。
  改成标题行的 `[标题][角标]` 后角标不占额外列宽;标题/摘要同时收紧为 `maxLines = 1` +
  省略号(长标题不再在约 116dp 里折成两三行把卡片撑高);`99+` 收敛、`0` 不画

测试:305 全绿(272 → +33):角标文本(0/负数不画、99 精确、100 与 1234 收敛 `99+`)、
两条调和规则的各种边界(游标等于/新于/旧于最新消息、无消息会话、坏帧 readAt=0、
按会话逐行定位、遮罩只压记过游标的行)、`toItem` 映射、`session_read` 帧解码
(生产者逐字取自后端 `gateway/session_read.go:broadcastSessionRead`)、DTO 真载荷
(`GET /sessions` 抓包里主会话 unreadCount=6、次级=0)与**旧快照无该键仍解得开**、
read 请求体序列化(`{}` / `{"before":…}`)。

未验证(无模拟器,也不许碰 adb,只有构建 + 单测):
- 角标与标题的**实际观感**没截图。宽度是按 Miuix 0.9.3 源码算的(抽屉 300dp − 卡片边距 24
  − 组件内边距 32 = 244dp;endActions 120dp < 60% 上限,标题列约 116dp,角标约 16~32dp)。
  LazyColumn 里角标与标题的具体落位(紧贴标题还是行尾)取决于 Row 的权重分配细节,两种都成立、
  都不遮蔽文字,但真机上可能想微调间距
- 「进入即读」在 UI 上没端到端跑过:POST /sessions/{id}/read 是否 200、`session_read` 帧
  是否真的清掉另一端的角标,只有单测与契约(后端源码)支撑,没有真机往返
- 已知近似:判断「正在看这个会话」只用 `activeSessionId`,而它是**活动页**的会话 id——
  用户切到设置 / 管理页时 ChatScreen 已卸载,此时落库的消息也会被标记已读(他会回来看到,
  但角标已经消了)。信息不足,如实记在这里

架构代价(需主人裁决):`session_read` / `message_persisted` 只在 WS 上,而 `AppContainer`
只提供 `createChatSocket()`(每次新建,**没有进程级共享连接**),`ChatSocket` / `ChatViewModel`
/ `AppContainer` 都在别人的改动范围里 —— 本 VM 因此自持一条连接(登录后连、登出断开、
销毁断开)。这是既有遗留问题「每 VM 一条 socket、旧 socket 不关」之上再加一条;服务端支持
同账号多连接(多端本来如此),收不到帧也只是退回「下次 GET /sessions 收敛」。共享一条
app 级连接是更好的终局,但那要动 AppContainer / ChatSocket。
2026-10-04 17:42:31 +08:00
AskaEth cc2f266564 feat(chat): 大图改走 /upload,历史里只留 URL
服务端把 `chat.image` **原样落库**,于是内联的 base64 会在**每一页历史**与**每一次
WS `history` 回放**里重传一遍(协同 G3)。B1 的 `POST /api/v1/upload` 早就有了,客户端
一直没用——本次把它接进发送路径。

- `data/api/UploadApi.kt` / `data/api/dto/UploadDto.kt` / `data/repository/UploadRepository.kt`:
  upload 三件套。multipart 字段名 `file`(Go 只认这个名,且只把带 `filename` 的部件当文件,
  已对着在线后端实测:字段名不对回 `400 {"code":"bad_request","message":"file is required"}`);
  走 `apiCall`(这台端点的状态码不承载额外语义,`413`/`415` 的区分在 `error.code` 里)。
  仓库另拦两种「服务端帮不了」的情况:`url` 为空**或不是绝对 http(s) 地址**就判解析失败
  ——视觉层只把 `http(s)://` 当 URL,裸路径会被当成 base64,照发等于把这一轮扔进黑洞。
- `app/AppContainer.kt`:按 baseUrl 缓存接线,与 chatApi 同源(上传产物 URL 必须交给同一个后端)。
- `domain/chat/ChatImageDelivery.kt`:内联 vs 上传的**唯一判据**(纯函数)。
  阈值为重编码后的 **64 KiB(含等于内联)**:内联的代价是每次历史取数再多 4/3 × B,
  64 KiB 即 +85 KB/次(20 张这种图的会话每次冷启动/重连回放就多传 ≥1.7 MB),而上传只是
  多一次往返(局域网毫秒级)——从第二次取数起就稳赚;≤64 KiB 的缩略图省下的量与一次往返同量级,
  不值得多一个失败面。阈值必须严格小于本端 1 MiB 预算,否则上传分支是死代码(有测试钉住)。
- `ui/chat/ChatViewModel.kt`:图片发送路径分流(小图内联、大图上传换 URL 再发 REST)。
  **上传失败不回退内联**:回退会把「省历史重量」当场作废、还是在用户看不见时换了一条语义不同的路;
  这里按上一步骤给可重试文案,并把图与文本还给输入框(`uploadFailureMessage`,纯函数可单测)。
- `data/image/ChatImagePreparer.kt`:产物改为同时给出 `data:` URI 与**同一份**字节,
  上传因此不必把刚编出来的东西再 base64 解一遍。

阈值依据、以及「URL 形态到底受不受 5 MiB 上限约束」的核查结论写在 `ChatImageDelivery`
与 `UploadRepository` 的注释里(结论:URL 形态**不受**解码后 5 MiB 约束——网关
`checkChatImageSize` 量的是 `image` 字符串长度,URL 才几十字符;URL 到视觉层才被取回,
上限是 `vision.maxFetchedBody` = 50 MiB。所以走上传**不是**为了绕开上限,本端 1 MiB 预算离
两个 5 MiB 上限都还差两个数量级)。

测试:309 全绿(272 → +37):上传响应/错误体的真实形状(错误体是在线抓包原文)、
multipart 线级形状(部件名、filename、字节原样)、内联阈值与两条不变量、上传失败文案。
`assembleDebug` 通过。**未实测**(本端无模拟器、且不给在线后端留测试文件):
真机上的上传成功链路与 Coil 渲染远程 URL——已静态确认 `coil-network-okhttp` 带
`META-INF/services/coil3.util.FetcherServiceLoaderTarget`(默认 ImageLoader 会自动注册网络
fetcher)、`network_security_config` 放行明文(同源 http URL 可取),但没跑过真机。
2026-10-04 17:40:12 +08:00
AskaEth b7806951eb feat(ws): interrupt 支持 scope,并按偏好选「追加队列 / 打断」(协同 A2)
契约 v0.56/A2(api-client §3.1):interrupt{sessionId, scope?},取值域 {"", "all", "current"}——省略 / 空串 / "all" 同义(取消在跑回合 + 清空待处理队列,逐字不变的旧行为),"current" = 只取消在跑回合、保留待处理队列(队列消息按编排器续跑规则继续执行,一条不丢);其余取值服务端定向回 error{bad_request}、不打断任何会话。

- WsOutbound.Interrupt 增可选 scope:null = 整键省略(服务端视同 all),默认帧因此与今天逐字节相同;传输层不校验取值、不补默认、不改写别名(开放取值域由服务端裁决,客户端替它纠正只会换来一次没解释的打断)。
- ChatSocket.sendInterrupt(sessionId, scope = null):把 scope 交给调用方从偏好取值;今天仍无调用方——「打断」触发点在聊天区(属另一轮),本 API 先把传输面备好,KDoc 如实标注。
- 新偏好 InterruptMode(DataStore cyrene_app/interrupt_mode):AppendQueue(默认,省略 scope = all)与 Interrupt(scope:"current");未知/缺省值一律解回默认档,绝不悄悄换成更激进的一档。偏好经 AppPrefs → LocalAppPrefs 下发,聊天区按既有方式读取。
- 设置页新增「她忙时发消息」分组:沿用主题那套 Miuix PreferenceCard + BasicComponent + ✓;两行文案说清唯一差别——忙时那条排队消息还在不在(看不懂效果的偏好比没有更糟)。
- 测试新增 11 条(WsOutboundInterruptTest 6 / InterruptModeTest 5):钉死默认档与旧帧逐字节相同、current 原样透传、"all"/""/未知取值都原样发出、两档不在线上打成同一种形状。工作树 assembleDebug 绿,testDebugUnitTest 283 条 0 失败(基线 272 + 新增 11)。
2026-10-04 17:36:22 +08:00
AskaEth feeb49ebeb fix(sessions): 刷新后校验当前会话是否还在,别一直指着已删会话
会话在**别处**被删掉(Web 面板 / 别的设备 / 直接调 API)时客户端无法感知:`refresh()`
成功后会整体替换 `sessions`,却**不校验 `activeSessionId` 是否还在新列表里**,
于是当前会话会一直指着一个已删会话——聊天页对着空会话,而且这个陈旧 id 会被
`prefs.setActive` 持久化下去,重启后依然是它。

本端自己的删除已经处理过(`deleteSession` 会主动切回主会话),漏的正是「别处删除」这一路。

- ui/sessions/SessionsViewModel.kt:新增纯函数 `resolveActiveSession(activeId, fetchedIds)`
  —— 不在列表里就回落主会话;**列表为空时不回落**(那是异常响应,不该拿它改用户的选择)。
  `refresh()` 成功分支调用它,值变化时同步持久化

测试:272 全绿(269 → +3):已删会话回落主会话 / 仍在列表则保留 / 空列表不覆盖用户选择。

实测:会话在别处删除后 force-stop 重开(重走 init 与刷新),
`cyrene_sessions.preferences_pb` 里 **`web:<sub>` 残留彻底消失**、`active_session_id` 回落 `web`;
界面回到主会话对话,不再出现已删会话的幽灵行。
2026-10-04 17:10:26 +08:00
AskaEth e642b0c18c feat(chat): 加 REST 发送路径 —— 长文与图片(含 clientMsgId 幂等与归一)
客户端此前**只有 WS** 发送,而 WS 帧硬上限 4096 字节、**超限是直接断连**(不是报个错),
所以长文发不出去、图完全发不了。v0.56 给 REST 流式补齐了明确响应面与 clientMsgId,
本次照它接入。

- data/api:ChatApi + ChatDto(窄请求体,不回环整条会话)+ ChatRepository
  —— **刻意不走 `apiCall`**:它把 4xx/5xx 一律折进 `Result.failure` 并丢掉状态码,
  那样 `202`(已排队)与 `409`(队满)的差别会在那一刻消失;改为映射成 ChatAck 密封结果
  (Accepted / Queued / QueueFull / Duplicate / Unavailable / Rejected)
- domain/chat/ChatSendRouting:纯函数路由 —— **UTF-8 字节数 ≤ 3584 且无图 → WS,否则 REST**。
  阈值按**字节**不按字符(中文 1 字 3 字节);预留 512 字节覆盖帧壳。短文本仍走 WS 是刻意的:
  那条路**绕开有损总线**,而超 WS 上限的代价是**断连**而非报错,两边风险不对称
- domain/chat/ChatImageBudget + data/image/ChatImagePreparer:解码 → 等比缩放 → 重编码为
  JPEG/PNG data URI;超预算自动降尺寸重来;**失败返回 null,绝不退回原图 base64**
- clientMsgId:每次发送 `"local-" + UUID`(**随机不自增**——服务端判重窗口是**整个会话历史
  且无过期清理**,进程重启后复用旧值会让那条消息永远发不出去);REST 与 WS 两路都带
- 归一:带 clientMsgId 的消息**只按 id 匹配**(文本不同也命中;匹配不到就什么都不吃,
  **绝不回落到文本**——那会吃掉一条无关气泡);不带键的老行才回落「第一条同文本」
- ack 分支:200 已受理(等 message_persisted)/ 202 已排队(出「已排队 N 条」)/
  **409 既未受理也未落库 → 回滚气泡并把文本与图片还回输入框**(不能当「已排队」,
  那会让气泡永远挂着「发送中…」)/ duplicate 不是错误(收气泡;本地没有该 messageId 就补拉一页)/
  500、503 回滚 / 400 image_too_large 换短文案
- 接下行帧 `chat_duplicate`;图片一律走 REST(`WsOutbound.Chat.image` 仍无生产者)

测试:269 全绿(218 → +51)。含真实抓包的四段响应(200 / 202 / duplicate / 400 image_too_large,
且服务端文案里的 6990572 与本端 SERVER_MAX_CHARS 做成对断言)、路由阈值边界
(1194 汉字 = 3582 字节走 WS、1195 = 3585 走 REST,并直接断言阈值处真实
`WsOutbound.Chat(...)` 序列化后 ≤ 4096 字节)、归一规则、六种 ack 映射。

真机(真后端 127.0.0.1:10942):**图片全链路通过** —— 选图 → 重编码(源 7.8 KB PNG →
1 KB JPEG,magic ffd8ffe0)→ `POST /api/v1/chat`(1558 字节体)→
`200 {"data":{"stream":true}}` → 气泡由「发送中…」归一为「来自 安卓」→ 昔涟回复**准确描述了那张图**。
另 WS 短文本路径未回归(无 POST,且 clientMsgId 被 v0.56 原样回填)。

未验证:`409 chat_busy` 与 `500` 非真实抓包(要填满主人后端的待处理队列、等于真跑 5 个回合,
判断不值得;形状与文案逐字取自后端源码,测试注释里已写明);UI 上的 202/409 未端到端跑过;
降尺寸「减半重来」那步未在设备上跑过(测试图 64×64 只走到重编码)。
2026-10-04 16:57:22 +08:00
AskaEth 4c14028c70 fix(tools): 接上 tool_execution 三事件,并改用 id 关联工具卡
客户端此前**只映射了 `tool_execution.update`**——恰好是后端**没有生产者**的那一帧
(后端回执亦确认:`.start`/`.end` 有真实生产者,`.update` 运行期可能永不来)。
真正会发出的 `.start` / `.end` 落进 `else -> null` 被**静默丢弃**,所以
**工具执行状态从未到过界面**:工具卡能显示调用与参数,却永远不显示「执行中 / 完成 / 失败」。

后端 v0.50 已把 `tool_execution` 拆成三帧、载荷为 `{tool, phase, id?}`——刻意**不带
`index` / `streamId`**(WS bridge 注释明写「tool_execution.* 不属于某一次模型流」),
关联键是 **`id`**(与同轮 `tool_call.*` 同一个 provider 调用 id)。所以旧实现按 `index`
归属本身就是错的。

- data/ws/WsEvent.kt:三个事件类型承载 `tool` / `phase` / 可空 `id` / `delta`(仅 update);
  新增私有 `ToolExecutionData`(**无 `index`**);三个帧名都进 `dispatchStreaming`
- ui/chat/ChatViewModel.kt:`ToolPhase { Running, Done, Error }` + `toolPhaseOf`;
  `ToolCallState` 扩 `tool` 与可空 `phase`;新增两个纯函数
  `toolExecutionTargetIndex` / `applyToolExecution`;三帧经既有 `isForSession` 会话闸
- ui/chat/StreamingBlocks.kt:工具卡标题与配色由 `phase` 驱动(`执行 <tool>…` /
  `工具调用 · <tool>` / `工具调用失败 · <tool>`,失败用 error 色)

关联策略是「**不确定就不挂**」,绝不默认 0:`id` 非空 → 精确匹配,找不到返回 -1
(宁可不挂也不挂错);`id` 缺失 → 仅当「恰好一张卡 id 为空」或「整个列表只有一张卡」
才挂,否则 -1。`applyToolExecution` 不建卡、不动别的卡;`tool` 空白保留已有名字、
`phase` 无法识别保留已有阶段、`delta` 为空不改输出。`.update` 那条路也实现并测了。

测试:218 全绿(194 → +24)。新增 10 条三帧解码(按真实 `toolExecutionData` 形状:
id 有/无、phase running/done/error、update 带/不带 delta、多余 `streamId` 被忽略、
旧单帧形状可容忍、会话闸)+ 14 条关联规则与 phase/name/delta 应用。

实机:只读 WS 探针抓到真实帧
`tool_execution.start {"id":"call_00_o9Q…","phase":"running","tool":"calculator"}` 与
`.end {…,"phase":"done","tool":"calculator"}`,`tool_call.*` 共享同一 id、执行帧确实
没有 `index`/`streamId`;屏幕录制抓到渲染出的「工具调用 · calculator」——工具名只可能
来自 `tool_execution.*`,故端到端成立。全程只见 `.start`/`.end`,印证 `.update` 无生产者。

未覆盖:`phase:"running"`(内置工具太快)与 `phase:"error"`(无法诱发)的瞬时态,
仅单测覆盖。
2026-10-04 16:28:22 +08:00
AskaEth d12a920096 fix(ws): 流式帧按 sessionId 收敛,修跨会话串台并退役 A1/D14
服务端从 v0.36 起就在结构化流帧(message.* / thinking.* / tool_call.* /
tool_execution.*)上带了 sessionId + turnId,而本端 WsEvent.parse 把这两个字段
**丢掉了**——所以「同一时刻只有一个会话在流式」这条长期约束(A1/D14)其实是
客户端自己造成的,不是后端限制。

而广播是发给**每条已认证连接**、不按会话订阅;每个 ChatScreen 的 VM 又各建一条
ChatSocket,切会话后旧 socket 与旧 VM 都还活着——于是旧 VM 会拿新会话的 delta
跑自己的流式状态机,思维链/工具卡/正文全线串台。

- data/ws/WsEvent.kt:基类开 open val sessionId(子类按帧壳覆盖),透传到 10 个
  结构化事件类型;新增纯函数 isForSession()——**只排除「明确属于别的会话」的帧**,
  空值视为「无法判定」而放行(与 historyForSession 对空值的处理一致)
- ui/chat/ChatViewModel.kt:onEvent 顶部加一道会话闸;history 帧**刻意豁免**,
  它的归属由帧内每条消息的 sessionId 判定(比帧壳更细,即 2a49c25 的语义)
- data/ws/ChatSocket.kt:改掉那段描述「同一时刻只允许一个会话在流式」的过时 KDoc

顺带核实:**没有任何代码实现过 D14**(切会话时不拦截,sendInterrupt 更是零调用点),
它只是文档假设 + 解析器丢字段的副作用 → 因此 D14 退役、A1 闭环。

测试:194 全绿(178 → +16)。新增 WsEventSessionScopeTest:12 种帧类型的解码
(按真实帧壳形状)、向后兼容(缺字段 / 显式 null)、实抓的 history 帧、isForSession 规则。

未验证项:跨会话修复只由单测 + 后端自带的 ws_hub_stream_scope_test 覆盖,
没有实跑「两个会话同时流式」。
2026-10-04 14:19:24 +08:00
AskaEth 2a49c25e1b fix(chat): WS history 帧按会话过滤,修次级会话串进主会话消息
WS 首帧 history 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意
ChatViewModel;onEvent 此前把它**原样**并进当前消息列表,导致进次级会话会看到
主会话的消息混在一起——是数据串台,不是显示问题。

- data/repository/PagedMessages.kt:新增纯函数 historyForSession(messages, sessionId),
  按**消息自身的 sessionId** 过滤;sessionId 为空的帧视为「无法判定」放行,
  不因缺一个字段就整段丢掉历史(保持既有行为)
- ui/chat/ChatViewModel.kt:history 分支先收敛到本会话再合并

为什么按 sessionId 而不是「只有主会话才收 history」:前者更耐将来——
服务端哪天开始给次级会话回放,这里无需改动。

测试:178 全绿(175 → +3),新增三条:主会话回放不得泄漏进次级会话、
缺 sessionId 的帧放行、过滤不修改入参。
2026-10-04 14:05:38 +08:00
AskaEth c09a21136c feat(sessions): 会话列表用服务端标题 + 置顶 + 最后消息预览(A4/D5/B3)
列表此前显示的是本地硬生成的「会话 N」,因为后端 title 恒空。后端后来做了
「首条用户消息自动命名」+ PATCH 改名/置顶 + lastMessage 预览,本轮接入,
并把本地那套序号机器整块删掉。

- data:SessionSummaryDto 补 pinned / lastMessage(均带默认值,老快照仍能解);
  新增 UpdateSessionRequest / Response 与 @PATCH api/v1/sessions/{id}
- core/prefs:**整块退役**本地序号机器(order / KEY_ORDER / OrderSerializer /
  decodeOrder / ensureOrder / 「会话 ?」)——有了服务端标题,本地序号永远不会一致,
  且 id 没有序号时会渲染出字面的「会话 ?」
- 标题规则:服务端标题非空白即原样显示,否则兜底(主会话「与昔涟」/ 次级「新会话」)。
  主会话刻意保留「与昔涟」而不采用服务端自己的「主会话」
- 顺序**完全信任服务端**(pinned DESC, main DESC, created_at ASC),客户端不重排;
  因此置顶的次级会排在主会话之前——顺带修正 SessionDto 里「主会话恒首位」的过时注释
- 置顶:主会话与次级都可置顶(契约允许);先就地翻转拿即时反馈 → PATCH →
  成功后静默重拉(服务端才是顺序权威,快照顺带修正)→ 失败回滚并报错
- 改名:长按行打开对话框(预填当前标题 + n/80 计数),不做乐观更新(服务端会 trim 后
  回写,等静默重拉取权威值);**不提供「恢复自动命名」**——服务端无法表达(空白即 400)
- **刷新触发(前置条件,不是锦上添花)**:列表此前只在 VM 初始化 / 新建 / 删除时拉取,
  所以自动标题与预览要重启 App 才可见。现改为**打开抽屉时静默刷新**,
  且不闪「同步中…」横幅
- 顺带修同文件里两个既有缺陷:① **快照的指纹闸门其实没实现**(fingerprint 写了却从不
  比较、clear() 是死代码)→ 现按 baseUrl+username 指纹过滤,并把 clear() 接到登出
  (否则换账号后第一帧会闪上个账号的会话列表);已实测确认指纹与写入时逐字节一致,
  不会误丢老快照。② 预览剥标签时旁白与正文会粘连(看截图发现的排版瑕疵)→
  标签改替成空格再折叠空白

测试:175 全绿(153 → +22)。含真实抓取响应体的 DTO 契约测试、标题兜底与预览规则的
纯函数测试、指纹闸门测试。
2026-10-04 13:55:52 +08:00
AskaEth f5576540b4 feat(auth): 接入 /auth/refresh,不再把登录口令落盘
客户端此前把明文口令存进 EncryptedSharedPreferences,用于过期后的静默重登
(当时后端没有 refresh 端点)。后端现在有 /auth/refresh + /auth/logout 了,
本轮改为存 refresh token,口令在首次成功认证后即被抹掉。

关键约束:服务端的 refresh 是**单次使用 + 原子旋转 + 无宽限期**——同一个 token
第二次使用立刻 invalid_grant;而本端并发 401 是常态(管理概览页两个请求并发、
聊天页 REST 与 WS 同时起步)。因此:

- core/auth/TokenRefresher.kt:单飞刷新 + 代际守卫(等锁期间别人已换好令牌就直接
  复用,绝不用同一个 refresh token 打第二次)+ 终态记忆(被 invalid_grant 拒过的
  token 记下来,一次失效里只打一次注定失败的请求;Transient 不记忆以保留重试)
- 用应用层 Interceptor 而非 OkHttp Authenticator:后者对**任何** 401 都会被调用
  (含不带令牌的登录请求),且由 RetryAndFollowUp 内部循环驱动(上限 20 次跟进),
  持续 401 的令牌会打出刷新风暴。应用层拦截器只在「带了令牌仍 401」时介入,且严格
  重试一次
- 可恢复的 401 不再登出:Transient(网络/5xx)原样交回 401 并保留会话;只有
  Unrecoverable,或「换上刚签发的令牌仍 401」,才判终态
- WS 握手 401:先尝试一次续期再重连,终态才登出
- 登出补调 POST /auth/logout(此前从不调用,服务端 grant 会白活 30 天)
- 主动 skew(60s)刷新 + 兜底重试;刷新请求走不带令牌的客户端,不会递归回本路径

迁移对用户无感、绝不强制重登:启动时若「无 refresh token 但有旧口令」,用旧路径
静默登录一次换到 refresh token,随即 commit() 抹掉口令;离线失败就下次启动再试,
且不清会话。另外鉴权过期时也走一次续期——否则去掉口令会把会话砍到 24h,比改动前
更差。

HttpLoggingInterceptor 保持 BASIC 不动:/auth/refresh 与 /auth/logout 把 refresh
token 放在**请求体**里,再高一级就会把凭据打进 logcat。

测试:153 全绿(115 → +38)。含真实抓取响应体的 DTO 契约测试、TokenRefresher 的
并发/代际/终态单测、bootstrap 迁移单测,以及走真实 OkHttp + Retrofit + 回环
HttpServer 的端到端测试(证明只重试一次)。未新增任何依赖。

未验证项:主动/被动续期路径本身没能真机触发(唯一输入是加密存储里的 expires_at,
shell 写不进去;而 tokenTTL 是 24h 常量),仅由回环端到端测试覆盖。
2026-10-04 13:34:53 +08:00
AskaEth 09a3222b7b feat(enums): 接入 /meta/enums 权威枚举表,删掉客户端硬编码映射
客户端此前自带 platform / mood / mode 三张硬编码标签表,会与服务端漂移——
实测已经漂了:客户端把 `web` 显示成「Web」,权威表里是「网页」。

- data:MetaApi(GET /api/v1/meta/enums)+ MetaDto + MetaRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain/enums/EnumCatalog.kt:标签查找器,**不含任何硬编码兜底表**;
  不在表内的取值一律原样返回(api-client §1.1 的未知值回落红线);
  platformLabelOrNull 供 deviceId 前缀这类「认识/不认识要走不同回落」的调用方用;
  空 id 或空 label 一律不入表(否则角标会显示空白,或查空串凭空借到别人的标签)
- core/prefs/EnumPreferences.kt:last-good 快照,baseUrl+username 指纹闸门,
  换服务器/账号即作废,登出清空
- EnumCatalogViewModel + LocalEnumCatalog:在 CyreneRoot 注入(与 LocalAppPrefs 同套路),
  聊天角标与管理页共读同一份
- 删除 platformLabels / personaModeLabels / moodLabels;MessageSource 与 AdminFormat
  改为查表,并保留各自原有的原样回落

为什么不留硬编码兜底表:没有登录就看不到任何消息,而权威表在登录前后即会拉取,
所以 last-good 快照几乎总是存在;留着硬编码表等于把「漂移」重新引进来,而且是无声的——
旧标签和新鲜标签长得一模一样。

不转换的:trace kind/status、memory scope、thinking.mode、WS session_busy 的 mode 集合——
它们都不在 /meta/enums 内,且原本就已按原样回落。

测试:115 全绿(88 → +27:MetaDtoRealPayload 8 / EnumCatalog 7 / EnumPrefs 3 /
MessageSource 8,AdminFormatTest 改写并 +1),其中 /meta/enums 与登录响应一样
用真实抓取的响应体做夹具。
2026-10-04 13:19:34 +08:00
AskaEth 9f054aad28 fix(admin): 后台思考 conclusion 的 <p>/<action> 标记被原样显示
现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `<p></p><p></p><p>亲爱的…</p>` 和 `<action>轻轻戳了戳你</action>` 这样的
原始标记,部分记录开头还有一串空 `<p></p>`。

为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。

改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 <p></p> 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。

测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 <p>/</p>/<action>/< 或 > 任何一项),全套 88 例全绿。
2026-10-04 13:02:09 +08:00
AskaEth acff289887 test(admin): 用真实后端响应锁定管理端 DTO 契约
原先 AdminDtoDecodingTest 用的是手写的合成 JSON,等于拿「我以为的形状」自证,
证明不了真实契约。新增 AdminDtoRealPayloadTest,把 5 个管理端点的真实响应体
(登录后从本机 10942 实抓)过一遍生产用的 Json 实例。

顺带把真实数据里几个容易翻车的形状钉死:
- trace.durationNs 必须是 Long(真实 ~1.17e9 ns,超过 Int 上限就炸)——
  真实值恰好塞得进 Int,所以额外用一例 3e9 的边界值才真正证明它是 Long
- 根 span 没有 parentId 键 → null
- 未配置人格的 thinking 键完全不出现 → null(不得变成全零对象)
- persona 的 mode / memoryNamespace 是空串,而非缺键
- backthink.id 是 JSON 字符串("487"),at 是数字(Unix 毫秒)
- props / events 同一个 map 里混着数字与字符串

为什么:C-2 的 DTO 此前只经「编译通过」这一层,从未被真实响应验证过。
真实响应全部一次解出,无需改动生产代码。

测试:新增 7 例(AdminDtoRealPayloadTest),全套 81 例全绿。
2026-10-04 13:02:08 +08:00
AskaEth a7b3452e6d feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读,
不调用任何 /admin/* 写端点。

- data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain:AdminAccess.kt 为权限真源(纯函数,便于单测);
  AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析
- ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举),
  四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent
- RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档——
  viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+
  服务端 403 也渲染「无权限」态,三层 fail-closed;
  任何失败都不渲染成「暂无数据」

为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点
均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②,
凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。

测试:74 个单测全绿,其中新增 29 个
(AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
2026-10-03 21:31:09 +08:00
AskaEth 9bc45dc315 fix(ui): PreferenceCard 补 padding import 2026-09-08 23:17:06 +08:00
AskaEth 664fb2d9ed style(ui): PreferenceCard 补左右外边距 12dp 2026-09-08 23:16:23 +08:00
AskaEth cf291b58ab style(ui): 列表项加卡片容器;登出移到设置页底部;按钮改矢量图标
新增 PreferenceCard(BasicComponent 本身无背景/圆角);抽屉去掉登出、新建与删除改图标按钮;设置页返回改 ChevronBackward、底部新增登出图标。
2026-09-08 23:13:00 +08:00
AskaEth df2a26701c style(ui): 列表项改用 Miuix BasicComponent(MD3 圆角卡片)
抽屉的会话行/入口行、设置页的主题选项统一换成 BasicComponent,间距 8dp,选中态用 ✓ 标记。
2026-09-08 22:55:29 +08:00
AskaEth 00856eeaf7 fix(ui): 浅色模式系统栏图标改深色;返回键逐层回退
CyreneRoot 用 WindowInsetsControllerCompat 跟随主题切换系统栏明暗;ChatHomeScreen 为设置页与抽屉各加 BackHandler,避免直接回桌面。
2026-09-08 22:45:20 +08:00
AskaEth 61214a18fa feat(settings): C 起步——App 设置页(主题/旁白/思维链默认展开/关于)
新增 AppSettings(DataStore)与 LocalAppPrefs;CyreneRoot 按偏好决定 Miuix 主题模式并下发偏好;设置页从侧边栏进入,返回回聊天。旁白开关与思维链默认展开已接入渲染层。
2026-09-08 22:41:37 +08:00
AskaEth 91117986e4 feat(chat): 改为文本选择模式,全选仅作用于当前气泡
用 SelectionContainer 包住气泡/旁白/思维链/工具卡的正文,长按可拖选;移除长按整段复制的实现与其工具文件。
2026-09-08 22:38:28 +08:00
AskaEth 33770d87a6 feat(chat): 回车发送 + 长按气泡复制
输入框 imeAction=Send(回车即发送);长按气泡体复制原文到剪贴板并 Toast 提示。
2026-09-08 22:08:52 +08:00
AskaEth 0ea41f5815 fix(chat): 补 LaunchedEffect import 2026-09-08 21:57:43 +08:00
AskaEth 5a5b02f6fb feat(chat): 思维链保留 10 分钟并自动折叠;输入栏下方留白承载忙碌提示
流式结束后把思维链挂到落库消息上,10 分钟后自动清除;ThinkingCard 在 active 变 false 时自动折叠,历史思维链默认折叠。输入栏底部预留 22dp 避开屏幕圆角,顶栏副标题只留连接状态,忙碌提示移到输入栏下方(带左缩进)。
2026-09-08 21:56:55 +08:00
AskaEth 5975827aea fix(chat): Column 同时传 content 与尾随 lambda 导致编译失败 2026-09-08 21:50:54 +08:00
AskaEth 7faa604fd0 feat(chat): M4 思维链卡片 + 工具卡 + 图片
thinking.*/tool_call.*/tool_execution.update 累积进流式状态并渲染为可折叠卡片(思维链默认展开);历史消息的 thinking(来自 REST /messages)同样展示;image 用 Coil 3 加载。
2026-09-08 21:48:40 +08:00
AskaEth 5ac0ea3c3b fix(chat): 输入法弹出时输入框被顶到顶部
窗口默认 adjustUnspecified 会先做 pan,叠加 imePadding 后高度被算两次;改为 adjustResize。
2026-09-08 21:42:36 +08:00
AskaEth 5d71c6a2ab fix(ui): 移除聊天页顶栏登出按钮;抽屉内容避开系统栏
登出已收进侧边栏;抽屉面板保留全高背景,内容用 systemBars inset 避开状态栏/导航栏。
2026-09-08 21:34:10 +08:00
AskaEth e72621db94 feat(sessions): M5 侧边栏抽屉 + 会话列表(切换/新建/删除)
自研 MiuixDrawer(draggable+Animatable,关闭态不拦截触摸);DataStore 持久化会话快照/序号映射/当前会话;SessionsViewModel 先快照后刷新;次级会话删除二次确认,主会话禁删。
2026-09-08 21:29:24 +08:00
AskaEth e04995a84d fix(chat): 新消息自动跟随底部(微信/QQ 式)
reverseLayout 会锚定原可见项,导致新消息插到视口外。改为:滚动结束时记录是否贴底(容差 64px),最新一项变化且处于跟随状态时 animateScrollToItem(0);用户上滑查看历史时不再强制滚动。
2026-09-08 21:16:49 +08:00
AskaEth 67ed0479e4 feat(chat): M3 发送与流式渲染
输入框 + 发送(WS chat 上行,deviceId=voiceassist:cyrene:v1);乐观消息先上屏,message_persisted 到达后按文本归一;message.start/delta/end + stream_end 走流式增量解析,末尾带光标;发送失败回滚文本到输入框。
2026-09-08 21:09:37 +08:00
AskaEth a8a422c4e1 fix(chat): 来源角标改挂到消息组末尾
角标应紧随它标注的那条消息(主人右下、昔涟左下),放在上方会显得在标注下一条。
2026-09-08 21:06:42 +08:00
AskaEth 6323f89b5b fix(chat): 气泡间距 6dp;来源文案按角色区分「来自 / 发送至」
主人发的显示「来自 X」,昔涟发的显示「发送至 X」。
2026-09-08 21:04:32 +08:00
AskaEth 01b5f980a5 fix(chat): 来源角标移到消息组顶部;IM deviceId 显示友好平台名
角标挂底部会视觉贴到下一条消息上;微信等 IM 的 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名显示「来自微信」。
2026-09-08 21:03:33 +08:00
AskaEth 63fe9a12a6 feat(chat): 接入 <p> 多气泡与 <action>/<narration> 旁白渲染
chat 模式消息按 <p> 拆成多个气泡;<action>/<narration> 解析为灰斜体小字依附气泡上/下;来源角标只在最后一条气泡显示一次。非 chat 模式原样渲染。
2026-09-08 21:01:48 +08:00
AskaEth 171f6ff59a fix(net): 放行明文 HTTP,修复自部署地址报「网络不可用」
Android 9+ 默认禁止 cleartext,OkHttp 抛 UnknownServiceException 被映射为无网。新增 network_security_config.xml(放行 cleartext + 信任用户 CA),并给该异常单独的提示文案。
2026-09-08 20:56:06 +08:00
AskaEth 0587f5a45f chore: devdocs 移出版本控制
设计/协作文档只保留在本地,不再随仓库分发。
2026-09-08 20:50:40 +08:00
AskaEth 253913ad82 chore: 机器特定的构建配置移出仓库
aapt2FromMavenOverride(ARM64 专用绝对路径)与 sdkDownload 移到本机 ~/.gradle/gradle.properties,仓库配置保持跨平台可用。
2026-09-08 20:49:00 +08:00
AskaEth 84de51c7b7 feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)
M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
2026-09-08 20:40:58 +08:00
411 changed files with 62508 additions and 9772 deletions

No files matched your search

+12 -30
View File
@@ -1,36 +1,18 @@
# ---> Android *.iml
# Gradle files
.gradle/ .gradle/
build/ build/
.kotlin/
# Local configuration file (sdk path, etc) # 本机开发凭据(主人授权落地的测试用 key 等)——只在本机,绝不进仓库
dev-secrets.local.md
local.properties local.properties
.idea/
# Log/OS Files .DS_Store
*.log
# Android Studio generated files and folders
captures/ captures/
.externalNativeBuild/ .externalNativeBuild/
.cxx/ .cxx/
*.apk # 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
output.json /IMG_*.png
# 开发文档 —— **主人 2026-10-07 明确指令:整个文件夹禁止进入远程仓库**。
# IntelliJ # 它只在本机磁盘上(前后端协同留言板、进度交接、设计拆解都在里面),靠磁盘本身交接。
*.iml # 凭据同样绝不写进去(那类东西在 dev-secrets.local.md)。
.idea/ devdocs/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
keystore.properties
-73
View File
@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 AskaEth
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-182
View File
@@ -1,182 +0,0 @@
# Cyrene for Android
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
## 概述
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
- **随时对话** — 通过文字或语音与昔涟交流
- **IoT 控制** — 远程操控家中的智能设备
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
## 开发状态
> **当前阶段**:项目初始化,尚未开始正式开发。
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。
## 技术栈 (规划)
| 层 | 技术 |
|----|------|
| 语言 | Kotlin |
| UI 框架 | Jetpack Compose + Material Design 3 |
| 架构 | MVVM + Repository |
| 网络 | OkHttp / Retrofit + WebSocket |
| 本地存储 | Room (SQLite) + DataStore |
| 推送 | FCM (Firebase Cloud Messaging) |
| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
| 构建 | Gradle (Kotlin DSL) |
## 功能路线图
- [ ] 用户认证与登录
- [ ] 实时文字对话 (WebSocket)
- [ ] VoiceInteractionService 注册(替换系统语音助手)
- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
- [ ] 语音识别 (STT)
- [ ] 语音合成 (TTS)
- [ ] IoT 设备控制面板
- [ ] 推送通知 (FCM)
- [ ] 后台连接与通知
- [ ] 锁屏 / 息屏语音交互
- [ ] PWA 保底方案 (短期替代)
## 交互模式
APP 有两种界面呈现方式,根据启动来源自动切换:
| 启动方式 | 界面模式 | 说明 |
|---------|---------|------|
| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
悬浮窗模式的要点:
- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
- 对话结束后窗口收起,用户回到触发前的界面
- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
## 设计规范
- **设计语言**:Material Design 3 (Material You)
- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
- **组件**:全面使用 `androidx.compose.material3` 组件库
- **图标**:Material Icons + 自定义图标
- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements)
- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
## 项目结构 (规划)
```
android/
├── app/
│ ├── src/main/
│ │ ├── java/com/cyrene/app/
│ │ │ ├── ui/ # Compose UI 层
│ │ │ ├── viewmodel/ # ViewModel 层
│ │ │ ├── repository/ # 数据仓库层
│ │ │ ├── data/ # 数据模型 & API 接口
│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
│ │ │ └── di/ # 依赖注入
│ │ └── res/ # 资源文件
│ └── build.gradle.kts
├── build.gradle.kts
├── settings.gradle.kts
└── gradle.properties
```
## 环境要求
- Android Studio Hedgehog (2023.1.1) 或更高版本
- Kotlin 2.0+
- JDK 17+
- Android SDK (target: 34+, min: 26+)
## 网络配置(中国大陆用户)
Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
### Gradle Wrapper
`gradle/wrapper/gradle-wrapper.properties`:
```properties
# 将 services.gradle.org 替换为腾讯/阿里镜像
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
```
### 仓库镜像
`settings.gradle.kts` 顶部添加:
```kotlin
pluginManagement {
repositories {
maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
google()
mavenCentral()
}
}
```
### Gradle 属性
`gradle.properties`:
```properties
# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
# systemProp.http.proxyHost=127.0.0.1
# systemProp.http.proxyPort=7890
# systemProp.https.proxyHost=127.0.0.1
# systemProp.https.proxyPort=7890
```
### Android SDK Proxy
若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。
## 快速开始
> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
1. 用 Android Studio 打开 `android/` 目录
2. 按上述"网络配置"替换国内镜像
3. 等待 Gradle 同步完成
4. 启动主项目后端服务(参考[主项目 README](../README.md))
5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址
6. 选择模拟器或设备,点击 Run
## 相关链接
- [Cyrene 主项目](../) — 后端服务、前端、部署文档
- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
## License
Apache-2.0
+36 -75
View File
@@ -1,58 +1,30 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
} }
android { android {
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
namespace = "top.yeij.cyrene" namespace = "top.yeij.cyrene"
compileSdk = 36 compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig { defaultConfig {
applicationId = "top.yeij.cyrene" applicationId = "top.yeij.cyrene"
minSdk = 26 minSdk = 33
targetSdk = 36 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
if (keystorePropertiesFile.exists()) { isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro",
) )
} }
debug {
isMinifyEnabled = false
}
} }
compileOptions { compileOptions {
@@ -60,58 +32,47 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
} }
} }
dependencies { dependencies {
// Compose BOM implementation(libs.androidx.core.ktx)
val composeBom = platform(libs.compose.bom) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(composeBom) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.compose.ui) implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
debugImplementation(libs.compose.ui.tooling)
// Activity & Navigation implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.activity) implementation(libs.androidx.compose.ui)
implementation(libs.compose.navigation) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
// Lifecycle implementation(libs.miuix.ui)
implementation(libs.lifecycle.runtime) implementation(libs.miuix.icons)
implementation(libs.lifecycle.viewmodel) implementation(libs.miuix.preference)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Network
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.gson) implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// Koin DI debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.koin.android)
implementation(libs.koin.compose)
// DataStore testImplementation(libs.junit)
implementation(libs.datastore)
// Coroutines
implementation(libs.coroutines)
// Core
implementation(libs.core.ktx)
// Biometric
implementation(libs.biometric)
} }
+9 -26
View File
@@ -1,27 +1,10 @@
# Cyrene ProGuard Rules # kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
# Retrofit -keepclassmembers class **$$serializer {
-keepattributes Signature *** descriptor;
-keepattributes *Annotation* }
-keep class top.yeij.cyrene.data.remote.dto.** { *; } -keepclasseswithmembers class ** {
-dontwarn retrofit2.** kotlinx.serialization.KSerializer serializer(...);
-keep class retrofit2.** { *; } }
# Gson
-keep class com.google.gson.** { *; }
-keepattributes EnclosingMethod
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Room
-keep class * extends androidx.room.RoomDatabase
-dontwarn androidx.room.paging.**
# Koin
-keep class org.koin.** { *; }
# Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+84 -64
View File
@@ -1,89 +1,94 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 网络 --> <!--
<uses-permission android:name="android.permission.INTERNET" /> 「后台保持连接」用到的三个权限(设置页那个开关,默认关):
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 语音助手核心 --> - FOREGROUND_SERVICE:起前台服务本身。
<uses-permission android:name="android.permission.RECORD_AUDIO" /> - FOREGROUND_SERVICE_DATA_SYNC:前台服务的类型权限(API 34+ 与 android:foregroundServiceType
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> 配合)。选 dataSync 是因为这条服务保的是一条**长连接**——保持与后端的实时同步,
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" /> 既不是 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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 锁屏交互 --> <!--
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> 「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
<!-- 查询其他应用(检查默认助手设置) --> 它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
<queries> 被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
<intent> 改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
<action android:name="android.service.voice.VoiceInteractionService" /> 没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
</intent> -->
</queries> <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 <application
android:name=".CyreneApplication" android:name=".app.CyreneApplication"
android:allowBackup="true" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Cyrene" android:theme="@style/Theme.Cyrene">
android:usesCleartextTraffic="true">
<!-- 全屏主界面 --> <!--
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
两个实例各持一套 ViewModel / socket。
-->
<activity <activity
android:name=".MainActivity" android:name=".app.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:label="@string/app_name"
android:theme="@style/Theme.Cyrene"> android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!--
分享入口:只声明本端真能落地的两类 MIME(纯文本 / 图片)。
刻意不声明 */*——对话路径上今天没有「文件」这个表示(协同文档 02/F1 的三缺),
声明了就等于给出一个点进去必然失败的入口。标签沿用应用名(活动不设自己的
label / icon 时,分享菜单取的就是 Cyrene 的名字与启动图标)。
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- 多图分享只收图片:多条纯文本的 EXTRA_TEXT(SEND_MULTIPLE + text/plain)本端不接。 -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity> </activity>
<!-- VoiceInteractionService:系统语音助手 --> <!--
<service 附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
android:name=".service.CyreneVoiceInteractionService" 系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
android:exported="true" 会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
android:permission="android.permission.BIND_VOICE_INTERACTION"> 路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
<meta-data -->
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_config" />
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
</service>
<!-- VoiceInteractionSessionService -->
<service
android:name=".service.CyreneSessionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION" />
<!-- AccessibilityService:读取屏幕内容 -->
<service
android:name=".service.CyreneAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
<!-- FileProvider:日志分享 -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -94,6 +99,21 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </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> </application>
</manifest> </manifest>
@@ -1,100 +0,0 @@
package top.yeij.cyrene
import android.app.Activity
import android.app.Application
import android.os.Bundle
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.GlobalContext
import org.koin.core.context.startKoin
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.di.appModule
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.concurrent.atomic.AtomicInteger
class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activityCount = AtomicInteger(0)
@Volatile
private var notificationHelper: NotificationHelper? = null
override fun onCreate() {
super.onCreate()
RuntimeLog.general("app", "Application onCreate")
startKoin {
androidContext(this@CyreneApplication)
modules(appModule)
}
// Track foreground/background state
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) {
if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground")
notificationHelper?.cancelAll()
getRepo()?.onAppForeground()
}
}
override fun onActivityStopped(activity: Activity) {
if (activityCount.decrementAndGet() == 0) {
RuntimeLog.general("app", "App in background")
getRepo()?.onAppBackground()
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
// Set up background notification callback once Koin is ready
initScope.launch {
val helper = NotificationHelper(this@CyreneApplication)
notificationHelper = helper
val repo = getRepo()
repo?.setNotificationCallback { message ->
helper.showMessageNotification(message)
}
}
initScope.launch {
val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
val urlInterceptor: DynamicUrlInterceptor = koin.get()
val authInterceptor: AuthInterceptor = koin.get()
prefs.baseUrl.firstOrNull()?.let { url ->
if (url.isNotBlank()) urlInterceptor.baseUrl = url
}
prefs.token.firstOrNull()?.let { token ->
authInterceptor.token = token
}
}
}
private fun getRepo(): ChatRepositoryImpl? {
return try {
GlobalContext.get().get()
} catch (_: Exception) {
null
}
}
companion object {
private const val TAG = "CyreneApp"
}
}
@@ -1,78 +0,0 @@
package top.yeij.cyrene
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.navigation.compose.rememberNavController
import org.koin.compose.koinInject
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.service.CyreneVoiceInteractionService
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
import top.yeij.cyrene.ui.navigation.Routes
import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants
class MainActivity : ComponentActivity() {
private val isDefaultAssistant = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
isDefaultAssistant.value = checkIsDefaultAssistant()
setContent {
val prefs: PreferencesDataStore = koinInject()
val themeMode by prefs.themeMode.collectAsState(initial = null)
val darkTheme = when (themeMode) {
"light" -> false
"dark" -> true
else -> isSystemInDarkTheme()
}
CyreneTheme(darkTheme = darkTheme) {
val navController = rememberNavController()
CyreneNavGraph(
navController = navController,
startDestination = Routes.MAIN,
isDefaultAssistant = isDefaultAssistant.value,
onOpenAssistantSettings = { openAssistantSettings() },
)
}
}
}
override fun onResume() {
super.onResume()
isDefaultAssistant.value = checkIsDefaultAssistant()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
private fun checkIsDefaultAssistant(): Boolean {
// Standard Android check
val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
if (current == flat) return true
// Fallback for COS and other custom OS: check persisted flag from service
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
return false
}
private fun openAssistantSettings() {
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
}
}
@@ -1,39 +0,0 @@
package top.yeij.cyrene.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import top.yeij.cyrene.data.local.dao.ConversationDao
import top.yeij.cyrene.data.local.dao.MessageDao
import top.yeij.cyrene.data.local.entity.ConversationEntity
import top.yeij.cyrene.data.local.entity.MessageEntity
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 1,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"cyrene.db",
)
.fallbackToDestructiveMigration()
.build()
.also { INSTANCE = it }
}
}
}
}
@@ -1,138 +0,0 @@
package top.yeij.cyrene.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "cyrene_prefs")
class PreferencesDataStore(private val context: Context) {
companion object {
private val KEY_TOKEN = stringPreferencesKey("jwt_token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val KEY_BASE_URL = stringPreferencesKey("base_url")
private val KEY_THEME = stringPreferencesKey("theme_mode")
private val KEY_WAKE_WORD = stringPreferencesKey("wake_word")
private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
}
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
val currentSessionId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
val dashScopeApiKey: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
val dashScopeEndpoint: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
val dashScopeModel: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
suspend fun saveToken(token: String) {
context.dataStore.edit { it[KEY_TOKEN] = token }
}
suspend fun saveRefreshToken(token: String) {
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
}
suspend fun saveBaseUrl(url: String) {
context.dataStore.edit { it[KEY_BASE_URL] = url }
}
suspend fun saveThemeMode(mode: String) {
context.dataStore.edit { it[KEY_THEME] = mode }
}
suspend fun saveWakeWord(word: String) {
context.dataStore.edit { it[KEY_WAKE_WORD] = word }
}
suspend fun saveUsername(username: String) {
context.dataStore.edit { it[KEY_USERNAME] = username }
}
suspend fun saveDashScopeApiKey(key: String) {
context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
}
suspend fun saveDashScopeEndpoint(endpoint: String) {
context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
}
suspend fun saveDashScopeModel(model: String) {
context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
}
suspend fun saveClientId(id: String) {
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
}
suspend fun saveDeviceName(name: String) {
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
}
suspend fun saveCurrentSessionId(id: String) {
context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
}
val lastClearedTimestamp: Flow<String?> = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] }
suspend fun saveLastClearedTimestamp(timestamp: Long) {
context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() }
}
// Cached profile for offline / local-first rendering
val profileUserId: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
val profileNickname: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
val profileIsAdmin: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
val profileCreatedAt: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] }
suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) {
context.dataStore.edit {
it[KEY_PROFILE_USER_ID] = userId
it[KEY_PROFILE_NICKNAME] = nickname
it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString()
it[KEY_PROFILE_CREATED_AT] = createdAt
}
}
val autoScreenContext: Flow<Boolean> = context.dataStore.data.map { it[KEY_AUTO_SCREEN_CONTEXT] ?: false }
suspend fun saveAutoScreenContext(enabled: Boolean) {
context.dataStore.edit { it[KEY_AUTO_SCREEN_CONTEXT] = enabled }
}
suspend fun clearProfileCache() {
context.dataStore.edit {
it.remove(KEY_PROFILE_USER_ID)
it.remove(KEY_PROFILE_NICKNAME)
it.remove(KEY_PROFILE_IS_ADMIN)
it.remove(KEY_PROFILE_CREATED_AT)
}
}
suspend fun clearAll() {
context.dataStore.edit { it.clear() }
}
}
@@ -1,30 +0,0 @@
package top.yeij.cyrene.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.data.local.entity.ConversationEntity
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
fun getAll(): Flow<List<ConversationEntity>>
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
suspend fun getAllSnapshot(): List<ConversationEntity>
@Query("SELECT * FROM conversations WHERE id = :id")
suspend fun getById(id: String): ConversationEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(conversation: ConversationEntity)
@Query("DELETE FROM conversations WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
}
@@ -1,33 +0,0 @@
package top.yeij.cyrene.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.data.local.entity.MessageEntity
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC")
fun getByConversation(conversationId: String): Flow<List<MessageEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
suspend fun deleteByConversation(conversationId: String)
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
suspend fun migrateConversationId(oldId: String, newId: String)
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM messages")
suspend fun deleteAll()
}
@@ -1,14 +0,0 @@
package top.yeij.cyrene.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val title: String,
val lastMessage: String?,
val lastMessageType: String?,
val updatedAt: Long,
val createdAt: Long,
)
@@ -1,27 +0,0 @@
package top.yeij.cyrene.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
foreignKeys = [
ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("conversationId")],
)
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val role: String,
val content: String,
val msgType: String,
val timestamp: Long,
)
@@ -1,62 +0,0 @@
package top.yeij.cyrene.data.remote
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest
import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.ProfileResponse
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.DeviceDto
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
import top.yeij.cyrene.data.remote.dto.SessionDto
import top.yeij.cyrene.data.remote.dto.SessionsListResponse
interface ApiService {
// Auth
@POST("api/v1/auth/login")
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
@POST("api/v1/auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<AuthResponse>
@GET("api/v1/profile")
suspend fun getProfile(): Response<ProfileResponse>
// Sessions
@GET("api/v1/sessions")
suspend fun getSessions(): Response<SessionsListResponse>
@POST("api/v1/sessions")
suspend fun createSession(@Body request: CreateSessionRequest): Response<SessionDto>
@DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") id: String): Response<Unit>
@DELETE("api/v1/sessions/{id}/messages")
suspend fun clearSessionMessages(@Path("id") sessionId: String): Response<Unit>
@GET("api/v1/sessions/{id}/messages")
suspend fun getSessionMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0,
): Response<MessagesListResponse>
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices")
suspend fun getDevices(): Response<List<DeviceDto>>
@POST("api/v1/iot/devices/{id}/control")
suspend fun controlDevice(
@Path("id") deviceId: String,
@Body request: IoTControlRequest,
): Response<DeviceDto>
}
@@ -1,21 +0,0 @@
package top.yeij.cyrene.data.remote
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor : Interceptor {
@Volatile
var token: String? = null
override fun intercept(chain: Interceptor.Chain): Response {
val request = if (!token.isNullOrEmpty()) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
@@ -1,37 +0,0 @@
package top.yeij.cyrene.data.remote
import android.util.Log
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
class DynamicUrlInterceptor : Interceptor {
companion object {
private const val TAG = "DynamicUrlInterceptor"
}
@Volatile
var baseUrl: String = "http://10.0.2.2:8080/"
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val originalUrl = originalRequest.url
val targetBase = baseUrl.trimEnd('/')
val parsed = targetBase.toHttpUrlOrNull()
if (parsed == null) {
Log.e(TAG, "Invalid baseUrl: '$baseUrl'")
return chain.proceed(chain.request())
}
val newUrl = originalUrl.newBuilder()
.scheme(parsed.scheme)
.host(parsed.host)
.port(parsed.port)
.build()
Log.d(TAG, "Rewriting ${originalUrl} → $newUrl (base: $targetBase)")
return chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
}
@@ -1,38 +0,0 @@
package top.yeij.cyrene.data.remote
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
dynamicUrlInterceptor: DynamicUrlInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.addInterceptor(dynamicUrlInterceptor)
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("http://localhost/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
@@ -1,84 +0,0 @@
package top.yeij.cyrene.data.remote
import com.google.gson.Gson
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Route
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
import android.util.Log
class TokenAuthenticator(
private val authInterceptor: AuthInterceptor,
private val preferencesDataStore: PreferencesDataStore,
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
) : Authenticator {
private val gson = Gson()
private val client = OkHttpClient()
override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
if (response.code != 401) return null
synchronized(this) {
// Double-check: if token changed while we were waiting for lock, use it
val currentToken = authInterceptor.token
val requestAuthHeader = response.request.header("Authorization")
if (requestAuthHeader != null && currentToken != null &&
requestAuthHeader != "Bearer $currentToken"
) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
// Try refresh
return try {
val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() }
?: return null
val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/')
val refreshUrl = "$baseUrl/api/v1/auth/refresh"
val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken))
val body = jsonBody.toRequestBody("application/json".toMediaType())
val refreshRequest = Request.Builder()
.url(refreshUrl)
.post(body)
.build()
val refreshResponse = client.newCall(refreshRequest).execute()
if (refreshResponse.isSuccessful) {
val authResponse = gson.fromJson(
refreshResponse.body?.string(),
AuthResponse::class.java
)
authInterceptor.token = authResponse.token
runBlocking {
preferencesDataStore.saveToken(authResponse.token)
authResponse.refreshToken?.let {
preferencesDataStore.saveRefreshToken(it)
}
}
Log.i("TokenAuthenticator", "Token refreshed successfully")
response.request.newBuilder()
.header("Authorization", "Bearer ${authResponse.token}")
.build()
} else {
Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}")
null
}
} catch (e: Exception) {
Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e)
null
}
}
}
}
@@ -1,29 +0,0 @@
package top.yeij.cyrene.data.remote.dto
import com.google.gson.annotations.SerializedName
data class AuthRequest(
@SerializedName("username") val username: String,
@SerializedName("password") val password: String,
)
data class AuthResponse(
@SerializedName("token") val token: String,
@SerializedName("refresh_token") val refreshToken: String?,
@SerializedName("username") val username: String?,
@SerializedName("nickname") val nickname: String?,
@SerializedName("user_id") val userId: String?,
@SerializedName("expires") val expires: Long? = null,
)
data class RefreshTokenRequest(
@SerializedName("refresh_token") val refreshToken: String,
)
data class ProfileResponse(
@SerializedName("user_id") val userId: String,
@SerializedName("username") val username: String,
@SerializedName("nickname") val nickname: String?,
@SerializedName("is_admin") val isAdmin: Boolean? = false,
@SerializedName("created_at") val createdAt: String? = null,
)
@@ -1,26 +0,0 @@
package top.yeij.cyrene.data.remote.dto
import com.google.gson.annotations.SerializedName
data class DeviceDto(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("type") val type: String,
@SerializedName("state") val state: DeviceState,
@SerializedName("room") val room: String?,
)
data class DeviceState(
@SerializedName("power") val power: Boolean?,
@SerializedName("brightness") val brightness: Int?,
@SerializedName("temperature") val temperature: Float?,
@SerializedName("humidity") val humidity: Float?,
@SerializedName("colorTemp") val colorTemp: Int?,
@SerializedName("locked") val locked: Boolean?,
@SerializedName("open") val open: Boolean?,
)
data class IoTControlRequest(
@SerializedName("action") val action: String,
@SerializedName("value") val value: Any? = null,
)
@@ -1,38 +0,0 @@
package top.yeij.cyrene.data.remote.dto
import com.google.gson.annotations.SerializedName
// POST /api/v1/sessions — request body
data class CreateSessionRequest(
@SerializedName("session_id") val sessionId: String? = null,
@SerializedName("title") val title: String = "新的对话",
@SerializedName("is_main") val isMain: Boolean = false,
)
// GET /api/v1/sessions — response wrapper
data class SessionsListResponse(
@SerializedName("sessions") val sessions: List<SessionDto>,
)
data class SessionDto(
@SerializedName("id") val id: String,
@SerializedName("user_id") val userId: String?,
@SerializedName("title") val title: String,
@SerializedName("is_main") val isMain: Boolean?,
@SerializedName("created_at") val createdAt: Long,
@SerializedName("updated_at") val updatedAt: Long,
)
// GET /api/v1/sessions/{id}/messages — response wrapper
data class MessagesListResponse(
@SerializedName("messages") val messages: List<SessionMessageDto>,
)
data class SessionMessageDto(
@SerializedName("id") val id: String,
@SerializedName("session_id") val sessionId: String,
@SerializedName("role") val role: String,
@SerializedName("msg_type") val msgType: String?,
@SerializedName("content") val content: String,
@SerializedName("created_at") val createdAt: Long,
)
@@ -1,86 +0,0 @@
package top.yeij.cyrene.data.remote.dto
import com.google.gson.annotations.SerializedName
// --- Client → Server ---
data class WSClientMessage(
@SerializedName("type") val type: String,
@SerializedName("session_id") val sessionId: String? = null,
@SerializedName("mode") val mode: String? = null,
@SerializedName("content") val content: String? = null,
@SerializedName("audio_data") val audioData: String? = null,
@SerializedName("attachments") val attachments: List<WSAttachment>? = null,
@SerializedName("timestamp") val timestamp: Long? = null,
@SerializedName("client_id") val clientId: String? = null,
@SerializedName("device_name") val deviceName: String? = null,
@SerializedName("user_agent") val userAgent: String? = null,
)
data class WSAttachment(
@SerializedName("type") val type: String,
@SerializedName("url") val url: String? = null,
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
@SerializedName("filename") val filename: String? = null,
@SerializedName("width") val width: Int? = null,
@SerializedName("height") val height: Int? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("description") val description: String? = null,
)
// --- Server → Client ---
data class WSClientInfo(
@SerializedName("client_id") val clientId: String? = null,
@SerializedName("device_name") val deviceName: String? = null,
)
data class WSServerMessage(
@SerializedName("type") val type: String?,
@SerializedName("message_id") val messageId: String? = null,
@SerializedName("text") val text: String? = null,
@SerializedName("content") val content: String? = null,
@SerializedName("role") val role: String? = null,
@SerializedName("msg_type") val msgType: String? = null,
@SerializedName("session_id") val sessionId: String? = null,
@SerializedName("error") val error: String? = null,
@SerializedName("review_messages") val reviewMessages: List<WSReviewMessage>? = null,
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = null,
@SerializedName("tool_progress") val toolProgress: WSToolProgress? = null,
@SerializedName("thinking_status") val thinkingStatus: String? = null,
@SerializedName("thinking_content") val thinkingContent: String? = null,
@SerializedName("timestamp") val timestamp: Long? = null,
@SerializedName("client_info") val clientInfo: WSClientInfo? = null,
)
data class WSReviewMessage(
@SerializedName("role") val role: String?,
@SerializedName("text") val text: String?,
@SerializedName("content") val content: String?,
@SerializedName("msg_type") val msgType: String?,
@SerializedName("delay_ms") val delayMs: Long? = 0,
)
data class WSHistoryMessage(
@SerializedName("id") val id: String?,
@SerializedName("role") val role: String?,
@SerializedName("content") val content: String?,
@SerializedName("msg_type") val msgType: String?,
@SerializedName("timestamp") val timestamp: Long?,
@SerializedName("name") val name: String?,
)
data class WSMultiItem(
@SerializedName("role") val role: String?,
@SerializedName("content") val content: String?,
@SerializedName("msg_type") val msgType: String?,
)
data class WSToolProgress(
@SerializedName("tool_name") val toolName: String?,
@SerializedName("status") val status: String?,
@SerializedName("detail") val detail: String?,
@SerializedName("message") val message: String?,
@SerializedName("progress") val progress: Int? = null,
)
@@ -1,50 +0,0 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.dto.AuthRequest
import top.yeij.cyrene.domain.model.AuthResult
import top.yeij.cyrene.domain.repository.AuthRepository
import kotlinx.coroutines.flow.firstOrNull
class AuthRepositoryImpl(
private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
private val authInterceptor: AuthInterceptor,
) : AuthRepository {
override suspend fun login(username: String, password: String): Result<AuthResult> {
return try {
val response = apiService.login(AuthRequest(username, password))
if (response.isSuccessful) {
val body = response.body()!!
authInterceptor.token = body.token
preferencesDataStore.saveToken(body.token)
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
preferencesDataStore.saveUsername(displayName)
Result.success(
AuthResult(
token = body.token,
refreshToken = body.refreshToken,
username = displayName,
)
)
} else {
Result.failure(Exception("登录失败: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
authInterceptor.token = null
preferencesDataStore.clearAll()
}
override suspend fun isLoggedIn(): Boolean {
return preferencesDataStore.token.firstOrNull() != null
}
}
@@ -1,692 +0,0 @@
package top.yeij.cyrene.data.repository
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.local.dao.ConversationDao
import top.yeij.cyrene.data.local.dao.MessageDao
import top.yeij.cyrene.data.local.entity.ConversationEntity
import top.yeij.cyrene.data.local.entity.MessageEntity
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID
class ChatRepositoryImpl(
private val conversationDao: ConversationDao,
private val messageDao: MessageDao,
private val webSocketService: WebSocketService,
private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
) : ChatRepository {
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
Log.e("ChatRepository", "Unhandled exception", e)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
private val _connectionState = MutableStateFlow(false)
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
override val connectionError: StateFlow<String?> = webSocketService.connectionError
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
override fun observeMessages(): Flow<Message> = _incomingMessages
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
override val messageClearEvents: Flow<Unit> = _messageClearEvents
private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
private var streamingContent = ""
private var streamingMessageId: String? = null
override var currentSessionId: String? = null
private var isAppInForeground = false
private var onBackgroundNotification: ((Message) -> Unit)? = null
private var historyRequested = false
private val notifiedMessageIds = mutableSetOf<String>()
// Duplicate suppression: track items from review/multi_message to skip wrapping response
private val recentParsedContents = mutableListOf<String>()
private var lastParsedTime = 0L
// Track last response to clean up if review/multi_message arrives after
private var lastResponseId: String? = null
private var lastResponseContent: String? = null
private var lastResponseTime = 0L
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
onBackgroundNotification = callback
}
override fun onAppForeground() {
isAppInForeground = true
notifiedMessageIds.clear()
if (!_connectionState.value) {
webSocketService.forceReconnect()
}
// Always request history on foreground to catch cross-device messages
scope.launch {
val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
requestHistoryViaWs(sid)
}
}
override fun onAppBackground() {
isAppInForeground = false
}
init {
// Restore persisted session ID, then connect and load history
scope.launch {
val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
if (!persistedSid.isNullOrBlank()) {
currentSessionId = persistedSid
}
RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
webSocketService.connect(currentSessionId)
loadConversationsFromServer()
}
scope.launch {
webSocketService.isConnected.collect { connected ->
_connectionState.value = connected
RuntimeLog.ws("connection", "Connected=$connected")
}
}
scope.launch {
webSocketService.incomingMessages.collect { wsMsg ->
try {
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
handleServerMessage(wsMsg)
} catch (e: Exception) {
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
}
}
}
}
override fun getConversations(): Flow<List<Conversation>> {
return conversationDao.getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
return messageDao.getByConversation(conversationId).map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun deleteConversation(id: String) {
conversationDao.deleteById(id)
try { apiService.deleteSession(id) } catch (_: Exception) { }
}
override suspend fun clearLocalMessages() {
val now = System.currentTimeMillis()
messageDao.deleteAll()
preferencesDataStore.saveLastClearedTimestamp(now)
// Also clear server-side messages for all known sessions
try {
val sessions = conversationDao.getAllSnapshot()
sessions.forEach { session ->
try {
apiService.clearSessionMessages(session.id)
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
} catch (_: Exception) { }
}
} catch (_: Exception) { }
_messageClearEvents.tryEmit(Unit)
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
}
private suspend fun changeSessionId(newId: String) {
val oldId = currentSessionId
if (oldId != null && oldId != newId) {
messageDao.migrateConversationId(oldId, newId)
conversationDao.deleteById(oldId)
}
currentSessionId = newId
preferencesDataStore.saveCurrentSessionId(newId)
}
override suspend fun connectWebSocket(sessionId: String?) {
currentSessionId = sessionId
webSocketService.connect(sessionId)
}
override suspend fun reconnectWebSocket() {
webSocketService.disconnect()
webSocketService.connect(currentSessionId)
}
override suspend fun ensureConnected() {
if (_connectionState.value) return
webSocketService.forceReconnect()
}
override suspend fun sendMessage(content: String, sessionId: String?) {
val messageId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val sid = sessionId ?: currentSessionId ?: "default"
if (currentSessionId == null) {
currentSessionId = sid
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
}
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
conversationDao.upsert(
ConversationEntity(
id = sid,
title = "对话",
lastMessage = content,
lastMessageType = "chat",
updatedAt = now,
createdAt = now,
)
)
messageDao.upsert(
MessageEntity(
id = messageId,
conversationId = sid,
role = "user",
content = content,
msgType = "chat",
timestamp = now,
)
)
emitMessage(
id = messageId,
sessionId = sid,
role = "user",
content = content,
msgType = "chat",
timestamp = now,
isStreaming = false,
)
webSocketService.sendMessage(content, sid)
}
override suspend fun loadConversationsFromServer() {
try {
val response = apiService.getSessions()
if (response.isSuccessful) {
response.body()?.sessions?.forEach { dto ->
conversationDao.upsert(
ConversationEntity(
id = dto.id,
title = dto.title,
lastMessage = "",
lastMessageType = "chat",
updatedAt = dto.updatedAt,
createdAt = dto.createdAt,
)
)
}
}
} catch (_: Exception) { }
}
override suspend fun sendScreenContext(content: String) {
webSocketService.sendScreenContext(content, currentSessionId)
}
override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
}
override suspend fun initializeSession(): String {
// Try to find an existing main session on the server
try {
val response = apiService.getSessions()
if (response.isSuccessful) {
val sessions = response.body()?.sessions ?: emptyList()
val mainSession = sessions.find { it.isMain == true }
if (mainSession != null) {
currentSessionId = mainSession.id
preferencesDataStore.saveCurrentSessionId(mainSession.id)
Log.i("ChatRepository", "Found main session: ${mainSession.id}")
return mainSession.id
}
}
} catch (_: Exception) { }
// No main session found on server, create one with deterministic ID
val sessionId = "session_admin_main"
try {
apiService.createSession(
CreateSessionRequest(
sessionId = sessionId,
title = "主对话",
isMain = true,
)
)
Log.i("ChatRepository", "Created main session: $sessionId")
} catch (e: Exception) {
Log.w("ChatRepository", "Failed to create main session: ${e.message}")
}
currentSessionId = sessionId
preferencesDataStore.saveCurrentSessionId(sessionId)
return sessionId
}
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
currentSessionId = sessionId
return try {
val response = apiService.getSessionMessages(sessionId)
if (response.isSuccessful) {
val messageDtos = response.body()?.messages ?: emptyList()
val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
?.toLongOrNull() ?: 0L
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
ensureConversation(sessionId)
filteredDtos.forEach { dto ->
messageDao.upsert(
MessageEntity(
id = "db_${dto.id}",
conversationId = sessionId,
role = dto.role,
content = dto.content,
msgType = dto.msgType ?: "chat",
timestamp = dto.createdAt,
)
)
}
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
filteredDtos.map { dto ->
Message(
id = "db_${dto.id}",
conversationId = sessionId,
role = dto.role,
content = dto.content,
msgType = dto.msgType ?: "chat",
timestamp = dto.createdAt,
)
}.removeWrappingDuplicates()
} else {
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
requestHistoryViaWs(sessionId)
emptyList()
}
} catch (e: Exception) {
RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
requestHistoryViaWs(sessionId)
emptyList()
}
}
private suspend fun requestHistoryViaWs(sessionId: String) {
// Wait up to 5s for WS to connect
if (!webSocketService.isConnected.value) {
withTimeoutOrNull(5000) {
webSocketService.isConnected.first { it }
}
}
webSocketService.requestHistory(sessionId)
}
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
val existing = conversationDao.getById(sessionId)
if (existing == null) {
val now = System.currentTimeMillis()
conversationDao.upsert(
ConversationEntity(
id = sessionId,
title = "对话",
lastMessage = lastMessage,
lastMessageType = "chat",
updatedAt = now,
createdAt = now,
)
)
}
}
private suspend fun handleServerMessage(wsMsg: WSServerMessage) {
when (wsMsg.type) {
"stream_start" -> {
streamingContent = ""
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
_isAssistantStreaming.value = true
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
}
"stream_chunk" -> {
val delta = wsMsg.content ?: wsMsg.text ?: return
streamingContent += delta
emitMessage(
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "assistant",
content = streamingContent,
msgType = "chat",
isStreaming = true,
)
}
"stream_end" -> {
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
streamingContent = ""
streamingMessageId = null
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid)
}
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
if (content.isNotBlank()) {
ensureConversation(sid, content)
messageDao.upsert(
MessageEntity(
id = msgId,
conversationId = sid,
role = "assistant",
content = content,
msgType = "chat",
timestamp = ts,
)
)
}
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = false
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
}
"response" -> {
val text = wsMsg.content ?: wsMsg.text ?: return
val role = wsMsg.role ?: "assistant"
val replyMsgType = wsMsg.msgType ?: "chat"
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
// Suppress response if it wraps recently emitted review/multi_message items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { text.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
return
}
}
recentParsedContents.clear()
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid)
}
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
ensureConversation(sid, text)
messageDao.upsert(
MessageEntity(
id = msgId,
conversationId = sid,
role = role,
content = text,
msgType = replyMsgType,
timestamp = ts,
)
)
lastResponseId = msgId
lastResponseContent = text
lastResponseTime = System.currentTimeMillis()
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
}
"review" -> {
recentParsedContents.clear()
wsMsg.reviewMessages?.forEach { review ->
val text = review.content ?: review.text ?: return@forEach
val role = review.role ?: "action"
val rvMsgType = review.msgType ?: review.role ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
recentParsedContents.add(text)
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
}
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
// Clean up wrapping response that arrived before this review
cleanupWrappingResponse()
}
"thinking" -> {
val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text
if (text != null) {
emitMessage(
id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "assistant",
content = text,
msgType = "thinking",
isStreaming = false,
)
}
}
"tool_progress" -> {
val detail = wsMsg.toolProgress?.message
?: wsMsg.toolProgress?.detail
?: wsMsg.toolProgress?.toolName
?: "正在执行操作..."
emitMessage(
id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = detail,
msgType = "tool_progress",
isStreaming = false,
)
}
"error" -> {
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
emitMessage(
id = "err_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = wsMsg.error ?: "未知错误",
msgType = "system_info",
isStreaming = false,
)
}
"voice_transcript" -> {
val text = wsMsg.text ?: wsMsg.content ?: return
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
ensureConversation(sid)
messageDao.upsert(
MessageEntity(
id = msgId,
conversationId = sid,
role = "user",
content = text,
msgType = "chat",
timestamp = ts,
)
)
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
}
"history_response" -> {
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid)
}
ensureConversation(sid)
val messages = wsMsg.messages ?: return
val messageList = messages.map { hist ->
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
Message(
id = msgId,
conversationId = sid,
role = hist.role ?: "system",
content = hist.content ?: "",
msgType = hist.msgType ?: "chat",
timestamp = hist.timestamp ?: System.currentTimeMillis(),
)
}
val deduped = messageList.removeWrappingDuplicates()
deduped.forEach { msg ->
messageDao.upsert(
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
)
emitMessage(
id = msg.id,
sessionId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
isStreaming = false,
shouldNotify = false,
)
}
RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
}
"multi_message" -> {
recentParsedContents.clear()
wsMsg.multiMessages?.forEach { item ->
val content = item.content ?: ""
recentParsedContents.add(content)
emitMessage(
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = item.role ?: "assistant",
content = content,
msgType = item.msgType ?: "chat",
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
isStreaming = false,
)
}
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
cleanupWrappingResponse()
}
}
}
private suspend fun cleanupWrappingResponse() {
val respId = lastResponseId ?: return
val respContent = lastResponseContent ?: return
val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
val allContained = recentParsedContents.all { respContent.contains(it) }
if (allContained) {
messageDao.deleteById(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId")
}
}
private fun emitMessage(
id: String,
sessionId: String,
role: String,
content: String,
msgType: String,
isStreaming: Boolean = false,
timestamp: Long = System.currentTimeMillis(),
shouldNotify: Boolean = false,
) {
if (content.isBlank() && msgType == "chat") return
val message = Message(
id = id,
conversationId = sessionId,
role = role,
content = content,
msgType = msgType,
timestamp = timestamp,
isStreaming = isStreaming,
)
_incomingMessages.tryEmit(message)
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
if (notifiedMessageIds.add(id)) {
onBackgroundNotification?.invoke(message)
}
}
}
/**
* Remove wrapper messages whose content contains the content of 2+ other messages.
* This handles the case where the server sends both a combined "response" and
* parsed "review"/"multi_message" items for the same turn.
*/
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
for (msg in this) {
val containedCount = count { other ->
other.id != msg.id &&
other.content.isNotBlank() &&
other.content.length < msg.content.length &&
msg.content.contains(other.content) &&
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
}
if (containedCount >= 2) {
toRemove.add(msg.id)
}
}
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
}
private fun ConversationEntity.toDomain() = Conversation(
id = id,
title = title,
lastMessage = lastMessage,
lastMessageType = lastMessageType,
updatedAt = updatedAt,
createdAt = createdAt,
)
private fun MessageEntity.toDomain() = Message(
id = id,
conversationId = conversationId,
role = role,
content = content,
msgType = msgType,
timestamp = timestamp,
)
}
@@ -1,70 +0,0 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.domain.model.Device
import top.yeij.cyrene.domain.model.DeviceState
import top.yeij.cyrene.domain.model.DeviceType
import top.yeij.cyrene.domain.repository.IoTRepository
class IoTRepositoryImpl(
private val apiService: ApiService,
private val webSocketService: top.yeij.cyrene.service.WebSocketService,
) : IoTRepository {
private val _devices = MutableStateFlow<List<Device>>(emptyList())
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
override suspend fun controlDevice(
deviceId: String,
action: String,
value: Any?,
): Result<Device> {
return try {
val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value))
if (response.isSuccessful) {
val updated = response.body()!!.toDomain()
_devices.value = _devices.value.map {
if (it.id == deviceId) updated else it
}
Result.success(updated)
} else {
Result.failure(Exception("设备控制失败: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun refreshDevices() {
try {
val response = apiService.getDevices()
if (response.isSuccessful) {
_devices.value = response.body()!!.map { it.toDomain() }
}
} catch (_: Exception) { }
}
private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device(
id = id,
name = name,
type = try {
DeviceType.valueOf(type.uppercase())
} catch (_: Exception) {
DeviceType.UNKNOWN
},
state = DeviceState(
power = state.power,
brightness = state.brightness,
temperature = state.temperature,
humidity = state.humidity,
colorTemp = state.colorTemp,
locked = state.locked,
open = state.open,
),
room = room,
)
}
@@ -1,80 +0,0 @@
package top.yeij.cyrene.di
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import top.yeij.cyrene.data.local.AppDatabase
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.remote.RetrofitClient
import top.yeij.cyrene.data.remote.TokenAuthenticator
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
import top.yeij.cyrene.domain.repository.AuthRepository
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.domain.repository.IoTRepository
import top.yeij.cyrene.domain.usecase.GetConversationsUseCase
import top.yeij.cyrene.domain.usecase.LoginUseCase
import top.yeij.cyrene.domain.usecase.SendMessageUseCase
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.IoTViewModel
import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.ProfileViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import top.yeij.cyrene.util.VoiceRecorder
import top.yeij.cyrene.voice.stt.BackendSttProvider
import top.yeij.cyrene.voice.stt.DashScopeSttService
import top.yeij.cyrene.voice.stt.SpeechRecognizer
import top.yeij.cyrene.voice.stt.SttManager
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
val appModule = module {
// DataStore
single { PreferencesDataStore(androidContext()) }
// Database
single { AppDatabase.getInstance(androidContext()) }
single { get<AppDatabase>().conversationDao() }
single { get<AppDatabase>().messageDao() }
// Network interceptors
single { AuthInterceptor() }
single { DynamicUrlInterceptor() }
single { TokenAuthenticator(get(), get(), get()) }
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
single { RetrofitClient.provideRetrofit(get()) }
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
// WebSocket
single { WebSocketService(get()) }
// Voice
single { VoiceRecorder(androidContext()) }
single { SpeechRecognizer(androidContext()) }
single { TextToSpeechEngine(androidContext()) }
single { DashScopeSttService(androidContext()) }
single { BackendSttProvider(androidContext(), get()) }
single { SttManager(get(), get(), get()) }
// Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases
factory { LoginUseCase(get()) }
factory { SendMessageUseCase(get()) }
factory { GetConversationsUseCase(get()) }
// ViewModels
viewModel { ChatViewModel(get(), get()) }
viewModel { IoTViewModel(get()) }
viewModel { OverlayViewModel(get(), get(), get()) }
viewModel { ProfileViewModel(get(), get(), get()) }
single { SettingsViewModel(get(), get(), get(), get(), get()) }
}
@@ -1,7 +0,0 @@
package top.yeij.cyrene.domain.model
data class AuthResult(
val token: String,
val refreshToken: String?,
val username: String,
)
@@ -1,10 +0,0 @@
package top.yeij.cyrene.domain.model
data class Conversation(
val id: String,
val title: String,
val lastMessage: String?,
val lastMessageType: String?,
val updatedAt: Long,
val createdAt: Long,
)
@@ -1,28 +0,0 @@
package top.yeij.cyrene.domain.model
data class Device(
val id: String,
val name: String,
val type: DeviceType,
val state: DeviceState,
val room: String?,
)
enum class DeviceType {
LIGHT,
AC,
CURTAIN,
SENSOR,
DOOR_LOCK,
UNKNOWN,
}
data class DeviceState(
val power: Boolean? = null,
val brightness: Int? = null,
val temperature: Float? = null,
val humidity: Float? = null,
val colorTemp: Int? = null,
val locked: Boolean? = null,
val open: Boolean? = null,
)
@@ -1,11 +0,0 @@
package top.yeij.cyrene.domain.model
data class Message(
val id: String,
val conversationId: String,
val role: String,
val content: String,
val msgType: String,
val timestamp: Long,
val isStreaming: Boolean = false,
)
@@ -1,12 +0,0 @@
package top.yeij.cyrene.domain.repository
import top.yeij.cyrene.domain.model.AuthResult
interface AuthRepository {
suspend fun login(username: String, password: String): Result<AuthResult>
suspend fun logout()
suspend fun isLoggedIn(): Boolean
}
@@ -1,46 +0,0 @@
package top.yeij.cyrene.domain.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
interface ChatRepository {
val connectionState: StateFlow<Boolean>
val connectionError: StateFlow<String?>
val isAssistantStreaming: StateFlow<Boolean>
val messageClearEvents: Flow<Unit>
var currentSessionId: String?
fun getConversations(): Flow<List<Conversation>>
suspend fun getMessages(conversationId: String): Flow<List<Message>>
suspend fun deleteConversation(id: String)
suspend fun connectWebSocket(sessionId: String?)
suspend fun sendMessage(content: String, sessionId: String?)
fun observeMessages(): Flow<Message>
suspend fun loadConversationsFromServer()
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
suspend fun initializeSession(): String
suspend fun reconnectWebSocket()
suspend fun ensureConnected()
suspend fun sendScreenContext(content: String)
suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg")
suspend fun clearLocalMessages()
fun onAppForeground()
fun onAppBackground()
}
@@ -1,13 +0,0 @@
package top.yeij.cyrene.domain.repository
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.domain.model.Device
interface IoTRepository {
fun getDevices(): Flow<List<Device>>
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
suspend fun refreshDevices()
}
@@ -1,11 +0,0 @@
package top.yeij.cyrene.domain.usecase
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.repository.ChatRepository
class GetConversationsUseCase(
private val chatRepository: ChatRepository,
) {
operator fun invoke(): Flow<List<Conversation>> = chatRepository.getConversations()
}
@@ -1,15 +0,0 @@
package top.yeij.cyrene.domain.usecase
import top.yeij.cyrene.domain.model.AuthResult
import top.yeij.cyrene.domain.repository.AuthRepository
class LoginUseCase(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(username: String, password: String): Result<AuthResult> {
if (username.isBlank() || password.isBlank()) {
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
}
return authRepository.login(username, password)
}
}
@@ -1,13 +0,0 @@
package top.yeij.cyrene.domain.usecase
import top.yeij.cyrene.domain.repository.ChatRepository
class SendMessageUseCase(
private val chatRepository: ChatRepository,
) {
suspend operator fun invoke(content: String, sessionId: String? = null) {
if (content.isNotBlank()) {
chatRepository.sendMessage(content, sessionId)
}
}
}
@@ -1,67 +0,0 @@
package top.yeij.cyrene.service
import android.accessibilityservice.AccessibilityService
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
class CyreneAccessibilityService : AccessibilityService() {
companion object {
@Volatile
private var instance: CyreneAccessibilityService? = null
fun getScreenContent(): String {
return instance?.captureScreenContent() ?: ""
}
fun isRunning(): Boolean = instance != null
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
override fun onInterrupt() {}
override fun onDestroy() {
instance = null
super.onDestroy()
}
private fun captureScreenContent(): String {
val root = rootInActiveWindow ?: return ""
return try {
val sb = StringBuilder()
collectNodeText(root, sb, depth = 0)
sb.toString().trim()
} finally {
root.recycle()
}
}
private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
// Skip the root decor view itself content, and limit depth
if (depth > 16) return
val text = node.text?.toString()?.trim()
val contentDesc = node.contentDescription?.toString()?.trim()
if (!text.isNullOrEmpty() && text != contentDesc) {
if (sb.isNotEmpty()) sb.append("\n")
sb.append(text)
} else if (!contentDesc.isNullOrEmpty() && depth > 0) {
// Only include content descriptions for non-root nodes
if (sb.isNotEmpty()) sb.append("\n")
sb.append(contentDesc)
}
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
collectNodeText(child, sb, depth + 1)
child.recycle()
}
}
}
@@ -1,33 +0,0 @@
package top.yeij.cyrene.service
import android.service.voice.VoiceInteractionService
import android.util.Log
/**
* Recognition service for always-on hotword detection.
*
* In API 36, AlwaysOnHotwordDetector was removed from the public SDK.
* The system manages low-level hotword detection internally and invokes
* the session service declared in voice_interaction_config.xml when triggered.
* For custom hotword models (Porcupine / openWakeWord), integrate via
* a foreground service with microphone capture instead.
*/
class CyreneRecognitionService : VoiceInteractionService() {
override fun onReady() {
super.onReady()
isActive = true
Log.i(TAG, "Recognition service ready")
}
override fun onShutdown() {
isActive = false
super.onShutdown()
}
companion object {
private const val TAG = "CyreneRecognition"
var isActive: Boolean = false
private set
}
}
@@ -1,27 +0,0 @@
package top.yeij.cyrene.service
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSessionService
import android.util.Log
class CyreneSessionService : VoiceInteractionSessionService() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Session service created")
}
override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession {
return CyreneVoiceInteractionSession(this)
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "Session service destroyed")
}
companion object {
private const val TAG = "CyreneSession"
}
}
@@ -1,60 +0,0 @@
package top.yeij.cyrene.service
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionService
import android.util.Log
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.util.Constants
class CyreneVoiceInteractionService : VoiceInteractionService() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Service created")
}
override fun onReady() {
super.onReady()
isActive = true
getSharedPreferences(PREF_NAME, MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
Log.i(TAG, "Service ready")
}
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
Log.i(TAG, "onPrepareToShowSession")
}
override fun onShowSessionFailed(args: Bundle) {
Log.e(TAG, "onShowSessionFailed")
}
override fun onLaunchVoiceAssistFromKeyguard() {
Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
}
startActivity(intent)
}
override fun onShutdown() {
isActive = false
super.onShutdown()
Log.i(TAG, "Service shutdown")
}
companion object {
var isActive: Boolean = false
private set
private const val TAG = "CyreneVIS"
private const val PREF_NAME = "cyrene_assistant"
private const val KEY_WAS_ACTIVE = "was_assistant_active"
fun wasEverActive(context: android.content.Context): Boolean {
return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
.getBoolean(KEY_WAS_ACTIVE, false)
}
}
}
@@ -1,138 +0,0 @@
package top.yeij.cyrene.service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.ui.overlay.OverlayContent
import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.OverlayViewModel
class CyreneVoiceInteractionSession(context: Context) :
VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
override val lifecycle: Lifecycle get() = lifecycleRegistry
private val savedStateRegistryController = SavedStateRegistryController.create(this)
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
// Resolved eagerly with fallback — lazy would silently crash composition on failure
private var overlayViewModel: OverlayViewModel? = null
private set
init {
savedStateRegistryController.performAttach()
savedStateRegistryController.performRestore(null)
}
private fun resolveViewModel(): OverlayViewModel? {
return try {
GlobalContext.get().get<OverlayViewModel>()
} catch (e: Exception) {
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
null
}
}
override fun onCreateContentView(): View {
Log.d(TAG, "onCreateContentView called")
RuntimeLog.general("overlay", "onCreateContentView")
overlayViewModel = resolveViewModel()
if (overlayViewModel == null) {
Log.w(TAG, "ViewModel unavailable — overlay will be static")
RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
}
lifecycleRegistry.currentState = Lifecycle.State.CREATED
val vm = overlayViewModel
return ComposeView(context).apply {
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
setContent {
CyreneTheme {
if (vm != null) {
OverlayContent(
onDismiss = { finish() },
onNavigateToMain = {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
},
viewModel = vm,
)
}
}
}
}
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
lifecycleRegistry.currentState = Lifecycle.State.STARTED
// Configure window: extend behind status bar, don't resize for IME
configureWindow()
// Only read screen content if user enabled it in settings (default off)
val autoScreenContext = try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
} catch (_: Exception) {
false
}
if (autoScreenContext) {
val screenContent = CyreneAccessibilityService.getScreenContent()
if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
}
}
}
private fun configureWindow() {
try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
val w = method.invoke(this) as? android.view.Window ?: return
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME")
} catch (e: Exception) {
Log.w(TAG, "Failed to configure window: ${e.message}")
}
}
override fun onHide() {
RuntimeLog.general("overlay", "onHide")
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
super.onHide()
overlayViewModel?.finish()
}
companion object {
private const val TAG = "CyreneVIS-Session"
}
}
@@ -1,309 +0,0 @@
package top.yeij.cyrene.service
import android.os.Build
import android.util.Log
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class WebSocketService(
private val preferencesDataStore: PreferencesDataStore,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val httpClient = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(0, TimeUnit.MILLISECONDS)
.callTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(25, TimeUnit.SECONDS)
.build()
private val gson = Gson()
private var webSocket: WebSocket? = null
private var heartbeatJob: Job? = null
private var reconnectJob: Job? = null
private var shouldReconnect = true
private var currentSessionId: String? = null
private val connectionId = AtomicInteger(0)
private var clientId: String = ""
private var deviceName: String = ""
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
private suspend fun initClientIdentity() {
clientId = preferencesDataStore.clientId.firstOrNull() ?: run {
val id = "cl_" + System.currentTimeMillis().toString(36) + "_" +
(100000..999999).random().toString(36)
preferencesDataStore.saveClientId(id)
id
}
deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run {
val name = "Android " + (Build.MODEL ?: "Device")
preferencesDataStore.saveDeviceName(name)
name
}
}
fun getClientId(): String = clientId
fun getDeviceName(): String = deviceName
suspend fun connect(sessionId: String? = null) {
currentSessionId = sessionId
shouldReconnect = true
initClientIdentity()
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?: "http://10.0.2.2:8080"
val token = preferencesDataStore.token.firstOrNull() ?: ""
val wsBase = baseUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/')
val urlBuilder = StringBuilder("$wsBase/ws/chat")
val params = mutableListOf<String>()
if (token.isNotBlank()) {
params.add("token=" + encode(token))
}
sessionId?.let {
if (it.isNotBlank()) params.add("session_id=" + encode(it))
}
if (clientId.isNotBlank()) {
params.add("client_id=" + encode(clientId))
}
if (deviceName.isNotBlank()) {
params.add("device_name=" + encode(deviceName))
}
if (params.isNotEmpty()) {
urlBuilder.append("?")
urlBuilder.append(params.joinToString("&"))
}
val url = urlBuilder.toString()
val connId = connectionId.incrementAndGet()
Log.i(TAG, "[#$connId] Connecting to $url")
val request = Request.Builder()
.url(url)
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
.build()
// Close previous socket silently
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
cancelHeartbeat()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (connectionId.get() != connId) {
Log.d(TAG, "[#$connId] onOpen ignored (stale)")
return
}
Log.i(TAG, "[#$connId] Connected")
_isConnected.value = true
_connectionError.value = null
startHeartbeat()
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
try {
val msg = gson.fromJson(text, WSServerMessage::class.java)
_incomingMessages.tryEmit(msg)
} catch (e: Exception) {
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (connectionId.get() != connId) return
val httpCode = response?.code
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
_isConnected.value = false
cancelHeartbeat()
val errorMsg = when (httpCode) {
403 -> {
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
"仅管理员用户可连接"
}
401 -> "认证失败,请重新登录"
else -> null
}
if (errorMsg != null) {
_connectionError.value = errorMsg
}
// onClosed will always follow, which triggers scheduleReconnect
}
})
}
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
private fun buildMessage(
type: String,
sessionId: String? = null,
mode: String? = null,
content: String? = null,
): WSClientMessage = WSClientMessage(
type = type,
sessionId = sessionId ?: currentSessionId,
mode = mode,
content = content,
timestamp = System.currentTimeMillis(),
clientId = clientId.ifBlank { null },
deviceName = deviceName.ifBlank { null },
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
)
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
val msg = buildMessage("message", sessionId, mode, content)
webSocket?.send(gson.toJson(msg))
}
fun requestHistory(sessionId: String?) {
val msg = buildMessage("history", sessionId)
if (webSocket != null) {
webSocket?.send(gson.toJson(msg))
Log.i(TAG, "History requested for session=$sessionId")
} else {
Log.w(TAG, "Cannot request history: WebSocket is null")
}
}
fun sendPing() {
val msg = buildMessage("ping")
webSocket?.send(gson.toJson(msg))
}
fun sendScreenContext(content: String, sessionId: String? = null) {
val msg = buildMessage("message", sessionId, mode = "text", content = content)
webSocket?.send(gson.toJson(msg))
}
fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
val msg = WSClientMessage(
type = "voice_input",
sessionId = sessionId ?: currentSessionId,
mode = mode,
audioData = audioBase64,
timestamp = System.currentTimeMillis(),
clientId = clientId.ifBlank { null },
deviceName = deviceName.ifBlank { null },
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
)
webSocket?.send(gson.toJson(msg))
}
fun forceReconnect() {
shouldReconnect = true
reconnectJob?.cancel()
reconnectJob = null
scope.launch {
if (!_isConnected.value) {
try {
connect(currentSessionId)
} catch (_: Exception) { }
}
}
}
fun disconnect() {
shouldReconnect = false
reconnectJob?.cancel()
reconnectJob = null
cancelHeartbeat()
try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
webSocket = null
_isConnected.value = false
}
private fun startHeartbeat() {
cancelHeartbeat()
heartbeatJob = scope.launch {
while (_isConnected.value) {
delay(30_000)
if (_isConnected.value) {
sendPing()
}
}
}
}
private fun cancelHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true || !shouldReconnect) return
reconnectJob = scope.launch {
var attempt = 0
while (shouldReconnect && !_isConnected.value) {
val delayMs = minOf(
(Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
30_000L
)
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
delay(delayMs)
attempt++
if (shouldReconnect && !_isConnected.value) {
try {
connect(currentSessionId)
} catch (e: Exception) {
Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e)
}
}
}
Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
}
}
companion object {
private const val TAG = "CyreneWS"
}
}
@@ -1,204 +0,0 @@
package top.yeij.cyrene.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ChatBubble(
content: String,
role: String,
msgType: String,
timestamp: Long,
modifier: Modifier = Modifier,
) {
val isUser = role == "user"
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
when (msgType) {
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
"action" -> ActionMessage(content, modifier)
"thinking" -> ThinkingBubble(content, modifier)
"tool_progress" -> ToolProgressBubble(content, modifier)
"system_info" -> SystemInfoBubble(content, modifier)
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChatMessageBubble(
content: String,
isUser: Boolean,
time: String,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Column(
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
) {
Box {
Surface(
shape = MaterialTheme.shapes.large,
color = if (isUser)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp,
modifier = Modifier
.widthIn(max = 300.dp)
.combinedClickable(
onClick = {},
onLongClick = { showMenu = true },
),
) {
Text(
text = content,
modifier = Modifier.padding(12.dp),
color = if (isUser)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("复制") },
leadingIcon = {
Icon(Icons.Default.ContentCopy, contentDescription = null)
},
onClick = {
showMenu = false
clipboardManager.setText(AnnotatedString(content))
},
)
}
}
Text(
text = time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@Composable
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Start,
) {
Text(
text = content,
style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
)
}
}
@Composable
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.medium,
),
) {
Text(
text = content,
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Start,
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
) {
Text(
text = content,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
@Composable
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
@@ -1,98 +0,0 @@
package top.yeij.cyrene.ui.components
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AcUnit
import androidx.compose.material.icons.filled.Curtains
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.model.Device
import top.yeij.cyrene.domain.model.DeviceType
@Composable
fun DeviceCard(
device: Device,
onTogglePower: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = device.type.toIcon(),
contentDescription = device.type.name,
tint = if (device.state.power == true)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
Column {
Text(
text = device.name,
style = MaterialTheme.typography.titleMedium,
)
device.room?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
device.state.temperature?.let { temp ->
Text(
text = "${"%.1f".format(temp)}°C",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (device.state.power != null) {
Switch(
checked = device.state.power,
onCheckedChange = { onTogglePower() },
)
}
}
}
}
private fun DeviceType.toIcon() = when (this) {
DeviceType.LIGHT -> Icons.Filled.Lightbulb
DeviceType.AC -> Icons.Filled.AcUnit
DeviceType.CURTAIN -> Icons.Filled.Curtains
DeviceType.SENSOR -> Icons.Filled.Sensors
DeviceType.DOOR_LOCK -> Icons.Filled.Lock
DeviceType.UNKNOWN -> Icons.Filled.Sensors
}
@@ -1,94 +0,0 @@
package top.yeij.cyrene.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
enum class CyreneStatus {
ONLINE,
THINKING,
SPEAKING,
OFFLINE,
}
@Composable
fun StatusIndicator(
status: CyreneStatus,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
when (status) {
CyreneStatus.ONLINE -> {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint = Color(0xFF4CAF50),
)
Text("昔涟", style = MaterialTheme.typography.labelLarge)
}
CyreneStatus.THINKING -> {
PulsingDot(Color(0xFFFFA726))
Text("思考中…", style = MaterialTheme.typography.labelLarge)
}
CyreneStatus.SPEAKING -> {
PulsingDot(Color(0xFF42A5F5))
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
}
CyreneStatus.OFFLINE -> {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint = Color(0xFF9E9E9E),
)
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge)
}
}
}
}
@Composable
private fun PulsingDot(color: Color) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600),
repeatMode = RepeatMode.Reverse,
),
label = "pulse_alpha",
)
Box(
modifier = Modifier
.size(8.dp)
.alpha(alpha)
.background(color, CircleShape),
)
}
@@ -1,77 +0,0 @@
package top.yeij.cyrene.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
@Composable
fun TypingIndicator(modifier: Modifier = Modifier) {
val infiniteTransition = rememberInfiniteTransition(label = "typing")
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Start,
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 1.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "昔涟正在输入",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
repeat(3) { index ->
val alpha by infiniteTransition.animateFloat(
initialValue = 0.2f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 400,
delayMillis = index * 200,
),
repeatMode = RepeatMode.Reverse,
),
label = "dot_$index",
)
Box(
modifier = Modifier
.size(5.dp)
.alpha(alpha)
.background(
MaterialTheme.colorScheme.onSurfaceVariant,
CircleShape,
),
)
}
}
}
}
}
@@ -1,160 +0,0 @@
package top.yeij.cyrene.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.DevicesOther
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import top.yeij.cyrene.ui.screens.chat.ChatScreen
import top.yeij.cyrene.ui.screens.iot.IoTScreen
import top.yeij.cyrene.ui.screens.login.LoginScreen
import top.yeij.cyrene.ui.screens.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
object Routes {
const val LOGIN = "login"
const val MAIN = "main"
const val CHAT = "chat"
const val IOT = "iot"
const val SETTINGS = "settings"
const val ABOUT = "about"
}
@Composable
fun CyreneNavGraph(
navController: NavHostController,
startDestination: String,
isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit,
) {
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Routes.LOGIN) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Routes.MAIN) {
popUpTo(Routes.LOGIN) { inclusive = true }
}
},
)
}
composable(Routes.MAIN) {
MainScreen(
navController = navController,
isDefaultAssistant = isDefaultAssistant,
onOpenAssistantSettings = onOpenAssistantSettings,
)
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
)
}
composable(Routes.ABOUT) {
AboutScreen(
onBack = { navController.popBackStack() },
)
}
}
}
data class BottomNavItem(
val label: String,
val icon: @Composable () -> Unit,
val route: String,
)
@Composable
fun MainScreen(
navController: NavHostController,
isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit,
) {
val items = listOf(
BottomNavItem(
label = "对话",
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "对话") },
route = Routes.CHAT,
),
BottomNavItem(
label = "设备",
icon = { Icon(Icons.Filled.DevicesOther, contentDescription = "设备") },
route = Routes.IOT,
),
BottomNavItem(
label = "我的",
icon = { Icon(Icons.Filled.Person, contentDescription = "我的") },
route = "profile",
),
)
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
Row(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding(),
) {
NavigationRail {
items.forEachIndexed { index, item ->
NavigationRailItem(
selected = selectedTab == index,
onClick = { selectedTab = index },
icon = item.icon,
label = { Text(item.label) },
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.background),
) {
when (selectedTab) {
0 -> ChatScreen()
1 -> IoTScreen()
2 -> ProfileScreen(
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = {
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.MAIN) { inclusive = true }
}
},
onNavigateToLogin = {
navController.navigate(Routes.LOGIN)
},
isDefaultAssistant = isDefaultAssistant,
onOpenAssistantSettings = onOpenAssistantSettings,
)
}
}
}
}
@@ -1,600 +0,0 @@
package top.yeij.cyrene.ui.overlay
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble
import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel
import kotlin.math.min
@Composable
private fun AnimatedChatBubble(
message: Message,
animIndex: Int,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(min(animIndex, 10) * 60L)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
slideInVertically(
animationSpec = androidx.compose.animation.core.tween(300),
initialOffsetY = { it / 4 },
),
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
)
}
}
@Composable
fun OverlayContent(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(),
) {
val state by viewModel.state.collectAsState()
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
val listState = rememberLazyListState()
val isProcessing = state == OverlayState.PROCESSING
val recordSec = recordDurationMs / 1000f
val isRecording = recordState == RecordState.RECORDING
val isLocked = recordState == RecordState.LOCKED
// Animated "昔涟正在输入..." dots
val typingDots = remember { mutableStateOf("") }
LaunchedEffect(isProcessing) {
if (isProcessing) {
val dots = arrayOf("", ".", "..", "...")
var i = 0
while (true) {
typingDots.value = dots[i % 4]
i++
delay(400)
}
} else {
typingDots.value = ""
}
}
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// Manual status bar height — Compose WindowInsets may not work in VoiceInteractionSession
val context = androidx.compose.ui.platform.LocalContext.current
val statusBarHeight = remember {
val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
}
val statusBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
statusBarHeight.toDp()
}
// Manual nav bar height
val navBarHeight = remember {
val resourceId = context.resources.getIdentifier("navigation_bar_height", "dimen", "android")
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
}
val navBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
navBarHeight.toDp()
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
LaunchedEffect(state) {
if (state == OverlayState.IDLE) {
viewModel.finish()
onDismiss()
}
}
AnimatedVisibility(
visible = state != OverlayState.IDLE,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = statusBarPaddingDp, bottom = navBarPaddingDp),
) {
if (isLandscape) {
LandscapeContent(
state = state,
messages = messages,
inputText = inputText,
isProcessing = isProcessing,
listState = listState,
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
)
} else {
PortraitContent(
state = state,
messages = messages,
inputText = inputText,
isProcessing = isProcessing,
listState = listState,
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun PortraitContent(
state: OverlayState,
messages: List<Message>,
inputText: String,
isProcessing: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
recordSec: Float,
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { /* consume clicks */ },
) {
// Messages + top bar stay fixed at top
Column(modifier = Modifier.fillMaxSize()) {
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = listState,
) {
if (messages.isNotEmpty()) {
items(messages, key = { it.id }) { message ->
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
)
}
}
if (isProcessing) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
// Input area at bottom, imePadding pushes it above full-screen IME
InputArea(
state = state,
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun LandscapeContent(
state: OverlayState,
messages: List<Message>,
inputText: String,
isProcessing: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
recordSec: Float,
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
) {
Row(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { /* consume clicks */ },
) {
// Messages + top bar on the left, stay fixed
Column(modifier = Modifier.weight(1f)) {
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = listState,
) {
if (messages.isNotEmpty()) {
items(messages, key = { it.id }) { message ->
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
)
}
}
if (isProcessing) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.BottomCenter,
) {
InputArea(
state = state,
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.fillMaxWidth()
.imePadding(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
)
}
}
}
@Composable
private fun MessageTopBar(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = "关闭")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onNavigateToMain) {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "进入主界面",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun InputArea(
state: OverlayState,
inputText: String,
viewModel: OverlayViewModel,
modifier: Modifier = Modifier,
recordSec: Float = 0f,
isRecording: Boolean = false,
isLocked: Boolean = false,
typingDots: String = "",
) {
// Gesture tracking state — local to InputArea
var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) }
var dragOffsetY by remember { mutableStateOf(0f) }
val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f
val isProcessing = state == OverlayState.PROCESSING
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
) {
// "昔涟正在输入..." indicator
if (isProcessing && typingDots.isNotEmpty()) {
Text(
text = "昔涟正在输入$typingDots",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
textAlign = TextAlign.Start,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording && isDragging) {
// Recording with drag — show recording indicator
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
// Locked (hands-free) mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
// Normal input mode
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 3,
shape = MaterialTheme.shapes.medium,
)
Spacer(modifier = Modifier.width(8.dp))
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendText() },
enabled = !isProcessing,
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
Box(
modifier = Modifier
.size(48.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { _ ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// Status hint
val hint = when {
isLocked -> ""
isRecording && isDragging -> ""
else -> when (state) {
OverlayState.LISTENING -> "我在听..."
OverlayState.PROCESSING -> "思考中..."
OverlayState.SPEAKING -> "正在说话..."
OverlayState.WAITING -> "长按麦克风开始说话"
OverlayState.IDLE -> ""
}
}
if (hint.isNotEmpty()) {
Text(
text = hint,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -1,191 +0,0 @@
package top.yeij.cyrene.ui.screens.about
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit) {
val context = LocalContext.current
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("关于") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Text(
text = "Cyrene",
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
),
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
)
Text(
text = "昔涟",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "v0.1.0",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "信息",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ListItem(
headlineContent = { Text("简介") },
leadingContent = {
Icon(Icons.Filled.Info, contentDescription = null)
},
supportingContent = {
Text(
text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。",
style = MaterialTheme.typography.bodyMedium,
)
},
)
ListItem(
headlineContent = { Text("开发者") },
leadingContent = {
Icon(Icons.Filled.Person, contentDescription = null)
},
supportingContent = { Text("AskaEth") },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ListItem(
headlineContent = { Text("源代码") },
leadingContent = {
Icon(Icons.Filled.Code, contentDescription = null)
},
supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") },
trailingContent = {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
modifier = Modifier
.fillMaxWidth()
.clickable {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android")
}
context.startActivity(intent)
},
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "技术栈",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
val techStack = listOf(
"Kotlin" to "主语言",
"Jetpack Compose" to "UI 框架",
"Material Design 3" to "设计系统",
"Room" to "本地数据库",
"OkHttp + Retrofit" to "网络请求",
"WebSocket" to "实时通信",
"Koin" to "依赖注入",
)
techStack.forEach { (name, desc) ->
ListItem(
headlineContent = { Text(name) },
supportingContent = { Text(desc) },
)
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "© 2026 AskaEth. All rights reserved.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -1,370 +0,0 @@
package top.yeij.cyrene.ui.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble
import top.yeij.cyrene.ui.components.CyreneStatus
import top.yeij.cyrene.ui.components.StatusIndicator
import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.ChatViewModel
import kotlin.math.min
@Composable
private fun AnimatedChatBubble(
message: Message,
animIndex: Int,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(min(animIndex, 10) * 60L)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
slideInVertically(
animationSpec = androidx.compose.animation.core.tween(300),
initialOffsetY = { it / 4 },
),
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(),
) {
val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
val listState = rememberLazyListState()
// Gesture tracking state
var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) }
var dragOffsetY by remember { mutableStateOf(0f) }
var recordButtonY by remember { mutableStateOf(0f) }
val recordSec = recordDurationMs / 1000f
val isRecording = recordState == RecordState.RECORDING
val isLocked = recordState == RecordState.LOCKED
val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f
LaunchedEffect(messages.size, isStreaming) {
if (messages.isNotEmpty()) {
val targetIndex = if (isStreaming) messages.size else messages.size - 1
listState.animateScrollToItem(targetIndex)
}
}
// Animated "昔涟正在输入..." dots
val typingDots = remember { mutableStateOf("") }
LaunchedEffect(isStreaming) {
if (isStreaming) {
val dots = arrayOf("", ".", "..", "...")
var i = 0
while (true) {
typingDots.value = dots[i % 4]
i++
delay(400)
}
} else {
typingDots.value = ""
}
}
val status = when {
isStreaming -> CyreneStatus.THINKING
isConnected -> CyreneStatus.ONLINE
else -> CyreneStatus.OFFLINE
}
Scaffold(
topBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StatusIndicator(status = status)
}
},
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
) {
// "昔涟正在输入..." indicator
if (isStreaming) {
Text(
text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
textAlign = TextAlign.Start,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording && isDragging) {
// Recording state with drag — show recording indicator
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Record button (drag anchor)
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
// Locked (hands-free) mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
// Normal input mode
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
)
// Voice record button with long-press gesture
Box(
modifier = Modifier
.padding(start = 4.dp)
.size(48.dp)
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Send button (only when text present)
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendMessage() },
enabled = !isStreaming,
) {
if (isStreaming) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
}
}
}
}
}
}
},
) { padding ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.refreshMessages() },
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
if (messages.isEmpty() && !isStreaming) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "开始和昔涟对话吧",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
items(messages, key = { it.id }) { message ->
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
)
}
if (isStreaming) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
}
}
}
@@ -1,59 +0,0 @@
package top.yeij.cyrene.ui.screens.iot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.koin.androidx.compose.koinViewModel
import top.yeij.cyrene.ui.components.DeviceCard
import top.yeij.cyrene.viewmodel.IoTViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IoTScreen(
viewModel: IoTViewModel = koinViewModel(),
) {
val devices by viewModel.devices.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
PullToRefreshBox(
isRefreshing = isLoading,
onRefresh = { viewModel.refreshDevices() },
modifier = Modifier.background(MaterialTheme.colorScheme.background),
) {
if (devices.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "暂无设备数据\n请确认已连接到服务器",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn {
items(devices, key = { it.id }) { device ->
DeviceCard(
device = device,
onTogglePower = { viewModel.togglePower(device) },
)
}
}
}
}
}
@@ -1,165 +0,0 @@
package top.yeij.cyrene.ui.screens.login
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.usecase.LoginUseCase
import top.yeij.cyrene.viewmodel.SettingsViewModel
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
loginUseCase: LoginUseCase = koinInject(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(80.dp))
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Cyrene",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "昔涟",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(48.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
singleLine = true,
shape = MaterialTheme.shapes.medium,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
visualTransformation = if (showPassword)
VisualTransformation.None
else
PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
singleLine = true,
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (showPassword) "隐藏密码" else "显示密码",
)
}
},
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
isLoading = true
scope.launch {
loginUseCase(username, password).fold(
onSuccess = {
isLoading = false
onLoginSuccess()
},
onFailure = { error ->
isLoading = false
scope.launch {
snackbarHostState.showSnackbar(
error.message ?: "登录失败",
)
}
},
)
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text("登录")
}
}
}
}
}
@@ -1,394 +0,0 @@
package top.yeij.cyrene.ui.screens.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.AdminPanelSettings
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.koin.compose.koinInject
import top.yeij.cyrene.viewmodel.ProfileViewModel
@Composable
fun ProfileScreen(
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit,
onNavigateToLogin: () -> Unit,
onNavigateToAbout: () -> Unit = {},
isDefaultAssistant: Boolean = false,
onOpenAssistantSettings: () -> Unit = {},
profileViewModel: ProfileViewModel = koinInject(),
) {
val profile by profileViewModel.profile.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
profileViewModel.fetchFreshProfile()
}
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("退出登录") },
text = { Text("确定要退出登录吗?") },
confirmButton = {
TextButton(
onClick = {
showLogoutDialog = false
profileViewModel.logout()
onLogout()
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("退出")
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("取消")
}
},
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState()),
) {
// Profile header
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Avatar
val initials = if (profile.nickname.isNotBlank()) {
profile.nickname.take(1)
} else if (profile.username.isNotBlank()) {
profile.username.take(1)
} else {
"?"
}
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = initials,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(16.dp))
if (profile.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else if (!profile.isLoggedIn) {
Text(
text = "未登录",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onNavigateToLogin() },
)
Text(
text = "点击登录以查看个人信息",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// Nickname
Text(
text = profile.nickname.ifEmpty { profile.username },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
// Username
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
Text(
text = "@${profile.username}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Admin badge
if (profile.isAdmin) {
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.AdminPanelSettings,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "管理员",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
HorizontalDivider()
if (profile.isLoggedIn) {
// User info card
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "账号信息",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ProfileInfoCard(
items = listOf(
ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
),
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
}
Spacer(modifier = Modifier.height(8.dp))
// Assistant status
Text(
text = "助手",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(16.dp),
color = if (isDefaultAssistant)
Color(0xFF4CAF50).copy(alpha = 0.1f)
else
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (!isDefaultAssistant) {
Text(
text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (!isDefaultAssistant) {
TextButton(onClick = onOpenAssistantSettings) {
Text("去设置")
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Menu items
Text(
text = "其他",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ListItem(
headlineContent = { Text("设置") },
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToSettings() },
)
ListItem(
headlineContent = { Text("提醒") },
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ListItem(
headlineContent = { Text("关于") },
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
supportingContent = { Text("Cyrene v0.1.0") },
modifier = Modifier.clickable { onNavigateToAbout() },
)
ListItem(
headlineContent = { Text("使用帮助") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
)
Spacer(modifier = Modifier.height(16.dp))
// Logout
if (profile.isLoggedIn) {
ListItem(
headlineContent = {
Text(
text = "退出登录",
color = MaterialTheme.colorScheme.error,
)
},
leadingContent = {
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
modifier = Modifier.clickable { showLogoutDialog = true },
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
private data class ProfileInfoItem(
val icon: ImageVector,
val label: String,
val value: String,
)
@Composable
private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
) {
Column {
items.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
item.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = item.value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (index < items.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
}
}
@@ -1,578 +0,0 @@
package top.yeij.cyrene.ui.screens.settings
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import top.yeij.cyrene.util.LogCategory
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = koinInject(),
) {
val baseUrl by viewModel.baseUrl.collectAsState()
val themeMode by viewModel.themeMode.collectAsState()
val wakeWord by viewModel.wakeWord.collectAsState()
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
fun sanitizeUrl(raw: String): String? {
var url = raw.trim()
if (url.isEmpty()) return null
val hasScheme = url.contains("://")
if (!hasScheme) url = "http://$url"
return try {
val parsed = java.net.URL(url)
val host = parsed.host ?: return null
if (host.isEmpty()) return null
val scheme = parsed.protocol
val port = if (parsed.port > 0) ":${parsed.port}" else ""
val path = parsed.path?.trimEnd('/') ?: ""
val query = parsed.query?.let { "?$it" } ?: ""
"$scheme://$host$port$path$query"
} catch (_: Exception) {
null
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("设置") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// Server
Text(
text = "服务器",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
OutlinedTextField(
value = baseUrl,
onValueChange = { viewModel.updateBaseUrlInput(it) },
label = { Text("服务器地址") },
placeholder = { Text("http://192.168.1.x:8080") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
val sanitized = sanitizeUrl(baseUrl)
if (sanitized != null) {
viewModel.saveBaseUrl(sanitized)
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
}
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
val sanitized = sanitizeUrl(baseUrl)
if (sanitized != null) {
viewModel.saveBaseUrl(sanitized)
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "地址格式无效", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "确认")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Appearance
Text(
text = "外观",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
val themeLabel = when (themeMode) {
"light" -> "浅色模式"
"dark" -> "深色模式"
else -> "跟随系统"
}
val themeIcon = when (themeMode) {
"light" -> Icons.Filled.LightMode
"dark" -> Icons.Filled.DarkMode
else -> Icons.Filled.SettingsBrightness
}
ListItem(
headlineContent = { Text("主题") },
supportingContent = { Text(themeLabel) },
leadingContent = { Icon(themeIcon, contentDescription = null) },
modifier = Modifier.clickable {
val next = when (themeMode) {
"light" -> "dark"
"dark" -> "auto"
else -> "light"
}
viewModel.saveThemeMode(next)
},
)
ListItem(
headlineContent = { Text("主题色") },
supportingContent = { Text("昔涟紫") },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Voice
Text(
text = "语音",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
OutlinedTextField(
value = wakeWord,
onValueChange = { viewModel.saveWakeWord(it) },
label = { Text("唤醒词") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
)
Spacer(modifier = Modifier.height(8.dp))
ListItem(
headlineContent = { Text("自动读取屏幕内容") },
supportingContent = { Text("电源键唤起助手时自动截取当前屏幕内容并加入对话上下文") },
trailingContent = {
androidx.compose.material3.Switch(
checked = autoScreenContext,
onCheckedChange = { viewModel.saveAutoScreenContext(it) },
)
},
modifier = Modifier.clickable { viewModel.saveAutoScreenContext(!autoScreenContext) },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// DashScope STT
Text(
text = "语音识别 (DashScope)",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
OutlinedTextField(
value = dashScopeApiKey,
onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
label = { Text("API Key") },
placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
viewModel.saveDashScopeApiKey(dashScopeApiKey)
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
scope.launch {
viewModel.saveDashScopeApiKey(dashScopeApiKey)
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "保存")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = dashScopeEndpoint,
onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
label = { Text("WebSocket 端点") },
placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
scope.launch {
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "保存")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = dashScopeModel,
onValueChange = { viewModel.updateDashScopeModelInput(it) },
label = { Text("模型") },
placeholder = { Text("fun-asr-realtime") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
viewModel.saveDashScopeModel(dashScopeModel)
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
scope.launch {
viewModel.saveDashScopeModel(dashScopeModel)
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "保存")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Data management
Text(
text = "数据",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
var showClearDialog by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text("清空本地消息记录") },
supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
modifier = Modifier.clickable { showClearDialog = true },
)
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("确认清空") },
text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
confirmButton = {
TextButton(
onClick = {
showClearDialog = false
val activity = context as? FragmentActivity
if (activity == null) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
return@TextButton
}
val biometricManager = BiometricManager.from(context)
val canAuth = when (biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
if (!canAuth) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
return@TextButton
}
val prompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationFailed() {
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
}
},
)
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("身份验证")
.setSubtitle("需要验证身份后才能清空消息记录")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build(),
)
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("清空")
}
},
dismissButton = {
TextButton(onClick = { showClearDialog = false }) {
Text("取消")
}
},
)
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Runtime logs
Text(
text = "运行日志",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
val logEntries by RuntimeLog.entries.collectAsState()
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("全部") + LogCategory.entries.map { it.label }
val allCategories = LogCategory.entries.toList()
// Scroll to bottom
val scrollState = rememberScrollState()
androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
if (logEntries.isNotEmpty()) {
scrollState.animateScrollTo(scrollState.maxValue)
}
}
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(label, maxLines = 1) },
)
}
}
val filteredLogs = if (selectedTab == 0) {
logEntries
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
}
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
val file = RuntimeLog.exportToFile(context, currentCategory)
RuntimeLog.shareFile(context, file)
},
) {
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
}
OutlinedButton(
onClick = {
val file = RuntimeLog.exportAllAsZip(context)
RuntimeLog.shareFile(context, file)
},
) {
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("打包全部")
}
OutlinedButton(
onClick = { RuntimeLog.clear() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("清空")
}
}
if (filteredLogs.isEmpty()) {
Text(
text = "暂无${tabs[selectedTab]}日志",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.weight(1f, fill = false),
) {
Column(
modifier = Modifier.verticalScroll(scrollState),
) {
filteredLogs.takeLast(500).forEach { entry ->
Text(
text = entry.formatted(),
style = MaterialTheme.typography.bodySmall,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 1.dp),
)
}
}
}
}
Spacer(modifier = Modifier.height(80.dp))
}
}
}
@@ -1,58 +0,0 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.ui.graphics.Color
// Light theme
val LightPrimary = Color(0xFF6D3BC0)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFEEDCFF)
val LightOnPrimaryContainer = Color(0xFF250058)
val LightSecondary = Color(0xFF625B71)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFE8DEF8)
val LightOnSecondaryContainer = Color(0xFF1E192B)
val LightTertiary = Color(0xFF7E5260)
val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFFFD9E3)
val LightOnTertiaryContainer = Color(0xFF31101D)
val LightBackground = Color(0xFFFFFBFF)
val LightOnBackground = Color(0xFF1C1B1F)
val LightSurface = Color(0xFFFFFBFF)
val LightOnSurface = Color(0xFF1C1B1F)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightOnSurfaceVariant = Color(0xFF49454F)
val LightError = Color(0xFFBA1A1A)
val LightOutline = Color(0xFF79747E)
val LightOutlineVariant = Color(0xFFCAC4D0)
// Dark theme
val DarkPrimary = Color(0xFFD3BBFF)
val DarkOnPrimary = Color(0xFF3D0089)
val DarkPrimaryContainer = Color(0xFF541BA6)
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
val DarkSecondary = Color(0xFFCBC2DC)
val DarkOnSecondary = Color(0xFF332D41)
val DarkSecondaryContainer = Color(0xFF4A4458)
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
val DarkTertiary = Color(0xFFEFB8C8)
val DarkOnTertiary = Color(0xFF4A2532)
val DarkTertiaryContainer = Color(0xFF633B48)
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
val DarkBackground = Color(0xFF1C1B1F)
val DarkOnBackground = Color(0xFFE6E1E5)
val DarkSurface = Color(0xFF1C1B1F)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkSurfaceVariant = Color(0xFF49454F)
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
val DarkError = Color(0xFFFFB4AB)
val DarkOutline = Color(0xFF938F99)
val DarkOutlineVariant = Color(0xFF49454F)
// Preset seed colors for manual theme selection
val SeedColors = mapOf(
"default" to 0xFF6D3BC0, // Lavender
"sakura" to 0xFFFFB4C8, // Pink
"ocean" to 0xFF6BA4FF, // Blue
"forest" to 0xFF6BCF7C, // Green
"sunset" to 0xFFFF9E6B, // Orange
)
@@ -1,13 +0,0 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val CyreneShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
@@ -1,101 +0,0 @@
package top.yeij.cyrene.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
onSecondaryContainer = LightOnSecondaryContainer,
tertiary = LightTertiary,
onTertiary = LightOnTertiary,
tertiaryContainer = LightTertiaryContainer,
onTertiaryContainer = LightOnTertiaryContainer,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = LightError,
outline = LightOutline,
outlineVariant = LightOutlineVariant,
)
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
tertiaryContainer = DarkTertiaryContainer,
onTertiaryContainer = DarkOnTertiaryContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = DarkError,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
)
@Composable
fun CyreneTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = CyreneTypography,
shapes = CyreneShapes,
content = content,
)
}
@@ -1,54 +0,0 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val CyreneTypography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
),
)
@@ -1,15 +0,0 @@
package top.yeij.cyrene.util
object Constants {
const val DEFAULT_WAKE_WORD = "昔涟"
const val SILENCE_TIMEOUT_MS = 10_000L
const val WS_RECONNECT_MAX_ATTEMPTS = 5
const val WS_PING_INTERVAL_SECONDS = 30L
const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L
const val OVERLAY_ANIM_DURATION_MS = 300
const val STT_SILENCE_THRESHOLD_MS = 1500L
// Intent extras
const val EXTRA_VOICE_ASSIST = "voice_assist"
const val EXTRA_START_LISTENING = "start_listening"
}
@@ -1,68 +0,0 @@
package top.yeij.cyrene.util
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.domain.model.Message
class NotificationHelper(private val context: Context) {
private val notificationManager = context.getSystemService(NotificationManager::class.java)
init {
createChannel()
}
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"消息通知",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "昔涟的新消息通知"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
fun showMessageNotification(message: Message) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val preview = if (message.content.length > 50) {
message.content.take(50) + "..."
} else {
message.content
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("昔涟")
.setContentText(preview)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
notificationManager.notify(message.id.hashCode(), notification)
}
fun cancelAll() {
notificationManager.cancelAll()
}
companion object {
private const val CHANNEL_ID = "cyrene_messages"
}
}
@@ -1,126 +0,0 @@
package top.yeij.cyrene.util
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
enum class LogCategory(val label: String) {
CHAT("聊天"),
WS("WebSocket"),
STT("语音识别"),
GENERAL("通用"),
HTTP("网络"),
VOICE("语音"),
}
data class LogEntry(
val timestamp: Long,
val category: LogCategory,
val tag: String,
val message: String,
) {
fun formatted(): String {
val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message"
}
}
object RuntimeLog {
private const val MAX_ENTRIES = 2000
private val _entries = MutableStateFlow<List<LogEntry>>(emptyList())
val entries: StateFlow<List<LogEntry>> = _entries.asStateFlow()
private val buffer = ArrayDeque<LogEntry>(MAX_ENTRIES)
@Synchronized
fun log(category: LogCategory, tag: String, message: String) {
val entry = LogEntry(System.currentTimeMillis(), category, tag, message)
if (buffer.size >= MAX_ENTRIES) {
buffer.removeFirst()
}
buffer.addLast(entry)
_entries.value = buffer.toList()
}
fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message)
fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message)
fun stt(tag: String, message: String) = log(LogCategory.STT, tag, message)
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
@Synchronized
fun getByCategory(category: LogCategory): List<LogEntry> {
return buffer.filter { it.category == category }
}
@Synchronized
fun exportAsText(category: LogCategory): String {
val entries = buffer.filter { it.category == category }
val header = "=== Cyrene ${category.label}日志 导出时间: ${
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
} ===\n\n"
return header + entries.joinToString("\n") { it.formatted() }
}
@Synchronized
fun exportAllAsText(): String {
val header = "=== Cyrene 全部日志 导出时间: ${
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
} ===\n\n"
val byCategory = buffer.groupBy { it.category }
return header + byCategory.entries.joinToString("\n\n") { (cat, entries) ->
"--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() }
}
}
fun exportToFile(context: Context, category: LogCategory? = null): File {
val text = if (category != null) exportAsText(category) else exportAllAsText()
val label = category?.name?.lowercase() ?: "all"
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt")
file.writeText(text)
return file
}
fun exportAllAsZip(context: Context): File {
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip")
ZipOutputStream(zipFile.outputStream().buffered()).use { zos ->
LogCategory.entries.forEach { cat ->
val text = exportAsText(cat)
zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt"))
zos.write(text.toByteArray(Charsets.UTF_8))
zos.closeEntry()
}
}
return zipFile
}
fun shareFile(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (file.extension == "zip") "application/zip" else "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "导出日志"))
}
@Synchronized
fun clear() {
buffer.clear()
_entries.value = emptyList()
}
}
@@ -1,158 +0,0 @@
package top.yeij.cyrene.util
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
enum class RecordState {
IDLE,
RECORDING,
LOCKED, // hands-free mode
CANCELLING,
}
class VoiceRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null
private var timerJob: Job? = null
private val _state = MutableStateFlow(RecordState.IDLE)
val state: StateFlow<RecordState> = _state.asStateFlow()
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
fun start() {
if (_state.value != RecordState.IDLE) return
try {
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
outputFile?.delete()
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
recorder?.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(16000)
setAudioEncodingBitRate(32000)
setAudioChannels(1)
setOutputFile(outputFile?.absolutePath)
prepare()
start()
}
_state.value = RecordState.RECORDING
startTimer()
Log.i(TAG, "Recording started: ${outputFile?.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording: ${e.message}", e)
cleanup()
}
}
fun lock() {
if (_state.value == RecordState.RECORDING) {
_state.value = RecordState.LOCKED
}
}
fun stop(): File? {
if (_state.value == RecordState.IDLE) return null
val file = outputFile
try {
recorder?.apply {
try { stop() } catch (_: Exception) { }
try { release() } catch (_: Exception) { }
}
} catch (e: Exception) {
Log.e(TAG, "Error stopping recorder: ${e.message}", e)
}
recorder = null
cancelTimer()
_state.value = RecordState.IDLE
_durationMs.value = 0L
Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}")
return if (file != null && file.exists() && file.length() > 0) file else null
}
fun cancel() {
val file = outputFile
try {
recorder?.apply {
try { stop() } catch (_: Exception) { }
try { release() } catch (_: Exception) { }
}
} catch (e: Exception) { }
recorder = null
file?.delete()
cancelTimer()
_state.value = RecordState.IDLE
_durationMs.value = 0L
Log.i(TAG, "Recording cancelled")
}
fun cleanup() {
try { recorder?.release() } catch (_: Exception) { }
recorder = null
outputFile?.delete()
outputFile = null
cancelTimer()
_state.value = RecordState.IDLE
_durationMs.value = 0L
}
fun getBase64(): String? {
val file = outputFile ?: return null
if (!file.exists()) return null
return try {
val bytes = file.readBytes()
Base64.encodeToString(bytes, Base64.NO_WRAP)
} catch (e: Exception) {
Log.e(TAG, "Failed to encode audio: ${e.message}", e)
null
}
}
fun deleteFile() {
outputFile?.delete()
outputFile = null
}
private fun startTimer() {
cancelTimer()
val startTime = System.currentTimeMillis()
timerJob = CoroutineScope(Dispatchers.Main).launch {
while (true) {
delay(100)
_durationMs.value = System.currentTimeMillis() - startTime
}
}
}
private fun cancelTimer() {
timerJob?.cancel()
timerJob = null
}
companion object {
private const val TAG = "VoiceRecorder"
}
}
@@ -1,281 +0,0 @@
package top.yeij.cyrene.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.util.VoiceRecorder
private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this
val result = mutableListOf(this[0])
for (i in 1 until size) {
val prev = result.last()
val curr = this[i]
val isDuplicate = curr.id == prev.id ||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
if (!isDuplicate) {
result.add(curr)
}
}
return result
}
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
for (msg in this) {
val containedCount = count { other ->
other.id != msg.id &&
other.content.isNotBlank() &&
other.content.length < msg.content.length &&
msg.content.contains(other.content) &&
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
}
if (containedCount >= 2) {
toRemove.add(msg.id)
}
}
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
}
class ChatViewModel(
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
) : ViewModel() {
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val connectionError: StateFlow<String?> = chatRepository.connectionError
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
private val _isSending = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = kotlinx.coroutines.flow.combine(
_isSending,
chatRepository.isAssistantStreaming,
) { sending, assistant -> sending || assistant }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// Voice recording state
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
// Animation ordering for message bubbles
private var animCounter = 0
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var currentSessionId: String? = null
private var dbObserverJob: Job? = null
init {
// Phase 1: find/create main session, reconnect WS, load server history
viewModelScope.launch {
try {
val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
chatRepository.ensureConnected()
loadMessagesFromDb(sessionId)
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
if (serverMessages.isNotEmpty()) {
val serverIds = serverMessages.map { it.id }.toSet()
_currentMessages.update { current ->
val localOnly = current.filter { it.id !in serverIds }
(serverMessages + localOnly)
.sortedBy { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
}
} catch (_: Exception) { }
}
// Observe incoming live messages with atomic dedup
viewModelScope.launch {
chatRepository.observeMessages().collect { message ->
try {
_currentMessages.update { list ->
val updated = list.toMutableList()
val existingIdx = updated.indexOfLast { it.id == message.id }
if (existingIdx >= 0) {
updated[existingIdx] = message
} else {
val isDup = updated.any {
it.role == message.role && it.content == message.content && it.msgType == message.msgType
}
if (!isDup) {
updated.add(message)
val idx = _messageAnimIndex.value.toMutableMap()
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
}
updated.deduplicate()
}
} catch (e: Exception) {
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
}
}
}
// Observe message clear events
viewModelScope.launch {
chatRepository.messageClearEvents.collect {
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
// Reset user-side sending state when server starts responding
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) _isSending.value = false
}
}
}
// --- Voice recording (WeChat-style gesture) ---
fun startRecord() {
voiceRecorder.start()
}
fun lockRecord() {
voiceRecorder.lock()
}
fun finishRecord() {
val file = voiceRecorder.stop() ?: return
val base64 = voiceRecorder.getBase64()
voiceRecorder.deleteFile()
if (base64.isNullOrBlank()) return
viewModelScope.launch {
chatRepository.sendVoiceInput(base64, "voice_msg")
RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}")
}
}
fun cancelRecord() {
voiceRecorder.cancel()
}
private fun loadMessagesFromDb(sessionId: String) {
dbObserverJob?.cancel()
dbObserverJob = viewModelScope.launch {
try {
chatRepository.getMessages(sessionId).collect { messages ->
_currentMessages.update { current ->
val live = current.associateBy { it.id }
val db = messages.associateBy { it.id }
(db + live).values
.sortedBy { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
val idx = _messageAnimIndex.value.toMutableMap()
messages.forEach { m ->
if (m.id !in idx) idx[m.id] = animCounter++
}
_messageAnimIndex.value = idx
}
} catch (e: Exception) {
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
}
}
}
fun onInputChanged(text: String) {
_inputText.value = text
}
fun sendMessage() {
val text = _inputText.value.trim()
if (text.isEmpty()) return
_inputText.value = ""
_isSending.value = true
val sid = currentSessionId
viewModelScope.launch {
chatRepository.sendMessage(text, sid)
}
}
fun switchSession(sessionId: String) {
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
viewModelScope.launch {
chatRepository.connectWebSocket(sessionId)
chatRepository.loadMessagesFromServer(sessionId)
}
loadMessagesFromDb(sessionId)
}
fun refreshMessages() {
val sid = currentSessionId ?: return
viewModelScope.launch {
_isRefreshing.value = true
try {
if (!isConnected.value) {
chatRepository.ensureConnected()
}
val serverMessages = chatRepository.loadMessagesFromServer(sid)
if (serverMessages.isNotEmpty()) {
val serverIds = serverMessages.map { it.id }.toSet()
_currentMessages.update { current ->
val localOnly = current.filter { it.id !in serverIds }
(serverMessages + localOnly)
.sortedBy { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
}
} catch (_: Exception) { }
_isRefreshing.value = false
}
}
fun deleteConversation(id: String) {
viewModelScope.launch {
chatRepository.deleteConversation(id)
}
}
fun clearLocalMessages() {
viewModelScope.launch {
chatRepository.clearLocalMessages()
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
}
@@ -1,59 +0,0 @@
package top.yeij.cyrene.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Device
import top.yeij.cyrene.domain.repository.IoTRepository
class IoTViewModel(
private val ioTRepository: IoTRepository,
) : ViewModel() {
val devices: StateFlow<List<Device>> = ioTRepository.getDevices()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
init {
refreshDevices()
}
fun refreshDevices() {
viewModelScope.launch {
_isLoading.value = true
ioTRepository.refreshDevices()
_isLoading.value = false
}
}
fun controlDevice(deviceId: String, action: String, value: Any? = null) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
ioTRepository.controlDevice(deviceId, action, value).fold(
onSuccess = { },
onFailure = { _error.value = it.message },
)
_isLoading.value = false
}
}
fun togglePower(device: Device) {
val action = if (device.state.power == true) "off" else "on"
controlDevice(device.id, action)
}
fun clearError() {
_error.value = null
}
}
@@ -1,237 +0,0 @@
package top.yeij.cyrene.viewmodel
import android.util.Log
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
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.util.Constants
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.VoiceRecorder
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this
val result = mutableListOf(this[0])
for (i in 1 until size) {
val prev = result.last()
val curr = this[i]
val isDuplicate = curr.id == prev.id ||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
if (!isDuplicate) {
result.add(curr)
}
}
return result
}
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
for (msg in this) {
val containedCount = count { other ->
other.id != msg.id &&
other.content.isNotBlank() &&
other.content.length < msg.content.length &&
msg.content.contains(other.content) &&
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
}
if (containedCount >= 2) {
toRemove.add(msg.id)
}
}
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
}
enum class OverlayState {
IDLE,
LISTENING,
PROCESSING,
SPEAKING,
WAITING,
}
class OverlayViewModel(
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val ttsEngine: TextToSpeechEngine,
) : ViewModel() {
private val _state = MutableStateFlow(OverlayState.WAITING)
val state: StateFlow<OverlayState> = _state.asStateFlow()
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
// Animation ordering for message bubbles
private var animCounter = 0
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var silenceTimer: Job? = null
private var lastAssistantMessageId: String? = null
init {
viewModelScope.launch {
chatRepository.observeMessages().collect { message ->
_messages.update { list ->
val updated = list.toMutableList()
val idx = updated.indexOfLast { it.id == message.id }
if (idx >= 0) {
updated[idx] = message
} else {
val isDup = updated.any {
it.role == message.role && it.content == message.content && it.msgType == message.msgType
}
if (!isDup) {
updated.add(message)
val animIdx = _messageAnimIndex.value.toMutableMap()
animIdx[message.id] = animCounter++
_messageAnimIndex.value = animIdx
}
}
updated.deduplicate()
}
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
lastAssistantMessageId = message.id
speakResponse(message.content)
}
}
}
}
viewModelScope.launch {
ttsEngine.onDone.collect {
if (_state.value == OverlayState.SPEAKING) {
setWaiting()
}
}
}
viewModelScope.launch {
chatRepository.messageClearEvents.collect {
_messages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
}
fun onInputChanged(text: String) {
_inputText.value = text
}
fun sendText() {
val text = _inputText.value.trim()
if (text.isEmpty()) return
_inputText.value = ""
_state.value = OverlayState.PROCESSING
cancelSilenceTimer()
viewModelScope.launch {
chatRepository.sendMessage(text, null)
}
}
// --- Voice recording (WeChat-style gesture) ---
fun startRecord() {
voiceRecorder.start()
_state.value = OverlayState.LISTENING
cancelSilenceTimer()
}
fun lockRecord() {
voiceRecorder.lock()
}
fun finishRecord() {
val file = voiceRecorder.stop() ?: return
val base64 = voiceRecorder.getBase64()
voiceRecorder.deleteFile()
if (base64.isNullOrBlank()) return
_state.value = OverlayState.PROCESSING
viewModelScope.launch {
chatRepository.sendVoiceInput(base64, "voice_msg")
}
}
fun cancelRecord() {
voiceRecorder.cancel()
setWaiting()
}
fun cancelCurrentAction() {
if (voiceRecorder.state.value == RecordState.LOCKED) {
voiceRecorder.cancel()
setWaiting()
}
}
private fun speakResponse(text: String) {
if (text.isBlank()) return
_state.value = OverlayState.SPEAKING
ttsEngine.speak(text)
}
private fun setWaiting() {
_state.value = OverlayState.WAITING
startSilenceTimer()
}
fun stopSpeaking() {
ttsEngine.stop()
if (_state.value == OverlayState.SPEAKING) {
setWaiting()
}
}
fun sendScreenContext(content: String) {
if (content.isBlank()) return
viewModelScope.launch {
chatRepository.sendScreenContext(content)
}
}
fun finish() {
_state.value = OverlayState.IDLE
cancelSilenceTimer()
voiceRecorder.cancel()
ttsEngine.stop()
}
private fun startSilenceTimer() {
cancelSilenceTimer()
silenceTimer = viewModelScope.launch {
delay(Constants.SILENCE_TIMEOUT_MS)
if (_state.value == OverlayState.WAITING) {
_state.value = OverlayState.IDLE
}
}
}
private fun cancelSilenceTimer() {
silenceTimer?.cancel()
silenceTimer = null
}
override fun onCleared() {
voiceRecorder.cancel()
ttsEngine.shutdown()
super.onCleared()
}
}
@@ -1,120 +0,0 @@
package top.yeij.cyrene.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.domain.repository.AuthRepository
data class ProfileState(
val userId: String = "",
val username: String = "",
val nickname: String = "",
val isAdmin: Boolean = false,
val createdAt: String = "",
val isLoading: Boolean = false,
val isLoggedIn: Boolean = false,
)
class ProfileViewModel(
private val apiService: ApiService,
private val authRepository: AuthRepository,
private val prefs: PreferencesDataStore,
) : ViewModel() {
private val _profile = MutableStateFlow(ProfileState())
val profile: StateFlow<ProfileState> = _profile.asStateFlow()
private var loadedFromCache = false
init {
viewModelScope.launch {
_profile.value = _profile.value.copy(isLoggedIn = authRepository.isLoggedIn())
if (_profile.value.isLoggedIn) {
loadCachedProfile()
fetchFreshProfile()
}
}
}
/**
* Show cached profile immediately for instant UI.
*/
private suspend fun loadCachedProfile() {
val userId = prefs.profileUserId.firstOrNull() ?: return
val nickname = prefs.profileNickname.firstOrNull() ?: ""
val isAdmin = prefs.profileIsAdmin.firstOrNull()?.toBoolean() ?: false
val createdAt = prefs.profileCreatedAt.firstOrNull() ?: ""
val username = prefs.username.firstOrNull() ?: ""
_profile.value = ProfileState(
userId = userId,
username = username,
nickname = nickname,
isAdmin = isAdmin,
createdAt = createdAt,
isLoggedIn = true,
)
loadedFromCache = true
}
/**
* Fetch fresh profile from server. On success, update cache and UI.
* On failure, keep showing cached data — no error, no UI disruption.
*/
fun fetchFreshProfile() {
viewModelScope.launch {
if (!loadedFromCache) {
_profile.value = _profile.value.copy(isLoading = true)
}
try {
val response = apiService.getProfile()
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
val dateStr = body.createdAt?.take(10) ?: ""
val nickname = body.nickname ?: body.username
_profile.value = ProfileState(
userId = body.userId,
username = body.username,
nickname = nickname,
isAdmin = body.isAdmin == true,
createdAt = dateStr,
isLoading = false,
isLoggedIn = true,
)
loadedFromCache = true
// Update local cache
prefs.saveProfileCache(body.userId, nickname, body.isAdmin == true, dateStr)
}
} else if (!loadedFromCache) {
_profile.value = _profile.value.copy(isLoading = false)
}
// On error with cache already shown: silently ignore
} catch (e: Exception) {
Log.w("ProfileVM", "Failed to fetch fresh profile: ${e.message}")
if (!loadedFromCache) {
_profile.value = _profile.value.copy(isLoading = false)
}
// If cached data is showing, keep it silently
}
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
prefs.clearProfileCache()
_profile.value = ProfileState()
loadedFromCache = false
}
}
}
@@ -1,180 +0,0 @@
package top.yeij.cyrene.viewmodel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.domain.repository.AuthRepository
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.voice.stt.SttManager
class SettingsViewModel(
private val authRepository: AuthRepository,
private val preferencesDataStore: PreferencesDataStore,
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
private val chatRepository: ChatRepository,
private val sttManager: SttManager,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val _baseUrl = MutableStateFlow("")
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
private val _themeMode = MutableStateFlow("auto")
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
private val _wakeWord = MutableStateFlow("昔涟")
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
private val _dashScopeApiKey = MutableStateFlow("")
val dashScopeApiKey: StateFlow<String> = _dashScopeApiKey.asStateFlow()
private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference")
val dashScopeEndpoint: StateFlow<String> = _dashScopeEndpoint.asStateFlow()
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
init {
scope.launch {
_isLoggedIn.value = authRepository.isLoggedIn()
}
// Single collector for all DataStore preferences — avoids subscriber explosion
scope.launch {
preferencesDataStore.autoScreenContext.collect { value ->
_autoScreenContext.value = value
}
}
scope.launch {
combine(
preferencesDataStore.baseUrl,
preferencesDataStore.themeMode,
preferencesDataStore.wakeWord,
preferencesDataStore.username,
combine(
preferencesDataStore.dashScopeApiKey,
preferencesDataStore.dashScopeEndpoint,
preferencesDataStore.dashScopeModel,
) { apiKey, endpoint, model ->
Triple(apiKey, endpoint, model)
},
) { baseUrl, themeMode, wakeWord, username, dashScope ->
baseUrl?.let { url ->
if (url.isNotBlank()) {
_baseUrl.value = url
dynamicUrlInterceptor.baseUrl = url
}
}
themeMode?.let { _themeMode.value = it }
wakeWord?.let { word ->
if (word.isNotBlank()) _wakeWord.value = word
}
username?.let { _username.value = it }
val (apiKey, endpoint, model) = dashScope
apiKey?.let { key ->
if (key.isNotBlank()) _dashScopeApiKey.value = key
sttManager.updateDashScopeApiKey(key)
}
endpoint?.let { ep ->
if (ep.isNotBlank()) _dashScopeEndpoint.value = ep
}
model?.let { m ->
if (m.isNotBlank()) _dashScopeModel.value = m
}
// Push full config to STT
sttManager.configureDashScope(
apiKey = _dashScopeApiKey.value,
endpoint = _dashScopeEndpoint.value,
model = _dashScopeModel.value,
)
}.collect { }
}
}
fun updateBaseUrlInput(url: String) {
_baseUrl.value = url
}
fun saveBaseUrl(url: String) {
_baseUrl.value = url
dynamicUrlInterceptor.baseUrl = url
scope.launch {
preferencesDataStore.saveBaseUrl(url)
chatRepository.reconnectWebSocket()
}
}
fun saveThemeMode(mode: String) {
_themeMode.value = mode
scope.launch { preferencesDataStore.saveThemeMode(mode) }
}
fun saveWakeWord(word: String) {
_wakeWord.value = word
scope.launch { preferencesDataStore.saveWakeWord(word) }
}
fun updateDashScopeApiKeyInput(key: String) {
_dashScopeApiKey.value = key
}
fun saveDashScopeApiKey(key: String) {
_dashScopeApiKey.value = key
sttManager.updateDashScopeApiKey(key)
scope.launch { preferencesDataStore.saveDashScopeApiKey(key) }
}
fun updateDashScopeEndpointInput(endpoint: String) {
_dashScopeEndpoint.value = endpoint
}
fun saveDashScopeEndpoint(endpoint: String) {
_dashScopeEndpoint.value = endpoint
sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value)
scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) }
}
fun updateDashScopeModelInput(model: String) {
_dashScopeModel.value = model
}
fun saveDashScopeModel(model: String) {
_dashScopeModel.value = model
sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model)
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
}
fun saveAutoScreenContext(enabled: Boolean) {
_autoScreenContext.value = enabled
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
}
fun clearLocalMessages() {
scope.launch {
chatRepository.clearLocalMessages()
}
}
fun logout() {
scope.launch {
authRepository.logout()
_isLoggedIn.value = false
}
}
}
@@ -1,27 +0,0 @@
package top.yeij.cyrene.voice.hotword
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class HotwordDetector {
private val _isListening = MutableStateFlow(false)
val isListening = _isListening.asStateFlow()
private val _onDetected = MutableStateFlow(false)
val onDetected = _onDetected.asStateFlow()
fun startListening(wakeWord: String) {
_isListening.value = true
// Integrate system AlwaysOnHotwordDetector or Porcupine SDK here
}
fun stopListening() {
_isListening.value = false
}
fun updateWakeWord(newWord: String) {
stopListening()
startListening(newWord)
}
}
@@ -1,160 +0,0 @@
package top.yeij.cyrene.voice.stt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.repository.ChatRepository
import java.io.ByteArrayOutputStream
class BackendSttProvider(
private val context: Context,
private val chatRepository: ChatRepository,
) : SttProvider {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var audioRecord: AudioRecord? = null
private var recordingJob: Job? = null
private val audioBuffer = ByteArrayOutputStream()
private val _partialResult = MutableStateFlow("")
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val sampleRate = 16000
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(3200)
override fun start() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
_onError.tryEmit("缺少录音权限")
return
}
cancel()
_isListening.value = true
_partialResult.value = ""
audioBuffer.reset()
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
sampleRate,
channelConfig,
audioFormat,
bufferSize,
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord init failed")
_onError.tryEmit("麦克风初始化失败")
_isListening.value = false
return
}
it.startRecording()
}
val readBuffer = ByteArray(bufferSize)
recordingJob = scope.launch {
while (isActive && _isListening.value) {
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
if (bytesRead > 0) {
audioBuffer.write(readBuffer, 0, bytesRead)
} else if (bytesRead < 0) break
}
}
} catch (e: SecurityException) {
_onError.tryEmit("缺少录音权限")
_isListening.value = false
} catch (e: Exception) {
Log.e(TAG, "Recording error", e)
_onError.tryEmit("录音失败: ${e.message}")
_isListening.value = false
}
}
override fun stop() {
if (!_isListening.value) return
Log.d(TAG, "Stopping recording")
stopRecording()
val audioBytes = audioBuffer.toByteArray()
if (audioBytes.isEmpty()) {
Log.w(TAG, "No audio data recorded")
_isListening.value = false
_onError.tryEmit("未录制到语音")
return
}
_isListening.value = false
scope.launch {
try {
val base64 = Base64.encodeToString(audioBytes, Base64.NO_WRAP)
chatRepository.sendVoiceInput(base64, "voice_msg")
Log.d(TAG, "Sent ${audioBytes.size} bytes of audio to backend")
} catch (e: Exception) {
Log.e(TAG, "Failed to send voice input", e)
_onError.tryEmit("发送语音失败: ${e.message}")
}
}
}
override fun cancel() {
if (!_isListening.value) return
Log.d(TAG, "Cancelling recording")
stopRecording()
audioBuffer.reset()
_isListening.value = false
_partialResult.value = ""
}
private fun stopRecording() {
recordingJob?.cancel()
recordingJob = null
try {
audioRecord?.stop()
audioRecord?.release()
} catch (e: Exception) {
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
}
audioRecord = null
}
fun shutdown() {
cancel()
scope.cancel()
}
companion object {
private const val TAG = "BackendSTT"
}
}
@@ -1,355 +0,0 @@
package top.yeij.cyrene.voice.stt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class DashScopeSttService(
private val context: Context,
) : SttProvider {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val gson = Gson()
private val connectionId = AtomicInteger(0)
private var webSocket: WebSocket? = null
private var audioRecord: AudioRecord? = null
private var recordingJob: Job? = null
private var taskId: String = ""
private val _partialResult = MutableStateFlow("")
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
// Configurable via settings
var apiKey: String = ""
var endpoint: String = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
var model: String = "fun-asr-realtime"
var enableInterimResults: Boolean = true
private val sampleRate = 16000
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val bytesPerSample = 2
private val chunkMs = 100
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(sampleRate * bytesPerSample * chunkMs / 1000)
override fun start() {
if (apiKey.isBlank()) {
Log.w(TAG, "DashScope API key not configured")
_onError.tryEmit("请先配置 DashScope API Key")
return
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
_onError.tryEmit("缺少录音权限")
return
}
cancel()
taskId = UUID.randomUUID().toString()
_isListening.value = true
_partialResult.value = ""
connectWebSocket()
startRecording()
}
override fun stop() {
if (!_isListening.value) return
Log.d(TAG, "Stopping recognition, taskId=$taskId")
stopRecording()
sendFinishTask()
}
override fun cancel() {
if (!_isListening.value) return
Log.d(TAG, "Cancelling recognition, taskId=$taskId")
stopRecording()
closeWebSocket()
_isListening.value = false
_partialResult.value = ""
}
private fun connectWebSocket() {
val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(0, TimeUnit.MILLISECONDS)
.callTimeout(0, TimeUnit.MILLISECONDS)
.build()
val request = Request.Builder()
.url(endpoint)
.header("Authorization", "Bearer $apiKey")
.build()
val connId = connectionId.incrementAndGet()
Log.i(TAG, "[#$connId] Connecting to DashScope: $endpoint")
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (connectionId.get() != connId) return
Log.i(TAG, "[#$connId] Connected, sending run-task")
sendRunTask()
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
handleServerMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (connectionId.get() != connId) return
Log.e(TAG, "[#$connId] WebSocket failure: ${t.message}", t)
_isListening.value = false
_onError.tryEmit("语音识别连接失败: ${t.message}")
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.d(TAG, "[#$connId] Server closing: $code $reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.d(TAG, "[#$connId] Closed: $code $reason")
_isListening.value = false
}
})
}
private fun sendRunTask() {
val msg = mapOf(
"header" to mapOf(
"action" to "run-task",
"task_id" to taskId,
"streaming" to "duplex",
),
"payload" to mapOf(
"model" to model,
"parameters" to mapOf(
"format" to "pcm",
"sample_rate" to sampleRate,
),
"input" to emptyMap<String, String>(),
),
)
val json = gson.toJson(msg)
Log.d(TAG, "Sending run-task: $json")
webSocket?.send(json)
}
private fun sendFinishTask() {
val msg = mapOf(
"header" to mapOf(
"action" to "finish-task",
"task_id" to taskId,
),
"payload" to emptyMap<String, String>(),
)
val json = gson.toJson(msg)
Log.d(TAG, "Sending finish-task: $json")
webSocket?.send(json)
}
private fun handleServerMessage(text: String) {
try {
val response = gson.fromJson(text, DashScopeResponse::class.java)
val header = response.header ?: return
val event = header.event ?: return
when (event) {
"result-generated" -> {
val sentence = response.payload?.output?.sentence ?: return
val sentenceText = sentence.text ?: return
_partialResult.value = sentenceText
val sttResult = SttResult(
text = sentenceText,
isFinal = sentence.sentenceEnd ?: true,
)
if (sttResult.isFinal) {
_finalResult.tryEmit(sttResult)
} else if (enableInterimResults) {
_finalResult.tryEmit(sttResult)
}
}
"task-finished" -> {
Log.i(TAG, "Task finished: $taskId")
// If no result-generated was received, emit what we have
val partial = _partialResult.value
if (partial.isNotBlank()) {
_finalResult.tryEmit(SttResult(text = partial, isFinal = true))
}
closeWebSocket()
_isListening.value = false
}
"task-failed" -> {
val error = header.errorMessage ?: "语音识别失败"
Log.e(TAG, "Task failed: $error")
_onError.tryEmit(error)
closeWebSocket()
_isListening.value = false
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse server message: ${e.message}")
}
}
private fun startRecording() {
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
sampleRate,
channelConfig,
audioFormat,
bufferSize,
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord initialization failed")
_onError.tryEmit("麦克风初始化失败")
_isListening.value = false
return
}
it.startRecording()
}
Log.d(TAG, "Recording started, bufferSize=$bufferSize")
val readBuffer = ByteArray(bufferSize)
recordingJob = scope.launch {
while (isActive && _isListening.value) {
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
if (bytesRead > 0) {
val data = readBuffer.copyOf(bytesRead)
webSocket?.let { ws ->
try {
ws.send(ByteString.of(*data))
} catch (e: Exception) {
Log.w(TAG, "Failed to send audio: ${e.message}")
}
}
} else if (bytesRead < 0) {
Log.w(TAG, "AudioRecord read error: $bytesRead")
break
}
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Missing RECORD_AUDIO permission", e)
_onError.tryEmit("缺少录音权限")
_isListening.value = false
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording", e)
_onError.tryEmit("录音启动失败: ${e.message}")
_isListening.value = false
}
}
private fun stopRecording() {
recordingJob?.cancel()
recordingJob = null
try {
audioRecord?.stop()
audioRecord?.release()
} catch (e: Exception) {
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
}
audioRecord = null
Log.d(TAG, "Recording stopped")
}
private fun closeWebSocket() {
try {
webSocket?.close(1000, "Done")
} catch (_: Exception) { }
webSocket = null
}
fun shutdown() {
cancel()
scope.cancel()
}
companion object {
private const val TAG = "DashScopeSTT"
}
}
// --- JSON DTOs for DashScope WebSocket protocol ---
data class DashScopeResponse(
@SerializedName("header") val header: DashScopeHeader?,
@SerializedName("payload") val payload: DashScopePayload?,
)
data class DashScopeHeader(
@SerializedName("task_id") val taskId: String?,
@SerializedName("event") val event: String?,
@SerializedName("action") val action: String?,
@SerializedName("error_message") val errorMessage: String?,
)
data class DashScopePayload(
@SerializedName("output") val output: DashScopeOutput?,
@SerializedName("usage") val usage: Map<String, Any>?,
)
data class DashScopeOutput(
@SerializedName("sentence") val sentence: DashScopeSentence?,
)
data class DashScopeSentence(
@SerializedName("text") val text: String?,
@SerializedName("sentence_end") val sentenceEnd: Boolean?,
@SerializedName("sentence_id") val sentenceId: Int?,
@SerializedName("begin_time") val beginTime: Long?,
@SerializedName("end_time") val endTime: Long?,
@SerializedName("words") val words: List<Map<String, Any>>?,
@SerializedName("emo_tag") val emoTag: String?,
)
@@ -1,118 +0,0 @@
package top.yeij.cyrene.voice.stt
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
class SpeechRecognizer(private val context: Context) : SttProvider {
private var recognizer: android.speech.SpeechRecognizer? = null
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val _partialResult = MutableStateFlow("")
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
override fun start() {
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(context)) {
Log.w(TAG, "Speech recognition not available on this device")
_onError.tryEmit("语音识别不可用")
return
}
cancel()
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(context).apply {
setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
_partialResult.value = ""
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onError(error: Int) {
_isListening.value = false
val msg = when (error) {
android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时"
android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误"
android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误"
android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误"
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音"
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别"
android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌"
android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限"
else -> "未知错误 ($error)"
}
Log.w(TAG, "Recognition error: $msg")
_onError.tryEmit(msg)
}
override fun onResults(results: Bundle?) {
_isListening.value = false
val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
_finalResult.tryEmit(SttResult(text = matches[0], isFinal = true))
}
}
override fun onPartialResults(partialResults: Bundle?) {
val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
_partialResult.value = matches[0]
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
}
startListening(intent)
}
}
override fun stop() {
recognizer?.stopListening()
_isListening.value = false
}
override fun cancel() {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
_isListening.value = false
_partialResult.value = ""
}
companion object {
private const val TAG = "CyreneSTT"
}
}
@@ -1,128 +0,0 @@
package top.yeij.cyrene.voice.stt
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.util.RuntimeLog
class SttManager(
private val dashScopeService: DashScopeSttService,
private val backendProvider: BackendSttProvider,
private val systemRecognizer: SpeechRecognizer,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val _partialResult = MutableStateFlow("")
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
val onError: SharedFlow<String> = _onError.asSharedFlow()
private var activeProvider: SttProvider? = null
private var dashScopeFailed = false
fun start() {
cancel()
val provider = if (dashScopeService.apiKey.isNotBlank() && !dashScopeFailed) {
Log.d(TAG, "Using DashScope STT")
RuntimeLog.stt("start", "Using DashScope provider")
dashScopeService.also { dashScopeFailed = false }
} else {
Log.d(TAG, "Using Backend STT (fallback)")
RuntimeLog.stt("start", "Using Backend provider (fallback)")
backendProvider
}
activeProvider = provider
_isListening.value = true
_partialResult.value = ""
collectFrom(provider)
provider.start()
}
fun stop() {
RuntimeLog.stt("stop", "Stopping STT")
activeProvider?.stop()
_isListening.value = false
}
fun cancel() {
RuntimeLog.stt("cancel", "Cancelling STT")
activeProvider?.cancel()
activeProvider = null
_isListening.value = false
_partialResult.value = ""
}
fun configureDashScope(apiKey: String, endpoint: String, model: String) {
dashScopeService.apiKey = apiKey
dashScopeService.endpoint = endpoint
dashScopeService.model = model
dashScopeFailed = false
}
fun updateDashScopeApiKey(apiKey: String) {
dashScopeService.apiKey = apiKey
if (apiKey.isNotBlank()) dashScopeFailed = false
}
fun shutdown() {
cancel()
dashScopeService.shutdown()
backendProvider.shutdown()
scope.cancel()
}
private fun collectFrom(provider: SttProvider) {
scope.launch {
provider.partialResult.collect { text ->
if (provider == activeProvider && text.isNotEmpty()) {
_partialResult.value = text
}
}
}
scope.launch {
provider.finalResult.collect { result ->
if (provider == activeProvider) {
RuntimeLog.stt("result", "Final result: isFinal=${result.isFinal} text=${result.text.take(80)}")
_finalResult.tryEmit(result)
}
}
}
scope.launch {
provider.onError.collect { error ->
if (provider == activeProvider) {
Log.w(TAG, "STT error: $error")
RuntimeLog.stt("error", error)
_onError.tryEmit(error)
if (provider == dashScopeService) {
dashScopeFailed = true
RuntimeLog.stt("fallback", "DashScope failed, will use backend next time")
Log.i(TAG, "DashScope failed, will use backend next time")
}
}
}
}
}
companion object {
private const val TAG = "SttManager"
}
}
@@ -1,23 +0,0 @@
package top.yeij.cyrene.voice.stt
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
enum class SttProviderType { DASHSCOPE, BACKEND, SYSTEM }
data class SttResult(
val text: String,
val isFinal: Boolean,
val isError: Boolean = false,
)
interface SttProvider {
val partialResult: StateFlow<String>
val finalResult: SharedFlow<SttResult>
val onError: SharedFlow<String>
val isListening: StateFlow<Boolean>
fun start()
fun stop()
fun cancel()
}
@@ -1,97 +0,0 @@
package top.yeij.cyrene.voice.tts
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
class TextToSpeechEngine(private val context: Context) {
private var tts: TextToSpeech? = null
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val _isSpeaking = MutableStateFlow(false)
val isSpeaking = _isSpeaking.asStateFlow()
private val _onDone = MutableStateFlow(false)
val onDone = _onDone.asStateFlow()
fun initialize(onReady: () -> Unit) {
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.CHINESE
tts?.setSpeechRate(0.9f)
tts?.setPitch(1.1f)
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
requestAudioFocus()
_isSpeaking.value = true
}
override fun onDone(utteranceId: String?) {
_isSpeaking.value = false
_onDone.value = true
abandonAudioFocus()
}
override fun onError(utteranceId: String?) {
_isSpeaking.value = false
abandonAudioFocus()
}
})
onReady()
}
}
}
fun speak(text: String, utteranceId: String = System.currentTimeMillis().toString()) {
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
}
fun stop() {
tts?.stop()
_isSpeaking.value = false
}
fun shutdown() {
tts?.stop()
tts?.shutdown()
}
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.build()
audioManager.requestAudioFocus(focusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
null,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
)
}
}
private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Focus released with the request
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(null)
}
}
}
@@ -0,0 +1,348 @@
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
import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.repository.UploadRepository
import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */
class AppContainer(context: Context) {
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
val appContext: Context = context.applicationContext
val sessionStore: SessionStore = SessionStore(context.applicationContext)
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
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
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
val sessionsApi: SessionsApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedSessionsApi
if (cached != null && cachedBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = SessionsApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedBaseUrl = baseUrl
cachedSessionsApi = created
return created
}
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
private var cachedChatApi: ChatApi? = null
private var cachedChatBaseUrl: String? = null
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val chatApi: ChatApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedChatApi
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = ChatApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedChatBaseUrl = baseUrl
cachedChatApi = created
return created
}
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
private var cachedUploadApi: UploadApi? = null
private var cachedUploadBaseUrl: String? = null
/**
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
*/
val uploadApi: UploadApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedUploadApi
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = UploadApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedUploadBaseUrl = baseUrl
cachedUploadApi = created
return created
}
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
private var cachedDocumentsApi: DocumentsApi? = null
private var cachedDocumentsBaseUrl: String? = null
/**
* 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节);
* 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给
* 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。
*
* Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient**
* 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。
*/
val documentsApi: DocumentsApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedDocumentsApi
if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = DocumentsApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedDocumentsBaseUrl = baseUrl
cachedDocumentsApi = created
return created
}
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
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val adminApi: AdminApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedAdminApi
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = AdminApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedAdminBaseUrl = baseUrl
cachedAdminApi = created
return created
}
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
private var cachedMetaApi: MetaApi? = null
private var cachedMetaBaseUrl: String? = null
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val metaApi: MetaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedMetaApi
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MetaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedMetaBaseUrl = baseUrl
cachedMetaApi = created
return created
}
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
private var cachedPersonaApi: PersonaApi? = null
private var cachedPersonaBaseUrl: String? = null
/** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
val personaApi: PersonaApi
get() {
val baseUrl = authManager.currentBaseUrl
val cached = cachedPersonaApi
if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = PersonaApi::class.java,
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedPersonaBaseUrl = baseUrl
cachedPersonaApi = created
return created
}
val personaRepository: PersonaRepository get() = PersonaRepository(personaApi)
/**
* 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。
*
* ## 为什么可以共享(不是「省点资源」,是**逐帧等价**)
* 服务端是**广播**:下行帧发给**每一条**已认证连接、**不按会话订阅**(见 [ChatSocket] 类注释
* 「会话归属」)。于是改前「N 条连接,每条都收到全部帧 ⇒ 每个 VM 都收到全部帧 ⇒ 各 VM 靠
* [top.yeij.cyrene.data.ws.WsEvent.isForSession] 收敛到自己的会话」与改后「一条连接收到全部帧
* ⇒ **每个 VM 仍然收到全部帧**」是**同一个语义**:帧集合逐帧相同,过滤与事件分支一个字都不用改。
* 这次变的只是「连接有几条」,不是「帧怎么走」。
*
* ## 为什么必须唯一
* 每个 `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`。现在这里就是唯一的
* 持有者(`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()`、
* `LoggedOut → close()`(为什么是它,见那个类的 KDoc)。`connect()` 幂等,baseUrl / token 都是
* 每次重连现读 ⇒ 登出重登、换账号**不需要**重建本实例。**任何 ViewModel 都不许关它**——
* 细节与理由写在 [ChatSocket.close]。
*/
val chatSocket: ChatSocket by lazy {
ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = authManager::currentToken,
onAuthRecover = authManager::recoverSocketAuth,
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,
)
}
@@ -0,0 +1,70 @@
package top.yeij.cyrene.app
import androidx.compose.runtime.Composable
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
@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)
is SessionState.LoggedIn -> ChatHomeScreen(
container = container,
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
onLogout = { scope.launch { container.authManager.logout() } },
)
}
}
@@ -0,0 +1,14 @@
package top.yeij.cyrene.app
import android.app.Application
class CyreneApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.app
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.LocalAppPrefs
import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.core.theme.CyreneTheme
import top.yeij.cyrene.domain.enums.LocalEnumCatalog
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
/**
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
*/
@Composable
fun CyreneRoot(container: AppContainer) {
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
)
val catalog by enumCatalogViewModel.catalog.collectAsState()
val darkTheme = when (prefs.themeMode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = view.context.findActivity()?.window ?: return@SideEffect
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
CompositionLocalProvider(
LocalAppPrefs provides prefs,
LocalEnumCatalog provides catalog,
) {
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
CyreneApp(container)
}
}
}
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
ThemeMode.System -> ColorSchemeMode.System
ThemeMode.Light -> ColorSchemeMode.Light
ThemeMode.Dark -> ColorSchemeMode.Dark
}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
*
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
*/
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
init {
viewModelScope.launch {
container.authManager.state.collect { state ->
if (state == SessionState.LoggedOut) {
container.enumPreferences.clear()
_catalog.value = EnumCatalog.Empty
return@collect
}
val fingerprint = fingerprint()
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
val snapshot = container.enumPreferences.data.first()
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
if (state is SessionState.LoggedIn) refresh(fingerprint)
}
}
}
private fun refresh(fingerprint: String) {
viewModelScope.launch {
container.metaRepository.enums().onSuccess { table ->
container.enumPreferences.save(table, fingerprint)
_catalog.value = EnumCatalog.of(table)
}
}
}
private fun fingerprint(): String =
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
}
@@ -0,0 +1,66 @@
package top.yeij.cyrene.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
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)
// 冷启动的两步:先清掉上一进程遗留的分享缓存(它已经没有主了),再接这次带来的分享。
// 顺序不能反——反过来会把刚拷进来的图一起删掉。
ShareIntake.reset(this)
// 只在**真正的冷启动**接 intent:Activity 因旋转 / 深色切换而重建时,系统会把原来那个
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
// 冷启动不带——这就是判据。
if (savedInstanceState == null) {
intent?.let {
ShareIntake.accept(this, it)
// 点她那条通知冷启动进来:记一笔「要看主会话」(见 ProactiveTapIntake)。
// 与分享同一个判据:重建时系统会把同一个 intent 再送一遍,那时不能重复记账
// (重复没有害处,但「冷启动才接」这条纪律只有一处,别在两处各写一半)。
container.proactiveTapIntake.accept(it)
}
}
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(可能停在设置页)」同理。
*/
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
}
}
@@ -0,0 +1,436 @@
package top.yeij.cyrene.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.StateFlow
import top.yeij.cyrene.domain.chat.DocumentAttachments
import top.yeij.cyrene.domain.chat.DocumentDelivery
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.share.PendingShareHolder
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yeij.cyrene.domain.share.SharedDocument
import java.io.File
import java.util.UUID
/**
* 「分享到 App」的接收入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)。
*
* ## 它为什么是一个进程级单例
* 分享进来时可能还没登录、ChatScreen 甚至还没被组合出来,而登录之后消费它的又是**另一个**
* Compose 实例。内容因此必须待在一个不属于任何 UI / ViewModel 的地方——见
* [PendingShareHolder]。不塞进 `AppContainer` 是因为那个文件同时被别的并行改动占用,
* 这里没有任何依赖需要它:本对象只需要一个 `Context`(读 URI、写 cache)。
*
* ## 图片为什么在收到的那一刻就落盘
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。
*
* ## 分流:图片还是文件
* 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]):
* MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码,
* 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。
*
* ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML /
* 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有
* 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`);
* 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。
*
* ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型,
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 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])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
* 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
*/
object ShareIntake {
private const val DIR_NAME = "share-intake"
private val holder = PendingShareHolder<SharedContent>()
/** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */
val pending: StateFlow<List<SharedContent>> = holder.pending
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** 进程启动时调用一次:把上一进程遗留的分享缓存清干净。 */
fun reset(context: Context) {
if (cleanedThisProcess) return
cleanedThisProcess = true
runCatching { shareDir(context).deleteRecursively() }
}
/**
* 「本进程已经清过一次」的标记。
*
* Activity 会因为旋转 / 分屏 / 深色切换而**重建**,重建就会再走一次 `onCreate`——
* 那时清缓存会把用户刚分享进来、还摆在输入框里的那张图删掉(预览变空图、发送也没有字节)。
* 缓存副本只对「产生它的进程」有意义,所以判据是**进程**而不是 Activity 实例。
*/
private var cleanedThisProcess = false
/**
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
*
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
*/
fun accept(context: Context, intent: Intent) {
val appContext = context.applicationContext
// 四个 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,
// 正文走回落链: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,
streamStrings = streamExtras.stringForms,
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
// 否则「分享点下去什么都没发生」会从那条缝里回来。
streamPresent = rawStream != null,
textPresent = textExtras.present,
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
},
)
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
if (!extracted.worthDelivering) return
scope.launch {
val dir = shareDir(appContext).apply { mkdirs() }
val copied = extracted.images.mapIndexedNotNull { index, uri ->
copyToCache(appContext, dir, uri, index)
}
val documents = extracted.documents.mapIndexedNotNull { index, document ->
copyDocumentToCache(appContext, dir, document, index)
}
holder.put(
extracted.copy(
images = copied,
documents = documents,
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size),
),
)
}
}
/**
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
*
* 出队是**唯一的认领方式**,而它在这里是原子的:两个消费者同时来也只会各拿各的一条
* (判据见 [PendingShareHolder.take])。而「**已经认领过了**」这件事此后记在认领它的那一格上
* ——`ChatViewModel` 的 [top.yeij.cyrene.ui.chat.ChatUiState.shareConfirm]。那一格活在
* `AppContainer` 那一层(`core.session.ChatSessionSlots` 的 `ViewModelStore`),比组合与
* Activity 都活得久,所以转屏 / 深浅色切换重建之后**不会再取第二遍**,也不会把队列里下一条顶掉:
* 认领的闸门在 `ChatViewModel.claimSharedContent` 里,且在调用本函数**之前**。
*/
fun take(): SharedContent? = holder.take()
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
/**
* 把一段**分享进来的超长正文**落成 cache 里的 `.txt`,返回可当附件用的待发文档;写不进去回 `null`。
*
* ## 为什么走本对象
* 落成附件的那份字节必须有个**本端的本地地址**才能进既有发送链(芯片要显示它、上传要按 URI
* 现读字节、发送失败回滚后用户还可能要点开看):`DraftDocument` 只带引用不带字节。而写进
* 「分享缓存」这个目录、跟着同一条清理规则(进程启动整目录删一次)是最省事也最一致的做法——
* 与外部分享进来的文件**同一处落地、同一条生命周期**,不另造一个没人回收的目录。
* 本函数只负责「落盘 + 报一个可用的引用」,**不判断**要不要转(那是
* [DocumentDelivery.planSharedText] 的事)。
*
* 文件名用 [ShareRules.documentCacheFileName] 生成(**只由本端 token 与序号拼路径**,
* 外部字符串一个字符都进不了文件名),但对**服务端**报的名字是
* [DocumentDelivery.TEXT_DOCUMENT_FILE_NAME] ——服务端只取扩展名(白名单里的 `.txt`),
* 其余部分会被内容寻址丢弃。
*
* @return 待发文档(含 `file://` 地址、显示名与**真实字节数**);任何一步失败都回 `null`,
* 由调用方按「内容一字不丢」的原则退回内联正文(见 `ChatViewModel.sendShared`)。
*/
suspend fun stageTextAsDocument(context: Context, text: String): DraftDocument? = withContext(Dispatchers.IO) {
val staged = runCatching { DocumentDelivery.textAsFile(text) }.getOrNull() ?: return@withContext null
val dir = shareDir(context.applicationContext).apply { mkdirs() }
val target = File(
dir,
ShareRules.documentCacheFileName(UUID.randomUUID().toString(), 0, staged.fileName),
)
try {
target.writeBytes(staged.bytes)
val size = target.length()
if (size > 0L) {
DraftDocument(
uri = Uri.fromFile(target).toString(),
displayName = staged.fileName,
sizeBytes = size,
)
} else {
target.delete()
null
}
} catch (_: Exception) {
// 半截文件不能留下:它会被当成一份真附件去读,报出的错会指向错的地方。
target.delete()
null
}
}
/**
* 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
* 失败回 `null`。
*
* 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段——
* 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜
* `document.txt`。
*/
private fun copyDocumentToCache(
context: Context,
dir: File,
document: SharedDocument,
index: Int,
): SharedDocument? {
val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null
if (parsed.scheme.isNullOrBlank()) return null
val name = displayNameOf(context, parsed)
val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name))
return try {
val source = context.contentResolver.openInputStream(parsed) ?: return null
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
val size = target.length()
if (size > 0L) {
document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size)
} else {
target.delete()
null
}
} catch (_: Exception) {
// 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。
target.delete()
null
}
}
/** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */
private fun displayNameOf(context: Context, uri: Uri): String {
val fromProvider = runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index < 0 || cursor.isNull(index)) null else cursor.getString(index)
}
}.getOrNull()
return fromProvider?.takeIf { it.isNotBlank() }
?: DocumentAttachments.fallbackDocumentName(uri.toString())
}
/**
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
*
* 必须是 `file://` 而不是裸路径:`ContentResolver.openInputStream` 认 `file` scheme,
* 但裸路径没有 scheme,会走到「找 content provider」那条路并抛 `FileNotFoundException`。
*/
private fun copyToCache(context: Context, dir: File, uri: String, index: Int): String? {
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return null
if (parsed.scheme.isNullOrBlank()) return null
val target = File(
dir,
ShareRules.cacheFileName(
token = UUID.randomUUID().toString(),
index = index,
mime = runCatching { context.contentResolver.getType(parsed) }.getOrNull(),
),
)
return try {
val source = context.contentResolver.openInputStream(parsed) ?: return null
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
if (target.length() > 0L) {
Uri.fromFile(target).toString()
} else {
target.delete()
null
}
} catch (_: Exception) {
// 半截文件不能留下:下一次发送会把它当一张真图去解码,报出的错会指向错的地方。
target.delete()
null
}
}
/**
* 一个 extra 的**原始值**(四个 extra 共用),取不到回 `null`。
*
* ## 为什么读原始值而不是 `getXxxExtra`
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
* `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.rawExtra(key: String): Any? = runCatching { extras?.get(key) }.getOrNull()
/**
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
*
* 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着
* 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种
* 情形必须能说出口,见 [SharedContent.contentUnreadable]。
*/
private fun Intent.clipUris(): List<String?> {
val clip = clipData ?: return emptyList()
return (0 until clip.itemCount).map { clip.getItemAt(it).uri?.toString() }
}
}
/**
* `EXTRA_STREAM` 一条**原始值** ⇒ 里面能读出来的地址,按形态分成两组。
*
* ## 为什么读原始值而不是挨个 `getXxxExtra` 去问
* `EXTRA_STREAM` 在真实世界里有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
* `ArrayList<String>`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`getParcelableExtra`
* 对 `String[]` 只会往 logcat 打一句
* `ClassCastException: Cannot cast [Ljava.lang.String; to android.net.Uri` 然后回 `null`,
* `getParcelableArrayListExtra` 同理——于是整次分享「什么都没发生」,连一句告知都没有。读原始值再按
* 类型分派是一次判完三种形态,顺带也不再往 logcat 里丢那两行噪声(`Bundle.get` 不做类型检查,
* 正是这里需要的;`getXxxExtra` 那一套是查完再 cast,查不中就把值扔了)。
*
* ## 形态决定信任级别
* - [uriForms]:`Uri`,以及数组 / 列表里的 `Uri`。对方是把 URI **对象**写进去的,这是规范路径;
* 本端维持既有口径不动(不看 scheme)。
* - [stringForms]:`String` / `CharSequence` 及其数组 / 列表。这是本端新开的一条读入口,所以开得窄
* ——能不能用由 [ShareRules.stringStreamUri] 逐条判(只认 `content://`)。
*
* 认不出形态的原始值(别的 `Parcelable` / 数字 / 映射……)**不猜**:两边都不收。
*/
internal data class ShareStreamExtras(
val uriForms: List<String>,
val stringForms: List<String>,
)
/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */
internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
val uriForms = mutableListOf<String>()
val stringForms = mutableListOf<String>()
fun collect(value: Any?) {
when (value) {
null -> Unit
is Uri -> uriForms += value.toString()
// 含 String。`Spanned` 之类也走这一条:它照样要过 scheme 白名单,不是「因为它是
// CharSequence 就信」。
is CharSequence -> stringForms += value.toString()
is Array<*> -> value.forEach { collect(it) }
is Iterable<*> -> value.forEach { collect(it) }
else -> Unit
}
}
collect(raw)
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,254 @@
package top.yeij.cyrene.core.auth
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorEnvelope
import top.yeij.cyrene.data.api.CyreneApi
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.RefreshRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
sealed interface SessionState {
data object Unknown : SessionState
data object LoggedOut : SessionState
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
}
/**
* 会话生命周期:登录、静默恢复、续期、登出。
*
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
*
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
*/
class AuthManager(
private val store: SessionStorage,
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
) {
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
val state: StateFlow<SessionState> = _state.asStateFlow()
@Volatile
var currentBaseUrl: String = ""
private set
private val refresher = TokenRefresher(store, ::refreshCall)
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
@Volatile
private var authApi: CyreneApi? = null
@Volatile
private var authApiBaseUrl: String? = null
/**
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
*/
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
override fun onSessionEnded() = onSessionExpired()
}
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
fun currentToken(): String? = refresher.ensureToken()
/**
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
*/
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
/**
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
*
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
*/
suspend fun bootstrap() = withContext(Dispatchers.IO) {
var session = store.load()
currentBaseUrl = session.baseUrl
val legacyUser = session.username?.takeIf(String::isNotBlank)
val legacyPassword = session.password?.takeIf(String::isNotBlank)
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
doLogin(session.baseUrl, legacyUser, legacyPassword)
session = store.load()
} else if (!session.password.isNullOrBlank()) {
store.purgePassword()
session = store.load()
}
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
refresher.renew(session.token)
session = store.load()
}
_state.value = if (session.hasValidToken()) {
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
} else {
SessionState.LoggedOut
}
}
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
withContext(Dispatchers.IO) {
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
}
/**
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
*/
suspend fun logout() = withContext(Dispatchers.IO) {
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
if (refreshToken != null) {
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
try {
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
}
}
}
store.clearAll()
authApi = null
authApiBaseUrl = null
_state.value = SessionState.LoggedOut
}
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
fun onSessionExpired() {
store.clearSession()
_state.value = SessionState.LoggedOut
}
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
return try {
val response = authApi(url).login(LoginRequest(username, password))
val data = response.body()?.data
when {
response.isSuccessful && data != null -> {
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
currentBaseUrl = url
_state.value = SessionState.LoggedIn(url, data.role)
null
}
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
else -> {
val parsed = parseError(response.errorBody()?.string())
if (response.code() == HTTP_UNAUTHORIZED) {
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
} else {
CyreneError.Server(
code = parsed?.code.orEmpty(),
message = parsed?.message.orEmpty(),
httpStatus = response.code(),
)
}
}
}
} catch (e: Exception) {
mapException(e)
}
}
/**
* [TokenRefresher] 注入的阻塞刷新调用。
*
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
*
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
*/
private fun refreshCall(refreshToken: String): RefreshCall {
val baseUrl = sessionBaseUrl()
if (baseUrl.isBlank()) return RefreshCall.Failed
return try {
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
val data = response.body()?.data
when {
response.isSuccessful && data != null ->
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
// 200 但解不动:不拿会话去赌,当暂时性失败。
response.isSuccessful -> RefreshCall.Failed
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
else -> RefreshCall.Failed
}
} catch (_: Exception) {
RefreshCall.Failed
}
}
private fun authApi(baseUrl: String): CyreneApi {
val cached = authApi
if (cached != null && authApiBaseUrl == baseUrl) return cached
val created = apiFactory(baseUrl)
authApi = created
authApiBaseUrl = baseUrl
return created
}
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
private fun StoredSession.hasValidToken(): Boolean =
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
private fun parseError(body: String?) = body
?.takeIf { it.isNotBlank() }
?.let {
runCatching {
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
}.getOrNull()
}
private fun mapException(e: Throwable): CyreneError = when (e) {
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
private companion object {
const val HTTP_UNAUTHORIZED = 401
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
const val REVOKE_TIMEOUT_MS = 3_000L
}
}
@@ -0,0 +1,137 @@
package top.yeij.cyrene.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
data class StoredSession(
val baseUrl: String,
val token: String?,
val expiresAt: Long,
val role: String?,
val username: String?,
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
val refreshToken: String?,
/**
* **遗留字段**:B2 之前的版本把登录密码落在这里。
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
*/
val password: String?,
)
/**
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
*/
interface SessionStorage {
fun load(): StoredSession
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
)
/**
* 只替换令牌三元组,保留 baseUrl / username / role。
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
* 此时旧凭据仍然有效,绝不能丢掉。
*/
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
/**
* 确定性抹掉历史遗留的明文密码。
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
*/
fun purgePassword()
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
fun clearSession()
/** 主动登出:整份清空。 */
fun clearAll()
}
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
class SessionStore(context: Context) : SessionStorage {
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
FILE_NAME,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override fun load(): StoredSession = StoredSession(
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
token = prefs.getString(KEY_TOKEN, null),
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
role = prefs.getString(KEY_ROLE, null),
username = prefs.getString(KEY_USERNAME, null),
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
password = prefs.getString(KEY_PASSWORD, null),
)
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
) {
val editor = prefs.edit()
.putString(KEY_BASE_URL, baseUrl)
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
.putString(KEY_ROLE, role)
.putString(KEY_USERNAME, username)
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
val editor = prefs.edit()
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun purgePassword() {
prefs.edit().remove(KEY_PASSWORD).commit()
}
override fun clearSession() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_ROLE)
.remove(KEY_REFRESH_TOKEN)
.apply()
}
override fun clearAll() {
prefs.edit().clear().apply()
}
private companion object {
const val FILE_NAME = "cyrene_session"
const val KEY_BASE_URL = "base_url"
const val KEY_TOKEN = "token"
const val KEY_EXPIRES_AT = "expires_at"
const val KEY_ROLE = "role"
const val KEY_USERNAME = "username"
const val KEY_REFRESH_TOKEN = "refresh_token"
const val KEY_PASSWORD = "password"
}
}
@@ -0,0 +1,132 @@
package top.yeij.cyrene.core.auth
/** 一次令牌续期的结果。 */
sealed interface AuthRecovery {
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
data class Renewed(val token: String) : AuthRecovery
/**
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
* 这是终态——必须重新登录。
*/
data object Unrecoverable : AuthRecovery
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
data object Transient : AuthRecovery
}
/**
* [TokenRefresher] 注入的刷新调用结果。
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
*/
sealed interface RefreshCall {
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
/** `401 invalid_grant`。 */
data object Rejected : RefreshCall
/** 网络 / 5xx / 解析失败。 */
data object Failed : RefreshCall
}
/**
* 单飞(single-flight)刷新协调器。
*
* ## 为什么必须单飞
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
*
* ## 代际守卫
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
*
* ## 线程模型
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
* 不会递归回本类。
*
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
* @param call 真正发刷新请求的阻塞调用。
* @param now 当前毫秒时间戳,可注入以便单测。
*/
class TokenRefresher(
private val store: SessionStorage,
private val call: (refreshToken: String) -> RefreshCall,
private val now: () -> Long = System::currentTimeMillis,
) {
private val lock = Any()
/**
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
*/
private var rejectedRefreshToken: String? = null
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
fun isNearExpiry(session: StoredSession): Boolean =
session.expiresAt * 1000 - now() <= SKEW_MS
/**
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
*
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
*/
fun ensureToken(): String? {
val session = store.load()
val token = session.token?.takeIf(String::isNotBlank) ?: return null
if (!isNearExpiry(session)) return token
return when (val recovery = renew(token)) {
is AuthRecovery.Renewed -> recovery.token
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
}
}
/**
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
*
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
*/
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
val session = store.load()
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
val current = session.token
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
return@synchronized AuthRecovery.Renewed(current)
}
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized AuthRecovery.Unrecoverable
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
when (val result = call(refreshToken)) {
is RefreshCall.Ok -> {
rejectedRefreshToken = null
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
AuthRecovery.Renewed(result.token)
}
RefreshCall.Rejected -> {
rejectedRefreshToken = refreshToken
AuthRecovery.Unrecoverable
}
RefreshCall.Failed -> AuthRecovery.Transient
}
}
private companion object {
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
const val SKEW_MS = 60_000L
}
}
@@ -0,0 +1,29 @@
package top.yeij.cyrene.core.auth
/**
* 「带了令牌仍然 401」的处置策略。
*
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
*/
interface UnauthorizedPolicy {
/**
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
*
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
*/
fun renew(usedToken: String): AuthRecovery
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
fun onSessionEnded()
}
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
object NoUnauthorizedPolicy : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
override fun onSessionEnded() = Unit
}
@@ -0,0 +1,53 @@
package top.yeij.cyrene.core.error
/**
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
*/
sealed interface CyreneError {
val userMessage: String
data class Server(
val code: String,
val message: String,
val httpStatus: Int,
) : CyreneError {
override val userMessage: String
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
}
data class Unauthorized(
val code: String,
val message: String,
) : CyreneError {
override val userMessage: String
get() = message.ifBlank { "用户名或密码不正确" }
}
data class Local(val kind: LocalKind) : CyreneError {
override val userMessage: String
get() = when (kind) {
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
LocalKind.NoNetwork -> "网络不可用,请检查连接"
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
LocalKind.Timeout -> "连接超时,服务器没有响应"
LocalKind.ConnectFailed -> "无法连接到服务器"
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
LocalKind.Parse -> "服务器返回的数据无法识别"
LocalKind.Unknown -> "发生未知错误"
}
}
enum class LocalKind {
InvalidUrl,
NoNetwork,
Dns,
Tls,
Timeout,
ConnectFailed,
CleartextBlocked,
Parse,
Unknown,
}
}
@@ -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+")
@@ -0,0 +1,210 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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")
enum class ThemeMode { System, Light, Dark }
/**
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
* `chat.parallel`)。
*
* 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断,
* 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字
* 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。
*
* 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、
* 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。
*
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是
* 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。
* @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。
*/
enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) {
/**
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
*
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
*/
AppendQueue(wireScope = null, wireParallel = false),
/**
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
*/
Interrupt(wireScope = "current", wireParallel = false),
/**
* 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`);
* 服务端并发额度满时**不报错**,这一条落回排队。
*
* [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是
* `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧——
* 一产生就等于先把要并行的那一路砍掉。
*
* 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能),
* 所以设置页这一栏的 summary 把这句代价明说出来。
*/
Parallel(wireScope = null, wireParallel = true),
}
/**
* 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测——
* `AppSettings` 本体要 Android + DataStore,单测里起不来)。
*
* 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。
*/
fun String?.toInterruptMode(): InterruptMode = when (this) {
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
InterruptMode.Parallel.name -> InterruptMode.Parallel
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 级偏好(非机密)。
*
* @param themeMode 主题深浅
* @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 偏好,供聊天渲染等深层组件读取。 */
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
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 }
}
suspend fun setShowNarration(enabled: Boolean) {
store.edit { it[KEY_SHOW_NARRATION] = enabled }
}
suspend fun setThinkingExpanded(enabled: Boolean) {
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
}
suspend fun setInterruptMode(mode: InterruptMode) {
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
else -> ThemeMode.System
}
private companion object {
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
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")
}
}
@@ -0,0 +1,63 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.EnumTableDto
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
/**
* 枚举权威表的本地回退快照(§1.1)。
*
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
*/
data class EnumPrefs(
val table: EnumTableDto? = null,
val fingerprint: String = "",
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
}
class EnumPreferences(context: Context) {
private val json = ApiClientFactory.json
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
val data: Flow<EnumPrefs> = store.data.map { prefs ->
EnumPrefs(
table = prefs[KEY_TABLE].decodeTable(),
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
)
}
suspend fun save(table: EnumTableDto, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
prefs[KEY_FINGERPRINT] = fingerprint
}
}
/** 登出 / 切换服务器时清空。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeTable(): EnumTableDto? = this
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
private companion object {
val KEY_TABLE = stringPreferencesKey("enum_table")
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
}
}
@@ -0,0 +1,109 @@
package top.yeij.cyrene.core.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
/**
* 会话列表的本地回退快照与显示态(§3.1)。
*
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
* @param fingerprint [accountFingerprint]:换服务器/账号后快照作废
* @param activeSessionId 上次停留的会话
*/
data class SessionPrefs(
val snapshot: List<SessionSummaryDto> = emptyList(),
val snapshotAt: Long = 0L,
val fingerprint: String = "",
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
) {
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
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
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
val data: Flow<SessionPrefs> = store.data.map { prefs ->
SessionPrefs(
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
)
}
/** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
store.edit { prefs ->
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
prefs[KEY_FINGERPRINT] = fingerprint
}
}
suspend fun setActive(sessionId: String) {
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
}
/** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
suspend fun removeSession(sessionId: String) {
store.edit { prefs ->
if (prefs[KEY_ACTIVE] == sessionId) {
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
}
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
}
}
/** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
suspend fun clear() {
store.edit { it.clear() }
}
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
.orEmpty()
private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
}
}
@@ -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 }
}
}
Loaded 100 of 411 files, more files were not shown because too many files have changed in this diff. Show more