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:
49
demo/build.gradle.kts
Normal file
49
demo/build.gradle.kts
Normal file
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.duooomi.ble.demo"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.duooomi.ble.demo"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":sdk"))
|
||||
|
||||
// Compose
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
33
demo/src/main/AndroidManifest.xml
Normal file
33
demo/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Android 11 and below -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<!-- Android 12+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="BLE SDK Demo"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
453
demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt
Normal file
453
demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt
Normal file
@@ -0,0 +1,453 @@
|
||||
package com.duooomi.ble.demo
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.duooomi.ble.DuooomiBleSDK
|
||||
import com.duooomi.ble.core.ConnectionState
|
||||
import com.duooomi.ble.models.FirmwareInfo
|
||||
import com.duooomi.ble.protocol.CommandType
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// MARK: - CDN Helper
|
||||
|
||||
private object CDNHelper {
|
||||
var cdnHost = "https://cdn.bowong.cc/"
|
||||
|
||||
fun ensureFullUrl(keyOrUrl: String): String {
|
||||
if (keyOrUrl.startsWith("http://") || keyOrUrl.startsWith("https://")) return keyOrUrl
|
||||
val key = keyOrUrl.removePrefix("/")
|
||||
return cdnHost + key
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log
|
||||
|
||||
private data class LogLine(
|
||||
val id: Long,
|
||||
val level: LogLevel,
|
||||
val message: String
|
||||
)
|
||||
|
||||
private enum class LogLevel { INFO, SUCCESS, ERROR }
|
||||
|
||||
// MARK: - Screen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DemoScreen(sdk: DuooomiBleSDK) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// SDK state
|
||||
val btState by sdk.btState.collectAsState()
|
||||
val discoveredDevices by sdk.discoveredDevices.collectAsState()
|
||||
val connectedDevice by sdk.connectedDevice.collectAsState()
|
||||
val deviceInfo by sdk.deviceInfo.collectAsState()
|
||||
val version by sdk.version.collectAsState()
|
||||
val transferProgress by sdk.transferProgress.collectAsState()
|
||||
val sdkError by sdk.error.collectAsState()
|
||||
|
||||
// Local state
|
||||
var userId by remember { mutableStateOf("test-user-001") }
|
||||
var fileUrl by remember { mutableStateOf("https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4") }
|
||||
var firmwareBrand by remember { mutableStateOf(sdk.config.firmwareIdentifier) }
|
||||
var firmwareStatus by remember { mutableStateOf(sdk.config.firmwareStatus) }
|
||||
var firmwareInfo by remember { mutableStateOf<FirmwareInfo?>(null) }
|
||||
var firmwareLoading by remember { mutableStateOf(false) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
var connectingDeviceId by remember { mutableStateOf<String?>(null) }
|
||||
var deviceFiles by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var logs by remember { mutableStateOf<List<LogLine>>(emptyList()) }
|
||||
var logCounter by remember { mutableLongStateOf(0L) }
|
||||
|
||||
fun log(message: String, level: LogLevel = LogLevel.INFO) {
|
||||
logCounter++
|
||||
logs = listOf(LogLine(logCounter, level, message)) + logs.take(199)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
CDNHelper.cdnHost = sdk.config.normalizedCdnHost
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text("SDK Demo") })
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// === Status Section ===
|
||||
item {
|
||||
SectionHeader("Status")
|
||||
StatusRow("BLE", btState.name)
|
||||
StatusRow("Device", connectedDevice?.name ?: "--")
|
||||
StatusRow("Version", version.ifEmpty { "--" })
|
||||
if (transferProgress > 0) {
|
||||
@Suppress("DEPRECATION")
|
||||
LinearProgressIndicator(
|
||||
progress = transferProgress / 100f,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
)
|
||||
Text("$transferProgress%", fontSize = 12.sp)
|
||||
}
|
||||
sdkError?.let {
|
||||
Text(it, color = Color.Red, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// === Scan & Connect Section ===
|
||||
item {
|
||||
SectionHeader("Scan & Connect")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
sdk.scan()
|
||||
log("-> scan()")
|
||||
}) { Text("Scan") }
|
||||
|
||||
OutlinedButton(onClick = {
|
||||
sdk.stopScan()
|
||||
log("-> stopScan()")
|
||||
}) { Text("Stop") }
|
||||
}
|
||||
}
|
||||
|
||||
items(discoveredDevices, key = { it.id }) { device ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(device.name ?: "Unknown", fontWeight = FontWeight.Medium)
|
||||
Text(device.id, fontSize = 10.sp, color = Color.Gray)
|
||||
}
|
||||
Text("${device.rssi} dBm", fontSize = 12.sp, color = Color.Gray)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
if (connectedDevice?.id == device.id) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
log("-> disconnect")
|
||||
try {
|
||||
sdk.disconnect()
|
||||
deviceFiles = emptyList()
|
||||
log("disconnect OK", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("disconnect FAIL: ${e.message}", LogLevel.ERROR)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red)
|
||||
) { Text("Disconnect") }
|
||||
} else if (connectingDeviceId == device.id) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
connectingDeviceId = device.id
|
||||
log("-> connect(${device.id})")
|
||||
try {
|
||||
val d = sdk.connect(device.id)
|
||||
log("connect OK: ${d.name ?: d.id}", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("connect FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally {
|
||||
connectingDeviceId = null
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = connectingDeviceId == null
|
||||
) { Text("Connect") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Device Section ===
|
||||
item {
|
||||
SectionHeader("Device")
|
||||
StatusRow("Brand", deviceInfo?.brand ?: "--")
|
||||
StatusRow("Size", deviceInfo?.size ?: "--")
|
||||
StatusRow("Power", deviceInfo?.let { "${it.powerlevel}%" } ?: "--")
|
||||
StatusRow("Storage", deviceInfo?.let { "${it.freespace}/${it.allspace}" } ?: "--")
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
log("-> getDeviceInfo")
|
||||
try {
|
||||
val info = sdk.getDeviceInfo()
|
||||
log("getDeviceInfo OK: brand=${info.brand} power=${info.powerlevel}%", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("getDeviceInfo FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
}) { Text("getDeviceInfo") }
|
||||
|
||||
OutlinedButton(onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
log("-> getVersion")
|
||||
try {
|
||||
val info = sdk.getVersion()
|
||||
log("getVersion OK: ${info.version}", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("getVersion FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
}) { Text("getVersion") }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = userId,
|
||||
onValueChange = { userId = it },
|
||||
label = { Text("userId") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
log("-> bind($userId)")
|
||||
try {
|
||||
val result = sdk.bind(userId)
|
||||
log("bind OK: sn=${result.sn} contents=${result.contents.size}", LogLevel.SUCCESS)
|
||||
deviceFiles = result.contents
|
||||
} catch (e: Exception) {
|
||||
log("bind FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
}) { Text("bind") }
|
||||
|
||||
OutlinedButton(onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
log("-> unbind($userId)")
|
||||
try {
|
||||
sdk.unbind(userId)
|
||||
deviceFiles = emptyList()
|
||||
log("unbind OK", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("unbind FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
}) { Text("unbind") }
|
||||
}
|
||||
}
|
||||
|
||||
// === Transfer Section ===
|
||||
item {
|
||||
SectionHeader("Transfer")
|
||||
OutlinedTextField(
|
||||
value = fileUrl,
|
||||
onValueChange = { fileUrl = it },
|
||||
label = { Text("File URL (mp4/jpg/png/ani)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
log("-> transferMedia($fileUrl)")
|
||||
try {
|
||||
sdk.transferMedia(fileUrl)
|
||||
log("transfer OK", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("transfer FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
},
|
||||
enabled = fileUrl.isNotEmpty() && connectedDevice != null && !isBusy
|
||||
) { Text("Transfer") }
|
||||
}
|
||||
|
||||
// === Firmware Section ===
|
||||
item {
|
||||
SectionHeader("Firmware Update")
|
||||
StatusRow("Current Version", version.ifEmpty { "--" })
|
||||
|
||||
OutlinedTextField(
|
||||
value = firmwareBrand,
|
||||
onValueChange = { firmwareBrand = it },
|
||||
label = { Text("Identifier") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf("DRAFT", "PUBLISHED").forEach { status ->
|
||||
FilterChip(
|
||||
selected = firmwareStatus == status,
|
||||
onClick = { firmwareStatus = status },
|
||||
label = { Text(status) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
firmwareInfo?.let { info ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
StatusRow("Latest", info.version)
|
||||
info.fileSize?.let { StatusRow("Size", "$it bytes") }
|
||||
info.fileMd5?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text("MD5: $it", fontSize = 10.sp, color = Color.Gray)
|
||||
}
|
||||
info.description?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text(it, fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
Text(info.fileUrl, fontSize = 10.sp, color = Color.Gray, maxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firmwareLoading) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Fetching...", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
firmwareLoading = true
|
||||
log("-> fetchLatestFirmware($firmwareBrand, $firmwareStatus)")
|
||||
try {
|
||||
val info = sdk.fetchLatestFirmware(firmwareBrand, firmwareStatus)
|
||||
firmwareInfo = info
|
||||
if (info != null) {
|
||||
log("firmware latest: ${info.version}", LogLevel.SUCCESS)
|
||||
} else {
|
||||
log("no firmware available")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("firmware info FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { firmwareLoading = false }
|
||||
}
|
||||
},
|
||||
enabled = !firmwareLoading && firmwareBrand.isNotEmpty()
|
||||
) { Text("Get Update Info") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val info = firmwareInfo ?: return@launch
|
||||
isBusy = true
|
||||
log("-> upgradeFirmware ${info.version}")
|
||||
try {
|
||||
sdk.upgradeFirmware(info.fileUrl)
|
||||
log("OTA OK", LogLevel.SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
log("OTA FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
},
|
||||
enabled = firmwareInfo?.fileUrl?.isNotEmpty() == true && connectedDevice != null && !isBusy
|
||||
) { Text("Upgrade") }
|
||||
}
|
||||
}
|
||||
|
||||
// === Device Files Section ===
|
||||
item {
|
||||
SectionHeader("Device Files (${deviceFiles.size})")
|
||||
if (deviceFiles.isEmpty()) {
|
||||
Text("Bind to see device files", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
|
||||
items(deviceFiles) { rawKey ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(rawKey, fontSize = 11.sp, modifier = Modifier.weight(1f), maxLines = 2)
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
log("-> deleteFile($rawKey)")
|
||||
try {
|
||||
sdk.deleteFile(rawKey)
|
||||
log("deleteFile OK", LogLevel.SUCCESS)
|
||||
deviceFiles = deviceFiles.filter { it != rawKey }
|
||||
} catch (e: Exception) {
|
||||
log("deleteFile FAIL: ${e.message}", LogLevel.ERROR)
|
||||
} finally { isBusy = false }
|
||||
}
|
||||
},
|
||||
enabled = !isBusy,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color.Red)
|
||||
) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Log Section ===
|
||||
item {
|
||||
SectionHeader("Log (${logs.size})")
|
||||
}
|
||||
|
||||
items(logs.take(80), key = { it.id }) { line ->
|
||||
Text(
|
||||
text = line.message,
|
||||
fontSize = 11.sp,
|
||||
color = when (line.level) {
|
||||
LogLevel.ERROR -> Color.Red
|
||||
LogLevel.SUCCESS -> Color(0xFF2E7D32)
|
||||
LogLevel.INFO -> Color.Unspecified
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(label, fontSize = 13.sp, color = Color.Gray)
|
||||
Text(value, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
61
demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt
Normal file
61
demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.duooomi.ble.demo
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.duooomi.ble.DuooomiBleConfig
|
||||
import com.duooomi.ble.DuooomiBleSDK
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var sdk: DuooomiBleSDK
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { /* permissions granted or denied — SDK handles errors */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
sdk = DuooomiBleSDK(
|
||||
context = this,
|
||||
config = DuooomiBleConfig(apiKey = "")
|
||||
)
|
||||
|
||||
requestBlePermissions()
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
DemoScreen(sdk = sdk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBlePermissions() {
|
||||
val permissions = mutableListOf<String>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
permissions.add(Manifest.permission.BLUETOOTH_SCAN)
|
||||
permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
permissions.add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
val needed = permissions.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (needed.isNotEmpty()) {
|
||||
permissionLauncher.launch(needed.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user