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 com.duooomi.ble.DuooomiBleSDK
import com.duooomi.ble.core.ConnectionState
import com.duooomi.ble.models.FirmwareUpgradeCheckResult
import com.duooomi.ble.models.PlayMode
import com.duooomi.ble.protocol.CommandType
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
// MARK: - CDN Helper
@@ -44,7 +44,11 @@ private enum class LogLevel { INFO, SUCCESS, ERROR }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DemoScreen(sdk: DuooomiBleSDK) {
fun DemoScreen(
sdk: DuooomiBleSDK,
config: DemoConfig,
onReload: (DemoConfig) -> Unit
) {
val scope = rememberCoroutineScope()
// SDK state
@@ -56,13 +60,18 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
val isActivated by sdk.isActivated.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") }
val sn by sdk.sn.collectAsState()
val firmwareUpgrade by sdk.firmwareUpgrade.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 isBusy by remember { mutableStateOf(false) }
var connectingDeviceId by remember { mutableStateOf<String?>(null) }
@@ -70,6 +79,23 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
var logs by remember { mutableStateOf<List<LogLine>>(emptyList()) }
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) {
logCounter++
logs = listOf(LogLine(logCounter, level, message)) + logs.take(199)
@@ -91,6 +117,24 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
.padding(horizontal = 16.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 ===
item {
SectionHeader("Status")
@@ -214,6 +258,27 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
} finally { isBusy = false }
}
}) { 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))
@@ -257,7 +322,18 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
}
// 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(
onClick = {
scope.launch {
@@ -319,64 +395,59 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
) { Text("Transfer") }
}
// === Firmware Section ===
// === Firmware OTA Section ===
item {
SectionHeader("Firmware Update")
StatusRow("SN", sn.ifEmpty { "--" })
SectionHeader("Firmware OTA")
StatusRow("Current Version", version.ifEmpty { "--" })
firmwareUpgrade?.targetFirmware?.let { target ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
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)
}
ConfigField("sn", otaFetchSn) { otaFetchSn = it }
ConfigField("currentVersion", otaFetchVersion) { otaFetchVersion = it }
ConfigField("userId", otaFetchUserId) { otaFetchUserId = it }
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),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(onClick = {
otaFetchSn = sn
otaFetchVersion = version
otaFetchUserId = userId
}) { Text("回填当前状态") }
Spacer(Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
scope.launch {
firmwareLoading = true
log("-> fetchLatestFirmware(sn=$sn, version=$version, $userId)")
log("-> fetchLatestFirmware(sn=$otaFetchSn, version=$otaFetchVersion, userId=$otaFetchUserId) [report→softBind→check]")
try {
val r = sdk.fetchLatestFirmware(sn = sn, currentVersion = version, userId = userId)
val target = r.targetFirmware
if (target != null) {
log("firmware latest: ${target.version}", LogLevel.SUCCESS)
} else {
log("already up-to-date")
}
val r = sdk.fetchLatestFirmware(
sn = otaFetchSn,
currentVersion = otaFetchVersion,
userId = otaFetchUserId
)
val tv = r.targetFirmware?.version ?: ""
log("fetchLatestFirmware OK target=$tv", LogLevel.SUCCESS)
} catch (e: Exception) {
log("firmware info FAIL: ${e.message}", LogLevel.ERROR)
log("fetchLatestFirmware FAIL: ${e.message}", LogLevel.ERROR)
} finally { firmwareLoading = false }
}
},
enabled = !firmwareLoading && sn.isNotEmpty()
) { Text("Get Update Info") }
enabled = !firmwareLoading && otaFetchSn.isNotEmpty() && otaFetchUserId.isNotEmpty()
) { Text("拉取安装包") }
Button(
onClick = {
scope.launch {
val target: FirmwareUpgradeCheckResult.TargetFirmware =
firmwareUpgrade?.targetFirmware ?: return@launch
val targetFileUrl = target.fileUrl ?: return@launch
val target = firmwareUpgrade?.targetFirmware
val targetFileUrl = target?.fileUrl
if (target == null || targetFileUrl.isNullOrEmpty()) {
log("install FAIL: 无可用固件包(请先点拉取安装包)", LogLevel.ERROR)
return@launch
}
isBusy = true
log("-> upgradeFirmware ${target.version}")
log("-> upgradeFirmware sn=$sn $version${target.version} (fw=${target.id})")
try {
sdk.upgradeFirmware(
sn = sn,
@@ -385,33 +456,69 @@ fun DemoScreen(sdk: DuooomiBleSDK) {
fromVersion = version,
targetVersion = target.version
)
log("OTA OK", LogLevel.SUCCESS)
log("install OK已落 UpgradeRecord下次 fetchLatestFirmware 自动对账上报)", LogLevel.SUCCESS)
} catch (e: Exception) {
log("OTA FAIL: ${e.message}", LogLevel.ERROR)
log("install FAIL: ${e.message}", LogLevel.ERROR)
} finally { isBusy = false }
}
},
enabled = firmwareUpgrade?.targetFirmware?.fileUrl?.isNotEmpty() == true
&& connectedDevice != null && !isBusy
) { Text("Upgrade") }
enabled = firmwareUpgrade?.targetFirmware?.fileUrl?.isNotEmpty() == true && !isBusy
) { Text("安装") }
}
if (upgradeRecords.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Upgrade Records (${upgradeRecords.size})", fontSize = 12.sp, fontWeight = FontWeight.Bold)
upgradeRecords.sortedByDescending { it.triggeredAt }.take(5).forEach { r ->
val outcome = r.outcome
val outcomeText = when {
!r.reported -> "pending"
outcome == null -> "expired"
else -> outcome.raw
if (firmwareLoading) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text("Fetching...", fontSize = 12.sp, color = Color.Gray)
}
}
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(
"[$outcomeText] fw=${r.firmwareId} ${r.fromVersion}${r.targetVersion}",
"forceUpgrade: ${if (it) "是" else "否"}",
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()
}
@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
private fun StatusRow(label: String, value: String) {
Row(

View File

@@ -1,6 +1,8 @@
package com.duooomi.ble.demo
import android.Manifest
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -10,42 +12,76 @@ 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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import com.duooomi.ble.DuooomiBleConfig
import com.duooomi.ble.DuooomiBleSDK
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
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 = DemoSecrets.API_KEY,
owner = DemoSecrets.OWNER,
scanNamePrefix = DemoSecrets.SCAN_NAME_PREFIX
)
)
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 {
var config by remember { mutableStateOf(initialConfig) }
var sdk by remember { mutableStateOf(buildSdk(config)) }
MaterialTheme {
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() {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -62,4 +98,17 @@ class MainActivity : ComponentActivity() {
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
/**
* 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 {
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-"
}