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 = platform.optTextOrNull("checksum")214 ?: throw ManifestFormatException("artifact has no checksum")215216 return ReleaseManifest(217 version = version,218 url = url,219 checksum = checksum,220 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),221 )222}223224225internal fun JSONObject.optTextOrNull(key: String): String? {226 if (!has(key) || isNull(key)) return null227 return optString(key).trim().takeIf { it.isNotEmpty() }228}229230231232233234235236237fun normalizeVersion(version: String): String =238 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')239240241242243244245246247248249fun compareVersions(a: String, b: String): Int {250 val left = normalizeVersion(a).split('.')251 val right = normalizeVersion(b).split('.')252 for (i in 0 until maxOf(left.size, right.size)) {253 val x = left.getOrNull(i)?.toIntOrNull() ?: 0254 val y = right.getOrNull(i)?.toIntOrNull() ?: 0255 if (x != y) return x.compareTo(y)256 }257 return 0258}259260fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0261262263264data class ExpectedChecksum(val algorithm: String, val hex: String)265266class ChecksumMismatchException(message: String) : Exception(message)267268private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")269270271fun parseChecksum(raw: String): ExpectedChecksum? {272 val separator = raw.indexOf(':')273 if (separator <= 0) return null274 val algorithm = raw.substring(0, separator).trim().lowercase()275 val hex = raw.substring(separator + 1).trim().lowercase()276 if (algorithm !in SUPPORTED_CHECKSUMS) return null277 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null278 return ExpectedChecksum(algorithm, hex)279}280281fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {282 "blake3" -> {283 val digest = Blake3Digest(32)284 val buffer = ByteArray(64 * 1024)285 while (true) {286 val read = input.read(buffer)287 if (read <= 0) break288 digest.update(buffer, 0, read)289 }290 val out = ByteArray(digest.digestSize)291 digest.doFinal(out, 0)292 out.toHex()293 }294 "sha256" -> {295 val digest = MessageDigest.getInstance("SHA-256")296 val buffer = ByteArray(64 * 1024)297 while (true) {298 val read = input.read(buffer)299 if (read <= 0) break300 digest.update(buffer, 0, read)301 }302 digest.digest().toHex()303 }304 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")305}306307fun hashFile(file: File, algorithm: String): String =308 file.inputStream().use { hashStream(it, algorithm) }309310private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }