1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONObject5import java.io.File6import java.io.InputStream7import java.security.MessageDigest891011121314151617181920data class ReleaseManifest(21 val version: String,22 val url: String,23 val checksum: String,24 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 3637383940414243 fun fromCachedJson(json: String): ReleaseManifest? = runCatching {44 val o = JSONObject(json)45 ReleaseManifest(46 version = o.getString("version").also(::validateSemver),47 url = o.getString("url"),48 checksum = o.getString("checksum"),49 notes = o.optTextOrNull("notes"),50 )51 }.getOrNull()52 }53}5455class ManifestFormatException(message: String) : Exception(message)565758class ApkIdentityException(message: String) : Exception(message)596061class DownloadSizeException(message: String) : Exception(message)62636465666768const val ANDROID_PLATFORM_KEY = "android-arm64"69const val ANDROID_X86_PLATFORM_KEY = "android-x86"7071fun androidPlatformKey(supportedAbis: Array<String>): String? = when {72 "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY73 "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY74 else -> null75}767778798081const val MAX_APK_BYTES = 50L * 1024 * 1024828384858687888990private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")9192internal fun validateSemver(version: String) {93 if (!STRICT_SEMVER.matches(version)) {94 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")95 }96 version.split('.').forEach { seg ->97 98 99 100 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {101 throw ManifestFormatException("version '$version' has an out-of-range segment")102 }103 }104}105106107108109110111112113114115116fun isExpectedApk(117 packageName: String?,118 versionName: String?,119 versionCode: Long,120 expectedPackage: String,121 expectedVersionName: String,122 currentVersionCode: Long,123): Boolean {124 if (packageName != expectedPackage) return false125 if (versionName != expectedVersionName) return false126 127 128 129 130 return versionCode > currentVersionCode131}132133134135136137138internal fun packageVersionCodeForSdk(139 sdkInt: Int,140 legacyVersionCode: Int,141 modernVersionCode: Long,142): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()143144145146147148149internal fun packageSigningInfoFlagsForSdk(150 sdkInt: Int,151 legacyFlags: Int,152 modernFlags: Int,153): Int = if (sdkInt >= 28) modernFlags else legacyFlags154155156157158159160161162163164165166167168169170internal fun isSigningLineageCompatible(171 archiveCurrent: Set<String>,172 archiveHistory: Set<String>,173 installedCurrent: Set<String>,174): Boolean {175 if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false176 if (archiveCurrent.size != installedCurrent.size) return false177 return installedCurrent.all { it in archiveCurrent || it in archiveHistory }178}179180181182183184185186187188189190191192193194195196197fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {198 val root = runCatching { JSONObject(body) }199 .getOrElse { throw ManifestFormatException("manifest is not JSON") }200201 val version = root.optTextOrNull("version")202 ?: throw ManifestFormatException("manifest has no version")203 validateSemver(version)204205 val platforms = root.optJSONObject("platforms")206 ?: throw ManifestFormatException("manifest has no platforms")207208 val platform = platforms.optJSONObject(platformKey)209 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")210211 val url = platform.optTextOrNull("url")212 ?: throw ManifestFormatException("artifact has no url")213 val checksum = parseArtifactChecksum(platform)214 ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")215216 return ReleaseManifest(217 version = version,218 url = url,219 checksum = checksum,220 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),221 )222}223224225226227228229230231232233234private fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {235 val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)236 if (blake3 != null) return "blake3:$blake3"237 val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)238 if (sha256 != null) return "sha256:$sha256"239 return artifact.optTextOrNull("checksum")240}241242private fun isHex64(value: String): Boolean =243 value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }244245246internal fun JSONObject.optTextOrNull(key: String): String? {247 if (!has(key) || isNull(key)) return null248 return optString(key).trim().takeIf { it.isNotEmpty() }249}250251252253254255256257258fun normalizeVersion(version: String): String =259 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')260261262263264265266267268269270fun compareVersions(a: String, b: String): Int {271 val left = normalizeVersion(a).split('.')272 val right = normalizeVersion(b).split('.')273 for (i in 0 until maxOf(left.size, right.size)) {274 val x = left.getOrNull(i)?.toIntOrNull() ?: 0275 val y = right.getOrNull(i)?.toIntOrNull() ?: 0276 if (x != y) return x.compareTo(y)277 }278 return 0279}280281fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0282283284285data class ExpectedChecksum(val algorithm: String, val hex: String)286287class ChecksumMismatchException(message: String) : Exception(message)288289private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")290291292fun parseChecksum(raw: String): ExpectedChecksum? {293 val separator = raw.indexOf(':')294 if (separator <= 0) return null295 val algorithm = raw.substring(0, separator).trim().lowercase()296 val hex = raw.substring(separator + 1).trim().lowercase()297 if (algorithm !in SUPPORTED_CHECKSUMS) return null298 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null299 return ExpectedChecksum(algorithm, hex)300}301302fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {303 "blake3" -> {304 val digest = Blake3Digest(32)305 val buffer = ByteArray(64 * 1024)306 while (true) {307 val read = input.read(buffer)308 if (read <= 0) break309 digest.update(buffer, 0, read)310 }311 val out = ByteArray(digest.digestSize)312 digest.doFinal(out, 0)313 out.toHex()314 }315 "sha256" -> {316 val digest = MessageDigest.getInstance("SHA-256")317 val buffer = ByteArray(64 * 1024)318 while (true) {319 val read = input.read(buffer)320 if (read <= 0) break321 digest.update(buffer, 0, read)322 }323 digest.digest().toHex()324 }325 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")326}327328fun hashFile(file: File, algorithm: String): String =329 file.inputStream().use { hashStream(it, algorithm) }330331private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }