Files
duooomi-android-sdk/.wiki/tech-architecture.md
km2023 8d83588e0b feat(sdk): 绑定时上报设备 log 到 update-metadata 并内聚双层绑定
1:1 复刻 iOS commit 4bc8fdb。

- bind(userId) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用,
  软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定
- fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑
- VersionInfo 增加 log: JSONObject?,fromJson 用 optJSONObject 解析;
  SDK 暴露只读 versionLog: StateFlow<JSONObject?>,断连/解绑/软绑回滚时清空
- ShipmentSnDeviceService 新增 updateMetadata(sn, data: JSONObject),
  HttpHelper.post 已支持 JSONObject 作为 body 子节点透传
- 老固件不返回 log → versionLog == null → 自动跳过 updateMetadata,前向兼容
- 同步更新 .wiki/tech-architecture.md(状态清理表 + OTA 编排图)
  + product-decisions.md(新增决策条目)+ README.md(versionLog 状态)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:38:03 +08:00

167 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术架构duooomi-android-sdk
## 总体分层
```
sdk/src/main/kotlin/com/duooomi/ble/
├── DuooomiBleSDK.kt # 公开 API 入口state + 业务编排)
├── DuooomiBleConfig.kt # 不可变配置
├── core/ # BleClient / BleTypes / 错误枚举
├── protocol/ # 帧格式 / UUID / ProtocolManager
├── services/ # 协议层 RPC + 持久化 + 共享 HTTP
│ ├── BleProtocolService.kt # 协议层(帧组包/拆包,与平台无关)
│ ├── DeviceInfoService.kt # 设备命令getDeviceInfo / bind / setPlayMode 等)
│ ├── FileTransferService.kt # 文件传输
│ ├── AniConverter.kt # ANI 转换HTTP
│ ├── ShipmentSnDeviceService.kt # 软绑/软解 HTTP
│ ├── FirmwareUpgradeService.kt # checkUpgrade / report-success / report-failure
│ ├── UpgradeRecordStore.kt # SharedPreferences 持久化升级记录
│ └── HttpHelper.kt # 共享 POST 工具(统一 header / unwrap / 错误剥离)
├── models/
│ ├── DeviceInfo.kt # 含可选 PlayMode? loop
│ ├── PlayMode.kt # SINGLE=0 / LOOP=1
│ ├── BindingResponse.kt
│ ├── UnbindResponse.kt
│ ├── DeleteFileResponse.kt
│ ├── PrepareTransferResponse.kt
│ ├── VersionInfo.kt
│ ├── FirmwareUpgradeCheckResult.kt # 含 TargetFirmware (id/version/fileUrl/forceUpgrade...)
│ └── UpgradeRecord.kt # 含 UpgradeOutcomesuccess / version_mismatch
└── utils/
├── BleLogger.kt
└── BeijingTimeProvider.kt # 三级 fallback 时间获取
```
## OTA 编排(核心架构 · 2026-05-18 重构)
`bind` 内聚(硬绑+getVersion+软绑+updateMetadata+ `fetchLatestFirmware` 瘦身(对账+checkUpgrade+ `unbind` 改胖(硬解+软解),与 iOS HEAD 1:1 对齐。
```
┌──────────────────────────────────────────────────────────────┐
│ 集成方调用流 │
└──────────────────────────────────────────────────────────────┘
connect(deviceId)
└─ BLE 连接 + 启动协议监听
bind(userId) ← 完整绑定4 步内部编排,对集成方原子)
├─ Step 1: verifyBrand
├─ Step 2: sendAndWait(BIND_DEVICE) BLE 硬绑 0x0F
│ └─ 写 sdk.sn / sdk.isActivated
├─ Step 3: runCatching { getVersion() } // 失败不阻断
│ └─ 写 sdk.version / sdk.versionLog
├─ Step 4: shipmentService.bind(sn, userId) 软绑
│ └─ 失败 → 自动硬解回滚 + 清 sn/isActivated/versionLog → 整体失败抛 SoftBindFailed
└─ Step 5: scope.launch { updateMetadata(sn, versionLog) } (fire-and-forget)
└─ 仅当 versionLog 非 null 且非空对象;失败仅 warn
fetchLatestFirmware(sn, currentVersion, userId) ← 两步编排
├─ Step 1: tryReportPendingUpgrade(...) (fire-and-forget)
│ └─ 把本地 pending UpgradeRecord 报给服务端
└─ Step 2: firmwareUpgradeService.checkUpgrade(sn, currentVersion)
└─ 写 sdk.firmwareUpgrade
upgradeFirmware(sn, fileUrl, firmwareId, fromVersion, targetVersion)
├─ transferFile(OTA_PACKAGE)
└─ upgradeRecordStore.upsert(record) // 落记录等下次对账
unbind(userId) ← 硬解 + 软解(容错)
├─ 缓存 cachedSn = sdk.sn
├─ sendAndWait(UNBIND_DEVICE)
├─ 写 sdk.isActivated = false / sdk.sn = "" / sdk.versionLog = null
└─ if cachedSn 非空 → shipmentService.unbind(...)(失败仅 warn
```
## 状态清理时机表
**严格对齐 iOS HEAD**。新增清理点前先确认 iOS 是否同步清。
| 状态字段 | 写入时机 | 清理时机 |
|---|---|---|
| `sn` | bind 硬绑成功BindingResponse.sn | unbind 成功;**bind 软绑失败回滚** |
| `firmwareUpgrade` | fetchLatestFirmware 成功 | **从不主动清** |
| `isActivated` | bind 硬绑成功(true) / unbind 成功(false) / **bind 软绑失败回滚(false)** | disconnect handler / 主动 disconnect |
| `version` | getVersion / bind | disconnect handler / 主动 disconnect |
| `versionLog` | getVersion 成功log 非 null | unbind 成功 / **bind 软绑失败回滚** / disconnect handler / 主动 disconnect |
| `deviceInfo` | getDeviceInfo / setPlayMode 后自动刷新 | disconnect handler / 主动 disconnect |
| `connectedDevice` | connect 成功 | disconnect handler / 主动 disconnect |
| `transferProgress` | transferFile 进度回调 | (持续写,无显式清) |
| `upgradeRecords` | upsert / markReportedstore.onChange 推) | (持久化,不清) |
⚠️ **关键决策**`sn``firmwareUpgrade` 在 disconnect 时**不清空**。理由:
- `sn` 是设备身份,断线重连同设备时还有效,避免每次重连后丢失 OTA 上下文
- `firmwareUpgrade` 是上次拉取结果UI 可继续展示直到下次 fetchLatestFirmware 覆盖
之前 Android 这两个字段在 disconnect 时被清,与 iOS 不一致commit `0dbfab9` 修正。
⚠️ **2026-05-18 重构**:把软绑从 `fetchLatestFirmware` 搬到 `bind` 内部。理由:软绑是"绑定"语义的一部分,不该让"检查更新"承担。同步新增 `versionLog` 状态 + `updateMetadata` 上报。详见 `product-decisions.md`
## sn 级 in-flight 互斥锁UpgradeRecordStore
`tryReportPendingUpgrade` 是 fire-and-forget。如果 demo 短时间内连续两次调 `fetchLatestFirmware`(用户狂点),会出现两个协程并行处理同一 sn 的 pending records 重复 markReported。
**方案**sn 级互斥(不是全局锁)。
```kotlin
fun tryAcquire(sn: String): Boolean = synchronized(lock) {
if (inFlight.contains(sn)) return false // 后到的协程直接放弃
inFlight.add(sn); true
}
fun release(sn: String) = synchronized(lock) { inFlight.remove(sn) }
```
调用方约定:
```kotlin
if (!upgradeRecordStore.tryAcquire(sn)) return
scope.launch {
try { /* 上报逻辑 */ } finally { upgradeRecordStore.release(sn) }
}
```
> 不同 sn 的并发上报互不干扰;这点比单全局锁更细粒度,符合多设备场景。
## BeijingTimeProvider 三级 fallback
bind / unbind / setPlayMode payload 必带 `time` 字段(北京时间 ISO 8601
```
fetch():
if 5s 缓存命中 → return
Mutex.withLock:
1. GET https://quan.suning.com/getSysTime.do (timeout=3s)
→ JSON body { sysTime2 / sysTime1 } → 解析 ISO 8601
2. response Date header (RFC 1123) → 转北京时间
3. fallback: System.currentTimeMillis() + 8h offset
写缓存 + 返回
```
`Mutex.withLock` 起单飞作用 —— 多协程同时 fetch 只有第一个真请求,其余等锁释放后命中缓存。
## HTTP 约定
所有 RPC 走 `HttpHelper.post()`
- 必带 `x-api-key` + `x-owner` header来自 `DuooomiBleConfig`
- 响应 `{ success: true, data: {...} }` 包装 → unwrap 后取 `data` 字段unwrap=true默认
- 错误:剥离 `API error:` 前缀 + 尝试从 JSON 抽 `message` 字段
-`HttpHelper.HttpError(code, message)` —— Service 层负责转成 `DuooomiBleError.SoftBindFailed` / `TransferFailed`
## Demo 架构
```
MainActivity
├─ 持有 mutableStateOf<DuooomiBleSDK>(支持 reload
├─ SharedPreferences 持久化 owner / apiKey / scanPrefix
└─ 提供 onReload 回调 → 旧 SDK fire-and-forget disconnect + 新建实例
DemoScreen(sdk, config, onReload)
└─ 通过 sdk.xxx.collectAsState() 订阅 SDK StateFlow
reload 时 sdk 引用变化 → collectAsState 重新订阅新实例
```
⚠️ **DemoSecrets 文件**
- `demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` —— 真实秘钥,**gitignored**
- `docs/DemoSecrets.template.kt` —— 模板,**编译路径外**(不放在 demo/src 否则 redeclare 冲突)
来源commit `b439851` / `2dde274` / `0dbfab9` / 复刻对话 2026-05-08。