UpdateManifest.kt

6.9 KB · raw

1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONObject5import java.io.File6import java.io.InputStream7import java.security.MessageDigest89/**10 * Kepr's generic release manifest, as returned by11 * `GET https://kepr.uk/api/<repo>/releases/latest`, flattened to the single12 * platform this app can install.13 *14 * ```json15 * { "version": "2.3.68",16 *   "notes": "...",17 *   "platforms": { "arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } }18 * ```19 */20data class ReleaseManifest(21    val version: String,22    val url: String,23    val checksum: String,24    /** Absent — not empty, not "null" — when the release carries no notes. */25    val notes: String?,26) {27    fun toJson(): String = JSONObject().apply {28        put("version", version)29        put("url", url)30        put("checksum", checksum)31        if (notes != null) put("notes", notes)32    }.toString()3334    companion object {35        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {36            val o = JSONObject(json)37            ReleaseManifest(38                version = o.getString("version"),39                url = o.getString("url"),40                checksum = o.getString("checksum"),41                notes = o.optTextOrNull("notes"),42            )43        }.getOrNull()44    }45}4647class ManifestFormatException(message: String) : Exception(message)4849/**50 * ASSUMPTION — unverifiable from here. Kepr's generic release endpoint normalizes51 * `<repo>-<platform>` against a bare `<platform>` artifact name, so for the52 * `peach-android` repo the bare key is what the manifest should carry. Every53 * upload must use this exact platform name; if the publishing side settles on54 * something else (`arm64-v8a`, `android-arm64`, `universal`), change this one55 * constant.56 */57const val ANDROID_PLATFORM_KEY = "arm64"5859/**60 * Parse a Kepr release manifest.61 *62 * Two contract wrinkles are handled deliberately:63 * - The notes key is `notes` canonically, but the published Guidelines still show64 *   the retired `release_notes`. Both are accepted, `notes` winning.65 * - `optString` on a JSON null yields the literal string "null" in org.json, which66 *   would render as changelog text. [optTextOrNull] rejects that class of value67 *   outright rather than trusting the caller to notice.68 */69fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {70    val root = runCatching { JSONObject(body) }71        .getOrElse { throw ManifestFormatException("manifest is not JSON") }7273    val version = root.optTextOrNull("version")74        ?: throw ManifestFormatException("manifest has no version")7576    val platforms = root.optJSONObject("platforms")77        ?: throw ManifestFormatException("manifest has no platforms")7879    // Fall back to the sole platform when the key doesn't match: a single-artifact80    // Android release is unambiguous, and a naming mismatch shouldn't strand every81    // installed build with "no download available".82    val platform = platforms.optJSONObject(platformKey)83        ?: platforms.keys().asSequence().toList().singleOrNull()?.let { platforms.optJSONObject(it) }84        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")8586    val url = platform.optTextOrNull("url")87        ?: throw ManifestFormatException("artifact has no url")88    val checksum = platform.optTextOrNull("checksum")89        ?: throw ManifestFormatException("artifact has no checksum")9091    return ReleaseManifest(92        version = version,93        url = url,94        checksum = checksum,95        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),96    )97}9899/** Non-blank string value, or null — never JSON null surfaced as the text "null". */100internal fun JSONObject.optTextOrNull(key: String): String? {101    if (!has(key) || isNull(key)) return null102    return optString(key).trim().takeIf { it.isNotEmpty() }103}104105/* ─────────────────────────────  version compare  ───────────────────────────── */106107/**108 * Strip everything that isn't the numeric release, so a debug build109 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was110 * built from rather than as an unrelated string.111 */112fun normalizeVersion(version: String): String =113    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')114115/** Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. */116fun compareVersions(a: String, b: String): Int {117    val left = normalizeVersion(a).split('.')118    val right = normalizeVersion(b).split('.')119    for (i in 0 until maxOf(left.size, right.size)) {120        val x = left.getOrNull(i)?.toIntOrNull() ?: 0121        val y = right.getOrNull(i)?.toIntOrNull() ?: 0122        if (x != y) return x.compareTo(y)123    }124    return 0125}126127fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0128129/* ──────────────────────────────────  checksum  ─────────────────────────────── */130131data class ExpectedChecksum(val algorithm: String, val hex: String)132133class ChecksumMismatchException(message: String) : Exception(message)134135private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")136137/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */138fun parseChecksum(raw: String): ExpectedChecksum? {139    val separator = raw.indexOf(':')140    if (separator <= 0) return null141    val algorithm = raw.substring(0, separator).trim().lowercase()142    val hex = raw.substring(separator + 1).trim().lowercase()143    if (algorithm !in SUPPORTED_CHECKSUMS) return null144    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null145    return ExpectedChecksum(algorithm, hex)146}147148fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {149    "blake3" -> {150        val digest = Blake3Digest(32)151        val buffer = ByteArray(64 * 1024)152        while (true) {153            val read = input.read(buffer)154            if (read <= 0) break155            digest.update(buffer, 0, read)156        }157        val out = ByteArray(digest.digestSize)158        digest.doFinal(out, 0)159        out.toHex()160    }161    "sha256" -> {162        val digest = MessageDigest.getInstance("SHA-256")163        val buffer = ByteArray(64 * 1024)164        while (true) {165            val read = input.read(buffer)166            if (read <= 0) break167            digest.update(buffer, 0, read)168        }169        digest.digest().toHex()170    }171    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")172}173174fun hashFile(file: File, algorithm: String): String =175    file.inputStream().use { hashStream(it, algorithm) }176177private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }