From 417012c49fee6581fcaa61abae58ae5dfea2db64 Mon Sep 17 00:00:00 2001 From: km2023 Date: Fri, 8 May 2026 16:03:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(demo):=20=E5=AF=B9=E9=BD=90=20iOS=20demo?= =?UTF-8?q?=20=E5=B8=83=E5=B1=80=20+=20Info+Ver=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改动: - 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) --- .../kotlin/com/duooomi/ble/demo/DemoScreen.kt | 256 +++++++++++++----- .../com/duooomi/ble/demo/MainActivity.kt | 75 ++++- docs/DemoSecrets.template.kt | 5 +- 3 files changed, 255 insertions(+), 81 deletions(-) diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt index df67525..5cbb497 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/DemoScreen.kt @@ -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(null) } @@ -70,6 +79,23 @@ fun DemoScreen(sdk: DuooomiBleSDK) { var logs by remember { mutableStateOf>(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,32 +456,68 @@ 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( + "forceUpgrade: ${if (it) "是" else "否"}", + fontSize = 10.sp, + color = if (it) Color.Red else Color.Gray + ) } - Text( - "[$outcomeText] fw=${r.firmwareId} ${r.fromVersion}→${r.targetVersion}", - fontSize = 10.sp, - color = 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( diff --git a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt index ad3467b..ac04856 100644 --- a/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt +++ b/demo/src/main/kotlin/com/duooomi/ble/demo/MainActivity.kt @@ -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) + // 旧 SDK:fire-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() 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 +) diff --git a/docs/DemoSecrets.template.kt b/docs/DemoSecrets.template.kt index daa4915..8bfd08a 100644 --- a/docs/DemoSecrets.template.kt +++ b/docs/DemoSecrets.template.kt @@ -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 = "" - const val OWNER: String = "" + const val OWNER: String = "xmly" const val SCAN_NAME_PREFIX: String = "Duooomi-" }