UpdateRepository.kt

20.3 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 org.json.JSONArray24import okhttp3.OkHttpClient25import okhttp3.Request26import java.io.File27import java.io.IOException28import java.security.MessageDigest29import java.util.concurrent.TimeUnit30import javax.inject.Inject31import javax.inject.Singleton3233/**34 * The Kepr side of in-app updates: what the newest release is, fetching its APK,35 * and proving that APK is the one Kepr published before it is ever handed to the36 * package installer.37 *38 * Nothing here downloads or installs on its own. `check()` is the only thing the39 * background worker and app-open path call; everything past that is driven by a40 * user tap, per the "never do anything silently with the user's device" rule.41 */42@Singleton43class UpdateRepository @Inject constructor(44    @ApplicationContext private val context: Context,45) {4647    private val client: OkHttpClient by lazy {48        OkHttpClient.Builder()49            .callTimeout(5, TimeUnit.MINUTES)50            .build()51    }5253    private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }5455    /** The running build's release version, with the debug `-debug` suffix stripped. */56    val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5758    private val _available = MutableStateFlow(cachedAvailable())59    /** The newest release, only when it is actually newer than [currentVersion]. */60    val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()6162    private val _whatsNew = MutableStateFlow<String?>(null)63    /** Notes for the running version, set only while they are still unread. */64    val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6566    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)6768    private val _openRequested = MutableStateFlow(false)69    /** Set when the app was launched by tapping the update notification. */70    val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()7172    init {73        refreshWhatsNew()74    }7576    fun requestOpen() { _openRequested.value = true }7778    fun consumeOpenRequest() { _openRequested.value = false }7980    /* ────────────────────────────────  check  ──────────────────────────────── */8182    /**83     * Fetch the release manifest and reconcile it against the running build.84     * Returns the manifest when a newer version exists, null when up to date.85     * Throws on network/parse failure — callers decide whether that is worth86     * surfacing (the periodic worker retries; the Settings "check now" reports).87     */88    suspend fun check(): ReleaseManifest? {89        val manifest = fetchLatest()90        val newer = isNewerVersion(manifest.version, currentVersion)9192        _available.value = manifest.takeIf { newer }93        prefs.edit().apply {94            if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)95        }.apply()9697        // The manifest for the version we're already running is the only place the98        // post-update changelog can come from, so cache it the moment we see it.99        if (normalizeVersion(manifest.version) == currentVersion) {100            if (manifest.notes == null) {101                // Genuinely no notes for this release — nothing to ever show, so102                // stop waiting for them rather than leaving the flag armed forever.103                markChangelogSeen()104            } else {105                prefs.edit()106                    .putString(KEY_NOTES_VERSION, currentVersion)107                    .putString(KEY_NOTES, manifest.notes)108                    .apply()109                refreshWhatsNew()110            }111        }112        return _available.value113    }114115    private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {116        val request = Request.Builder()117            .url(LATEST_RELEASE_URL)118            .header("User-Agent", USER_AGENT)119            .build()120        client.newCall(request).execute().use { response ->121            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")122            val platform = androidPlatformKey(Build.SUPPORTED_ABIS)123                ?: throw ManifestFormatException("this Android ABI is not supported")124            parseReleaseManifest(response.body?.string().orEmpty(), platform)125        }126    }127128    /* ──────────────────────────  download + verify  ────────────────────────── */129130    /**131     * Download the release APK into app cache and verify its checksum. A file that132     * does not match is deleted and the call fails — an unverified APK is never133     * left on disk for something else to pick up, and never reaches the installer.134     */135    suspend fun downloadAndVerify(136        manifest: ReleaseManifest,137        onProgress: (Float) -> Unit = {},138    ): File = withContext(Dispatchers.IO) {139        val expected = parseChecksum(manifest.checksum)140            ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")141142        // parseReleaseManifest has already enforced strict SemVer, so the version143        // is guaranteed to be a clean `major.minor.patch` with no path-traversal144        // characters; using it in the filename is safe.145        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }146        val target = File(dir, "peach-${manifest.version}.apk")147        require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {148            "refusing to write outside cache dir"149        }150151        // A previously verified download of the same version is reusable; anything152        // else in here is a stale or half-finished attempt.153        dir.listFiles()?.forEach { if (it != target) it.delete() }154        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {155            // Even on a cache hit we re-prove identity: a tampered file in the156            // cache slot must not reach the installer any more than a tampered157            // download must.158            verifyApkIdentity(target, manifest.version)159            onProgress(1f)160            return@withContext target161        }162        target.delete()163164        val request = Request.Builder()165            .url(manifest.url)166            .header("User-Agent", USER_AGENT)167            .build()168        client.newCall(request).execute().use { response ->169            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")170            val body = response.body ?: throw IOException("empty response")171            val advertised = body.contentLength()172            // Reject before streaming if the server is honest about being too173            // big; Content-Length can be -1 (chunked / unknown) and the174            // streaming cap below catches that case as bytes arrive.175            if (advertised in 1..MAX_APK_BYTES) {176                // known and within bounds — nothing to do177            } else if (advertised > MAX_APK_BYTES) {178                throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")179            }180            try {181                body.byteStream().use { input ->182                    target.outputStream().use { output ->183                        val buffer = ByteArray(64 * 1024)184                        var written = 0L185                        while (true) {186                            val read = input.read(buffer)187                            if (read <= 0) break188                            written += read189                            if (written > MAX_APK_BYTES) {190                                // Streaming cap: a server that lies about its191                                // Content-Length, or chunks past it, is still192                                // bounded here. The partial file is removed so193                                // a later retry doesn't pick up half an APK.194                                throw DownloadSizeException(195                                    "APK exceeded $MAX_APK_BYTES bytes mid-download",196                                )197                            }198                            output.write(buffer, 0, read)199                            if (advertised > 0) onProgress(200                                (written.toFloat() / advertised).coerceIn(0f, 1f),201                            )202                        }203                    }204                }205            } catch (e: DownloadSizeException) {206                target.delete()207                throw e208            }209        }210211        val actual = hashFile(target, expected.algorithm)212        if (actual != expected.hex) {213            target.delete()214            throw ChecksumMismatchException(215                "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",216            )217        }218        verifyApkIdentity(target, manifest.version)219        onProgress(1f)220        target221    }222223    /**224     * Confirm the cached APK is the exact package/version this updater belongs225     * to and that its signer is the currently installed Peach signer (or a226     * forward v3 descendant with a proof-of-rotation back to it). PackageManager227     * is happy to parse any well-formed APK the cache may contain, so both228     * identity and signing lineage are checked before the installer sees it.229     */230    private fun verifyApkIdentity(apk: File, expectedVersionName: String) {231        val packageManager = context.packageManager232        val info = packageManager.getPackageArchiveInfo(233            apk.absolutePath,234            signingInfoFlags(),235        ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")236        val archiveVersionCode = archiveVersionCode(info)237        if (!isExpectedApk(238                packageName = info.packageName,239                versionName = info.versionName,240                versionCode = archiveVersionCode,241                expectedPackage = context.packageName,242                expectedVersionName = expectedVersionName,243                currentVersionCode = BuildConfig.VERSION_CODE.toLong(),244            )245        ) {246            rejectIdentity(247                apk,248                "APK identity mismatch: package=${info.packageName} " +249                    "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +250                    "expected ${context.packageName} versionName=$expectedVersionName " +251                    "and versionCode>${BuildConfig.VERSION_CODE}",252            )253        }254255        val installed = runCatching {256            packageManager.getPackageInfo(context.packageName, signingInfoFlags())257        }.getOrElse {258            rejectIdentity(apk, "installed Peach package could not be inspected")259        }260        val archiveSigning = signingCertificates(info)261        val installedSigning = signingCertificates(installed)262        if (!isSigningLineageCompatible(263                archiveCurrent = archiveSigning.current,264                archiveHistory = archiveSigning.history,265                installedCurrent = installedSigning.current,266            )267        ) {268            rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")269        }270    }271272    private data class SigningCertificates(273        val current: Set<String>,274        val history: Set<String>,275    )276277    /**278     * `PackageInfo.longVersionCode` and signing history were added in API 28;279     * keep the archive gate runnable on the app's API-26 minimum instead of280     * letting lint-approved source crash on Oreo devices.281     */282    @Suppress("DEPRECATION", "InlinedApi")283    private fun signingInfoFlags(): Int =284        packageSigningInfoFlagsForSdk(285            sdkInt = Build.VERSION.SDK_INT,286            legacyFlags = PackageManager.GET_SIGNATURES,287            modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,288        )289290    @Suppress("DEPRECATION", "InlinedApi")291    private fun archiveVersionCode(info: PackageInfo): Long =292        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {293            packageVersionCodeForSdk(294                sdkInt = Build.VERSION.SDK_INT,295                legacyVersionCode = info.versionCode,296                modernVersionCode = modernVersionCode(info),297            )298        } else {299            packageVersionCodeForSdk(300                sdkInt = Build.VERSION.SDK_INT,301                legacyVersionCode = info.versionCode,302                modernVersionCode = info.versionCode.toLong(),303            )304        }305306    @RequiresApi(Build.VERSION_CODES.P)307    private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode308309    @Suppress("DEPRECATION", "InlinedApi")310    private fun signingCertificates(info: PackageInfo): SigningCertificates {311        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {312            val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())313            val current = signingInfo.apkContentsSigners.orEmpty()314                .map(::certificateSha256)315                .toSet()316            val history = signingInfo.signingCertificateHistory.orEmpty()317                .map(::certificateSha256)318                .toSet()319            return SigningCertificates(current = current, history = history)320        }321        val current = info.signatures.orEmpty()322            .map(::certificateSha256)323            .toSet()324        return SigningCertificates(current = current, history = emptySet())325    }326327    private fun certificateSha256(signature: Signature): String =328        MessageDigest.getInstance("SHA-256")329            .digest(signature.toByteArray())330            .joinToString("") { byte -> "%02x".format(byte) }331332    private fun rejectIdentity(apk: File, message: String): Nothing {333        apk.delete()334        throw ApkIdentityException(message)335    }336337    fun discardDownloads() {338        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }339    }340341    /* ────────────────────────────────  install  ────────────────────────────── */342343    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */344    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()345346    fun unknownSourcesSettingsIntent(): Intent =347        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))348            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)349350    /**351     * Hand a verified APK to the system package installer. The file lives in app352     * cache, so it can only be read through the FileProvider grant this intent carries.353     */354    fun installIntent(apk: File): Intent {355        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)356        return Intent(Intent.ACTION_VIEW)357            .setDataAndType(uri, APK_MIME)358            .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)359    }360361    /* ──────────────────────────────  what's new  ───────────────────────────── */362363    /**364     * Arm the post-update changelog when the running version differs from the last365     * one whose notes were read. A fresh install has nothing to catch up on, so it366     * silently records the current version instead of showing a changelog for a367     * release the user never ran.368     */369    fun refreshWhatsNew() {370        val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)371        if (lastSeen == null) {372            markChangelogSeen()373            return374        }375        if (lastSeen == currentVersion) {376            _whatsNew.value = null377            return378        }379        // Show cached single-version notes immediately as fallback, then380        // fetch the full history to accumulate all versions since lastSeen.381        _whatsNew.value = prefs.getString(KEY_NOTES, null)382            ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }383        scope.launch {384            val accumulated = fetchAccumulatedNotes(lastSeen, currentVersion)385            if (accumulated != null) _whatsNew.value = accumulated386        }387    }388389    fun markChangelogSeen() {390        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()391        _whatsNew.value = null392    }393394    /* ─────────────────────────────  notify-once  ───────────────────────────── */395396    /** True the first time a given version is seen, so the same update notifies once. */397    fun shouldNotifyFor(version: String): Boolean =398        prefs.getString(KEY_NOTIFIED_VERSION, null) != version399400    fun markNotified(version: String) {401        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()402    }403404    private fun cachedAvailable(): ReleaseManifest? =405        prefs.getString(KEY_LATEST_MANIFEST, null)406            ?.let { ReleaseManifest.fromCachedJson(it) }407            ?.takeIf { isNewerVersion(it.version, currentVersion) }408409    /**410     * Fetch release history from Kepr and build accumulated notes for every411     * version newer than [lastSeen] up to and including [currentVersion].412     * Returns null on network failure (caller keeps the fallback).413     */414    private suspend fun fetchAccumulatedNotes(lastSeen: String, current: String): String? =415        withContext(Dispatchers.IO) {416            runCatching {417                val request = Request.Builder()418                    .url(HISTORY_URL)419                    .header("User-Agent", USER_AGENT)420                    .build()421                client.newCall(request).execute().use { response ->422                    if (!response.isSuccessful) return@runCatching null423                    val arr = JSONArray(response.body?.string().orEmpty())424                    // History is newest-first; collect versions in range.425                    val inRange = mutableListOf<Pair<String, String>>()426                    for (i in 0 until arr.length()) {427                        val obj = arr.getJSONObject(i)428                        val ver = obj.getString("version")429                        val notes = obj.optString("notes", "")430                        if (ver == lastSeen) break431                        if (notes.isNotBlank()) inRange.add(ver to notes)432                        if (ver == current) break433                    }434                    if (inRange.isEmpty()) return@runCatching null435                    // Reverse to chronological order (oldest first).436                    inRange.reverse()437                    inRange.joinToString("\n\n") { (ver, notes) ->438                        "## v$ver\n\n$notes"439                    }440                }441            }.getOrNull()442        }443444    companion object {445        /** Kepr repo name for this app; the generic release API is keyed on it. */446        const val KEPR_REPO = "peach-android"447        const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"448        const val HISTORY_URL = "https://kepr.uk/api/$KEPR_REPO/releases/history"449450        private const val APK_MIME = "application/vnd.android.package-archive"451        private const val USER_AGENT = "Peach-Android"452        private const val CACHE_DIR = "updates"453454        private const val PREFS = "peach_updates"455        private const val KEY_LATEST_MANIFEST = "latest_manifest"456        private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"457        private const val KEY_NOTES_VERSION = "notes_version"458        private const val KEY_NOTES = "notes"459        private const val KEY_NOTIFIED_VERSION = "notified_version"460    }461}