1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONArray5import org.json.JSONObject6import java.io.File7import java.io.InputStream8import java.security.MessageDigest9101112131415161718192021data class ReleaseManifest(22 val version: String,23 val url: String,24 val checksum: String,25 26 val notes: String?,27 28 val platformKey: String = ANDROID_PLATFORM_KEY,29) {30 fun toJson(): String = JSONObject().apply {31 put("version", version)32 if (notes != null) put("notes", notes)33 put("platforms", JSONObject().put(platformKey, JSONObject().apply {34 put("url", url)35 put(checksum.substringBefore(':'), checksum.substringAfter(':'))36 }))37 }.toString()3839 companion object {40 41 fun fromCachedJson(json: String): ReleaseManifest? = runCatching {42 parseReleaseManifest(json)43 }.getOrNull()44 }45}4647class ManifestFormatException(message: String) : Exception(message)484950class ApkIdentityException(message: String) : Exception(message)515253class DownloadSizeException(message: String) : Exception(message)54555657585960const val ANDROID_PLATFORM_KEY = "android-arm64"61const val ANDROID_X86_PLATFORM_KEY = "android-x86"6263fun androidPlatformKey(supportedAbis: Array<String>): String? = when {64 "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY65 "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY66 else -> null67}6869sealed interface UpdateCheckResult {70 data class Accepted(val manifest: ReleaseManifest) : UpdateCheckResult71 data class NoUpdate(val version: String) : UpdateCheckResult72 data class RollbackSuppressed(val servedVersion: String, val highWaterVersion: String) : UpdateCheckResult73}7475767778const val MAX_APK_BYTES = 50L * 1024 * 1024798081828384858687private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")8889internal fun validateSemver(version: String) {90 if (!STRICT_SEMVER.matches(version)) {91 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")92 }93 version.split('.').forEach { seg ->94 95 96 97 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {98 throw ManifestFormatException("version '$version' has an out-of-range segment")99 }100 }101}102103104105106107108109110111112113fun isExpectedApk(114 packageName: String?,115 versionName: String?,116 versionCode: Long,117 expectedPackage: String,118 expectedVersionName: String,119 currentVersionCode: Long,120): Boolean {121 if (packageName != expectedPackage) return false122 if (versionName != expectedVersionName) return false123 124 125 126 127 return versionCode > currentVersionCode128}129130131132133134135internal fun packageVersionCodeForSdk(136 sdkInt: Int,137 legacyVersionCode: Int,138 modernVersionCode: Long,139): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()140141142143144145146internal fun packageSigningInfoFlagsForSdk(147 sdkInt: Int,148 legacyFlags: Int,149 modernFlags: Int,150): Int = if (sdkInt >= 28) modernFlags else legacyFlags151152153154155156157158159160161162163164165166167internal fun isSigningLineageCompatible(168 archiveCurrent: Set<String>,169 archiveHistory: Set<String>,170 installedCurrent: Set<String>,171): Boolean {172 if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false173 if (archiveCurrent.size != installedCurrent.size) return false174 return installedCurrent.all { it in archiveCurrent || it in archiveHistory }175}176177178179180181182183184185186187188189190191fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {192 val root = runCatching { JSONObject(body) }193 .getOrElse { throw ManifestFormatException("manifest is not JSON") }194195 val version = root.optTextOrNull("version")196 ?: throw ManifestFormatException("manifest has no version")197 validateSemver(version)198199 val platforms = root.optJSONObject("platforms")200 ?: throw ManifestFormatException("manifest has no platforms")201202 val platform = platforms.optJSONObject(platformKey)203 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")204205 val url = platform.optTextOrNull("url")206 ?: throw ManifestFormatException("artifact has no url")207 if (!url.startsWith("https://")) {208 throw ManifestFormatException("artifact url must use https")209 }210 val checksum = parseArtifactChecksum(platform)211 ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")212213 return ReleaseManifest(214 version = version,215 url = url,216 checksum = checksum,217 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),218 )219}220221222223224225226227228229230231internal fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {232 val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)233 if (blake3 != null) return "blake3:$blake3"234 val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)235 if (sha256 != null) return "sha256:$sha256"236 return artifact.optTextOrNull("checksum")237}238239private fun isHex64(value: String): Boolean =240 value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }241242243internal fun JSONObject.optTextOrNull(key: String): String? {244 if (!has(key) || isNull(key)) return null245 return optString(key).trim().takeIf { it.isNotEmpty() }246}247248249data class ReleaseNote(val version: String, val notes: String?)250251252253254255256fun parseReleaseHistory(json: String): List<ReleaseNote> {257 val arr = JSONArray(json)258 return (0 until arr.length()).map { i ->259 val obj = arr.getJSONObject(i)260 ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes"))261 }262}263264265266267268269270271fun normalizeVersion(version: String): String =272 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')273274275276277278279280281282283fun compareVersions(a: String, b: String): Int {284 val left = normalizeVersion(a).split('.')285 val right = normalizeVersion(b).split('.')286 for (i in 0 until maxOf(left.size, right.size)) {287 val x = left.getOrNull(i)?.toIntOrNull() ?: 0288 val y = right.getOrNull(i)?.toIntOrNull() ?: 0289 if (x != y) return x.compareTo(y)290 }291 return 0292}293294fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0295296297298data class ExpectedChecksum(val algorithm: String, val hex: String)299300class ChecksumMismatchException(message: String) : Exception(message)301302private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")303304305fun parseChecksum(raw: String): ExpectedChecksum? {306 val separator = raw.indexOf(':')307 if (separator <= 0) return null308 val algorithm = raw.substring(0, separator).trim().lowercase()309 val hex = raw.substring(separator + 1).trim().lowercase()310 if (algorithm !in SUPPORTED_CHECKSUMS) return null311 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null312 return ExpectedChecksum(algorithm, hex)313}314315fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {316 "blake3" -> {317 val digest = Blake3Digest(32)318 val buffer = ByteArray(64 * 1024)319 while (true) {320 val read = input.read(buffer)321 if (read <= 0) break322 digest.update(buffer, 0, read)323 }324 val out = ByteArray(digest.digestSize)325 digest.doFinal(out, 0)326 out.toHex()327 }328 "sha256" -> {329 val digest = MessageDigest.getInstance("SHA-256")330 val buffer = ByteArray(64 * 1024)331 while (true) {332 val read = input.read(buffer)333 if (read <= 0) break334 digest.update(buffer, 0, read)335 }336 digest.digest().toHex()337 }338 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")339}340341fun hashFile(file: File, algorithm: String): String =342 file.inputStream().use { hashStream(it, algorithm) }343344private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }