UpdateRepository.kt

20.4 KB · raw

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.Singleton3132/**33 * The Kepr side of in-app updates: what the newest release is, fetching its APK,34 * and proving that APK is the one Kepr published before it is ever handed to the35 * package installer.36 *37 * Nothing here downloads or installs on its own. `check()` is the only thing the38 * background worker and app-open path call; everything past that is driven by a39 * user tap, per the "never do anything silently with the user's device" rule.40 */41@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    /** The running build's release version, with the debug `-debug` suffix stripped. */55    val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5657    private val _available = MutableStateFlow(cachedAvailable())58    /** The newest release, only when it is actually newer than [currentVersion]. */59    val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()6061    private val _whatsNew = MutableStateFlow<String?>(null)62    /** Notes for the running version, set only while they are still unread. */63    val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6465    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)6667    private val _openRequested = MutableStateFlow(false)68    /** Set when the app was launched by tapping the update notification. */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    /* ────────────────────────────────  check  ──────────────────────────────── */8081    /**82     * Fetch and reconcile the release manifest. A suppressed rollback never83     * changes the currently offerable manifest or the persisted high-water mark.84     */85    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        // The manifest for the version we're already running is the only place103        // the post-update changelog can come from, so cache it when observed.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    /* ──────────────────────────  download + verify  ────────────────────────── */132133    /**134     * Download the release APK into app cache and verify its checksum. A file that135     * does not match is deleted and the call fails — an unverified APK is never136     * left on disk for something else to pick up, and never reaches the installer.137     */138    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        // parseReleaseManifest has already enforced strict SemVer, so the version146        // is guaranteed to be a clean `major.minor.patch` with no path-traversal147        // characters; using it in the filename is safe.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        // A previously verified download of the same version is reusable; anything155        // else in here is a stale or half-finished attempt.156        dir.listFiles()?.forEach { if (it != target) it.delete() }157        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {158            // Even on a cache hit we re-prove identity: a tampered file in the159            // cache slot must not reach the installer any more than a tampered160            // download must.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            // Reject before streaming if the server is honest about being too176            // big; Content-Length can be -1 (chunked / unknown) and the177            // streaming cap below catches that case as bytes arrive.178            if (advertised in 1..MAX_APK_BYTES) {179                // known and within bounds — nothing to do180            } 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                                // Streaming cap: a server that lies about its194                                // Content-Length, or chunks past it, is still195                                // bounded here. The partial file is removed so196                                // a later retry doesn't pick up half an APK.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    /**227     * Confirm the cached APK is the exact package/version this updater belongs228     * to and that its signer is the currently installed Peach signer (or a229     * forward v3 descendant with a proof-of-rotation back to it). PackageManager230     * is happy to parse any well-formed APK the cache may contain, so both231     * identity and signing lineage are checked before the installer sees it.232     */233    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    /**281     * `PackageInfo.longVersionCode` and signing history were added in API 28;282     * keep the archive gate runnable on the app's API-26 minimum instead of283     * letting lint-approved source crash on Oreo devices.284     */285    @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    /* ────────────────────────────────  install  ────────────────────────────── */345346    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */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    /**354     * Hand a verified APK to the system package installer. The file lives in app355     * cache, so it can only be read through the FileProvider grant this intent carries.356     */357    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    /* ──────────────────────────────  what's new  ───────────────────────────── */365366    /**367     * Arm the post-update changelog when the running version differs from the last368     * one whose notes were read. A fresh install has nothing to catch up on, so it369     * silently records the current version instead of showing a changelog for a370     * release the user never ran.371     */372    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        // Show cached single-version notes immediately as fallback, then383        // fetch the full history to accumulate all versions since lastSeen.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    /* ─────────────────────────────  notify-once  ───────────────────────────── */398399    /** True the first time a given version is seen, so the same update notifies once. */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    /**413     * Every published release from Kepr, newest first, or null on network or414     * parse failure. The settings changelog renders this directly; the415     * post-update "what's new" accumulates a slice of it.416     */417    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    /**431     * Build accumulated notes for every version newer than [lastSeen] up to and432     * including [current]. Returns null on network failure (caller keeps the433     * fallback).434     */435    private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? {436        // History is newest-first; collect versions in range.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        // Reverse to chronological order (oldest first).445        inRange.reverse()446        return inRange.joinToString("\n\n") { "## v${it.version}\n\n${it.notes}" }447    }448449    companion object {450        /** Kepr repo name for this app; the generic release API is keyed on it. */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}