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,111 @@
/*
* MEDiDFU — VesiSeek firmware updater
*
* Unpacks the DFU archive bundled in assets/ and extracts the signed MCUboot image.
*
* Author: Charles KWON OhJun <charleskwon@medithings.co.kr>
* MEDiThings Inc.
*/
package com.medithings.vesiscan.firmware
import android.content.Context
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.zip.ZipInputStream
/** A signed MCUboot application image unpacked from a `dfu_application_*.zip`. */
data class FirmwarePackage(
val assetName: String,
val imageFileName: String,
/** `version_MCUBOOT` from the manifest, e.g. "0.0.2+35". Empty when the manifest omits it. */
val version: String,
val image: ByteArray,
) {
val sizeBytes: Int get() = image.size
// Generated equals/hashCode would compare the ByteArray by reference; identify the package by
// its source instead.
override fun equals(other: Any?): Boolean =
this === other || (other is FirmwarePackage &&
assetName == other.assetName &&
imageFileName == other.imageFileName &&
version == other.version &&
image.size == other.image.size)
override fun hashCode(): Int =
(((assetName.hashCode() * 31 + imageFileName.hashCode()) * 31) + version.hashCode()) * 31 +
image.size
}
/**
* Reads a DFU zip bundled in `assets/`.
*
* The zip is the nRF Connect SDK / Zephyr layout: a `manifest.json` listing the images plus the
* signed binaries themselves. The application image (`image_index` 0, or `type` "application") is
* the one uploaded over SMP.
*/
object FirmwareLoader {
const val DEFAULT_ASSET = "dfu_application_2+35.zip"
private const val MANIFEST = "manifest.json"
@Throws(IllegalStateException::class)
fun loadFromAssets(context: Context, assetName: String = DEFAULT_ASSET): FirmwarePackage {
val entries = readZipEntries(context, assetName)
val manifestBytes = entries.entries
.firstOrNull { it.key.equals(MANIFEST, ignoreCase = true) }
?.value
?: error("$MANIFEST not found in $assetName")
val files = JSONObject(String(manifestBytes, Charsets.UTF_8)).optJSONArray("files")
?: error("$MANIFEST has no \"files\" array")
check(files.length() > 0) { "$MANIFEST lists no images" }
var chosen = files.getJSONObject(0)
for (i in 0 until files.length()) {
val entry = files.getJSONObject(i)
if (entry.optString("image_index") == "0" ||
entry.optString("type").equals("application", ignoreCase = true)
) {
chosen = entry
break
}
}
val fileName = chosen.optString("file")
check(fileName.isNotEmpty()) { "$MANIFEST entry has no \"file\" field" }
val image = entries[fileName] ?: error("$fileName listed in $MANIFEST but missing from the zip")
check(image.isNotEmpty()) { "$fileName is empty" }
return FirmwarePackage(
assetName = assetName,
imageFileName = fileName,
version = chosen.optString("version_MCUBOOT"),
image = image,
)
}
private fun readZipEntries(context: Context, assetName: String): Map<String, ByteArray> {
val entries = LinkedHashMap<String, ByteArray>()
ZipInputStream(context.assets.open(assetName)).use { zip ->
val buffer = ByteArray(16 * 1024)
while (true) {
val entry = zip.nextEntry ?: break
if (entry.isDirectory) {
zip.closeEntry()
continue
}
val out = ByteArrayOutputStream()
while (true) {
val read = zip.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
}
entries[entry.name] = out.toByteArray()
zip.closeEntry()
}
}
return entries
}
}