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)626364656667686970const val ANDROID_PLATFORM_KEY = "android-arm64"717273747576const val MAX_APK_BYTES = 50L * 1024 * 1024777879808182838485private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")8687internal fun validateSemver(version: String) {88 if (!STRICT_SEMVER.matches(version)) {89 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")90 }91 version.split('.').forEach { seg ->92 93 94 95 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {96 throw ManifestFormatException("version '$version' has an out-of-range segment")97 }98 }99}100101102103104105106107108109110fun isExpectedApk(111 packageName: String?,112 versionCode: Long,113 expectedPackage: String,114 expectedVersionCode: Int,115): Boolean {116 if (packageName != expectedPackage && packageName != "$expectedPackage.debug") return false117 return versionCode >= expectedVersionCode118}119120121122123124125126127128129130131132133134135136137fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {138 val root = runCatching { JSONObject(body) }139 .getOrElse { throw ManifestFormatException("manifest is not JSON") }140141 val version = root.optTextOrNull("version")142 ?: throw ManifestFormatException("manifest has no version")143 validateSemver(version)144145 val platforms = root.optJSONObject("platforms")146 ?: throw ManifestFormatException("manifest has no platforms")147148 val platform = platforms.optJSONObject(platformKey)149 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")150151 val url = platform.optTextOrNull("url")152 ?: throw ManifestFormatException("artifact has no url")153 val checksum = platform.optTextOrNull("checksum")154 ?: throw ManifestFormatException("artifact has no checksum")155156 return ReleaseManifest(157 version = version,158 url = url,159 checksum = checksum,160 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),161 )162}163164165internal fun JSONObject.optTextOrNull(key: String): String? {166 if (!has(key) || isNull(key)) return null167 return optString(key).trim().takeIf { it.isNotEmpty() }168}169170171172173174175176177fun normalizeVersion(version: String): String =178 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')179180181182183184185186187188189fun compareVersions(a: String, b: String): Int {190 val left = normalizeVersion(a).split('.')191 val right = normalizeVersion(b).split('.')192 for (i in 0 until maxOf(left.size, right.size)) {193 val x = left.getOrNull(i)?.toIntOrNull() ?: 0194 val y = right.getOrNull(i)?.toIntOrNull() ?: 0195 if (x != y) return x.compareTo(y)196 }197 return 0198}199200fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0201202203204data class ExpectedChecksum(val algorithm: String, val hex: String)205206class ChecksumMismatchException(message: String) : Exception(message)207208private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")209210211fun parseChecksum(raw: String): ExpectedChecksum? {212 val separator = raw.indexOf(':')213 if (separator <= 0) return null214 val algorithm = raw.substring(0, separator).trim().lowercase()215 val hex = raw.substring(separator + 1).trim().lowercase()216 if (algorithm !in SUPPORTED_CHECKSUMS) return null217 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null218 return ExpectedChecksum(algorithm, hex)219}220221fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {222 "blake3" -> {223 val digest = Blake3Digest(32)224 val buffer = ByteArray(64 * 1024)225 while (true) {226 val read = input.read(buffer)227 if (read <= 0) break228 digest.update(buffer, 0, read)229 }230 val out = ByteArray(digest.digestSize)231 digest.doFinal(out, 0)232 out.toHex()233 }234 "sha256" -> {235 val digest = MessageDigest.getInstance("SHA-256")236 val buffer = ByteArray(64 * 1024)237 while (true) {238 val read = input.read(buffer)239 if (read <= 0) break240 digest.update(buffer, 0, read)241 }242 digest.digest().toHex()243 }244 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")245}246247fun hashFile(file: File, algorithm: String): String =248 file.inputStream().use { hashStream(it, algorithm) }249250private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }