Files
duooomi-android-sdk/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt
km2023 0dbfab9519 fix: 流程对齐 iOS 严格 1:1(disconnect 不清 sn/firmwareUpgrade)
发现并修正以下 1:1 复刻偏离:

1. SDK:disconnect 行为
   - iOS:被动断开 / 主动 disconnect 都 **不清** sn / firmwareUpgrade
     · sn 仅在 unbind 成功 / fetchLatestFirmware 软绑失败回滚时清空
     · firmwareUpgrade 仅在 fetchLatestFirmware 成功时覆盖
   - 之前 Android 在两个 disconnect 入口都清,与 iOS 不一致;现已修正

2. Demo:otaFetchUserId / otaFetchSn 进入页面时的初始化
   - iOS:onAppear 时同步默认 userId / 当前 sdk.sn 到字段
   - Android:之前 bind 后才填,进入页面默认空;现已与 iOS 对齐

3. Demo:ConfigField 禁用自动大写 / 自动纠错
   - iOS 用 .autocapitalization(.none).disableAutocorrection(true)
   - Android 加 KeyboardOptions(autoCorrect=false, capitalization=None)

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

775 lines
30 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()
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()
// MARK: - Internal Services
private val bleClient = BleClient(context)
private val protocolService = BleProtocolService(bleClient)
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.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() {
bleClient.onDisconnected = {
scope.launch {
BleLog.w("Peripheral disconnected; resetting state", "SDK")
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
protocolService.stopListening()
cancelAllPending(DuooomiBleError.NotConnected)
// 与 iOS 一致sn / firmwareUpgrade 不在断开时重置
// —— sn 仅在 unbind 成功 / fetchLatestFirmware 软绑失败回滚时清空
// —— 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 = ""
_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
BleLog.i("Version received: ${info.version} (type=${info.type})", "Command")
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")
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")
// 同步拉版本,让后续 fetchLatestFirmware 的对账上报有 currentVersion 可用
runCatching { getVersion() }
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 = ""
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 {
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()
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. **软绑**POST `/api/auth/loomart/shipment-sn/device/bind`,登记云端绑定关系
* - 软绑失败 → 自动硬解绑BLE 0x12回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败
* 3. **拉服务端最新固件**:写入 [firmwareUpgrade],通过返回值回传
*/
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: 软绑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
}
/**
* 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: 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
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
}
}
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")
}
}