apps\README.md¶
源码路径:
apps\README.md· 154 行
# NFarms Android · 五个 App
一个 Gradle 工程、五个**相互独立**的 APK,共用一个 `core:common` 库。
```text
apps/
├─ settings.gradle.kts 五个 app + core:common
├─ gradle/libs.versions.toml 版本目录(AGP 8.7.3 / Kotlin 2.0.21 / Compose BOM 2024.09.00)
├─ build-all.ps1 一次构建五个 APK → dist-android-apps/
├─ core/common/ 存储(原子 JSON)/ 加密(AES-GCM)/ 验签(RSA·ECDSA)/ 共享 UI
├─ player/ Media3 + MediaSessionService
├─ store/ 点单 / 离线订单队列 / 账单验签 / 幂等上报
├─ hub/ 任务调度 / 模型下载校验 / FFmpeg 封装 / dsh 调度
├─ iot/ BLE 协议编解码 / 状态机 / 固件升级验签
└─ online/ 工单同步 / 附件校验 / 离线草稿 / 三路合并
```
## 构建
```powershell
.\apps\build-all.ps1 # debug APK
.\apps\build-all.ps1 -Task assembleRelease
```
产物落在 `dist-android-apps\nfarms-<app>-0.1.0-debug.apk`。
**两个环境前提**(踩过,写在这里):
| 前提 | 为什么 |
| --- | --- |
| **JDK 21**,不能是 26 | AGP 8.7.3 只认到 JDK 22;用 JDK 26 时 Gradle 只报一行 `What went wrong: 26.0.1`,完全看不出是 JDK 问题。`build-all.ps1` 会自动指到 `.tools\jdk21` |
| Android SDK(build-tools 36+,platform 36) | `local.properties` 不是必需的,脚本用 `ANDROID_HOME` |
## 五个 App 与需求对照
| App | 技术栈 | 需求里的核心职责 | 本轮落地情况 |
| --- | --- | --- | --- |
| **Player** | Kotlin + Media3/ExoPlayer | 播放/听书、列表同步、加密解密、歌词/书签、跨端数据模型 | 全部落地;**不做 Rust 重写播放器** |
| **Store** | Kotlin + Compose | 商城/点单/账单、离线订单队列、账单签名校验、幂等上报、加密存储 | 全部落地;支付走 `PaymentGateway` 接口 + 本地模拟 |
| **HUB** | Kotlin 外壳 + FFmpeg/模型运行时 | 任务调度、模型下载校验、FFmpeg 封装、ds harness 调度 | 全部落地;FFmpeg 与 dsh 按「有可执行文件就用真」降级 |
| **IOT** | Kotlin + BLE | 协议编解码、状态机、固件升级包签名校验 | 协议/状态机/OTA 全部落地;BLE 传输给模拟实现 + 真机接入说明 |
| **online** | Kotlin + Compose | 工单同步、附件校验、离线草稿、冲突合并 | 全部落地 |
## 每个 App 的关键设计(以及为什么这么做)
### Player
* **`MediaSessionService` 是硬要求**,不是选择:只放 Activity 里的 ExoPlayer 会「切后台被挂起、没通知栏控制、接电话不暂停」,而且**都不报错**。
* 耳机控制**不自己监听** `ACTION_MEDIA_BUTTON` —— 交给 MediaSession,系统负责分发。
* **变速与进度都落盘**:只改 `PlaybackParameters` 不写盘,下次启动回到 1.0x,有声书用户会立刻发现。
* **加密曲目是整文件 AES-GCM**,不是流式:GCM 的认证标签在尾部,流式解密无法在起播前验证完整性;做对需要「分块 + 每块独立标签」的格式,那是下一步。
### Store
* **幂等键由业务字段派生**(`shopId + 下单时刻 + 行项目`),不是随机 UUID。随机 UUID 在重试时是**新键**,后端会当成第二笔 —— 这正是重复扣款的成因。界面上「服务端已收单」数字不会因重试而变大,那就是幂等在起作用。
* **先落盘订单再支付**:支付成功后进程被杀,下次启动还能看到这笔未上报的单。
* **账单验签不仅验签名,还核对订单号与金额**:只验签名等于允许「把 10 元账单贴到 100 元单上」。
* 支付**走 `PaymentGateway` 接口**:真实 SDK 接进来时只换实现,业务代码不动。演示网关在金额尾数为 4 时失败,用来验证失败路径。
### HUB
* **串行调度器**:同时跑两个 ffmpeg 会互抢 CPU,同时下两个 17 GB 权重会写满磁盘。
* **模型必须「下载 → 校验 → 落盘」**:先落 `.part`,SHA-256 通过才 rename。顺序反了,校验失败的那一瞬文件会被当成可用。
* **FFmpeg 只用 LGPL 可用编码器**(mpeg4/aac)。要 libx264 得换 GPL 构建,那会把整个 APK 拖进 GPL —— 这个选择写在界面里。
* **进度解析 `-progress pipe:1`**,不是正则匹 stderr(那个格式随版本变)。
* **前台服务 + `dataSync` 类型**:长任务不挂前台服务,切后台就被挂起,而且不报错。
### IOT
* **帧格式**:`AA 55 | CMD | LEN | PAYLOAD | CRC8`,小端,CRC 覆盖 `CMD+LEN+PAYLOAD`。
* **增量解码**:BLE 通知会被拆包/粘包,解码器自己缓冲、找帧头、按 LEN 等够;CRC 错只丢一个字节再重找(扔掉整帧会连带丢掉紧跟的合法帧)。
* **命令必须有超时**:演示传输层默认丢 8% 的包(真实 2.4G 环境就这样),没有超时会永久挂住,而界面只是「没反应」。
* **OTA 拒绝顺序:签名 → 摘要 → 机型 → 版本**。反了会有一个具体后果:先比版本再验签,攻击者把 version 改大就能绕过版本检查。
* 界面上三个按钮(改内容 / 换密钥签 / 版本倒退)可以**当场验证拒绝路径真的会拒绝**。
### online
* **三路合并**,必须有「基线版本」(`base` = 上次同步成功的快照)。只有 local vs remote 无法区分「我改的」和「他改的」,会把别人的修改当成自己的覆盖掉。
* **`rev` 乐观锁**:服务端 rev 变了就拒绝提交并让客户端合并 —— **不用「最后写入者赢」**,它会静默吃掉别人的修改。
* 远端会在 8 秒后自己改一单,所以冲突是**自然发生**的,不是按钮伪造的。
## 本轮是「本地可跑」,不是「生产可上」
按约定,支付 / 云端上报 / BLE 真机 / 工单后端都用**接口 + 本地或模拟实现**隔离。每一处都在代码里标了接入点:
| 位置 | 接口 | 接真实东西时改哪 |
| --- | --- | --- |
| 支付 | `store/pay/Payment.kt` → `PaymentGateway` | 换掉 `MockGateway`,需要商户号 + 服务端下单接口 + 服务端验签回调 |
| 订单上报 | `store/data/Sync.kt` → `ReportApi` | 换掉 `DemoReportApi` 为 HTTP 实现;**后端必须按 `idempotencyKey` 去重** |
| BLE | `iot/transport/Transport.kt` → `BleTransport` | 实现 `RealBleTransport`(类注释里有 scan/connect/write/notify 的完整落点) |
| 工单后端 | `online/data/TicketRepo.kt` → `TicketApi` | 换掉 `DemoTicketApi`;服务端要有 rev 乐观锁 |
| 数据密钥 | `core/secure/Crypto.kt` → `Secrets` | 换 `SoftwareSecrets` 为 Android Keystore(`KeyGenParameterSpec` + `AndroidKeyStore`),业务代码不动 |
| 模型/固件签名 | `hub/engine/Models.kt`、`iot/ota/Ota.kt` | 把公钥固定成**厂商公钥并编译进只读资源**;决不能从服务器现拉 |
| FFmpeg / dsh | `hub/engine/Ffmpeg.kt`、`Harness.kt` | 把可执行文件放到 App 私有目录 `bin/`(`ffmpeg` 需 GPL 时自行评估合规) |
## 已知限制
* **HUB 的 ffmpeg 与 dsh 默认不存在**:Android 上不内置这两个二进制,界面会如实显示「未安装/演示进程」,并给出放置路径。这是刻意的 —— 一个 60 MB 的二进制只为让演示跑起来不值当。
* **Player 的加密曲目整文件解密到缓存**:大文件(几百 MB 有声书)起播会慢;见上面 Player 第 4 条。
* **IOT 的 `RealBleTransport` 未实现**:没有真机时写出来的只会是「看起来能编过、跑起来连不上」,那比没有更坏。
* **五个 App 不共享登录态**:独立 APK 各有沙箱,共享登录要 ContentProvider / AccountManager,不在本轮范围。
* **release 构建用 debug 签名**:能装能跑,但不能上架;发布前要换正式 keystore。
## 真机/模拟器验收
`apps/build-all.ps1` 只保证「编得过」;`apps/smoke-install.ps1` 才验证「装得上、跑得起来」:
```powershell
# 装五个 APK 并逐个启动,用 logcat 抓 FATAL / ANR
powershell -ExecutionPolicy Bypass -File .\apps\smoke-install.ps1
# 只验其中两个
powershell -ExecutionPolicy Bypass -File .\apps\smoke-install.ps1 -Only hub,player -SettleSec 8
```
判定标准是 `FATAL EXCEPTION` 与 `ANR in <pkg>`,而不是「窗口出现了」——
一个 App 完全可以先画出界面、再在后台协程里崩掉。
本轮在 **Android 16 (API 36) x86_64 模拟器**上实际跑通的路径(截图在 `apps/screenshots/`):
| App | 走通的流程 | 证据 |
| --- | --- | --- |
| Player | 点播放 → `PLAYING(3)` + 音频焦点 + 前台服务 → 进度推进、歌词高亮跟随 → 杀进程重开,从「上次进度」续播 | 日志 `MediaSessionService: state=PLAYING`、界面 `00:43 / 00:45`; |
| Store | 选规格 → 加购 → 下单并支付 → 购物车清空 → 订单「已确认」→ 服务端已收单 1 笔(幂等键不变) | 审计三条:`[checkout] [pay] [report]`; |
| HUB | 下载 Qwen3-Coder → `校验中 100%` → `完成` → 卡片变「已就绪」并显示 256 KB 与 SHA-256 | 磁盘 `files/nfarms/models/qwen3-coder-30b-a3b-q4.gguf`; |
| IOT | 扫描 2 台 → 连接 `NF-Demo-01` → 状态轮询 → OTA 传 48 KB → **固件 1.4.2 → 1.5.0** → 三种坏包分别被 `Digest` / `Signature` / `Version` 拒绝 | 界面版本号与三条拒绝原因; |
| online | 首次进入自动同步 → **拉取 2 张工单** → 显示 rev / 状态 / 摘要 | 审计 `[sync] 拉取 2 · 合并 0 · 冲突 0 · 推送 0`; |
## 本轮修掉的 bug(都是"编译/单测都过,但跑起来不对")
| bug | 表象 | 根因 |
| --- | --- | --- |
| Player 的上一首/下一首无效 | 点按钮没反应 | `setMediaItem` 只放了单曲,队列长度 1;改成 `setMediaItems(全列表, index)` |
| Store 支付失败后购物车被清空 | 用户重挑一遍 | 清空写在 `placeOrder` 之前;改成支付成功后才 `commitCartAfterPay` |
| Store 把未支付的单上报了 | 后端收到没付钱的单 | 上报队列没校验 `payTxnId`;加了 `PendingPayment` 状态与过滤 |
| IOT 没有连接时升级必被「版本倒退」误拒 | 首次进 App 点升级就报错 | 状态初值是空版本;改成用 `initialVersion` 预置当前版本 |
| IOT OTA 卡在 95% | 小命令全正常,只有大帧超时 | 模拟设备把**每个 MTU 分片**单独解帧,68 字节的 `OtaChunk` 永远拼不出完整帧 |
| IOT 偶发「回了 ack 却报超时」 | 日志里 `收到 升级应答` 与 `等升级应答超时` 同时出现 | 计时窗口套在 `write()` 外面,分片写耗时吃掉了等待预算;改成先立 waiter、写完再计时,并按帧长放大上限 |
| online 同步拿不到别人建的工单 | 首次进入 0 张单 | `sync()` 只遍历本地已有工单,没把服务端多出来的拉下来 |
| Player 秒退/掉帧 | 播放页明显卡 | 进度采样每秒 5 次 `copy()` 整个 `PlayerUi`,导致整棵界面重组;改成值变了才发布,并把落盘移到 IO |
| Player 文件路径截断到没有文件名 | 显示 `ser/0/cn.nfarms.player/...` | 用 `takeLast` 截尾而非截头 |
| Player 变速按钮少一个 | `2.0x` 不见且不报错 | `Row` 放不下会裁掉;改成 `FlowRow` 自动换行 |
| 耳机/蓝牙播放键不生效 | 按了没反应 | 清单里缺 `MediaButtonReceiver`(`Couldn't find a unique registered media button receiver`) |
| HUB 目录标签指错 | 照界面找文件找不到 | 标签写死 `files/nfarms/media/models`,实际是 `files/nfarms/models` |
三条与「可测性」有关的改动值得单独记:
1. `AppFiles` 增加了纯目录构造 `AppFiles.at(...)` —— 之前仓库层依赖 `Context`,
导致「支付失败后购物车还在不在」这类**最该测**的行为根本跑不了 JVM 测试(它确实出过 bug)。
2. `Crypto` 从 `android.util.Base64` 换成 `java.util.Base64` —— 前者在 JVM 单测里是未实现的桩,
等于密码学这一层一行都测不了。
3. 测试从 35 条增加到 **57 条**,新增的是**端到端**的:整条 OTA(含 48 KB 分片传输)、
模拟设备的握手/大帧往返、下单→支付→验签、演示后端乐观锁与三路合并。