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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user