feat(sdk): OTA 升级流程对齐 expo + 秘钥本地化
主要改动(与 iOS SDK 1:1 对齐): - 新增 fetchLatestFirmware/upgradeFirmware/tryReportPendingUpgrade 三段 OTA 接口,对齐 expo bindDeviceWithOrchestration 行为 - bind 仅做硬绑+getVersion,软绑/对账上报/拉最新固件搬到 fetchLatestFirmware 内部串;软绑失败自动硬解回滚 - unbind 改胖:硬解 + 软解(容错失败仅 warn) - UpgradeRecord 复合主键 (sn, firmwareId) 持久化(SharedPreferences) + sn 级 in-flight 互斥 - 新增 sn / firmwareUpgrade / upgradeRecords StateFlow,集成方 可观察对账上报结果(reported/outcome 状态) - DuooomiBleConfig:删 firmwareIdentifier/firmwareStatus, 改 owner/scanNamePrefix 为必填字段 - 新增 ShipmentSnDeviceService(软绑/软解 HTTP)+ FirmwareUpgradeService(checkUpgrade/reportSuccess/reportFailure) + HttpHelper 共享 POST 工具 - 删 FirmwareInfo + FirmwareService + hasNewerFirmware(旧 API) - 新增 FirmwareUpgradeCheckResult 模型(含 forceUpgrade 字段) - BleClient 增加 service UUID 列表 log,便于固件兼容调试 - demo 秘钥移至本地 DemoSecrets.kt(gitignored),模板放 docs/ - demo 改造到新 OTA 流程 + 升级记录展示 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
sdk/build/
|
||||
demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.duooomi.ble.DuooomiBleSDK
|
||||
import com.duooomi.ble.core.ConnectionState
|
||||
import com.duooomi.ble.models.FirmwareInfo
|
||||
import com.duooomi.ble.models.FirmwareUpgradeCheckResult
|
||||
import com.duooomi.ble.models.PlayMode
|
||||
import com.duooomi.ble.protocol.CommandType
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -60,9 +60,9 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
|
||||
// Local state
|
||||
var userId by remember { mutableStateOf("test-user-001") }
|
||||
var fileUrl by remember { mutableStateOf("https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4") }
|
||||
var firmwareBrand by remember { mutableStateOf(sdk.config.firmwareIdentifier) }
|
||||
var firmwareStatus by remember { mutableStateOf(sdk.config.firmwareStatus) }
|
||||
var firmwareInfo by remember { mutableStateOf<FirmwareInfo?>(null) }
|
||||
val sn by sdk.sn.collectAsState()
|
||||
val firmwareUpgrade by sdk.firmwareUpgrade.collectAsState()
|
||||
val upgradeRecords by sdk.upgradeRecords.collectAsState()
|
||||
var firmwareLoading by remember { mutableStateOf(false) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
var connectingDeviceId by remember { mutableStateOf<String?>(null) }
|
||||
@@ -322,40 +322,21 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
|
||||
// === Firmware Section ===
|
||||
item {
|
||||
SectionHeader("Firmware Update")
|
||||
StatusRow("SN", sn.ifEmpty { "--" })
|
||||
StatusRow("Current Version", version.ifEmpty { "--" })
|
||||
|
||||
OutlinedTextField(
|
||||
value = firmwareBrand,
|
||||
onValueChange = { firmwareBrand = it },
|
||||
label = { Text("Identifier") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf("DRAFT", "PUBLISHED").forEach { status ->
|
||||
FilterChip(
|
||||
selected = firmwareStatus == status,
|
||||
onClick = { firmwareStatus = status },
|
||||
label = { Text(status) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
firmwareInfo?.let { info ->
|
||||
firmwareUpgrade?.targetFirmware?.let { target ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
StatusRow("Latest", info.version)
|
||||
info.fileSize?.let { StatusRow("Size", "$it bytes") }
|
||||
info.fileMd5?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text("MD5: $it", fontSize = 10.sp, color = Color.Gray)
|
||||
}
|
||||
info.description?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text(it, fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
Text(info.fileUrl, fontSize = 10.sp, color = Color.Gray, maxLines = 2)
|
||||
StatusRow("Latest", target.version)
|
||||
target.fileSize?.let { StatusRow("Size", "$it bytes") }
|
||||
target.fileMd5?.let { Text("MD5: $it", fontSize = 10.sp, color = Color.Gray) }
|
||||
target.forceUpgrade?.let { Text("forceUpgrade=$it", fontSize = 11.sp, color = Color.Red) }
|
||||
target.fileUrl?.let { Text(it, fontSize = 10.sp, color = Color.Gray, maxLines = 2) }
|
||||
}
|
||||
}
|
||||
} ?: firmwareUpgrade?.let {
|
||||
Text("Already up-to-date", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
if (firmwareLoading) {
|
||||
@@ -371,40 +352,67 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
|
||||
onClick = {
|
||||
scope.launch {
|
||||
firmwareLoading = true
|
||||
log("-> fetchLatestFirmware($firmwareBrand, $firmwareStatus)")
|
||||
log("-> fetchLatestFirmware(sn=$sn, version=$version, $userId)")
|
||||
try {
|
||||
val info = sdk.fetchLatestFirmware(firmwareBrand, firmwareStatus)
|
||||
firmwareInfo = info
|
||||
if (info != null) {
|
||||
log("firmware latest: ${info.version}", LogLevel.SUCCESS)
|
||||
val r = sdk.fetchLatestFirmware(sn = sn, currentVersion = version, userId = userId)
|
||||
val target = r.targetFirmware
|
||||
if (target != null) {
|
||||
log("firmware latest: ${target.version}", LogLevel.SUCCESS)
|
||||
} else {
|
||||
log("no firmware available")
|
||||
log("already up-to-date")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("firmware info FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { firmwareLoading = false }
|
||||
}
|
||||
},
|
||||
enabled = !firmwareLoading && firmwareBrand.isNotEmpty()
|
||||
enabled = !firmwareLoading && sn.isNotEmpty()
|
||||
) { Text("Get Update Info") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val info = firmwareInfo ?: return@launch
|
||||
val target: FirmwareUpgradeCheckResult.TargetFirmware =
|
||||
firmwareUpgrade?.targetFirmware ?: return@launch
|
||||
val targetFileUrl = target.fileUrl ?: return@launch
|
||||
isBusy = true
|
||||
log("-> upgradeFirmware ${info.version}")
|
||||
log("-> upgradeFirmware ${target.version}")
|
||||
try {
|
||||
sdk.upgradeFirmware(info.fileUrl)
|
||||
sdk.upgradeFirmware(
|
||||
sn = sn,
|
||||
fileUrl = targetFileUrl,
|
||||
firmwareId = target.id,
|
||||
fromVersion = version,
|
||||
targetVersion = target.version
|
||||
)
|
||||
log("OTA OK", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("OTA FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
},
|
||||
enabled = firmwareInfo?.fileUrl?.isNotEmpty() == true && connectedDevice != null && !isBusy
|
||||
enabled = firmwareUpgrade?.targetFirmware?.fileUrl?.isNotEmpty() == true
|
||||
&& connectedDevice != null && !isBusy
|
||||
) { Text("Upgrade") }
|
||||
}
|
||||
|
||||
if (upgradeRecords.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Upgrade Records (${upgradeRecords.size})", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
upgradeRecords.sortedByDescending { it.triggeredAt }.take(5).forEach { r ->
|
||||
val outcome = r.outcome
|
||||
val outcomeText = when {
|
||||
!r.reported -> "pending"
|
||||
outcome == null -> "expired"
|
||||
else -> outcome.raw
|
||||
}
|
||||
Text(
|
||||
"[$outcomeText] fw=${r.firmwareId} ${r.fromVersion}→${r.targetVersion}",
|
||||
fontSize = 10.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Device Files Section ===
|
||||
|
||||
@@ -28,7 +28,11 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
sdk = DuooomiBleSDK(
|
||||
context = this,
|
||||
config = DuooomiBleConfig(apiKey = "")
|
||||
config = DuooomiBleConfig(
|
||||
apiKey = DemoSecrets.API_KEY,
|
||||
owner = DemoSecrets.OWNER,
|
||||
scanNamePrefix = DemoSecrets.SCAN_NAME_PREFIX
|
||||
)
|
||||
)
|
||||
|
||||
requestBlePermissions()
|
||||
|
||||
10
docs/DemoSecrets.template.kt
Normal file
10
docs/DemoSecrets.template.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.duooomi.ble.demo
|
||||
|
||||
/**
|
||||
* Copy this file to `DemoSecrets.kt` (gitignored) and fill in real credentials.
|
||||
*/
|
||||
object DemoSecrets {
|
||||
const val API_KEY: String = "<paste-api-key>"
|
||||
const val OWNER: String = "<paste-owner>"
|
||||
const val SCAN_NAME_PREFIX: String = "Duooomi-"
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.duooomi.ble
|
||||
|
||||
/**
|
||||
* SDK 全局配置,在初始化时一次性传入。
|
||||
*/
|
||||
data class DuooomiBleConfig(
|
||||
/** API 服务地址 */
|
||||
val apiHost: String = "https://api.duooomi.com",
|
||||
/** API 鉴权 key(Loomart x-api-key) */
|
||||
val apiKey: String,
|
||||
/** 多租户 owner 标识,所有 RPC 必带 `x-owner` header */
|
||||
val owner: String,
|
||||
/** 扫描时按设备蓝牙广播名前缀过滤(大小写不敏感)。**必填**,由集成方约定固件广播名前缀。 */
|
||||
val scanNamePrefix: String,
|
||||
/** CDN 资源地址(末尾自动补 `/`) */
|
||||
val cdnHost: String = "https://cdn.bowong.cc/",
|
||||
val firmwareIdentifier: String = "duomi",
|
||||
val firmwareStatus: String = "DRAFT",
|
||||
/** ANI 转换宽度(像素) */
|
||||
val aniWidth: Int = 360,
|
||||
/** ANI 转换高度(像素) */
|
||||
|
||||
@@ -36,6 +36,18 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
private val _isActivated = MutableStateFlow(false)
|
||||
val isActivated: StateFlow<Boolean> = _isActivated.asStateFlow()
|
||||
|
||||
/** 当前已绑定设备的 SN(绑定成功后写入,解绑后清空) */
|
||||
private val _sn = MutableStateFlow("")
|
||||
val sn: StateFlow<String> = _sn.asStateFlow()
|
||||
|
||||
/** 服务端最新一次 [fetchLatestFirmware] 的结果。 */
|
||||
private val _firmwareUpgrade = MutableStateFlow<FirmwareUpgradeCheckResult?>(null)
|
||||
val firmwareUpgrade: StateFlow<FirmwareUpgradeCheckResult?> = _firmwareUpgrade.asStateFlow()
|
||||
|
||||
/** 当前 OTA 升级记录快照(按 triggeredAt 倒序展示用;持久化于 SharedPreferences)。 */
|
||||
private val _upgradeRecords = MutableStateFlow<List<UpgradeRecord>>(emptyList())
|
||||
val upgradeRecords: StateFlow<List<UpgradeRecord>> = _upgradeRecords.asStateFlow()
|
||||
|
||||
private val _transferProgress = MutableStateFlow(0)
|
||||
val transferProgress: StateFlow<Int> = _transferProgress.asStateFlow()
|
||||
|
||||
@@ -50,7 +62,9 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
private val deviceInfoService = DeviceInfoService(protocolService, timeProvider)
|
||||
private val fileTransferService = FileTransferService(protocolService)
|
||||
private val aniConverter = AniConverter(config)
|
||||
private val firmwareService = FirmwareService(config)
|
||||
private val shipmentService = ShipmentSnDeviceService(config)
|
||||
private val firmwareUpgradeService = FirmwareUpgradeService(config)
|
||||
private val upgradeRecordStore = UpgradeRecordStore(context)
|
||||
|
||||
// MARK: - Request-Response
|
||||
|
||||
@@ -69,8 +83,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
private var flushJob: Job? = null
|
||||
|
||||
init {
|
||||
BleLog.i("SDK initialized (apiHost=${config.apiHost}, firmware=${config.firmwareIdentifier}/${config.firmwareStatus})", "SDK")
|
||||
BleLog.i("SDK initialized (apiHost=${config.apiHost}, owner=${config.owner})", "SDK")
|
||||
setupDisconnectHandler()
|
||||
// 启动时把持久化记录回放到 StateFlow
|
||||
_upgradeRecords.value = upgradeRecordStore.all
|
||||
upgradeRecordStore.onChange = { records ->
|
||||
scope.launch { _upgradeRecords.value = records }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDisconnectHandler() {
|
||||
@@ -81,6 +100,8 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
_deviceInfo.value = null
|
||||
_version.value = ""
|
||||
_isActivated.value = false
|
||||
_sn.value = ""
|
||||
_firmwareUpgrade.value = null
|
||||
_btState.value = ConnectionState.DISCONNECTED
|
||||
protocolService.stopListening()
|
||||
cancelAllPending(DuooomiBleError.NotConnected)
|
||||
@@ -177,6 +198,8 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
_deviceInfo.value = null
|
||||
_version.value = ""
|
||||
_isActivated.value = false
|
||||
_sn.value = ""
|
||||
_firmwareUpgrade.value = null
|
||||
_btState.value = ConnectionState.DISCONNECTED
|
||||
BleLog.i("Disconnected", "Connect")
|
||||
|
||||
@@ -215,6 +238,20 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬绑:BLE 协议层把当前用户绑定到设备(命令 0x0F),完成后同步拉一次版本号。
|
||||
*
|
||||
* **作用域**:只做 BLE 通道的绑定 + [getVersion]。
|
||||
* 软绑 / 升级对账上报 / 拉最新固件 → 全部移到 [fetchLatestFirmware] 内部串。
|
||||
*
|
||||
* 完成后:
|
||||
* - [sn] 写入设备序列号
|
||||
* - [version] 写入当前固件版本(getVersion 失败时保持空,但 bind 不算失败)
|
||||
* - [isActivated] = true
|
||||
*
|
||||
* @throws DuooomiBleError.NotConnected 未连接
|
||||
* @throws DuooomiBleError.AlreadyBoundByOtherUser BLE 层 success != 1(设备已绑给他人)
|
||||
*/
|
||||
suspend fun bind(userId: String): BindingResponse {
|
||||
ensureConnected()
|
||||
BleLog.d("Sending bind (userId=$userId)", "Command")
|
||||
@@ -224,10 +261,13 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
val resp = BindingResponse.fromJson(parseJsonResponse(data))
|
||||
_isActivated.value = resp.success == 1
|
||||
if (resp.success != 1) {
|
||||
BleLog.w("Bind failed: device already bound", "Command")
|
||||
throw DuooomiBleError.BindingFailed("Device already bound to another user")
|
||||
BleLog.w("Hard bind failed: success=0", "Command")
|
||||
throw DuooomiBleError.AlreadyBoundByOtherUser
|
||||
}
|
||||
BleLog.i("Bind success: sn=${resp.sn}", "Command")
|
||||
_sn.value = resp.sn
|
||||
BleLog.i("Hard bind success: sn=${resp.sn}", "Command")
|
||||
// 同步拉版本,让后续 fetchLatestFirmware 的对账上报有 currentVersion 可用
|
||||
runCatching { getVersion() }
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -261,20 +301,33 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
return resp
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑:硬解绑(BLE 0x12)→ 软解绑(shipment-sn HTTP)。SDK 内部完成全套。
|
||||
*
|
||||
* 软解失败仅 warn 不阻断硬解结果回传 —— 对端云记录如果残留,由后续 bind 自然修正。
|
||||
*/
|
||||
suspend fun unbind(userId: String): UnbindResponse {
|
||||
ensureConnected()
|
||||
val cachedSn = _sn.value
|
||||
BleLog.d("Sending unbind (userId=$userId)", "Command")
|
||||
val data = sendAndWait(CommandType.UNBIND_DEVICE) {
|
||||
deviceInfoService.unbindDevice(userId)
|
||||
}
|
||||
val resp = UnbindResponse.fromJson(parseJsonResponse(data))
|
||||
if (resp.success == 1) {
|
||||
_isActivated.value = false
|
||||
BleLog.i("Unbind success", "Command")
|
||||
} else {
|
||||
if (resp.success != 1) {
|
||||
BleLog.w("Unbind failed", "Command")
|
||||
throw DuooomiBleError.UnbindFailed
|
||||
}
|
||||
_isActivated.value = false
|
||||
_sn.value = ""
|
||||
BleLog.i("Hard unbind success", "Command")
|
||||
if (cachedSn.isNotEmpty()) {
|
||||
runCatching {
|
||||
shipmentService.unbind(sn = cachedSn, bindUserId = userId)
|
||||
}.onFailure { e ->
|
||||
BleLog.w("Soft unbind failed (ignored): ${e.message}", "Command")
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -407,18 +460,136 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
transferFile(fileUri = aniUrl, commandType = CommandType.TRANSFER_ANI_VIDEO)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取服务端最新固件信息。**内部串三步业务编排**(顺序固定):
|
||||
*
|
||||
* 1. **对账上报**:把本地 pending [UpgradeRecord] 逐条报给服务端(容错失败,不阻断)
|
||||
* 2. **软绑**:POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系
|
||||
* - 软绑失败 → 自动硬解绑(BLE 0x12)回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败
|
||||
* 3. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
|
||||
*/
|
||||
suspend fun fetchLatestFirmware(
|
||||
identifier: String? = null,
|
||||
status: String? = null
|
||||
): FirmwareInfo? {
|
||||
return firmwareService.fetchLatest(identifier, status)
|
||||
sn: String,
|
||||
currentVersion: String,
|
||||
userId: String
|
||||
): FirmwareUpgradeCheckResult {
|
||||
if (sn.isEmpty()) {
|
||||
throw DuooomiBleError.FirmwareUpgradeUnavailable("sn is empty (bind first)")
|
||||
}
|
||||
// Step 1: 对账上报历史 OTA 记录(容错失败,sn 级 in-flight 互斥)
|
||||
tryReportPendingUpgrade(sn = sn, currentVersion = currentVersion, userId = userId)
|
||||
|
||||
// Step 2: 软绑(shipment-sn HTTP)
|
||||
BleLog.d("Soft bind start: sn=$sn", "Firmware")
|
||||
try {
|
||||
shipmentService.bind(sn = sn, bindUserId = userId)
|
||||
} catch (e: Exception) {
|
||||
BleLog.e("Soft bind failed during fetchLatestFirmware: ${e.message}; rolling back hard unbind", "Firmware")
|
||||
// 硬解绑回滚(fire-and-forget;BLE ack 不影响本次 fetch 错误回传)
|
||||
runCatching {
|
||||
deviceInfoService.unbindDevice(userId)
|
||||
}.onFailure { rb ->
|
||||
BleLog.w("Hard unbind rollback failed (ignored): ${rb.message}", "Firmware")
|
||||
}
|
||||
_isActivated.value = false
|
||||
_sn.value = ""
|
||||
throw e
|
||||
}
|
||||
BleLog.i("Soft bind success: sn=$sn", "Firmware")
|
||||
|
||||
// Step 3: 服务端 checkUpgrade
|
||||
val result = firmwareUpgradeService.checkUpgrade(sn = sn, currentVersion = currentVersion)
|
||||
_firmwareUpgrade.value = result
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun upgradeFirmware(fileUrl: String) {
|
||||
/**
|
||||
* OTA 固件烧录。完成后自动落 [UpgradeRecord] 到本地(复合主键 `(sn, firmwareId)`),
|
||||
* 等待下次 [fetchLatestFirmware] 触发 [tryReportPendingUpgrade] 对账上报。
|
||||
*/
|
||||
suspend fun upgradeFirmware(
|
||||
sn: String,
|
||||
fileUrl: String,
|
||||
firmwareId: String,
|
||||
fromVersion: String,
|
||||
targetVersion: String
|
||||
) {
|
||||
ensureConnected()
|
||||
BleLog.i("OTA upgrade start: $fileUrl", "Firmware")
|
||||
BleLog.i("OTA upgrade start: sn=$sn $fromVersion→$targetVersion (fw=$firmwareId)", "Firmware")
|
||||
transferFile(fileUri = fileUrl, commandType = CommandType.OTA_PACKAGE)
|
||||
BleLog.i("OTA upgrade completed", "Firmware")
|
||||
if (sn.isNotEmpty() && firmwareId.isNotEmpty() && fromVersion.isNotEmpty() && targetVersion.isNotEmpty()) {
|
||||
upgradeRecordStore.upsert(
|
||||
UpgradeRecord(
|
||||
sn = sn,
|
||||
firmwareId = firmwareId,
|
||||
fromVersion = fromVersion,
|
||||
targetVersion = targetVersion,
|
||||
triggeredAt = UpgradeRecordStore.nowMillis()
|
||||
)
|
||||
)
|
||||
BleLog.i("UpgradeRecord upserted: sn=$sn, fw=$firmwareId, $fromVersion→$targetVersion", "Firmware")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级对账上报:把本地 pending [UpgradeRecord] 逐条报给服务端。
|
||||
*
|
||||
* [fetchLatestFirmware] 内部第一步会调,集成方一般无需直接调用。**fire-and-forget**——
|
||||
* 没有异常抛出,结果通过 [upgradeRecords] 中 `reported` / `outcome` 字段反映。
|
||||
*
|
||||
* 内部逻辑(对齐 expo `bleStore.tryReportPendingUpgrade`):
|
||||
* - 30 天 TTL:超期记录 → `markReported(outcome=null)`,保留为审计轨迹
|
||||
* - 设备实际版本 == 目标版本 → `report-success` → `outcome=SUCCESS`
|
||||
* - 不命中 → `report-failure(VERSION_MISMATCH ...)` → `outcome=VERSION_MISMATCH`
|
||||
* - HTTP 失败 → 保持 `reported=false`,下次再重试
|
||||
* - sn 级 in-flight 互斥锁防止并发重复上报
|
||||
*/
|
||||
fun tryReportPendingUpgrade(sn: String, currentVersion: String, userId: String) {
|
||||
if (sn.isEmpty() || currentVersion.isEmpty()) return
|
||||
if (!upgradeRecordStore.tryAcquire(sn)) return
|
||||
scope.launch {
|
||||
try {
|
||||
val pending = upgradeRecordStore.pending(sn)
|
||||
val now = UpgradeRecordStore.nowMillis()
|
||||
for (r in pending) {
|
||||
if (now - r.triggeredAt > UpgradeRecordStore.TTL_MILLIS) {
|
||||
upgradeRecordStore.markReported(r.sn, r.firmwareId, outcome = null)
|
||||
continue
|
||||
}
|
||||
if (currentVersion == r.targetVersion) {
|
||||
runCatching {
|
||||
firmwareUpgradeService.reportUpgradeSuccess(
|
||||
sn = r.sn,
|
||||
firmwareId = r.firmwareId,
|
||||
fromVersion = r.fromVersion,
|
||||
bindUserId = userId
|
||||
)
|
||||
}.onSuccess {
|
||||
upgradeRecordStore.markReported(r.sn, r.firmwareId, UpgradeOutcome.SUCCESS)
|
||||
}.onFailure { e ->
|
||||
BleLog.w("Report success failed for fw ${r.firmwareId}: ${e.message}; will retry", "Firmware")
|
||||
}
|
||||
} else {
|
||||
val reason = "VERSION_MISMATCH expected=${r.targetVersion} actual=$currentVersion sn=${r.sn} userId=$userId"
|
||||
runCatching {
|
||||
firmwareUpgradeService.reportUpgradeFailure(
|
||||
sn = r.sn,
|
||||
firmwareId = r.firmwareId,
|
||||
fromVersion = r.fromVersion,
|
||||
failureReason = reason
|
||||
)
|
||||
}.onSuccess {
|
||||
upgradeRecordStore.markReported(r.sn, r.firmwareId, UpgradeOutcome.VERSION_MISMATCH)
|
||||
}.onFailure { e ->
|
||||
BleLog.w("Report failure failed for fw ${r.firmwareId}: ${e.message}; will retry", "Firmware")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
upgradeRecordStore.release(sn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal Helpers
|
||||
@@ -553,16 +724,6 @@ class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
|
||||
} ?: BleLog.w("No pending continuation for opId=$opId", "RPC")
|
||||
}
|
||||
|
||||
// MARK: - Version Check
|
||||
|
||||
fun hasNewerFirmware(deviceVersion: String?, serverVersion: String?): Boolean {
|
||||
if (deviceVersion.isNullOrEmpty() || serverVersion.isNullOrEmpty()) return false
|
||||
if (deviceVersion.length < 10 || serverVersion.length < 10) return false
|
||||
val deviceTs = deviceVersion.takeLast(10).toLongOrNull() ?: return false
|
||||
val serverTs = serverVersion.takeLast(10).toLongOrNull() ?: return false
|
||||
return serverTs > deviceTs
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private fun ensureConnected() {
|
||||
|
||||
@@ -287,6 +287,8 @@ internal class BleClient(context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
val services = gatt.services ?: emptyList()
|
||||
BleLog.d("Discovered ${services.size} service(s): ${services.map { it.uuid.toString() }}", "BLE")
|
||||
val service = gatt.getService(BleUUIDs.SERVICE)
|
||||
if (service == null) {
|
||||
val cont = connectContinuation
|
||||
|
||||
@@ -20,6 +20,9 @@ sealed class DuooomiBleError(message: String) : Exception(message) {
|
||||
object ServiceNotFound : DuooomiBleError("BLE service not found")
|
||||
object CharacteristicNotFound : DuooomiBleError("Characteristic not found")
|
||||
class BindingFailed(reason: String) : DuooomiBleError("Binding failed: $reason")
|
||||
object AlreadyBoundByOtherUser : DuooomiBleError("Device already bound to another user")
|
||||
class SoftBindFailed(reason: String) : DuooomiBleError("Soft bind failed: $reason")
|
||||
class FirmwareUpgradeUnavailable(reason: String) : DuooomiBleError("Firmware upgrade unavailable: $reason")
|
||||
object UnbindFailed : DuooomiBleError("Unbind failed")
|
||||
class DeleteFileFailed(val status: Int) : DuooomiBleError("Delete failed: status=$status")
|
||||
class PrepareTransferFailed(val status: String) : DuooomiBleError("Prepare failed: $status")
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.duooomi.ble.models
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
data class FirmwareInfo(
|
||||
val version: String,
|
||||
val fileUrl: String,
|
||||
val description: String?,
|
||||
val fileSize: String?,
|
||||
val fileMd5: String?,
|
||||
val identifier: String?,
|
||||
val status: String?
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): FirmwareInfo {
|
||||
return FirmwareInfo(
|
||||
version = json.optString("version", ""),
|
||||
fileUrl = json.optString("fileUrl", ""),
|
||||
description = json.optString("description").ifEmpty { null },
|
||||
fileSize = json.optString("fileSize").ifEmpty { null },
|
||||
fileMd5 = json.optString("fileMd5").ifEmpty { null },
|
||||
identifier = json.optString("identifier").ifEmpty { null },
|
||||
status = json.optString("status").ifEmpty { null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.duooomi.ble.models
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 服务端 `/firmware/upgrade/check` 响应。
|
||||
*
|
||||
* **判断能否升级看 [targetFirmware] 是否非空** —— SDK 不再暴露 client 端 "判断"语义,
|
||||
* 回归"拉取最新固件信息"单一职责。
|
||||
*/
|
||||
data class FirmwareUpgradeCheckResult(
|
||||
val currentSystemVersion: String?,
|
||||
val targetFirmware: TargetFirmware?
|
||||
) {
|
||||
data class TargetFirmware(
|
||||
/** 固件 ID。`UpgradeRecord.firmwareId` 来源、对账上报必带。 */
|
||||
val id: String,
|
||||
/** 固件版本字符串(如 "1.2.3")。UI 展示「最新版本」+ 落 `UpgradeRecord.targetVersion`。 */
|
||||
val version: String,
|
||||
/** 固件包下载 URL。烧录入参 `upgradeFirmware(fileUrl=)`. */
|
||||
val fileUrl: String?,
|
||||
/** 固件包字节数(字符串编码,避免 Long 溢出)。 */
|
||||
val fileSize: String?,
|
||||
/** 固件包 MD5。集成方有需要可校验下载内容;SDK 不做校验。 */
|
||||
val fileMd5: String?,
|
||||
/**
|
||||
* 强制升级标记。`true` 表示该版本必须升级(典型场景:安全补丁 / 协议破坏性变更),
|
||||
* 集成方应据此弹不可关闭的升级弹窗、阻断设备其他操作;`false` / `null` 由用户自行决定。
|
||||
*/
|
||||
val forceUpgrade: Boolean?
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): TargetFirmware = TargetFirmware(
|
||||
id = json.optString("id", ""),
|
||||
version = json.optString("version", ""),
|
||||
fileUrl = json.optString("fileUrl").takeIf { it.isNotEmpty() },
|
||||
fileSize = json.optString("fileSize").takeIf { it.isNotEmpty() },
|
||||
fileMd5 = json.optString("fileMd5").takeIf { it.isNotEmpty() },
|
||||
forceUpgrade = if (json.has("forceUpgrade")) json.optBoolean("forceUpgrade") else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): FirmwareUpgradeCheckResult {
|
||||
val target = json.optJSONObject("targetFirmware")?.let { TargetFirmware.fromJson(it) }
|
||||
return FirmwareUpgradeCheckResult(
|
||||
currentSystemVersion = json.optString("currentSystemVersion").takeIf { it.isNotEmpty() },
|
||||
targetFirmware = target
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
sdk/src/main/kotlin/com/duooomi/ble/models/UpgradeRecord.kt
Normal file
66
sdk/src/main/kotlin/com/duooomi/ble/models/UpgradeRecord.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.duooomi.ble.models
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 升级对账结果。
|
||||
* - [SUCCESS]: 设备实际版本 == 目标版本,已成功上报
|
||||
* - [VERSION_MISMATCH]: 实际版本 ≠ 目标版本,已上报 failure
|
||||
* - 持久化时 `outcome=null` 含义:30 天 TTL 过期未上报,保留为审计轨迹
|
||||
*/
|
||||
enum class UpgradeOutcome(val raw: String) {
|
||||
SUCCESS("success"),
|
||||
VERSION_MISMATCH("version_mismatch");
|
||||
|
||||
companion object {
|
||||
fun fromRaw(raw: String?): UpgradeOutcome? = when (raw) {
|
||||
SUCCESS.raw -> SUCCESS
|
||||
VERSION_MISMATCH.raw -> VERSION_MISMATCH
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OTA 升级记录。复合主键 `(sn, firmwareId)`。
|
||||
* 与 expo `bleStore.upgradeRecords` 1:1 对齐。
|
||||
*/
|
||||
data class UpgradeRecord(
|
||||
val sn: String,
|
||||
val firmwareId: String,
|
||||
val fromVersion: String,
|
||||
val targetVersion: String,
|
||||
/** 触发 OTA 烧录的时间戳(毫秒,与 RN `Date.now()` 同口径) */
|
||||
val triggeredAt: Long,
|
||||
val reported: Boolean = false,
|
||||
val reportedAt: Long? = null,
|
||||
val outcome: UpgradeOutcome? = null
|
||||
) {
|
||||
fun toJson(): JSONObject {
|
||||
val o = JSONObject()
|
||||
.put("sn", sn)
|
||||
.put("firmwareId", firmwareId)
|
||||
.put("fromVersion", fromVersion)
|
||||
.put("targetVersion", targetVersion)
|
||||
.put("triggeredAt", triggeredAt)
|
||||
.put("reported", reported)
|
||||
if (reportedAt != null) o.put("reportedAt", reportedAt)
|
||||
if (outcome != null) o.put("outcome", outcome.raw)
|
||||
return o
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): UpgradeRecord = UpgradeRecord(
|
||||
sn = json.optString("sn", ""),
|
||||
firmwareId = json.optString("firmwareId", ""),
|
||||
fromVersion = json.optString("fromVersion", ""),
|
||||
targetVersion = json.optString("targetVersion", ""),
|
||||
triggeredAt = json.optLong("triggeredAt", 0L),
|
||||
reported = json.optBoolean("reported", false),
|
||||
reportedAt = if (json.has("reportedAt") && !json.isNull("reportedAt")) json.optLong("reportedAt") else null,
|
||||
outcome = UpgradeOutcome.fromRaw(
|
||||
if (json.has("outcome") && !json.isNull("outcome")) json.optString("outcome") else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.duooomi.ble.services
|
||||
|
||||
import com.duooomi.ble.DuooomiBleConfig
|
||||
import com.duooomi.ble.core.DuooomiBleError
|
||||
import com.duooomi.ble.models.FirmwareInfo
|
||||
import com.duooomi.ble.utils.BleLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
internal class FirmwareService(private val config: DuooomiBleConfig) {
|
||||
|
||||
private val latestPath = "api/auth/loomart/firmware/latest-published"
|
||||
|
||||
suspend fun fetchLatest(
|
||||
identifier: String? = null,
|
||||
status: String? = null
|
||||
): FirmwareInfo? = withContext(Dispatchers.IO) {
|
||||
val id = (identifier ?: config.firmwareIdentifier).trim()
|
||||
val st = status ?: config.firmwareStatus
|
||||
|
||||
if (id.isEmpty()) {
|
||||
throw DuooomiBleError.TransferFailed("Invalid firmware identifier")
|
||||
}
|
||||
|
||||
val encodedId = URLEncoder.encode(id, "UTF-8")
|
||||
val encodedStatus = URLEncoder.encode(st, "UTF-8")
|
||||
val urlStr = "${config.apiHost}/$latestPath?identifier=$encodedId&status=$encodedStatus"
|
||||
|
||||
val connection = URL(urlStr).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("x-api-key", config.apiKey)
|
||||
connection.setRequestProperty("accept", "application/json")
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 30_000
|
||||
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
throw DuooomiBleError.TransferFailed("HTTP $code")
|
||||
}
|
||||
|
||||
val responseText = connection.inputStream.bufferedReader().readText()
|
||||
val json = JSONObject(responseText)
|
||||
|
||||
if (json.optBoolean("success") != true) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val dataObj = json.optJSONObject("data") ?: return@withContext null
|
||||
FirmwareInfo.fromJson(dataObj)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.duooomi.ble.services
|
||||
|
||||
import com.duooomi.ble.DuooomiBleConfig
|
||||
import com.duooomi.ble.core.DuooomiBleError
|
||||
import com.duooomi.ble.models.FirmwareUpgradeCheckResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 固件升级 RPC:检查升级 / 升级成功上报 / 升级失败上报。
|
||||
* 与 RN `FirmwareController.{checkUpgrade, reportUpgradeSuccessBySn, reportUpgradeFailureBySn}` 1:1 对齐。
|
||||
*/
|
||||
internal class FirmwareUpgradeService(private val config: DuooomiBleConfig) {
|
||||
|
||||
/** 由服务端判断是否需要升级。 */
|
||||
suspend fun checkUpgrade(sn: String, currentVersion: String?): FirmwareUpgradeCheckResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = mutableMapOf<String, Any?>("sn" to sn)
|
||||
if (!currentVersion.isNullOrEmpty()) body["currentVersion"] = currentVersion
|
||||
val raw = try {
|
||||
HttpHelper.post(
|
||||
config = config,
|
||||
path = "/api/auth/loomart/firmware/upgrade/check",
|
||||
body = body
|
||||
)
|
||||
} catch (e: HttpHelper.HttpError) {
|
||||
throw DuooomiBleError.TransferFailed(e.message ?: "HTTP ${e.code}")
|
||||
}
|
||||
FirmwareUpgradeCheckResult.fromJson(JSONObject(raw))
|
||||
}
|
||||
|
||||
suspend fun reportUpgradeSuccess(
|
||||
sn: String,
|
||||
firmwareId: String,
|
||||
fromVersion: String,
|
||||
bindUserId: String?
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val body = mutableMapOf<String, Any?>(
|
||||
"sn" to sn,
|
||||
"firmwareId" to firmwareId,
|
||||
"fromVersion" to fromVersion
|
||||
)
|
||||
if (!bindUserId.isNullOrEmpty()) body["bindUserId"] = bindUserId
|
||||
try {
|
||||
HttpHelper.post(
|
||||
config = config,
|
||||
path = "/api/auth/loomart/firmware/upgrade/report-success",
|
||||
body = body
|
||||
)
|
||||
} catch (e: HttpHelper.HttpError) {
|
||||
throw DuooomiBleError.TransferFailed(e.message ?: "HTTP ${e.code}")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
suspend fun reportUpgradeFailure(
|
||||
sn: String,
|
||||
firmwareId: String,
|
||||
fromVersion: String,
|
||||
failureReason: String
|
||||
) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
HttpHelper.post(
|
||||
config = config,
|
||||
path = "/api/auth/loomart/firmware/upgrade/report-failure",
|
||||
body = mapOf(
|
||||
"sn" to sn,
|
||||
"firmwareId" to firmwareId,
|
||||
"fromVersion" to fromVersion,
|
||||
"failureReason" to failureReason
|
||||
)
|
||||
)
|
||||
} catch (e: HttpHelper.HttpError) {
|
||||
throw DuooomiBleError.TransferFailed(e.message ?: "HTTP ${e.code}")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
84
sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt
Normal file
84
sdk/src/main/kotlin/com/duooomi/ble/services/HttpHelper.kt
Normal file
@@ -0,0 +1,84 @@
|
||||
package com.duooomi.ble.services
|
||||
|
||||
import com.duooomi.ble.DuooomiBleConfig
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* SDK 内部共享的最小 HTTP POST helper。
|
||||
* 统一带 `x-api-key` + `x-owner` header,统一剥离后端 `API error:` 前缀。
|
||||
*/
|
||||
internal object HttpHelper {
|
||||
|
||||
/**
|
||||
* @return 响应体 String(2xx)。`unwrap = true` 时尝试从 `{success, data}` 包装中抽出 `data` 字段(JSON 字符串)。
|
||||
* @throws IOException / HttpError on non-2xx
|
||||
*/
|
||||
fun post(
|
||||
config: DuooomiBleConfig,
|
||||
path: String,
|
||||
body: Map<String, Any?>,
|
||||
timeoutMs: Int = 10_000,
|
||||
unwrap: Boolean = true
|
||||
): String {
|
||||
val url = URL(config.apiHost.trimEnd('/') + path)
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "POST"
|
||||
conn.connectTimeout = timeoutMs
|
||||
conn.readTimeout = timeoutMs
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.setRequestProperty("x-api-key", config.apiKey)
|
||||
conn.setRequestProperty("x-owner", config.owner)
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
for ((k, v) in body) {
|
||||
if (v != null) put(k, v)
|
||||
}
|
||||
}
|
||||
conn.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
||||
|
||||
val code = conn.responseCode
|
||||
val raw = if (code in 200..299) {
|
||||
conn.inputStream.bufferedReader().use { it.readText() }
|
||||
} else {
|
||||
val errBody = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }.getOrNull()
|
||||
throw HttpError(code, parseErrorMessage(errBody) ?: "HTTP $code")
|
||||
}
|
||||
|
||||
if (!unwrap) return raw
|
||||
|
||||
return runCatching {
|
||||
val o = JSONObject(raw)
|
||||
if (o.optBoolean("success", false) && o.has("data")) {
|
||||
val inner = o.opt("data")
|
||||
inner?.toString() ?: raw
|
||||
} else raw
|
||||
}.getOrDefault(raw)
|
||||
} finally {
|
||||
runCatching { conn.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/** 剥离 `API error:` 前缀,必要时从 JSON 中抽 message 字段,与 RN 端 stripApiErrorPrefix 行为对齐。 */
|
||||
fun parseErrorMessage(raw: String?): String? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
var stripped = raw
|
||||
if (stripped.startsWith("API error:")) {
|
||||
stripped = stripped.removePrefix("API error:").trim()
|
||||
}
|
||||
if (stripped.startsWith("{")) {
|
||||
runCatching {
|
||||
val json = JSONObject(stripped)
|
||||
json.optString("message").takeIf { it.isNotEmpty() }?.let { return it }
|
||||
json.optJSONObject("error")?.optString("message")?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
}
|
||||
}
|
||||
return stripped.ifEmpty { null }
|
||||
}
|
||||
|
||||
class HttpError(val code: Int, message: String) : Exception(message)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.duooomi.ble.services
|
||||
|
||||
import com.duooomi.ble.DuooomiBleConfig
|
||||
import com.duooomi.ble.core.DuooomiBleError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 服务端 shipment-sn 软绑/软解绑:本地 BLE 硬绑成功后由 SDK 内部调用,集成方无需手动调起。
|
||||
*/
|
||||
internal class ShipmentSnDeviceService(private val config: DuooomiBleConfig) {
|
||||
|
||||
suspend fun bind(sn: String, bindUserId: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
HttpHelper.post(
|
||||
config = config,
|
||||
path = "/api/auth/loomart/shipment-sn/device/bind",
|
||||
body = mapOf("sn" to sn, "bindUserId" to bindUserId)
|
||||
)
|
||||
} catch (e: HttpHelper.HttpError) {
|
||||
throw DuooomiBleError.SoftBindFailed(e.message ?: "HTTP ${e.code}")
|
||||
} catch (e: Exception) {
|
||||
throw DuooomiBleError.SoftBindFailed(e.message ?: "unknown")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
suspend fun unbind(sn: String, bindUserId: String? = null) = withContext(Dispatchers.IO) {
|
||||
val body = mutableMapOf<String, Any?>("sn" to sn)
|
||||
if (!bindUserId.isNullOrEmpty()) body["bindUserId"] = bindUserId
|
||||
try {
|
||||
HttpHelper.post(
|
||||
config = config,
|
||||
path = "/api/auth/loomart/shipment-sn/device/unbind",
|
||||
body = body
|
||||
)
|
||||
} catch (e: HttpHelper.HttpError) {
|
||||
throw DuooomiBleError.SoftBindFailed(e.message ?: "HTTP ${e.code}")
|
||||
} catch (e: Exception) {
|
||||
throw DuooomiBleError.SoftBindFailed(e.message ?: "unknown")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.duooomi.ble.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.duooomi.ble.models.UpgradeOutcome
|
||||
import com.duooomi.ble.models.UpgradeRecord
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* OTA 升级记录持久化存储。与 RN `bleStore.upgradeRecords` 1:1 对齐:
|
||||
* - 复合主键 `(sn, firmwareId)` 的 upsert
|
||||
* - 30 天 TTL:过期但保留为审计轨迹(标记 `reported=true, outcome=null`)
|
||||
* - sn 级 in-flight 互斥锁,防止并发重复上报
|
||||
*/
|
||||
internal class UpgradeRecordStore(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private val lock = Any()
|
||||
private val records: MutableList<UpgradeRecord> = loadRecords().toMutableList()
|
||||
private val inFlight: MutableSet<String> = mutableSetOf()
|
||||
|
||||
/** mutation 后回调当前快照(SDK 用它通过 StateFlow 发布给集成方)。 */
|
||||
var onChange: ((List<UpgradeRecord>) -> Unit)? = null
|
||||
|
||||
val all: List<UpgradeRecord>
|
||||
get() = synchronized(lock) { records.toList() }
|
||||
|
||||
/** 取该 sn 下所有未上报的记录,按 triggeredAt 升序(旧→新)。 */
|
||||
fun pending(sn: String): List<UpgradeRecord> = synchronized(lock) {
|
||||
records.filter { it.sn == sn && !it.reported }.sortedBy { it.triggeredAt }
|
||||
}
|
||||
|
||||
/** 复合主键 `(sn, firmwareId)`:已存在 → 覆盖并复位 reported/outcome;不存在 → 追加。 */
|
||||
fun upsert(record: UpgradeRecord) {
|
||||
if (record.sn.isEmpty() || record.firmwareId.isEmpty()) return
|
||||
val snapshot: List<UpgradeRecord>
|
||||
synchronized(lock) {
|
||||
val idx = records.indexOfFirst { it.sn == record.sn && it.firmwareId == record.firmwareId }
|
||||
if (idx >= 0) records[idx] = record else records.add(record)
|
||||
persistLocked()
|
||||
snapshot = records.toList()
|
||||
}
|
||||
onChange?.invoke(snapshot)
|
||||
}
|
||||
|
||||
/** 标记某条记录已上报(线程安全)。 */
|
||||
fun markReported(sn: String, firmwareId: String, outcome: UpgradeOutcome?) {
|
||||
val snapshot: List<UpgradeRecord>?
|
||||
synchronized(lock) {
|
||||
val idx = records.indexOfFirst { it.sn == sn && it.firmwareId == firmwareId }
|
||||
if (idx < 0) {
|
||||
snapshot = null
|
||||
} else {
|
||||
val orig = records[idx]
|
||||
records[idx] = orig.copy(
|
||||
reported = true,
|
||||
reportedAt = nowMillis(),
|
||||
outcome = outcome
|
||||
)
|
||||
persistLocked()
|
||||
snapshot = records.toList()
|
||||
}
|
||||
}
|
||||
snapshot?.let { onChange?.invoke(it) }
|
||||
}
|
||||
|
||||
/** 尝试占用 sn 互斥锁。返回 false 表示已有进行中的上报。 */
|
||||
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)
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun loadRecords(): List<UpgradeRecord> {
|
||||
val raw = prefs.getString(KEY_RECORDS, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).map { UpgradeRecord.fromJson(arr.getJSONObject(it)) }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/** 必须在 lock 内调用。 */
|
||||
private fun persistLocked() {
|
||||
val arr = JSONArray()
|
||||
for (r in records) arr.put(r.toJson())
|
||||
prefs.edit().putString(KEY_RECORDS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "duooomi_ble_sdk"
|
||||
private const val KEY_RECORDS = "duooomi.firmware_upgrade_records"
|
||||
const val TTL_MILLIS: Long = 30L * 24 * 60 * 60 * 1000
|
||||
fun nowMillis(): Long = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user