UpdateRepository.kt

10.8 KB · raw

1package com.peachpasswords.updates23import android.content.Context4import android.content.Intent5import android.net.Uri6import android.provider.Settings7import androidx.core.content.FileProvider8import com.peachpasswords.BuildConfig9import dagger.hilt.android.qualifiers.ApplicationContext10import kotlinx.coroutines.Dispatchers11import kotlinx.coroutines.flow.MutableStateFlow12import kotlinx.coroutines.flow.StateFlow13import kotlinx.coroutines.flow.asStateFlow14import kotlinx.coroutines.withContext15import okhttp3.OkHttpClient16import okhttp3.Request17import java.io.File18import java.io.IOException19import java.util.concurrent.TimeUnit20import javax.inject.Inject21import javax.inject.Singleton2223/**24 * The Kepr side of in-app updates: what the newest release is, fetching its APK,25 * and proving that APK is the one Kepr published before it is ever handed to the26 * package installer.27 *28 * Nothing here downloads or installs on its own. `check()` is the only thing the29 * background worker and app-open path call; everything past that is driven by a30 * user tap, per the "never do anything silently with the user's device" rule.31 */32@Singleton33class UpdateRepository @Inject constructor(34    @ApplicationContext private val context: Context,35) {3637    private val client: OkHttpClient by lazy {38        OkHttpClient.Builder()39            .callTimeout(5, TimeUnit.MINUTES)40            .build()41    }4243    private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }4445    /** The running build's release version, with the debug `-debug` suffix stripped. */46    val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)4748    private val _available = MutableStateFlow(cachedAvailable())49    /** The newest release, only when it is actually newer than [currentVersion]. */50    val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()5152    private val _whatsNew = MutableStateFlow<String?>(null)53    /** Notes for the running version, set only while they are still unread. */54    val whatsNew: StateFlow<String?> = _whatsNew.asStateFlow()5556    private val _openRequested = MutableStateFlow(false)57    /** Set when the app was launched by tapping the update notification. */58    val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()5960    init {61        refreshWhatsNew()62    }6364    fun requestOpen() { _openRequested.value = true }6566    fun consumeOpenRequest() { _openRequested.value = false }6768    /* ────────────────────────────────  check  ──────────────────────────────── */6970    /**71     * Fetch the release manifest and reconcile it against the running build.72     * Returns the manifest when a newer version exists, null when up to date.73     * Throws on network/parse failure — callers decide whether that is worth74     * surfacing (the periodic worker retries; the Settings "check now" reports).75     */76    suspend fun check(): ReleaseManifest? {77        val manifest = fetchLatest()78        val newer = isNewerVersion(manifest.version, currentVersion)7980        _available.value = manifest.takeIf { newer }81        prefs.edit().apply {82            if (newer) putString(KEY_LATEST_MANIFEST, manifest.toJson()) else remove(KEY_LATEST_MANIFEST)83        }.apply()8485        // The manifest for the version we're already running is the only place the86        // post-update changelog can come from, so cache it the moment we see it.87        if (normalizeVersion(manifest.version) == currentVersion) {88            if (manifest.notes == null) {89                // Genuinely no notes for this release — nothing to ever show, so90                // stop waiting for them rather than leaving the flag armed forever.91                markChangelogSeen()92            } else {93                prefs.edit()94                    .putString(KEY_NOTES_VERSION, currentVersion)95                    .putString(KEY_NOTES, manifest.notes)96                    .apply()97                refreshWhatsNew()98            }99        }100        return _available.value101    }102103    private suspend fun fetchLatest(): ReleaseManifest = withContext(Dispatchers.IO) {104        val request = Request.Builder()105            .url(LATEST_RELEASE_URL)106            .header("User-Agent", USER_AGENT)107            .build()108        client.newCall(request).execute().use { response ->109            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")110            parseReleaseManifest(response.body?.string().orEmpty())111        }112    }113114    /* ──────────────────────────  download + verify  ────────────────────────── */115116    /**117     * Download the release APK into app cache and verify its checksum. A file that118     * does not match is deleted and the call fails — an unverified APK is never119     * left on disk for something else to pick up, and never reaches the installer.120     */121    suspend fun downloadAndVerify(122        manifest: ReleaseManifest,123        onProgress: (Float) -> Unit = {},124    ): File = withContext(Dispatchers.IO) {125        val expected = parseChecksum(manifest.checksum)126            ?: throw ChecksumMismatchException("unrecognised checksum '${manifest.checksum}'")127128        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }129        val target = File(dir, "peach-${manifest.version}.apk")130131        // A previously verified download of the same version is reusable; anything132        // else in here is a stale or half-finished attempt.133        dir.listFiles()?.forEach { if (it != target) it.delete() }134        if (target.exists() && hashFile(target, expected.algorithm) == expected.hex) {135            onProgress(1f)136            return@withContext target137        }138        target.delete()139140        val request = Request.Builder()141            .url(manifest.url)142            .header("User-Agent", USER_AGENT)143            .build()144        client.newCall(request).execute().use { response ->145            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")146            val body = response.body ?: throw IOException("empty response")147            val total = body.contentLength()148            body.byteStream().use { input ->149                target.outputStream().use { output ->150                    val buffer = ByteArray(64 * 1024)151                    var written = 0L152                    while (true) {153                        val read = input.read(buffer)154                        if (read <= 0) break155                        output.write(buffer, 0, read)156                        written += read157                        if (total > 0) onProgress((written.toFloat() / total).coerceIn(0f, 1f))158                    }159                }160            }161        }162163        val actual = hashFile(target, expected.algorithm)164        if (actual != expected.hex) {165            target.delete()166            throw ChecksumMismatchException(167                "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",168            )169        }170        onProgress(1f)171        target172    }173174    fun discardDownloads() {175        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }176    }177178    /* ────────────────────────────────  install  ────────────────────────────── */179180    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */181    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()182183    fun unknownSourcesSettingsIntent(): Intent =184        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))185            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)186187    /**188     * Hand a verified APK to the system package installer. The file lives in app189     * cache, so it can only be read through the FileProvider grant this intent carries.190     */191    fun installIntent(apk: File): Intent {192        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)193        return Intent(Intent.ACTION_VIEW)194            .setDataAndType(uri, APK_MIME)195            .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)196    }197198    /* ──────────────────────────────  what's new  ───────────────────────────── */199200    /**201     * Arm the post-update changelog when the running version differs from the last202     * one whose notes were read. A fresh install has nothing to catch up on, so it203     * silently records the current version instead of showing a changelog for a204     * release the user never ran.205     */206    fun refreshWhatsNew() {207        val lastSeen = prefs.getString(KEY_LAST_SEEN_VERSION, null)208        if (lastSeen == null) {209            markChangelogSeen()210            return211        }212        if (lastSeen == currentVersion) {213            _whatsNew.value = null214            return215        }216        _whatsNew.value = prefs.getString(KEY_NOTES, null)217            ?.takeIf { prefs.getString(KEY_NOTES_VERSION, null) == currentVersion }218    }219220    fun markChangelogSeen() {221        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()222        _whatsNew.value = null223    }224225    /* ─────────────────────────────  notify-once  ───────────────────────────── */226227    /** True the first time a given version is seen, so the same update notifies once. */228    fun shouldNotifyFor(version: String): Boolean =229        prefs.getString(KEY_NOTIFIED_VERSION, null) != version230231    fun markNotified(version: String) {232        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()233    }234235    private fun cachedAvailable(): ReleaseManifest? =236        prefs.getString(KEY_LATEST_MANIFEST, null)237            ?.let { ReleaseManifest.fromCachedJson(it) }238            ?.takeIf { isNewerVersion(it.version, currentVersion) }239240    companion object {241        /** Kepr repo name for this app; the generic release API is keyed on it. */242        const val KEPR_REPO = "peach-android"243        const val LATEST_RELEASE_URL = "https://kepr.uk/api/$KEPR_REPO/releases/latest"244245        private const val APK_MIME = "application/vnd.android.package-archive"246        private const val USER_AGENT = "Peach-Android"247        private const val CACHE_DIR = "updates"248249        private const val PREFS = "peach_updates"250        private const val KEY_LATEST_MANIFEST = "latest_manifest"251        private const val KEY_LAST_SEEN_VERSION = "last_seen_changelog_version"252        private const val KEY_NOTES_VERSION = "notes_version"253        private const val KEY_NOTES = "notes"254        private const val KEY_NOTIFIED_VERSION = "notified_version"255    }256}