UpdateRepository.kt

17.8 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.withContext20import okhttp3.OkHttpClient21import okhttp3.Request22import java.io.File23import java.io.IOException24import java.security.MessageDigest25import java.util.concurrent.TimeUnit26import javax.inject.Inject27import javax.inject.Singleton2829/**30 * The Kepr side of in-app updates: what the newest release is, fetching its APK,31 * and proving that APK is the one Kepr published before it is ever handed to the32 * package installer.33 *34 * Nothing here downloads or installs on its own. `check()` is the only thing the35 * background worker and app-open path call; everything past that is driven by a36 * user tap, per the "never do anything silently with the user's device" rule.37 */38@Singleton39class UpdateRepository @Inject constructor(40    @ApplicationContext private val context: Context,41) {4243    private val client: OkHttpClient by lazy {44        OkHttpClient.Builder()45            .callTimeout(5, TimeUnit.MINUTES)46            .build()47    }4849    private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }5051    /** The running build's release version, with the debug `-debug` suffix stripped. */52    val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)5354    private val _available = MutableStateFlow(cachedAvailable())55    /** The newest release, only when it is actually newer than [currentVersion]. */56    val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()5758    private val _whatsNew = MutableStateFlow<String?>(null)59    /** Notes for the running version, set only while they are still unread. */60    val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()6162    private val _openRequested = MutableStateFlow(false)63    /** Set when the app was launched by tapping the update notification. */64    val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()6566    init {67        refreshWhatsNew()68    }6970    fun requestOpen() { _openRequested.value = true }7172    fun consumeOpenRequest() { _openRequested.value = false }7374    /* ────────────────────────────────  check  ──────────────────────────────── */7576    /**77     * Fetch the release manifest and reconcile it against the running build.78     * Returns the manifest when a newer version exists, null when up to date.79     * Throws on network/parse failure — callers decide whether that is worth80     * surfacing (the periodic worker retries; the Settings "check now" reports).81     */82    suspend fun check(): ReleaseManifest? {83        val manifest = fetchLatest()84        val newer = isNewerVersion(manifest.version, currentVersion)8586        _available.value = manifest.takeIf { newer }87        prefs.edit().apply {88            if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)89        }.apply()9091        // The manifest for the version we're already running is the only place the92        // post-update changelog can come from, so cache it the moment we see it.93        if (normalizeVersion(manifest.version) == currentVersion) {94            if (manifest.notes == null) {95                // Genuinely no notes for this release — nothing to ever show, so96                // stop waiting for them rather than leaving the flag armed forever.97                markChangelogSeen()98            } else {99                prefs.edit()100                    .putString(KEY_NOTES_VERSION, currentVersion)101                    .putString(KEY_NOTES, manifest.notes)102                    .apply()103                refreshWhatsNew()104            }105        }106        return _available.value107    }108109    private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {110        val request = Request.Builder()111            .url(LATEST_RELEASE_URL)112            .header("User-Agent", USER_AGENT)113            .build()114        client.newCall(request).execute().use { response ->115            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")116            parseReleaseManifest(response.body?.string().orEmpty())117        }118    }119120    /* ──────────────────────────  download + verify  ────────────────────────── */121122    /**123     * Download the release APK into app cache and verify its checksum. A file that124     * does not match is deleted and the call fails — an unverified APK is never125     * left on disk for something else to pick up, and never reaches the installer.126     */127    suspend fun downloadAndVerify(128        manifest: ReleaseManifest,129        onProgress: (Float) -> Unit = {},130    ): File = withContext(Dispatchers.IO) {131        val expected = parseChecksum(manifest.checksum)132            ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")133134        // parseReleaseManifest has already enforced strict SemVer, so the version135        // is guaranteed to be a clean `major.minor.patch` with no path-traversal136        // characters; using it in the filename is safe.137        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }138        val target = File(dir, "peach-${manifest.version}.apk")139        require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {140            "refusing to write outside cache dir"141        }142143        // A previously verified download of the same version is reusable; anything144        // else in here is a stale or half-finished attempt.145        dir.listFiles()?.forEach { if (it != target) it.delete() }146        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {147            // Even on a cache hit we re-prove identity: a tampered file in the148            // cache slot must not reach the installer any more than a tampered149            // download must.150            verifyApkIdentity(target, manifest.version)151            onProgress(1f)152            return@withContext target153        }154        target.delete()155156        val request = Request.Builder()157            .url(manifest.url)158            .header("User-Agent", USER_AGENT)159            .build()160        client.newCall(request).execute().use { response ->161            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")162            val body = response.body ?: throw IOException("empty response")163            val advertised = body.contentLength()164            // Reject before streaming if the server is honest about being too165            // big; Content-Length can be -1 (chunked / unknown) and the166            // streaming cap below catches that case as bytes arrive.167            if (advertised in 1..MAX_APK_BYTES) {168                // known and within bounds — nothing to do169            } else if (advertised > MAX_APK_BYTES) {170                throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")171            }172            try {173                body.byteStream().use { input ->174                    target.outputStream().use { output ->175                        val buffer = ByteArray(64 * 1024)176                        var written = 0L177                        while (true) {178                            val read = input.read(buffer)179                            if (read <= 0) break180                            written += read181                            if (written > MAX_APK_BYTES) {182                                // Streaming cap: a server that lies about its183                                // Content-Length, or chunks past it, is still184                                // bounded here. The partial file is removed so185                                // a later retry doesn't pick up half an APK.186                                throw DownloadSizeException(187                                    "APK exceeded $MAX_APK_BYTES bytes mid-download",188                                )189                            }190                            output.write(buffer, 0, read)191                            if (advertised > 0) onProgress(192                                (written.toFloat() / advertised).coerceIn(0f, 1f),193                            )194                        }195                    }196                }197            } catch (e: DownloadSizeException) {198                target.delete()199                throw e200            }201        }202203        val actual = hashFile(target, expected.algorithm)204        if (actual != expected.hex) {205            target.delete()206            throw ChecksumMismatchException(207                "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",208            )209        }210        verifyApkIdentity(target, manifest.version)211        onProgress(1f)212        target213    }214215    /**216     * Confirm the cached APK is the exact package/version this updater belongs217     * to and that its signer is the currently installed Peach signer (or a218     * forward v3 descendant with a proof-of-rotation back to it). PackageManager219     * is happy to parse any well-formed APK the cache may contain, so both220     * identity and signing lineage are checked before the installer sees it.221     */222    private fun verifyApkIdentity(apk: File, expectedVersionName: String) {223        val packageManager = context.packageManager224        val info = packageManager.getPackageArchiveInfo(225            apk.absolutePath,226            signingInfoFlags(),227        ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")228        val archiveVersionCode = archiveVersionCode(info)229        if (!isExpectedApk(230                packageName = info.packageName,231                versionName = info.versionName,232                versionCode = archiveVersionCode,233                expectedPackage = context.packageName,234                expectedVersionName = expectedVersionName,235                currentVersionCode = BuildConfig.VERSION_CODE.toLong(),236            )237        ) {238            rejectIdentity(239                apk,240                "APK identity mismatch: package=${info.packageName} " +241                    "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +242                    "expected ${context.packageName} versionName=$expectedVersionName " +243                    "and versionCode>${BuildConfig.VERSION_CODE}",244            )245        }246247        val installed = runCatching {248            packageManager.getPackageInfo(context.packageName, signingInfoFlags())249        }.getOrElse {250            rejectIdentity(apk, "installed Peach package could not be inspected")251        }252        val archiveSigning = signingCertificates(info)253        val installedSigning = signingCertificates(installed)254        if (!isSigningLineageCompatible(255                archiveCurrent = archiveSigning.current,256                archiveHistory = archiveSigning.history,257                installedCurrent = installedSigning.current,258            )259        ) {260            rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")261        }262    }263264    private data class SigningCertificates(265        val current: Set<String>,266        val history: Set<String>,267    )268269    /**270     * `PackageInfo.longVersionCode` and signing history were added in API 28;271     * keep the archive gate runnable on the app's API-26 minimum instead of272     * letting lint-approved source crash on Oreo devices.273     */274    @Suppress("DEPRECATION", "InlinedApi")275    private fun signingInfoFlags(): Int =276        packageSigningInfoFlagsForSdk(277            sdkInt = Build.VERSION.SDK_INT,278            legacyFlags = PackageManager.GET_SIGNATURES,279            modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,280        )281282    @Suppress("DEPRECATION", "InlinedApi")283    private fun archiveVersionCode(info: PackageInfo): Long =284        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {285            packageVersionCodeForSdk(286                sdkInt = Build.VERSION.SDK_INT,287                legacyVersionCode = info.versionCode,288                modernVersionCode = modernVersionCode(info),289            )290        } else {291            packageVersionCodeForSdk(292                sdkInt = Build.VERSION.SDK_INT,293                legacyVersionCode = info.versionCode,294                modernVersionCode = info.versionCode.toLong(),295            )296        }297298    @RequiresApi(Build.VERSION_CODES.P)299    private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode300301    @Suppress("DEPRECATION", "InlinedApi")302    private fun signingCertificates(info: PackageInfo): SigningCertificates {303        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {304            val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())305            val current = signingInfo.apkContentsSigners.orEmpty()306                .map(::certificateSha256)307                .toSet()308            val history = signingInfo.signingCertificateHistory.orEmpty()309                .map(::certificateSha256)310                .toSet()311            return SigningCertificates(current = current, history = history)312        }313        val current = info.signatures.orEmpty()314            .map(::certificateSha256)315            .toSet()316        return SigningCertificates(current = current, history = emptySet())317    }318319    private fun certificateSha256(signature: Signature): String =320        MessageDigest.getInstance("SHA-256")321            .digest(signature.toByteArray())322            .joinToString("") { byte -> "%02x".format(byte) }323324    private fun rejectIdentity(apk: File, message: String): Nothing {325        apk.delete()326        throw ApkIdentityException(message)327    }328329    fun discardDownloads() {330        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }331    }332333    /* ────────────────────────────────  install  ────────────────────────────── */334335    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */336    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()337338    fun unknownSourcesSettingsIntent(): Intent =339        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))340            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)341342    /**343     * Hand a verified APK to the system package installer. The file lives in app344     * cache, so it can only be read through the FileProvider grant this intent carries.345     */346    fun installIntent(apk: File): Intent {347        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)348        return Intent(Intent.ACTION_VIEW)349            .setDataAndType(uri, APK_MIME)350            .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)351    }352353    /* ──────────────────────────────  what's new  ───────────────────────────── */354355    /**356     * Arm the post-update changelog when the running version differs from the last357     * one whose notes were read. A fresh install has nothing to catch up on, so it358     * silently records the current version instead of showing a changelog for a359     * release the user never ran.360     */361    fun refreshWhatsNew() {362        val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)363        if (lastSeen == null) {364            markChangelogSeen()365            return366        }367        if (lastSeen == currentVersion) {368            _whatsNew.value = null369            return370        }371        _whatsNew.value = prefs.getString(KEY_NOTES, null)372            ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }373    }374375    fun markChangelogSeen() {376        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()377        _whatsNew.value = null378    }379380    /* ─────────────────────────────  notify-once  ───────────────────────────── */381382    /** True the first time a given version is seen, so the same update notifies once. */383    fun shouldNotifyFor(version: String): Boolean =384        prefs.getString(KEY_NOTIFIED_VERSION, null) != version385386    fun markNotified(version: String) {387        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()388    }389390    private fun cachedAvailable(): ReleaseManifest? =391        prefs.getString(KEY_LATEST_MANIFEST, null)392            ?.let { ReleaseManifest.fromCachedJson(it) }393            ?.takeIf { isNewerVersion(it.version, currentVersion) }394395    companion object {396        /** Kepr repo name for this app; the generic release API is keyed on it. */397        const val KEPR_REPO = "peach-android"398        const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"399400        private const val APK_MIME = "application/vnd.android.package-archive"401        private const val USER_AGENT = "Peach-Android"402        private const val CACHE_DIR = "updates"403404        private const val PREFS = "peach_updates"405        private const val KEY_LATEST_MANIFEST = "latest_manifest"406        private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"407        private const val KEY_NOTES_VERSION = "notes_version"408        private const val KEY_NOTES = "notes"409        private const val KEY_NOTIFIED_VERSION = "notified_version"410    }411}