feat(demo-final): cherry-pick — Firmware DFU + Battery dismiss + AppState fix (cloud-mvp 이식)

feature/cloud-mvp 에서 최근 만들어진 3가지 사용자용 기능을 demo-final 에도 적용.
demo-final 옛 폴더 구조 유지 (folder reorg 안 됨) · 각 파일 위치 수동 매핑.

이식 대상:

1. Firmware DFU (cloud-mvp 672e679):
   - firmware/ 폴더 신규 · MEDiDFU 6 파일 이식 (DfuManager 459 line 등)
   - assets/dfu_application_2+35.zip (243 KB · signed MCUboot)
   - libs.versions.toml: mcumgr = "2.5.0" (Kotlin 2.0 호환)
   - build.gradle.kts: mcumgr-ble + lifecycle-runtime-compose 추가
   - AppScreen.FIRMWARE_UPDATE enum 추가
   - MainActivity: 라우팅 + 뒤로가기 (→ HOME)
   - HomeView: "펌웨어 업데이트 (프로브 firmware)" TextButton 추가 (Dev clinical 버튼 밑)

2. BatteryWarningBanner tap-to-dismiss (cloud-mvp aa6cc1f):
   - clickable { dismissed = true } + 우측 X 아이콘
   - WARNING → CRITICAL 악화 시 자동 재표시
   - 재연결 시 dismissed 초기화

3. AppState.isDeviceConnected getter (cloud-mvp 9c82a50):
   - mutableStateOf → getter 로 변경 (BleManager.isConnected.value 실시간)
   - Compose 자동 재구성 · setter 는 no-op 로 호환 유지
   - 홈화면 "연결됨" · Start 후 "연결해제" 불일치 해결

