UpdateRepository.kt

14.0 KB · raw

1package com.peachpasswords.updates23import android.content.Context4import android.content.Intent5import android.content.pm.PackageManager6import android.net.Uri7import android.provider.Settings8import androidx.core.content.FileProvider9import com.peachpasswords.BuildConfig10import dagger.hilt.android.qualifiers.ApplicationContext11import kotlinx.coroutines.Dispatchers12import kotlinx.coroutines.flow.MutableStateFlow13import kotlinx.coroutines.flow.StateFlow14import kotlinx.coroutines.flow.asStateFlow15import kotlinx.coroutines.withContext16import okhttp3.OkHttpClient17import okhttp3.Request18import java.io.File19import java.io.IOException20import java.util.concurrent.TimeUnit21import javax.inject.Inject22import javax.inject.Singleton2324/**25 * The Kepr side of in-app updates: what the newest release is, fetching its APK,26 * and proving that APK is the one Kepr published before it is ever handed to the27 * package installer.28 *29 * Nothing here downloads or installs on its own. `check()` is the only thing the30 * background worker and app-open path call; everything past that is driven by a31 * user tap, per the "never do anything silently with the user's device" rule.32 */33@Singleton34class UpdateRepository @Inject constructor(35    @ApplicationContext private val context: Context,36) {3738    private val client: OkHttpClient by lazy {39        OkHttpClient.Builder()40            .callTimeout(5, TimeUnit.MINUTES)41            .build()42    }4344    private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }4546    /** The running build's release version, with the debug `-debug` suffix stripped. */47    val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)4849    private val _available = MutableStateFlow(cachedAvailable())50    /** The newest release, only when it is actually newer than [currentVersion]. */51    val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()5253    private val _whatsNew = MutableStateFlow<String?>(null)54    /** Notes for the running version, set only while they are still unread. */55    val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()5657    private val _openRequested = MutableStateFlow(false)58    /** Set when the app was launched by tapping the update notification. */59    val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()6061    init {62        refreshWhatsNew()63    }6465    fun requestOpen() { _openRequested.value = true }6667    fun consumeOpenRequest() { _openRequested.value = false }6869    /* ────────────────────────────────  check  ──────────────────────────────── */7071    /**72     * Fetch the release manifest and reconcile it against the running build.73     * Returns the manifest when a newer version exists, null when up to date.74     * Throws on network/parse failure — callers decide whether that is worth75     * surfacing (the periodic worker retries; the Settings "check now" reports).76     */77    suspend fun check(): ReleaseManifest? {78        val manifest = fetchLatest()79        val newer = isNewerVersion(manifest.version, currentVersion)8081        _available.value = manifest.takeIf { newer }82        prefs.edit().apply {83            if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)84        }.apply()8586        // The manifest for the version we're already running is the only place the87        // post-update changelog can come from, so cache it the moment we see it.88        if (normalizeVersion(manifest.version) == currentVersion) {89            if (manifest.notes == null) {90                // Genuinely no notes for this release — nothing to ever show, so91                // stop waiting for them rather than leaving the flag armed forever.92                markChangelogSeen()93            } else {94                prefs.edit()95                    .putString(KEY_NOTES_VERSION, currentVersion)96                    .putString(KEY_NOTES, manifest.notes)97                    .apply()98                refreshWhatsNew()99            }100        }101        return _available.value102    }103104    private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {105        val request = Request.Builder()106            .url(LATEST_RELEASE_URL)107            .header("User-Agent", USER_AGENT)108            .build()109        client.newCall(request).execute().use { response ->110            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")111            parseReleaseManifest(response.body?.string().orEmpty())112        }113    }114115    /* ──────────────────────────  download + verify  ────────────────────────── */116117    /**118     * Download the release APK into app cache and verify its checksum. A file that119     * does not match is deleted and the call fails — an unverified APK is never120     * left on disk for something else to pick up, and never reaches the installer.121     */122    suspend fun downloadAndVerify(123        manifest: ReleaseManifest,124        onProgress: (Float) -> Unit = {},125    ): File = withContext(Dispatchers.IO) {126        val expected = parseChecksum(manifest.checksum)127            ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")128129        // parseReleaseManifest has already enforced strict SemVer, so the version130        // is guaranteed to be a clean `major.minor.patch` with no path-traversal131        // characters; using it in the filename is safe.132        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }133        val target = File(dir, "peach-${manifest.version}.apk")134        require(!target.name.contains("..") && !target.name.contains('/') && !target.name.contains('\\')) {135            "refusing to write outside cache dir"136        }137138        // A previously verified download of the same version is reusable; anything139        // else in here is a stale or half-finished attempt.140        dir.listFiles()?.forEach { if (it != target) it.delete() }141        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {142            // Even on a cache hit we re-prove identity: a tampered file in the143            // cache slot must not reach the installer any more than a tampered144            // download must.145            verifyApkIdentity(target)146            onProgress(1f)147            return@withContext target148        }149        target.delete()150151        val request = Request.Builder()152            .url(manifest.url)153            .header("User-Agent", USER_AGENT)154            .build()155        client.newCall(request).execute().use { response ->156            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")157            val body = response.body ?: throw IOException("empty response")158            val advertised = body.contentLength()159            // Reject before streaming if the server is honest about being too160            // big; Content-Length can be -1 (chunked / unknown) and the161            // streaming cap below catches that case as bytes arrive.162            if (advertised in 1..MAX_APK_BYTES) {163                // known and within bounds — nothing to do164            } else if (advertised > MAX_APK_BYTES) {165                throw DownloadSizeException("APK advertises $advertised bytes, cap is $MAX_APK_BYTES")166            }167            try {168                body.byteStream().use { input ->169                    target.outputStream().use { output ->170                        val buffer = ByteArray(64 * 1024)171                        var written = 0L172                        while (true) {173                            val read = input.read(buffer)174                            if (read <= 0) break175                            written += read176                            if (written > MAX_APK_BYTES) {177                                // Streaming cap: a server that lies about its178                                // Content-Length, or chunks past it, is still179                                // bounded here. The partial file is removed so180                                // a later retry doesn't pick up half an APK.181                                throw DownloadSizeException(182                                    "APK exceeded $MAX_APK_BYTES bytes mid-download",183                                )184                            }185                            output.write(buffer, 0, read)186                            if (advertised > 0) onProgress(187                                (written.toFloat() / advertised).coerceIn(0f, 1f),188                            )189                        }190                    }191                }192            } catch (e: DownloadSizeException) {193                target.delete()194                throw e195            }196        }197198        val actual = hashFile(target, expected.algorithm)199        if (actual != expected.hex) {200            target.delete()201            throw ChecksumMismatchException(202                "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",203            )204        }205        verifyApkIdentity(target)206        onProgress(1f)207        target208    }209210    /**211     * Confirm the cached APK is the package and version this updater belongs212     * to. PackageManager is happy to parse any well-formed APK the cache may213     * contain (or, in the future, a hostile Kepr build with a substituted214     * manifest); the package-name and version-code checks are what stop an215     * arbitrary binary from being handed to the installer.216     */217    private fun verifyApkIdentity(apk: File) {218        val info = context.packageManager.getPackageArchiveInfo(219            apk.absolutePath,220            0,221        ) ?: throw ApkIdentityException("APK could not be parsed as an Android package")222        if (!isExpectedApk(223                packageName = info.packageName,224                versionCode = info.longVersionCode,225                expectedPackage = context.packageName,226                expectedVersionCode = BuildConfig.VERSION_CODE,227            )228        ) {229            apk.delete()230            throw ApkIdentityException(231                "APK identity mismatch: package=${info.packageName} " +232                    "versionCode=${info.longVersionCode}, expected ${context.packageName} " +233                    ">=${BuildConfig.VERSION_CODE}",234            )235        }236    }237238    fun discardDownloads() {239        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }240    }241242    /* ────────────────────────────────  install  ────────────────────────────── */243244    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */245    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()246247    fun unknownSourcesSettingsIntent(): Intent =248        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))249            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)250251    /**252     * Hand a verified APK to the system package installer. The file lives in app253     * cache, so it can only be read through the FileProvider grant this intent carries.254     */255    fun installIntent(apk: File): Intent {256        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)257        return Intent(Intent.ACTION_VIEW)258            .setDataAndType(uri, APK_MIME)259            .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)260    }261262    /* ──────────────────────────────  what's new  ───────────────────────────── */263264    /**265     * Arm the post-update changelog when the running version differs from the last266     * one whose notes were read. A fresh install has nothing to catch up on, so it267     * silently records the current version instead of showing a changelog for a268     * release the user never ran.269     */270    fun refreshWhatsNew() {271        val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)272        if (lastSeen == null) {273            markChangelogSeen()274            return275        }276        if (lastSeen == currentVersion) {277            _whatsNew.value = null278            return279        }280        _whatsNew.value = prefs.getString(KEY_NOTES, null)281            ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }282    }283284    fun markChangelogSeen() {285        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()286        _whatsNew.value = null287    }288289    /* ─────────────────────────────  notify-once  ───────────────────────────── */290291    /** True the first time a given version is seen, so the same update notifies once. */292    fun shouldNotifyFor(version: String): Boolean =293        prefs.getString(KEY_NOTIFIED_VERSION, null) != version294295    fun markNotified(version: String) {296        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()297    }298299    private fun cachedAvailable(): ReleaseManifest? =300        prefs.getString(KEY_LATEST_MANIFEST, null)301            ?.let { ReleaseManifest.fromCachedJson(it) }302            ?.takeIf { isNewerVersion(it.version, currentVersion) }303304    companion object {305        /** Kepr repo name for this app; the generic release API is keyed on it. */306        const val KEPR_REPO = "peach-android"307        const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"308309        private const val APK_MIME = "application/vnd.android.package-archive"310        private const val USER_AGENT = "Peach-Android"311        private const val CACHE_DIR = "updates"312313        private const val PREFS = "peach_updates"314        private const val KEY_LATEST_MANIFEST = "latest_manifest"315        private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"316        private const val KEY_NOTES_VERSION = "notes_version"317        private const val KEY_NOTES = "notes"318        private const val KEY_NOTIFIED_VERSION = "notified_version"319    }320}