UpdateRepository.kt

20.1 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 the release manifest and reconcile it against the running build.83     * Returns the manifest when a newer version exists, null when up to date.84     * Throws on network/parse failure — callers decide whether that is worth85     * surfacing (the periodic worker retries; the Settings "check now" reports).86     */87    suspend fun check(): ReleaseManifest? {88        val manifest = fetchLatest()89        val newer = isNewerVersion(manifest.version, currentVersion)9091        _available.value = manifest.takeIf { newer }92        prefs.edit().apply {93            if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)94        }.apply()9596        // The manifest for the version we're already running is the only place the97        // post-update changelog can come from, so cache it the moment we see it.98        if (normalizeVersion(manifest.version) == currentVersion) {99            if (manifest.notes == null) {100                // Genuinely no notes for this release — nothing to ever show, so101                // stop waiting for them rather than leaving the flag armed forever.102                markChangelogSeen()103            } else {104                prefs.edit()105                    .putString(KEY_NOTES_VERSION, currentVersion)106                    .putString(KEY_NOTES, manifest.notes)107                    .apply()108                refreshWhatsNew()109            }110        }111        return _available.value112    }113114    private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {115        val request = Request.Builder()116            .url(LATEST_RELEASE_URL)117            .header("User-Agent", USER_AGENT)118            .build()119        client.newCall(request).execute().use { response ->120            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")121            val platform = androidPlatformKey(Build.SUPPORTED_ABIS)122                ?: throw ManifestFormatException("this Android ABI is not supported")123            parseReleaseManifest(response.body?.string().orEmpty(), platform)124        }125    }126127    /* ──────────────────────────  download + verify  ────────────────────────── */128129    /**130     * Download the release APK into app cache and verify its checksum. A file that131     * does not match is deleted and the call fails — an unverified APK is never132     * left on disk for something else to pick up, and never reaches the installer.133     */134    suspend fun downloadAndVerify(135        manifest: ReleaseManifest,136        onProgress: (Float) -> Unit = {},137    ): File = withContext(Dispatchers.IO) {138        val expected = parseChecksum(manifest.checksum)139            ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")140141        // parseReleaseManifest has already enforced strict SemVer, so the version142        // is guaranteed to be a clean `major.minor.patch` with no path-traversal143        // characters; using it in the filename is safe.144        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }145        val target = File(dir, "peach-${manifest.version}.apk")146        require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {147            "refusing to write outside cache dir"148        }149150        // A previously verified download of the same version is reusable; anything151        // else in here is a stale or half-finished attempt.152        dir.listFiles()?.forEach { if (it != target) it.delete() }153        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {154            // Even on a cache hit we re-prove identity: a tampered file in the155            // cache slot must not reach the installer any more than a tampered156            // download must.157            verifyApkIdentity(target, manifest.version)158            onProgress(1f)159            return@withContext target160        }161        target.delete()162163        val request = Request.Builder()164            .url(manifest.url)165            .header("User-Agent", USER_AGENT)166            .build()167        client.newCall(request).execute().use { response ->168            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")169            val body = response.body ?: throw IOException("empty response")170            val advertised = body.contentLength()171            // Reject before streaming if the server is honest about being too172            // big; Content-Length can be -1 (chunked / unknown) and the173            // streaming cap below catches that case as bytes arrive.174            if (advertised in 1..MAX_APK_BYTES) {175                // known and within bounds — nothing to do176            } else if (advertised > MAX_APK_BYTES) {177                throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")178            }179            try {180                body.byteStream().use { input ->181                    target.outputStream().use { output ->182                        val buffer = ByteArray(64 * 1024)183                        var written = 0L184                        while (true) {185                            val read = input.read(buffer)186                            if (read <= 0) break187                            written += read188                            if (written > MAX_APK_BYTES) {189                                // Streaming cap: a server that lies about its190                                // Content-Length, or chunks past it, is still191                                // bounded here. The partial file is removed so192                                // a later retry doesn't pick up half an APK.193                                throw DownloadSizeException(194                                    "APK exceeded $MAX_APK_BYTES bytes mid-download",195                                )196                            }197                            output.write(buffer, 0, read)198                            if (advertised > 0) onProgress(199                                (written.toFloat() / advertised).coerceIn(0f, 1f),200                            )201                        }202                    }203                }204            } catch (e: DownloadSizeException) {205                target.delete()206                throw e207            }208        }209210        val actual = hashFile(target, expected.algorithm)211        if (actual != expected.hex) {212            target.delete()213            throw ChecksumMismatchException(214                "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",215            )216        }217        verifyApkIdentity(target, manifest.version)218        onProgress(1f)219        target220    }221222    /**223     * Confirm the cached APK is the exact package/version this updater belongs224     * to and that its signer is the currently installed Peach signer (or a225     * forward v3 descendant with a proof-of-rotation back to it). PackageManager226     * is happy to parse any well-formed APK the cache may contain, so both227     * identity and signing lineage are checked before the installer sees it.228     */229    private fun verifyApkIdentity(apk: File, expectedVersionName: String) {230        val packageManager = context.packageManager231        val info = packageManager.getPackageArchiveInfo(232            apk.absolutePath,233            signingInfoFlags(),234        ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")235        val archiveVersionCode = archiveVersionCode(info)236        if (!isExpectedApk(237                packageName = info.packageName,238                versionName = info.versionName,239                versionCode = archiveVersionCode,240                expectedPackage = context.packageName,241                expectedVersionName = expectedVersionName,242                currentVersionCode = BuildConfig.VERSION_CODE.toLong(),243            )244        ) {245            rejectIdentity(246                apk,247                "APK identity mismatch: package=${info.packageName} " +248                    "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +249                    "expected ${context.packageName} versionName=$expectedVersionName " +250                    "and versionCode>${BuildConfig.VERSION_CODE}",251            )252        }253254        val installed = runCatching {255            packageManager.getPackageInfo(context.packageName, signingInfoFlags())256        }.getOrElse {257            rejectIdentity(apk, "installed Peach package could not be inspected")258        }259        val archiveSigning = signingCertificates(info)260        val installedSigning = signingCertificates(installed)261        if (!isSigningLineageCompatible(262                archiveCurrent = archiveSigning.current,263                archiveHistory = archiveSigning.history,264                installedCurrent = installedSigning.current,265            )266        ) {267            rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")268        }269    }270271    private data class SigningCertificates(272        val current: Set<String>,273        val history: Set<String>,274    )275276    /**277     * `PackageInfo.longVersionCode` and signing history were added in API 28;278     * keep the archive gate runnable on the app's API-26 minimum instead of279     * letting lint-approved source crash on Oreo devices.280     */281    @Suppress("DEPRECATION", "InlinedApi")282    private fun signingInfoFlags(): Int =283        packageSigningInfoFlagsForSdk(284            sdkInt = Build.VERSION.SDK_INT,285            legacyFlags = PackageManager.GET_SIGNATURES,286            modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,287        )288289    @Suppress("DEPRECATION", "InlinedApi")290    private fun archiveVersionCode(info: PackageInfo): Long =291        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {292            packageVersionCodeForSdk(293                sdkInt = Build.VERSION.SDK_INT,294                legacyVersionCode = info.versionCode,295                modernVersionCode = modernVersionCode(info),296            )297        } else {298            packageVersionCodeForSdk(299                sdkInt = Build.VERSION.SDK_INT,300                legacyVersionCode = info.versionCode,301                modernVersionCode = info.versionCode.toLong(),302            )303        }304305    @RequiresApi(Build.VERSION_CODES.P)306    private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode307308    @Suppress("DEPRECATION", "InlinedApi")309    private fun signingCertificates(info: PackageInfo): SigningCertificates {310        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {311            val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())312            val current = signingInfo.apkContentsSigners.orEmpty()313                .map(::certificateSha256)314                .toSet()315            val history = signingInfo.signingCertificateHistory.orEmpty()316                .map(::certificateSha256)317                .toSet()318            return SigningCertificates(current = current, history = history)319        }320        val current = info.signatures.orEmpty()321            .map(::certificateSha256)322            .toSet()323        return SigningCertificates(current = current, history = emptySet())324    }325326    private fun certificateSha256(signature: Signature): String =327        MessageDigest.getInstance("SHA-256")328            .digest(signature.toByteArray())329            .joinToString("") { byte -> "%02x".format(byte) }330331    private fun rejectIdentity(apk: File, message: String): Nothing {332        apk.delete()333        throw ApkIdentityException(message)334    }335336    fun discardDownloads() {337        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }338    }339340    /* ────────────────────────────────  install  ────────────────────────────── */341342    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */343    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()344345    fun unknownSourcesSettingsIntent(): Intent =346        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))347            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)348349    /**350     * Hand a verified APK to the system package installer. The file lives in app351     * cache, so it can only be read through the FileProvider grant this intent carries.352     */353    fun installIntent(apk: File): Intent {354        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)355        return Intent(Intent.ACTION_VIEW)356            .setDataAndType(uri, APK_MIME)357            .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)358    }359360    /* ──────────────────────────────  what's new  ───────────────────────────── */361362    /**363     * Arm the post-update changelog when the running version differs from the last364     * one whose notes were read. A fresh install has nothing to catch up on, so it365     * silently records the current version instead of showing a changelog for a366     * release the user never ran.367     */368    fun refreshWhatsNew() {369        val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)370        if (lastSeen == null) {371            markChangelogSeen()372            return373        }374        if (lastSeen == currentVersion) {375            _whatsNew.value = null376            return377        }378        // Show cached single-version notes immediately as fallback, then379        // fetch the full history to accumulate all versions since lastSeen.380        _whatsNew.value = prefs.getString(KEY_NOTES, null)381            ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }382        scope.launch {383            val accumulated = fetchAccumulatedNotes(lastSeen, currentVersion)384            if (accumulated != null) _whatsNew.value = accumulated385        }386    }387388    fun markChangelogSeen() {389        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()390        _whatsNew.value = null391    }392393    /* ─────────────────────────────  notify-once  ───────────────────────────── */394395    /** True the first time a given version is seen, so the same update notifies once. */396    fun shouldNotifyFor(version: String): Boolean =397        prefs.getString(KEY_NOTIFIED_VERSION, null) != version398399    fun markNotified(version: String) {400        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()401    }402403    private fun cachedAvailable(): ReleaseManifest? =404        prefs.getString(KEY_LATEST_MANIFEST, null)405            ?.let { ReleaseManifest.fromCachedJson(it) }406            ?.takeIf { isNewerVersion(it.version, currentVersion) }407408    /**409     * Every published release from Kepr, newest first, or null on network or410     * parse failure. The settings changelog renders this directly; the411     * post-update "what's new" accumulates a slice of it.412     */413    suspend fun fetchHistory(): List<ReleaseNote>? = withContext(Dispatchers.IO) {414        runCatching {415            val request = Request.Builder()416                .url(HISTORY_URL)417                .header("User-Agent", USER_AGENT)418                .build()419            client.newCall(request).execute().use { response ->420                if (!response.isSuccessful) return@runCatching null421                parseReleaseHistory(response.body?.string().orEmpty())422            }423        }.getOrNull()424    }425426    /**427     * Build accumulated notes for every version newer than [lastSeen] up to and428     * including [current]. Returns null on network failure (caller keeps the429     * fallback).430     */431    private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? {432        // History is newest-first; collect versions in range.433        val inRange = mutableListOf<ReleaseNote>()434        for (entry in fetchHistory() ?: return null) {435            if (entry.version == lastSeen) break436            if (!entry.notes.isNullOrBlank()) inRange.add(entry)437            if (entry.version == current) break438        }439        if (inRange.isEmpty()) return null440        // Reverse to chronological order (oldest first).441        inRange.reverse()442        return inRange.joinToString("\n\n") { "## v${it.version}\n\n${it.notes}" }443    }444445    companion object {446        /** Kepr repo name for this app; the generic release API is keyed on it. */447        const val KEPR_REPO = "peach-android"448        const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"449        const val HISTORY_URL = "https://kepr.uk/api/$KEPR_REPO/releases/history"450451        private const val APK_MIME = "application/vnd.android.package-archive"452        private const val USER_AGENT = "Peach-Android"453        private const val CACHE_DIR = "updates"454455        private const val PREFS = "peach_updates"456        private const val KEY_LATEST_MANIFEST = "latest_manifest"457        private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"458        private const val KEY_NOTES_VERSION = "notes_version"459        private const val KEY_NOTES = "notes"460        private const val KEY_NOTIFIED_VERSION = "notified_version"461    }462}