feat(demo): 对齐 iOS demo 布局 + Info+Ver 并发按钮

主要改动:
- DemoSecrets 默认值对齐 iOS:owner=xmly, scanNamePrefix=Duooomi-
- userId 默认值对齐:Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3
- Config 区可在运行时编辑 owner/apiKey/scanPrefix,SharedPreferences
  持久化;Reload SDK 按钮重建实例(旧 SDK 自动 disconnect)
- PlayMode 行:显示当前模式标签(循环 / 单播)+ Single/Loop 按钮
- Firmware OTA:3 个独立输入(sn/currentVersion/userId) +「回填当前
  状态」+「拉取安装包」+「安装」三按钮,对齐 iOS WrapperTestView
- bind 完成 / version 更新自动回填 OTA 入参
- UpgradeRecords 4 状态展示:待上报 /  正确上报 /  错误上报
  (版本不匹配) / 过期放弃
- 保留先前加的 Info+Ver 并发按钮(async 同时发 getDeviceInfo +
  getVersion,验证不同 commandType 通道并发不冲突)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-08 16:03:54 +08:00
parent 65ff905889
commit 417012c49f
3 changed files with 255 additions and 81 deletions

View File

@@ -13,9 +13,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.duooomi.ble.DuooomiBleSDK import com.duooomi.ble.DuooomiBleSDK
import com.duooomi.ble.core.ConnectionState import com.duooomi.ble.core.ConnectionState
import com.duooomi.ble.models.FirmwareUpgradeCheckResult
import com.duooomi.ble.models.PlayMode import com.duooomi.ble.models.PlayMode
import com.duooomi.ble.protocol.CommandType import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// MARK: - CDN Helper // MARK: - CDN Helper
@@ -44,7 +44,11 @@ private enum class LogLevel { INFO, SUCCESS, ERROR }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DemoScreen(sdk: DuooomiBleSDK) { fun DemoScreen(
sdk: DuooomiBleSDK,
config: DemoConfig,
onReload: (DemoConfig) -> Unit
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// SDK state // SDK state
@@ -56,13 +60,18 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
val isActivated by sdk.isActivated.collectAsState() val isActivated by sdk.isActivated.collectAsState()
val transferProgress by sdk.transferProgress.collectAsState() val transferProgress by sdk.transferProgress.collectAsState()
val sdkError by sdk.error.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") }
val sn by sdk.sn.collectAsState() val sn by sdk.sn.collectAsState()
val firmwareUpgrade by sdk.firmwareUpgrade.collectAsState() val firmwareUpgrade by sdk.firmwareUpgrade.collectAsState()
val upgradeRecords by sdk.upgradeRecords.collectAsState() val upgradeRecords by sdk.upgradeRecords.collectAsState()
// Editable config (rekey on sdk change so new SDK 不会带着旧编辑残留)
var configOwner by remember(sdk) { mutableStateOf(config.owner) }
var configApiKey by remember(sdk) { mutableStateOf(config.apiKey) }
var configScanPrefix by remember(sdk) { mutableStateOf(config.scanPrefix) }
// Local state
var userId by remember { mutableStateOf("Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3") }
var fileUrl by remember { mutableStateOf("https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4") }
var firmwareLoading by remember { mutableStateOf(false) } var firmwareLoading by remember { mutableStateOf(false) }
var isBusy by remember { mutableStateOf(false) } var isBusy by remember { mutableStateOf(false) }
var connectingDeviceId by remember { mutableStateOf<String?>(null) } var connectingDeviceId by remember { mutableStateOf<String?>(null) }
@@ -70,6 +79,23 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
var logs by remember { mutableStateOf<List<LogLine>>(emptyList()) } var logs by remember { mutableStateOf<List<LogLine>>(emptyList()) }
var logCounter by remember { mutableLongStateOf(0L) } var logCounter by remember { mutableLongStateOf(0L) }
// OTA fetch 入参可手动填bind/getVersion 完成时自动回填)
var otaFetchSn by remember(sdk) { mutableStateOf("") }
var otaFetchVersion by remember(sdk) { mutableStateOf("") }
var otaFetchUserId by remember(sdk) { mutableStateOf("") }
// bind 完成 → 回填 OTA 入参
LaunchedEffect(isActivated, sn) {
if (isActivated && sn.isNotEmpty()) {
otaFetchSn = sn
if (version.isNotEmpty()) otaFetchVersion = version
if (otaFetchUserId.isEmpty()) otaFetchUserId = userId
}
}
LaunchedEffect(version) {
if (version.isNotEmpty()) otaFetchVersion = version
}
fun log(message: String, level: LogLevel = LogLevel.INFO) { fun log(message: String, level: LogLevel = LogLevel.INFO) {
logCounter++ logCounter++
logs = listOf(LogLine(logCounter, level, message)) + logs.take(199) logs = listOf(LogLine(logCounter, level, message)) + logs.take(199)
@@ -91,6 +117,24 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// === Config Section ===
item {
SectionHeader("SDK Config修改后点 Reload 生效)")
ConfigField("x-owner", configOwner) { configOwner = it }
ConfigField("apiKey", configApiKey) { configApiKey = it }
ConfigField("scanPrefix", configScanPrefix) { configScanPrefix = it }
Button(
onClick = {
onReload(DemoConfig(configOwner, configApiKey, configScanPrefix))
log(
"SDK reloaded with owner=$configOwner, prefix=$configScanPrefix",
LogLevel.SUCCESS
)
},
modifier = Modifier.fillMaxWidth()
) { Text("Reload SDK") }
}
// === Status Section === // === Status Section ===
item { item {
SectionHeader("Status") SectionHeader("Status")
@@ -214,6 +258,27 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
} finally { isBusy = false } } finally { isBusy = false }
} }
}) { Text("getVersion") } }) { Text("getVersion") }
OutlinedButton(onClick = {
scope.launch {
isBusy = true
log("-> getDeviceInfo + getVersion (并发)")
try {
coroutineScope {
val infoJob = async { sdk.getDeviceInfo() }
val verJob = async { sdk.getVersion() }
val info = infoJob.await()
val ver = verJob.await()
log(
"info+ver OK: brand=${info.brand} power=${info.powerlevel}% v=${ver.version}",
LogLevel.SUCCESS
)
}
} catch (e: Exception) {
log("info+ver FAIL: ${e.message}", LogLevel.ERROR)
} finally { isBusy = false }
}
}) { Text("Info+Ver") }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -257,7 +322,18 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
} }
// Play mode toggle —— gated by isActivated // Play mode toggle —— gated by isActivated
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("PlayMode", fontSize = 12.sp)
val modeLabel = when (deviceInfo?.loop) {
PlayMode.LOOP -> "循环"
PlayMode.SINGLE -> "单播"
null -> ""
}
Text(modeLabel, fontSize = 12.sp, color = Color.Gray, modifier = Modifier.weight(1f))
OutlinedButton( OutlinedButton(
onClick = { onClick = {
scope.launch { scope.launch {
@@ -319,64 +395,59 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
) { Text("Transfer") } ) { Text("Transfer") }
} }
// === Firmware Section === // === Firmware OTA Section ===
item { item {
SectionHeader("Firmware Update") SectionHeader("Firmware OTA")
StatusRow("SN", sn.ifEmpty { "--" })
StatusRow("Current Version", version.ifEmpty { "--" }) StatusRow("Current Version", version.ifEmpty { "--" })
firmwareUpgrade?.targetFirmware?.let { target -> ConfigField("sn", otaFetchSn) { otaFetchSn = it }
Card(modifier = Modifier.fillMaxWidth()) { ConfigField("currentVersion", otaFetchVersion) { otaFetchVersion = it }
Column(modifier = Modifier.padding(12.dp)) { ConfigField("userId", otaFetchUserId) { otaFetchUserId = it }
StatusRow("Latest", target.version)
target.fileSize?.let { StatusRow("Size", "$it bytes") }
target.fileMd5?.let { Text("MD5: $it", fontSize = 10.sp, color = Color.Gray) }
target.forceUpgrade?.let { Text("forceUpgrade=$it", fontSize = 11.sp, color = Color.Red) }
target.fileUrl?.let { Text(it, fontSize = 10.sp, color = Color.Gray, maxLines = 2) }
}
}
} ?: firmwareUpgrade?.let {
Text("Already up-to-date", fontSize = 12.sp, color = Color.Gray)
}
if (firmwareLoading) { Row(
Row(verticalAlignment = Alignment.CenterVertically) { horizontalArrangement = Arrangement.spacedBy(8.dp),
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) verticalAlignment = Alignment.CenterVertically
Spacer(Modifier.width(8.dp)) ) {
Text("Fetching...", fontSize = 12.sp, color = Color.Gray) OutlinedButton(onClick = {
} otaFetchSn = sn
} otaFetchVersion = version
otaFetchUserId = userId
}) { Text("回填当前状态") }
Spacer(Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
scope.launch { scope.launch {
firmwareLoading = true firmwareLoading = true
log("-> fetchLatestFirmware(sn=$sn, version=$version, $userId)") log("-> fetchLatestFirmware(sn=$otaFetchSn, version=$otaFetchVersion, userId=$otaFetchUserId) [report→softBind→check]")
try { try {
val r = sdk.fetchLatestFirmware(sn = sn, currentVersion = version, userId = userId) val r = sdk.fetchLatestFirmware(
val target = r.targetFirmware sn = otaFetchSn,
if (target != null) { currentVersion = otaFetchVersion,
log("firmware latest: ${target.version}", LogLevel.SUCCESS) userId = otaFetchUserId
} else { )
log("already up-to-date") val tv = r.targetFirmware?.version ?: ""
} log("fetchLatestFirmware OK target=$tv", LogLevel.SUCCESS)
} catch (e: Exception) { } catch (e: Exception) {
log("firmware info FAIL: ${e.message}", LogLevel.ERROR) log("fetchLatestFirmware FAIL: ${e.message}", LogLevel.ERROR)
} finally { firmwareLoading = false } } finally { firmwareLoading = false }
} }
}, },
enabled = !firmwareLoading && sn.isNotEmpty() enabled = !firmwareLoading && otaFetchSn.isNotEmpty() && otaFetchUserId.isNotEmpty()
) { Text("Get Update Info") } ) { Text("拉取安装包") }
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
val target: FirmwareUpgradeCheckResult.TargetFirmware = val target = firmwareUpgrade?.targetFirmware
firmwareUpgrade?.targetFirmware ?: return@launch val targetFileUrl = target?.fileUrl
val targetFileUrl = target.fileUrl ?: return@launch if (target == null || targetFileUrl.isNullOrEmpty()) {
log("install FAIL: 无可用固件包(请先点拉取安装包)", LogLevel.ERROR)
return@launch
}
isBusy = true isBusy = true
log("-> upgradeFirmware ${target.version}") log("-> upgradeFirmware sn=$sn $version${target.version} (fw=${target.id})")
try { try {
sdk.upgradeFirmware( sdk.upgradeFirmware(
sn = sn, sn = sn,
@@ -385,33 +456,69 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
fromVersion = version, fromVersion = version,
targetVersion = target.version targetVersion = target.version
) )
log("OTA OK", LogLevel.SUCCESS) log("install OK已落 UpgradeRecord下次 fetchLatestFirmware 自动对账上报)", LogLevel.SUCCESS)
} catch (e: Exception) { } catch (e: Exception) {
log("OTA FAIL: ${e.message}", LogLevel.ERROR) log("install FAIL: ${e.message}", LogLevel.ERROR)
} finally { isBusy = false } } finally { isBusy = false }
} }
}, },
enabled = firmwareUpgrade?.targetFirmware?.fileUrl?.isNotEmpty() == true enabled = firmwareUpgrade?.targetFirmware?.fileUrl?.isNotEmpty() == true && !isBusy
&& connectedDevice != null && !isBusy ) { Text("安装") }
) { Text("Upgrade") }
} }
if (upgradeRecords.isNotEmpty()) { if (firmwareLoading) {
Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Text("Upgrade Records (${upgradeRecords.size})", fontSize = 12.sp, fontWeight = FontWeight.Bold) CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
upgradeRecords.sortedByDescending { it.triggeredAt }.take(5).forEach { r -> Spacer(Modifier.width(8.dp))
val outcome = r.outcome Text("Fetching...", fontSize = 12.sp, color = Color.Gray)
val outcomeText = when {
!r.reported -> "pending"
outcome == null -> "expired"
else -> outcome.raw
} }
}
firmwareUpgrade?.let { upgrade ->
val target = upgrade.targetFirmware
if (target != null) {
StatusRow("target version", target.version)
Text("firmwareId: ${target.id}", fontSize = 10.sp, color = Color.Gray)
target.forceUpgrade?.let {
Text( Text(
"[$outcomeText] fw=${r.firmwareId} ${r.fromVersion}${r.targetVersion}", "forceUpgrade: ${if (it) "是" else "否"}",
fontSize = 10.sp, fontSize = 10.sp,
color = Color.Gray color = if (it) Color.Red else Color.Gray
) )
} }
target.fileSize?.let { Text("fileSize: $it", fontSize = 10.sp, color = Color.Gray) }
target.fileMd5?.let { Text("md5: $it", fontSize = 10.sp, color = Color.Gray) }
target.fileUrl?.let { Text(it, fontSize = 10.sp, color = Color.Gray, maxLines = 2) }
} else {
Text("无可用固件targetFirmware 为空)", fontSize = 12.sp, color = Color.Gray)
}
}
}
// === Upgrade Records ===
if (upgradeRecords.isNotEmpty()) {
item {
Text(
"Upgrade Records (${upgradeRecords.size})",
fontSize = 12.sp,
color = Color.Gray,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 8.dp)
)
}
items(upgradeRecords.sortedByDescending { it.triggeredAt }, key = { it.firmwareId + it.sn }) { r ->
Column(modifier = Modifier.padding(vertical = 2.dp)) {
Text("${r.sn} · ${r.firmwareId}", fontSize = 11.sp)
Text("${r.fromVersion}${r.targetVersion}", fontSize = 10.sp, color = Color.Gray)
val outcome = r.outcome
val (statusText, statusColor) = when {
!r.reported -> "待上报" to Color(0xFFFF9800)
outcome?.raw == "success" -> "✅ 正确上报" to Color(0xFF2E7D32)
outcome?.raw == "version_mismatch" -> "❌ 错误上报(版本不匹配)" to Color.Red
else -> "过期放弃" to Color.Gray
}
Text(statusText, fontSize = 10.sp, color = statusColor)
}
} }
} }
@@ -486,6 +593,23 @@ private fun SectionHeader(title: String) {
Divider() Divider()
} }
@Composable
private fun ConfigField(label: String, value: String, onChange: (String) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(label, fontSize = 12.sp, modifier = Modifier.width(90.dp))
OutlinedTextField(
value = value,
onValueChange = onChange,
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
)
}
}
@Composable @Composable
private fun StatusRow(label: String, value: String) { private fun StatusRow(label: String, value: String) {
Row( Row(

View File

@@ -1,6 +1,8 @@
package com.duooomi.ble.demo package com.duooomi.ble.demo
import android.Manifest import android.Manifest
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -10,42 +12,76 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.duooomi.ble.DuooomiBleConfig import com.duooomi.ble.DuooomiBleConfig
import com.duooomi.ble.DuooomiBleSDK import com.duooomi.ble.DuooomiBleSDK
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var sdk: DuooomiBleSDK
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { /* permissions granted or denied — SDK handles errors */ } ) { /* permissions granted or denied — SDK handles errors */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
sdk = DuooomiBleSDK(
context = this,
config = DuooomiBleConfig(
apiKey = DemoSecrets.API_KEY,
owner = DemoSecrets.OWNER,
scanNamePrefix = DemoSecrets.SCAN_NAME_PREFIX
)
)
requestBlePermissions() requestBlePermissions()
val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val initialConfig = DemoConfig(
owner = prefs.getString(KEY_OWNER, DemoSecrets.OWNER) ?: DemoSecrets.OWNER,
apiKey = prefs.getString(KEY_API_KEY, DemoSecrets.API_KEY) ?: DemoSecrets.API_KEY,
scanPrefix = prefs.getString(KEY_SCAN_PREFIX, DemoSecrets.SCAN_NAME_PREFIX)
?: DemoSecrets.SCAN_NAME_PREFIX
)
setContent { setContent {
var config by remember { mutableStateOf(initialConfig) }
var sdk by remember { mutableStateOf(buildSdk(config)) }
MaterialTheme { MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
DemoScreen(sdk = sdk) DemoScreen(
sdk = sdk,
config = config,
onReload = { newConfig ->
persist(prefs, newConfig)
// 旧 SDKfire-and-forget disconnect 释放 BLE 资源
val old = sdk
@Suppress("OPT_IN_USAGE")
GlobalScope.launch { runCatching { old.disconnect() } }
config = newConfig
sdk = buildSdk(newConfig)
}
)
} }
} }
} }
} }
private fun buildSdk(c: DemoConfig) = DuooomiBleSDK(
context = this,
config = DuooomiBleConfig(
apiKey = c.apiKey,
owner = c.owner,
scanNamePrefix = c.scanPrefix
)
)
private fun persist(prefs: SharedPreferences, c: DemoConfig) {
prefs.edit()
.putString(KEY_OWNER, c.owner)
.putString(KEY_API_KEY, c.apiKey)
.putString(KEY_SCAN_PREFIX, c.scanPrefix)
.apply()
}
private fun requestBlePermissions() { private fun requestBlePermissions() {
val permissions = mutableListOf<String>() val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -62,4 +98,17 @@ class MainActivity : ComponentActivity() {
permissionLauncher.launch(needed.toTypedArray()) permissionLauncher.launch(needed.toTypedArray())
} }
} }
private companion object {
const val PREFS = "demo_config"
const val KEY_OWNER = "demo.config.owner"
const val KEY_API_KEY = "demo.config.apiKey"
const val KEY_SCAN_PREFIX = "demo.config.scanPrefix"
} }
}
data class DemoConfig(
val owner: String,
val apiKey: String,
val scanPrefix: String
)

View File

@@ -1,10 +1,11 @@
package com.duooomi.ble.demo package com.duooomi.ble.demo
/** /**
* Copy this file to `DemoSecrets.kt` (gitignored) and fill in real credentials. * Copy this file to `demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt`
* (already gitignored) and fill in real credentials.
*/ */
object DemoSecrets { object DemoSecrets {
const val API_KEY: String = "<paste-api-key>" const val API_KEY: String = "<paste-api-key>"
const val OWNER: String = "<paste-owner>" const val OWNER: String = "xmly"
const val SCAN_NAME_PREFIX: String = "Duooomi-" const val SCAN_NAME_PREFIX: String = "Duooomi-"
} }