984fe2c939
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>
112 lines
4.0 KiB
Kotlin
112 lines
4.0 KiB
Kotlin
/*
|
|
* 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
|
|
}
|
|
}
|