feat: 添加项目级指引文档,包含上游参照、修改前必读、Wiki索引及关键约束
This commit is contained in:
79
.wiki/product-decisions.md
Normal file
79
.wiki/product-decisions.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 产品决策(duooomi-android-sdk)
|
||||
|
||||
## owner / scanNamePrefix 设为必填(破坏性)
|
||||
|
||||
**决策时间**:2026-05-08(commit `2dde274`)
|
||||
|
||||
`DuooomiBleConfig` 删除 `firmwareIdentifier` / `firmwareStatus`,改 `owner` / `scanNamePrefix` 为必填。
|
||||
|
||||
**原因**:
|
||||
- iOS HEAD 已经这么做,1:1 复刻要求一致
|
||||
- 多租户 `x-owner` header 是后端鉴权前提,给默认值反而隐患(默认填到错的租户)
|
||||
- `scanNamePrefix` 给默认值会扫到非预期设备
|
||||
|
||||
**影响**:集成方升级时构造函数签名报错,必须显式传两个参数。可接受,因为 demo / iOS / Harmony 都同步改了。
|
||||
|
||||
## bind 改瘦 + fetchLatestFirmware 串三步
|
||||
|
||||
**决策时间**:2026-05-08(commit `2dde274`)
|
||||
|
||||
旧设计:`bind(userId)` 一键搞定 BLE 硬绑 + 软绑 + 拉最新固件 + 对账上报。
|
||||
新设计:`bind` 只做硬绑 + getVersion;软绑/对账/拉固件搬到 `fetchLatestFirmware` 内部串。
|
||||
|
||||
**原因**:
|
||||
- 与 expo `bindDeviceWithOrchestration` 行为对齐,便于跨平台一致排查
|
||||
- bind 速度更快(不卡 HTTP 软绑),用户感知更自然
|
||||
- fetchLatestFirmware 失败可重试,但 bind 失败重试要回滚 BLE 状态——分开后职责清晰
|
||||
|
||||
**集成方迁移指南**:bind 之后必须显式调 `fetchLatestFirmware(sn, currentVersion, userId)` 才能拿到固件信息和触发对账。
|
||||
|
||||
## disconnect 时不清 sn / firmwareUpgrade
|
||||
|
||||
**决策时间**:2026-05-08(commit `0dbfab9`)
|
||||
|
||||
`sn` 和 `firmwareUpgrade` 在被动断开 / 主动 disconnect 时**不清空**,与 iOS HEAD 严格对齐。
|
||||
|
||||
**原因**:
|
||||
- `sn` 是设备身份。断线重连同设备时仍有效,避免业务方每次都要重新 bind 拿 sn
|
||||
- `firmwareUpgrade` 是 UI 展示数据,断线时 UI 突然空白体验差
|
||||
- 想清空 → unbind 即可(unbind 会清 sn)
|
||||
|
||||
**潜在风险**:如果集成方在 disconnect 后**换了一台设备 connect**,但没 unbind 也没 bind 直接调 fetchLatestFirmware,会用错 sn。判定:靠集成方约束,SDK 不强行守卫(与 iOS 一致)。
|
||||
|
||||
## UpgradeRecord 30 天 TTL + 对账 4 状态
|
||||
|
||||
**决策时间**:跟随 expo 设计(2026-05-08 复刻)
|
||||
|
||||
每条 UpgradeRecord 持久化于 SharedPreferences,状态机:
|
||||
|
||||
| reported | outcome | 含义 | 触发 |
|
||||
|---|---|---|---|
|
||||
| false | null | 待上报 / HTTP 失败重试中 | 默认 / report 网络错误 |
|
||||
| true | SUCCESS | 升级正确 | 设备实际版本 == 目标版本,report-success 成功 |
|
||||
| true | VERSION_MISMATCH | 升级失败 | 设备实际版本 ≠ 目标版本,report-failure 成功 |
|
||||
| true | null | 30 天 TTL 过期未上报 | 触发 OTA 后 30 天内仍未上报,标记审计轨迹 |
|
||||
|
||||
**原因**:
|
||||
- 30 天 TTL:防止持久化数据无限膨胀;30 天后用户感知低,可放弃
|
||||
- 状态保留:对账成功后**不删 record**,便于 UI 展示历史
|
||||
- `outcome=null` 与 "reported=false" 区分意图(一个是过期放弃,一个是网络在等重试)
|
||||
|
||||
## scanNamePrefix 由集成方约定(不再硬编码 "Duooomi-")
|
||||
|
||||
**决策时间**:跟随 iOS(2026-05-08)
|
||||
|
||||
旧版扫描自动按 `Duooomi-` 前缀过滤。新版作为必填 Config 字段。
|
||||
|
||||
**原因**:同一 SDK 代码可服务多个 brand(duomi / xmly 等),广播名前缀不同,硬编码只服务一家。Demo 默认填 `Duooomi-`(可改)。
|
||||
|
||||
## Demo 内运行时编辑 Config + Reload SDK
|
||||
|
||||
**决策时间**:2026-05-08(commit `417012c`)
|
||||
|
||||
Demo 顶部 Config 区可编辑 owner / apiKey / scanPrefix,SharedPreferences 持久化,点击 Reload SDK 重建实例。
|
||||
|
||||
**原因**:调试时切换租户 / 切换 apiKey 不用重装 app。iOS demo 已这么做。
|
||||
|
||||
**实现细节**:旧 SDK fire-and-forget disconnect 释放 BLE,再 `mutableStateOf<DuooomiBleSDK>` 赋新实例。Compose `collectAsState(sdk)` 自动订阅新 SDK 的 flow。
|
||||
|
||||
来源:复刻对话 2026-05-08。
|
||||
159
.wiki/tech-architecture.md
Normal file
159
.wiki/tech-architecture.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# 技术架构(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 # 含 UpgradeOutcome(success / version_mismatch)
|
||||
└── utils/
|
||||
├── BleLogger.kt
|
||||
└── BeijingTimeProvider.kt # 三级 fallback 时间获取
|
||||
```
|
||||
|
||||
## OTA 三段式编排(核心架构)
|
||||
|
||||
`bind` 改瘦 + `unbind` 改胖 + `fetchLatestFirmware` 串三步,是与 expo / iOS HEAD 1:1 对齐的核心。**不要回退到"bind 一键搞定全套"的旧设计**。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 集成方调用流 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
connect(deviceId)
|
||||
└─ BLE 连接 + 启动协议监听
|
||||
|
||||
bind(userId) ← 只做 BLE 硬绑 + getVersion
|
||||
├─ sendAndWait(BIND_DEVICE) payload = {type, userId, time}
|
||||
├─ 写入 sdk.sn / sdk.isActivated
|
||||
└─ runCatching { getVersion() } // 失败不阻断
|
||||
|
||||
fetchLatestFirmware(sn, currentVersion, userId) ← 三步编排
|
||||
├─ Step 1: tryReportPendingUpgrade(...) (fire-and-forget)
|
||||
│ └─ 把本地 pending UpgradeRecord 报给服务端
|
||||
├─ Step 2: shipmentService.bind(sn, userId) 软绑
|
||||
│ └─ 失败 → 自动硬解回滚 + 清 sn / isActivated
|
||||
└─ Step 3: 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 = ""
|
||||
└─ if cachedSn 非空 → shipmentService.unbind(...)(失败仅 warn)
|
||||
```
|
||||
|
||||
## 状态清理时机表
|
||||
|
||||
**严格对齐 iOS HEAD**。新增清理点前先确认 iOS 是否同步清。
|
||||
|
||||
| 状态字段 | 写入时机 | 清理时机 |
|
||||
|---|---|---|
|
||||
| `sn` | bind 成功(BindingResponse.sn) | unbind 成功;fetchLatestFirmware 软绑失败回滚 |
|
||||
| `firmwareUpgrade` | fetchLatestFirmware 成功 | (**从不主动清**) |
|
||||
| `isActivated` | bind 成功(true) / unbind 成功(false) / 软绑失败回滚(false) | disconnect handler / 主动 disconnect |
|
||||
| `version` | getVersion / bind | disconnect handler / 主动 disconnect |
|
||||
| `deviceInfo` | getDeviceInfo / setPlayMode 后自动刷新 | disconnect handler / 主动 disconnect |
|
||||
| `connectedDevice` | connect 成功 | disconnect handler / 主动 disconnect |
|
||||
| `transferProgress` | transferFile 进度回调 | (持续写,无显式清) |
|
||||
| `upgradeRecords` | upsert / markReported(store.onChange 推) | (持久化,不清) |
|
||||
|
||||
⚠️ **关键决策**:`sn` 和 `firmwareUpgrade` 在 disconnect 时**不清空**。理由:
|
||||
- `sn` 是设备身份,断线重连同设备时还有效,避免每次重连后丢失 OTA 上下文
|
||||
- `firmwareUpgrade` 是上次拉取结果,UI 可继续展示直到下次 fetchLatestFirmware 覆盖
|
||||
|
||||
之前 Android 这两个字段在 disconnect 时被清,与 iOS 不一致;commit `0dbfab9` 修正。
|
||||
|
||||
## 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。
|
||||
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# duooomi-android-sdk · 项目级指引
|
||||
|
||||
Android 版 Duooomi BLE SDK,与 iOS / expo 版业务行为 1:1 对齐。
|
||||
|
||||
## 上游参照
|
||||
|
||||
| 仓库 | 路径 | 关系 |
|
||||
|---|---|---|
|
||||
| `duooomi-ios-sdk` | `/Users/cchis/Documents/ship/bw/duooomi-ios-sdk` | **业务行为权威**。HEAD 是 1:1 复刻目标,发现偏差以 iOS 为准 |
|
||||
| `expo-duooomi-app` | `/Users/cchis/Documents/ship/bw/expo-duooomi-app` | OTA 编排原型(`bindDeviceWithOrchestration`) |
|
||||
| `duooomi-harmony-sdk` | `/Users/cchis/Documents/ship/bw/duooomi-harmony-sdk` | 平级复刻,可对照实现 |
|
||||
|
||||
## 修改前必读
|
||||
|
||||
改 SDK 业务行为前 → 先读对应 iOS 实现,**1:1 严格对齐**,偏离要有理由(详见 [跨平台 SDK 复刻方法论](~/.claude/wiki/cross-platform-sdk-port.md))。
|
||||
|
||||
改 BLE 协议层(帧格式 / discoverServices)→ 先看全局 [lessons-learned](~/.claude/wiki/lessons-learned.md) 中的 BLE 章节。
|
||||
|
||||
## 项目 Wiki 索引
|
||||
|
||||
| Wiki | 内容 |
|
||||
|---|---|
|
||||
| [tech-architecture](.wiki/tech-architecture.md) | 项目分层 / OTA 三段式编排 / 状态清理时机表 / sn 级互斥 / BeijingTime 三级 fallback / Demo Reload 机制 |
|
||||
| [product-decisions](.wiki/product-decisions.md) | owner 必填 / bind 改瘦 / disconnect 不清 sn / UpgradeRecord 4 状态 / scanNamePrefix 必填 |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **SDK 公开 API 改动 = 破坏性**。改前看 iOS HEAD 是不是也这样;不是 → 不要改
|
||||
- **状态清理时机** 改前查 [tech-architecture](.wiki/tech-architecture.md) 表格,与 iOS 对照
|
||||
- **HTTP path / header / body 字段名** 必须 1:1 与 iOS / expo 一致
|
||||
- **JSON payload 可选字段 nil 时省略 key**(不是 `null`),bind/setPlayMode 的 `loop` 字段必须遵守
|
||||
- **Demo 秘钥**:`demo/.../DemoSecrets.kt` 是 gitignored 真实文件;模板放 `docs/DemoSecrets.template.kt`(**不要放 demo/src/**,会 redeclare 冲突)
|
||||
|
||||
## 复刻 iOS 新功能时的标准工作流
|
||||
|
||||
1. `git -C ../duooomi-ios-sdk log <last-aligned>..HEAD --oneline` 圈 gap
|
||||
2. 按依赖关系重排 commit 顺序(基础设施合并到第一个 feature)
|
||||
3. 先做 SDK 改动 → debug + release 都编译过
|
||||
4. 写完后**对照 iOS 状态清理表逐字段核**
|
||||
5. 改 demo / README 跟随
|
||||
6. 全部跑通后系统化检查报告(哪些严格对齐、哪些平台惯例偏离)
|
||||
Reference in New Issue
Block a user