diff --git a/docs/superpowers/plans/2026-04-10-android-ble-sdk.md b/docs/superpowers/plans/2026-04-10-android-ble-sdk.md new file mode 100644 index 0000000..47cd38a --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-android-ble-sdk.md @@ -0,0 +1,2257 @@ +# DuooomiBleSDK Android Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a pure Kotlin Android BLE SDK with 1:1 feature parity to the iOS DuooomiBleSDK. + +**Architecture:** Layered design — BleClient wraps Android BLE APIs with coroutines, ProtocolManager handles frame encoding/parsing, services layer provides command/transfer/HTTP logic, DuooomiBleSDK facade exposes public API with StateFlow observables. + +**Tech Stack:** Kotlin 1.9+, Android BLE API, kotlinx-coroutines, org.json (built-in), Gradle KTS + +--- + +## File Map + +| File | Responsibility | +|------|---------------| +| `build.gradle.kts` (root) | Root Gradle config, Kotlin plugin | +| `settings.gradle.kts` | Include `:sdk` module | +| `gradle.properties` | Android/Kotlin properties | +| `sdk/build.gradle.kts` | Android Library module config | +| `sdk/src/main/AndroidManifest.xml` | BLE permissions | +| `sdk/.../DuooomiBleConfig.kt` | SDK configuration data class | +| `sdk/.../core/BleTypes.kt` | ConnectionState, DiscoveredDevice, DuooomiBleError | +| `sdk/.../protocol/ProtocolConstants.kt` | UUIDs, frame constants, CommandType | +| `sdk/.../protocol/ProtocolFrame.kt` | Frame data class | +| `sdk/.../protocol/ProtocolManager.kt` | Frame create/parse/checksum | +| `sdk/.../utils/BleLogger.kt` | Debug logging | +| `sdk/.../core/BleClient.kt` | Android BLE wrapper (scan/connect/write/notify) | +| `sdk/.../services/BleProtocolService.kt` | Frame send/receive/reassembly | +| `sdk/.../services/DeviceInfoService.kt` | JSON command sender | +| `sdk/.../services/FileTransferService.kt` | File download + BLE transfer | +| `sdk/.../services/AniConverter.kt` | ANI conversion HTTP API | +| `sdk/.../services/FirmwareService.kt` | Firmware query HTTP API | +| `sdk/.../models/DeviceInfo.kt` | DeviceInfo model | +| `sdk/.../models/VersionInfo.kt` | VersionInfo model | +| `sdk/.../models/BindingResponse.kt` | Binding response model | +| `sdk/.../models/UnbindResponse.kt` | Unbind response model | +| `sdk/.../models/DeleteFileResponse.kt` | Delete file response model | +| `sdk/.../models/PrepareTransferResponse.kt` | Prepare transfer response model | +| `sdk/.../models/FirmwareInfo.kt` | Firmware info model | +| `sdk/.../DuooomiBleSDK.kt` | Public API facade | + +All `...` paths expand to `src/main/kotlin/com/duooomi/ble/`. + +--- + +### Task 1: Gradle Project Scaffolding + +**Files:** +- Create: `build.gradle.kts` +- Create: `settings.gradle.kts` +- Create: `gradle.properties` +- Create: `sdk/build.gradle.kts` +- Create: `sdk/src/main/AndroidManifest.xml` + +- [ ] **Step 1: Create root `build.gradle.kts`** + +```kotlin +plugins { + id("com.android.library") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} +``` + +- [ ] **Step 2: Create `settings.gradle.kts`** + +```kotlin +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "duooomi-android-sdk" +include(":sdk") +``` + +- [ ] **Step 3: Create `gradle.properties`** + +```properties +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048m +``` + +- [ ] **Step 4: Create `sdk/build.gradle.kts`** + +```kotlin +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.duooomi.ble" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") +} +``` + +- [ ] **Step 5: Create `sdk/src/main/AndroidManifest.xml`** + +```xml + + + + + + + + + + +``` + +- [ ] **Step 6: Initialize Gradle wrapper** + +Run: +```bash +cd /Users/cchis/Documents/ship/bw/duooomi-android-sdk +gradle wrapper --gradle-version 8.5 +``` + +- [ ] **Step 7: Verify build** + +Run: +```bash +cd /Users/cchis/Documents/ship/bw/duooomi-android-sdk +./gradlew :sdk:assembleDebug +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 8: Add `.gitignore` and commit** + +Create `.gitignore`: +``` +.gradle/ +build/ +local.properties +*.iml +.idea/ +.DS_Store +sdk/build/ +``` + +```bash +git add -A +git commit -m "feat: scaffold Gradle project with SDK library module" +``` + +--- + +### Task 2: BLE Types & Protocol Constants + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolConstants.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt` + +- [ ] **Step 1: Create `BleTypes.kt`** + +```kotlin +package com.duooomi.ble.core + +enum class ConnectionState { + IDLE, SCANNING, CONNECTING, CONNECTED, DISCONNECTING, DISCONNECTED +} + +data class DiscoveredDevice( + val id: String, + val name: String?, + val rssi: Int +) + +sealed class DuooomiBleError(message: String) : Exception(message) { + class BluetoothNotPoweredOn(state: String) : DuooomiBleError("Bluetooth not powered on: $state") + object NotConnected : DuooomiBleError("No device connected") + class Timeout(val command: CommandType) : DuooomiBleError("Timeout: $command") + class ConnectionFailed(reason: String) : DuooomiBleError("Connection failed: $reason") + object ServiceNotFound : DuooomiBleError("BLE service not found") + object CharacteristicNotFound : DuooomiBleError("Characteristic not found") + class BindingFailed(reason: String) : DuooomiBleError("Binding failed: $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") + class TransferFailed(reason: String) : DuooomiBleError("Transfer failed: $reason") + object InvalidFrame : DuooomiBleError("Invalid protocol frame") + object PermissionDenied : DuooomiBleError("Bluetooth permission denied") +} + +// Re-export CommandType here for convenience +typealias CommandType = com.duooomi.ble.protocol.CommandType +``` + +- [ ] **Step 2: Create `ProtocolConstants.kt`** + +```kotlin +package com.duooomi.ble.protocol + +import java.util.UUID + +object BleUUIDs { + val SERVICE: UUID = UUID.fromString("000002c4-0000-1000-8000-00805f9b34fb") + val WRITE_CHARACTERISTIC: UUID = UUID.fromString("000002c5-0000-1000-8000-00805f9b34fb") + val READ_CHARACTERISTIC: UUID = UUID.fromString("000002c6-0000-1000-8000-00805f9b34fb") + val BROADCAST_CHARACTERISTIC: UUID = UUID.fromString("000002c1-0000-1000-8000-00805f9b34fb") +} + +enum class FrameHead(val value: Int) { + APP_TO_DEVICE(0xC7), + DEVICE_TO_APP(0xB0); + + val byte: Byte get() = value.toByte() +} + +object FrameConstants { + const val MAX_DATA_SIZE = 496 + const val HEADER_SIZE = 8 + const val FOOTER_SIZE = 1 + const val FRAME_INTERVAL_MS = 35L + const val SCREEN_SIZE = 360 +} + +enum class CommandType(val value: Int) { + OTA_PACKAGE(0x02), + TRANSFER_BOOT_ANIMATION(0x03), + TRANSFER_ANI_VIDEO(0x05), + TRANSFER_JPEG_IMAGE(0x06), + GET_DEVICE_VERSION(0x07), + GET_DEVICE_INFO(0x0D), + BIND_DEVICE(0x0F), + UNBIND_DEVICE(0x12), + DELETE_FILE(0x13), + PREPARE_TRANSFER(0x14); + + val byte: Byte get() = value.toByte() + + companion object { + fun fromValue(value: Int): CommandType? = entries.find { it.value == value } + } +} +``` + +- [ ] **Step 3: Create `BleLogger.kt`** + +```kotlin +package com.duooomi.ble.utils + +import android.util.Log + +internal object BleLog { + private const val TAG = "DuooomiBleSDK" + + fun d(message: String, category: String = "General") { + Log.d(TAG, "[$category] $message") + } + + fun i(message: String, category: String = "General") { + Log.i(TAG, "[$category] $message") + } + + fun w(message: String, category: String = "General") { + Log.w(TAG, "[$category] $message") + } + + fun e(message: String, category: String = "General") { + Log.e(TAG, "[$category] $message") + } +} +``` + +- [ ] **Step 4: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt \ + sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolConstants.kt \ + sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt +git commit -m "feat: add BLE types, protocol constants, and logger" +``` + +--- + +### Task 3: Protocol Frame & Manager + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolFrame.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt` + +- [ ] **Step 1: Create `ProtocolFrame.kt`** + +```kotlin +package com.duooomi.ble.protocol + +/** + * BLE protocol frame structure. + * Format: [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1] + */ +data class ProtocolFrame( + val head: Byte, + val type: Byte, + val subpageTotal: Int, + val curPage: Int, + val dataLen: Int, + val data: ByteArray, + val checksum: Byte +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ProtocolFrame) return false + return head == other.head && type == other.type && + subpageTotal == other.subpageTotal && curPage == other.curPage && + dataLen == other.dataLen && data.contentEquals(other.data) && + checksum == other.checksum + } + + override fun hashCode(): Int { + var result = head.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + subpageTotal + result = 31 * result + curPage + result = 31 * result + dataLen + result = 31 * result + data.contentHashCode() + result = 31 * result + checksum.hashCode() + return result + } +} +``` + +- [ ] **Step 2: Create `ProtocolManager.kt`** + +```kotlin +package com.duooomi.ble.protocol + +object ProtocolManager { + + // MARK: - Checksum + + /** + * Checksum = (~sum + 1) & 0xFF (two's complement, low 8 bits) + */ + fun calculateChecksum(data: ByteArray, offset: Int = 0, length: Int = data.size): Byte { + var sum = 0 + for (i in offset until offset + length) { + sum += data[i].toInt() and 0xFF + } + return ((sum.inv() + 1) and 0xFF).toByte() + } + + fun verifyChecksum(data: ByteArray, offset: Int = 0, length: Int, expected: Byte): Boolean { + return calculateChecksum(data, offset, length) == expected + } + + // MARK: - Frame Creation + + /** + * Auto-split data into single or fragmented frames. + * Returns empty list if payload exceeds 65535-page limit. + */ + fun createFrames( + type: Byte, + data: ByteArray, + head: FrameHead = FrameHead.APP_TO_DEVICE, + maxDataSize: Int = FrameConstants.MAX_DATA_SIZE + ): List { + val maxChunk = maxOf(1, maxDataSize) + val totalPages = (data.size + maxChunk - 1) / maxChunk + if (totalPages > 65535) return emptyList() + + return if (data.size <= maxDataSize) { + listOf(createSingleFrame(type, data, head)) + } else { + createFragmentedFrames(type, data, head, maxChunk) + } + } + + private fun createSingleFrame(type: Byte, data: ByteArray, head: FrameHead): ByteArray { + val totalSize = FrameConstants.HEADER_SIZE + data.size + FrameConstants.FOOTER_SIZE + val buffer = ByteArray(totalSize) + var pos = 0 + + // Header + buffer[pos++] = head.byte + buffer[pos++] = type + buffer[pos++] = 0 // subpageTotal high + buffer[pos++] = 0 // subpageTotal low + buffer[pos++] = 0 // curPage high + buffer[pos++] = 0 // curPage low + buffer[pos++] = ((data.size shr 8) and 0xFF).toByte() // dataLen high + buffer[pos++] = (data.size and 0xFF).toByte() // dataLen low + + // Data + data.copyInto(buffer, pos) + pos += data.size + + // Checksum (on everything before checksum) + buffer[pos] = calculateChecksum(buffer, 0, pos) + + return buffer + } + + private fun createFragmentedFrames( + type: Byte, + data: ByteArray, + head: FrameHead, + maxDataSize: Int + ): List { + val totalPages = (data.size + maxDataSize - 1) / maxDataSize + val frames = mutableListOf() + + for (i in 0 until totalPages) { + val start = i * maxDataSize + val end = minOf(start + maxDataSize, data.size) + val chunkSize = end - start + + val frameSize = FrameConstants.HEADER_SIZE + chunkSize + FrameConstants.FOOTER_SIZE + val buffer = ByteArray(frameSize) + var pos = 0 + + buffer[pos++] = head.byte + buffer[pos++] = type + + // subpageTotal (big-endian UInt16) + buffer[pos++] = ((totalPages shr 8) and 0xFF).toByte() + buffer[pos++] = (totalPages and 0xFF).toByte() + + // curPage: from (total-1) counting down to 0 + val curPageVal = totalPages - 1 - i + buffer[pos++] = ((curPageVal shr 8) and 0xFF).toByte() + buffer[pos++] = (curPageVal and 0xFF).toByte() + + // dataLen + buffer[pos++] = ((chunkSize shr 8) and 0xFF).toByte() + buffer[pos++] = (chunkSize and 0xFF).toByte() + + // data + data.copyInto(buffer, pos, start, end) + pos += chunkSize + + // checksum + buffer[pos] = calculateChecksum(buffer, 0, pos) + + frames.add(buffer) + } + + return frames + } + + // MARK: - Frame Parsing + + /** + * Parse raw bytes into a ProtocolFrame. Returns null if invalid. + */ + fun parseFrame(data: ByteArray): ProtocolFrame? { + val minSize = FrameConstants.HEADER_SIZE + FrameConstants.FOOTER_SIZE + if (data.size < minSize) return null + + val head = data[0] + if (head != FrameHead.DEVICE_TO_APP.byte && head != FrameHead.APP_TO_DEVICE.byte) { + return null + } + + val type = data[1] + val subpageTotal = ((data[2].toInt() and 0xFF) shl 8) or (data[3].toInt() and 0xFF) + val curPage = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF) + val dataLen = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) + + val expectedSize = FrameConstants.HEADER_SIZE + dataLen + FrameConstants.FOOTER_SIZE + if (data.size < expectedSize) return null + + val frameData = data.copyOfRange(FrameConstants.HEADER_SIZE, FrameConstants.HEADER_SIZE + dataLen) + val checksumPos = FrameConstants.HEADER_SIZE + dataLen + val checksum = data[checksumPos] + + if (!verifyChecksum(data, 0, checksumPos, checksum)) return null + + return ProtocolFrame( + head = head, + type = type, + subpageTotal = subpageTotal, + curPage = curPage, + dataLen = dataLen, + data = frameData, + checksum = checksum + ) + } +} +``` + +- [ ] **Step 3: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolFrame.kt \ + sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt +git commit -m "feat: add protocol frame structure and manager" +``` + +--- + +### Task 4: Data Models + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/BindingResponse.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/UnbindResponse.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/DeleteFileResponse.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/PrepareTransferResponse.kt` +- Create: `sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt` + +- [ ] **Step 1: Create `DeviceInfo.kt`** + +```kotlin +package com.duooomi.ble.models + +import org.json.JSONObject + +data class DeviceInfo( + val allspace: Long, + val freespace: Long, + val name: String, + val size: String, + val brand: String, + val powerlevel: Int +) { + companion object { + fun fromJson(json: JSONObject): DeviceInfo { + return DeviceInfo( + allspace = json.optLong("allspace", 0L), + freespace = json.optLong("freespace", 0L), + name = json.optString("name", ""), + size = json.optString("size", ""), + brand = json.optString("brand", ""), + powerlevel = json.optInt("powerlevel", 0) + ) + } + } +} +``` + +- [ ] **Step 2: Create `VersionInfo.kt`** + +```kotlin +package com.duooomi.ble.models + +import org.json.JSONObject + +data class VersionInfo( + val version: String, + val type: String +) { + companion object { + fun fromJson(json: JSONObject): VersionInfo { + val typeValue = when { + json.has("type") && json.get("type") is String -> json.getString("type") + json.has("type") && json.get("type") is Number -> json.getInt("type").toString() + else -> "" + } + return VersionInfo( + version = json.optString("version", ""), + type = typeValue + ) + } + } +} +``` + +- [ ] **Step 3: Create `BindingResponse.kt`** + +```kotlin +package com.duooomi.ble.models + +import org.json.JSONObject + +data class BindingResponse( + val type: Int, + val sn: String, + val success: Int, + val contents: List +) { + companion object { + fun fromJson(json: JSONObject): BindingResponse { + val contentsArray = json.optJSONArray("contents") + val contentsList = mutableListOf() + if (contentsArray != null) { + for (i in 0 until contentsArray.length()) { + contentsList.add(contentsArray.getString(i)) + } + } + return BindingResponse( + type = json.optInt("type", 0), + sn = json.optString("sn", ""), + success = json.optInt("success", 0), + contents = contentsList + ) + } + } +} +``` + +- [ ] **Step 4: Create `UnbindResponse.kt`** + +```kotlin +package com.duooomi.ble.models + +import org.json.JSONObject + +data class UnbindResponse( + val success: Int +) { + companion object { + fun fromJson(json: JSONObject): UnbindResponse { + return UnbindResponse(success = json.optInt("success", 0)) + } + } +} +``` + +- [ ] **Step 5: Create `DeleteFileResponse.kt`** + +```kotlin +package com.duooomi.ble.models + +import org.json.JSONObject + +data class DeleteFileResponse( + val type: Int, + val success: Int +) { + companion object { + fun fromJson(json: JSONObject): DeleteFileResponse { + return DeleteFileResponse( + type = json.optInt("type", 0), + success = json.optInt("success", 0) + ) + } + } +} +``` + +- [ ] **Step 6: Create `PrepareTransferResponse.kt`** + +```kotlin +package com.duooomi.ble.models + +import org.json.JSONObject + +data class PrepareTransferResponse( + val type: Int, + val key: String, + val status: String +) { + companion object { + fun fromJson(json: JSONObject): PrepareTransferResponse { + return PrepareTransferResponse( + type = json.optInt("type", 0), + key = json.optString("key", ""), + status = json.optString("status", "") + ) + } + } +} +``` + +- [ ] **Step 7: Create `FirmwareInfo.kt`** + +```kotlin +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", null), + fileSize = json.optString("fileSize", null), + fileMd5 = json.optString("fileMd5", null), + identifier = json.optString("identifier", null), + status = json.optString("status", null) + ) + } + } +} +``` + +- [ ] **Step 8: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/models/ +git commit -m "feat: add all data models with JSON parsing" +``` + +--- + +### Task 5: SDK Config + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt` + +- [ ] **Step 1: Create `DuooomiBleConfig.kt`** + +```kotlin +package com.duooomi.ble + +data class DuooomiBleConfig( + val apiHost: String = "https://api.mixvideo.bowong.cc", + val apiKey: String, + val cdnHost: String = "https://cdn.bowong.cc/", + val firmwareIdentifier: String = "duomi", + val firmwareStatus: String = "DRAFT" +) { + /** Ensure cdnHost ends with '/' */ + val normalizedCdnHost: String = if (cdnHost.endsWith("/")) cdnHost else "$cdnHost/" +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt +git commit -m "feat: add SDK configuration data class" +``` + +--- + +### Task 6: BleClient — Android BLE Wrapper + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt` + +- [ ] **Step 1: Create `BleClient.kt`** + +```kotlin +package com.duooomi.ble.core + +import android.annotation.SuppressLint +import android.bluetooth.* +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.ParcelUuid +import com.duooomi.ble.protocol.BleUUIDs +import com.duooomi.ble.utils.BleLog +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Android BLE wrapper with coroutine-based API. + * Mirrors iOS BleClient functionality. + */ +@SuppressLint("MissingPermission") +internal class BleClient(context: Context) { + + private val appContext = context.applicationContext + private val bluetoothManager = appContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter + + var connectedGatt: BluetoothGatt? = null + private set + var writeCharacteristic: BluetoothGattCharacteristic? = null + private set + var readCharacteristic: BluetoothGattCharacteristic? = null + private set + + private val discoveredDevices = mutableMapOf() + + // Continuations + private var connectContinuation: CancellableContinuation? = null + private var disconnectContinuation: CancellableContinuation? = null + + // Notification flow + private val _notifications = MutableSharedFlow(extraBufferCapacity = 64) + val notifications: SharedFlow = _notifications + + // Callbacks + var onDisconnected: (() -> Unit)? = null + + // MTU + private var mtuContinuation: CancellableContinuation? = null + + val isBluetoothEnabled: Boolean + get() = bluetoothAdapter?.isEnabled == true + + val maximumWriteLength: Int + get() { + val gatt = connectedGatt ?: return 20 + // After MTU negotiation, usable payload = MTU - 3 (ATT header) + // But we cap at a reasonable BLE max + return (negotiatedMtu - 3).coerceIn(20, 512) + } + + private var negotiatedMtu = 23 // default BLE MTU + + // MARK: - GATT Callback + + private val gattCallback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + BleLog.d("onConnectionStateChange: status=$status, newState=$newState", "BLE") + + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + BleLog.i("GATT connected, discovering services...", "BLE") + gatt.discoverServices() + } + BluetoothProfile.STATE_DISCONNECTED -> { + // If connect was pending, fail it + connectContinuation?.let { cont -> + connectContinuation = null + cont.resumeWithException( + DuooomiBleError.ConnectionFailed("Disconnected during setup") + ) + } + + cleanup() + + disconnectContinuation?.let { cont -> + disconnectContinuation = null + cont.resume(Unit) + } + + BleLog.w("GATT disconnected", "BLE") + onDisconnected?.invoke() + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + connectContinuation?.let { cont -> + connectContinuation = null + cont.resumeWithException(DuooomiBleError.ServiceNotFound) + } + return + } + + val service = gatt.getService(BleUUIDs.SERVICE) + if (service == null) { + connectContinuation?.let { cont -> + connectContinuation = null + cont.resumeWithException(DuooomiBleError.ServiceNotFound) + } + return + } + + val wChar = service.getCharacteristic(BleUUIDs.WRITE_CHARACTERISTIC) + val rChar = service.getCharacteristic(BleUUIDs.READ_CHARACTERISTIC) + + if (wChar == null || rChar == null) { + connectContinuation?.let { cont -> + connectContinuation = null + cont.resumeWithException(DuooomiBleError.CharacteristicNotFound) + } + return + } + + writeCharacteristic = wChar + readCharacteristic = rChar + connectedGatt = gatt + + // Enable notifications on read characteristic + gatt.setCharacteristicNotification(rChar, true) + val descriptor = rChar.getDescriptor( + java.util.UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + ) + if (descriptor != null) { + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + } + + BleLog.i("Characteristics ready; requesting MTU 512", "BLE") + gatt.requestMtu(512) + } + + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + BleLog.i("MTU changed: $mtu (status=$status)", "BLE") + negotiatedMtu = if (status == BluetoothGatt.GATT_SUCCESS) mtu else 23 + + // Now connection is fully ready + connectContinuation?.let { cont -> + connectContinuation = null + cont.resume(gatt) + } + mtuContinuation?.let { cont -> + mtuContinuation = null + cont.resume(negotiatedMtu) + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + if (characteristic.uuid == BleUUIDs.READ_CHARACTERISTIC) { + val value = characteristic.value ?: return + BleLog.d("Notification received, len=${value.size}", "BLE") + _notifications.tryEmit(value.copyOf()) + } + } + } + + // MARK: - Scan + + fun scan(): Flow = callbackFlow { + val scanner = bluetoothAdapter?.bluetoothLeScanner + ?: throw DuooomiBleError.BluetoothNotPoweredOn("Scanner unavailable") + + discoveredDevices.clear() + + val scanFilter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(BleUUIDs.SERVICE)) + .build() + + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val id = device.address + discoveredDevices[id] = device + + val discovered = DiscoveredDevice( + id = id, + name = device.name ?: result.scanRecord?.deviceName, + rssi = result.rssi + ) + BleLog.d("Discovered: id=$id, rssi=${result.rssi}", "BLE") + trySend(discovered) + } + + override fun onScanFailed(errorCode: Int) { + BleLog.e("Scan failed: $errorCode", "BLE") + close(DuooomiBleError.TransferFailed("Scan failed: $errorCode")) + } + } + + BleLog.i("Start scanning", "BLE") + scanner.startScan(listOf(scanFilter), scanSettings, callback) + + awaitClose { + BleLog.i("Stop scanning", "BLE") + scanner.stopScan(callback) + } + } + + fun stopScan() { + // Flow cancellation handles stopScan via awaitClose + } + + // MARK: - Connect + + suspend fun connect(deviceId: String, timeout: Long = 30_000L): BluetoothGatt { + if (bluetoothAdapter?.isEnabled != true) { + throw DuooomiBleError.BluetoothNotPoweredOn("Adapter disabled") + } + + val device = discoveredDevices[deviceId] + ?: throw DuooomiBleError.ConnectionFailed("Device not found: $deviceId") + + return withTimeout(timeout) { + suspendCancellableCoroutine { continuation -> + connectContinuation = continuation + BleLog.i("Connecting to $deviceId", "BLE") + device.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + + continuation.invokeOnCancellation { + connectContinuation = null + connectedGatt?.disconnect() + BleLog.w("Connection cancelled: $deviceId", "BLE") + } + } + } + } + + // MARK: - Disconnect + + suspend fun disconnect() { + val gatt = connectedGatt ?: return + + withTimeoutOrNull(5_000L) { + suspendCancellableCoroutine { continuation -> + disconnectContinuation = continuation + BleLog.i("Disconnecting", "BLE") + gatt.disconnect() + + continuation.invokeOnCancellation { + disconnectContinuation = null + cleanup() + } + } + } ?: run { + BleLog.w("Force-finish disconnect after timeout", "BLE") + cleanup() + } + } + + // MARK: - Write + + fun writeWithoutResponse(data: ByteArray) { + val gatt = connectedGatt ?: throw DuooomiBleError.NotConnected + val char = writeCharacteristic ?: throw DuooomiBleError.NotConnected + + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + char.value = data + gatt.writeCharacteristic(char) + BleLog.d("Write w/o response, len=${data.size}", "BLE") + } + + // MARK: - Connected Devices + + fun retrieveConnectedDevices(): List { + val devices = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT) + return devices.map { device -> + DiscoveredDevice( + id = device.address, + name = device.name, + rssi = 0 + ) + } + } + + // MARK: - Private + + private fun cleanup() { + connectedGatt?.close() + connectedGatt = null + writeCharacteristic = null + readCharacteristic = null + negotiatedMtu = 23 + BleLog.i("Client state cleaned up", "BLE") + } +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt +git commit -m "feat: add BleClient Android BLE wrapper with coroutines" +``` + +--- + +### Task 7: BleProtocolService + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/services/BleProtocolService.kt` + +- [ ] **Step 1: Create `BleProtocolService.kt`** + +```kotlin +package com.duooomi.ble.services + +import com.duooomi.ble.core.BleClient +import com.duooomi.ble.core.DuooomiBleError +import com.duooomi.ble.protocol.* +import com.duooomi.ble.utils.BleLog +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.json.JSONObject + +/** + * Protocol communication service: frame send/receive, fragment reassembly. + * Mirrors iOS BleProtocolService. + */ +internal class BleProtocolService(private val client: BleClient) { + + private data class FragmentSession( + val total: Int, + val frames: MutableMap = mutableMapOf() + ) + + private val fragments = mutableMapOf() + private var listenerJob: Job? = null + + private val _incomingMessages = MutableSharedFlow>(extraBufferCapacity = 64) + val incomingMessages: SharedFlow> = _incomingMessages + + // MARK: - Start/Stop + + fun startListening(scope: CoroutineScope) { + fragments.clear() + + listenerJob = scope.launch(Dispatchers.IO) { + BleLog.i("Start listening for notifications", "Protocol") + client.notifications.collect { data -> + handleRawData(data) + } + } + } + + fun stopListening() { + listenerJob?.cancel() + listenerJob = null + fragments.clear() + BleLog.i("Stop listening; fragments cleared", "Protocol") + } + + // MARK: - Send + + suspend fun send( + type: Byte, + data: ByteArray, + onProgress: ((Double) -> Unit)? = null + ) { + val maxWriteLen = client.maximumWriteLength + val maxDataSize = minOf( + maxWriteLen - FrameConstants.HEADER_SIZE - FrameConstants.FOOTER_SIZE, + FrameConstants.MAX_DATA_SIZE + ).coerceAtLeast(1) + + val frames = ProtocolManager.createFrames( + type = type, + data = data, + head = FrameHead.APP_TO_DEVICE, + maxDataSize = maxDataSize + ) + + if (frames.isEmpty()) { + BleLog.e("Payload too large; frames empty", "Protocol") + throw DuooomiBleError.TransferFailed("Payload too large: exceeds 65535-page limit") + } + + val total = frames.size + BleLog.d("Sending frames: total=$total, maxChunk=$maxDataSize", "Protocol") + + for ((index, frame) in frames.withIndex()) { + client.writeWithoutResponse(frame) + delay(FrameConstants.FRAME_INTERVAL_MS) + onProgress?.invoke((index + 1).toDouble() / total.toDouble()) + } + + BleLog.i("Frames sent: total=$total", "Protocol") + } + + suspend fun sendJSON(type: CommandType, payload: JSONObject) { + val jsonBytes = payload.toString().toByteArray(Charsets.UTF_8) + send(type.byte, jsonBytes) + } + + // MARK: - Receive & Reassemble + + private fun handleRawData(data: ByteArray) { + val frame = ProtocolManager.parseFrame(data) + if (frame == null) { + BleLog.w("Drop invalid frame (len=${data.size})", "Protocol") + return + } + + if (frame.subpageTotal > 0) { + handleFragment(frame) + } else { + BleLog.d("Single frame: type=${frame.type.toInt() and 0xFF}, len=${frame.data.size}", "Protocol") + _incomingMessages.tryEmit(Pair(frame.type.toInt() and 0xFF, frame.data)) + } + } + + private fun handleFragment(frame: ProtocolFrame) { + val deviceKey = client.connectedGatt?.device?.address ?: "unknown" + val typeInt = frame.type.toInt() and 0xFF + val key = "${deviceKey}_${typeInt}" + + if (key !in fragments) { + fragments[key] = FragmentSession(total = frame.subpageTotal) + BleLog.d("New fragment session: key=$key, total=${frame.subpageTotal}", "Protocol") + } + + val session = fragments[key] ?: return + if (frame.curPage >= session.total) return + + session.frames[frame.curPage] = frame.data + + if (session.frames.size != session.total) return + + // Reassemble: from high page to low page + var combined = ByteArray(0) + for (i in session.total - 1 downTo 0) { + session.frames[i]?.let { combined += it } + } + + fragments.remove(key) + BleLog.i("Fragment reassembled: key=$key, size=${combined.size}", "Protocol") + _incomingMessages.tryEmit(Pair(typeInt, combined)) + } +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/services/BleProtocolService.kt +git commit -m "feat: add BleProtocolService with frame send/receive/reassembly" +``` + +--- + +### Task 8: DeviceInfoService + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt` + +- [ ] **Step 1: Create `DeviceInfoService.kt`** + +```kotlin +package com.duooomi.ble.services + +import com.duooomi.ble.protocol.CommandType +import com.duooomi.ble.utils.BleLog +import org.json.JSONObject + +/** + * Device command service: sends JSON commands via BLE protocol. + * Mirrors iOS DeviceInfoService. + */ +internal class DeviceInfoService(private val protocolService: BleProtocolService) { + + suspend fun getDeviceInfo() { + val payload = JSONObject().put("type", CommandType.GET_DEVICE_INFO.value) + protocolService.sendJSON(CommandType.GET_DEVICE_INFO, payload) + } + + suspend fun getDeviceVersion() { + val payload = JSONObject().put("type", CommandType.GET_DEVICE_VERSION.value) + protocolService.sendJSON(CommandType.GET_DEVICE_VERSION, payload) + } + + suspend fun bindDevice(userId: String) { + val payload = JSONObject() + .put("type", CommandType.BIND_DEVICE.value) + .put("userId", userId) + protocolService.sendJSON(CommandType.BIND_DEVICE, payload) + } + + suspend fun unbindDevice(userId: String) { + val payload = JSONObject() + .put("type", CommandType.UNBIND_DEVICE.value) + .put("userId", userId) + protocolService.sendJSON(CommandType.UNBIND_DEVICE, payload) + } + + suspend fun deleteFile(key: String) { + val payload = JSONObject() + .put("type", CommandType.DELETE_FILE.value) + .put("key", key) + protocolService.sendJSON(CommandType.DELETE_FILE, payload) + } + + suspend fun prepareTransfer(key: String, size: Int) { + val payload = JSONObject() + .put("type", CommandType.PREPARE_TRANSFER.value) + .put("key", key) + .put("size", size) + protocolService.sendJSON(CommandType.PREPARE_TRANSFER, payload) + } +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt +git commit -m "feat: add DeviceInfoService for JSON BLE commands" +``` + +--- + +### Task 9: FileTransferService + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/services/FileTransferService.kt` + +- [ ] **Step 1: Create `FileTransferService.kt`** + +```kotlin +package com.duooomi.ble.services + +import com.duooomi.ble.core.DuooomiBleError +import com.duooomi.ble.protocol.CommandType +import com.duooomi.ble.utils.BleLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * File transfer service: download file and send via BLE protocol. + * Mirrors iOS FileTransferService. + */ +internal class FileTransferService(private val protocolService: BleProtocolService) { + + suspend fun transferFile( + fileUri: String, + commandType: CommandType, + onProgress: ((Double) -> Unit)? = null + ) { + BleLog.i("Load file: $fileUri", "Transfer") + val data = loadFileData(fileUri) + BleLog.d("File loaded: size=${data.size} bytes", "Transfer") + + protocolService.send( + type = commandType.byte, + data = data, + onProgress = onProgress + ) + } + + private suspend fun loadFileData(uri: String): ByteArray = withContext(Dispatchers.IO) { + if (uri.startsWith("file://") || uri.startsWith("/")) { + val path = if (uri.startsWith("file://")) uri.removePrefix("file://") else uri + val file = File(path) + if (!file.exists()) throw DuooomiBleError.TransferFailed("File not found: $uri") + file.readBytes() + } else { + val url = URL(uri) + val conn = url.openConnection() as HttpURLConnection + try { + conn.requestMethod = "GET" + conn.connectTimeout = 30_000 + conn.readTimeout = 30_000 + + val responseCode = conn.responseCode + if (responseCode !in 200..299) { + throw DuooomiBleError.TransferFailed("Failed to download file: HTTP $responseCode") + } + + conn.inputStream.readBytes() + } finally { + conn.disconnect() + } + } + } +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/services/FileTransferService.kt +git commit -m "feat: add FileTransferService for file download and BLE transfer" +``` + +--- + +### Task 10: AniConverter + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt` + +- [ ] **Step 1: Create `AniConverter.kt`** + +```kotlin +package com.duooomi.ble.services + +import com.duooomi.ble.DuooomiBleConfig +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 + +/** + * ANI file conversion service via HTTP API. + * Mirrors iOS AniConverter. + */ +internal class AniConverter(private val config: DuooomiBleConfig) { + + private val endpoint: String + get() = "${config.apiHost}/api/auth/loomart/file/convert-to-ani" + + /** + * Convert a video/image URL to ANI format. Returns the ANI file CDN URL. + * Retries once on connection lost. + */ + suspend fun convert(fileUrl: String): String { + return try { + performConvert(fileUrl) + } catch (e: java.net.SocketException) { + BleLog.w("Connection lost, retrying ANI convert...", "AniConverter") + performConvert(fileUrl) + } + } + + private suspend fun performConvert(fileUrl: String): String = withContext(Dispatchers.IO) { + val url = URL(endpoint) + val conn = url.openConnection() as HttpURLConnection + try { + conn.requestMethod = "POST" + conn.setRequestProperty("content-type", "application/json") + conn.setRequestProperty("x-api-key", config.apiKey) + conn.connectTimeout = 120_000 + conn.readTimeout = 120_000 + conn.doOutput = true + + val body = JSONObject().apply { + put("videoUrl", fileUrl) + put("width", 360) + put("height", 360) + put("fps", "24") + } + + conn.outputStream.bufferedWriter().use { it.write(body.toString()) } + + val responseCode = conn.responseCode + if (responseCode !in 200..299) { + throw AniConverterError("HTTP $responseCode") + } + + val responseBody = conn.inputStream.bufferedReader().readText() + val json = JSONObject(responseBody) + + if (json.optBoolean("success") != true) { + throw AniConverterError("ANI API reported failure") + } + + val outer = json.optJSONObject("data") + ?: throw AniConverterError("ANI API reported failure") + + if (outer.optBoolean("status") != true) { + val msg = outer.optString("msg", "ANI conversion failed") + throw AniConverterError(msg) + } + + val aniUrl = outer.optString("data", "") + if (aniUrl.isEmpty()) { + throw AniConverterError("Missing ani url in response") + } + + aniUrl + } finally { + conn.disconnect() + } + } +} + +internal class AniConverterError(message: String) : Exception(message) +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt +git commit -m "feat: add AniConverter HTTP service" +``` + +--- + +### Task 11: FirmwareService + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt` + +- [ ] **Step 1: Create `FirmwareService.kt`** + +```kotlin +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 + +/** + * Firmware query service via HTTP API. + * Mirrors iOS FirmwareService. + */ +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 encodedSt = URLEncoder.encode(st, "UTF-8") + val urlStr = "${config.apiHost}/$latestPath?identifier=$encodedId&status=$encodedSt" + + BleLog.d("Fetching firmware: $urlStr", "Firmware") + + val url = URL(urlStr) + val conn = url.openConnection() as HttpURLConnection + try { + conn.requestMethod = "GET" + conn.setRequestProperty("x-api-key", config.apiKey) + conn.setRequestProperty("accept", "application/json") + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + + val responseCode = conn.responseCode + if (responseCode !in 200..299) { + throw DuooomiBleError.TransferFailed("HTTP $responseCode") + } + + val responseBody = conn.inputStream.bufferedReader().readText() + val json = JSONObject(responseBody) + + if (json.isNull("data")) { + return@withContext null + } + + val dataObj = json.optJSONObject("data") ?: return@withContext null + FirmwareInfo.fromJson(dataObj) + } finally { + conn.disconnect() + } + } +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt +git commit -m "feat: add FirmwareService HTTP query" +``` + +--- + +### Task 12: DuooomiBleSDK — Public API Facade + +**Files:** +- Create: `sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt` + +- [ ] **Step 1: Create `DuooomiBleSDK.kt`** + +```kotlin +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.BleLog +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +/** + * Duooomi BLE SDK — pure Kotlin Android BLE SDK. + * + * Provides device scan, connect, command, file transfer, ANI conversion, and firmware query. + * 1:1 feature parity with iOS DuooomiBleSDK. + */ +class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) { + + // MARK: - Observable State + + private val _btState = MutableStateFlow(ConnectionState.IDLE) + val btState: StateFlow = _btState.asStateFlow() + + private val _connectedDevice = MutableStateFlow(null) + val connectedDevice: StateFlow = _connectedDevice.asStateFlow() + + private val _deviceInfo = MutableStateFlow(null) + val deviceInfo: StateFlow = _deviceInfo.asStateFlow() + + private val _version = MutableStateFlow("") + val version: StateFlow = _version.asStateFlow() + + private val _isActivated = MutableStateFlow(false) + val isActivated: StateFlow = _isActivated.asStateFlow() + + private val _transferProgress = MutableStateFlow(0) + val transferProgress: StateFlow = _transferProgress.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _discoveredDevices = MutableStateFlow>(emptyList()) + val discoveredDevices: StateFlow> = _discoveredDevices.asStateFlow() + + // MARK: - Internal Services + + private val bleClient = BleClient(context) + private val protocolService = BleProtocolService(bleClient) + private val deviceInfoService = DeviceInfoService(protocolService) + private val fileTransferService = FileTransferService(protocolService) + private val aniConverter = AniConverter(config) + private val firmwareService = FirmwareService(config) + + // MARK: - Coroutine Scope + + private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // MARK: - Request-Response + + private val pendingContinuations = mutableMapOf>() + private var messageListenerJob: Job? = null + + // MARK: - Scan Batching + + private var scanJob: Job? = null + private val allDiscoveredDevices = mutableListOf() + private val pendingDevices = mutableListOf() + private var flushJob: Job? = null + + init { + BleLog.i("SDK initialized (apiHost=${config.apiHost}, firmware=${config.firmwareIdentifier}/${config.firmwareStatus})", "SDK") + setupDisconnectHandler() + } + + private fun setupDisconnectHandler() { + bleClient.onDisconnected = { + sdkScope.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) + } + } + } + + // MARK: - Scanning + + fun scan() { + stopScan() + _btState.value = ConnectionState.SCANNING + _discoveredDevices.value = emptyList() + allDiscoveredDevices.clear() + pendingDevices.clear() + + BleLog.i("Start scanning...", "Scan") + scanJob = sdkScope.launch { + bleClient.scan().collect { device -> + queueDevice(device) + } + } + } + + fun stopScan() { + scanJob?.cancel() + scanJob = null + 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) + + protocolService.startListening(sdkScope) + 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 { + 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.value.toString()) { + deviceInfoService.getDeviceInfo() + } + val info = DeviceInfo.fromJson(JSONObject(cleanNullBytes(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.value.toString()) { + deviceInfoService.getDeviceVersion() + } + val info = VersionInfo.fromJson(JSONObject(cleanNullBytes(data))) + _version.value = info.version + BleLog.i("Version received: ${info.version} (type=${info.type})", "Command") + return info + } + + suspend fun bind(userId: String): BindingResponse { + ensureConnected() + BleLog.d("Sending bind (userId=$userId)", "Command") + val data = sendAndWait(CommandType.BIND_DEVICE.value.toString()) { + deviceInfoService.bindDevice(userId) + } + val resp = BindingResponse.fromJson(JSONObject(cleanNullBytes(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.i("Bind success: sn=${resp.sn}", "Command") + return resp + } + + suspend fun unbind(userId: String): UnbindResponse { + ensureConnected() + BleLog.d("Sending unbind (userId=$userId)", "Command") + val data = sendAndWait(CommandType.UNBIND_DEVICE.value.toString()) { + deviceInfoService.unbindDevice(userId) + } + val resp = UnbindResponse.fromJson(JSONObject(cleanNullBytes(data))) + if (resp.success == 1) { + _isActivated.value = false + BleLog.i("Unbind success", "Command") + } else { + BleLog.w("Unbind failed", "Command") + throw DuooomiBleError.UnbindFailed + } + return resp + } + + suspend fun deleteFile(key: String): DeleteFileResponse { + ensureConnected() + BleLog.d("Sending deleteFile (key=$key)", "Command") + val data = sendAndWait(CommandType.DELETE_FILE.value.toString()) { + deviceInfoService.deleteFile(key) + } + val resp = DeleteFileResponse.fromJson(JSONObject(cleanNullBytes(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) { + deviceInfoService.prepareTransfer(key, size) + } + val resp = PrepareTransferResponse.fromJson(JSONObject(cleanNullBytes(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 pct = (progress * 100).toInt() + _transferProgress.value = pct + if (pct % 10 == 0) { + BleLog.d("Progress: $pct%", "Transfer") + } + } + + _transferProgress.value = 100 + BleLog.i("Transfer completed", "Transfer") + } + + // MARK: - High-Level APIs + + 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 { + 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") + + prepareTransfer(key = url, size = aniSize) + transferFile(fileUri = aniUrl, commandType = CommandType.TRANSFER_ANI_VIDEO) + } + + suspend fun fetchLatestFirmware( + identifier: String? = null, + status: String? = null + ): FirmwareInfo? { + return firmwareService.fetchLatest(identifier, status) + } + + suspend fun upgradeFirmware(fileUrl: String) { + ensureConnected() + BleLog.i("OTA upgrade start: $fileUrl", "Firmware") + transferFile(fileUri = fileUrl, commandType = CommandType.OTA_PACKAGE) + BleLog.i("OTA upgrade completed", "Firmware") + } + + /** + * Release SDK resources. Call when done using the SDK. + */ + fun destroy() { + sdkScope.cancel() + protocolService.stopListening() + BleLog.i("SDK destroyed", "SDK") + } + + // MARK: - Internal Helpers + + private suspend fun getRemoteFileSize(url: String): Int = withContext(Dispatchers.IO) { + val fileUrl = URL(url) + // Try HEAD request + var conn = fileUrl.openConnection() as HttpURLConnection + try { + conn.requestMethod = "HEAD" + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + conn.connect() + + val length = conn.contentLength + if (length > 0) return@withContext length + + val lengthHeader = conn.getHeaderField("Content-Length") + if (lengthHeader != null) { + val parsed = lengthHeader.toIntOrNull() + if (parsed != null && parsed > 0) return@withContext parsed + } + } finally { + conn.disconnect() + } + + // Fallback: Range request + conn = fileUrl.openConnection() as HttpURLConnection + try { + conn.requestMethod = "GET" + conn.setRequestProperty("Range", "bytes=0-0") + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + conn.connect() + + val rangeHeader = conn.getHeaderField("Content-Range") + if (rangeHeader != null) { + val totalStr = rangeHeader.substringAfterLast("/", "") + val total = totalStr.toIntOrNull() + if (total != null && total > 0) return@withContext total + } + } finally { + conn.disconnect() + } + + throw DuooomiBleError.TransferFailed("Cannot determine file size: $url") + } + + // MARK: - Request-Response Pattern + + private suspend fun sendAndWait( + opId: String, + timeoutMs: Long = 10_000L, + send: suspend () -> Unit + ): ByteArray { + BleLog.d("Register opId=$opId", "RPC") + + return withTimeout(timeoutMs) { + suspendCancellableCoroutine { continuation -> + pendingContinuations[opId] = continuation + + continuation.invokeOnCancellation { + pendingContinuations.remove(opId) + } + + sdkScope.launch { + try { + send() + BleLog.d("Command sent for opId=$opId", "RPC") + } catch (e: Exception) { + pendingContinuations.remove(opId) + continuation.resumeWith(Result.failure(e)) + } + } + } + } + } + + // MARK: - Message Listener + + private fun startMessageListener() { + messageListenerJob?.cancel() + messageListenerJob = sdkScope.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: Int, data: ByteArray) { + val opId = "$commandType" + + // Check for prepareTransfer with key-based opId + if (commandType == CommandType.PREPARE_TRANSFER.value) { + try { + val json = JSONObject(cleanNullBytes(data)) + val resp = PrepareTransferResponse.fromJson(json) + val keyedOpId = "${commandType}_${resp.key}" + pendingContinuations.remove(keyedOpId)?.let { cont -> + BleLog.d("Resuming keyed opId=$keyedOpId", "RPC") + cont.resumeWith(Result.success(data)) + return + } + } catch (_: Exception) { } + } + + pendingContinuations.remove(opId)?.let { cont -> + BleLog.d("Resuming opId=$opId", "RPC") + cont.resumeWith(Result.success(data)) + } ?: 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 cleanNullBytes(data: ByteArray): String { + return String(data.filter { it != 0.toByte() }.toByteArray(), Charsets.UTF_8) + } + + private fun cancelAllPending(error: Exception) { + val continuations = pendingContinuations.toMap() + pendingContinuations.clear() + for ((_, cont) in continuations) { + cont.resumeWith(Result.failure(error)) + } + } + + // MARK: - Scan Batching (500ms throttle) + + private fun queueDevice(device: DiscoveredDevice) { + if (allDiscoveredDevices.any { it.id == device.id }) return + + allDiscoveredDevices.add(device) + pendingDevices.add(device) + + flushJob?.cancel() + flushJob = sdkScope.launch { + delay(500) + flushDevices() + } + } + + private fun flushDevices() { + if (pendingDevices.isEmpty()) return + pendingDevices.clear() + _discoveredDevices.value = allDiscoveredDevices.toList() + BleLog.d("Discovered devices flushed: total=${_discoveredDevices.value.size}", "Scan") + } +} +``` + +- [ ] **Step 2: Build & commit** + +```bash +./gradlew :sdk:assembleDebug +git add sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt +git commit -m "feat: add DuooomiBleSDK public API facade" +``` + +--- + +### Task 13: README + +**Files:** +- Create: `README.md` + +- [ ] **Step 1: Create `README.md`** + +```markdown +# DuooomiBleSDK (Android) + +Pure Kotlin Android BLE SDK, zero third-party依赖 (仅 kotlinx-coroutines). + +## 集成 + +```groovy +// settings.gradle.kts +include(":sdk") +// or as a module dependency +implementation(project(":sdk")) +``` + +## 初始化 + +```kotlin +val sdk = DuooomiBleSDK( + context = applicationContext, + config = DuooomiBleConfig( + apiKey = "your-api-key" // 必填 + // apiHost = "https://api.mixvideo.bowong.cc" // 默认 + // cdnHost = "https://cdn.bowong.cc/" // 默认 + // firmwareIdentifier = "duomi" // 默认 + // firmwareStatus = "DRAFT" // 默认 + ) +) +``` + +## API + +### 扫描 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `scan()` | `Unit` | 开始扫描,结果通过 `discoveredDevices` 观察 | +| `stopScan()` | `Unit` | 停止扫描 | + +### 连接 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `connect(deviceId: String)` | `DiscoveredDevice` | 连接设备 | +| `disconnect()` | `Unit` | 断开连接 | +| `getConnectedDevices()` | `List` | 获取系统已连接设备 | + +### 设备命令 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `getDeviceInfo()` | `DeviceInfo` | 品牌、容量、电量、型号 | +| `getVersion()` | `VersionInfo` | 固件版本 | +| `bind(userId: String)` | `BindingResponse` | 绑定设备,返回 sn + 文件列表。**绑定后才能查看设备文件** | +| `unbind(userId: String)` | `UnbindResponse` | 解绑设备。**解绑后该账号的设备文件将被删除** | +| `deleteFile(key: String)` | `DeleteFileResponse` | 删除设备上的单个文件 | + +### 文件传输 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `transferMedia(fileUrl: String)` | `Unit` | 输入文件 URL(mp4/jpg/png),内部自动完成 ANI 转换 → prepare → 传输。进度通过 `transferProgress` 观察 | + +### 固件升级 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `fetchLatestFirmware(identifier?, status?)` | `FirmwareInfo?` | 查询最新固件,null 表示无可用版本 | +| `upgradeFirmware(fileUrl: String)` | `Unit` | OTA 传输固件包到设备 | + +### 可观察状态 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `btState` | `StateFlow` | IDLE / SCANNING / CONNECTING / CONNECTED / DISCONNECTING / DISCONNECTED | +| `discoveredDevices` | `StateFlow>` | 扫描到的设备列表 | +| `connectedDevice` | `StateFlow` | 当前连接设备 | +| `deviceInfo` | `StateFlow` | 设备信息 | +| `version` | `StateFlow` | 固件版本 | +| `isActivated` | `StateFlow` | 是否已绑定 | +| `transferProgress` | `StateFlow` | 传输进度 0-100 | +| `error` | `StateFlow` | 最近错误 | + +### 生命周期 + +```kotlin +// 使用完毕后释放资源 +sdk.destroy() +``` + +## 权限 + +```xml + + + +``` + +运行时权限由调用方负责请求。 + +## 技术规格 + +- Android 8.0+ (API 26) / Kotlin 1.9+ / 仅依赖 kotlinx-coroutines +- 所有 BLE 操作基于 coroutines,公开 API 为 suspend 函数 +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: add README with SDK API documentation" +``` + +--- + +### Task 14: Full Build Verification + +- [ ] **Step 1: Clean build** + +```bash +cd /Users/cchis/Documents/ship/bw/duooomi-android-sdk +./gradlew clean :sdk:assembleDebug +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 2: Final commit if any fixups needed** + +```bash +git status +# If clean: done. If fixes were needed, commit them. +```