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