주요 파일 매핑 (feature/cloud-mvp → demo-final):
- core/AppState.kt         → AppState.kt (root)
- core/MainActivity.kt     → MainActivity.kt (root)
- common/ui/components/BatteryWarningBanner.kt → ui/components/BatteryWarningBanner.kt
- firmware/**              → firmware/** (신규 · 동일)
- shell/ui/SettingsTabView.kt (진입점 원본) → HomeView 로 대체 (demo 는 SettingsTab 없음)

검증: BUILD SUCCESSFUL (demoDebug 1m 56s) · APK install PASS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 13:29:18 +09:00
parent 2e0ca889d9
commit 984fe2c939
13 changed files with 1416 additions and 6 deletions
@@ -0,0 +1,349 @@
/*
* VesiScan firmware updater — MEDiDFU 이식 (2026-08-03).
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr> · adapt: dw.jang
*
* VesiScan 앱 통합: 설정 > 앱정보 > "펌웨어 업데이트" 진입점.
* FirmwareUpdateScreen() = 권한 · manager 라이프사이클 처리하는 host composable.
* DfuScreen() = 원본 UI (scan · connect · upload · log).
*/
package com.medithings.vesiscan.firmware.ui
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.medithings.vesiscan.firmware.ScannedDevice
import com.medithings.vesiscan.firmware.DfuManager
import com.medithings.vesiscan.firmware.DfuPhase
import kotlin.math.roundToInt
/**
* VesiScan 통합용 host — 권한 · manager 생성/해제 · 뒤로가기 처리.
* MEDiDFU 의 MainActivity.DfuApp() 상당.
*/
@Composable
fun FirmwareUpdateScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
val manager = remember { DfuManager(context) }
val perms: Array<String> = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
var granted by remember {
mutableStateOf(perms.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
})
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result -> granted = result.values.all { it } }
LaunchedEffect(Unit) {
manager.loadFirmware()
if (!granted) launcher.launch(perms)
}
DisposableEffect(Unit) { onDispose { manager.shutdown() } }
Column(modifier = modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로")
}
Text("펌웨어 업데이트", style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold)
}
if (granted) {
DfuScreen(manager = manager, modifier = Modifier.fillMaxSize())
} else {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"펌웨어 업데이트에는 Bluetooth 권한이 필요합니다.",
style = MaterialTheme.typography.bodyMedium,
)
Button(onClick = { launcher.launch(perms) }) { Text("권한 허용") }
}
}
}
}
@Composable
fun DfuScreen(manager: DfuManager, modifier: Modifier = Modifier) {
val phase by manager.phase
val status by manager.status
val firmware by manager.firmware
val firmwareError by manager.firmwareError
val progress by manager.progress
val bytesSent by manager.bytesSent
val target by manager.target
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("MEDiDFU", style = MaterialTheme.typography.headlineSmall)
// ── Anything outside the app that would silently break scanning ───────────────────
val blocker = manager.bleBlockingCondition()
if (blocker != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Text(
blocker,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
// ── Firmware bundled in the APK ────────────────────────────────────────────────────
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Firmware (bundled)", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
val fw = firmware
when {
fw != null -> {
Text(fw.assetName, style = MaterialTheme.typography.bodyMedium)
Text(
"${fw.imageFileName} · v${fw.version.ifEmpty { "?" }} · ${fw.sizeBytes} bytes",
style = MaterialTheme.typography.bodySmall,
)
}
firmwareError != null -> Text(
firmwareError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
else -> Text("Loading…", style = MaterialTheme.typography.bodySmall)
}
}
}
// ── Status + progress ─────────────────────────────────────────────────────────────
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(phase.name, style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(4.dp))
Text(status, style = MaterialTheme.typography.bodyMedium)
target?.let {
Text(
"${it.name} · ${it.address}",
style = MaterialTheme.typography.bodySmall,
)
}
if (phase == DfuPhase.UPLOADING || phase == DfuPhase.COMPLETED) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
val total = firmware?.sizeBytes ?: 0
Text(
"${(progress * 100).roundToInt()}% ($bytesSent / $total bytes)",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
// ── Actions ───────────────────────────────────────────────────────────────────────
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Keyed off the phase, not the scanner: the DFU re-scan also runs the scanner, and
// stopping that one from here would abort the update.
if (phase == DfuPhase.SCANNING) {
OutlinedButton(onClick = { manager.stopScan() }) { Text("Stop") }
} else {
OutlinedButton(
onClick = { manager.startScan() },
enabled = !manager.isBusy,
) { Text("Scan") }
}
Button(
onClick = { manager.startDfu() },
enabled = manager.canStartDfu,
) { Text("Start DFU") }
if (phase == DfuPhase.CONNECTED) {
OutlinedButton(onClick = { manager.disconnect() }) { Text("Disconnect") }
}
if (phase == DfuPhase.UPLOADING) {
OutlinedButton(onClick = { manager.cancelDfu() }) { Text("Cancel") }
}
if (phase == DfuPhase.COMPLETED || phase == DfuPhase.FAILED) {
OutlinedButton(onClick = { manager.reset() }) { Text("Reset") }
}
}
// ── Discovered devices ────────────────────────────────────────────────────────────
Text(
"Devices (${manager.devices.size})",
style = MaterialTheme.typography.labelLarge,
)
Card(modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp, max = 220.dp)) {
if (manager.devices.isEmpty()) {
Text(
when (phase) {
DfuPhase.CONNECTED -> "Connected — disconnect to scan again."
DfuPhase.SCANNING -> "Scanning…"
else -> "No devices yet — tap Scan."
},
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
} else {
LazyColumn {
items(manager.devices, key = { it.address }) { device ->
DeviceRow(
device = device,
enabled = !manager.isBusy,
onClick = { manager.connect(device) },
)
HorizontalDivider()
}
}
}
}
// ── Log ───────────────────────────────────────────────────────────────────────────
Text("Log", style = MaterialTheme.typography.labelLarge)
LogView(
lines = manager.logLines,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
@Composable
private fun DeviceRow(device: ScannedDevice, enabled: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(device.name, style = MaterialTheme.typography.bodyMedium)
if (device.inDfuMode) {
Spacer(Modifier.width(6.dp))
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(4.dp),
) {
Text(
"DFU",
modifier = Modifier.padding(horizontal = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
Text(
"${device.address} · ${device.rssi} dBm",
style = MaterialTheme.typography.bodySmall,
)
}
Button(onClick = onClick, enabled = enabled) { Text("Connect") }
}
}
@Composable
private fun LogView(lines: List<String>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LaunchedEffect(lines.size) {
if (lines.isNotEmpty()) listState.animateScrollToItem(lines.size - 1)
}
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.padding(8.dp),
) {
itemsIndexed(lines) { _, line ->
Text(
line,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Normal,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}