88 Commits
Author SHA1 Message Date
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
295 changed files with 31750 additions and 12652 deletions

No files matched your search

+4 -30
View File
@@ -1,36 +1,10 @@
# ---> Android
# Gradle files
*.iml
.gradle/
build/
# Local configuration file (sdk path, etc)
.kotlin/
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
.idea/
.DS_Store
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
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
+37 -79
View File
@@ -1,58 +1,30 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
alias(libs.plugins.kotlin.serialization)
}
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"
compileSdk = 36
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "top.yeij.cyrene"
minSdk = 26
targetSdk = 36
minSdk = 33
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
@@ -60,61 +32,47 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
// Compose BOM
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
implementation(libs.compose.ui)
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)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Activity & Navigation
implementation(libs.compose.activity)
implementation(libs.compose.navigation)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.foundation)
// Lifecycle
implementation(libs.lifecycle.runtime)
implementation(libs.lifecycle.viewmodel)
implementation(libs.miuix.ui)
implementation(libs.miuix.icons)
implementation(libs.miuix.preference)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// Network
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Koin DI
implementation(libs.koin.android)
implementation(libs.koin.compose)
// DataStore
implementation(libs.datastore)
// Coroutines
implementation(libs.coroutines)
// Core
implementation(libs.core.ktx)
// Biometric
implementation(libs.biometric)
// Coil — image loading
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)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
}
+8 -102
View File
@@ -1,104 +1,10 @@
# Cyrene ProGuard Rules
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
# --- Keep Android components declared in manifest ---
# These are instantiated by the Android system via reflection
-keep class top.yeij.cyrene.CyreneApplication { *; }
-keep class top.yeij.cyrene.MainActivity { *; }
-keep class top.yeij.cyrene.service.** { *; }
# --- Kotlin ---
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeVisibleParameterAnnotations
-keepattributes AnnotationDefault
-keepattributes KotlinMetadata
-dontwarn kotlin.**
-keep class kotlin.Metadata { *; }
-keep class kotlin.coroutines.Continuation
-keep class kotlinx.coroutines.** { *; }
# --- Retrofit ---
-keep class retrofit2.** { *; }
-dontwarn retrofit2.**
# --- Gson ---
-keep class com.google.gson.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
-keepclassmembers class **$$serializer {
*** descriptor;
}
-keepclasseswithmembers class ** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep all DTO classes and their members for Gson serialization
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
-keepclassmembers class top.yeij.cyrene.data.remote.dto.** { *; }
# --- OkHttp ---
-dontwarn okhttp3.**
-dontwarn okio.**
# --- Room ---
-keep class * extends androidx.room.RoomDatabase { *; }
-keep class top.yeij.cyrene.data.local.entity.** { *; }
-keepclassmembers class top.yeij.cyrene.data.local.entity.** { *; }
-dontwarn androidx.room.paging.**
# --- Koin ---
-keep class org.koin.** { *; }
-keep class top.yeij.cyrene.di.** { *; }
-keepclassmembers class top.yeij.cyrene.di.** { *; }
# --- Compose ---
-dontwarn androidx.compose.**
-keep class androidx.compose.** { *; }
# --- Coroutines ---
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
# --- Keep domain models (used in StateFlow, SharedFlow, etc.) ---
-keep class top.yeij.cyrene.domain.model.** { *; }
# --- Keep ViewModels (Koin instantiates via reflection) ---
-keep class top.yeij.cyrene.viewmodel.** { *; }
-keepclassmembers class top.yeij.cyrene.viewmodel.** { *; }
# --- Keep repository implementations (Koin binds by interface) ---
-keep class top.yeij.cyrene.data.repository.** { *; }
-keep class top.yeij.cyrene.domain.repository.** { *; }
# --- Keep PreferencesDataStore (Koin injects) ---
-keep class top.yeij.cyrene.data.local.PreferencesDataStore { *; }
# --- Keep utility classes (VoiceRecorder, RuntimeLog, etc. — injected by Koin) ---
-keep class top.yeij.cyrene.util.** { *; }
-keepclassmembers class top.yeij.cyrene.util.** { *; }
# --- Keep voice/TTS/STT classes (injected by Koin into OverlayViewModel) ---
-keep class top.yeij.cyrene.voice.** { *; }
-keepclassmembers class top.yeij.cyrene.voice.** { *; }
# --- Keep domain use cases (injected by Koin into ViewModels) ---
-keep class top.yeij.cyrene.domain.usecase.** { *; }
-keepclassmembers class top.yeij.cyrene.domain.usecase.** { *; }
# --- Keep network interceptors and ApiService (Koin singletons) ---
-keep class top.yeij.cyrene.data.remote.RetrofitClient { *; }
-keep class top.yeij.cyrene.data.remote.ApiService { *; }
-keep class top.yeij.cyrene.data.remote.AuthInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.DynamicUrlInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.TokenAuthenticator { *; }
# --- Keep WebSocketService (injected into ChatRepositoryImpl) ---
-keep class top.yeij.cyrene.service.WebSocketService { *; }
# --- UI screens & components (called via Navigation compose lambda — R8 may not trace) ---
-keep class top.yeij.cyrene.ui.screens.** { *; }
-keep class top.yeij.cyrene.ui.components.** { *; }
-keep class top.yeij.cyrene.ui.overlay.** { *; }
-keep class top.yeij.cyrene.ui.navigation.** { *; }
-keep class top.yeij.cyrene.ui.theme.** { *; }
# --- General AndroidX ---
-keep class androidx.lifecycle.** { *; }
-dontwarn androidx.lifecycle.**
+43 -104
View File
@@ -1,121 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<!-- 语音助手核心 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
<!-- 后台服务 -->
<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_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 锁屏交互 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 激进保活 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- 查询其他应用(检查默认助手设置) -->
<queries>
<intent>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent>
</queries>
<application
android:name=".CyreneApplication"
android:allowBackup="true"
android:name=".app.CyreneApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Cyrene"
android:usesCleartextTraffic="true">
<!-- 全屏主界面 -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Cyrene">
<!--
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
两个实例各持一套 ViewModel / socket。
-->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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>
<!-- VoiceInteractionService:系统语音助手 -->
<service
android:name=".service.CyreneVoiceInteractionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<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>
<!-- WebSocket 后台保活 -->
<service
android:name=".service.WebSocketKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="websocket_keepalive_for_push_message_delivery" />
</service>
<!-- 开机自启 -->
<receiver
android:name=".service.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- 定时保活唤醒 -->
<receiver
android:name=".service.KeepAliveReceiver"
android:exported="false" />
<!-- FileProvider:日志分享 -->
<!--
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -1,138 +0,0 @@
package top.yeij.cyrene
import android.Manifest
import android.app.Activity
import android.app.Application
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat
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.service.KeepAliveReceiver
import top.yeij.cyrene.util.RootKeepAliveHelper
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)
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 onActivityResumed(activity: Activity) {
if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground")
getRepo()?.cancelNotifications()
getRepo()?.onAppForeground()
}
}
override fun onActivityPaused(activity: Activity) {
if (activityCount.decrementAndGet() == 0) {
RuntimeLog.general("app", "App in background")
getRepo()?.onAppBackground()
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
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
}
}
// Schedule periodic keep-alive on first launch
scheduleInitialKeepAlive()
// Check battery optimization
checkBatteryOptimization()
// Apply root keep-alive if enabled
initScope.launch {
val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
if (enabled) {
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
if (ok) {
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
} else if (RootKeepAliveHelper.isRootAvailable()) {
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
}
// Only attempt system wakelock if root keep-alive was enabled
// We don't persist the wakelock across reboots since it's per-session
}
}
}
private fun scheduleInitialKeepAlive() {
try {
KeepAliveReceiver.schedule(this)
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
}
}
private fun checkBatteryOptimization() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(PowerManager::class.java)
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
Log.i(TAG, "App is NOT exempt from battery optimization")
// Note: we can't request exemption from Application context directly.
// SettingsScreen should offer a button to open the exemption dialog.
}
}
}
private fun getRepo(): ChatRepositoryImpl? {
return try {
GlobalContext.get().get()
} catch (_: Throwable) {
null
}
}
companion object {
private const val TAG = "CyreneApp"
}
}
@@ -1,104 +0,0 @@
package top.yeij.cyrene
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
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)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* granted or denied — either way we continue */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
isDefaultAssistant.value = checkIsDefaultAssistant()
setContent {
val prefs: PreferencesDataStore = koinInject()
val themeMode by prefs.themeMode.collectAsState(initial = null)
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
val darkTheme = when (themeMode) {
"light" -> false
"dark" -> true
else -> isSystemInDarkTheme()
}
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColor,
useDynamicColor = useDynamic,
) {
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))
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@@ -1,38 +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",
)
.build()
.also { INSTANCE = it }
}
}
}
}
@@ -1,166 +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")
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
private val KEY_ROOT_KEEPALIVE = booleanPreferencesKey("root_keepalive")
private val KEY_THEME_COLOR = stringPreferencesKey("theme_color")
}
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
suspend fun saveTypingIndicatorStyle(style: String) {
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
}
val enterToSend: Flow<Boolean> = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false }
suspend fun saveEnterToSend(enabled: Boolean) {
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
}
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
suspend fun saveRootKeepAlive(enabled: Boolean) {
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
}
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
suspend fun saveThemeColor(color: String) {
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
}
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,36 +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 conversationId = :conversationId AND role = 'user'")
suspend fun deleteUserMessagesByConversation(conversationId: 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,73 +0,0 @@
package top.yeij.cyrene.data.remote
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
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.FileUploadResponse
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 = 500,
@Query("offset") offset: Int = 0,
): Response<MessagesListResponse>
// Files
@Multipart
@POST("api/v1/files/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
): Response<FileUploadResponse>
// 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,47 +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,
)
// POST /api/v1/files/upload — response
data class FileUploadResponse(
@SerializedName("id") val id: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("mime_type") val mimeType: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("url") val url: String? = null,
)
@@ -1,94 +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("file_id") val fileId: 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("type") val type: String? = null,
@SerializedName("role") val role: String? = null,
@SerializedName("text") val text: String? = null,
@SerializedName("content") val content: String? = null,
@SerializedName("msg_type") val msgType: String? = null,
@SerializedName("delay_ms") val delayMs: Long? = 0,
@SerializedName("metadata") val metadata: WSReviewMetadata? = null,
)
data class WSReviewMetadata(
@SerializedName("language") val language: String? = null,
@SerializedName("url") val url: String? = null,
)
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,867 +0,0 @@
package top.yeij.cyrene.data.repository
import android.app.Application
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.delay
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.WSAttachment
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.KeepAliveReceiver
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID
class ChatRepositoryImpl(
private val app: Application,
private val conversationDao: ConversationDao,
private val messageDao: MessageDao,
private val webSocketService: WebSocketService,
private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
private val notificationHelper: NotificationHelper,
) : 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 _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
override val messageRemovals: Flow<String> = _messageRemovals
private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
private var streamingContent = ""
private var streamingMessageId: String? = null
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
override var currentSessionId: String? = null
private var isAppInForeground = false
private var hasEverBeenForeground = false
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 cancelNotifications() {
notificationHelper.cancelAll()
}
private fun resetStreamTimeout() {
cancelStreamTimeout()
streamTimeoutJob = scope.launch {
kotlinx.coroutines.delay(120_000L) // 2 min timeout
if (_isAssistantStreaming.value) {
RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting")
streamingContent = ""
streamingMessageId = null
_isAssistantStreaming.value = false
emitMessage(
id = "timeout_${System.currentTimeMillis()}",
sessionId = currentSessionId ?: "default",
role = "system",
content = "AI 响应超时,请重试",
msgType = "system_info",
isStreaming = false,
)
}
}
}
private fun cancelStreamTimeout() {
streamTimeoutJob?.cancel()
streamTimeoutJob = null
}
override fun onAppForeground() {
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
isAppInForeground = true
hasEverBeenForeground = true
notifiedMessageIds.clear()
notificationHelper.cancelAll()
KeepAliveReceiver.cancel(app)
WebSocketKeepAliveService.stop(app)
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
scope.launch {
val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
requestHistoryViaWs(sid)
}
}
override fun onAppBackground() {
isAppInForeground = false
WebSocketKeepAliveService.start(app)
KeepAliveReceiver.schedule(app)
val currentlyConnected = _connectionState.value
RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
// Only reconnect if the WS is already dead. Tearing down a healthy
// connection creates a message loss window with no benefit.
if (!currentlyConnected) {
scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
}
} else {
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
}
}
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)
_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() {
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
webSocketService.forceReconnect()
}
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<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) }
}
val hasImages = localImageUris.isNotEmpty()
val displayContent = content.ifBlank { "" }
val lastMsg = when {
hasImages && content.isBlank() -> "[图片]"
hasImages -> content
else -> content
}
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)} attachments=${attachments?.size ?: 0}")
conversationDao.upsert(
ConversationEntity(
id = sid,
title = "对话",
lastMessage = lastMsg,
lastMessageType = "chat",
updatedAt = now,
createdAt = now,
)
)
messageDao.upsert(
MessageEntity(
id = messageId,
conversationId = sid,
role = "user",
content = displayContent,
msgType = "chat",
timestamp = now,
)
)
emitMessage(
id = messageId,
sessionId = sid,
role = "user",
content = displayContent,
msgType = "chat",
timestamp = now,
isStreaming = false,
imageDataUris = localImageUris,
)
webSocketService.sendMessage(content, sid, attachments = attachments)
}
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)
val messages = filteredDtos.map { dto ->
Message(
id = "${dto.id}",
conversationId = sessionId,
role = dto.role,
content = dto.content,
msgType = dto.msgType ?: "chat",
timestamp = dto.createdAt,
)
}
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
messageDao.deleteUserMessagesByConversation(sessionId)
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
})
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
deduped
} 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) {
if (!webSocketService.isConnected.value) {
val connected = withTimeoutOrNull(5000) {
webSocketService.isConnected.first { it }
}
if (connected != true) {
// WS couldn't connect, fall back to REST API
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
loadMessagesFromServer(sessionId)
return
}
}
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
recentParsedContents.clear()
resetStreamTimeout()
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
}
"stream_chunk" -> {
val delta = wsMsg.content ?: wsMsg.text ?: return
streamingContent += delta
resetStreamTimeout()
emitMessage(
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "assistant",
content = streamingContent,
msgType = wsMsg.msgType ?: "chat",
isStreaming = true,
)
}
"stream_end" -> {
cancelStreamTimeout()
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()
// Dedup: suppress if streaming content wraps already-shown multi_message/review items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { content.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
_isAssistantStreaming.value = false
return
}
}
recentParsedContents.clear()
if (content.isNotBlank()) {
ensureConversation(sid, content)
messageDao.upsert(
MessageEntity(
id = msgId,
conversationId = sid,
role = "assistant",
content = content,
msgType = wsMsg.msgType ?: "chat",
timestamp = ts,
)
)
}
lastResponseId = msgId
lastResponseContent = content
lastResponseTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = false
}
"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"
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()
// Track parsed content so stream_end can suppress the wrapping full text
recentParsedContents.add(text)
lastParsedTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
}
"review" -> {
recentParsedContents.clear()
wsMsg.reviewMessages?.forEachIndexed { index, review ->
if (index > 0) delay(1000L)
val rawText = review.content ?: review.text ?: return@forEachIndexed
val role = review.role ?: "assistant"
val rvMsgType = review.type ?: review.msgType ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
// Encode code language metadata into content for the renderer
val content = if (rvMsgType == "code" && review.metadata?.language != null) {
"[lang:${review.metadata.language}]\n$rawText"
} else rawText
recentParsedContents.add(rawText)
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, 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 = wsMsg.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 = wsMsg.msgType ?: "tool_progress",
isStreaming = false,
)
}
"queued" -> {
emitMessage(
id = "queued_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = "消息已加入处理队列",
msgType = "system_info",
isStreaming = false,
)
}
"error" -> {
cancelStreamTimeout()
streamingContent = ""
streamingMessageId = null
_isAssistantStreaming.value = false
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
emitMessage(
id = "err_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = wsMsg.error ?: "未知错误",
msgType = wsMsg.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 = wsMsg.msgType ?: "chat",
timestamp = ts,
)
)
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = wsMsg.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().splitInlineActions()
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
})
deduped.forEach { msg ->
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()
var isFirst = true
wsMsg.multiMessages?.forEachIndexed { index, item ->
if (index > 0) delay(1000L)
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,
shouldNotify = isFirst,
)
isFirst = 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)
_messageRemovals.tryEmit(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state 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,
imageDataUris: List<String> = emptyList(),
) {
if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
// Fallback: detect inline <action> tags missed by server parsing
if (role == "assistant" && msgType == "chat") {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
val match = actionRegex.find(content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(content, "").trim()
RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
if (actionText.isNotEmpty()) {
emitMessage(
id = "${id}_action",
sessionId = sessionId,
role = "assistant",
content = actionText,
msgType = "action",
timestamp = timestamp,
shouldNotify = false,
)
}
if (remaining.isNotEmpty()) {
emitMessage(
id = id,
sessionId = sessionId,
role = role,
content = remaining,
msgType = msgType,
timestamp = timestamp + 1,
isStreaming = isStreaming,
shouldNotify = shouldNotify,
)
}
return
}
}
val message = Message(
id = id,
conversationId = sessionId,
role = role,
content = content,
msgType = msgType,
timestamp = timestamp,
isStreaming = isStreaming,
imageDataUris = imageDataUris,
)
_incomingMessages.tryEmit(message)
if (shouldNotify && role == "assistant" && !isStreaming) {
if (!hasEverBeenForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
} else if (isAppInForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
} else {
if (notifiedMessageIds.add(id)) {
notificationHelper.showMessageNotification(message)
RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
} else {
RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
}
}
} else if (shouldNotify && role == "assistant" && isStreaming) {
RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
}
}
/**
* 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,
)
/**
* Split inline `<action>` tags from assistant chat messages into separate messages.
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
*/
private fun List<Message>.splitInlineActions(): List<Message> {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
return flatMap { msg ->
if (msg.role == "assistant" && msg.msgType == "chat") {
val match = actionRegex.find(msg.content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
val result = mutableListOf<Message>()
if (actionText.isNotEmpty()) {
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
}
if (remaining.isNotEmpty()) {
result.add(msg.copy(content = remaining))
}
if (result.isEmpty()) listOf(msg) else result
} else {
listOf(msg)
}
} else {
listOf(msg)
}
}
}
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,84 +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.NotificationHelper
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 {
// Notifications
single { NotificationHelper(androidContext()) }
// 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(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases
factory { LoginUseCase(get()) }
factory { SendMessageUseCase(get()) }
factory { GetConversationsUseCase(get()) }
// ViewModels
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), 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,12 +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,
val imageDataUris: List<String> = emptyList(),
)
@@ -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,47 +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>
val messageRemovals: Flow<String>
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?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null, localImageUris: List<String> = emptyList())
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,59 +0,0 @@
package top.yeij.cyrene.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
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.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
class BootReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
Log.i(TAG, "Boot completed, restoring background connection")
scope.launch {
try {
// Wait a moment for Koin to initialize
kotlinx.coroutines.delay(5000)
val prefs: PreferencesDataStore = GlobalContext.get().get()
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.i(TAG, "No auth token, skipping auto-connect")
return@launch
}
// Start keep-alive service
WebSocketKeepAliveService.start(context)
// Always reconnect — session may be stale
val repo: ChatRepositoryImpl = GlobalContext.get().get()
repo.ensureConnected()
Log.i(TAG, "Boot connection restored, connected=${repo.connectionState.value}")
// Schedule periodic wake-up
KeepAliveReceiver.schedule(context)
Log.i(TAG, "Background connection restored")
} catch (e: Throwable) {
Log.e(TAG, "Failed to restore connection on boot: ${e.message}", e)
// Fallback: still try to start the service
try { WebSocketKeepAliveService.start(context) } catch (_: Exception) { }
}
}
}
companion object {
private const val TAG = "CyreneBoot"
}
}
@@ -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,178 +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 android.content.res.Configuration
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: Throwable) {
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
val session = this@CyreneVoiceInteractionSession
val (darkTheme, themeColorKey) = runBlocking {
val prefs = GlobalContext.get().get<PreferencesDataStore>()
val mode = prefs.themeMode.firstOrNull()
val color = prefs.themeColor.firstOrNull() ?: "pink"
val dark = when (mode) {
"light" -> false
"dark" -> true
else -> {
val nightMode = session.context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK
nightMode == Configuration.UI_MODE_NIGHT_YES
}
}
Pair(dark, color)
}
return ComposeView(context).apply {
// Configure window as soon as view is attached — before system overrides flags
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
session.configureWindow()
}
override fun onViewDetachedFromWindow(v: View) {}
})
setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session)
setContent {
CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColorKey,
useDynamicColor = themeColorKey == "monet",
) {
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
// Defer window config — system may override softInputMode after onShow returns
try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
val w = method.invoke(this) as? android.view.Window
w?.decorView?.post { configureWindow() }
} catch (e: Throwable) {
// Fallback: configure immediately
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 (_: Throwable) {
false
}
if (autoScreenContext) {
val screenContent = CyreneAccessibilityService.getScreenContent()
if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
}
}
}
fun configureWindow() {
try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
val w = method.invoke(this) as? android.view.Window ?: return
// Transparent window so the underlying screen is visible through the overlay
w.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
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: transparent bg, translucent status/nav")
} catch (e: Throwable) {
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,102 +0,0 @@
package top.yeij.cyrene.service
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
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.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.util.RuntimeLog
class KeepAliveReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Keep-alive alarm fired")
RuntimeLog.notify("keepalive", "Alarm fired in background")
scope.launch {
try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.d(TAG, "No auth token, skipping wake-up")
RuntimeLog.notify("keepalive", "Skipping: no auth token")
return@launch
}
if (!WebSocketKeepAliveService.isRunning) {
WebSocketKeepAliveService.start(context)
RuntimeLog.notify("keepalive", "Foreground service restarted")
}
val repo: ChatRepositoryImpl = GlobalContext.get().get()
val wasConnected = repo.connectionState.value
repo.ensureConnected()
RuntimeLog.notify("keepalive", "WS reconnect triggered: wasConnected=$wasConnected nowConnected=${repo.connectionState.value}")
schedule(context)
} catch (e: Throwable) {
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
RuntimeLog.notify("keepalive", "Failed: ${e.message}")
schedule(context)
}
}
}
companion object {
private const val TAG = "CyreneKeepAlive"
const val INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
fun schedule(context: Context) {
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// Cancel existing alarm first
alarmManager.cancel(pendingIntent)
val triggerAt = System.currentTimeMillis() + INTERVAL_MS
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule keep-alive: ${e.message}")
}
}
fun cancel(context: Context) {
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.cancel(pendingIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel keep-alive: ${e.message}")
}
}
}
}
@@ -1,154 +0,0 @@
package top.yeij.cyrene.service
import android.app.AlarmManager
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.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.util.RuntimeLog
class WebSocketKeepAliveService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
isRunning = true
createChannel()
acquireWakeLock()
Log.i(TAG, "Service created, wakeLock held")
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
}
override fun onDestroy() {
isRunning = false
releaseWakeLock()
scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled")
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand flags=$flags startId=$startId")
startForegroundNotification()
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
// App swiped away from recents — schedule restart and let it die
Log.i(TAG, "Task removed, scheduling restart")
scheduleRestart()
super.onTaskRemoved(rootIntent)
}
private fun startForegroundNotification() {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText("后台连接中,可接收消息推送")
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, 0x40000001 /* dataSync | specialUse */)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"Cyrene:WebSocketKeepAlive"
).apply {
acquire(10 * 60 * 1000L) // 10 min timeout as safety net
}
}
private fun releaseWakeLock() {
try { wakeLock?.release() } catch (_: Exception) { }
wakeLock = null
}
private fun scheduleRestart() {
try {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule restart: ${e.message}")
}
}
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"连接状态",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "后台连接保活"
setShowBadge(false)
}
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
companion object {
private const val TAG = "CyreneKeepAlive"
private const val CHANNEL_ID = "cyrene_keepalive"
private const val NOTIFICATION_ID = 1
@Volatile
var isRunning: Boolean = false
private set
fun start(context: Context) {
if (isRunning) return
context.startForegroundService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
fun stop(context: Context) {
context.stopService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
const val RESTART_DELAY_MS = 60_000L
}
}
@@ -1,335 +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 java.util.concurrent.atomic.AtomicLong
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.util.RuntimeLog
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)
@Volatile private var lastMessageReceived = System.currentTimeMillis()
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
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()
RuntimeLog.ws("lifecycle", "WS connected #$connId")
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
lastMessageReceived = System.currentTimeMillis()
try {
val msg = gson.fromJson(text, WSServerMessage::class.java)
val preview = text.take(100).replace("\n", "\\n")
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
_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()
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
}
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()
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
}
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()
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
val errorMsg = when (httpCode) {
403 -> {
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
"仅管理员用户可连接"
}
401 -> "认证失败,请重新登录"
else -> null
}
if (errorMsg != null) {
_connectionError.value = errorMsg
}
// onClosed may or may not follow — schedule reconnect directly
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,
attachments: List<WSAttachment>? = null,
): WSClientMessage = WSClientMessage(
type = type,
sessionId = sessionId ?: currentSessionId,
mode = mode,
content = content,
attachments = attachments,
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", attachments: List<WSAttachment>? = null) {
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
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() {
RuntimeLog.ws("lifecycle", "forceReconnect called")
shouldReconnect = true
reconnectJob?.cancel()
reconnectJob = null
scope.launch {
try {
// Close existing socket directly without resetting shouldReconnect
cancelHeartbeat()
webSocket?.close(1000, "Reconnecting")
webSocket = null
_isConnected.value = false
connect(currentSessionId)
} catch (_: Exception) { }
}
}
fun disconnect() {
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
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(15_000)
if (!_isConnected.value) break
sendPing()
// Check if connection is silently dead (no message received in 60s)
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
if (sinceLastMsg > deadConnectionTimeoutMs) {
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
forceReconnect()
break
}
}
}
}
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,785 +0,0 @@
package top.yeij.cyrene.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// --- Markdown block model ---
private sealed class MdBlock {
data class Heading(val level: Int, val text: String) : MdBlock()
data class Paragraph(val text: String) : MdBlock()
data class CodeBlock(val language: String?, val code: String) : MdBlock()
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
data class Quote(val text: String) : MdBlock()
class ThematicBreak : MdBlock()
}
private fun parseMarkdownBlocks(text: String): List<MdBlock> {
val lines = text.lines()
val blocks = mutableListOf<MdBlock>()
var i = 0
while (i < lines.size) {
val line = lines[i]
val trimmed = line.trimStart()
when {
// Fenced code block
trimmed.startsWith("```") -> {
val lang = trimmed.removePrefix("```").trim().ifBlank { null }
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
if (i < lines.size) i++ // skip closing ```
blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n")))
}
// Heading
trimmed.startsWith("#") -> {
val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed)
if (match != null) {
val level = match.groupValues[1].length
blocks.add(MdBlock.Heading(level, match.groupValues[2]))
}
i++
}
// Thematic break
trimmed.matches(Regex("^[-*_]{3,}$")) -> {
blocks.add(MdBlock.ThematicBreak())
i++
}
// Blockquote
line.startsWith(">") || trimmed.startsWith(">") -> {
val quoteLines = mutableListOf<String>()
while (i < lines.size) {
val cur = lines[i]
if (cur.trimStart().startsWith(">")) {
quoteLines.add(cur.trimStart().removePrefix(">").trimStart())
i++
} else if (cur.isBlank()) {
i++
break
} else {
break
}
}
if (quoteLines.isNotEmpty()) {
blocks.add(MdBlock.Quote(quoteLines.joinToString("\n")))
}
}
// Unordered list
trimmed.matches(Regex("^[-*+]\\s+.*$")) -> {
while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) {
val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText))
i++
}
}
// Ordered list
trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> {
var idx = 1
while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) {
val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
blocks.add(MdBlock.ListItem(true, idx, itemText))
idx++
i++
}
}
// Blank line — skip
line.isBlank() -> { i++ }
// Paragraph
else -> {
val paraLines = mutableListOf<String>()
while (i < lines.size &&
lines[i].isNotBlank() &&
!lines[i].trimStart().startsWith("```") &&
!lines[i].trimStart().startsWith("#") &&
!lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) &&
!lines[i].trimStart().startsWith(">") &&
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) &&
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))
) {
paraLines.add(lines[i])
i++
}
if (paraLines.isNotEmpty()) {
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
}
}
}
}
return blocks
}
@Composable
private fun renderInlineMarkdown(text: String): AnnotatedString {
return buildAnnotatedString {
var remaining = text
while (remaining.isNotEmpty()) {
// Bold + Italic ***
val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining)
// Bold **
val bold = Regex("""\*\*(.+?)\*\*""").find(remaining)
// Italic *
val italic = Regex("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
// Inline code `
val code = Regex("""`([^`]+)`""").find(remaining)
// Link [text](url)
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
val matches = listOfNotNull(
boldItalic?.let { "bi" to it },
bold?.let { "b" to it },
italic?.let { "i" to it },
code?.let { "c" to it },
link?.let { "l" to it },
).sortedBy { it.second.range.first }
if (matches.isEmpty()) {
append(remaining)
remaining = ""
} else {
val (kind, match) = matches.first()
// Append text before the match
if (match.range.first > 0) {
append(remaining.substring(0, match.range.first))
}
when (kind) {
"bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(match.groupValues[1])
}
"b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(match.groupValues[1])
}
"i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(match.groupValues[1])
}
"c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) {
append(match.groupValues[1])
}
"l" -> {
val label = match.groupValues[1]
val url = match.groupValues[2]
pushStringAnnotation("url", url)
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) {
append(label)
}
pop()
}
}
remaining = remaining.substring(match.range.last + 1)
}
}
}
}
// --- Markdown bubble ---
@Composable
private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) {
val blocks = remember(content) { parseMarkdownBlocks(content) }
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
shadowElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
blocks.forEach { block ->
when (block) {
is MdBlock.Heading -> {
val fontSize = when (block.level) {
1 -> 22.sp
2 -> 19.sp
3 -> 17.sp
4 -> 15.sp
5 -> 14.sp
else -> 13.sp
}
Text(
text = renderInlineMarkdown(block.text),
fontSize = fontSize,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp),
)
}
is MdBlock.Paragraph -> {
if (block.text.isNotBlank()) {
Text(
text = renderInlineMarkdown(block.text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is MdBlock.CodeBlock -> {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = MaterialTheme.shapes.small,
color = Color(0xFF1E1E1E),
) {
Column {
if (block.language != null) {
Text(
text = block.language,
modifier = Modifier
.background(Color(0xFF333333))
.padding(horizontal = 10.dp, vertical = 4.dp),
color = Color(0xFFCCCCCC),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
Text(
text = block.code,
modifier = Modifier.padding(10.dp),
color = Color(0xFFD4D4D4),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
is MdBlock.ListItem -> {
val prefix = if (block.ordered) "${block.index}. " else "• "
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
Text(
text = prefix,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = renderInlineMarkdown(block.text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is MdBlock.Quote -> {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small,
) {
Row {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
.weight(0.012f)
) {}
Text(
text = renderInlineMarkdown(block.text),
modifier = Modifier
.weight(1f)
.padding(8.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
is MdBlock.ThematicBreak -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.background(
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
shape = MaterialTheme.shapes.extraSmall,
)
.weight(1f)
) {}
}
}
}
}
}
}
// --- Code bubble (standalone code block message) ---
private val codeDarkBg = Color(0xFF1E1E1E)
private val codeSurface = Color(0xFF333333)
@Composable
private fun CodeBubble(content: String, modifier: Modifier = Modifier) {
val (language, code) = remember(content) {
if (content.startsWith("[lang:")) {
val endBracket = content.indexOf("]\n")
if (endBracket > 0) {
content.substring(6, endBracket) to content.substring(endBracket + 2)
} else "Code" to content
} else "Code" to content
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = codeDarkBg,
shadowElevation = 2.dp,
) {
Column {
// Language header
Box(
modifier = Modifier
.background(codeSurface, MaterialTheme.shapes.medium)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = language,
color = Color(0xFFAAAAAA),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
}
// Code content
Text(
text = code,
modifier = Modifier.padding(12.dp),
color = Color(0xFFD4D4D4),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
// --- Collapsible wrapper for non-chat content ---
private const val COLLAPSE_THRESHOLD = 300
@Composable
private fun CollapsibleBubble(
content: String,
modifier: Modifier = Modifier,
bubble: @Composable (String, Modifier) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val lines = content.lines()
val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8
if (!isLong) {
bubble(content, modifier)
return
}
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.weight(1f)) {
if (expanded) {
bubble(content, Modifier)
} else {
val truncated = lines.take(5).joinToString("\n").let {
if (it.length >= content.length) it else it + "\n…"
}
bubble(truncated, Modifier)
}
}
IconButton(
onClick = { expanded = !expanded },
modifier = Modifier
.padding(top = 4.dp)
.size(32.dp),
) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "折叠" else "展开",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
// --- Main ChatBubble dispatcher ---
@Composable
fun ChatBubble(
content: String,
role: String,
msgType: String,
timestamp: Long,
modifier: Modifier = Modifier,
imageDataUris: List<String> = emptyList(),
) {
val isUser = role == "user"
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
when (msgType) {
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
"action" -> ActionMessage(content, modifier)
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
MarkdownBubble(text, mod)
}
"code" -> CollapsibleBubble(content, modifier) { text, mod ->
CodeBubble(text, mod)
}
"thinking" -> CollapsibleBubble(content, modifier) { text, mod ->
ThinkingBubble(text, mod)
}
"tool_progress" -> CollapsibleBubble(content, modifier) { text, mod ->
ToolProgressBubble(text, mod)
}
"system_info" -> SystemInfoBubble(content, modifier)
else -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
}
}
// --- Full-screen image preview dialog ---
@Composable
private fun ImagePreviewDialog(
imageUri: String,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.95f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp),
)
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageUri)
.crossfade(true)
.build(),
contentDescription = "图片预览",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentScale = ContentScale.Fit,
)
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White,
)
}
}
}
}
// --- Chat message bubble ---
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChatMessageBubble(
content: String,
isUser: Boolean,
time: String,
modifier: Modifier = Modifier,
imageDataUris: List<String> = emptyList(),
) {
var showMenu by remember { mutableStateOf(false) }
var previewImageUri by remember { mutableStateOf<String?>(null) }
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val hasImages = imageDataUris.isNotEmpty()
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 },
),
) {
Column {
if (hasImages) {
imageDataUris.forEach { uri ->
AsyncImage(
model = ImageRequest.Builder(context)
.data(uri)
.crossfade(true)
.build(),
contentDescription = "图片",
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 240.dp)
.padding(top = 6.dp, start = 6.dp, end = 6.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { previewImageUri = uri },
contentScale = ContentScale.FillWidth,
)
}
}
if (content.isNotBlank()) {
Text(
text = renderInlineMarkdown(content),
modifier = Modifier.padding(
start = 12.dp, end = 12.dp,
top = if (hasImages) 6.dp else 12.dp,
bottom = 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),
)
}
}
// Full-screen image preview
if (previewImageUri != null) {
ImagePreviewDialog(
imageUri = previewImageUri!!,
onDismiss = { previewImageUri = null },
)
}
}
// --- Action message ---
private val actionTagRegex = Regex("""</?action>""", RegexOption.IGNORE_CASE)
@Composable
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
val displayText = remember(content) {
content.replace(actionTagRegex, "").trim()
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Start,
) {
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
)
}
}
// --- Thinking bubble ---
@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,
)
}
}
// --- Tool progress bubble ---
@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,
)
}
}
}
// --- System info bubble ---
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
var showMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
Box {
Text(
text = content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { showMenu = true },
),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("复制") },
leadingIcon = {
Icon(Icons.Default.ContentCopy, contentDescription = null)
},
onClick = {
showMenu = false
clipboardManager.setText(AnnotatedString(content))
},
)
}
}
}
}
@@ -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, color = MaterialTheme.colorScheme.onSurface)
}
CyreneStatus.THINKING -> {
PulsingDot(Color(0xFFFFA726))
Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
CyreneStatus.SPEAKING -> {
PulsingDot(Color(0xFF42A5F5))
Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
CyreneStatus.OFFLINE -> {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint = Color(0xFF9E9E9E),
)
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
@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,221 +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.offset
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.LaunchedEffect
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.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.dp
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.KeepAlivePage
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
import top.yeij.cyrene.util.RuntimeLog
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"
const val KEEP_ALIVE = "keep_alive"
}
@Composable
fun CyreneNavGraph(
navController: NavHostController,
startDestination: String,
isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit,
) {
// After process death, the NavController may restore a stale back stack
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
LaunchedEffect(Unit) {
val entries = navController.currentBackStack.value
val currentRoute = navController.currentDestination?.route
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
if (entries.size > 1 && entries.first().destination.route != startDestination) {
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
navController.popBackStack(startDestination, inclusive = true)
navController.navigate(startDestination)
}
}
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 = {
if (navController.currentDestination?.route == Routes.SETTINGS) {
navController.popBackStack()
}
},
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
)
}
composable(Routes.KEEP_ALIVE) {
KeepAlivePage(
onBack = {
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
navController.popBackStack()
}
},
)
}
composable(Routes.ABOUT) {
AboutScreen(
onBack = {
if (navController.currentDestination?.route == Routes.ABOUT) {
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
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
},
icon = item.icon,
label = { Text(item.label) },
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds()
.background(MaterialTheme.colorScheme.background),
) {
// Keep all tabs alive by offsetting hidden ones off-screen.
// clipToBounds ensures they don't intercept touches outside the visible area.
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 0) 0.dp else 2000.dp),
) {
ChatScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 1) 0.dp else 2000.dp),
) {
IoTScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 2) 0.dp else 2000.dp),
) {
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,636 +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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.input.ImeAction
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 top.yeij.cyrene.viewmodel.SettingsViewModel
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,
imageDataUris = message.imageDataUris,
)
}
}
@Composable
fun OverlayContent(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(),
settingsViewModel: SettingsViewModel = 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 typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.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,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
navBarHeightPx = navBarHeight,
)
} else {
PortraitContent(
state = state,
messages = messages,
inputText = inputText,
isProcessing = isProcessing,
listState = listState,
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
navBarHeightPx = navBarHeight,
)
}
}
}
}
@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,
typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
navBarHeightPx: Int,
) {
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 && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
// Input area at bottom; system adjust=pan handles IME offset
InputArea(
state = state,
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
)
}
}
@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,
typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
navBarHeightPx: Int,
) {
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 && typingIndicatorStyle != "text") {
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(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
)
}
}
}
@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 = "",
typingIndicatorStyle: String = "bubble",
enterToSend: Boolean = false,
) {
// 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
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
// "昔涟正在输入..." indicator (text mode only)
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
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 = RoundedCornerShape(24.dp),
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f),
focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
),
keyboardOptions = if (enterToSend) {
KeyboardOptions(imeAction = ImeAction.Done)
} else {
KeyboardOptions.Default
},
keyboardActions = if (enterToSend) {
KeyboardActions(
onDone = { if (inputText.isNotBlank()) viewModel.sendText() },
)
} else {
KeyboardActions.Default
},
)
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,513 +0,0 @@
package top.yeij.cyrene.ui.screens.chat
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
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.imePadding
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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AddPhotoAlternate
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.material.icons.filled.Refresh
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
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.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.util.RuntimeLog
import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
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 = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 },
),
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
)
}
}
@Composable
fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
// Track composition to diagnose navigation-related issues
LaunchedEffect(Unit) {
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
}
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 typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.collectAsState()
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
val listState = rememberLazyListState()
// Track whether user is near the latest messages (visual bottom = index 0)
val isNearBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
if (info.totalItemsCount == 0) return@derivedStateOf true
(info.visibleItemsInfo.firstOrNull()?.index ?: 0) <= 2
}
}
// 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
// Image picker
val selectedImages by viewModel.selectedImageUris.collectAsState()
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
viewModel.addImages(uris)
}
}
val context = LocalContext.current
// Stay at bottom for new messages unless user scrolled up
LaunchedEffect(Unit) {
snapshotFlow { messages.size to isNearBottom }
.collect { (_, nearBottom) ->
if (nearBottom && listState.firstVisibleItemIndex != 0) {
listState.animateScrollToItem(0)
}
}
}
// 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
}
// Single column layout: everything flows together and IME shrinks the whole view
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.imePadding(),
) {
// Top status bar with refresh button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StatusIndicator(status = status)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { viewModel.refreshMessages() },
enabled = !isRefreshing,
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.Filled.Refresh,
contentDescription = "刷新",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Selected images preview
if (selectedImages.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(selectedImages, key = { i, _ -> i }) { index, uri ->
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(uri)
.crossfade(true)
.build(),
contentDescription = "已选图片",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
IconButton(
onClick = { viewModel.removeImage(index) },
modifier = Modifier
.align(Alignment.TopEnd)
.size(20.dp)
.padding(0.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "移除",
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier
.size(14.dp)
.background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
CircleShape,
),
)
}
}
}
}
}
// Messages area (fills remaining space, shrinks with IME)
Box(modifier = Modifier.weight(1f)) {
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,
reverseLayout = true,
) {
if (isStreaming && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
AnimatedChatBubble(
message = message,
animIndex = index.coerceAtMost(20),
)
}
}
}
}
// Input area at bottom, in flow (not overlaid)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding(),
) {
// "昔涟正在输入..." indicator (text mode only)
if (isStreaming && typingIndicatorStyle == "text") {
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) {
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) {
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 {
IconButton(
onClick = { imagePickerLauncher.launch("image/*") },
) {
Icon(
Icons.Default.AddPhotoAlternate,
contentDescription = "添加图片",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
keyboardOptions = if (enterToSend) {
KeyboardOptions(imeAction = ImeAction.Done)
} else {
KeyboardOptions.Default
},
keyboardActions = if (enterToSend) {
KeyboardActions(
onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() },
)
} else {
KeyboardActions.Default
},
)
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,
)
}
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 = "发送",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@@ -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,377 +0,0 @@
package top.yeij.cyrene.ui.screens.settings
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.util.KeepAliveManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeepAlivePage(
onBack: () -> Unit,
) {
val context = LocalContext.current
val keepAliveManager = KeepAliveManager(context)
val fgRunning = WebSocketKeepAliveService.isRunning
val batteryExempt = keepAliveManager.isBatteryOptimizationExempt()
val canOverlay = keepAliveManager.canDrawOverlays()
val manufacturerName = keepAliveManager.getManufacturerName()
val batteryLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
// Re-check battery optimization after returning from settings
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("后台保活") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// Header explanation
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Android 系统会在应用进入后台后限制网络连接或终止进程,导致无法接收服务端主动推送的消息。请按照以下方法加强后台保活能力。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
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),
)
// 1. Foreground Service
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Notifications,
contentDescription = null,
tint = if (fgRunning) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "前台服务通知",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (fgRunning) "已开启,通知栏显示「昔涟 — 已连接」" else "切后台时显示持久通知保活"
)
}
Switch(
checked = fgRunning,
onCheckedChange = {
if (it) {
WebSocketKeepAliveService.start(context)
} else {
WebSocketKeepAliveService.stop(context)
}
},
)
}
}
// 2. Battery Optimization
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (batteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "忽略电池优化",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (batteryExempt) "已免除,Doze 模式不会限制网络"
else "未免除,后台待久会被系统限制网络(Doze 休眠)"
)
}
if (!batteryExempt) {
TextButton(onClick = {
batteryLauncher.launch(
keepAliveManager.openBatteryOptimizationSettings()
)
}) {
Text("去设置")
}
}
}
}
// 3. Auto-start (OEM-specific)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "自启动管理",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = when (manufacturerName) {
"xiaomi" -> "小米手机请在「安全中心 → 自启动管理」中允许昔涟自启动"
"huawei" -> "华为手机请在「手机管家 → 自启动管理」中允许昔涟自启动"
"oppo" -> "OPPO 手机请在「设置 → 应用自启动」中允许昔涟自启动"
"vivo" -> "vivo 手机请在「i管家 → 自启动」中允许昔涟自启动"
"oneplus" -> "一加手机请在「设置 → 自启动」中允许昔涟自启动"
"samsung" -> "三星手机请在「设置 → 电池 → 不受限制的应用」中添加昔涟"
else -> "请在系统设置中为昔涟开启「自启动/后台运行」权限"
}
)
}
TextButton(onClick = {
val intent = keepAliveManager.getAutoStartIntent()
if (intent != null) {
try {
context.startActivity(intent)
} catch (_: Exception) {
// Fallback to app info
try {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (_: Exception) { }
}
} else {
Toast.makeText(context, "未找到对应设置页面,请手动前往系统设置", Toast.LENGTH_LONG).show()
}
}) {
Text("去设置")
}
}
}
// 4. Lock task (recent apps lock)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "锁定后台任务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "进入最近任务界面(多任务键),将昔涟卡片下拉锁定,防止系统清理后台时误杀"
)
}
}
}
// 5. Battery saver passthrough
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "电池优化白名单",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "手动确认系统电池优化白名单,确保昔涟不被限制"
)
}
TextButton(onClick = {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
try {
context.startActivity(intent)
} catch (_: Exception) { }
}) {
Text("查看")
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "补充提示",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Text(
text = """
不同的手机厂商对待后台应用的方式各不相同:
• 谷歌 Pixel / 原生 Android:开启电池优化豁免即可
• 小米 MIUI / HyperOS:需同时开启自启动 + 电池无限制
• 华为 HarmonyOS:需开启自启动 + 关闭省电模式限制
• OPPO ColorOS / vivo OriginOS:需开启自启动 + 后台运行
• 三星 OneUI:需添加到「不受限制的应用」列表
实际效果因系统版本和厂商策略而异。建议至少开启「前台服务通知」+「忽略电池优化」两项。
""".trimIndent(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -1,954 +0,0 @@
package top.yeij.cyrene.ui.screens.settings
import android.os.Build
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.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.automirrored.filled.Send
import androidx.compose.material.icons.filled.BatterySaver
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.Security
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
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.ScrollableTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
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.mutableIntStateOf
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.graphics.Brush
import androidx.lifecycle.compose.LocalLifecycleOwner
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.ui.theme.PresetColorLabels
import top.yeij.cyrene.ui.theme.PresetThemeColors
import top.yeij.cyrene.util.KeepAliveManager
import top.yeij.cyrene.util.LogCategory
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onNavigateToKeepAlive: () -> 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 typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val themeColor by viewModel.themeColor.collectAsState()
val enterToSend by viewModel.enterToSend.collectAsState()
val rootKeepAlive by viewModel.rootKeepAlive.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)
},
)
var showColorDialog by remember { mutableStateOf(false) }
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
ListItem(
headlineContent = { Text("主题色") },
supportingContent = { Text(currentColorLabel) },
leadingContent = {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = androidx.compose.ui.graphics.Color(
(PresetThemeColors[themeColor]?.seed ?: 0xFFE91E8C).toInt()
),
shape = CircleShape,
),
)
},
modifier = Modifier.clickable { showColorDialog = true },
)
if (showColorDialog) {
AlertDialog(
onDismissRequest = { showColorDialog = false },
title = { Text("选择主题色") },
text = {
Column {
// Monet option (Android 12+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
val isMonet = themeColor == "monet"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.saveThemeColor("monet")
showColorDialog = false
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.background(
brush = androidx.compose.ui.graphics.Brush.horizontalGradient(
listOf(
androidx.compose.ui.graphics.Color(0xFF4ECDC4),
androidx.compose.ui.graphics.Color(0xFFFF6B6B),
androidx.compose.ui.graphics.Color(0xFFFFE66D),
androidx.compose.ui.graphics.Color(0xFF45B7D1),
)
),
shape = CircleShape,
)
.then(
if (isMonet) Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
) else Modifier
),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "莫奈取色",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "跟随壁纸",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Preset color chips
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PresetThemeColors.keys.forEach { key ->
val isSelected = themeColor == key
val seedColor = androidx.compose.ui.graphics.Color(
(PresetThemeColors[key]?.seed ?: 0xFFE91E8C).toInt()
)
val label = PresetColorLabels[key] ?: key
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable {
viewModel.saveThemeColor(key)
showColorDialog = false
}
.padding(4.dp),
) {
Box(
modifier = Modifier
.size(36.dp)
.background(seedColor, CircleShape)
.then(
if (isSelected) Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
) else Modifier.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
CircleShape,
)
),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showColorDialog = false }) {
Text("关闭")
}
},
)
}
val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
ListItem(
headlineContent = { Text("正在输入指示器") },
supportingContent = { Text(indicatorStyleLabel) },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
modifier = Modifier.clickable {
val next = if (typingIndicatorStyle == "bubble") "text" else "bubble"
viewModel.saveTypingIndicatorStyle(next)
},
)
ListItem(
headlineContent = { Text("回车键发送") },
supportingContent = { Text(if (enterToSend) "回车直接发送消息" else "回车换行") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) },
trailingContent = {
androidx.compose.material3.Switch(
checked = enterToSend,
onCheckedChange = { viewModel.saveEnterToSend(it) },
)
},
modifier = Modifier.clickable { viewModel.saveEnterToSend(!enterToSend) },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Background keep-alive
val keepAliveManager = remember { KeepAliveManager(context) }
var isBatteryExempt by remember { mutableStateOf(keepAliveManager.isBatteryOptimizationExempt()) }
val lifecycleOwner = LocalLifecycleOwner.current
// Re-check battery exemption when returning from system settings
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) {
isBatteryExempt = keepAliveManager.isBatteryOptimizationExempt()
}
})
}
// Hidden root toggle: tap section title 5 times to reveal
var rootTapCount by remember { mutableIntStateOf(0) }
var rootRevealed by remember { mutableStateOf(false) }
Text(
text = "后台保活",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(16.dp)
.combinedClickable(
onClick = {
if (!rootRevealed) {
rootTapCount++
if (rootTapCount >= 5) {
rootRevealed = true
rootTapCount = 0
Toast.makeText(context, "已解锁 Root 保活选项", Toast.LENGTH_SHORT).show()
}
}
},
),
)
ListItem(
headlineContent = { Text("忽略电池优化") },
supportingContent = {
Text(
if (isBatteryExempt) "已允许,后台连接更稳定"
else "未允许,建议开启以确保消息推送及时送达"
)
},
leadingContent = {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = if (isBatteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
)
},
modifier = Modifier.clickable {
if (!isBatteryExempt) {
try {
context.startActivity(keepAliveManager.openBatteryOptimizationSettings())
} catch (_: Exception) {
Toast.makeText(context, "无法打开电池优化设置", Toast.LENGTH_SHORT).show()
}
}
},
)
// Root-level keep-alive (hidden by default, revealed after 5 taps)
if (rootRevealed) {
val isRootAvailable = remember { RootKeepAliveHelper.isRootAvailable() }
ListItem(
headlineContent = { Text("Root 保活 (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "未检测到 Root 权限"
else if (rootKeepAlive) "已启用 — 系统级白名单、Doze豁免、强制后台运行"
else "使用 Root 权限将应用加入系统级白名单,对抗任何保活限制"
)
},
leadingContent = {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
tint = if (rootKeepAlive && isRootAvailable) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = rootKeepAlive,
enabled = isRootAvailable,
onCheckedChange = { enabled ->
if (enabled) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败,请检查 Root 权限", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
},
modifier = Modifier.clickable(enabled = isRootAvailable) {
val newState = !rootKeepAlive
if (newState) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
// System wakelock toggle (held only while app is alive, reapplied on boot)
var sysWakeLockHeld by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text("系统级 WakeLock (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "需要 Root 权限"
else if (sysWakeLockHeld) "已持有系统级内核锁,CPU永不休眠"
else "写入 /sys/power/wake_lock 阻止 CPU 休眠"
)
},
leadingContent = {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = if (sysWakeLockHeld) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = sysWakeLockHeld,
enabled = isRootAvailable,
onCheckedChange = { enable ->
if (enable) {
val ok = RootKeepAliveHelper.acquireSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = true
Toast.makeText(context, "系统 WakeLock 已持有 — 注意:将显著增加耗电", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(context, "WakeLock 获取失败", Toast.LENGTH_SHORT).show()
}
} else {
val ok = RootKeepAliveHelper.releaseSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = false
Toast.makeText(context, "系统 WakeLock 已释放", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "WakeLock 释放失败", Toast.LENGTH_SHORT).show()
}
}
},
)
},
)
}
ListItem(
headlineContent = { Text("保活设置") },
supportingContent = { Text("前台服务、自启动管理、OEM厂商后台白名单") },
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToKeepAlive() },
)
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("取消")
}
},
)
}
// 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)
}
}
ScrollableTabRow(
selectedTabIndex = selectedTab,
edgePadding = 16.dp,
divider = {},
) {
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
)
}
}
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("清空")
}
}
val displayLogs = if (selectedTab == 0) {
logEntries.takeLast(500)
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat }.takeLast(500) else emptyList()
}
if (displayLogs.isEmpty()) {
Text(
text = "暂无${tabs[selectedTab]}日志",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else {
// Log count header
Text(
text = "共 ${displayLogs.size} 条${if (displayLogs.size >= 500) "+" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// Fixed-height scrollable log area
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.height(280.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
RoundedCornerShape(8.dp),
)
.padding(8.dp),
) {
Column(
modifier = Modifier.verticalScroll(scrollState),
) {
displayLogs.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,201 +0,0 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
// Each preset provides a seed color and light/dark primary colors
data class ThemePreset(
val seed: Long,
val lightPrimary: Color,
val darkPrimary: Color,
)
val PresetThemeColors = mapOf(
"pink" to ThemePreset(
seed = 0xFFE91E8C,
lightPrimary = Color(0xFFC2185B),
darkPrimary = Color(0xFFFF80AB),
),
"sakura" to ThemePreset(
seed = 0xFFFFB4C8,
lightPrimary = Color(0xFFE91E63),
darkPrimary = Color(0xFFFFB4C8),
),
"lavender" to ThemePreset(
seed = 0xFF6D3BC0,
lightPrimary = Color(0xFF6D3BC0),
darkPrimary = Color(0xFFD3BBFF),
),
"ocean" to ThemePreset(
seed = 0xFF1565C0,
lightPrimary = Color(0xFF1565C0),
darkPrimary = Color(0xFF90CAF9),
),
"forest" to ThemePreset(
seed = 0xFF2E7D32,
lightPrimary = Color(0xFF2E7D32),
darkPrimary = Color(0xFFA5D6A7),
),
"sunset" to ThemePreset(
seed = 0xFFE65100,
lightPrimary = Color(0xFFE65100),
darkPrimary = Color(0xFFFFCC80),
),
"rose" to ThemePreset(
seed = 0xFFD81B60,
lightPrimary = Color(0xFFAD1457),
darkPrimary = Color(0xFFF48FB1),
),
"sky" to ThemePreset(
seed = 0xFF0277BD,
lightPrimary = Color(0xFF0277BD),
darkPrimary = Color(0xFF81D4FA),
),
"mint" to ThemePreset(
seed = 0xFF00695C,
lightPrimary = Color(0xFF00695C),
darkPrimary = Color(0xFF80CBC4),
),
)
val PresetColorLabels = mapOf(
"pink" to "昔涟粉",
"sakura" to "樱花粉",
"lavender" to "薰衣草紫",
"ocean" to "海洋蓝",
"forest" to "森林绿",
"sunset" to "日落橙",
"rose" to "玫瑰红",
"sky" to "天空蓝",
"mint" to "薄荷青",
)
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
private data class HSL(val h: Float, val s: Float, val l: Float)
private fun Color.toHSL(): HSL {
val r = red / 255f
val g = green / 255f
val b = blue / 255f
val maxV = max(max(r, g), b)
val minV = min(min(r, g), b)
val delta = maxV - minV
val l = (maxV + minV) / 2f
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
val h = when {
delta == 0f -> 0f
maxV == r -> 60f * (((g - b) / delta) % 6f)
maxV == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}
return HSL(if (h < 0) h + 360f else h, s, l)
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val c = (1f - abs(2f * l - 1f)) * s
val x = c * (1f - abs((h / 60f) % 2f - 1f))
val m = l - c / 2f
val (r, g, b) = when {
h < 60f -> Triple(c, x, 0f)
h < 120f -> Triple(x, c, 0f)
h < 180f -> Triple(0f, c, x)
h < 240f -> Triple(0f, x, c)
h < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
}
/**
* Build a full light ColorScheme derived from a primary color.
* All secondary/tertiary/container colors are computed from the primary
* so focus rings, ripples, badges, and containers all match the theme.
*/
fun buildLightScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
// Secondary: similar hue, less saturated
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.35f, 0.42f)
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
// Tertiary: complementary hue shift
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.38f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
val onPrimary = Color.White
val onSecondary = Color.White
val onTertiary = Color.White
val surfaceTint = primary
return lightColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
/**
* Build a full dark ColorScheme derived from a primary color.
*/
fun buildDarkScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.40f, 0.76f)
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.80f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
val surfaceTint = primary
return darkColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
@@ -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,58 +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.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
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
@Composable
fun CyreneTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
presetKey: String = "pink",
useDynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val preset = getPreset(presetKey)
val colorScheme = when {
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> buildDarkScheme(preset.darkPrimary)
else -> buildLightScheme(preset.lightPrimary)
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as? Activity)?.window
if (window != null) {
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
}
} else {
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
}
}
}
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,149 +0,0 @@
package top.yeij.cyrene.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import top.yeij.cyrene.service.WebSocketKeepAliveService
class KeepAliveManager(private val context: Context) {
// --- 前台服务 ---
val isForegroundServiceRunning: Boolean
get() = WebSocketKeepAliveService.isRunning
// --- 电池优化 ---
fun isBatteryOptimizationExempt(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun openBatteryOptimizationSettings(): Intent {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
}
}
}
// --- 自启动 / 后台管理 (OEM-specific) ---
fun getAutoStartIntent(): Intent? {
val packageName = context.packageName
val manufacturers = listOf(
// Xiaomi
AutoStartIntent("xiaomi", Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"
)
}),
AutoStartIntent("xiaomi", Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.appmanager.ApplicationsManagerActivity"
)
}),
// Huawei
AutoStartIntent("huawei", Intent().apply {
component = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
)
}),
AutoStartIntent("huawei", Intent().apply {
component = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.optimize.process.ProtectActivity"
)
}),
// Oppo
AutoStartIntent("oppo", Intent().apply {
component = ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.StartupAppListActivity"
)
}),
AutoStartIntent("oppo", Intent().apply {
component = ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.FakeActivity"
)
}),
// Vivo
AutoStartIntent("vivo", Intent().apply {
component = ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity"
)
}),
AutoStartIntent("vivo", Intent().apply {
component = ComponentName(
"com.iqoo.secure",
"com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"
)
}),
// Samsung
AutoStartIntent("samsung", Intent().apply {
component = ComponentName(
"com.samsung.android.lool",
"com.samsung.android.sm.ui.battery.BatteryActivity"
)
}),
// OnePlus
AutoStartIntent("oneplus", Intent().apply {
component = ComponentName(
"com.oneplus.security",
"com.oneplus.security.chainlaunch.view.ChainLaunchAppListActivity"
)
}),
// Generic fallback: app info
AutoStartIntent("generic", Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName")
)),
)
for (entry in manufacturers) {
val intent = entry.intent
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) {
return intent
}
}
return null
}
fun getManufacturerName(): String {
return Build.MANUFACTURER.lowercase()
}
private data class AutoStartIntent(val manufacturer: String, val intent: Intent)
// --- 悬浮窗权限 (optional, for overlay mode) ---
fun canDrawOverlays(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(context)
} else {
true
}
}
fun openOverlaySettings(): Intent {
return Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
}
}
@@ -1,71 +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.R
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(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText(preview)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
val notifyId = message.id.hashCode()
notificationManager.notify(notifyId, notification)
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
}
fun cancelAll() {
notificationManager.cancelAll()
}
companion object {
private const val CHANNEL_ID = "cyrene_messages"
}
}
@@ -1,170 +0,0 @@
package top.yeij.cyrene.util
import android.util.Log
import java.io.DataOutputStream
import java.io.File
/**
* Hidden root-based keep-alive operations. Only accessible via secret gesture in Settings.
* Performs system-level whitelisting that normal apps cannot do.
*/
object RootKeepAliveHelper {
private const val TAG = "CyreneRootKA"
private fun execRoot(vararg commands: String): Boolean {
return try {
val process = Runtime.getRuntime().exec("su")
val os = DataOutputStream(process.outputStream)
val shell = buildString {
append("export PATH=\$PATH:/system/bin:/system/xbin:/su/bin:/sbin:/vendor/bin\n")
for (cmd in commands) {
append(cmd).append(" 2>&1\n")
}
append("exit\n")
}
os.writeBytes(shell)
os.flush()
os.close()
process.waitFor()
val exitCode = process.exitValue()
if (exitCode != 0) {
Log.w(TAG, "Root command returned exit code $exitCode")
}
exitCode == 0
} catch (e: Exception) {
Log.e(TAG, "Root exec failed: ${e.message}")
false
}
}
fun isRootAvailable(): Boolean {
// Check common su binary locations
val suPaths = listOf(
"/system/bin/su",
"/system/xbin/su",
"/su/bin/su",
"/sbin/su",
"/system/sbin/su",
"/vendor/bin/su",
"/data/local/bin/su",
)
for (path in suPaths) {
if (File(path).exists()) return true
}
// Fallback: try running 'which su'
return try {
val p = Runtime.getRuntime().exec(arrayOf("which", "su"))
p.waitFor()
p.exitValue() == 0
} catch (_: Exception) {
false
}
}
/**
* Apply aggressive root-level keep-alive.
* @param packageName The app's package name.
*/
fun applyRootKeepAlive(packageName: String): Boolean {
if (!isRootAvailable()) {
Log.w(TAG, "Root not available, cannot apply root keep-alive")
return false
}
Log.i(TAG, "Applying root keep-alive for $packageName")
val commands = mutableListOf<String>()
// 1. Doze whitelist — prevent Doze from blocking network/wakelocks
commands.add("dumpsys deviceidle whitelist +$packageName")
// 2. Disable standby bucket — keep app in "active" bucket
commands.add("am set-standby-bucket $packageName active")
// 3. Grant WAKE_LOCK permission at system level (bypasses appops)
commands.add("appops set $packageName WAKE_LOCK allow")
// 4. Disable battery optimization via system settings
commands.add("settings put global app_restrictions_enabled false")
commands.add("cmd appops set $packageName RUN_IN_BACKGROUND allow")
commands.add("cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow")
// 5. Make app not battery-restricted (hidden API)
commands.add("cmd deviceidle tempwhitelist $packageName")
// 6. Set app as START_FOREGROUND always allowed
commands.add("appops set $packageName START_FOREGROUND allow")
// 7. Persistent alarm allowance
commands.add("appops set $packageName SCHEDULE_EXACT_ALARM allow")
val success = execRoot(*commands.toTypedArray())
if (success) {
Log.i(TAG, "Root keep-alive applied successfully")
} else {
Log.e(TAG, "Failed to apply root keep-alive")
}
return success
}
/**
* Remove root-level keep-alive settings.
*/
fun removeRootKeepAlive(packageName: String): Boolean {
if (!isRootAvailable()) return false
Log.i(TAG, "Removing root keep-alive for $packageName")
val commands = listOf(
"dumpsys deviceidle whitelist -$packageName",
"am set-standby-bucket $packageName rarely",
"appops set $packageName WAKE_LOCK default",
"appops set $packageName RUN_IN_BACKGROUND default",
"appops set $packageName RUN_ANY_IN_BACKGROUND default",
"appops set $packageName START_FOREGROUND default",
"appops set $packageName SCHEDULE_EXACT_ALARM default",
)
val success = execRoot(*commands.toTypedArray())
if (success) {
Log.i(TAG, "Root keep-alive removed successfully")
}
return success
}
/**
* Hold a system-level wakelock. Use sparingly — drains battery.
* Released automatically when the process exits or can be released manually.
*/
private var wakeLockFile: File? = null
fun acquireSystemWakeLock(tag: String): Boolean {
if (!isRootAvailable()) return false
val lockPath = "/sys/power/wake_lock"
return try {
execRoot("echo '$tag' > $lockPath")
wakeLockFile = File(lockPath)
Log.i(TAG, "System wakelock acquired: $tag")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to acquire system wakelock: ${e.message}")
false
}
}
fun releaseSystemWakeLock(tag: String): Boolean {
if (!isRootAvailable()) return false
val lockPath = "/sys/power/wake_unlock"
return try {
execRoot("echo '$tag' > $lockPath")
wakeLockFile = null
Log.i(TAG, "System wakelock released: $tag")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to release system wakelock: ${e.message}")
false
}
}
}
@@ -1,128 +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("语音"),
NOTIFY("通知"),
}
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)
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, 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,382 +0,0 @@
package top.yeij.cyrene.viewmodel
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.WSAttachment
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 seen = mutableSetOf<String>()
return filter { seen.add(it.id) }
}
class ChatViewModel(
application: Application,
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val preferencesDataStore: PreferencesDataStore,
private val apiService: ApiService,
) : AndroidViewModel(application) {
companion object {
private var instanceCounter = 0
}
private val instanceId = ++instanceCounter
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
private var sendTimeoutJob: Job? = null
init {
RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
viewModelScope.launch {
try {
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
RuntimeLog.general("app", "Session initialized: $sessionId")
chatRepository.ensureConnected()
} catch (e: Exception) {
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
}
// Always try to load from DB, even if initializeSession failed.
// After process death the persisted session ID gives us the history.
val sid = currentSessionId
?: preferencesDataStore.currentSessionId.firstOrNull()
if (sid != null) {
currentSessionId = sid
chatRepository.currentSessionId = sid
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
loadMessagesFromDb(sid)
} else {
RuntimeLog.general("app", "No session ID available — cannot load messages")
}
}
// Observe incoming live messages — insert at correct descending position
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 {
// Insert at correct position for descending timestamp (newest first)
val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
val idx = _messageAnimIndex.value.toMutableMap()
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
updated.deduplicate()
}
// Any non-user response means the server acknowledged our message
if (_isSending.value && message.role != "user") {
_isSending.value = false
sendTimeoutJob?.cancel()
}
} 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
}
}
// Observe message removals (e.g. wrapping stream_end deduped by review items)
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_currentMessages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
// Reset user-side sending state when server starts responding
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) {
_isSending.value = false
sendTimeoutJob?.cancel()
}
}
}
}
// --- 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()
}
// --- Image attachments ---
private val _selectedImageUris = MutableStateFlow<List<Uri>>(emptyList())
val selectedImageUris: StateFlow<List<Uri>> = _selectedImageUris.asStateFlow()
fun addImages(uris: List<Uri>) {
_selectedImageUris.update { it + uris }
}
fun removeImage(index: Int) {
_selectedImageUris.update { list ->
list.filterIndexed { i, _ -> i != index }
}
}
fun clearImages() {
_selectedImageUris.value = emptyList()
}
private data class UploadResult(
val attachment: WSAttachment,
val thumbnailUrl: String,
)
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
return withContext(Dispatchers.IO) {
try {
val cr = getApplication<Application>().contentResolver
val mimeType = cr.getType(uri) ?: "image/jpeg"
val filename = uri.lastPathSegment ?: "image"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
if (bytes.isEmpty()) return@withContext null
// Upload to server, get file_id
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
val response = apiService.uploadFile(part)
if (!response.isSuccessful) {
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
return@withContext null
}
val fileId = response.body()?.id ?: return@withContext null
// Construct thumbnail URL
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?.trimEnd('/') ?: "http://10.0.2.2:8080"
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
val attachment = WSAttachment(
type = "image",
fileId = fileId,
thumbnailUrl = thumbnailUrl,
filename = filename,
size = bytes.size.toLong(),
)
UploadResult(attachment, thumbnailUrl)
} catch (e: Exception) {
Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
null
}
}
}
// Override sendMessage to support image attachments
fun sendMessage() {
val text = _inputText.value.trim()
val uris = _selectedImageUris.value
if (text.isEmpty() && uris.isEmpty()) return
val sid = currentSessionId
if (sid == null) {
RuntimeLog.chat("send", "Cannot send — no current session")
return
}
_inputText.value = ""
_isSending.value = true
sendTimeoutJob?.cancel()
sendTimeoutJob = viewModelScope.launch {
delay(15_000L)
if (_isSending.value) {
Log.w("ChatViewModel", "Send timeout — no response in 15s, resetting")
_isSending.value = false
}
}
val localUriStrings = uris.map { it.toString() }
viewModelScope.launch {
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
clearImages()
val attachments = results.map { it.attachment }
val thumbnailUrls = results.map { it.thumbnailUrl }
try {
chatRepository.sendMessage(
text, sid,
attachments = attachments.ifEmpty { null },
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
)
} catch (e: Exception) {
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
_isSending.value = false
sendTimeoutJob?.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
.sortedByDescending { it.timestamp }
.deduplicate()
}
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)
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
}
}
}
fun onInputChanged(text: String) {
_inputText.value = text
}
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 {
// Clear all messages and let the DB observer rebuild from scratch.
// This avoids duplicates that occur when local-UUID messages survive
// the merge alongside server-ID versions loaded from HTTP.
_currentMessages.value = emptyList()
if (!isConnected.value) {
chatRepository.ensureConnected()
}
chatRepository.loadMessagesFromServer(sid)
} 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,275 +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 seen = mutableSetOf<String>()
return filter { seen.add(it.id) }
}
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 processingTimeoutJob: Job? = null
private var lastAssistantMessageId: String? = null
init {
viewModelScope.launch {
chatRepository.observeMessages().collect { message ->
_messages.update { list ->
val updated = list.toMutableList()
val existingIdx = updated.indexOfLast { it.id == message.id }
if (existingIdx >= 0) {
updated[existingIdx] = message
} else {
// Insert at correct position for ascending timestamp (oldest first for top-down layout)
val insertAt = updated.indexOfFirst { it.timestamp >= message.timestamp }
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
val animIdx = _messageAnimIndex.value.toMutableMap()
animIdx[message.id] = animCounter++
_messageAnimIndex.value = animIdx
}
updated.deduplicate()
}
// Any non-user response means the server acknowledged our message
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
cancelProcessingTimeout()
setWaiting()
}
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
lastAssistantMessageId = message.id
speakResponse(message.content)
}
}
}
}
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) {
cancelProcessingTimeout()
} else if (_state.value == OverlayState.PROCESSING) {
delay(500)
if (_state.value == OverlayState.PROCESSING) {
setWaiting()
}
}
}
}
viewModelScope.launch {
ttsEngine.onDone.collect {
if (_state.value == OverlayState.SPEAKING) {
setWaiting()
}
}
}
viewModelScope.launch {
chatRepository.messageClearEvents.collect {
_messages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_messages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
}
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()
startProcessingTimeout()
viewModelScope.launch {
try {
chatRepository.sendMessage(text, null)
} catch (e: Exception) {
Log.e("OverlayVM", "sendText failed: ${e.message}", e)
if (_state.value == OverlayState.PROCESSING) setWaiting()
}
}
}
// --- 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
startProcessingTimeout()
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
}
private fun startProcessingTimeout() {
cancelProcessingTimeout()
processingTimeoutJob = viewModelScope.launch {
delay(15_000L)
if (_state.value == OverlayState.PROCESSING) {
Log.w("OverlayVM", "Processing timeout — no response in 15s, resetting to WAITING")
setWaiting()
}
}
}
private fun cancelProcessingTimeout() {
processingTimeoutJob?.cancel()
processingTimeoutJob = 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,232 +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 _themeColor = MutableStateFlow("pink")
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
private val _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
private val _rootKeepAlive = MutableStateFlow(false)
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.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 {
preferencesDataStore.typingIndicatorStyle.collect { value ->
_typingIndicatorStyle.value = value
}
}
scope.launch {
preferencesDataStore.enterToSend.collect { value ->
_enterToSend.value = value
}
}
scope.launch {
preferencesDataStore.rootKeepAlive.collect { value ->
_rootKeepAlive.value = value
}
}
scope.launch {
preferencesDataStore.themeColor.collect { value ->
_themeColor.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 saveTypingIndicatorStyle(style: String) {
_typingIndicatorStyle.value = style
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
}
fun saveEnterToSend(enabled: Boolean) {
_enterToSend.value = enabled
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
}
fun saveThemeColor(color: String) {
_themeColor.value = color
scope.launch { preferencesDataStore.saveThemeColor(color) }
}
fun saveRootKeepAlive(enabled: Boolean) {
_rootKeepAlive.value = enabled
scope.launch { preferencesDataStore.saveRootKeepAlive(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,211 @@
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.prefs.AppSettings
import top.yeij.cyrene.core.prefs.EnumPreferences
import top.yeij.cyrene.core.prefs.SessionPreferences
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.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.repository.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.DocumentRepository
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)
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 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)
fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = authManager::currentToken,
onAuthRecover = authManager::recoverSocketAuth,
onAuthFailed = authManager::onSessionExpired,
)
}
@@ -0,0 +1,32 @@
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 kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
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 scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
container.authManager.bootstrap()
}
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,38 @@
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() {
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) }
}
val container = (application as CyreneApplication).container
setContent {
CyreneRoot(container)
}
}
/**
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
setIntent(intent)
ShareIntake.accept(this, intent)
}
}
@@ -0,0 +1,229 @@
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.flow.StateFlow
import top.yeij.cyrene.domain.chat.DocumentAttachments
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 都会报图片那一类,风险面很小。
*
* ## 清理
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,见 [ShareRules]),
* 另有系统的 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
val extracted = ShareRules.extract(
action = intent.action,
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
stream = intent.streamUri(),
streams = intent.streamUris(),
clipUris = intent.clipUris(),
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
mimeOf = { uri ->
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
},
)
// 没有任何文本、也没有任何待落地的 URI ⇒ 这条分享里什么都没有(丢件计数在提取期恒为 0;
// 保留原来那条防御性判断,只是把「文件」那一档一并算上)。
if (extracted.isEmpty && extracted.droppedImages == 0 && extracted.droppedDocuments == 0) 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`。
*
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
*/
fun take(): SharedContent? = holder.take()
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
/**
* 把一份非图片分享文件的字节拷进 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
}
}
private fun Intent.streamUri(): String? =
getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)?.toString()
private fun Intent.streamUris(): List<String> =
getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java).orEmpty().map { it.toString() }
/** `ClipData` 里的 URI(部分 App 只填它);纯文本条目的 URI 为空,天然被过滤掉。 */
private fun Intent.clipUris(): List<String> {
val clip = clipData ?: return emptyList()
return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri?.toString() }
}
}
@@ -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,106 @@
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.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
enum class ThemeMode { System, Light, Dark }
/**
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
*
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
*/
enum class InterruptMode(val wireScope: String?) {
/**
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
*
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
*/
AppendQueue(null),
/**
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
*/
Interrupt("current"),
}
/**
* App 级偏好(非机密)。
*
* @param themeMode 主题深浅
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
* @param thinkingExpanded 思维链卡片默认是否展开
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
*/
data class AppPrefs(
val themeMode: ThemeMode = ThemeMode.System,
val showNarration: Boolean = true,
val thinkingExpanded: Boolean = true,
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
)
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
class AppSettings(context: Context) {
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
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(),
)
}
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 }
}
private fun String?.toThemeMode(): ThemeMode = when (this) {
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Dark.name -> ThemeMode.Dark
else -> ThemeMode.System
}
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
private fun String?.toInterruptMode(): InterruptMode = when (this) {
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
else -> InterruptMode.AppendQueue
}
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")
}
}
@@ -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,95 @@
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 `baseUrl + username`;换服务器/账号后快照作废
* @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()
}
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,16 @@
package top.yeij.cyrene.core.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
@Composable
fun CyreneTheme(
mode: ColorSchemeMode = ColorSchemeMode.System,
content: @Composable () -> Unit,
) {
val controller = remember(mode) { ThemeController(mode) }
MiuixTheme(controller = controller, content = content)
}
@@ -0,0 +1,54 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
*
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
*/
interface AdminApi {
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
@GET("api/v1/admin/observability/metrics")
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
@GET("api/v1/admin/observability/trace")
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
/**
* 后端日志 tail(api-client §2「可观测性契约」,v0.35):`{"data":[{time,level,source,msg}]}`,
* **最新在前**;`limit` 缺省 200、上限 1000(≤0 或非法回落缺省)。
*
* 不暴露契约里的 `?level=`:它是**最低等级**过滤,只在服务端裁剪一次结果——而界面上那排
* 等级档要能在同一份数据上来回切换(契约 §3.12 也明确推送帧的过滤只能本地做),
* 所以这里一律取全量、由客户端本地过滤(`ui/admin/AdminOverviewMerge.kt`)。
*
* RBAC 是 v0.40 的 `viewerAuth`:**viewer** 也读得到(owner / admin 本来就可以),
* 与 `trace` / `metrics` 同档。
*/
@GET("api/v1/admin/observability/logs")
suspend fun logs(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminLogEntryDto>>>
/** 人格列表(含 admin 专属 `thinking`)。 */
@GET("api/v1/admin/personas")
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
/** 私人记忆正文;`viewer` 会拿到 403。 */
@GET("api/v1/admin/memories")
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
/** 后台思考记录;`viewer` 会拿到 403。 */
@GET("api/v1/admin/backthink")
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
}
@@ -0,0 +1,122 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
object ApiClientFactory {
val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
fun create(
baseUrl: String,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
/** 通用入口:按需构建任意 Retrofit service。 */
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
tokenProvider: () -> String? = { null },
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): T {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
// 再高一级就会把凭据打进 logcat。
level = HttpLoggingInterceptor.Level.BASIC
redactHeader("Authorization")
},
)
}
}
.build()
return Retrofit.Builder()
.baseUrl(normalizeBaseUrl(baseUrl))
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(service)
}
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
fun normalizeBaseUrl(raw: String): String {
var url = raw.trim()
if (url.isEmpty()) return url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "http://$url"
}
if (!url.endsWith("/")) url += "/"
return url
}
/**
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
*
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
*/
private class AuthInterceptor(
private val tokenProvider: () -> String?,
private val unauthorizedPolicy: UnauthorizedPolicy,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
var response = chain.proceed(chain.request().withAuth(token))
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
when (val recovery = unauthorizedPolicy.renew(token)) {
is AuthRecovery.Renewed -> {
response.close()
response = chain.proceed(chain.request().withAuth(recovery.token))
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
}
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
AuthRecovery.Transient -> Unit
}
return response
}
private fun Request.withAuth(token: String?): Request = newBuilder()
.header("X-API-Version", "1")
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
}
private const val HTTP_UNAUTHORIZED = 401
}
@@ -0,0 +1,21 @@
package top.yeij.cyrene.data.api
import kotlinx.serialization.Serializable
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
@Serializable
data class ApiEnvelope<T>(
val data: T? = null,
val error: ApiError? = null,
)
@Serializable
data class ApiErrorEnvelope(
val error: ApiError? = null,
)
@Serializable
data class ApiError(
val code: String = "",
val message: String = "",
)
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more