1package com.peachpasswords.updates23import android.content.Context4import android.content.Intent5import android.content.pm.PackageInfo6import android.content.pm.PackageManager7import android.content.pm.Signature8import android.net.Uri9import android.os.Build10import android.provider.Settings11import androidx.core.content.FileProvider12import androidx.annotation.RequiresApi13import com.peachpasswords.BuildConfig14import dagger.hilt.android.qualifiers.ApplicationContext15import kotlinx.coroutines.Dispatchers16import kotlinx.coroutines.flow.MutableStateFlow17import kotlinx.coroutines.flow.StateFlow18import kotlinx.coroutines.flow.asStateFlow19import kotlinx.coroutines.CoroutineScope20import kotlinx.coroutines.SupervisorJob21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import okhttp3.OkHttpClient24import okhttp3.Request25import java.io.File26import java.io.IOException27import java.security.MessageDigest28import java.util.concurrent.TimeUnit29import javax.inject.Inject30import javax.inject.Singleton3132333435363738394041@Singleton42class UpdateRepository @Inject constructor(43 @ApplicationContext private val context: Context,44) {4546 private val client: OkHttpClient by lazy {47 OkHttpClient.Builder()48 .callTimeout(5, TimeUnit.MINUTES)49 .build()50 }5152 private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }5354 55 val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5657 private val _available = MutableStateFlow(cachedAvailable())58 59 val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()6061 private val _whatsNew = MutableStateFlow<String?>(null)62 63 val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6465 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)6667 private val _openRequested = MutableStateFlow(false)68 69 val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()7071 init {72 refreshWhatsNew()73 }7475 fun requestOpen() { _openRequested.value = true }7677 fun consumeOpenRequest() { _openRequested.value = false }7879 8081 82838485 suspend fun check(): UpdateCheckResult {86 val manifest = fetchLatest()87 val highWater = prefs.getString(KEY_VERSION_HIGH_WATER, null)88 if (highWater != null && compareVersions(manifest.version, highWater) < 0) {89 return UpdateCheckResult.RollbackSuppressed(manifest.version, highWater)90 }9192 if (highWater == null || compareVersions(manifest.version, highWater) > 0) {93 prefs.edit().putString(KEY_VERSION_HIGH_WATER, manifest.version).apply()94 }9596 val newer = isNewerVersion(manifest.version, currentVersion)97 if (newer) {98 _available.value = manifest99 prefs.edit().putString(KEY_LATEST_MANIFEST, manifest.toJson()).apply()100 }101102 103 104 if (normalizeVersion(manifest.version) == currentVersion) {105 if (manifest.notes == null) {106 markChangelogSeen()107 } else {108 prefs.edit()109 .putString(KEY_NOTES_VERSION, currentVersion)110 .putString(KEY_NOTES, manifest.notes)111 .apply()112 refreshWhatsNew()113 }114 }115 return if (newer) UpdateCheckResult.Accepted(manifest) else UpdateCheckResult.NoUpdate(manifest.version)116 }117118 private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {119 val request = Request.Builder()120 .url(LATEST_RELEASE_URL)121 .header("User-Agent", USER_AGENT)122 .build()123 client.newCall(request).execute().use { response ->124 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")125 val platform = androidPlatformKey(Build.SUPPORTED_ABIS)126 ?: throw ManifestFormatException("this Android ABI is not supported")127 parseReleaseManifest(response.body?.string().orEmpty(), platform)128 }129 }130131 132133 134135136137138 suspend fun downloadAndVerify(139 manifest: ReleaseManifest,140 onProgress: (Float) -> Unit = {},141 ): File = withContext(Dispatchers.IO) {142 val expected = parseChecksum(manifest.checksum)143 ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")144145 146 147 148 val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }149 val target = File(dir, "peach-${manifest.version}.apk")150 require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {151 "refusing to write outside cache dir"152 }153154 155 156 dir.listFiles()?.forEach { if (it != target) it.delete() }157 if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {158 159 160 161 verifyApkIdentity(target, manifest.version)162 onProgress(1f)163 return@withContext target164 }165 target.delete()166167 val request = Request.Builder()168 .url(manifest.url)169 .header("User-Agent", USER_AGENT)170 .build()171 client.newCall(request).execute().use { response ->172 if (!response.isSuccessful) throw IOException("HTTP ${response.code}")173 val body = response.body ?: throw IOException("empty response")174 val advertised = body.contentLength()175 176 177 178 if (advertised in 1..MAX_APK_BYTES) {179 180 } else if (advertised > MAX_APK_BYTES) {181 throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")182 }183 try {184 body.byteStream().use { input ->185 target.outputStream().use { output ->186 val buffer = ByteArray(64 * 1024)187 var written = 0L188 while (true) {189 val read = input.read(buffer)190 if (read <= 0) break191 written += read192 if (written > MAX_APK_BYTES) {193 194 195 196 197 throw DownloadSizeException(198 "APK exceeded $MAX_APK_BYTES bytes mid-download",199 )200 }201 output.write(buffer, 0, read)202 if (advertised > 0) onProgress(203 (written.toFloat() / advertised).coerceIn(0f, 1f),204 )205 }206 }207 }208 } catch (e: DownloadSizeException) {209 target.delete()210 throw e211 }212 }213214 val actual = hashFile(target, expected.algorithm)215 if (actual != expected.hex) {216 target.delete()217 throw ChecksumMismatchException(218 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",219 )220 }221 verifyApkIdentity(target, manifest.version)222 onProgress(1f)223 target224 }225226 227228229230231232233 private fun verifyApkIdentity(apk: File, expectedVersionName: String) {234 val packageManager = context.packageManager235 val info = packageManager.getPackageArchiveInfo(236 apk.absolutePath,237 signingInfoFlags(),238 ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")239 val archiveVersionCode = archiveVersionCode(info)240 if (!isExpectedApk(241 packageName = info.packageName,242 versionName = info.versionName,243 versionCode = archiveVersionCode,244 expectedPackage = context.packageName,245 expectedVersionName = expectedVersionName,246 currentVersionCode = BuildConfig.VERSION_CODE.toLong(),247 )248 ) {249 rejectIdentity(250 apk,251 "APK identity mismatch: package=${info.packageName} " +252 "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +253 "expected ${context.packageName} versionName=$expectedVersionName " +254 "and versionCode>${BuildConfig.VERSION_CODE}",255 )256 }257258 val installed = runCatching {259 packageManager.getPackageInfo(context.packageName, signingInfoFlags())260 }.getOrElse {261 rejectIdentity(apk, "installed Peach package could not be inspected")262 }263 val archiveSigning = signingCertificates(info)264 val installedSigning = signingCertificates(installed)265 if (!isSigningLineageCompatible(266 archiveCurrent = archiveSigning.current,267 archiveHistory = archiveSigning.history,268 installedCurrent = installedSigning.current,269 )270 ) {271 rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")272 }273 }274275 private data class SigningCertificates(276 val current: Set<String>,277 val history: Set<String>,278 )279280 281282283284285 @Suppress("DEPRECATION", "InlinedApi")286 private fun signingInfoFlags(): Int =287 packageSigningInfoFlagsForSdk(288 sdkInt = Build.VERSION.SDK_INT,289 legacyFlags = PackageManager.GET_SIGNATURES,290 modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,291 )292293 @Suppress("DEPRECATION", "InlinedApi")294 private fun archiveVersionCode(info: PackageInfo): Long =295 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {296 packageVersionCodeForSdk(297 sdkInt = Build.VERSION.SDK_INT,298 legacyVersionCode = info.versionCode,299 modernVersionCode = modernVersionCode(info),300 )301 } else {302 packageVersionCodeForSdk(303 sdkInt = Build.VERSION.SDK_INT,304 legacyVersionCode = info.versionCode,305 modernVersionCode = info.versionCode.toLong(),306 )307 }308309 @RequiresApi(Build.VERSION_CODES.P)310 private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode311312 @Suppress("DEPRECATION", "InlinedApi")313 private fun signingCertificates(info: PackageInfo): SigningCertificates {314 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {315 val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())316 val current = signingInfo.apkContentsSigners.orEmpty()317 .map(::certificateSha256)318 .toSet()319 val history = signingInfo.signingCertificateHistory.orEmpty()320 .map(::certificateSha256)321 .toSet()322 return SigningCertificates(current = current, history = history)323 }324 val current = info.signatures.orEmpty()325 .map(::certificateSha256)326 .toSet()327 return SigningCertificates(current = current, history = emptySet())328 }329330 private fun certificateSha256(signature: Signature): String =331 MessageDigest.getInstance("SHA-256")332 .digest(signature.toByteArray())333 .joinToString("") { byte -> "%02x".format(byte) }334335 private fun rejectIdentity(apk: File, message: String): Nothing {336 apk.delete()337 throw ApkIdentityException(message)338 }339340 fun discardDownloads() {341 File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }342 }343344 345346 347 fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()348349 fun unknownSourcesSettingsIntent(): Intent =350 Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))351 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)352353 354355356357 fun installIntent(apk: File): Intent {358 val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)359 return Intent(Intent.ACTION_VIEW)360 .setDataAndType(uri, APK_MIME)361 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)362 }363364 365366 367368369370371372 fun refreshWhatsNew() {373 val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)374 if (lastSeen == null) {375 markChangelogSeen()376 return377 }378 if (lastSeen == currentVersion) {379 _whatsNew.value = null380 return381 }382 383 384 _whatsNew.value = prefs.getString(KEY_NOTES, null)385 ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }386 scope.launch {387 val accumulated = fetchAccumulatedNotes(lastSeen, currentVersion)388 if (accumulated != null) _whatsNew.value = accumulated389 }390 }391392 fun markChangelogSeen() {393 prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()394 _whatsNew.value = null395 }396397 398399 400 fun shouldNotifyFor(version: String): Boolean =401 prefs.getString(KEY_NOTIFIED_VERSION, null) != version402403 fun markNotified(version: String) {404 prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()405 }406407 private fun cachedAvailable(): ReleaseManifest? =408 prefs.getString(KEY_LATEST_MANIFEST, null)409 ?.let { runCatching { parseReleaseManifest(it, androidPlatformKey(Build.SUPPORTED_ABIS) ?: ANDROID_PLATFORM_KEY) }.getOrNull() }410 ?.takeIf { isNewerVersion(it.version, currentVersion) }411412 413414415416417 suspend fun fetchHistory(): List<ReleaseNote>? = withContext(Dispatchers.IO) {418 runCatching {419 val request = Request.Builder()420 .url(HISTORY_URL)421 .header("User-Agent", USER_AGENT)422 .build()423 client.newCall(request).execute().use { response ->424 if (!response.isSuccessful) return@runCatching null425 parseReleaseHistory(response.body?.string().orEmpty())426 }427 }.getOrNull()428 }429430 431432433434435 private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? {436 437 val inRange = mutableListOf<ReleaseNote>()438 for (entry in fetchHistory() ?: return null) {439 if (entry.version == lastSeen) break440 if (!entry.notes.isNullOrBlank()) inRange.add(entry)441 if (entry.version == current) break442 }443 if (inRange.isEmpty()) return null444 445 inRange.reverse()446 return inRange.joinToString("\n\n") { "## v${it.version}\n\n${it.notes}" }447 }448449 companion object {450 451 const val KEPR_REPO = "peach-android"452 const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"453 const val HISTORY_URL = "https://kepr.uk/api/$KEPR_REPO/releases/history"454 private const val APK_MIME = "application/vnd.android.package-archive"455 private const val USER_AGENT = "Peach-Android"456 private const val CACHE_DIR = "updates"457458 private const val PREFS = "peach_updates"459 private const val KEY_LATEST_MANIFEST = "latest_manifest"460 private const val KEY_VERSION_HIGH_WATER = "version_high_water"461 private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"462 private const val KEY_NOTES_VERSION = "notes_version"463 private const val KEY_NOTES = "notes"464 private const val KEY_NOTIFIED_VERSION = "notified_version"465 }466}