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>
69 KiB
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
.gitignoreand 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 |
输入文件 URL(mp4/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.