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 signature: String = "",29 30 val provenanceSource: String = "",31 32 val platformKey: String = ANDROID_PLATFORM_KEY,33) {34 fun toJson(): String = JSONObject().apply {35 put("version", version)36 if (notes != null) put("notes", notes)37 put("platforms", JSONObject().put(platformKey, JSONObject().apply {38 put("url", url)39 put(checksum.substringBefore(':'), checksum.substringAfter(':'))40 put("provenance", JSONObject().apply {41 put("version", version)42 put("platform", platformKey)43 put("checksum", checksum)44 put("source", provenanceSource)45 })46 put("signature", signature)47 }))48 }.toString()4950 companion object {51 5253545556575859 fun fromCachedJson(json: String): ReleaseManifest? = runCatching {60 parseSignedReleaseManifest(json)61 }.getOrNull()62 }63}6465class ManifestFormatException(message: String) : Exception(message)666768class ApkIdentityException(message: String) : Exception(message)697071class DownloadSizeException(message: String) : Exception(message)72737475767778const val ANDROID_PLATFORM_KEY = "android-arm64"79const val ANDROID_X86_PLATFORM_KEY = "android-x86"8081fun androidPlatformKey(supportedAbis: Array<String>): String? = when {82 "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY83 "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY84 else -> null85}868788899091const val MAX_APK_BYTES = 50L * 1024 * 10249293949596979899100private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")101102internal fun validateSemver(version: String) {103 if (!STRICT_SEMVER.matches(version)) {104 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")105 }106 version.split('.').forEach { seg ->107 108 109 110 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {111 throw ManifestFormatException("version '$version' has an out-of-range segment")112 }113 }114}115116117118119120121122123124125126fun isExpectedApk(127 packageName: String?,128 versionName: String?,129 versionCode: Long,130 expectedPackage: String,131 expectedVersionName: String,132 currentVersionCode: Long,133): Boolean {134 if (packageName != expectedPackage) return false135 if (versionName != expectedVersionName) return false136 137 138 139 140 return versionCode > currentVersionCode141}142143144145146147148internal fun packageVersionCodeForSdk(149 sdkInt: Int,150 legacyVersionCode: Int,151 modernVersionCode: Long,152): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()153154155156157158159internal fun packageSigningInfoFlagsForSdk(160 sdkInt: Int,161 legacyFlags: Int,162 modernFlags: Int,163): Int = if (sdkInt >= 28) modernFlags else legacyFlags164165166167168169170171172173174175176177178179180internal fun isSigningLineageCompatible(181 archiveCurrent: Set<String>,182 archiveHistory: Set<String>,183 installedCurrent: Set<String>,184): Boolean {185 if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false186 if (archiveCurrent.size != installedCurrent.size) return false187 return installedCurrent.all { it in archiveCurrent || it in archiveHistory }188}189190191192193194195196197198199200201202203204205206fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {207 val root = runCatching { JSONObject(body) }208 .getOrElse { throw ManifestFormatException("manifest is not JSON") }209210 val version = root.optTextOrNull("version")211 ?: throw ManifestFormatException("manifest has no version")212 validateSemver(version)213214 val platforms = root.optJSONObject("platforms")215 ?: throw ManifestFormatException("manifest has no platforms")216217 val platform = platforms.optJSONObject(platformKey)218 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")219220 val url = platform.optTextOrNull("url")221 ?: throw ManifestFormatException("artifact has no url")222 val checksum = parseArtifactChecksum(platform)223 ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")224225 return ReleaseManifest(226 version = version,227 url = url,228 checksum = checksum,229 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),230 )231}232233234235236237238239240241242243internal fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {244 val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)245 if (blake3 != null) return "blake3:$blake3"246 val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)247 if (sha256 != null) return "sha256:$sha256"248 return artifact.optTextOrNull("checksum")249}250251private fun isHex64(value: String): Boolean =252 value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }253254255internal fun JSONObject.optTextOrNull(key: String): String? {256 if (!has(key) || isNull(key)) return null257 return optString(key).trim().takeIf { it.isNotEmpty() }258}259260261data class ReleaseNote(val version: String, val notes: String?)262263264265266267268fun parseReleaseHistory(json: String): List<ReleaseNote> {269 val arr = JSONArray(json)270 return (0 until arr.length()).map { i ->271 val obj = arr.getJSONObject(i)272 ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes"))273 }274}275276277278279280281282283fun normalizeVersion(version: String): String =284 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')285286287288289290291292293294295fun compareVersions(a: String, b: String): Int {296 val left = normalizeVersion(a).split('.')297 val right = normalizeVersion(b).split('.')298 for (i in 0 until maxOf(left.size, right.size)) {299 val x = left.getOrNull(i)?.toIntOrNull() ?: 0300 val y = right.getOrNull(i)?.toIntOrNull() ?: 0301 if (x != y) return x.compareTo(y)302 }303 return 0304}305306fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0307308309310data class ExpectedChecksum(val algorithm: String, val hex: String)311312class ChecksumMismatchException(message: String) : Exception(message)313314private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")315316317fun parseChecksum(raw: String): ExpectedChecksum? {318 val separator = raw.indexOf(':')319 if (separator <= 0) return null320 val algorithm = raw.substring(0, separator).trim().lowercase()321 val hex = raw.substring(separator + 1).trim().lowercase()322 if (algorithm !in SUPPORTED_CHECKSUMS) return null323 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null324 return ExpectedChecksum(algorithm, hex)325}326327fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {328 "blake3" -> {329 val digest = Blake3Digest(32)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 val out = ByteArray(digest.digestSize)337 digest.doFinal(out, 0)338 out.toHex()339 }340 "sha256" -> {341 val digest = MessageDigest.getInstance("SHA-256")342 val buffer = ByteArray(64 * 1024)343 while (true) {344 val read = input.read(buffer)345 if (read <= 0) break346 digest.update(buffer, 0, read)347 }348 digest.digest().toHex()349 }350 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")351}352353fun hashFile(file: File, algorithm: String): String =354 file.inputStream().use { hashStream(it, algorithm) }355356private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }