package com.peachpasswords.updates import android.content.Context import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.Signature import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider import androidx.annotation.RequiresApi import com.peachpasswords.BuildConfig import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.IOException import java.security.MessageDigest import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton /** * The Kepr side of in-app updates: what the newest release is, fetching its APK, * and proving that APK is the one Kepr published before it is ever handed to the * package installer. * * Nothing here downloads or installs on its own. `check()` is the only thing the * background worker and app-open path call; everything past that is driven by a * user tap, per the "never do anything silently with the user's device" rule. */ @Singleton class UpdateRepository @Inject constructor( @ApplicationContext private val context: Context, ) { private val client: OkHttpClient by lazy { OkHttpClient.Builder() .callTimeout(5, TimeUnit.MINUTES) .build() } private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) } /** The running build's release version, with the debug `-debug` suffix stripped. */ val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME) private val _available = MutableStateFlow(cachedAvailable()) /** The newest release, only when it is actually newer than [currentVersion]. */ val available: StateFlow = _available.asStateFlow() private val _whatsNew = MutableStateFlow(null) /** Notes for the running version, set only while they are still unread. */ val whatsNew: StateFlow = _whatsNew.asStateFlow() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _openRequested = MutableStateFlow(false) /** Set when the app was launched by tapping the update notification. */ val openRequested: StateFlow = _openRequested.asStateFlow() init { refreshWhatsNew() } fun requestOpen() { _openRequested.value = true } fun consumeOpenRequest() { _openRequested.value = false } /* ──────────────────────────────── check ──────────────────────────────── */ /** * Fetch and reconcile the release manifest. A suppressed rollback never * changes the currently offerable manifest or the persisted high-water mark. */ suspend fun check(): UpdateCheckResult { val manifest = fetchLatest() val highWater = prefs.getString(KEY_VERSION_HIGH_WATER, null) if (highWater != null && compareVersions(manifest.version, highWater) < 0) { return UpdateCheckResult.RollbackSuppressed(manifest.version, highWater) } if (highWater == null || compareVersions(manifest.version, highWater) > 0) { prefs.edit().putString(KEY_VERSION_HIGH_WATER, manifest.version).apply() } val newer = isNewerVersion(manifest.version, currentVersion) if (newer) { _available.value = manifest prefs.edit().putString(KEY_LATEST_MANIFEST, manifest.toJson()).apply() } // The manifest for the version we're already running is the only place // the post-update changelog can come from, so cache it when observed. if (normalizeVersion(manifest.version) == currentVersion) { if (manifest.notes == null) { markChangelogSeen() } else { prefs.edit() .putString(KEY_NOTES_VERSION, currentVersion) .putString(KEY_NOTES, manifest.notes) .apply() refreshWhatsNew() } } return if (newer) UpdateCheckResult.Accepted(manifest) else UpdateCheckResult.NoUpdate(manifest.version) } private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) { val request = Request.Builder() .url(LATEST_RELEASE_URL) .header("User-Agent", USER_AGENT) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IOException("HTTP ${response.code}") val platform = androidPlatformKey(Build.SUPPORTED_ABIS) ?: throw ManifestFormatException("this Android ABI is not supported") parseReleaseManifest(response.body?.string().orEmpty(), platform) } } /* ────────────────────────── download + verify ────────────────────────── */ /** * Download the release APK into app cache and verify its checksum. A file that * does not match is deleted and the call fails — an unverified APK is never * left on disk for something else to pick up, and never reaches the installer. */ suspend fun downloadAndVerify( manifest: ReleaseManifest, onProgress: (Float) -> Unit = {}, ): File = withContext(Dispatchers.IO) { val expected = parseChecksum(manifest.checksum) ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'") // parseReleaseManifest has already enforced strict SemVer, so the version // is guaranteed to be a clean `major.minor.patch` with no path-traversal // characters; using it in the filename is safe. val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() } val target = File(dir, "peach-${manifest.version}.apk") require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) { "refusing to write outside cache dir" } // A previously verified download of the same version is reusable; anything // else in here is a stale or half-finished attempt. dir.listFiles()?.forEach { if (it != target) it.delete() } if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) { // Even on a cache hit we re-prove identity: a tampered file in the // cache slot must not reach the installer any more than a tampered // download must. verifyApkIdentity(target, manifest.version) onProgress(1f) return@withContext target } target.delete() val request = Request.Builder() .url(manifest.url) .header("User-Agent", USER_AGENT) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) throw IOException("HTTP ${response.code}") val body = response.body ?: throw IOException("empty response") val advertised = body.contentLength() // Reject before streaming if the server is honest about being too // big; Content-Length can be -1 (chunked / unknown) and the // streaming cap below catches that case as bytes arrive. if (advertised in 1..MAX_APK_BYTES) { // known and within bounds — nothing to do } else if (advertised > MAX_APK_BYTES) { throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES") } try { body.byteStream().use { input -> target.outputStream().use { output -> val buffer = ByteArray(64 * 1024) var written = 0L while (true) { val read = input.read(buffer) if (read <= 0) break written += read if (written > MAX_APK_BYTES) { // Streaming cap: a server that lies about its // Content-Length, or chunks past it, is still // bounded here. The partial file is removed so // a later retry doesn't pick up half an APK. throw DownloadSizeException( "APK exceeded $MAX_APK_BYTES bytes mid-download", ) } output.write(buffer, 0, read) if (advertised > 0) onProgress( (written.toFloat() / advertised).coerceIn(0f, 1f), ) } } } } catch (e: DownloadSizeException) { target.delete() throw e } } val actual = hashFile(target, expected.algorithm) if (actual != expected.hex) { target.delete() throw ChecksumMismatchException( "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual", ) } verifyApkIdentity(target, manifest.version) onProgress(1f) target } /** * Confirm the cached APK is the exact package/version this updater belongs * to and that its signer is the currently installed Peach signer (or a * forward v3 descendant with a proof-of-rotation back to it). PackageManager * is happy to parse any well-formed APK the cache may contain, so both * identity and signing lineage are checked before the installer sees it. */ private fun verifyApkIdentity(apk: File, expectedVersionName: String) { val packageManager = context.packageManager val info = packageManager.getPackageArchiveInfo( apk.absolutePath, signingInfoFlags(), ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package") val archiveVersionCode = archiveVersionCode(info) if (!isExpectedApk( packageName = info.packageName, versionName = info.versionName, versionCode = archiveVersionCode, expectedPackage = context.packageName, expectedVersionName = expectedVersionName, currentVersionCode = BuildConfig.VERSION_CODE.toLong(), ) ) { rejectIdentity( apk, "APK identity mismatch: package=${info.packageName} " + "versionName=${info.versionName}, versionCode=$archiveVersionCode, " + "expected ${context.packageName} versionName=$expectedVersionName " + "and versionCode>${BuildConfig.VERSION_CODE}", ) } val installed = runCatching { packageManager.getPackageInfo(context.packageName, signingInfoFlags()) }.getOrElse { rejectIdentity(apk, "installed Peach package could not be inspected") } val archiveSigning = signingCertificates(info) val installedSigning = signingCertificates(installed) if (!isSigningLineageCompatible( archiveCurrent = archiveSigning.current, archiveHistory = archiveSigning.history, installedCurrent = installedSigning.current, ) ) { rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage") } } private data class SigningCertificates( val current: Set, val history: Set, ) /** * `PackageInfo.longVersionCode` and signing history were added in API 28; * keep the archive gate runnable on the app's API-26 minimum instead of * letting lint-approved source crash on Oreo devices. */ @Suppress("DEPRECATION", "InlinedApi") private fun signingInfoFlags(): Int = packageSigningInfoFlagsForSdk( sdkInt = Build.VERSION.SDK_INT, legacyFlags = PackageManager.GET_SIGNATURES, modernFlags = PackageManager.GET_SIGNING_CERTIFICATES, ) @Suppress("DEPRECATION", "InlinedApi") private fun archiveVersionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageVersionCodeForSdk( sdkInt = Build.VERSION.SDK_INT, legacyVersionCode = info.versionCode, modernVersionCode = modernVersionCode(info), ) } else { packageVersionCodeForSdk( sdkInt = Build.VERSION.SDK_INT, legacyVersionCode = info.versionCode, modernVersionCode = info.versionCode.toLong(), ) } @RequiresApi(Build.VERSION_CODES.P) private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode @Suppress("DEPRECATION", "InlinedApi") private fun signingCertificates(info: PackageInfo): SigningCertificates { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet()) val current = signingInfo.apkContentsSigners.orEmpty() .map(::certificateSha256) .toSet() val history = signingInfo.signingCertificateHistory.orEmpty() .map(::certificateSha256) .toSet() return SigningCertificates(current = current, history = history) } val current = info.signatures.orEmpty() .map(::certificateSha256) .toSet() return SigningCertificates(current = current, history = emptySet()) } private fun certificateSha256(signature: Signature): String = MessageDigest.getInstance("SHA-256") .digest(signature.toByteArray()) .joinToString("") { byte -> "%02x".format(byte) } private fun rejectIdentity(apk: File, message: String): Nothing { apk.delete() throw ApkIdentityException(message) } fun discardDownloads() { File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() } } /* ──────────────────────────────── install ────────────────────────────── */ /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */ fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls() fun unknownSourcesSettingsIntent(): Intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) /** * Hand a verified APK to the system package installer. The file lives in app * cache, so it can only be read through the FileProvider grant this intent carries. */ fun installIntent(apk: File): Intent { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) return Intent(Intent.ACTION_VIEW) .setDataAndType(uri, APK_MIME) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } /* ────────────────────────────── what's new ───────────────────────────── */ /** * Arm the post-update changelog when the running version differs from the last * one whose notes were read. A fresh install has nothing to catch up on, so it * silently records the current version instead of showing a changelog for a * release the user never ran. */ fun refreshWhatsNew() { val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null) if (lastSeen == null) { markChangelogSeen() return } if (lastSeen == currentVersion) { _whatsNew.value = null return } // Show cached single-version notes immediately as fallback, then // fetch the full history to accumulate all versions since lastSeen. _whatsNew.value = prefs.getString(KEY_NOTES, null) ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion } scope.launch { val accumulated = fetchAccumulatedNotes(lastSeen, currentVersion) if (accumulated != null) _whatsNew.value = accumulated } } fun markChangelogSeen() { prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply() _whatsNew.value = null } /* ───────────────────────────── notify-once ───────────────────────────── */ /** True the first time a given version is seen, so the same update notifies once. */ fun shouldNotifyFor(version: String): Boolean = prefs.getString(KEY_NOTIFIED_VERSION, null) != version fun markNotified(version: String) { prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply() } private fun cachedAvailable(): ReleaseManifest? = prefs.getString(KEY_LATEST_MANIFEST, null) ?.let { runCatching { parseReleaseManifest(it, androidPlatformKey(Build.SUPPORTED_ABIS) ?: ANDROID_PLATFORM_KEY) }.getOrNull() } ?.takeIf { isNewerVersion(it.version, currentVersion) } /** * Every published release from Kepr, newest first, or null on network or * parse failure. The settings changelog renders this directly; the * post-update "what's new" accumulates a slice of it. */ suspend fun fetchHistory(): List? = withContext(Dispatchers.IO) { runCatching { val request = Request.Builder() .url(HISTORY_URL) .header("User-Agent", USER_AGENT) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@runCatching null parseReleaseHistory(response.body?.string().orEmpty()) } }.getOrNull() } /** * Build accumulated notes for every version newer than [lastSeen] up to and * including [current]. Returns null on network failure (caller keeps the * fallback). */ private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? { // History is newest-first; collect versions in range. val inRange = mutableListOf() for (entry in fetchHistory() ?: return null) { if (entry.version == lastSeen) break if (!entry.notes.isNullOrBlank()) inRange.add(entry) if (entry.version == current) break } if (inRange.isEmpty()) return null // Reverse to chronological order (oldest first). inRange.reverse() return inRange.joinToString("\n\n") { "## v${it.version}\n\n${it.notes}" } } companion object { /** Kepr repo name for this app; the generic release API is keyed on it. */ const val KEPR_REPO = "peach-android" const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest" const val HISTORY_URL = "https://kepr.uk/api/$KEPR_REPO/releases/history" private const val APK_MIME = "application/vnd.android.package-archive" private const val USER_AGENT = "Peach-Android" private const val CACHE_DIR = "updates" private const val PREFS = "peach_updates" private const val KEY_LATEST_MANIFEST = "latest_manifest" private const val KEY_VERSION_HIGH_WATER = "version_high_water" private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version" private const val KEY_NOTES_VERSION = "notes_version" private const val KEY_NOTES = "notes" private const val KEY_NOTIFIED_VERSION = "notified_version" } }