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 fun toJson(): String = JSONObject().apply {29 put("version", version)30 put("url", url)31 put("checksum", checksum)32 if (notes != null) put("notes", notes)33 }.toString()3435 companion object {36 3738394041424344 fun fromCachedJson(json: String): ReleaseManifest? = runCatching {45 val o = JSONObject(json)46 ReleaseManifest(47 version = o.getString("version").also(::validateSemver),48 url = o.getString("url"),49 checksum = o.getString("checksum"),50 notes = o.optTextOrNull("notes"),51 )52 }.getOrNull()53 }54}5556class ManifestFormatException(message: String) : Exception(message)575859class ApkIdentityException(message: String) : Exception(message)606162class DownloadSizeException(message: String) : Exception(message)63646566676869const val ANDROID_PLATFORM_KEY = "android-arm64"70const val ANDROID_X86_PLATFORM_KEY = "android-x86"7172fun androidPlatformKey(supportedAbis: Array<String>): String? = when {73 "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY74 "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY75 else -> null76}777879808182const val MAX_APK_BYTES = 50L * 1024 * 1024838485868788899091private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")9293internal fun validateSemver(version: String) {94 if (!STRICT_SEMVER.matches(version)) {95 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")96 }97 version.split('.').forEach { seg ->98 99 100 101 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {102 throw ManifestFormatException("version '$version' has an out-of-range segment")103 }104 }105}106107108109110111112113114115116117fun isExpectedApk(118 packageName: String?,119 versionName: String?,120 versionCode: Long,121 expectedPackage: String,122 expectedVersionName: String,123 currentVersionCode: Long,124): Boolean {125 if (packageName != expectedPackage) return false126 if (versionName != expectedVersionName) return false127 128 129 130 131 return versionCode > currentVersionCode132}133134135136137138139internal fun packageVersionCodeForSdk(140 sdkInt: Int,141 legacyVersionCode: Int,142 modernVersionCode: Long,143): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()144145146147148149150internal fun packageSigningInfoFlagsForSdk(151 sdkInt: Int,152 legacyFlags: Int,153 modernFlags: Int,154): Int = if (sdkInt >= 28) modernFlags else legacyFlags155156157158159160161162163164165166167168169170171internal fun isSigningLineageCompatible(172 archiveCurrent: Set<String>,173 archiveHistory: Set<String>,174 installedCurrent: Set<String>,175): Boolean {176 if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false177 if (archiveCurrent.size != installedCurrent.size) return false178 return installedCurrent.all { it in archiveCurrent || it in archiveHistory }179}180181182183184185186187188189190191192193194195196197198fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {199 val root = runCatching { JSONObject(body) }200 .getOrElse { throw ManifestFormatException("manifest is not JSON") }201202 val version = root.optTextOrNull("version")203 ?: throw ManifestFormatException("manifest has no version")204 validateSemver(version)205206 val platforms = root.optJSONObject("platforms")207 ?: throw ManifestFormatException("manifest has no platforms")208209 val platform = platforms.optJSONObject(platformKey)210 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")211212 val url = platform.optTextOrNull("url")213 ?: throw ManifestFormatException("artifact has no url")214 val checksum = parseArtifactChecksum(platform)215 ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")216217 return ReleaseManifest(218 version = version,219 url = url,220 checksum = checksum,221 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),222 )223}224225226227228229230231232233234235private fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {236 val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)237 if (blake3 != null) return "blake3:$blake3"238 val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)239 if (sha256 != null) return "sha256:$sha256"240 return artifact.optTextOrNull("checksum")241}242243private fun isHex64(value: String): Boolean =244 value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }245246247internal fun JSONObject.optTextOrNull(key: String): String? {248 if (!has(key) || isNull(key)) return null249 return optString(key).trim().takeIf { it.isNotEmpty() }250}251252253data class ReleaseNote(val version: String, val notes: String?)254255256257258259260fun parseReleaseHistory(json: String): List<ReleaseNote> {261 val arr = JSONArray(json)262 return (0 until arr.length()).map { i ->263 val obj = arr.getJSONObject(i)264 ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes"))265 }266}267268269270271272273274275fun normalizeVersion(version: String): String =276 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')277278279280281282283284285286287fun compareVersions(a: String, b: String): Int {288 val left = normalizeVersion(a).split('.')289 val right = normalizeVersion(b).split('.')290 for (i in 0 until maxOf(left.size, right.size)) {291 val x = left.getOrNull(i)?.toIntOrNull() ?: 0292 val y = right.getOrNull(i)?.toIntOrNull() ?: 0293 if (x != y) return x.compareTo(y)294 }295 return 0296}297298fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0299300301302data class ExpectedChecksum(val algorithm: String, val hex: String)303304class ChecksumMismatchException(message: String) : Exception(message)305306private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")307308309fun parseChecksum(raw: String): ExpectedChecksum? {310 val separator = raw.indexOf(':')311 if (separator <= 0) return null312 val algorithm = raw.substring(0, separator).trim().lowercase()313 val hex = raw.substring(separator + 1).trim().lowercase()314 if (algorithm !in SUPPORTED_CHECKSUMS) return null315 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null316 return ExpectedChecksum(algorithm, hex)317}318319fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {320 "blake3" -> {321 val digest = Blake3Digest(32)322 val buffer = ByteArray(64 * 1024)323 while (true) {324 val read = input.read(buffer)325 if (read <= 0) break326 digest.update(buffer, 0, read)327 }328 val out = ByteArray(digest.digestSize)329 digest.doFinal(out, 0)330 out.toHex()331 }332 "sha256" -> {333 val digest = MessageDigest.getInstance("SHA-256")334 val buffer = ByteArray(64 * 1024)335 while (true) {336 val read = input.read(buffer)337 if (read <= 0) break338 digest.update(buffer, 0, read)339 }340 digest.digest().toHex()341 }342 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")343}344345fun hashFile(file: File, algorithm: String): String =346 file.inputStream().use { hashStream(it, algorithm) }347348private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }