黑曜石移动端 Phase 5:手机第一次写回 GitHub
「黑曜石移动端」是我做的 Obsidian 知识库 Android 伴侣:GitHub 私有仓库是唯一真相源,手机端 Kotlin + Compose 负责阅读——之前四个阶段都只读。Phase 5 的目标是让手机第一次真正写回 GitHub:编辑已有 Markdown、安全保存、处理好冲突。开发、真机验证、文档、提交同日完成,这篇记录全过程。
交付的核心闭环
一句话:手机可以编辑已有 Markdown 并安全写回 GitHub,核心闭环真机实测通过——
1 | Reader → 编辑 → 保存 → GitHub Contents API → Commit |

图 1:保存闭环:先落暂存再 PUT,409 走两版本冲突界面
拆开看是五块内容:
- 入口恢复:Reader → More →「编辑」进入既有 EditorScreen,展示真实 Markdown source。进入时冻结
baseSha与原文作为本次 session base——后台 Tree refresh 绝不替换编辑中的文本。 - GitHub Write:
updateFile()(PUT contents,body = message + Base64 正文 + base sha + defaultBranch)。409 映射为新增的DomainError.Conflict,这是唯一的冲突信号。 - 保存即收敛:新正文立即按 newSha 写 ContentCache(Reader 返回直接读,不重新下载自己刚上传的正文);Tree entry 立即指向新 blob 且
observedChangedAt = now(Home 的「最近修改」即时出现);appScope异步 refreshTree 不阻塞 UI。 - 冲突处理:SHA 过期 409 → 绝不静默覆盖 → 两版本 ConflictScreen(「我的修改 / GitHub 版本」两个 Tab,无 diff、无三方合并)。「使用我的修改」弹确认后重取最新 remote SHA 再 PUT;「使用 GitHub 版本」放弃手机修改、缓存远端、清暂存。Phase 1 旧的「创建 conflict-remote 文件」模型随本阶段正式退役。
- PendingEdit 暂存:发送 PUT 前先把内容写进 Room
pending_edits表(DB v1→v2 手动 Migration,老设备平滑升级),失败/冲突/进程死亡均保留,成功即清。Editor 打开检测到暂存 →「发现未保存的修改」对话框,恢复 = rebase 到当前 entry SHA,丢弃 = 明确清除。
三个关键设计决策
Draft 先于 PUT 落盘。 任何写 GitHub 的动作先把内容暂存到 Room——崩溃恢复不是靠事后日志,而是靠「写之前就有副本」。恢复时用当前 entry SHA 做 base(rebase 语义),避免恢复后保存必然 409。
冲突只有一种。 只处理「编辑期间远端被改」这一种真正重要的并发;不建 conflict 文件、不做自动合并、不做离线写队列。GitHub 是真相源,两版本 UI 让用户显式选择——这是数据安全与复杂度的平衡点。
收敛走两条时钟。 本地 entry/缓存同步更新(Reader/Home 即时正确),整树 refresh 异步跟上(最终一致),UI 永远不等网络。
过程:按八步纪律推进
整个阶段按项目的 Build Discipline 八步执行,几个关键节点:
- 先写 API 再动 UI:PUT/JSON-read 与 409 映射先行,29 个 JVM 测试覆盖 200/401/403/404/409/断网,以及 body 四字段、中文 emoji CRLF 的 Base64 逐字节保真。构建通过后才动 UI。
- 真实保存 E2E:用 gh CLI 在测试仓
tushuguan预建测试文件(不碰正式 vault),手机编辑加一行保存 → gh 核实远端正文逐字节一致 + commit940e6726;飞行模式冷启动重开仍显示新内容(新 SHA 保存即缓存)。 - 真实冲突 E2E(本阶段最重要验证):手机打开(base=ABC)→ gh 改远程(=DEF)→ 手机保存 → ConflictScreen 两 Tab 内容分别核实 →「使用我的修改」确认覆盖 → gh 核实远端被手机版本覆盖(
6172a9a8);再次分叉 →「使用 GitHub 版本」→ Reader 显示 PC 版 → gh 确认远端零新增 commit,没有误写。
最终 JVM 单测 133/133(较 Phase 4 +22)、androidTest 18/18(+9 仓库级写流程),真网 E2E 每条路径至少一遍、gh 侧逐条复核。
踩坑与修正
这个阶段踩的坑质量很高,值得逐条留档:
| 坑 | 现象 | 处置 |
|---|---|---|
| Gboard 候选词托盘遮挡按钮 | 引导页「下一步」点了 20 分钟没反应,uiautomator dump 里一切正常 | 截图才发现 IME 托盘盖住下半屏。教训:tap 莫名失效时先截图,dump 看不见 IME |
adb shell input text 转义 |
用 %20 输入空格结果原样打进编辑器 |
正确转义是 %s;token 注入用 input text "$(gh auth token)",不回显 |
| androidTest 进程 uid | 写流程测试全挂:缓存写文件 ENOENT | 测试运行在目标 App uid,.test 包私有目录不可写(mkdirs 静默失败);改用目标 App Context,并做「setUp 快照 + tearDown 恢复」,在日常手机上跑测试零副作用 |
connectedAndroidTest 卸载 App |
跑完测试模拟器里 App 消失 | AGP 默认行为;重装 + 重新引导 |
| Editor load 竞态(真 bug) | 离线进 Editor 打字后内容疑似被旧版本覆盖 | load 完成前用户输入可能被加载结果冲掉;加 loaded 门控,加载完成前输入框禁用 |
最后一个 Editor load 竞态是这阶段唯一的真 bug,也正是「编辑 session base 必须稳定」这条设计原则的边界情况。中间还虚惊一场:同一次离线会话里 Editor 显示内容比 Reader 少一行,查完数据层(Room/缓存/远端 SHA)全部一致,定性为打字撞上加载窗口的瞬时态,已由门控根治。
结论
App 达到 Daily-usable V1:读(渲染/链接/图片/离线)与写(编辑/保存/冲突保护)闭环都过了真实 GitHub 验证;任何失败路径都不丢内容——draft 兜底 + 冲突绝不静默覆盖。
一个日常建议:手机端使用 scoped 到知识库仓库的 fine-grained PAT(只开 Contents: Read and write),不要把 gh 登录态的宽权限 token 留在手机 App 里。
下一阶段 Phase 6 要把 PC 侧自动同步打通,让 PC↔GitHub↔Android 三端链路闭环,那是后话。