feat: implement Android BLE SDK with demo app

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

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

49
demo/build.gradle.kts Normal file
View 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")
}

View 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>

View 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)
}
}

View 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())
}
}
}