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 fun fromCachedJson(json: String): ReleaseManifest? = runCatching {36 val o = JSONObject(json)37 ReleaseManifest(38 version = o.getString("version"),39 url = o.getString("url"),40 checksum = o.getString("checksum"),41 notes = o.optTextOrNull("notes"),42 )43 }.getOrNull()44 }45}4647class ManifestFormatException(message: String) : Exception(message)484950class ApkIdentityException(message: String) : Exception(message)515253class DownloadSizeException(message: String) : Exception(message)545556575859606162const val ANDROID_PLATFORM_KEY = "android-arm64"636465666768const val MAX_APK_BYTES = 50L * 1024 * 1024697071727374757677private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")7879internal fun validateSemver(version: String) {80 if (!STRICT_SEMVER.matches(version)) {81 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")82 }83 version.split('.').forEach { seg ->84 85 86 87 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {88 throw ManifestFormatException("version '$version' has an out-of-range segment")89 }90 }91}9293949596979899100101102fun isExpectedApk(103 packageName: String?,104 versionCode: Long,105 expectedPackage: String,106 expectedVersionCode: Int,107): Boolean {108 if (packageName != expectedPackage && packageName != "$expectedPackage.debug") return false109 return versionCode >= expectedVersionCode110}111112113114115116117118119120121122123124125126127128129fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {130 val root = runCatching { JSONObject(body) }131 .getOrElse { throw ManifestFormatException("manifest is not JSON") }132133 val version = root.optTextOrNull("version")134 ?: throw ManifestFormatException("manifest has no version")135 validateSemver(version)136137 val platforms = root.optJSONObject("platforms")138 ?: throw ManifestFormatException("manifest has no platforms")139140 val platform = platforms.optJSONObject(platformKey)141 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")142143 val url = platform.optTextOrNull("url")144 ?: throw ManifestFormatException("artifact has no url")145 val checksum = platform.optTextOrNull("checksum")146 ?: throw ManifestFormatException("artifact has no checksum")147148 return ReleaseManifest(149 version = version,150 url = url,151 checksum = checksum,152 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),153 )154}155156157internal fun JSONObject.optTextOrNull(key: String): String? {158 if (!has(key) || isNull(key)) return null159 return optString(key).trim().takeIf { it.isNotEmpty() }160}161162163164165166167168169fun normalizeVersion(version: String): String =170 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')171172173fun compareVersions(a: String, b: String): Int {174 val left = normalizeVersion(a).split('.')175 val right = normalizeVersion(b).split('.')176 for (i in 0 until maxOf(left.size, right.size)) {177 val x = left.getOrNull(i)?.toIntOrNull() ?: 0178 val y = right.getOrNull(i)?.toIntOrNull() ?: 0179 if (x != y) return x.compareTo(y)180 }181 return 0182}183184fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0185186187188data class ExpectedChecksum(val algorithm: String, val hex: String)189190class ChecksumMismatchException(message: String) : Exception(message)191192private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")193194195fun parseChecksum(raw: String): ExpectedChecksum? {196 val separator = raw.indexOf(':')197 if (separator <= 0) return null198 val algorithm = raw.substring(0, separator).trim().lowercase()199 val hex = raw.substring(separator + 1).trim().lowercase()200 if (algorithm !in SUPPORTED_CHECKSUMS) return null201 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null202 return ExpectedChecksum(algorithm, hex)203}204205fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {206 "blake3" -> {207 val digest = Blake3Digest(32)208 val buffer = ByteArray(64 * 1024)209 while (true) {210 val read = input.read(buffer)211 if (read <= 0) break212 digest.update(buffer, 0, read)213 }214 val out = ByteArray(digest.digestSize)215 digest.doFinal(out, 0)216 out.toHex()217 }218 "sha256" -> {219 val digest = MessageDigest.getInstance("SHA-256")220 val buffer = ByteArray(64 * 1024)221 while (true) {222 val read = input.read(buffer)223 if (read <= 0) break224 digest.update(buffer, 0, read)225 }226 digest.digest().toHex()227 }228 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")229}230231fun hashFile(file: File, algorithm: String): String =232 file.inputStream().use { hashStream(it, algorithm) }233234private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }