diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..48e67a5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.gradle/
+build/
+local.properties
+*.iml
+.idea/
+.DS_Store
+sdk/build/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..322912b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,213 @@
+# Duooomi Android BLE SDK
+
+Duooomi 蓝牙 BLE SDK 的 Android 版本。
+
+## 环境要求
+
+| 项目 | 要求 |
+|------|------|
+| Android Studio | Hedgehog (2023.1.1) 或更高 |
+| Kotlin | 1.9.22+ |
+| AGP | 8.2.2+ |
+| Gradle | 8.x |
+| JDK | 17 |
+| minSdk | 26 (Android 8.0) |
+| compileSdk / targetSdk | 34 |
+
+## 项目结构
+
+```
+duooomi-android-sdk/
+├── sdk/ # SDK 库模块 (com.android.library)
+│ └── src/main/kotlin/com/duooomi/ble/
+│ ├── DuooomiBleSDK.kt # 公开 API 入口
+│ ├── DuooomiBleConfig.kt # SDK 配置
+│ ├── core/ # BLE 核心层 (BleClient, 类型定义, 错误类型)
+│ ├── protocol/ # BLE 协议层 (帧格式, UUID, 协议管理)
+│ ├── services/ # 服务层 (设备信息, 文件传输, ANI转换, 固件)
+│ ├── models/ # 数据模型
+│ └── utils/ # 日志工具
+├── demo/ # 演示 App (Jetpack Compose)
+└── build.gradle.kts
+```
+
+## 依赖
+
+SDK 仅依赖一个外部库:
+
+```
+org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
+```
+
+其余全部使用 Android 内置 API(BLE、JSON、HttpURLConnection)。
+
+## 快速开始
+
+### 1. 引入 SDK
+
+在 `settings.gradle.kts` 中:
+
+```kotlin
+include(":sdk")
+```
+
+在 app 模块的 `build.gradle.kts` 中:
+
+```kotlin
+dependencies {
+ implementation(project(":sdk"))
+}
+```
+
+### 2. 添加权限
+
+在 `AndroidManifest.xml` 中添加:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+```
+
+> **注意:** 运行时仍需动态申请 `BLUETOOTH_SCAN`、`BLUETOOTH_CONNECT`(Android 12+)和 `ACCESS_FINE_LOCATION` 权限。
+
+### 3. 初始化 SDK
+
+```kotlin
+val sdk = DuooomiBleSDK(
+ context = this,
+ config = DuooomiBleConfig(
+ apiKey = "YOUR_API_KEY"
+ )
+)
+```
+
+### 4. API Key 说明
+
+| 参数 | 说明 |
+|------|------|
+| `apiKey` | **必填**。用于 ANI 转换和固件查询等后端 API 鉴权。|
+| `apiHost` | 默认 `https://api.mixvideo.bowong.cc`,一般无需修改 |
+| `cdnHost` | 默认 `https://cdn.bowong.cc/`,一般无需修改 |
+| `firmwareIdentifier` | 默认 `duomi`,设备固件标识符 |
+| `firmwareStatus` | 默认 `DRAFT`,可选 `PUBLISHED` |
+
+## API 参考
+
+### 可观察状态 (StateFlow)
+
+```kotlin
+sdk.btState // ConnectionState: IDLE, SCANNING, CONNECTING, CONNECTED, ...
+sdk.discoveredDevices // List
+sdk.connectedDevice // DiscoveredDevice?
+sdk.deviceInfo // DeviceInfo?
+sdk.version // String
+sdk.isActivated // Boolean
+sdk.transferProgress // Int (0-100)
+sdk.error // String?
+```
+
+### 扫描与连接
+
+```kotlin
+// 开始扫描 (自动过滤 Duooomi 设备)
+sdk.scan()
+
+// 停止扫描
+sdk.stopScan()
+
+// 连接设备 (suspend)
+val device = sdk.connect(deviceId)
+
+// 断开连接 (suspend)
+sdk.disconnect()
+```
+
+### 设备操作 (均需先连接)
+
+```kotlin
+// 获取设备信息
+val info = sdk.getDeviceInfo()
+// info.brand, info.name, info.size, info.powerlevel, info.freespace, info.allspace
+
+// 获取固件版本
+val versionInfo = sdk.getVersion()
+// versionInfo.version, versionInfo.type
+
+// 绑定设备
+val bindResult = sdk.bind(userId)
+// bindResult.sn, bindResult.contents (设备文件列表)
+
+// 解绑设备
+sdk.unbind(userId)
+
+// 删除设备文件
+sdk.deleteFile(fileKey)
+```
+
+### 文件传输
+
+```kotlin
+// 传输媒体文件 (自动转换为 ANI 格式)
+// 支持: mp4, jpg, png, ani
+sdk.transferMedia(fileUrl)
+
+// 监听进度
+sdk.transferProgress.collect { percent -> /* 0-100 */ }
+```
+
+### 固件升级
+
+```kotlin
+// 查询最新固件
+val firmware = sdk.fetchLatestFirmware(identifier, status)
+// firmware?.version, firmware?.fileUrl, firmware?.fileSize
+
+// 执行 OTA 升级
+sdk.upgradeFirmware(firmware.fileUrl)
+```
+
+### 错误处理
+
+所有 suspend 方法在失败时抛出 `DuooomiBleError`:
+
+```kotlin
+try {
+ sdk.connect(deviceId)
+} catch (e: DuooomiBleError.BluetoothNotPoweredOn) { /* 蓝牙未开启 */ }
+ catch (e: DuooomiBleError.ConnectionFailed) { /* 连接失败 */ }
+ catch (e: DuooomiBleError.NotConnected) { /* 未连接 */ }
+ catch (e: DuooomiBleError.Timeout) { /* 超时 */ }
+ catch (e: DuooomiBleError.PermissionDenied) { /* 权限不足 */ }
+```
+
+## 运行 Demo
+
+```bash
+# 构建
+./gradlew :demo:assembleDebug
+
+# 安装到设备
+adb install demo/build/outputs/apk/debug/demo-debug.apk
+```
+
+## BLE 协议简述
+
+- **Service UUID:** `000002c4-0000-1000-8000-00805f9b34fb`
+- **Write Characteristic:** `000002c5-...`
+- **Read Characteristic (Notify):** `000002c6-...`
+- **帧格式:** `[head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1]`
+- **校验和:** 二进制补码 `(~sum + 1) & 0xFF`
+- **大数据分片:** 自动拆分,帧间延迟 35ms
+
+## License
+
+Proprietary - Duooomi
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..38f981e
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.library") version "8.2.2" apply false
+ id("com.android.application") version "8.2.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.22" apply false
+}
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
new file mode 100644
index 0000000..1df29fb
--- /dev/null
+++ b/demo/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.duooomi.ble.demo"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.duooomi.ble.demo"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(":sdk"))
+
+ // Compose
+ val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.activity:activity-compose:1.8.2")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+}
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3fcd9dd
--- /dev/null
+++ b/demo/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt
new file mode 100644
index 0000000..3cd6abb
--- /dev/null
+++ b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt
@@ -0,0 +1,453 @@
+package com.duooomi.ble.demo
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.duooomi.ble.DuooomiBleSDK
+import com.duooomi.ble.core.ConnectionState
+import com.duooomi.ble.models.FirmwareInfo
+import com.duooomi.ble.protocol.CommandType
+import kotlinx.coroutines.launch
+
+// MARK: - CDN Helper
+
+private object CDNHelper {
+ var cdnHost = "https://cdn.bowong.cc/"
+
+ fun ensureFullUrl(keyOrUrl: String): String {
+ if (keyOrUrl.startsWith("http://") || keyOrUrl.startsWith("https://")) return keyOrUrl
+ val key = keyOrUrl.removePrefix("/")
+ return cdnHost + key
+ }
+}
+
+// MARK: - Log
+
+private data class LogLine(
+ val id: Long,
+ val level: LogLevel,
+ val message: String
+)
+
+private enum class LogLevel { INFO, SUCCESS, ERROR }
+
+// MARK: - Screen
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DemoScreen(sdk: DuooomiBleSDK) {
+ val scope = rememberCoroutineScope()
+
+ // SDK state
+ val btState by sdk.btState.collectAsState()
+ val discoveredDevices by sdk.discoveredDevices.collectAsState()
+ val connectedDevice by sdk.connectedDevice.collectAsState()
+ val deviceInfo by sdk.deviceInfo.collectAsState()
+ val version by sdk.version.collectAsState()
+ val transferProgress by sdk.transferProgress.collectAsState()
+ val sdkError by sdk.error.collectAsState()
+
+ // Local state
+ var userId by remember { mutableStateOf("test-user-001") }
+ var fileUrl by remember { mutableStateOf("https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4") }
+ var firmwareBrand by remember { mutableStateOf(sdk.config.firmwareIdentifier) }
+ var firmwareStatus by remember { mutableStateOf(sdk.config.firmwareStatus) }
+ var firmwareInfo by remember { mutableStateOf(null) }
+ var firmwareLoading by remember { mutableStateOf(false) }
+ var isBusy by remember { mutableStateOf(false) }
+ var connectingDeviceId by remember { mutableStateOf(null) }
+ var deviceFiles by remember { mutableStateOf>(emptyList()) }
+ var logs by remember { mutableStateOf>(emptyList()) }
+ var logCounter by remember { mutableLongStateOf(0L) }
+
+ fun log(message: String, level: LogLevel = LogLevel.INFO) {
+ logCounter++
+ logs = listOf(LogLine(logCounter, level, message)) + logs.take(199)
+ }
+
+ LaunchedEffect(Unit) {
+ CDNHelper.cdnHost = sdk.config.normalizedCdnHost
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text("SDK Demo") })
+ }
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // === Status Section ===
+ item {
+ SectionHeader("Status")
+ StatusRow("BLE", btState.name)
+ StatusRow("Device", connectedDevice?.name ?: "--")
+ StatusRow("Version", version.ifEmpty { "--" })
+ if (transferProgress > 0) {
+ @Suppress("DEPRECATION")
+ LinearProgressIndicator(
+ progress = transferProgress / 100f,
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
+ )
+ Text("$transferProgress%", fontSize = 12.sp)
+ }
+ sdkError?.let {
+ Text(it, color = Color.Red, fontSize = 12.sp)
+ }
+ }
+
+ // === Scan & Connect Section ===
+ item {
+ SectionHeader("Scan & Connect")
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = {
+ sdk.scan()
+ log("-> scan()")
+ }) { Text("Scan") }
+
+ OutlinedButton(onClick = {
+ sdk.stopScan()
+ log("-> stopScan()")
+ }) { Text("Stop") }
+ }
+ }
+
+ items(discoveredDevices, key = { it.id }) { device ->
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.padding(12.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(device.name ?: "Unknown", fontWeight = FontWeight.Medium)
+ Text(device.id, fontSize = 10.sp, color = Color.Gray)
+ }
+ Text("${device.rssi} dBm", fontSize = 12.sp, color = Color.Gray)
+ Spacer(Modifier.width(8.dp))
+
+ if (connectedDevice?.id == device.id) {
+ OutlinedButton(
+ onClick = {
+ scope.launch {
+ log("-> disconnect")
+ try {
+ sdk.disconnect()
+ deviceFiles = emptyList()
+ log("disconnect OK", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("disconnect FAIL: ${e.message}", LogLevel.ERROR)
+ }
+ }
+ },
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red)
+ ) { Text("Disconnect") }
+ } else if (connectingDeviceId == device.id) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
+ } else {
+ OutlinedButton(
+ onClick = {
+ scope.launch {
+ connectingDeviceId = device.id
+ log("-> connect(${device.id})")
+ try {
+ val d = sdk.connect(device.id)
+ log("connect OK: ${d.name ?: d.id}", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("connect FAIL: ${e.message}", LogLevel.ERROR)
+ } finally {
+ connectingDeviceId = null
+ }
+ }
+ },
+ enabled = connectingDeviceId == null
+ ) { Text("Connect") }
+ }
+ }
+ }
+ }
+
+ // === Device Section ===
+ item {
+ SectionHeader("Device")
+ StatusRow("Brand", deviceInfo?.brand ?: "--")
+ StatusRow("Size", deviceInfo?.size ?: "--")
+ StatusRow("Power", deviceInfo?.let { "${it.powerlevel}%" } ?: "--")
+ StatusRow("Storage", deviceInfo?.let { "${it.freespace}/${it.allspace}" } ?: "--")
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = {
+ scope.launch {
+ isBusy = true
+ log("-> getDeviceInfo")
+ try {
+ val info = sdk.getDeviceInfo()
+ log("getDeviceInfo OK: brand=${info.brand} power=${info.powerlevel}%", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("getDeviceInfo FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ }) { Text("getDeviceInfo") }
+
+ OutlinedButton(onClick = {
+ scope.launch {
+ isBusy = true
+ log("-> getVersion")
+ try {
+ val info = sdk.getVersion()
+ log("getVersion OK: ${info.version}", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("getVersion FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ }) { Text("getVersion") }
+ }
+
+ Spacer(Modifier.height(4.dp))
+
+ OutlinedTextField(
+ value = userId,
+ onValueChange = { userId = it },
+ label = { Text("userId") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = {
+ scope.launch {
+ isBusy = true
+ log("-> bind($userId)")
+ try {
+ val result = sdk.bind(userId)
+ log("bind OK: sn=${result.sn} contents=${result.contents.size}", LogLevel.SUCCESS)
+ deviceFiles = result.contents
+ } catch (e: Exception) {
+ log("bind FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ }) { Text("bind") }
+
+ OutlinedButton(onClick = {
+ scope.launch {
+ isBusy = true
+ log("-> unbind($userId)")
+ try {
+ sdk.unbind(userId)
+ deviceFiles = emptyList()
+ log("unbind OK", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("unbind FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ }) { Text("unbind") }
+ }
+ }
+
+ // === Transfer Section ===
+ item {
+ SectionHeader("Transfer")
+ OutlinedTextField(
+ value = fileUrl,
+ onValueChange = { fileUrl = it },
+ label = { Text("File URL (mp4/jpg/png/ani)") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ Button(
+ onClick = {
+ scope.launch {
+ isBusy = true
+ log("-> transferMedia($fileUrl)")
+ try {
+ sdk.transferMedia(fileUrl)
+ log("transfer OK", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("transfer FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ },
+ enabled = fileUrl.isNotEmpty() && connectedDevice != null && !isBusy
+ ) { Text("Transfer") }
+ }
+
+ // === Firmware Section ===
+ item {
+ SectionHeader("Firmware Update")
+ StatusRow("Current Version", version.ifEmpty { "--" })
+
+ OutlinedTextField(
+ value = firmwareBrand,
+ onValueChange = { firmwareBrand = it },
+ label = { Text("Identifier") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("DRAFT", "PUBLISHED").forEach { status ->
+ FilterChip(
+ selected = firmwareStatus == status,
+ onClick = { firmwareStatus = status },
+ label = { Text(status) }
+ )
+ }
+ }
+
+ firmwareInfo?.let { info ->
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ StatusRow("Latest", info.version)
+ info.fileSize?.let { StatusRow("Size", "$it bytes") }
+ info.fileMd5?.takeIf { it.isNotEmpty() }?.let {
+ Text("MD5: $it", fontSize = 10.sp, color = Color.Gray)
+ }
+ info.description?.takeIf { it.isNotEmpty() }?.let {
+ Text(it, fontSize = 12.sp, color = Color.Gray)
+ }
+ Text(info.fileUrl, fontSize = 10.sp, color = Color.Gray, maxLines = 2)
+ }
+ }
+ }
+
+ if (firmwareLoading) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
+ Spacer(Modifier.width(8.dp))
+ Text("Fetching...", fontSize = 12.sp, color = Color.Gray)
+ }
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(
+ onClick = {
+ scope.launch {
+ firmwareLoading = true
+ log("-> fetchLatestFirmware($firmwareBrand, $firmwareStatus)")
+ try {
+ val info = sdk.fetchLatestFirmware(firmwareBrand, firmwareStatus)
+ firmwareInfo = info
+ if (info != null) {
+ log("firmware latest: ${info.version}", LogLevel.SUCCESS)
+ } else {
+ log("no firmware available")
+ }
+ } catch (e: Exception) {
+ log("firmware info FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { firmwareLoading = false }
+ }
+ },
+ enabled = !firmwareLoading && firmwareBrand.isNotEmpty()
+ ) { Text("Get Update Info") }
+
+ Button(
+ onClick = {
+ scope.launch {
+ val info = firmwareInfo ?: return@launch
+ isBusy = true
+ log("-> upgradeFirmware ${info.version}")
+ try {
+ sdk.upgradeFirmware(info.fileUrl)
+ log("OTA OK", LogLevel.SUCCESS)
+ } catch (e: Exception) {
+ log("OTA FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ },
+ enabled = firmwareInfo?.fileUrl?.isNotEmpty() == true && connectedDevice != null && !isBusy
+ ) { Text("Upgrade") }
+ }
+ }
+
+ // === Device Files Section ===
+ item {
+ SectionHeader("Device Files (${deviceFiles.size})")
+ if (deviceFiles.isEmpty()) {
+ Text("Bind to see device files", fontSize = 12.sp, color = Color.Gray)
+ }
+ }
+
+ items(deviceFiles) { rawKey ->
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.padding(12.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(rawKey, fontSize = 11.sp, modifier = Modifier.weight(1f), maxLines = 2)
+ TextButton(
+ onClick = {
+ scope.launch {
+ isBusy = true
+ log("-> deleteFile($rawKey)")
+ try {
+ sdk.deleteFile(rawKey)
+ log("deleteFile OK", LogLevel.SUCCESS)
+ deviceFiles = deviceFiles.filter { it != rawKey }
+ } catch (e: Exception) {
+ log("deleteFile FAIL: ${e.message}", LogLevel.ERROR)
+ } finally { isBusy = false }
+ }
+ },
+ enabled = !isBusy,
+ colors = ButtonDefaults.textButtonColors(contentColor = Color.Red)
+ ) { Text("Delete") }
+ }
+ }
+ }
+
+ // === Log Section ===
+ item {
+ SectionHeader("Log (${logs.size})")
+ }
+
+ items(logs.take(80), key = { it.id }) { line ->
+ Text(
+ text = line.message,
+ fontSize = 11.sp,
+ color = when (line.level) {
+ LogLevel.ERROR -> Color.Red
+ LogLevel.SUCCESS -> Color(0xFF2E7D32)
+ LogLevel.INFO -> Color.Unspecified
+ }
+ )
+ }
+
+ item { Spacer(Modifier.height(32.dp)) }
+ }
+ }
+}
+
+// MARK: - Components
+
+@Composable
+private fun SectionHeader(title: String) {
+ Text(
+ text = title,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
+ )
+ Divider()
+}
+
+@Composable
+private fun StatusRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(label, fontSize = 13.sp, color = Color.Gray)
+ Text(value, fontSize = 13.sp)
+ }
+}
diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt
new file mode 100644
index 0000000..5cd514c
--- /dev/null
+++ b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt
@@ -0,0 +1,61 @@
+package com.duooomi.ble.demo
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import androidx.core.content.ContextCompat
+import com.duooomi.ble.DuooomiBleConfig
+import com.duooomi.ble.DuooomiBleSDK
+
+class MainActivity : ComponentActivity() {
+
+ private lateinit var sdk: DuooomiBleSDK
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { /* permissions granted or denied — SDK handles errors */ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ sdk = DuooomiBleSDK(
+ context = this,
+ config = DuooomiBleConfig(apiKey = "")
+ )
+
+ requestBlePermissions()
+
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ DemoScreen(sdk = sdk)
+ }
+ }
+ }
+ }
+
+ private fun requestBlePermissions() {
+ val permissions = mutableListOf()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ permissions.add(Manifest.permission.BLUETOOTH_SCAN)
+ permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
+ permissions.add(Manifest.permission.ACCESS_COARSE_LOCATION)
+
+ val needed = permissions.filter {
+ ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
+ }
+ if (needed.isNotEmpty()) {
+ permissionLauncher.launch(needed.toTypedArray())
+ }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..32a0c14
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d64cd49
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..05e3f09
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,124 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+# Gradle start up script for POSIX generated by "Gradle 8.5"
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #( absolute
+ *) app_path=$APP_HOME$link ;; #( relative
+ esac
+done
+
+# This is reliable as long as the user does not change working directory during the script's execution.
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ ;;
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..2b49b8c
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /C_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts
new file mode 100644
index 0000000..827ead2
--- /dev/null
+++ b/sdk/build.gradle.kts
@@ -0,0 +1,27 @@
+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")
+}
diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fc0a1f5
--- /dev/null
+++ b/sdk/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt
new file mode 100644
index 0000000..5fd43e7
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt
@@ -0,0 +1,12 @@
+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"
+) {
+ val normalizedCdnHost: String
+ get() = if (cdnHost.endsWith("/")) cdnHost else "$cdnHost/"
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt
new file mode 100644
index 0000000..9e05919
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt
@@ -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 = _btState.asStateFlow()
+
+ private val _discoveredDevices = MutableStateFlow>(emptyList())
+ val discoveredDevices: StateFlow> = _discoveredDevices.asStateFlow()
+
+ private val _connectedDevice = MutableStateFlow(null)
+ val connectedDevice: StateFlow = _connectedDevice.asStateFlow()
+
+ private val _deviceInfo = MutableStateFlow(null)
+ val deviceInfo: StateFlow = _deviceInfo.asStateFlow()
+
+ private val _version = MutableStateFlow("")
+ val version: StateFlow = _version.asStateFlow()
+
+ private val _isActivated = MutableStateFlow(false)
+ val isActivated: StateFlow = _isActivated.asStateFlow()
+
+ private val _transferProgress = MutableStateFlow(0)
+ val transferProgress: StateFlow = _transferProgress.asStateFlow()
+
+ private val _error = MutableStateFlow(null)
+ val error: StateFlow = _error.asStateFlow()
+
+ // 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>()
+ 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()
+ 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 {
+ 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")
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt b/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt
new file mode 100644
index 0000000..ae637a2
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt
@@ -0,0 +1,329 @@
+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 kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Android BLE wrapper using coroutines.
+ * Mirrors iOS BleClient 1:1.
+ */
+@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 connectedDeviceId: String? = null
+ private set
+ private var writeCharacteristic: BluetoothGattCharacteristic? = null
+ private var readCharacteristic: BluetoothGattCharacteristic? = null
+
+ private val writeMutex = Mutex()
+
+ // Continuations for connect/disconnect
+ private var connectContinuation: CancellableContinuation? = null
+ private var disconnectContinuation: CancellableContinuation? = null
+
+ // Notification flow
+ private val _notifications = MutableSharedFlow(extraBufferCapacity = 64)
+ val notifications: SharedFlow = _notifications
+
+ // Callbacks
+ var onDisconnected: (() -> Unit)? = null
+
+ // Scan state
+ private var scanCallback: ScanCallback? = null
+
+ val bluetoothState: Int
+ get() = bluetoothAdapter?.state ?: BluetoothAdapter.STATE_OFF
+
+ val isBluetoothEnabled: Boolean
+ get() = bluetoothAdapter?.isEnabled == true
+
+ // MARK: - Scan
+
+ fun scan(): Flow = callbackFlow {
+ val scanner = bluetoothAdapter?.bluetoothLeScanner
+ ?: throw DuooomiBleError.BluetoothNotPoweredOn("Adapter unavailable")
+
+ if (!isBluetoothEnabled) {
+ throw DuooomiBleError.BluetoothNotPoweredOn("Bluetooth is off")
+ }
+
+ val callback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ val device = DiscoveredDevice(
+ id = result.device.address,
+ name = result.device.name ?: result.scanRecord?.deviceName,
+ rssi = result.rssi
+ )
+ BleLog.d("Did discover: id=${device.id}, rssi=${device.rssi}", "BLE")
+ trySend(device)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ BleLog.e("Scan failed: errorCode=$errorCode", "BLE")
+ close(DuooomiBleError.BluetoothNotPoweredOn("Scan failed: $errorCode"))
+ }
+ }
+ scanCallback = callback
+
+ val filters = listOf(
+ ScanFilter.Builder()
+ .setServiceUuid(ParcelUuid(BleUUIDs.SERVICE))
+ .build()
+ )
+ val settings = ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .build()
+
+ BleLog.i("CBCentral startScan", "BLE")
+ try {
+ scanner.startScan(filters, settings, callback)
+ } catch (e: SecurityException) {
+ BleLog.e("Scan permission denied: ${e.message}", "BLE")
+ close(DuooomiBleError.PermissionDenied)
+ return@callbackFlow
+ }
+
+ awaitClose {
+ BleLog.i("CBCentral stopScan", "BLE")
+ scanner.stopScan(callback)
+ scanCallback = null
+ }
+ }
+
+ fun stopScan() {
+ try {
+ val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return
+ scanCallback?.let {
+ scanner.stopScan(it)
+ scanCallback = null
+ BleLog.i("CBCentral stopScan", "BLE")
+ }
+ } catch (e: SecurityException) {
+ BleLog.w("stopScan permission error: ${e.message}", "BLE")
+ scanCallback = null
+ } catch (_: Exception) {
+ scanCallback = null
+ }
+ }
+
+ // MARK: - Connect
+
+ suspend fun connect(deviceId: String, timeoutMs: Long = 30_000): BluetoothGatt {
+ if (!isBluetoothEnabled) {
+ throw DuooomiBleError.BluetoothNotPoweredOn("Bluetooth is off")
+ }
+
+ val device = bluetoothAdapter?.getRemoteDevice(deviceId)
+ ?: throw DuooomiBleError.ConnectionFailed("Device not found: $deviceId")
+
+ return withTimeout(timeoutMs) {
+ suspendCancellableCoroutine { continuation ->
+ connectContinuation = continuation
+ continuation.invokeOnCancellation {
+ connectContinuation = null
+ connectedGatt?.disconnect()
+ BleLog.w("Connection cancelled: $deviceId", "BLE")
+ }
+
+ BleLog.i("Connecting to peripheral: $deviceId", "BLE")
+ device.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
+ }
+ }
+ }
+
+ // MARK: - Disconnect
+
+ suspend fun disconnect() {
+ val gatt = connectedGatt ?: return
+
+ try {
+ withTimeout(5_000) {
+ suspendCancellableCoroutine { continuation ->
+ disconnectContinuation = continuation
+ continuation.invokeOnCancellation {
+ disconnectContinuation = null
+ cleanup()
+ }
+
+ BleLog.i("Cancel connection: ${gatt.device.address}", "BLE")
+ gatt.disconnect()
+ }
+ }
+ } catch (_: TimeoutCancellationException) {
+ BleLog.w("Force-finish disconnect after timeout", "BLE")
+ cleanup()
+ }
+ }
+
+ // MARK: - Write
+
+ suspend fun writeWithoutResponse(data: ByteArray) {
+ val gatt = connectedGatt ?: throw DuooomiBleError.NotConnected
+ val characteristic = writeCharacteristic ?: throw DuooomiBleError.NotConnected
+
+ writeMutex.withLock {
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
+ characteristic.value = data
+ val success = gatt.writeCharacteristic(characteristic)
+ if (!success) {
+ BleLog.w("Write failed, len=${data.size}", "BLE")
+ } else {
+ BleLog.d("Write w/o response, len=${data.size}", "BLE")
+ }
+ }
+ }
+
+ // MARK: - Connected Devices
+
+ fun retrieveConnectedDevices(): List {
+ val peripherals = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
+ return peripherals.map { device ->
+ DiscoveredDevice(
+ id = device.address,
+ name = device.name,
+ rssi = 0
+ )
+ }
+ }
+
+ // MARK: - Private
+
+ private fun cleanup() {
+ connectedGatt?.close()
+ connectedGatt = null
+ connectedDeviceId = null
+ writeCharacteristic = null
+ readCharacteristic = null
+ BleLog.i("Client state cleaned up", "BLE")
+ }
+
+ // MARK: - GATT Callback
+
+ private val gattCallback = object : BluetoothGattCallback() {
+
+ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
+ when (newState) {
+ BluetoothProfile.STATE_CONNECTED -> {
+ BleLog.i("Did connect: ${gatt.device.address}", "BLE")
+ // Request MTU 512 for throughput
+ gatt.requestMtu(512)
+ }
+
+ BluetoothProfile.STATE_DISCONNECTED -> {
+ // If disconnect happens mid-setup, resolve connect continuation
+ connectContinuation?.let { cont ->
+ connectContinuation = null
+ cont.resumeWithException(
+ DuooomiBleError.ConnectionFailed("Disconnected during setup")
+ )
+ }
+
+ val wasConnected = connectedGatt != null
+ cleanup()
+
+ disconnectContinuation?.let { cont ->
+ disconnectContinuation = null
+ cont.resume(Unit)
+ }
+
+ BleLog.w("Did disconnect: ${gatt.device.address}, status=$status", "BLE")
+ if (wasConnected) {
+ onDisconnected?.invoke()
+ }
+ }
+ }
+ }
+
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
+ BleLog.d("MTU changed: $mtu, status=$status", "BLE")
+ // After MTU negotiation, discover services
+ gatt.discoverServices()
+ }
+
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ val cont = connectContinuation
+ connectContinuation = null
+ BleLog.e("Discover services failed: status=$status", "BLE")
+ cont?.resumeWithException(
+ DuooomiBleError.ConnectionFailed("Service discovery failed: $status")
+ )
+ return
+ }
+
+ val service = gatt.getService(BleUUIDs.SERVICE)
+ if (service == null) {
+ val cont = connectContinuation
+ connectContinuation = null
+ BleLog.e("Service not found", "BLE")
+ cont?.resumeWithException(DuooomiBleError.ServiceNotFound)
+ return
+ }
+
+ BleLog.d("Service found: ${service.uuid}", "BLE")
+
+ writeCharacteristic = service.getCharacteristic(BleUUIDs.WRITE_CHARACTERISTIC)
+ readCharacteristic = service.getCharacteristic(BleUUIDs.READ_CHARACTERISTIC)
+
+ if (writeCharacteristic == null || readCharacteristic == null) {
+ val cont = connectContinuation
+ connectContinuation = null
+ BleLog.e("Required characteristics missing", "BLE")
+ cont?.resumeWithException(DuooomiBleError.CharacteristicNotFound)
+ return
+ }
+
+ // Enable notifications on read characteristic
+ val readChar = readCharacteristic!!
+ gatt.setCharacteristicNotification(readChar, true)
+ val descriptor = readChar.getDescriptor(
+ java.util.UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+ )
+ if (descriptor != null) {
+ descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ gatt.writeDescriptor(descriptor)
+ }
+
+ // Connection fully ready
+ connectedGatt = gatt
+ connectedDeviceId = gatt.device.address
+ val cont = connectContinuation
+ connectContinuation = null
+ BleLog.i("Characteristics ready; connection established", "BLE")
+ cont?.resume(gatt)
+ }
+
+ @Suppress("DEPRECATION")
+ 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())
+ }
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt b/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt
new file mode 100644
index 0000000..8eecfd1
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt
@@ -0,0 +1,29 @@
+package com.duooomi.ble.core
+
+import com.duooomi.ble.protocol.CommandType
+
+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")
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/BindingResponse.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/BindingResponse.kt
new file mode 100644
index 0000000..8137676
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/BindingResponse.kt
@@ -0,0 +1,28 @@
+package com.duooomi.ble.models
+
+import org.json.JSONObject
+
+data class BindingResponse(
+ val type: Int,
+ val sn: String,
+ val success: Int,
+ val contents: List
+) {
+ companion object {
+ fun fromJson(json: JSONObject): BindingResponse {
+ val contentsArray = json.optJSONArray("contents")
+ val contents = mutableListOf()
+ if (contentsArray != null) {
+ for (i in 0 until contentsArray.length()) {
+ contents.add(contentsArray.getString(i))
+ }
+ }
+ return BindingResponse(
+ type = json.optInt("type", 0),
+ sn = json.optString("sn", ""),
+ success = json.optInt("success", 0),
+ contents = contents
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/DeleteFileResponse.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/DeleteFileResponse.kt
new file mode 100644
index 0000000..5ee5e75
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/DeleteFileResponse.kt
@@ -0,0 +1,18 @@
+package com.duooomi.ble.models
+
+import org.json.JSONObject
+
+data class DeleteFileResponse(
+ val type: Int,
+ /** 0 = success, 1 = failed, 2 = file not found */
+ val success: Int
+) {
+ companion object {
+ fun fromJson(json: JSONObject): DeleteFileResponse {
+ return DeleteFileResponse(
+ type = json.optInt("type", 0),
+ success = json.optInt("success", 0)
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt
new file mode 100644
index 0000000..e54ebc2
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt
@@ -0,0 +1,25 @@
+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)
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt
new file mode 100644
index 0000000..7b4ccfb
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt
@@ -0,0 +1,27 @@
+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").ifEmpty { null },
+ fileSize = json.optString("fileSize").ifEmpty { null },
+ fileMd5 = json.optString("fileMd5").ifEmpty { null },
+ identifier = json.optString("identifier").ifEmpty { null },
+ status = json.optString("status").ifEmpty { null }
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/PrepareTransferResponse.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/PrepareTransferResponse.kt
new file mode 100644
index 0000000..28096f3
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/PrepareTransferResponse.kt
@@ -0,0 +1,20 @@
+package com.duooomi.ble.models
+
+import org.json.JSONObject
+
+data class PrepareTransferResponse(
+ val type: Int,
+ val key: String,
+ /** "ready" | "no_space" | "duplicated" */
+ 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", "")
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/UnbindResponse.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/UnbindResponse.kt
new file mode 100644
index 0000000..d5b3b81
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/UnbindResponse.kt
@@ -0,0 +1,15 @@
+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)
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt b/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt
new file mode 100644
index 0000000..5288812
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt
@@ -0,0 +1,22 @@
+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.getInt("type").toString()
+ else -> ""
+ }
+ return VersionInfo(
+ version = json.optString("version", ""),
+ type = typeValue
+ )
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolConstants.kt b/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolConstants.kt
new file mode 100644
index 0000000..3fe0cab
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolConstants.kt
@@ -0,0 +1,44 @@
+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 }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolFrame.kt b/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolFrame.kt
new file mode 100644
index 0000000..f89f8eb
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolFrame.kt
@@ -0,0 +1,35 @@
+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
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt b/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt
new file mode 100644
index 0000000..c954f2c
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt
@@ -0,0 +1,159 @@
+package com.duooomi.ble.protocol
+
+object ProtocolManager {
+
+ // MARK: - Checksum
+
+ /**
+ * Checksum = (~sum + 1) & 0xFF (two's complement, low 8 bits)
+ */
+ fun calculateChecksum(data: ByteArray, offset: Int = 0, length: Int = data.size): Byte {
+ var sum = 0
+ for (i in offset until offset + length) {
+ sum += data[i].toInt() and 0xFF
+ }
+ return ((sum.inv() + 1) and 0xFF).toByte()
+ }
+
+ fun verifyChecksum(data: ByteArray, offset: Int = 0, length: Int, expected: Byte): Boolean {
+ return calculateChecksum(data, offset, length) == expected
+ }
+
+ // MARK: - Frame Creation
+
+ /**
+ * Auto-split data into single or fragmented frames.
+ * Returns empty list if payload exceeds 65535-page limit.
+ */
+ fun createFrames(
+ type: Byte,
+ data: ByteArray,
+ head: FrameHead = FrameHead.APP_TO_DEVICE,
+ maxDataSize: Int = FrameConstants.MAX_DATA_SIZE
+ ): List {
+ val maxChunk = maxOf(1, maxDataSize)
+ val totalPages = (data.size + maxChunk - 1) / maxChunk
+ if (totalPages > 65535) return emptyList()
+
+ return if (data.size <= maxDataSize) {
+ listOf(createSingleFrame(type, data, head))
+ } else {
+ createFragmentedFrames(type, data, head, maxChunk)
+ }
+ }
+
+ private fun createSingleFrame(type: Byte, data: ByteArray, head: FrameHead): ByteArray {
+ val totalSize = FrameConstants.HEADER_SIZE + data.size + FrameConstants.FOOTER_SIZE
+ val buffer = ByteArray(totalSize)
+ var pos = 0
+
+ // Header
+ buffer[pos++] = head.byte
+ buffer[pos++] = type
+ buffer[pos++] = 0 // subpageTotal high
+ buffer[pos++] = 0 // subpageTotal low
+ buffer[pos++] = 0 // curPage high
+ buffer[pos++] = 0 // curPage low
+ buffer[pos++] = ((data.size shr 8) and 0xFF).toByte() // dataLen high
+ buffer[pos++] = (data.size and 0xFF).toByte() // dataLen low
+
+ // Data
+ data.copyInto(buffer, pos)
+ pos += data.size
+
+ // Checksum (on everything before checksum)
+ buffer[pos] = calculateChecksum(buffer, 0, pos)
+
+ return buffer
+ }
+
+ private fun createFragmentedFrames(
+ type: Byte,
+ data: ByteArray,
+ head: FrameHead,
+ maxDataSize: Int
+ ): List {
+ val totalPages = (data.size + maxDataSize - 1) / maxDataSize
+ val frames = mutableListOf()
+
+ for (i in 0 until totalPages) {
+ val start = i * maxDataSize
+ val end = minOf(start + maxDataSize, data.size)
+ val chunkSize = end - start
+
+ val frameSize = FrameConstants.HEADER_SIZE + chunkSize + FrameConstants.FOOTER_SIZE
+ val buffer = ByteArray(frameSize)
+ var pos = 0
+
+ buffer[pos++] = head.byte
+ buffer[pos++] = type
+
+ // subpageTotal (big-endian UInt16)
+ buffer[pos++] = ((totalPages shr 8) and 0xFF).toByte()
+ buffer[pos++] = (totalPages and 0xFF).toByte()
+
+ // curPage: from (total-1) counting down to 0
+ val curPageVal = totalPages - 1 - i
+ buffer[pos++] = ((curPageVal shr 8) and 0xFF).toByte()
+ buffer[pos++] = (curPageVal and 0xFF).toByte()
+
+ // dataLen
+ buffer[pos++] = ((chunkSize shr 8) and 0xFF).toByte()
+ buffer[pos++] = (chunkSize and 0xFF).toByte()
+
+ // data
+ data.copyInto(buffer, pos, start, end)
+ pos += chunkSize
+
+ // checksum
+ buffer[pos] = calculateChecksum(buffer, 0, pos)
+
+ frames.add(buffer)
+ }
+
+ return frames
+ }
+
+ // MARK: - Frame Parsing
+
+ /**
+ * Parse raw bytes into a ProtocolFrame. Returns null if invalid.
+ */
+ fun parseFrame(data: ByteArray): ProtocolFrame? {
+ val minSize = FrameConstants.HEADER_SIZE + FrameConstants.FOOTER_SIZE
+ if (data.size < minSize) return null
+
+ val head = data[0]
+ if (head != FrameHead.DEVICE_TO_APP.byte && head != FrameHead.APP_TO_DEVICE.byte) {
+ return null
+ }
+
+ val type = data[1]
+ val subpageTotal = ((data[2].toInt() and 0xFF) shl 8) or (data[3].toInt() and 0xFF)
+ val curPage = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
+ val dataLen = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
+
+ val expectedSize = FrameConstants.HEADER_SIZE + dataLen + FrameConstants.FOOTER_SIZE
+ if (data.size < expectedSize) return null
+
+ val dataStart = FrameConstants.HEADER_SIZE
+ val dataEnd = dataStart + dataLen
+ val frameData = data.copyOfRange(dataStart, dataEnd)
+ val checksum = data[dataEnd]
+
+ // Verify checksum on everything before checksum byte
+ if (!verifyChecksum(data, 0, dataEnd, checksum)) {
+ return null
+ }
+
+ return ProtocolFrame(
+ head = head,
+ type = type,
+ subpageTotal = subpageTotal,
+ curPage = curPage,
+ dataLen = dataLen,
+ data = frameData,
+ checksum = checksum
+ )
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt
new file mode 100644
index 0000000..dfd9f9e
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt
@@ -0,0 +1,79 @@
+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
+
+internal class AniConverter(private val config: DuooomiBleConfig) {
+
+ private val endpoint: String
+ get() = "${config.apiHost}/api/auth/loomart/file/convert-to-ani"
+
+ suspend fun convert(fileUrl: String): String {
+ return try {
+ performConvert(fileUrl)
+ } catch (e: java.net.SocketException) {
+ // Retry once on connection lost
+ BleLog.w("Connection lost, retrying ANI conversion", "ANI")
+ performConvert(fileUrl)
+ }
+ }
+
+ private suspend fun performConvert(fileUrl: String): String = withContext(Dispatchers.IO) {
+ val connection = URL(endpoint).openConnection() as HttpURLConnection
+ try {
+ connection.requestMethod = "POST"
+ connection.setRequestProperty("content-type", "application/json")
+ connection.setRequestProperty("x-api-key", config.apiKey)
+ connection.connectTimeout = 120_000
+ connection.readTimeout = 120_000
+ connection.doOutput = true
+
+ val body = JSONObject().apply {
+ put("videoUrl", fileUrl)
+ put("width", 360)
+ put("height", 360)
+ put("fps", "24")
+ }
+
+ connection.outputStream.use { os ->
+ os.write(body.toString().toByteArray(Charsets.UTF_8))
+ }
+
+ val code = connection.responseCode
+ if (code !in 200..299) {
+ throw AniConverterException("HTTP $code")
+ }
+
+ val responseText = connection.inputStream.bufferedReader().readText()
+ val json = JSONObject(responseText)
+
+ if (json.optBoolean("success") != true) {
+ throw AniConverterException("ANI API reported failure")
+ }
+
+ val outer = json.optJSONObject("data")
+ ?: throw AniConverterException("ANI API reported failure")
+
+ if (outer.optBoolean("status") != true) {
+ val msg = outer.optString("msg", "ANI conversion failed")
+ throw AniConverterException(msg)
+ }
+
+ val aniUrl = outer.optString("data", "")
+ if (aniUrl.isEmpty()) {
+ throw AniConverterException("Missing ani url in response")
+ }
+
+ aniUrl
+ } finally {
+ connection.disconnect()
+ }
+ }
+}
+
+internal class AniConverterException(message: String) : Exception(message)
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/BleProtocolService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/BleProtocolService.kt
new file mode 100644
index 0000000..84aebbe
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/services/BleProtocolService.kt
@@ -0,0 +1,127 @@
+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
+
+internal class BleProtocolService(private val client: BleClient) {
+
+ private data class FragmentSession(
+ val total: Int,
+ val frames: MutableMap = mutableMapOf()
+ )
+
+ private val fragments = mutableMapOf()
+ private var listenerJob: Job? = null
+
+ private val _incomingMessages = MutableSharedFlow>(extraBufferCapacity = 16)
+ val incomingMessages: SharedFlow> = _incomingMessages
+
+ // MARK: - Start/Stop Listening
+
+ fun startListening(scope: CoroutineScope) {
+ fragments.clear()
+
+ listenerJob = scope.launch(Dispatchers.Default) {
+ 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 maxDataSize = FrameConstants.MAX_DATA_SIZE
+ val safeMaxDataSize = maxOf(1, maxDataSize)
+
+ val frames = ProtocolManager.createFrames(
+ type = type,
+ data = data,
+ head = FrameHead.APP_TO_DEVICE,
+ maxDataSize = safeMaxDataSize
+ )
+
+ 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=$safeMaxDataSize", "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 = type.byte, data = 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 received: type=${frame.type}, len=${frame.data.size}", "Protocol")
+ _incomingMessages.tryEmit(Pair(frame.type, frame.data))
+ }
+ }
+
+ private fun handleFragment(frame: ProtocolFrame) {
+ val deviceKey = client.connectedDeviceId ?: "unknown"
+ val key = "${deviceKey}_${frame.type}"
+
+ if (fragments[key] == null) {
+ 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
+
+ // Check if all pages received
+ 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 { part ->
+ combined += part
+ }
+ }
+
+ fragments.remove(key)
+ BleLog.i("Fragment reassembled: key=$key, size=${combined.size}", "Protocol")
+ _incomingMessages.tryEmit(Pair(frame.type, combined))
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt
new file mode 100644
index 0000000..58f0987
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/services/DeviceInfoService.kt
@@ -0,0 +1,58 @@
+package com.duooomi.ble.services
+
+import com.duooomi.ble.protocol.CommandType
+import org.json.JSONObject
+
+internal class DeviceInfoService(private val protocolService: BleProtocolService) {
+
+ suspend fun getDeviceInfo() {
+ protocolService.sendJSON(
+ type = CommandType.GET_DEVICE_INFO,
+ payload = JSONObject().put("type", CommandType.GET_DEVICE_INFO.value)
+ )
+ }
+
+ suspend fun getDeviceVersion() {
+ protocolService.sendJSON(
+ type = CommandType.GET_DEVICE_VERSION,
+ payload = JSONObject().put("type", CommandType.GET_DEVICE_VERSION.value)
+ )
+ }
+
+ suspend fun bindDevice(userId: String) {
+ protocolService.sendJSON(
+ type = CommandType.BIND_DEVICE,
+ payload = JSONObject()
+ .put("type", CommandType.BIND_DEVICE.value)
+ .put("userId", userId)
+ )
+ }
+
+ suspend fun unbindDevice(userId: String) {
+ protocolService.sendJSON(
+ type = CommandType.UNBIND_DEVICE,
+ payload = JSONObject()
+ .put("type", CommandType.UNBIND_DEVICE.value)
+ .put("userId", userId)
+ )
+ }
+
+ suspend fun deleteFile(key: String) {
+ protocolService.sendJSON(
+ type = CommandType.DELETE_FILE,
+ payload = JSONObject()
+ .put("type", CommandType.DELETE_FILE.value)
+ .put("key", key)
+ )
+ }
+
+ suspend fun prepareTransfer(key: String, size: Int) {
+ protocolService.sendJSON(
+ type = CommandType.PREPARE_TRANSFER,
+ payload = JSONObject()
+ .put("type", CommandType.PREPARE_TRANSFER.value)
+ .put("key", key)
+ .put("size", size)
+ )
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/FileTransferService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/FileTransferService.kt
new file mode 100644
index 0000000..34198b5
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/services/FileTransferService.kt
@@ -0,0 +1,54 @@
+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
+
+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://")) {
+ val path = uri.removePrefix("file://")
+ File(path).readBytes()
+ } else if (uri.startsWith("http://") || uri.startsWith("https://")) {
+ val connection = URL(uri).openConnection() as HttpURLConnection
+ try {
+ connection.requestMethod = "GET"
+ connection.connectTimeout = 30_000
+ connection.readTimeout = 60_000
+ connection.connect()
+
+ val code = connection.responseCode
+ if (code !in 200..299) {
+ throw DuooomiBleError.TransferFailed("Failed to download file: HTTP $code")
+ }
+ connection.inputStream.readBytes()
+ } finally {
+ connection.disconnect()
+ }
+ } else {
+ throw DuooomiBleError.TransferFailed("Invalid URL: $uri")
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt b/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt
new file mode 100644
index 0000000..a6c539e
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/services/FirmwareService.kt
@@ -0,0 +1,59 @@
+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
+
+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 encodedStatus = URLEncoder.encode(st, "UTF-8")
+ val urlStr = "${config.apiHost}/$latestPath?identifier=$encodedId&status=$encodedStatus"
+
+ val connection = URL(urlStr).openConnection() as HttpURLConnection
+ try {
+ connection.requestMethod = "GET"
+ connection.setRequestProperty("x-api-key", config.apiKey)
+ connection.setRequestProperty("accept", "application/json")
+ connection.connectTimeout = 30_000
+ connection.readTimeout = 30_000
+
+ val code = connection.responseCode
+ if (code !in 200..299) {
+ throw DuooomiBleError.TransferFailed("HTTP $code")
+ }
+
+ val responseText = connection.inputStream.bufferedReader().readText()
+ val json = JSONObject(responseText)
+
+ if (json.optBoolean("success") != true) {
+ return@withContext null
+ }
+
+ val dataObj = json.optJSONObject("data") ?: return@withContext null
+ FirmwareInfo.fromJson(dataObj)
+ } finally {
+ connection.disconnect()
+ }
+ }
+}
diff --git a/sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt b/sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt
new file mode 100644
index 0000000..63ea336
--- /dev/null
+++ b/sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt
@@ -0,0 +1,23 @@
+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")
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..4ce3755
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,19 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "duooomi-android-sdk"
+include(":sdk")
+include(":demo")