Commit Graph
7 Commits
Author SHA1 Message Date
AskaEth f3d8d28cf7 fix(manifest): 显式声明 INTERNET(不再搭在 okhttp-android 的传递清单上)
原来 release 能联网是靠 com.squareup.okhttp3:okhttp-android:5.5.0 的清单把 INTERNET 合并进来的;
换掉/排除那个依赖,release 会静默失去网络权限(debug 侧 AGP 也注入同一份,所以本地怎么试都看不出来)。
现在显式写进源码。验收::app:processReleaseMainManifest 的 merger 报告里该权限已是
ADDED from app/src/main/AndroidManifest.xml(并 MERGED from okhttp-android)⇒ 来源是源码。
2026-10-06 18:43:13 +08:00
AskaEth 0243fb78d4 feat(设置): 新增「后台保持连接」开关与前台服务(默认关)
现状是一件保活都没做:Android 12+ 会把 cached 进程冻结,进程级共享的那条 WS 连接
(AppContainer.chatSocket)久了就断。这一批只解决「进程别被冻」这一件事,连接本身的生死
仍然归 SessionsViewModel(登录连、登出关),本服务不碰它、也不订阅任何帧。

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

测试:新增 3 个文件 13 条 —— 默认关、三条件缺一不可、四入参全表一致、文案四档各不
相同且「跑不了时一个字都不提已开」、渠道重要级只许 LOW/MIN。基线 93 suite/1205 tests 全部保持
通过,本树 96 suite/1218 tests/0 失败。
2026-10-06 15:59:28 +08:00
AskaEth 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 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 5ac0ea3c3b fix(chat): 输入法弹出时输入框被顶到顶部
窗口默认 adjustUnspecified 会先做 pan,叠加 imePadding 后高度被算两次;改为 adjustResize。
2026-09-08 21:42:36 +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 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