Files
duooomi-android-sdk/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt
km2023 00a17fbd6a fix(sdk): 同步 iOS — transferMedia 入口立即把 _transferProgress 归零
旧实现里 _transferProgress 只在 transferFile() 内部才置 0,但
transferMedia 要先走 validateMediaFormat → aniConverter.convert →
prepareTransfer 这条数秒级异步链,期间 StateFlow 还停留在上次结束
的 100。集成方一 collect 就看到旧值 100,体感像「上次进度被带过来」。

修法:transferMedia 在 ensureConnected() 后立即 _transferProgress.value = 0,
collector 立即收到 0;后续 transferFile() 内的 = 0 因 StateFlow 的
distinct-value 语义被天然跳过。

对齐 ios:ed7de42。

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

875 lines
35 KiB
Kotlin
Raw 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.
package com.duooomi.ble
import android.content.Context
import com.duooomi.ble.core.*
import com.duooomi.ble.models.*
import com.duooomi.ble.protocol.CommandType
import com.duooomi.ble.services.*
import com.duooomi.ble.utils.BeijingTimeProvider
import com.duooomi.ble.utils.BleLog
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
// MARK: - Observable State
private val _btState = MutableStateFlow(ConnectionState.IDLE)
val btState: StateFlow<ConnectionState> = _btState.asStateFlow()
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow()
private val _connectedDevice = MutableStateFlow<DiscoveredDevice?>(null)
val connectedDevice: StateFlow<DiscoveredDevice?> = _connectedDevice.asStateFlow()
private val _deviceInfo = MutableStateFlow<DeviceInfo?>(null)
val deviceInfo: StateFlow<DeviceInfo?> = _deviceInfo.asStateFlow()
private val _version = MutableStateFlow("")
val version: StateFlow<String> = _version.asStateFlow()
/**
* 设备返回的 `log` 字段(任意 JSON 对象)。`getVersion` 成功时写入,断连 / 解绑 / 软绑回滚时清空。
* `bind` 软绑成功后透传给 shipment-sn `update-metadata`,集成方一般无需直接读。
*/
private val _versionLog = MutableStateFlow<JSONObject?>(null)
val versionLog: StateFlow<JSONObject?> = _versionLog.asStateFlow()
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()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
/**
* BLE JSON 协议层原始收发事件流。**调试用**——日志/抓包/UI 联调展示,
* 不属业务行为契约iOS 版无对应通道)。集成方不要依赖它做业务逻辑。
*/
private val _rawJsonEvents = MutableSharedFlow<BleRawJsonEvent>(extraBufferCapacity = 32)
val rawJsonEvents: SharedFlow<BleRawJsonEvent> = _rawJsonEvents.asSharedFlow()
// MARK: - Internal Services
private val bleClient = BleClient(context)
private val protocolService = BleProtocolService(bleClient, config.frameIntervalMs)
private val timeProvider = BeijingTimeProvider()
private val deviceInfoService = DeviceInfoService(protocolService, timeProvider)
private val fileTransferService = FileTransferService(protocolService)
private val aniConverter = AniConverter(config)
private val shipmentService = ShipmentSnDeviceService(config)
private val firmwareUpgradeService = FirmwareUpgradeService(config)
private val upgradeRecordStore = UpgradeRecordStore(context)
// MARK: - Request-Response
private val pendingContinuations = mutableMapOf<String, CancellableContinuation<ByteArray>>()
private var messageListenerJob: Job? = null
// MARK: - Scan State
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
BleLog.e("Coroutine exception: ${throwable.message}", "SDK")
_error.value = throwable.message
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
private var scanJob: Job? = null
private val allDiscoveredDevices = mutableListOf<DiscoveredDevice>()
private var flushJob: Job? = null
init {
BleLog.level = config.logLevel
BleLog.i("SDK initialized (apiHost=${config.apiHost}, brand=${config.brand}, logLevel=${config.logLevel})", "SDK")
if (config.frameIntervalMs !in 0..500) {
BleLog.w("frameIntervalMs=${config.frameIntervalMs} out of [0,500], will clamp at use site", "Config")
}
setupDisconnectHandler()
protocolService.rawJsonEmitter = { evt -> _rawJsonEvents.tryEmit(evt) }
// 启动时把持久化记录回放到 StateFlow
_upgradeRecords.value = upgradeRecordStore.all
upgradeRecordStore.onChange = { records ->
scope.launch { _upgradeRecords.value = records }
}
}
private fun setupDisconnectHandler() {
bleClient.onDisconnected = {
scope.launch {
BleLog.w("Peripheral disconnected; resetting state", "SDK")
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_versionLog.value = null
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
protocolService.stopListening()
cancelAllPending(DuooomiBleError.NotConnected)
// 与 iOS 一致sn / firmwareUpgrade 不在断开时重置
// —— sn 仅在 unbind 成功 / bind 软绑失败回滚时清空
// —— firmwareUpgrade 仅在 fetchLatestFirmware 成功时覆盖
}
}
}
// MARK: - Scanning
fun scan() {
stopScan()
_btState.value = ConnectionState.SCANNING
_discoveredDevices.value = emptyList()
allDiscoveredDevices.clear()
BleLog.i("Start scanning...", "Scan")
scanJob = scope.launch {
try {
bleClient.scan().collect { device ->
BleLog.d("Discovered: id=${device.id}, name=${device.name ?: "-"}, rssi=${device.rssi}", "Scan")
queueDevice(device)
}
} catch (e: Exception) {
BleLog.e("Scan error: ${e.message}", "Scan")
_error.value = e.message
_btState.value = ConnectionState.IDLE
}
}
}
fun stopScan() {
scanJob?.cancel()
scanJob = null
bleClient.stopScan()
flushDevices()
if (_connectedDevice.value != null) {
_btState.value = ConnectionState.CONNECTED
} else if (_btState.value == ConnectionState.SCANNING) {
_btState.value = ConnectionState.IDLE
}
BleLog.i("Stop scanning (state=${_btState.value})", "Scan")
}
// MARK: - Connection
suspend fun connect(deviceId: String): DiscoveredDevice {
stopScan()
_btState.value = ConnectionState.CONNECTING
BleLog.i("Connecting to $deviceId...", "Connect")
try {
val gatt = bleClient.connect(deviceId)
// Start protocol listener
protocolService.startListening(scope)
startMessageListener()
val device = DiscoveredDevice(
id = gatt.device.address,
name = gatt.device.name,
rssi = 0
)
_connectedDevice.value = device
_btState.value = ConnectionState.CONNECTED
BleLog.i("Connected: ${device.id}", "Connect")
return device
} catch (e: Exception) {
_btState.value = ConnectionState.IDLE
_error.value = e.message
BleLog.e("Connect failed: ${e.message}", "Connect")
throw e
}
}
suspend fun disconnect() {
_btState.value = ConnectionState.DISCONNECTING
BleLog.i("Disconnect requested", "Connect")
protocolService.stopListening()
messageListenerJob?.cancel()
messageListenerJob = null
cancelAllPending(DuooomiBleError.NotConnected)
var disconnectError: Exception? = null
try {
bleClient.disconnect()
} catch (e: Exception) {
disconnectError = e
_error.value = e.message
BleLog.e("Disconnect error: ${e.message}", "Connect")
}
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_versionLog.value = null
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
BleLog.i("Disconnected", "Connect")
disconnectError?.let { throw it }
}
fun getConnectedDevices(): List<DiscoveredDevice> {
val list = bleClient.retrieveConnectedDevices()
BleLog.d("System-connected devices: ${list.size}", "Connect")
return list
}
// MARK: - Device Commands
suspend fun getDeviceInfo(): DeviceInfo {
ensureConnected()
BleLog.d("Sending getDeviceInfo", "Command")
val data = sendAndWait(CommandType.GET_DEVICE_INFO) {
deviceInfoService.getDeviceInfo()
}
val info = DeviceInfo.fromJson(parseJsonResponse(data))
BleLog.i("DeviceInfo received: name=${info.name}, brand=${info.brand}", "Command")
_deviceInfo.value = info
return info
}
suspend fun getVersion(): VersionInfo {
ensureConnected()
BleLog.d("Sending getDeviceVersion", "Command")
val data = sendAndWait(CommandType.GET_DEVICE_VERSION) {
deviceInfoService.getDeviceVersion()
}
val info = VersionInfo.fromJson(parseJsonResponse(data))
_version.value = info.version
_versionLog.value = info.log
BleLog.i(
"Version received: ${info.version} (type=${info.type}, log=${if (info.log != null) "present" else "nil"})",
"Command"
)
return info
}
/**
* 完整绑定:内部串四步业务编排(顺序固定),对集成方表现为一次原子调用。
*
* 1. **BLE 硬绑**(命令 0x0F写 [sn] / [isActivated]
* 2. **getVersion**:写 [version] / [versionLog]log 为任意 JSON 对象,缺失则 null
* 3. **软绑**POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系
* - 软绑失败 → 自动硬解绑BLE 0x12回滚 + 重置 `isActivated`/`sn`/`versionLog`bind 整体失败
* 4. **updateMetadata**fire-and-forget把 `versionLog` 透传给 `shipment-sn/device/update-metadata`
* - 失败只 warn 不阻断versionLog 为 null / 空对象则跳过
*
* 完成后:
* - [sn] 写入设备序列号
* - [version] / [versionLog] 写入当前设备状态getVersion 失败时保持空,但 bind 不算失败)
* - [isActivated] = true
*
* @throws DuooomiBleError.NotConnected 未连接
* @throws DuooomiBleError.AlreadyBoundByOtherUser BLE 层 success != 1设备已绑给他人
* @throws DuooomiBleError.SoftBindFailed 软绑失败(含硬解绑回滚后)
*/
suspend fun bind(userId: String): BindingResponse {
ensureConnected()
verifyBrand()
BleLog.d("Sending bind (userId=$userId)", "Command")
val data = sendAndWait(CommandType.BIND_DEVICE) {
deviceInfoService.bindDevice(userId)
}
val resp = BindingResponse.fromJson(parseJsonResponse(data))
_isActivated.value = resp.success == 1
if (resp.success != 1) {
BleLog.w("Hard bind failed: success=0", "Command")
throw DuooomiBleError.AlreadyBoundByOtherUser
}
_sn.value = resp.sn
BleLog.i("Hard bind success: sn=${resp.sn}", "Command")
// Step 2: 同步拉版本(写 version / versionLog 供软绑后的 updateMetadata 透传)
runCatching { getVersion() }
// Step 3: 软绑(失败 → 硬解绑回滚 + 清状态 + 整体失败)
val bindSn = resp.sn
BleLog.d("Soft bind start: sn=$bindSn", "Command")
try {
shipmentService.bind(sn = bindSn, bindUserId = userId)
} catch (e: Exception) {
BleLog.e("Soft bind failed: ${e.message}; rolling back hard unbind", "Command")
runCatching {
deviceInfoService.unbindDevice(userId)
}.onFailure { rb ->
BleLog.w("Hard unbind rollback failed (ignored): ${rb.message}", "Command")
}
_isActivated.value = false
_sn.value = ""
_versionLog.value = null
throw e
}
BleLog.i("Soft bind success: sn=$bindSn", "Command")
// Step 4: updateMetadatafire-and-forgetversionLog 缺失/空则跳过)
val log = _versionLog.value
if (log != null && log.length() > 0) {
scope.launch {
runCatching {
shipmentService.updateMetadata(sn = bindSn, data = log)
}.onSuccess {
BleLog.i("updateMetadata success: sn=$bindSn", "Command")
}.onFailure { e ->
BleLog.w("updateMetadata failed (ignored): ${e.message}", "Command")
}
}
}
return resp
}
/**
* 切换播放模式0=单播 / 1=循环播放)。
*
* 复用 [CommandType.BIND_DEVICE] (0x0F) 命令通道;响应类型为 [BindingResponse]。
* 成功后自动刷新一次 `deviceInfo`(含最新 `loop` 字段),与 expo 端行为对齐。
*
* 前置:必须已 [bind] 完成(`isActivated == true`)。
* `bind` / `unbind` / `setPlayMode` 共享 0x0F / 0x12 opId 通道,**不能并发**。
*/
suspend fun setPlayMode(mode: com.duooomi.ble.models.PlayMode, userId: String): BindingResponse {
ensureConnected()
if (!_isActivated.value) {
BleLog.w("setPlayMode called before bind (isActivated=false)", "Command")
throw DuooomiBleError.BindingFailed("Device not bound; call bind(userId) first")
}
BleLog.d("Sending setPlayMode (mode=$mode, userId=$userId)", "Command")
val data = sendAndWait(CommandType.BIND_DEVICE) {
deviceInfoService.setPlayMode(userId, mode)
}
val resp = BindingResponse.fromJson(parseJsonResponse(data))
if (resp.success != 1) {
BleLog.w("setPlayMode failed: device rejected", "Command")
throw DuooomiBleError.BindingFailed("Set play mode failed")
}
BleLog.i("setPlayMode success: mode=$mode", "Command")
// 自动刷新 deviceInfo含最新 loop 字段)
runCatching { getDeviceInfo() }
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) {
BleLog.w("Unbind failed", "Command")
throw DuooomiBleError.UnbindFailed
}
_isActivated.value = false
_sn.value = ""
_versionLog.value = null
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
}
suspend fun deleteFile(key: String): DeleteFileResponse {
ensureConnected()
BleLog.d("Sending deleteFile (key=$key)", "Command")
val data = sendAndWait(CommandType.DELETE_FILE) {
deviceInfoService.deleteFile(key)
}
val resp = DeleteFileResponse.fromJson(parseJsonResponse(data))
if (resp.success != 0) {
BleLog.w("Delete failed with status=${resp.success}", "Command")
throw DuooomiBleError.DeleteFileFailed(resp.success)
}
BleLog.i("Delete success (key=$key)", "Command")
return resp
}
suspend fun prepareTransfer(key: String, size: Int): PrepareTransferResponse {
ensureConnected()
val opId = "${CommandType.PREPARE_TRANSFER.value}_$key"
BleLog.d("Sending prepareTransfer (key=$key, size=$size)", "Transfer")
val data = sendAndWait(opId = opId, command = CommandType.PREPARE_TRANSFER) {
deviceInfoService.prepareTransfer(key, size)
}
val resp = PrepareTransferResponse.fromJson(parseJsonResponse(data))
if (resp.status != "ready") {
BleLog.w("PrepareTransfer not ready: status=${resp.status}", "Transfer")
throw DuooomiBleError.PrepareTransferFailed(resp.status)
}
BleLog.i("PrepareTransfer ready (key=$key)", "Transfer")
return resp
}
// MARK: - File Transfer
suspend fun transferFile(
fileUri: String,
commandType: CommandType = CommandType.TRANSFER_ANI_VIDEO
) {
ensureConnected()
_transferProgress.value = 0
BleLog.i("Transfer start: uri=$fileUri, cmd=$commandType", "Transfer")
fileTransferService.transferFile(
fileUri = fileUri,
commandType = commandType
) { progress ->
val percent = (progress * 100).toInt()
_transferProgress.value = percent
if (percent % 10 == 0) {
BleLog.d("Progress: $percent%", "Transfer")
}
}
_transferProgress.value = 100
BleLog.i("Transfer completed", "Transfer")
}
// MARK: - High-Level APIs
companion object {
/** OTA 烧录前置电量门槛(百分比)。低于此值拒绝下发,避免烧录中途断电变砖。 */
internal const val MIN_BATTERY_FOR_UPGRADE = 40
private val SUPPORTED_EXTENSIONS = setOf("jpeg", "jpg", "png", "gif", "mp4", "webm", "webp")
private val SUPPORTED_MIME_TYPES = setOf(
"image/jpeg", "image/png", "image/gif", "image/webp",
"video/mp4", "video/webm"
)
}
private fun extractExtension(urlString: String): String? {
return try {
val path = URI(urlString).path ?: return null
val dot = path.lastIndexOf('.')
if (dot >= 0 && dot < path.length - 1) path.substring(dot + 1).lowercase() else null
} catch (_: Exception) { null }
}
private suspend fun validateMediaFormat(url: String) {
val ext = extractExtension(url)
if (ext != null) {
if (SUPPORTED_EXTENSIONS.contains(ext)) return
throw DuooomiBleError.UnsupportedFormat(ext)
}
val mime = withContext(Dispatchers.IO) {
val conn = java.net.URL(url).openConnection() as HttpURLConnection
try {
conn.requestMethod = "HEAD"
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
conn.contentType?.split(";")?.firstOrNull()?.trim()?.lowercase()
} finally {
conn.disconnect()
}
}
if (mime == null || !SUPPORTED_MIME_TYPES.contains(mime)) {
throw DuooomiBleError.UnsupportedFormat(mime ?: "unknown")
}
}
suspend fun transferMedia(fileUrl: String) {
ensureConnected()
_transferProgress.value = 0
val url = fileUrl.trim()
if (url.isEmpty()) throw DuooomiBleError.TransferFailed("Empty file URL")
val isAni = url.lowercase().endsWith(".ani")
val aniUrl: String
if (isAni) {
aniUrl = url
BleLog.i("Direct ANI transfer: $url", "Transfer")
} else {
validateMediaFormat(url)
BleLog.i("Converting to ANI: $url", "Transfer")
try {
aniUrl = aniConverter.convert(url)
} catch (e: Exception) {
throw DuooomiBleError.TransferFailed("ANI conversion failed: ${e.message}")
}
BleLog.i("ANI ready: $aniUrl", "Transfer")
}
val aniSize = getRemoteFileSize(aniUrl)
BleLog.d("ANI size: $aniSize bytes", "Transfer")
val key = url
prepareTransfer(key = key, size = aniSize)
transferFile(fileUri = aniUrl, commandType = CommandType.TRANSFER_ANI_VIDEO)
}
/**
* 拉取服务端最新固件信息。**内部串两步业务编排**(顺序固定):
*
* 1. **对账上报**:把本地 pending [UpgradeRecord] 逐条报给服务端(容错失败,不阻断)
* 2. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
*
* 软绑 / metadata 上报 / 硬绑失败回滚 → 已全部内聚到 [bind],本方法不再处理。
*/
suspend fun fetchLatestFirmware(
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: 服务端 checkUpgrade
val result = firmwareUpgradeService.checkUpgrade(sn = sn, currentVersion = currentVersion)
_firmwareUpgrade.value = result
return result
}
/**
* OTA 固件烧录。完成后自动落 [UpgradeRecord] 到本地(复合主键 `(sn, firmwareId)`
* 等待下次 [fetchLatestFirmware] 触发 [tryReportPendingUpgrade] 对账上报。
*
* 下发前会**主动拉一次新鲜 [getDeviceInfo]** 校验实时电量,低于
* [MIN_BATTERY_FOR_UPGRADE]40%)直接抛 [DuooomiBleError.BatteryTooLow],不发任何 OTA 字节。
*
* @throws DuooomiBleError.NotConnected 未连接
* @throws DuooomiBleError.BatteryTooLow 电量 < [MIN_BATTERY_FOR_UPGRADE](未下发任何字节)
* @throws DuooomiBleError.TransferFailed 烧录阶段失败;[getDeviceInfo] 的 Timeout / 解码错误同样透传
*/
suspend fun upgradeFirmware(
sn: String,
fileUrl: String,
firmwareId: String,
fromVersion: String,
targetVersion: String
) {
ensureConnected()
// OTA 前置电量校验:取实时电量(不用缓存,可能过期),低于门槛拒绝下发。
val info = getDeviceInfo()
if (info.powerlevel < MIN_BATTERY_FOR_UPGRADE) {
BleLog.w("OTA blocked: battery ${info.powerlevel}% < $MIN_BATTERY_FOR_UPGRADE%", "Firmware")
throw DuooomiBleError.BatteryTooLow(current = info.powerlevel, required = MIN_BATTERY_FOR_UPGRADE)
}
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
private suspend fun getRemoteFileSize(url: String): Int = withContext(Dispatchers.IO) {
val fileUrl = URL(url)
// Try HEAD request first
val headConn = fileUrl.openConnection() as HttpURLConnection
try {
headConn.requestMethod = "HEAD"
headConn.connectTimeout = 15_000
headConn.connect()
val length = headConn.contentLength
if (length > 0) return@withContext length
} finally {
headConn.disconnect()
}
// Fallback: Range request
val rangeConn = fileUrl.openConnection() as HttpURLConnection
try {
rangeConn.requestMethod = "GET"
rangeConn.setRequestProperty("Range", "bytes=0-0")
rangeConn.connectTimeout = 15_000
rangeConn.connect()
val rangeHeader = rangeConn.getHeaderField("Content-Range")
if (rangeHeader != null) {
val totalStr = rangeHeader.split("/").lastOrNull()
val total = totalStr?.toIntOrNull()
if (total != null && total > 0) return@withContext total
}
} finally {
rangeConn.disconnect()
}
throw DuooomiBleError.TransferFailed("Cannot determine file size: $url")
}
// MARK: - Request-Response Pattern
private suspend fun sendAndWait(
commandType: CommandType,
timeoutMs: Long = 10_000,
send: suspend () -> Unit
): ByteArray {
return sendAndWait(
opId = "${commandType.value}",
command = commandType,
timeoutMs = timeoutMs,
send = send
)
}
private suspend fun sendAndWait(
opId: String,
command: CommandType,
timeoutMs: Long = 10_000,
send: suspend () -> Unit
): ByteArray {
BleLog.d("Register opId=$opId, cmd=$command", "RPC")
try {
return withTimeout(timeoutMs) {
coroutineScope {
val resultDeferred = async {
suspendCancellableCoroutine { continuation ->
pendingContinuations[opId] = continuation
continuation.invokeOnCancellation {
pendingContinuations.remove(opId)
}
BleLog.d("Continuation stored for opId=$opId", "RPC")
}
}
// Send command
send()
BleLog.d("Command sent for opId=$opId", "RPC")
// Wait for response
val result = resultDeferred.await()
BleLog.d("Result received for opId=$opId", "RPC")
result
}
}
} catch (e: Exception) {
pendingContinuations.remove(opId)?.let { stale ->
stale.cancel()
}
if (e is TimeoutCancellationException) {
BleLog.e("RPC timeout for opId=$opId", "RPC")
throw DuooomiBleError.Timeout(command)
}
BleLog.e("RPC failed for opId=$opId: ${e.message}", "RPC")
throw e
}
}
// MARK: - Message Listener
private fun startMessageListener() {
messageListenerJob?.cancel()
messageListenerJob = scope.launch {
BleLog.i("Message listener started", "RPC")
protocolService.incomingMessages.collect { (commandType, data) ->
BleLog.d("Incoming message: type=$commandType, size=${data.size}", "RPC")
handleIncomingMessage(commandType, data)
}
}
}
private fun handleIncomingMessage(commandType: Byte, data: ByteArray) {
val typeValue = commandType.toInt() and 0xFF
emitRawJsonRx(typeValue, data)
val opId = "$typeValue"
// Check for prepareTransfer with key-based opId
if (typeValue == CommandType.PREPARE_TRANSFER.value) {
try {
val json = parseJsonResponse(data)
val resp = PrepareTransferResponse.fromJson(json)
val keyedOpId = "${typeValue}_${resp.key}"
pendingContinuations.remove(keyedOpId)?.let { continuation ->
BleLog.d("Resuming keyed opId=$keyedOpId", "RPC")
continuation.resume(data, null)
return
}
} catch (_: Exception) { }
}
pendingContinuations.remove(opId)?.let { continuation ->
BleLog.d("Resuming opId=$opId", "RPC")
continuation.resume(data, null)
} ?: BleLog.w("No pending continuation for opId=$opId", "RPC")
}
// MARK: - Helpers
private fun ensureConnected() {
if (_connectedDevice.value == null) {
BleLog.w("Operation requires connection", "SDK")
throw DuooomiBleError.NotConnected
}
}
/**
* 校验设备 brand 与 SDK 配置 brand 是否一致;缺失则先拉一次 deviceInfo。
* 不一致时主动断连并抛 [DuooomiBleError.BrandMismatch]。
*/
private suspend fun verifyBrand() {
val actual = _deviceInfo.value?.brand ?: getDeviceInfo().brand
val expected = config.brand
if (actual == expected) return
BleLog.w("Brand mismatch: expected=$expected, actual=$actual; disconnecting", "Command")
runCatching { disconnect() }
throw DuooomiBleError.BrandMismatch(expected = expected, actual = actual)
}
private fun emitRawJsonRx(typeValue: Int, data: ByteArray) {
runCatching {
val cleaned = data.filter { it != 0.toByte() }.toByteArray()
if (cleaned.isEmpty()) return@runCatching
val jsonStr = String(cleaned, Charsets.UTF_8)
if (jsonStr.startsWith("{") || jsonStr.startsWith("[")) {
val name = CommandType.fromValue(typeValue)?.name
?: "UNKNOWN(0x%02X)".format(typeValue)
_rawJsonEvents.tryEmit(
BleRawJsonEvent(
direction = BleRawJsonEvent.Direction.RX,
commandType = typeValue,
commandName = name,
json = jsonStr
)
)
}
}
}
private fun parseJsonResponse(data: ByteArray): JSONObject {
// Device responses may contain null bytes
val cleaned = data.filter { it != 0.toByte() }.toByteArray()
val jsonStr = String(cleaned, Charsets.UTF_8)
return try {
JSONObject(jsonStr)
} catch (e: Exception) {
BleLog.e("JSON parse failed: ${e.message}\nRaw: $jsonStr", "Decode")
throw e
}
}
private fun cancelAllPending(error: Exception) {
val continuations = pendingContinuations.toMap()
pendingContinuations.clear()
for ((_, continuation) in continuations) {
continuation.cancel(CancellationException(error.message))
}
}
// MARK: - Scan Batching (500ms throttle)
private fun queueDevice(device: DiscoveredDevice) {
if (allDiscoveredDevices.any { it.id == device.id }) return
allDiscoveredDevices.add(device)
flushJob?.cancel()
flushJob = scope.launch {
delay(500)
flushDevices()
}
}
private fun flushDevices() {
if (allDiscoveredDevices.isEmpty()) return
_discoveredDevices.value = allDiscoveredDevices.toList()
BleLog.d("Discovered devices flushed: total=${_discoveredDevices.value.size}", "Scan")
}
}