UpdateRepository.kt

18.0 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            val platform = androidPlatformKey(Build.SUPPORTED_ABIS)117                ?: throw ManifestFormatException("this Android ABI is not supported")118            parseReleaseManifest(response.body?.string().orEmpty(), platform)119        }120    }121122    /* ──────────────────────────  download + verify  ────────────────────────── */123124    /**125     * Download the release APK into app cache and verify its checksum. A file that126     * does not match is deleted and the call fails — an unverified APK is never127     * left on disk for something else to pick up, and never reaches the installer.128     */129    suspend fun downloadAndVerify(130        manifest: ReleaseManifest,131        onProgress: (Float) -> Unit = {},132    ): File = withContext(Dispatchers.IO) {133        val expected = parseChecksum(manifest.checksum)134            ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")135136        // parseReleaseManifest has already enforced strict SemVer, so the version137        // is guaranteed to be a clean `major.minor.patch` with no path-traversal138        // characters; using it in the filename is safe.139        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }140        val target = File(dir, "peach-${manifest.version}.apk")141        require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {142            "refusing to write outside cache dir"143        }144145        // A previously verified download of the same version is reusable; anything146        // else in here is a stale or half-finished attempt.147        dir.listFiles()?.forEach { if (it != target) it.delete() }148        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {149            // Even on a cache hit we re-prove identity: a tampered file in the150            // cache slot must not reach the installer any more than a tampered151            // download must.152            verifyApkIdentity(target, manifest.version)153            onProgress(1f)154            return@withContext target155        }156        target.delete()157158        val request = Request.Builder()159            .url(manifest.url)160            .header("User-Agent", USER_AGENT)161            .build()162        client.newCall(request).execute().use { response ->163            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")164            val body = response.body ?: throw IOException("empty response")165            val advertised = body.contentLength()166            // Reject before streaming if the server is honest about being too167            // big; Content-Length can be -1 (chunked / unknown) and the168            // streaming cap below catches that case as bytes arrive.169            if (advertised in 1..MAX_APK_BYTES) {170                // known and within bounds — nothing to do171            } else if (advertised > MAX_APK_BYTES) {172                throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")173            }174            try {175                body.byteStream().use { input ->176                    target.outputStream().use { output ->177                        val buffer = ByteArray(64 * 1024)178                        var written = 0L179                        while (true) {180                            val read = input.read(buffer)181                            if (read <= 0) break182                            written += read183                            if (written > MAX_APK_BYTES) {184                                // Streaming cap: a server that lies about its185                                // Content-Length, or chunks past it, is still186                                // bounded here. The partial file is removed so187                                // a later retry doesn't pick up half an APK.188                                throw DownloadSizeException(189                                    "APK exceeded $MAX_APK_BYTES bytes mid-download",190                                )191                            }192                            output.write(buffer, 0, read)193                            if (advertised > 0) onProgress(194                                (written.toFloat() / advertised).coerceIn(0f, 1f),195                            )196                        }197                    }198                }199            } catch (e: DownloadSizeException) {200                target.delete()201                throw e202            }203        }204205        val actual = hashFile(target, expected.algorithm)206        if (actual != expected.hex) {207            target.delete()208            throw ChecksumMismatchException(209                "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",210            )211        }212        verifyApkIdentity(target, manifest.version)213        onProgress(1f)214        target215    }216217    /**218     * Confirm the cached APK is the exact package/version this updater belongs219     * to and that its signer is the currently installed Peach signer (or a220     * forward v3 descendant with a proof-of-rotation back to it). PackageManager221     * is happy to parse any well-formed APK the cache may contain, so both222     * identity and signing lineage are checked before the installer sees it.223     */224    private fun verifyApkIdentity(apk: File, expectedVersionName: String) {225        val packageManager = context.packageManager226        val info = packageManager.getPackageArchiveInfo(227            apk.absolutePath,228            signingInfoFlags(),229        ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")230        val archiveVersionCode = archiveVersionCode(info)231        if (!isExpectedApk(232                packageName = info.packageName,233                versionName = info.versionName,234                versionCode = archiveVersionCode,235                expectedPackage = context.packageName,236                expectedVersionName = expectedVersionName,237                currentVersionCode = BuildConfig.VERSION_CODE.toLong(),238            )239        ) {240            rejectIdentity(241                apk,242                "APK identity mismatch: package=${info.packageName} " +243                    "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +244                    "expected ${context.packageName} versionName=$expectedVersionName " +245                    "and versionCode>${BuildConfig.VERSION_CODE}",246            )247        }248249        val installed = runCatching {250            packageManager.getPackageInfo(context.packageName, signingInfoFlags())251        }.getOrElse {252            rejectIdentity(apk, "installed Peach package could not be inspected")253        }254        val archiveSigning = signingCertificates(info)255        val installedSigning = signingCertificates(installed)256        if (!isSigningLineageCompatible(257                archiveCurrent = archiveSigning.current,258                archiveHistory = archiveSigning.history,259                installedCurrent = installedSigning.current,260            )261        ) {262            rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")263        }264    }265266    private data class SigningCertificates(267        val current: Set<String>,268        val history: Set<String>,269    )270271    /**272     * `PackageInfo.longVersionCode` and signing history were added in API 28;273     * keep the archive gate runnable on the app's API-26 minimum instead of274     * letting lint-approved source crash on Oreo devices.275     */276    @Suppress("DEPRECATION", "InlinedApi")277    private fun signingInfoFlags(): Int =278        packageSigningInfoFlagsForSdk(279            sdkInt = Build.VERSION.SDK_INT,280            legacyFlags = PackageManager.GET_SIGNATURES,281            modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,282        )283284    @Suppress("DEPRECATION", "InlinedApi")285    private fun archiveVersionCode(info: PackageInfo): Long =286        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {287            packageVersionCodeForSdk(288                sdkInt = Build.VERSION.SDK_INT,289                legacyVersionCode = info.versionCode,290                modernVersionCode = modernVersionCode(info),291            )292        } else {293            packageVersionCodeForSdk(294                sdkInt = Build.VERSION.SDK_INT,295                legacyVersionCode = info.versionCode,296                modernVersionCode = info.versionCode.toLong(),297            )298        }299300    @RequiresApi(Build.VERSION_CODES.P)301    private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode302303    @Suppress("DEPRECATION", "InlinedApi")304    private fun signingCertificates(info: PackageInfo): SigningCertificates {305        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {306            val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())307            val current = signingInfo.apkContentsSigners.orEmpty()308                .map(::certificateSha256)309                .toSet()310            val history = signingInfo.signingCertificateHistory.orEmpty()311                .map(::certificateSha256)312                .toSet()313            return SigningCertificates(current = current, history = history)314        }315        val current = info.signatures.orEmpty()316            .map(::certificateSha256)317            .toSet()318        return SigningCertificates(current = current, history = emptySet())319    }320321    private fun certificateSha256(signature: Signature): String =322        MessageDigest.getInstance("SHA-256")323            .digest(signature.toByteArray())324            .joinToString("") { byte -> "%02x".format(byte) }325326    private fun rejectIdentity(apk: File, message: String): Nothing {327        apk.delete()328        throw ApkIdentityException(message)329    }330331    fun discardDownloads() {332        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }333    }334335    /* ────────────────────────────────  install  ────────────────────────────── */336337    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */338    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()339340    fun unknownSourcesSettingsIntent(): Intent =341        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))342            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)343344    /**345     * Hand a verified APK to the system package installer. The file lives in app346     * cache, so it can only be read through the FileProvider grant this intent carries.347     */348    fun installIntent(apk: File): Intent {349        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)350        return Intent(Intent.ACTION_VIEW)351            .setDataAndType(uri, APK_MIME)352            .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)353    }354355    /* ──────────────────────────────  what's new  ───────────────────────────── */356357    /**358     * Arm the post-update changelog when the running version differs from the last359     * one whose notes were read. A fresh install has nothing to catch up on, so it360     * silently records the current version instead of showing a changelog for a361     * release the user never ran.362     */363    fun refreshWhatsNew() {364        val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)365        if (lastSeen == null) {366            markChangelogSeen()367            return368        }369        if (lastSeen == currentVersion) {370            _whatsNew.value = null371            return372        }373        _whatsNew.value = prefs.getString(KEY_NOTES, null)374            ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }375    }376377    fun markChangelogSeen() {378        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()379        _whatsNew.value = null380    }381382    /* ─────────────────────────────  notify-once  ───────────────────────────── */383384    /** True the first time a given version is seen, so the same update notifies once. */385    fun shouldNotifyFor(version: String): Boolean =386        prefs.getString(KEY_NOTIFIED_VERSION, null) != version387388    fun markNotified(version: String) {389        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()390    }391392    private fun cachedAvailable(): ReleaseManifest? =393        prefs.getString(KEY_LATEST_MANIFEST, null)394            ?.let { ReleaseManifest.fromCachedJson(it) }395            ?.takeIf { isNewerVersion(it.version, currentVersion) }396397    companion object {398        /** Kepr repo name for this app; the generic release API is keyed on it. */399        const val KEPR_REPO = "peach-android"400        const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"401402        private const val APK_MIME = "application/vnd.android.package-archive"403        private const val USER_AGENT = "Peach-Android"404        private const val CACHE_DIR = "updates"405406        private const val PREFS = "peach_updates"407        private const val KEY_LATEST_MANIFEST = "latest_manifest"408        private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"409        private const val KEY_NOTES_VERSION = "notes_version"410        private const val KEY_NOTES = "notes"411        private const val KEY_NOTIFIED_VERSION = "notified_version"412    }413}