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.
+```