Files
duooomi-android-sdk/docs/superpowers/plans/2026-04-10-android-ble-sdk.md
km2023 43be8989f4 Add implementation plan for Android BLE SDK
14 tasks covering: Gradle scaffolding, types, protocol, models, config,
BleClient, services, public API facade, README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:37:53 +08:00

69 KiB
Raw Blame History

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

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
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
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m
  • Step 4: Create sdk/build.gradle.kts
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 version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="true" />

</manifest>
  • Step 6: Initialize Gradle wrapper

Run:

cd /Users/cchis/Documents/ship/bw/duooomi-android-sdk
gradle wrapper --gradle-version 8.5
  • Step 7: Verify build

Run:

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

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
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
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
./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

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
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<ByteArray> {
        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<ByteArray> {
        val totalPages = (data.size + maxDataSize - 1) / maxDataSize
        val frames = mutableListOf<ByteArray>()

        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
./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

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
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
package com.duooomi.ble.models

import org.json.JSONObject

data class BindingResponse(
    val type: Int,
    val sn: String,
    val success: Int,
    val contents: List<String>
) {
    companion object {
        fun fromJson(json: JSONObject): BindingResponse {
            val contentsArray = json.optJSONArray("contents")
            val contentsList = mutableListOf<String>()
            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
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
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
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
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
./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

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
./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

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<String, BluetoothDevice>()

    // Continuations
    private var connectContinuation: CancellableContinuation<BluetoothGatt>? = null
    private var disconnectContinuation: CancellableContinuation<Unit>? = null

    // Notification flow
    private val _notifications = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
    val notifications: SharedFlow<ByteArray> = _notifications

    // Callbacks
    var onDisconnected: (() -> Unit)? = null

    // MTU
    private var mtuContinuation: CancellableContinuation<Int>? = 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<DiscoveredDevice> = 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<DiscoveredDevice> {
        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
./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

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<Int, ByteArray> = mutableMapOf()
    )

    private val fragments = mutableMapOf<String, FragmentSession>()
    private var listenerJob: Job? = null

    private val _incomingMessages = MutableSharedFlow<Pair<Int, ByteArray>>(extraBufferCapacity = 64)
    val incomingMessages: SharedFlow<Pair<Int, ByteArray>> = _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
./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

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
./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

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
./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

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
./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

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
./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

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<ConnectionState> = _btState.asStateFlow()

    private val _connectedDevice = MutableStateFlow<DiscoveredDevice?>(null)
    val connectedDevice: StateFlow<DiscoveredDevice?> = _connectedDevice.asStateFlow()

    private val _deviceInfo = MutableStateFlow<DeviceInfo?>(null)
    val deviceInfo: StateFlow<DeviceInfo?> = _deviceInfo.asStateFlow()

    private val _version = MutableStateFlow("")
    val version: StateFlow<String> = _version.asStateFlow()

    private val _isActivated = MutableStateFlow(false)
    val isActivated: StateFlow<Boolean> = _isActivated.asStateFlow()

    private val _transferProgress = MutableStateFlow(0)
    val transferProgress: StateFlow<Int> = _transferProgress.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
    val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _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<String, CancellableContinuation<ByteArray>>()
    private var messageListenerJob: Job? = null

    // MARK: - Scan Batching

    private var scanJob: Job? = null
    private val allDiscoveredDevices = mutableListOf<DiscoveredDevice>()
    private val pendingDevices = mutableListOf<DiscoveredDevice>()
    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<DiscoveredDevice> {
        val list = bleClient.retrieveConnectedDevices()
        BleLog.d("System-connected devices: ${list.size}", "Connect")
        return list
    }

    // MARK: - Device Commands

    suspend fun getDeviceInfo(): DeviceInfo {
        ensureConnected()
        BleLog.d("Sending getDeviceInfo", "Command")
        val data = sendAndWait(CommandType.GET_DEVICE_INFO.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
./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

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

初始化

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<DiscoveredDevice> 获取系统已连接设备

设备命令

方法 返回 说明
getDeviceInfo() DeviceInfo 品牌、容量、电量、型号
getVersion() VersionInfo 固件版本
bind(userId: String) BindingResponse 绑定设备,返回 sn + 文件列表。绑定后才能查看设备文件
unbind(userId: String) UnbindResponse 解绑设备。解绑后该账号的设备文件将被删除
deleteFile(key: String) DeleteFileResponse 删除设备上的单个文件

文件传输

方法 返回 说明
transferMedia(fileUrl: String) Unit 输入文件 URLmp4/jpg/png内部自动完成 ANI 转换 → prepare → 传输。进度通过 transferProgress 观察

固件升级

方法 返回 说明
fetchLatestFirmware(identifier?, status?) FirmwareInfo? 查询最新固件null 表示无可用版本
upgradeFirmware(fileUrl: String) Unit OTA 传输固件包到设备

可观察状态

属性 类型 说明
btState StateFlow<ConnectionState> IDLE / SCANNING / CONNECTING / CONNECTED / DISCONNECTING / DISCONNECTED
discoveredDevices StateFlow<List<DiscoveredDevice>> 扫描到的设备列表
connectedDevice StateFlow<DiscoveredDevice?> 当前连接设备
deviceInfo StateFlow<DeviceInfo?> 设备信息
version StateFlow<String> 固件版本
isActivated StateFlow<Boolean> 是否已绑定
transferProgress StateFlow<Int> 传输进度 0-100
error StateFlow<String?> 最近错误

生命周期

// 使用完毕后释放资源
sdk.destroy()

权限

<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

运行时权限由调用方负责请求。

技术规格

  • 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
cd /Users/cchis/Documents/ship/bw/duooomi-android-sdk
./gradlew clean :sdk:assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 2: Final commit if any fixups needed
git status
# If clean: done. If fixes were needed, commit them.