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