feat: implement Android BLE SDK with demo app

Complete Android port of duooomi-ios-sdk with 1:1 API parity.
Includes SDK library module and Jetpack Compose demo app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
km2023
2026-04-13 12:09:22 +08:00
parent 43be8989f4
commit d655b13416
35 changed files with 2802 additions and 0 deletions

View File

@@ -0,0 +1,530 @@
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
class DuooomiBleSDK(context: Context, val config: DuooomiBleConfig) {
// MARK: - Observable State
private val _btState = MutableStateFlow(ConnectionState.IDLE)
val btState: StateFlow<ConnectionState> = _btState.asStateFlow()
private val _discoveredDevices = MutableStateFlow<List<DiscoveredDevice>>(emptyList())
val discoveredDevices: StateFlow<List<DiscoveredDevice>> = _discoveredDevices.asStateFlow()
private val _connectedDevice = MutableStateFlow<DiscoveredDevice?>(null)
val connectedDevice: StateFlow<DiscoveredDevice?> = _connectedDevice.asStateFlow()
private val _deviceInfo = MutableStateFlow<DeviceInfo?>(null)
val deviceInfo: StateFlow<DeviceInfo?> = _deviceInfo.asStateFlow()
private val _version = MutableStateFlow("")
val version: StateFlow<String> = _version.asStateFlow()
private val _isActivated = MutableStateFlow(false)
val isActivated: StateFlow<Boolean> = _isActivated.asStateFlow()
private val _transferProgress = MutableStateFlow(0)
val transferProgress: StateFlow<Int> = _transferProgress.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// MARK: - Internal Services
private val bleClient = BleClient(context)
private val protocolService = BleProtocolService(bleClient)
private val deviceInfoService = DeviceInfoService(protocolService)
private val fileTransferService = FileTransferService(protocolService)
private val aniConverter = AniConverter(config)
private val firmwareService = FirmwareService(config)
// MARK: - Request-Response
private val pendingContinuations = mutableMapOf<String, CancellableContinuation<ByteArray>>()
private var messageListenerJob: Job? = null
// MARK: - Scan State
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
BleLog.e("Coroutine exception: ${throwable.message}", "SDK")
_error.value = throwable.message
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
private var scanJob: Job? = null
private val allDiscoveredDevices = mutableListOf<DiscoveredDevice>()
private var flushJob: Job? = null
init {
BleLog.i("SDK initialized (apiHost=${config.apiHost}, firmware=${config.firmwareIdentifier}/${config.firmwareStatus})", "SDK")
setupDisconnectHandler()
}
private fun setupDisconnectHandler() {
bleClient.onDisconnected = {
scope.launch {
BleLog.w("Peripheral disconnected; resetting state", "SDK")
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
protocolService.stopListening()
cancelAllPending(DuooomiBleError.NotConnected)
}
}
}
// MARK: - Scanning
fun scan() {
stopScan()
_btState.value = ConnectionState.SCANNING
_discoveredDevices.value = emptyList()
allDiscoveredDevices.clear()
BleLog.i("Start scanning...", "Scan")
scanJob = scope.launch {
try {
bleClient.scan().collect { device ->
BleLog.d("Discovered: id=${device.id}, name=${device.name ?: "-"}, rssi=${device.rssi}", "Scan")
queueDevice(device)
}
} catch (e: Exception) {
BleLog.e("Scan error: ${e.message}", "Scan")
_error.value = e.message
_btState.value = ConnectionState.IDLE
}
}
}
fun stopScan() {
scanJob?.cancel()
scanJob = null
bleClient.stopScan()
flushDevices()
if (_connectedDevice.value != null) {
_btState.value = ConnectionState.CONNECTED
} else if (_btState.value == ConnectionState.SCANNING) {
_btState.value = ConnectionState.IDLE
}
BleLog.i("Stop scanning (state=${_btState.value})", "Scan")
}
// MARK: - Connection
suspend fun connect(deviceId: String): DiscoveredDevice {
stopScan()
_btState.value = ConnectionState.CONNECTING
BleLog.i("Connecting to $deviceId...", "Connect")
try {
val gatt = bleClient.connect(deviceId)
// Start protocol listener
protocolService.startListening(scope)
startMessageListener()
val device = DiscoveredDevice(
id = gatt.device.address,
name = gatt.device.name,
rssi = 0
)
_connectedDevice.value = device
_btState.value = ConnectionState.CONNECTED
BleLog.i("Connected: ${device.id}", "Connect")
return device
} catch (e: Exception) {
_btState.value = ConnectionState.IDLE
_error.value = e.message
BleLog.e("Connect failed: ${e.message}", "Connect")
throw e
}
}
suspend fun disconnect() {
_btState.value = ConnectionState.DISCONNECTING
BleLog.i("Disconnect requested", "Connect")
protocolService.stopListening()
messageListenerJob?.cancel()
messageListenerJob = null
cancelAllPending(DuooomiBleError.NotConnected)
var disconnectError: Exception? = null
try {
bleClient.disconnect()
} catch (e: Exception) {
disconnectError = e
_error.value = e.message
BleLog.e("Disconnect error: ${e.message}", "Connect")
}
_connectedDevice.value = null
_deviceInfo.value = null
_version.value = ""
_isActivated.value = false
_btState.value = ConnectionState.DISCONNECTED
BleLog.i("Disconnected", "Connect")
disconnectError?.let { throw it }
}
fun getConnectedDevices(): List<DiscoveredDevice> {
val list = bleClient.retrieveConnectedDevices()
BleLog.d("System-connected devices: ${list.size}", "Connect")
return list
}
// MARK: - Device Commands
suspend fun getDeviceInfo(): DeviceInfo {
ensureConnected()
BleLog.d("Sending getDeviceInfo", "Command")
val data = sendAndWait(CommandType.GET_DEVICE_INFO) {
deviceInfoService.getDeviceInfo()
}
val info = DeviceInfo.fromJson(parseJsonResponse(data))
BleLog.i("DeviceInfo received: name=${info.name}, brand=${info.brand}", "Command")
_deviceInfo.value = info
return info
}
suspend fun getVersion(): VersionInfo {
ensureConnected()
BleLog.d("Sending getDeviceVersion", "Command")
val data = sendAndWait(CommandType.GET_DEVICE_VERSION) {
deviceInfoService.getDeviceVersion()
}
val info = VersionInfo.fromJson(parseJsonResponse(data))
_version.value = info.version
BleLog.i("Version received: ${info.version} (type=${info.type})", "Command")
return info
}
suspend fun bind(userId: String): BindingResponse {
ensureConnected()
BleLog.d("Sending bind (userId=$userId)", "Command")
val data = sendAndWait(CommandType.BIND_DEVICE) {
deviceInfoService.bindDevice(userId)
}
val resp = BindingResponse.fromJson(parseJsonResponse(data))
_isActivated.value = resp.success == 1
if (resp.success != 1) {
BleLog.w("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) {
deviceInfoService.unbindDevice(userId)
}
val resp = UnbindResponse.fromJson(parseJsonResponse(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) {
deviceInfoService.deleteFile(key)
}
val resp = DeleteFileResponse.fromJson(parseJsonResponse(data))
if (resp.success != 0) {
BleLog.w("Delete failed with status=${resp.success}", "Command")
throw DuooomiBleError.DeleteFileFailed(resp.success)
}
BleLog.i("Delete success (key=$key)", "Command")
return resp
}
suspend fun prepareTransfer(key: String, size: Int): PrepareTransferResponse {
ensureConnected()
val opId = "${CommandType.PREPARE_TRANSFER.value}_$key"
BleLog.d("Sending prepareTransfer (key=$key, size=$size)", "Transfer")
val data = sendAndWait(opId = opId, command = CommandType.PREPARE_TRANSFER) {
deviceInfoService.prepareTransfer(key, size)
}
val resp = PrepareTransferResponse.fromJson(parseJsonResponse(data))
if (resp.status != "ready") {
BleLog.w("PrepareTransfer not ready: status=${resp.status}", "Transfer")
throw DuooomiBleError.PrepareTransferFailed(resp.status)
}
BleLog.i("PrepareTransfer ready (key=$key)", "Transfer")
return resp
}
// MARK: - File Transfer
suspend fun transferFile(
fileUri: String,
commandType: CommandType = CommandType.TRANSFER_ANI_VIDEO
) {
ensureConnected()
_transferProgress.value = 0
BleLog.i("Transfer start: uri=$fileUri, cmd=$commandType", "Transfer")
fileTransferService.transferFile(
fileUri = fileUri,
commandType = commandType
) { progress ->
val percent = (progress * 100).toInt()
_transferProgress.value = percent
if (percent % 10 == 0) {
BleLog.d("Progress: $percent%", "Transfer")
}
}
_transferProgress.value = 100
BleLog.i("Transfer completed", "Transfer")
}
// MARK: - High-Level APIs
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")
val key = url
prepareTransfer(key = key, 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")
}
// MARK: - Internal Helpers
private suspend fun getRemoteFileSize(url: String): Int = withContext(Dispatchers.IO) {
val fileUrl = URL(url)
// Try HEAD request first
val headConn = fileUrl.openConnection() as HttpURLConnection
try {
headConn.requestMethod = "HEAD"
headConn.connectTimeout = 15_000
headConn.connect()
val length = headConn.contentLength
if (length > 0) return@withContext length
} finally {
headConn.disconnect()
}
// Fallback: Range request
val rangeConn = fileUrl.openConnection() as HttpURLConnection
try {
rangeConn.requestMethod = "GET"
rangeConn.setRequestProperty("Range", "bytes=0-0")
rangeConn.connectTimeout = 15_000
rangeConn.connect()
val rangeHeader = rangeConn.getHeaderField("Content-Range")
if (rangeHeader != null) {
val totalStr = rangeHeader.split("/").lastOrNull()
val total = totalStr?.toIntOrNull()
if (total != null && total > 0) return@withContext total
}
} finally {
rangeConn.disconnect()
}
throw DuooomiBleError.TransferFailed("Cannot determine file size: $url")
}
// MARK: - Request-Response Pattern
private suspend fun sendAndWait(
commandType: CommandType,
timeoutMs: Long = 10_000,
send: suspend () -> Unit
): ByteArray {
return sendAndWait(
opId = "${commandType.value}",
command = commandType,
timeoutMs = timeoutMs,
send = send
)
}
private suspend fun sendAndWait(
opId: String,
command: CommandType,
timeoutMs: Long = 10_000,
send: suspend () -> Unit
): ByteArray {
BleLog.d("Register opId=$opId, cmd=$command", "RPC")
try {
return withTimeout(timeoutMs) {
coroutineScope {
val resultDeferred = async {
suspendCancellableCoroutine { continuation ->
pendingContinuations[opId] = continuation
continuation.invokeOnCancellation {
pendingContinuations.remove(opId)
}
BleLog.d("Continuation stored for opId=$opId", "RPC")
}
}
// Send command
send()
BleLog.d("Command sent for opId=$opId", "RPC")
// Wait for response
val result = resultDeferred.await()
BleLog.d("Result received for opId=$opId", "RPC")
result
}
}
} catch (e: Exception) {
pendingContinuations.remove(opId)?.let { stale ->
stale.cancel()
}
if (e is TimeoutCancellationException) {
BleLog.e("RPC timeout for opId=$opId", "RPC")
throw DuooomiBleError.Timeout(command)
}
BleLog.e("RPC failed for opId=$opId: ${e.message}", "RPC")
throw e
}
}
// MARK: - Message Listener
private fun startMessageListener() {
messageListenerJob?.cancel()
messageListenerJob = scope.launch {
BleLog.i("Message listener started", "RPC")
protocolService.incomingMessages.collect { (commandType, data) ->
BleLog.d("Incoming message: type=$commandType, size=${data.size}", "RPC")
handleIncomingMessage(commandType, data)
}
}
}
private fun handleIncomingMessage(commandType: Byte, data: ByteArray) {
val typeValue = commandType.toInt() and 0xFF
val opId = "$typeValue"
// Check for prepareTransfer with key-based opId
if (typeValue == CommandType.PREPARE_TRANSFER.value) {
try {
val json = parseJsonResponse(data)
val resp = PrepareTransferResponse.fromJson(json)
val keyedOpId = "${typeValue}_${resp.key}"
pendingContinuations.remove(keyedOpId)?.let { continuation ->
BleLog.d("Resuming keyed opId=$keyedOpId", "RPC")
continuation.resume(data, null)
return
}
} catch (_: Exception) { }
}
pendingContinuations.remove(opId)?.let { continuation ->
BleLog.d("Resuming opId=$opId", "RPC")
continuation.resume(data, null)
} ?: BleLog.w("No pending continuation for opId=$opId", "RPC")
}
// MARK: - Helpers
private fun ensureConnected() {
if (_connectedDevice.value == null) {
BleLog.w("Operation requires connection", "SDK")
throw DuooomiBleError.NotConnected
}
}
private fun parseJsonResponse(data: ByteArray): JSONObject {
// Device responses may contain null bytes
val cleaned = data.filter { it != 0.toByte() }.toByteArray()
val jsonStr = String(cleaned, Charsets.UTF_8)
return try {
JSONObject(jsonStr)
} catch (e: Exception) {
BleLog.e("JSON parse failed: ${e.message}\nRaw: $jsonStr", "Decode")
throw e
}
}
private fun cancelAllPending(error: Exception) {
val continuations = pendingContinuations.toMap()
pendingContinuations.clear()
for ((_, continuation) in continuations) {
continuation.cancel(CancellationException(error.message))
}
}
// MARK: - Scan Batching (500ms throttle)
private fun queueDevice(device: DiscoveredDevice) {
if (allDiscoveredDevices.any { it.id == device.id }) return
allDiscoveredDevices.add(device)
flushJob?.cancel()
flushJob = scope.launch {
delay(500)
flushDevices()
}
}
private fun flushDevices() {
if (allDiscoveredDevices.isEmpty()) return
_discoveredDevices.value = allDiscoveredDevices.toList()
BleLog.d("Discovered devices flushed: total=${_discoveredDevices.value.size}", "Scan")
}
}