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:
km2023
2026-05-08 15:42:04 +08:00
parent b4398518a9
commit 2dde274578
16 changed files with 697 additions and 157 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ local.properties
.idea/
.DS_Store
sdk/build/
demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt

View File

@@ -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 ===

View File

@@ -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()

View 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-"
}

View File

@@ -1,11 +1,19 @@
package com.duooomi.ble
/**
* SDK 全局配置,在初始化时一次性传入。
*/
data class DuooomiBleConfig(
/** API 服务地址 */
val apiHost: String = "https://api.duooomi.com",
/** API 鉴权 keyLoomart 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 转换高度(像素) */

View File

@@ -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-forgetBLE 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() {

View File

@@ -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

View File

@@ -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")

View File

@@ -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 }
)
}
}
}

View File

@@ -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
)
}
}
}

View 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
)
)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View 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 响应体 String2xx。`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)
}

View File

@@ -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
}
}

View File

@@ -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()
}
}