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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
sdk/build/
|
||||||
213
README.md
Normal file
213
README.md
Normal file
@@ -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
|
||||||
|
<!-- Android 11 及以下 -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<!-- Android 12+ -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<!-- 网络 (ANI转换/固件下载) -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** 运行时仍需动态申请 `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<DiscoveredDevice>
|
||||||
|
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
|
||||||
5
build.gradle.kts
Normal file
5
build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
49
demo/build.gradle.kts
Normal file
49
demo/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
33
demo/src/main/AndroidManifest.xml
Normal file
33
demo/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Android 11 and below -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<!-- Android 12+ -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.bluetooth_le"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="BLE SDK Demo"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
453
demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt
Normal file
453
demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt
Normal file
@@ -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<FirmwareInfo?>(null) }
|
||||||
|
var firmwareLoading by remember { mutableStateOf(false) }
|
||||||
|
var isBusy by remember { mutableStateOf(false) }
|
||||||
|
var connectingDeviceId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var deviceFiles by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
var logs by remember { mutableStateOf<List<LogLine>>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt
Normal file
61
demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt
Normal file
@@ -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<String>()
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
gradle.properties
Normal file
3
gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
124
gradlew
vendored
Executable file
124
gradlew
vendored
Executable file
@@ -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" "$@"
|
||||||
92
gradlew.bat
vendored
Normal file
92
gradlew.bat
vendored
Normal file
@@ -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
|
||||||
27
sdk/build.gradle.kts
Normal file
27
sdk/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
16
sdk/src/main/AndroidManifest.xml
Normal file
16
sdk/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Android 11 and below -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<!-- Android 12+ -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.bluetooth_le"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
12
sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt
Normal file
12
sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleConfig.kt
Normal file
@@ -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/"
|
||||||
|
}
|
||||||
530
sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt
Normal file
530
sdk/src/main/kotlin/com/duooomi/ble/DuooomiBleSDK.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
329
sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt
Normal file
329
sdk/src/main/kotlin/com/duooomi/ble/core/BleClient.kt
Normal file
@@ -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<BluetoothGatt>? = null
|
||||||
|
private var disconnectContinuation: CancellableContinuation<Unit>? = null
|
||||||
|
|
||||||
|
// Notification flow
|
||||||
|
private val _notifications = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||||
|
val notifications: SharedFlow<ByteArray> = _notifications
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onDisconnected: (() -> Unit)? = null
|
||||||
|
|
||||||
|
// 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<DiscoveredDevice> = 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<DiscoveredDevice> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt
Normal file
29
sdk/src/main/kotlin/com/duooomi/ble/core/BleTypes.kt
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -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<String>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromJson(json: JSONObject): BindingResponse {
|
||||||
|
val contentsArray = json.optJSONArray("contents")
|
||||||
|
val contents = mutableListOf<String>()
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt
Normal file
25
sdk/src/main/kotlin/com/duooomi/ble/models/DeviceInfo.kt
Normal file
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt
Normal file
27
sdk/src/main/kotlin/com/duooomi/ble/models/FirmwareInfo.kt
Normal file
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
sdk/src/main/kotlin/com/duooomi/ble/models/UnbindResponse.kt
Normal file
15
sdk/src/main/kotlin/com/duooomi/ble/models/UnbindResponse.kt
Normal file
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt
Normal file
22
sdk/src/main/kotlin/com/duooomi/ble/models/VersionInfo.kt
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
159
sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt
Normal file
159
sdk/src/main/kotlin/com/duooomi/ble/protocol/ProtocolManager.kt
Normal file
@@ -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<ByteArray> {
|
||||||
|
val maxChunk = maxOf(1, maxDataSize)
|
||||||
|
val totalPages = (data.size + maxChunk - 1) / maxChunk
|
||||||
|
if (totalPages > 65535) return emptyList()
|
||||||
|
|
||||||
|
return if (data.size <= maxDataSize) {
|
||||||
|
listOf(createSingleFrame(type, data, head))
|
||||||
|
} else {
|
||||||
|
createFragmentedFrames(type, data, head, maxChunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSingleFrame(type: Byte, data: ByteArray, head: FrameHead): ByteArray {
|
||||||
|
val totalSize = FrameConstants.HEADER_SIZE + data.size + FrameConstants.FOOTER_SIZE
|
||||||
|
val buffer = ByteArray(totalSize)
|
||||||
|
var pos = 0
|
||||||
|
|
||||||
|
// Header
|
||||||
|
buffer[pos++] = head.byte
|
||||||
|
buffer[pos++] = type
|
||||||
|
buffer[pos++] = 0 // subpageTotal high
|
||||||
|
buffer[pos++] = 0 // subpageTotal low
|
||||||
|
buffer[pos++] = 0 // curPage high
|
||||||
|
buffer[pos++] = 0 // curPage low
|
||||||
|
buffer[pos++] = ((data.size shr 8) and 0xFF).toByte() // dataLen high
|
||||||
|
buffer[pos++] = (data.size and 0xFF).toByte() // dataLen low
|
||||||
|
|
||||||
|
// Data
|
||||||
|
data.copyInto(buffer, pos)
|
||||||
|
pos += data.size
|
||||||
|
|
||||||
|
// Checksum (on everything before checksum)
|
||||||
|
buffer[pos] = calculateChecksum(buffer, 0, pos)
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFragmentedFrames(
|
||||||
|
type: Byte,
|
||||||
|
data: ByteArray,
|
||||||
|
head: FrameHead,
|
||||||
|
maxDataSize: Int
|
||||||
|
): List<ByteArray> {
|
||||||
|
val totalPages = (data.size + maxDataSize - 1) / maxDataSize
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
|
||||||
|
for (i in 0 until totalPages) {
|
||||||
|
val start = i * maxDataSize
|
||||||
|
val end = minOf(start + maxDataSize, data.size)
|
||||||
|
val chunkSize = end - start
|
||||||
|
|
||||||
|
val frameSize = FrameConstants.HEADER_SIZE + chunkSize + FrameConstants.FOOTER_SIZE
|
||||||
|
val buffer = ByteArray(frameSize)
|
||||||
|
var pos = 0
|
||||||
|
|
||||||
|
buffer[pos++] = head.byte
|
||||||
|
buffer[pos++] = type
|
||||||
|
|
||||||
|
// subpageTotal (big-endian UInt16)
|
||||||
|
buffer[pos++] = ((totalPages shr 8) and 0xFF).toByte()
|
||||||
|
buffer[pos++] = (totalPages and 0xFF).toByte()
|
||||||
|
|
||||||
|
// curPage: from (total-1) counting down to 0
|
||||||
|
val curPageVal = totalPages - 1 - i
|
||||||
|
buffer[pos++] = ((curPageVal shr 8) and 0xFF).toByte()
|
||||||
|
buffer[pos++] = (curPageVal and 0xFF).toByte()
|
||||||
|
|
||||||
|
// dataLen
|
||||||
|
buffer[pos++] = ((chunkSize shr 8) and 0xFF).toByte()
|
||||||
|
buffer[pos++] = (chunkSize and 0xFF).toByte()
|
||||||
|
|
||||||
|
// data
|
||||||
|
data.copyInto(buffer, pos, start, end)
|
||||||
|
pos += chunkSize
|
||||||
|
|
||||||
|
// checksum
|
||||||
|
buffer[pos] = calculateChecksum(buffer, 0, pos)
|
||||||
|
|
||||||
|
frames.add(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Frame Parsing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw bytes into a ProtocolFrame. Returns null if invalid.
|
||||||
|
*/
|
||||||
|
fun parseFrame(data: ByteArray): ProtocolFrame? {
|
||||||
|
val minSize = FrameConstants.HEADER_SIZE + FrameConstants.FOOTER_SIZE
|
||||||
|
if (data.size < minSize) return null
|
||||||
|
|
||||||
|
val head = data[0]
|
||||||
|
if (head != FrameHead.DEVICE_TO_APP.byte && head != FrameHead.APP_TO_DEVICE.byte) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val type = data[1]
|
||||||
|
val subpageTotal = ((data[2].toInt() and 0xFF) shl 8) or (data[3].toInt() and 0xFF)
|
||||||
|
val curPage = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
||||||
|
val dataLen = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
|
||||||
|
|
||||||
|
val expectedSize = FrameConstants.HEADER_SIZE + dataLen + FrameConstants.FOOTER_SIZE
|
||||||
|
if (data.size < expectedSize) return null
|
||||||
|
|
||||||
|
val 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt
Normal file
79
sdk/src/main/kotlin/com/duooomi/ble/services/AniConverter.kt
Normal file
@@ -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)
|
||||||
@@ -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<Int, ByteArray> = mutableMapOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val fragments = mutableMapOf<String, FragmentSession>()
|
||||||
|
private var listenerJob: Job? = null
|
||||||
|
|
||||||
|
private val _incomingMessages = MutableSharedFlow<Pair<Byte, ByteArray>>(extraBufferCapacity = 16)
|
||||||
|
val incomingMessages: SharedFlow<Pair<Byte, ByteArray>> = _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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt
Normal file
23
sdk/src/main/kotlin/com/duooomi/ble/utils/BleLogger.kt
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
19
settings.gradle.kts
Normal file
19
settings.gradle.kts
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user