/* * MEDiDFU — VesiSeek firmware updater * * Unpacks the DFU archive bundled in assets/ and extracts the signed MCUboot image. * * Author: Charles KWON OhJun * 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_1.0.0+5.zip" /** * 번들된 DFU 버전 (manifest.json 의 version_MCUBOOT 필드와 일치). * 2026-08-04: BleManager 의 update-available 배너가 이 값 vs 런타임 FW 를 비교. */ const val BUNDLED_VERSION = "1.0.0+5" 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 { val entries = LinkedHashMap() 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 } }