package com.peachpasswords.updates import org.bouncycastle.crypto.digests.Blake3Digest import org.json.JSONObject import java.io.File import java.io.InputStream import java.security.MessageDigest /** * Kepr's generic release manifest, as returned by * `GET https://kepr.uk/api//releases/latest`, flattened to the single * platform this app can install. * * ```json * { "version": "2.3.68", * "notes": "...", * "platforms": { "android-arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } } * ``` */ data class ReleaseManifest( val version: String, val url: String, val checksum: String, /** Absent — not empty, not "null" — when the release carries no notes. */ val notes: String?, ) { fun toJson(): String = JSONObject().apply { put("version", version) put("url", url) put("checksum", checksum) if (notes != null) put("notes", notes) }.toString() companion object { fun fromCachedJson(json: String): ReleaseManifest? = runCatching { val o = JSONObject(json) ReleaseManifest( version = o.getString("version"), url = o.getString("url"), checksum = o.getString("checksum"), notes = o.optTextOrNull("notes"), ) }.getOrNull() } } class ManifestFormatException(message: String) : Exception(message) /** * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its * `-` platform vocabulary. This is the key the release manifest is * published under, so it is the key the updater must look up. It has to stay * identical to `platforms =` in this repo's `kepr.build` and to Kepr's * `release_platforms` allowlist; changing any one alone breaks APK publishing. */ const val ANDROID_PLATFORM_KEY = "android-arm64" /** * Parse a Kepr release manifest. * * Two contract wrinkles are handled deliberately: * - The notes key is `notes` canonically, but the published Guidelines still show * the retired `release_notes`. Both are accepted, `notes` winning. * - `optString` on a JSON null yields the literal string "null" in org.json, which * would render as changelog text. [optTextOrNull] rejects that class of value * outright rather than trusting the caller to notice. */ fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest { val root = runCatching { JSONObject(body) } .getOrElse { throw ManifestFormatException("manifest is not JSON") } val version = root.optTextOrNull("version") ?: throw ManifestFormatException("manifest has no version") val platforms = root.optJSONObject("platforms") ?: throw ManifestFormatException("manifest has no platforms") // Fall back to the sole platform when the key doesn't match: a single-artifact // Android release is unambiguous, and a naming mismatch shouldn't strand every // installed build with "no download available". val platform = platforms.optJSONObject(platformKey) ?: platforms.keys().asSequence().toList().singleOrNull()?.let { platforms.optJSONObject(it) } ?: throw ManifestFormatException("manifest has no '$platformKey' artifact") val url = platform.optTextOrNull("url") ?: throw ManifestFormatException("artifact has no url") val checksum = platform.optTextOrNull("checksum") ?: throw ManifestFormatException("artifact has no checksum") return ReleaseManifest( version = version, url = url, checksum = checksum, notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"), ) } /** Non-blank string value, or null — never JSON null surfaced as the text "null". */ internal fun JSONObject.optTextOrNull(key: String): String? { if (!has(key) || isNull(key)) return null return optString(key).trim().takeIf { it.isNotEmpty() } } /* ───────────────────────────── version compare ───────────────────────────── */ /** * Strip everything that isn't the numeric release, so a debug build * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was * built from rather than as an unrelated string. */ fun normalizeVersion(version: String): String = version.trim().removePrefix("v").substringBefore('-').substringBefore('+') /** Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. */ fun compareVersions(a: String, b: String): Int { val left = normalizeVersion(a).split('.') val right = normalizeVersion(b).split('.') for (i in 0 until maxOf(left.size, right.size)) { val x = left.getOrNull(i)?.toIntOrNull() ?: 0 val y = right.getOrNull(i)?.toIntOrNull() ?: 0 if (x != y) return x.compareTo(y) } return 0 } fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0 /* ────────────────────────────────── checksum ─────────────────────────────── */ data class ExpectedChecksum(val algorithm: String, val hex: String) class ChecksumMismatchException(message: String) : Exception(message) private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256") /** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */ fun parseChecksum(raw: String): ExpectedChecksum? { val separator = raw.indexOf(':') if (separator <= 0) return null val algorithm = raw.substring(0, separator).trim().lowercase() val hex = raw.substring(separator + 1).trim().lowercase() if (algorithm !in SUPPORTED_CHECKSUMS) return null if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null return ExpectedChecksum(algorithm, hex) } fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) { "blake3" -> { val digest = Blake3Digest(32) val buffer = ByteArray(64 * 1024) while (true) { val read = input.read(buffer) if (read <= 0) break digest.update(buffer, 0, read) } val out = ByteArray(digest.digestSize) digest.doFinal(out, 0) out.toHex() } "sha256" -> { val digest = MessageDigest.getInstance("SHA-256") val buffer = ByteArray(64 * 1024) while (true) { val read = input.read(buffer) if (read <= 0) break digest.update(buffer, 0, read) } digest.digest().toHex() } else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'") } fun hashFile(file: File, algorithm: String): String = file.inputStream().use { hashStream(it, algorithm) } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }