df43a3a2a1bd

add Kepr update check, checksum-verified APK download, install prompt, what's-new screen and Settings About row

time
2026-07-25 01:05 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
kepr in-app updates
parent
b3f592370e5a
14 files app/build.gradle+9 −0app/src/main/AndroidManifest.xml+18 −0app/src/main/java/com/peachpasswords/MainActivity.kt+7 −0app/src/main/java/com/peachpasswords/PeachApplication.kt+14 −1app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+29 −1app/src/main/java/com/peachpasswords/ui/PeachNav.kt+6 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+49 −1app/src/main/java/com/peachpasswords/ui/updates/UpdateScreen.kt+461 −0app/src/main/java/com/peachpasswords/ui/updates/WhatsNewScreen.kt+63 −0app/src/main/java/com/peachpasswords/updates/UpdateCheckWorker.kt+144 −0app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+177 −0app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+256 −0app/src/main/res/xml/file_paths.xml+4 −0app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+111 −0
app/build.gradle+9 −0
@@ -99,6 +99,12 @@9999     implementation "androidx.glance:glance-appwidget:1.1.0"100100     implementation "androidx.glance:glance-material3:1.1.0"101101     implementation "androidx.hilt:hilt-navigation-compose:1.2.0"102+    // In-app updates: a periodic release check that survives Doze without a103+    // service. hilt-work lets the worker take UpdateRepository by injection104+    // rather than reaching for a global.105+    implementation "androidx.hilt:hilt-work:1.2.0"106+    kapt "androidx.hilt:hilt-compiler:1.2.0"107+    implementation "androidx.work:work-runtime-ktx:2.9.1"102108     implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.4"103109     implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4"104110     implementation "androidx.lifecycle:lifecycle-process:2.8.4"@@ -147,5 +153,8 @@147153     androidTestImplementation "androidx.compose.ui:ui-test-junit4"148154 149155     testImplementation "junit:junit:4.13.2"156+    // Real org.json on the unit-test classpath; android.jar's is a throwing stub,157+    // so the release-manifest parser would otherwise be untestable off-device.158+    testImplementation "org.json:json:20240303"150159     testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1"151160 }

view file

app/src/main/AndroidManifest.xml+18 −0
@@ -17,6 +17,10 @@1717          an already-paired browser's reconnect time out with no visible cause. -->1818     <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />1919     <uses-permission android:name="android.permission.CAMERA" />20+    <!-- Peach ships outside the Play Store, so it installs its own updates via the21+         system package installer. Still gated per-app by the user's "install unknown22+         apps" grant — this permission only makes the ask possible. -->23+    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />2024     <uses-feature android:name="android.hardware.camera.any" android:required="false" />2125 2226     <application@@ -67,6 +71,20 @@6771             android:exported="false"6872             android:excludeFromRecents="true"6973             android:theme="@style/Theme.Peach.Transparent" />74+75+        <!-- WorkManager is initialized on demand by PeachApplication (Configuration.Provider)76+             so the update worker can be Hilt-injected; its default auto-initializer would77+             race that and build a factory-less WorkManager first. -->78+        <provider79+            android:name="androidx.startup.InitializationProvider"80+            android:authorities="${applicationId}.androidx-startup"81+            android:exported="false"82+            tools:node="merge">83+            <meta-data84+                android:name="androidx.work.WorkManagerInitializer"85+                android:value="androidx.startup"86+                tools:node="remove" />87+        </provider>7088 7189         <provider7290             android:name="androidx.core.content.FileProvider"

view file

app/src/main/java/com/peachpasswords/MainActivity.kt+7 −0
@@ -33,6 +33,8 @@3333 import com.peachpasswords.ui.theme.ACCENT_COLORS3434 import com.peachpasswords.ui.theme.DEFAULT_ACCENT3535 import com.peachpasswords.ui.theme.PeachTheme36+import com.peachpasswords.updates.EXTRA_OPEN_UPDATE37+import com.peachpasswords.updates.UpdateRepository3638 import com.peachpasswords.vault.VaultLockTimer3739 import com.peachpasswords.vault.VaultRepository3840 @@ -41,6 +43,7 @@4143 class MainActivity : FragmentActivity() {4244     @Inject lateinit var vault: VaultRepository4345     @Inject lateinit var lockTimer: VaultLockTimer46+    @Inject lateinit var updates: UpdateRepository4447 4548     // Every touch/key event resets the idle auto-lock countdown (CF-1).4649     override fun onUserInteraction() {@@ -54,6 +57,7 @@5457     override fun onNewIntent(intent: Intent) {5558         super.onNewIntent(intent)5659         setIntent(intent)60+        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()5761         if (BuildConfig.DEBUG) {5862             QaFlags.disableSecureCaptureForQa = intent.getBooleanExtra("qa_disable_secure_flag", false)5963         }@@ -62,6 +66,9 @@6266     override fun onCreate(savedInstanceState: Bundle?) {6367         installSplashScreen()6468         super.onCreate(savedInstanceState)69+70+        // Cold launch from the update notification (the warm case is onNewIntent).71+        if (intent.getBooleanExtra(EXTRA_OPEN_UPDATE, false)) updates.requestOpen()6572 6673         // Match the splash/window fill to the resolved theme's --bg so launch6774         // never flashes the wrong colour before Compose draws. Resolved from the

view file

app/src/main/java/com/peachpasswords/PeachApplication.kt+14 −1
@@ -1,23 +1,36 @@11 package com.peachpasswords22 33 import android.app.Application4+import androidx.hilt.work.HiltWorkerFactory45 import androidx.lifecycle.DefaultLifecycleObserver56 import androidx.lifecycle.LifecycleOwner67 import androidx.lifecycle.ProcessLifecycleOwner8+import androidx.work.Configuration79 import com.peachpasswords.sync.ForegroundSyncCoordinator10+import com.peachpasswords.updates.UpdateCheckWorker811 import com.peachpasswords.vault.VaultLockTimer912 import com.peachpasswords.vault.VaultRepository1013 import dagger.hilt.android.HiltAndroidApp1114 import javax.inject.Inject1215 1316 @HiltAndroidApp14-class PeachApplication : Application() {17+class PeachApplication : Application(), Configuration.Provider {1518     @Inject lateinit var vault: VaultRepository1619     @Inject lateinit var lockTimer: VaultLockTimer1720     @Inject lateinit var foregroundSync: ForegroundSyncCoordinator21+    @Inject lateinit var workerFactory: HiltWorkerFactory1822 23+    override val workManagerConfiguration: Configuration24+        get() = Configuration.Builder().setWorkerFactory(workerFactory).build()25+1926     override fun onCreate() {2027         super.onCreate()28+29+        // Update checks: once a day in the background, plus one now — process30+        // start is the honest "app open". Neither ever downloads or installs;31+        // the most they do is post a notification.32+        UpdateCheckWorker.schedulePeriodic(this)33+        UpdateCheckWorker.checkNow(this)2134         // Lock the vault the moment the whole app goes to background (CF-1).2235         // ProcessLifecycleOwner is process-wide and debounced, so a rotation or a2336         // brief Activity restart does not trip it — only a genuine backgrounding.

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+29 −1
@@ -13,6 +13,7 @@1313 import androidx.compose.runtime.LaunchedEffect1414 import androidx.compose.runtime.getValue1515 import androidx.compose.ui.Modifier16+import androidx.hilt.navigation.compose.hiltViewModel1617 import androidx.lifecycle.compose.collectAsStateWithLifecycle1718 import androidx.navigation.NavHostController1819 import androidx.navigation.NavType@@ -31,6 +32,9 @@3132 import com.peachpasswords.ui.settings.SettingsScreen3233 import com.peachpasswords.ui.vault.VaultHealthScreen3334 import com.peachpasswords.ui.theme.PeachMotion35+import com.peachpasswords.ui.updates.UpdateScreen36+import com.peachpasswords.ui.updates.UpdateViewModel37+import com.peachpasswords.ui.updates.WhatsNewScreen3438 import com.peachpasswords.ui.vault.EntryDetailScreen3539 import com.peachpasswords.ui.vault.EntryEditScreen3640 import com.peachpasswords.ui.vault.ExportScreen@@ -53,8 +57,30 @@5357     // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no5458     // post-unlock screen lingers behind it. Guarded so it never fires on the lock5559     // or onboarding screens (where the vault is legitimately locked).60+    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value61+62+    // Update surfaces, both driven from outside the nav graph: a notification tap63+    // (openRequested) and the once-per-version post-update changelog.64+    val updateVm: UpdateViewModel = hiltViewModel()65+    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()66+    LaunchedEffect(openUpdateRequested) {67+        if (openUpdateRequested) {68+            updateVm.consumeOpenRequest()69+            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }70+        }71+    }72+    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()73+    // Held until the vault is open: the changelog is harmless, but shoving it in74+    // front of the lock screen reads as the app having lost its place.75+    LaunchedEffect(whatsNewNotes, unlocked) {76+        if (whatsNewNotes != null && unlocked &&77+            navController.currentDestination?.route != PeachNav.WhatsNew.route78+        ) {79+            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }80+        }81+    }82+5683     if (isUnlocked != null) {57-        val unlocked by isUnlocked.collectAsStateWithLifecycle()5884         LaunchedEffect(unlocked) {5985             if (!unlocked) {6086                 val current = navController.currentDestination?.route@@ -269,6 +295,8 @@269295         composable(PeachNav.RecoveryView.route) {270296             RecoveryWordsScreen(requireConfirm = false, onDone = back)271297         }298+        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }299+        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }272300         composable(PeachNav.RecoveryEntry.route) {273301             RecoveryWordEntryScreen(274302                 onSuccess = {

view file

app/src/main/java/com/peachpasswords/ui/PeachNav.kt+6 −0
@@ -48,4 +48,10 @@4848     data object RecoverySetup : PeachNav("recovery/setup")4949     data object RecoveryView : PeachNav("recovery/view")5050     data object RecoveryEntry : PeachNav("recovery/entry")51+52+    /** Check / download / install a newer release from Kepr. */53+    data object Update : PeachNav("update")54+55+    /** Post-update changelog for the version now running, shown once. */56+    data object WhatsNew : PeachNav("whatsnew")5157 }

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+49 −1
@@ -61,6 +61,8 @@6161 import com.peachpasswords.ui.theme.ACCENT_COLORS6262 import com.peachpasswords.ui.theme.PeachSpacing6363 import com.peachpasswords.ui.theme.PeachType64+import com.peachpasswords.ui.updates.UpdatePhase65+import com.peachpasswords.ui.updates.UpdateViewModel6466 import androidx.hilt.navigation.compose.hiltViewModel6567 import com.peachpasswords.vault.VaultSettings6668 import com.peachpasswords.vault.VaultSnapshot@@ -95,7 +97,53 @@9597         CardId.Sync -> SyncBody(fg, onNavigate)9698         CardId.Shortcuts -> ShortcutsBody(fg)9799         CardId.Pro -> ProBody(fg, vm)98-        CardId.Feedback -> FeedbackBody(fg, vm)100+        CardId.Feedback -> {101+            // The closest thing Peach has to an About surface, and where the brief102+            // put the update entry point: version first, then the feedback form.103+            AboutUpdatesBody(fg, onNavigate)104+            FeedbackBody(fg, vm)105+        }106+    }107+}108+109+/* ────────────────────────────  about / updates  ─────────────────────────── */110+111+@Composable112+private fun AboutUpdatesBody(fg: Color, onNavigate: (PeachNav) -> Unit) {113+    val vm: UpdateViewModel = hiltViewModel()114+    val available by vm.available.collectAsState()115+    val state by vm.state.collectAsState()116+117+    SubLabel("THIS BUILD", fg, top = 0.dp)118+    RowLine(fg) {119+        Name("Version", fg)120+        ValueText(BuildConfig.VERSION_NAME, fg)121+    }122+    RowLine(fg) {123+        Column(Modifier.weight(1f)) {124+            Text("Updates", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)125+            Text(126+                when {127+                    available != null -> "v${available?.version} is available"128+                    state.phase == UpdatePhase.Checking -> "Checking…"129+                    state.phase == UpdatePhase.UpToDate -> "You're on the latest version"130+                    state.phase == UpdatePhase.Failed -> "Couldn't reach the update server"131+                    else -> "Peach checks once a day and tells you when there's a new version."132+                },133+                color = fg.copy(alpha = 0.7f),134+                fontFamily = PeachType.Body,135+                fontSize = 11.sp,136+                lineHeight = 16.sp,137+                modifier = Modifier.padding(top = 2.dp),138+            )139+        }140+        if (available != null) {141+            SubtleButton("update", fg) { onNavigate(PeachNav.Update) }142+        } else {143+            SubtleButton(if (state.phase == UpdatePhase.Checking) "checking" else "check now", fg) {144+                vm.check()145+            }146+        }99147     }100148 }101149 

view file

app/src/main/java/com/peachpasswords/ui/updates/UpdateScreen.kt+461 −0
@@ -0,0 +1,461 @@1+package com.peachpasswords.ui.updates2+3+import android.Manifest4+import android.content.Context5+import android.content.pm.PackageManager6+import android.os.Build7+import androidx.activity.compose.rememberLauncherForActivityResult8+import androidx.activity.result.contract.ActivityResultContracts9+import androidx.compose.foundation.background10+import androidx.compose.foundation.border11+import androidx.compose.foundation.clickable12+import androidx.compose.foundation.layout.Arrangement13+import androidx.compose.foundation.layout.Box14+import androidx.compose.foundation.layout.Column15+import androidx.compose.foundation.layout.Row16+import androidx.compose.foundation.layout.Spacer17+import androidx.compose.foundation.layout.fillMaxSize18+import androidx.compose.foundation.layout.fillMaxWidth19+import androidx.compose.foundation.layout.height20+import androidx.compose.foundation.layout.padding21+import androidx.compose.foundation.layout.size22+import androidx.compose.foundation.layout.systemBarsPadding23+import androidx.compose.foundation.rememberScrollState24+import androidx.compose.foundation.shape.RoundedCornerShape25+import androidx.compose.foundation.verticalScroll26+import androidx.compose.material.icons.Icons27+import androidx.compose.material.icons.automirrored.rounded.ArrowBack28+import androidx.compose.material3.Icon29+import androidx.compose.material3.Text30+import androidx.compose.runtime.Composable31+import androidx.compose.runtime.LaunchedEffect32+import androidx.compose.runtime.collectAsState33+import androidx.compose.runtime.getValue34+import androidx.compose.runtime.mutableStateOf35+import androidx.compose.runtime.remember36+import androidx.compose.runtime.setValue37+import androidx.compose.ui.Alignment38+import androidx.compose.ui.Modifier39+import androidx.compose.ui.draw.clip40+import androidx.compose.ui.platform.LocalContext41+import androidx.compose.ui.unit.dp42+import androidx.compose.ui.unit.sp43+import androidx.core.content.ContextCompat44+import androidx.hilt.navigation.compose.hiltViewModel45+import androidx.lifecycle.ViewModel46+import androidx.lifecycle.viewModelScope47+import com.peachpasswords.ui.components.PeachTopBar48+import com.peachpasswords.ui.theme.LocalPeachAccent49+import com.peachpasswords.ui.theme.PeachSpacing50+import com.peachpasswords.ui.theme.PeachType51+import com.peachpasswords.ui.theme.ShellBackground52+import com.peachpasswords.ui.theme.peachColors53+import com.peachpasswords.updates.ChecksumMismatchException54+import com.peachpasswords.updates.ReleaseManifest55+import com.peachpasswords.updates.UpdateRepository56+import dagger.hilt.android.lifecycle.HiltViewModel57+import kotlinx.coroutines.flow.MutableStateFlow58+import kotlinx.coroutines.flow.StateFlow59+import kotlinx.coroutines.flow.asStateFlow60+import kotlinx.coroutines.launch61+import java.io.File62+import javax.inject.Inject63+64+enum class UpdatePhase { Idle, Checking, UpToDate, Available, Downloading, Ready, Failed }65+66+data class UpdateUiState(67+    val phase: UpdatePhase = UpdatePhase.Idle,68+    val manifest: ReleaseManifest? = null,69+    val progress: Float = 0f,70+    val apk: File? = null,71+    val error: String? = null,72+    /** True once the user has asked to install but hasn't granted install-unknown-apps. */73+    val explainInstallPermission: Boolean = false,74+)75+76+@HiltViewModel77+class UpdateViewModel @Inject constructor(78+    private val updates: UpdateRepository,79+) : ViewModel() {80+81+    val currentVersion: String = updates.currentVersion82+    val available: StateFlow<ReleaseManifest?> = updates.available83+    val whatsNew: StateFlow<String?> = updates.whatsNew84+    val openRequested: StateFlow<Boolean> = updates.openRequested85+86+    private val _state = MutableStateFlow(87+        updates.available.value88+            ?.let { UpdateUiState(phase = UpdatePhase.Available, manifest = it) }89+            ?: UpdateUiState(),90+    )91+    val state: StateFlow<UpdateUiState> = _state.asStateFlow()92+93+    fun consumeOpenRequest() = updates.consumeOpenRequest()94+95+    fun dismissWhatsNew() = updates.markChangelogSeen()96+97+    fun check() {98+        if (_state.value.phase == UpdatePhase.Checking || _state.value.phase == UpdatePhase.Downloading) return99+        _state.value = _state.value.copy(phase = UpdatePhase.Checking, error = null)100+        viewModelScope.launch {101+            _state.value = try {102+                val manifest = updates.check()103+                if (manifest == null) {104+                    UpdateUiState(phase = UpdatePhase.UpToDate)105+                } else {106+                    UpdateUiState(phase = UpdatePhase.Available, manifest = manifest)107+                }108+            } catch (e: Exception) {109+                // A failed re-check doesn't invalidate an update we already know110+                // about — keep it offerable rather than dropping to a dead end.111+                _state.value.copy(112+                    phase = if (_state.value.manifest != null) UpdatePhase.Available else UpdatePhase.Failed,113+                    error = "Couldn't reach the update server — check your connection",114+                )115+            }116+        }117+    }118+119+    fun download() {120+        val manifest = _state.value.manifest ?: return121+        if (_state.value.phase == UpdatePhase.Downloading) return122+        _state.value = _state.value.copy(phase = UpdatePhase.Downloading, progress = 0f, error = null)123+        viewModelScope.launch {124+            _state.value = try {125+                val apk = updates.downloadAndVerify(manifest) { p ->126+                    _state.value = _state.value.copy(progress = p)127+                }128+                _state.value.copy(phase = UpdatePhase.Ready, apk = apk, progress = 1f)129+            } catch (e: ChecksumMismatchException) {130+                // Deliberately loud: a mismatch means the file is not what Kepr131+                // published, and the download has already been deleted.132+                _state.value.copy(133+                    phase = UpdatePhase.Failed,134+                    apk = null,135+                    error = "The download didn't match its checksum and was deleted. Nothing was installed.",136+                )137+            } catch (e: Exception) {138+                _state.value.copy(139+                    phase = UpdatePhase.Failed,140+                    apk = null,141+                    error = "Download failed — check your connection and try again",142+                )143+            }144+        }145+    }146+147+    /**148+     * Install the verified APK. If Android hasn't granted this app the149+     * install-unknown-apps permission, surface the in-app explanation instead of150+     * dropping a bare system dialog on the user.151+     */152+    fun install(context: Context) {153+        val apk = _state.value.apk ?: return154+        if (!updates.canInstallPackages()) {155+            _state.value = _state.value.copy(explainInstallPermission = true)156+            return157+        }158+        _state.value = _state.value.copy(explainInstallPermission = false)159+        runCatching { context.startActivity(updates.installIntent(apk)) }160+            .onFailure {161+                _state.value = _state.value.copy(162+                    phase = UpdatePhase.Failed,163+                    error = "No package installer available on this device",164+                )165+            }166+    }167+168+    fun openInstallPermissionSettings(context: Context) {169+        runCatching { context.startActivity(updates.unknownSourcesSettingsIntent()) }170+    }171+172+    fun dismissInstallExplanation() {173+        _state.value = _state.value.copy(explainInstallPermission = false)174+    }175+}176+177+@Composable178+fun UpdateScreen(179+    onBack: () -> Unit,180+    vm: UpdateViewModel = hiltViewModel(),181+) {182+    val state by vm.state.collectAsState()183+    val context = LocalContext.current184+185+    // Reaching this screen with nothing known yet: check straight away, since the186+    // user got here to find out.187+    LaunchedEffect(Unit) {188+        if (state.phase == UpdatePhase.Idle) vm.check()189+    }190+191+    ShellBackground {192+        Column(193+            Modifier194+                .fillMaxSize()195+                .systemBarsPadding()196+                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),197+        ) {198+            PeachTopBar(199+                title = "Update",200+                leading = {201+                    Icon(202+                        Icons.AutoMirrored.Rounded.ArrowBack,203+                        contentDescription = "back",204+                        tint = peachColors.T0,205+                        modifier = Modifier206+                            .size(22.dp)207+                            .clip(RoundedCornerShape(PeachSpacing.sm))208+                            .clickable(onClick = onBack),209+                    )210+                },211+            )212+            Spacer(Modifier.height(PeachSpacing.lg))213+214+            Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {215+                Text(216+                    when (state.phase) {217+                        UpdatePhase.Checking -> "checking…"218+                        UpdatePhase.UpToDate -> "you're up to date"219+                        UpdatePhase.Available -> "a newer Peach"220+                        UpdatePhase.Downloading -> "downloading"221+                        UpdatePhase.Ready -> "ready to install"222+                        UpdatePhase.Failed -> "couldn't finish"223+                        UpdatePhase.Idle -> "updates"224+                    },225+                    color = peachColors.T0,226+                    style = PeachType.createHeadline,227+                )228+                Spacer(Modifier.height(PeachSpacing.xs))229+                Text(230+                    buildString {231+                        append("Installed v${vm.currentVersion}")232+                        state.manifest?.let { append("  ·  latest v${it.version}") }233+                    },234+                    color = peachColors.T1,235+                    style = PeachType.labelMono,236+                )237+238+                state.error?.let { message ->239+                    Spacer(Modifier.height(PeachSpacing.md))240+                    Box(241+                        Modifier.fillMaxWidth()242+                            .clip(RoundedCornerShape(10.dp))243+                            .background(peachColors.BreachBg)244+                            .border(1.dp, peachColors.BreachBorder, RoundedCornerShape(10.dp))245+                            .padding(PeachSpacing.md),246+                    ) {247+                        Text(message, color = peachColors.Breach, style = PeachType.hint)248+                    }249+                }250+251+                state.manifest?.notes?.let { notes ->252+                    Spacer(Modifier.height(PeachSpacing.lg))253+                    SectionLabel("WHAT'S IN IT")254+                    ReleaseNotes(notes)255+                }256+257+                if (state.phase == UpdatePhase.Downloading) {258+                    Spacer(Modifier.height(PeachSpacing.lg))259+                    ProgressBar(state.progress)260+                }261+262+                if (state.explainInstallPermission) {263+                    Spacer(Modifier.height(PeachSpacing.lg))264+                    InstallPermissionExplanation(265+                        onOpenSettings = { vm.openInstallPermissionSettings(context) },266+                        onDismiss = { vm.dismissInstallExplanation() },267+                    )268+                }269+270+                Spacer(Modifier.height(PeachSpacing.xl))271+                when (state.phase) {272+                    UpdatePhase.Available ->273+                        PrimaryButton("Download v${state.manifest?.version}") { vm.download() }274+                    UpdatePhase.Ready ->275+                        PrimaryButton("Install v${state.manifest?.version}") { vm.install(context) }276+                    UpdatePhase.Downloading -> Unit277+                    UpdatePhase.Checking -> Unit278+                    else -> PrimaryButton("Check for updates") { vm.check() }279+                }280+281+                if (state.phase == UpdatePhase.Ready) {282+                    Spacer(Modifier.height(PeachSpacing.sm))283+                    Text(284+                        "Verified against the checksum Kepr published for this release.",285+                        color = peachColors.T1,286+                        style = PeachType.hint,287+                    )288+                }289+290+                Spacer(Modifier.height(PeachSpacing.xl))291+                NotificationOptIn()292+                Spacer(Modifier.height(PeachSpacing.xxl))293+            }294+        }295+    }296+}297+298+/**299+ * Android 13+ won't post the "update available" notification without this grant.300+ * Asked for here, next to the explanation of what it's for, rather than as an301+ * unexplained prompt at some unrelated moment.302+ */303+@Composable304+private fun NotificationOptIn() {305+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return306+    val context = LocalContext.current307+    var granted by remember {308+        mutableStateOf(309+            ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==310+                PackageManager.PERMISSION_GRANTED,311+        )312+    }313+    if (granted) return314+    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {315+        granted = it316+    }317+    SectionLabel("NOTIFICATIONS")318+    Text(319+        "Peach can tell you when a new version is out. Nothing else uses this.",320+        color = peachColors.T1,321+        style = PeachType.hint,322+    )323+    Spacer(Modifier.height(PeachSpacing.sm))324+    GhostButton("Allow update notifications") {325+        launcher.launch(Manifest.permission.POST_NOTIFICATIONS)326+    }327+}328+329+/**330+ * The in-app explanation that must come before Android's own permission screen —331+ * a bare system prompt for "install unknown apps" reads like something has gone332+ * wrong, and the user has no way to tell what asked for it.333+ */334+@Composable335+private fun InstallPermissionExplanation(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {336+    Column(337+        Modifier.fillMaxWidth()338+            .clip(RoundedCornerShape(12.dp))339+            .background(peachColors.SettingsGroupBg)340+            .border(1.dp, peachColors.T2, RoundedCornerShape(12.dp))341+            .padding(PeachSpacing.md),342+    ) {343+        Text(344+            "Android needs your permission",345+            color = peachColors.T0,346+            style = PeachType.onboardingHeadline,347+        )348+        Spacer(Modifier.height(PeachSpacing.sm))349+        Text(350+            "Peach isn't distributed through the Play Store, so Android treats this " +351+                "update like any other app you install yourself. The next screen is " +352+                "Android's own — turn on \"Allow from this source\" for Peach, then come " +353+                "back here and tap install. You can turn it off again afterwards.",354+            color = peachColors.T1,355+            style = PeachType.hint,356+        )357+        Spacer(Modifier.height(PeachSpacing.md))358+        Row(horizontalArrangement = Arrangement.spacedBy(PeachSpacing.sm)) {359+            GhostButton("Not now", onDismiss)360+            GhostButton("Open Android settings", onOpenSettings)361+        }362+    }363+}364+365+@Composable366+internal fun SectionLabel(text: String) {367+    Text(368+        text,369+        modifier = Modifier.padding(bottom = PeachSpacing.sm),370+        color = peachColors.T1,371+        style = PeachType.tinyMono,372+    )373+}374+375+/**376+ * Release notes as Peach type rather than a raw markdown dump: headings in the377+ * display face, bullets as bullets, inline emphasis markers stripped. Anything378+ * fancier than that is left as plain text on purpose — the notes are prose.379+ */380+@Composable381+internal fun ReleaseNotes(notes: String) {382+    Column(verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs)) {383+        notes.lines().forEach { raw ->384+            val line = raw.trim()385+            when {386+                line.isEmpty() -> Spacer(Modifier.height(PeachSpacing.xs))387+                line.startsWith("#") -> Text(388+                    stripInlineMarkers(line.trimStart('#').trim()),389+                    modifier = Modifier.padding(top = PeachSpacing.sm),390+                    color = peachColors.T0,391+                    style = PeachType.settingsLede.copy(fontSize = 15.sp),392+                )393+                line.startsWith("- ") || line.startsWith("* ") || line.startsWith("• ") -> Row {394+                    Text("·  ", color = LocalPeachAccent.current, style = PeachType.body)395+                    Text(396+                        stripInlineMarkers(line.drop(2).trim()),397+                        color = peachColors.T1,398+                        style = PeachType.body.copy(lineHeight = 19.sp),399+                    )400+                }401+                else -> Text(402+                    stripInlineMarkers(line),403+                    color = peachColors.T1,404+                    style = PeachType.body.copy(lineHeight = 19.sp),405+                )406+            }407+        }408+    }409+}410+411+internal fun stripInlineMarkers(line: String): String =412+    line.replace("**", "").replace("__", "").replace("`", "")413+414+@Composable415+private fun ProgressBar(progress: Float) {416+    val accent = LocalPeachAccent.current417+    Column {418+        Box(419+            Modifier.fillMaxWidth().height(4.dp)420+                .clip(RoundedCornerShape(999.dp))421+                .background(peachColors.T3),422+        ) {423+            Box(424+                Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(4.dp)425+                    .clip(RoundedCornerShape(999.dp))426+                    .background(accent),427+            )428+        }429+        Spacer(Modifier.height(PeachSpacing.sm))430+        Text("${(progress * 100).toInt()}%", color = peachColors.T1, style = PeachType.labelMono)431+    }432+}433+434+@Composable435+internal fun PrimaryButton(label: String, onClick: () -> Unit) {436+    val accent = LocalPeachAccent.current437+    Box(438+        Modifier.fillMaxWidth()439+            .clip(RoundedCornerShape(999.dp))440+            .background(accent.copy(alpha = 0.16f))441+            .border(1.dp, accent.copy(alpha = 0.55f), RoundedCornerShape(999.dp))442+            .clickable(onClick = onClick)443+            .padding(horizontal = PeachSpacing.lg, vertical = 11.dp),444+        contentAlignment = Alignment.Center,445+    ) {446+        Text(label, color = accent, style = PeachType.sortPill.copy(fontSize = 11.sp))447+    }448+}449+450+@Composable451+private fun GhostButton(label: String, onClick: () -> Unit) {452+    Box(453+        Modifier.clip(RoundedCornerShape(999.dp))454+            .border(1.dp, peachColors.T2, RoundedCornerShape(999.dp))455+            .clickable(onClick = onClick)456+            .padding(horizontal = PeachSpacing.md, vertical = 9.dp),457+        contentAlignment = Alignment.Center,458+    ) {459+        Text(label, color = peachColors.T0, style = PeachType.sortPill.copy(fontSize = 10.sp))460+    }461+}

view file

app/src/main/java/com/peachpasswords/ui/updates/WhatsNewScreen.kt+63 −0
@@ -0,0 +1,63 @@1+package com.peachpasswords.ui.updates2+3+import androidx.activity.compose.BackHandler4+import androidx.compose.foundation.layout.Column5+import androidx.compose.foundation.layout.Spacer6+import androidx.compose.foundation.layout.fillMaxSize7+import androidx.compose.foundation.layout.height8+import androidx.compose.foundation.layout.padding9+import androidx.compose.foundation.layout.systemBarsPadding10+import androidx.compose.foundation.rememberScrollState11+import androidx.compose.foundation.verticalScroll12+import androidx.compose.material3.Text13+import androidx.compose.runtime.Composable14+import androidx.compose.runtime.remember15+import androidx.compose.ui.Modifier16+import androidx.hilt.navigation.compose.hiltViewModel17+import com.peachpasswords.ui.theme.PeachSpacing18+import com.peachpasswords.ui.theme.PeachType19+import com.peachpasswords.ui.theme.ShellBackground20+import com.peachpasswords.ui.theme.peachColors21+22+/**23+ * Shown once, after an update, for the version now running. The gate is a stored24+ * "last seen changelog version" compared against this build — so it appears on the25+ * first launch of a new version and never again, and never at all on a fresh26+ * install (there is nothing to catch up on).27+ */28+@Composable29+fun WhatsNewScreen(30+    onDone: () -> Unit,31+    vm: UpdateViewModel = hiltViewModel(),32+) {33+    // Snapshotted, not collected: dismissing clears the flow, and a live read would34+    // blank the text out from under the exit animation.35+    val notes = remember { vm.whatsNew.value.orEmpty() }36+37+    fun finish() {38+        vm.dismissWhatsNew()39+        onDone()40+    }41+    BackHandler { finish() }42+43+    ShellBackground {44+        Column(45+            Modifier46+                .fillMaxSize()47+                .systemBarsPadding()48+                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.xl),49+        ) {50+            Text("what's new", color = peachColors.T0, style = PeachType.createHeadline)51+            Spacer(Modifier.height(PeachSpacing.xs))52+            Text("v${vm.currentVersion}", color = peachColors.T1, style = PeachType.labelMono)53+            Spacer(Modifier.height(PeachSpacing.xl))54+55+            Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {56+                ReleaseNotes(notes)57+            }58+59+            Spacer(Modifier.height(PeachSpacing.lg))60+            PrimaryButton("Continue") { finish() }61+        }62+    }63+}

view file

app/src/main/java/com/peachpasswords/updates/UpdateCheckWorker.kt+144 −0
@@ -0,0 +1,144 @@1+package com.peachpasswords.updates2+3+import android.app.NotificationChannel4+import android.app.NotificationManager5+import android.app.PendingIntent6+import android.content.Context7+import android.content.Intent8+import android.os.Build9+import androidx.core.app.NotificationCompat10+import androidx.core.app.NotificationManagerCompat11+import androidx.hilt.work.HiltWorker12+import androidx.work.BackoffPolicy13+import androidx.work.Constraints14+import androidx.work.CoroutineWorker15+import androidx.work.ExistingPeriodicWorkPolicy16+import androidx.work.ExistingWorkPolicy17+import androidx.work.NetworkType18+import androidx.work.OneTimeWorkRequestBuilder19+import androidx.work.PeriodicWorkRequestBuilder20+import androidx.work.WorkManager21+import androidx.work.WorkerParameters22+import com.peachpasswords.MainActivity23+import com.peachpasswords.R24+import dagger.assisted.Assisted25+import dagger.assisted.AssistedInject26+import java.util.concurrent.TimeUnit27+28+/** Launch extra set by the update notification so the app opens on the update flow. */29+const val EXTRA_OPEN_UPDATE = "peach_open_update"30+31+/**32+ * The quiet half of the update flow: ask Kepr what the latest release is and, if33+ * it is newer than this build, say so once in a low-importance notification.34+ *35+ * Kepr has no push mechanism, so this poll is the only way the app learns about a36+ * release. It never downloads anything — tapping through is what starts that.37+ */38+@HiltWorker39+class UpdateCheckWorker @AssistedInject constructor(40+    @Assisted appContext: Context,41+    @Assisted params: WorkerParameters,42+    private val updates: UpdateRepository,43+) : CoroutineWorker(appContext, params) {44+45+    override suspend fun doWork(): Result {46+        val manifest = try {47+            updates.check()48+        } catch (e: Exception) {49+            // Offline, DNS failure, a repo not registered yet — all transient from50+            // here. Never surface a failed background check to the user.51+            return Result.retry()52+        } ?: return Result.success()53+54+        if (updates.shouldNotifyFor(manifest.version) &&55+            postUpdateNotification(applicationContext, manifest.version)56+        ) {57+            updates.markNotified(manifest.version)58+        }59+        return Result.success()60+    }61+62+    companion object {63+        private const val PERIODIC_WORK = "peach_update_check"64+        private const val ONE_SHOT_WORK = "peach_update_check_now"65+66+        private val NETWORK_ONLY = Constraints.Builder()67+            .setRequiredNetworkType(NetworkType.CONNECTED)68+            .build()69+70+        /** Once a day is plenty for a manually-installed beta; KEEP so reinstalling the schedule doesn't reset it. */71+        fun schedulePeriodic(context: Context) {72+            WorkManager.getInstance(context).enqueueUniquePeriodicWork(73+                PERIODIC_WORK,74+                ExistingPeriodicWorkPolicy.KEEP,75+                PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)76+                    .setConstraints(NETWORK_ONLY)77+                    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES)78+                    .build(),79+            )80+        }81+82+        /** The on-app-open check. REPLACE so an explicit "check now" isn't swallowed by a queued one. */83+        fun checkNow(context: Context) {84+            WorkManager.getInstance(context).enqueueUniqueWork(85+                ONE_SHOT_WORK,86+                ExistingWorkPolicy.REPLACE,87+                OneTimeWorkRequestBuilder<UpdateCheckWorker>()88+                    .setConstraints(NETWORK_ONLY)89+                    .build(),90+            )91+        }92+    }93+}94+95+private const val CHANNEL_ID = "peach_updates"96+private const val NOTIFICATION_ID = 430197+98+/**99+ * Post the "an update exists" notification. Returns false when the user has not100+ * granted POST_NOTIFICATIONS (API 33+) or has muted the channel — the check still101+ * counts as done, but it must not be recorded as notified, or the grant arriving102+ * later would never produce a notification for this version.103+ */104+fun postUpdateNotification(context: Context, version: String): Boolean {105+    val manager = NotificationManagerCompat.from(context)106+    if (!manager.areNotificationsEnabled()) return false107+108+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {109+        val system = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager110+        system.createNotificationChannel(111+            NotificationChannel(CHANNEL_ID, "Peach updates", NotificationManager.IMPORTANCE_LOW).apply {112+                description = "Tells you when a newer version of Peach is available."113+                setShowBadge(false)114+            },115+        )116+    }117+118+    val open = PendingIntent.getActivity(119+        context,120+        0,121+        Intent(context, MainActivity::class.java)122+            .putExtra(EXTRA_OPEN_UPDATE, true)123+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),124+        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,125+    )126+127+    val notification = NotificationCompat.Builder(context, CHANNEL_ID)128+        .setContentTitle("Peach update available — v$version")129+        .setContentText("Tap to see what changed and install it.")130+        .setSmallIcon(R.drawable.ic_launcher_monochrome)131+        .setPriority(NotificationCompat.PRIORITY_LOW)132+        .setAutoCancel(true)133+        .setContentIntent(open)134+        .build()135+136+    return try {137+        manager.notify(NOTIFICATION_ID, notification)138+        true139+    } catch (e: SecurityException) {140+        // areNotificationsEnabled() and the actual permission can disagree on some141+        // OEM builds; a missing notification must never take the app down.142+        false143+    }144+}

view file

app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+177 −0
@@ -0,0 +1,177 @@1+package com.peachpasswords.updates2+3+import org.bouncycastle.crypto.digests.Blake3Digest4+import org.json.JSONObject5+import java.io.File6+import java.io.InputStream7+import java.security.MessageDigest8+9+/**10+ * Kepr's generic release manifest, as returned by11+ * `GET https://kepr.uk/api/<repo>/releases/latest`, flattened to the single12+ * platform this app can install.13+ *14+ * ```json15+ * { "version": "2.3.68",16+ *   "notes": "...",17+ *   "platforms": { "arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } }18+ * ```19+ */20+data class ReleaseManifest(21+    val version: String,22+    val url: String,23+    val checksum: String,24+    /** Absent — not empty, not "null" — when the release carries no notes. */25+    val notes: String?,26+) {27+    fun toJson(): String = JSONObject().apply {28+        put("version", version)29+        put("url", url)30+        put("checksum", checksum)31+        if (notes != null) put("notes", notes)32+    }.toString()33+34+    companion object {35+        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {36+            val o = JSONObject(json)37+            ReleaseManifest(38+                version = o.getString("version"),39+                url = o.getString("url"),40+                checksum = o.getString("checksum"),41+                notes = o.optTextOrNull("notes"),42+            )43+        }.getOrNull()44+    }45+}46+47+class ManifestFormatException(message: String) : Exception(message)48+49+/**50+ * ASSUMPTION — unverifiable from here. Kepr's generic release endpoint normalizes51+ * `<repo>-<platform>` against a bare `<platform>` artifact name, so for the52+ * `peach-android` repo the bare key is what the manifest should carry. Every53+ * upload must use this exact platform name; if the publishing side settles on54+ * something else (`arm64-v8a`, `android-arm64`, `universal`), change this one55+ * constant.56+ */57+const val ANDROID_PLATFORM_KEY = "arm64"58+59+/**60+ * Parse a Kepr release manifest.61+ *62+ * Two contract wrinkles are handled deliberately:63+ * - The notes key is `notes` canonically, but the published Guidelines still show64+ *   the retired `release_notes`. Both are accepted, `notes` winning.65+ * - `optString` on a JSON null yields the literal string "null" in org.json, which66+ *   would render as changelog text. [optTextOrNull] rejects that class of value67+ *   outright rather than trusting the caller to notice.68+ */69+fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {70+    val root = runCatching { JSONObject(body) }71+        .getOrElse { throw ManifestFormatException("manifest is not JSON") }72+73+    val version = root.optTextOrNull("version")74+        ?: throw ManifestFormatException("manifest has no version")75+76+    val platforms = root.optJSONObject("platforms")77+        ?: throw ManifestFormatException("manifest has no platforms")78+79+    // Fall back to the sole platform when the key doesn't match: a single-artifact80+    // Android release is unambiguous, and a naming mismatch shouldn't strand every81+    // installed build with "no download available".82+    val platform = platforms.optJSONObject(platformKey)83+        ?: platforms.keys().asSequence().toList().singleOrNull()?.let { platforms.optJSONObject(it) }84+        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")85+86+    val url = platform.optTextOrNull("url")87+        ?: throw ManifestFormatException("artifact has no url")88+    val checksum = platform.optTextOrNull("checksum")89+        ?: throw ManifestFormatException("artifact has no checksum")90+91+    return ReleaseManifest(92+        version = version,93+        url = url,94+        checksum = checksum,95+        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),96+    )97+}98+99+/** Non-blank string value, or null — never JSON null surfaced as the text "null". */100+internal fun JSONObject.optTextOrNull(key: String): String? {101+    if (!has(key) || isNull(key)) return null102+    return optString(key).trim().takeIf { it.isNotEmpty() }103+}104+105+/* ─────────────────────────────  version compare  ───────────────────────────── */106+107+/**108+ * Strip everything that isn't the numeric release, so a debug build109+ * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was110+ * built from rather than as an unrelated string.111+ */112+fun normalizeVersion(version: String): String =113+    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')114+115+/** Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. */116+fun compareVersions(a: String, b: String): Int {117+    val left = normalizeVersion(a).split('.')118+    val right = normalizeVersion(b).split('.')119+    for (i in 0 until maxOf(left.size, right.size)) {120+        val x = left.getOrNull(i)?.toIntOrNull() ?: 0121+        val y = right.getOrNull(i)?.toIntOrNull() ?: 0122+        if (x != y) return x.compareTo(y)123+    }124+    return 0125+}126+127+fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0128+129+/* ──────────────────────────────────  checksum  ─────────────────────────────── */130+131+data class ExpectedChecksum(val algorithm: String, val hex: String)132+133+class ChecksumMismatchException(message: String) : Exception(message)134+135+private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")136+137+/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */138+fun parseChecksum(raw: String): ExpectedChecksum? {139+    val separator = raw.indexOf(':')140+    if (separator <= 0) return null141+    val algorithm = raw.substring(0, separator).trim().lowercase()142+    val hex = raw.substring(separator + 1).trim().lowercase()143+    if (algorithm !in SUPPORTED_CHECKSUMS) return null144+    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null145+    return ExpectedChecksum(algorithm, hex)146+}147+148+fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {149+    "blake3" -> {150+        val digest = Blake3Digest(32)151+        val buffer = ByteArray(64 * 1024)152+        while (true) {153+            val read = input.read(buffer)154+            if (read <= 0) break155+            digest.update(buffer, 0, read)156+        }157+        val out = ByteArray(digest.digestSize)158+        digest.doFinal(out, 0)159+        out.toHex()160+    }161+    "sha256" -> {162+        val digest = MessageDigest.getInstance("SHA-256")163+        val buffer = ByteArray(64 * 1024)164+        while (true) {165+            val read = input.read(buffer)166+            if (read <= 0) break167+            digest.update(buffer, 0, read)168+        }169+        digest.digest().toHex()170+    }171+    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")172+}173+174+fun hashFile(file: File, algorithm: String): String =175+    file.inputStream().use { hashStream(it, algorithm) }176+177+private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }

view file

app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+256 −0
@@ -0,0 +1,256 @@1+package com.peachpasswords.updates2+3+import android.content.Context4+import android.content.Intent5+import android.net.Uri6+import android.provider.Settings7+import androidx.core.content.FileProvider8+import com.peachpasswords.BuildConfig9+import dagger.hilt.android.qualifiers.ApplicationContext10+import kotlinx.coroutines.Dispatchers11+import kotlinx.coroutines.flow.MutableStateFlow12+import kotlinx.coroutines.flow.StateFlow13+import kotlinx.coroutines.flow.asStateFlow14+import kotlinx.coroutines.withContext15+import okhttp3.OkHttpClient16+import okhttp3.Request17+import java.io.File18+import java.io.IOException19+import java.util.concurrent.TimeUnit20+import javax.inject.Inject21+import javax.inject.Singleton22+23+/**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+@Singleton33+class UpdateRepository @Inject constructor(34+    @ApplicationContext private val context: Context,35+) {36+37+    private val client: OkHttpClient by lazy {38+        OkHttpClient.Builder()39+            .callTimeout(5, TimeUnit.MINUTES)40+            .build()41+    }42+43+    private val prefs by lazy { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) }44+45+    /** The running build's release version, with the debug `-debug` suffix stripped. */46+    val currentVersion: String = normalizeVersion(BuildConfig.VERSION_NAME)47+48+    private val _available = MutableStateFlow(cachedAvailable())49+    /** The newest release, only when it is actually newer than [currentVersion]. */50+    val available: StateFlow<ReleaseManifest?> = _available.asStateFlow()51+52+    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()55+56+    private val _openRequested = MutableStateFlow(false)57+    /** Set when the app was launched by tapping the update notification. */58+    val openRequested: StateFlow<Boolean> = _openRequested.asStateFlow()59+60+    init {61+        refreshWhatsNew()62+    }63+64+    fun requestOpen() { _openRequested.value = true }65+66+    fun consumeOpenRequest() { _openRequested.value = false }67+68+    /* ────────────────────────────────  check  ──────────────────────────────── */69+70+    /**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)79+80+        _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()84+85+        // 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+    }102+103+    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+    }113+114+    /* ──────────────────────────  download + verify  ────────────────────────── */115+116+    /**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}'")127+128+        val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }129+        val target = File(dir, "peach-${manifest.version}.apk")130+131+        // 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()139+140+        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+        }162+163+        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+    }173+174+    fun discardDownloads() {175+        File(context.cacheDir, CACHE_DIR).listFiles()?.forEach { it.delete() }176+    }177+178+    /* ────────────────────────────────  install  ────────────────────────────── */179+180+    /** Android's per-app "install unknown apps" grant. Without it the installer just bounces. */181+    fun canInstallPackages(): Boolean = context.packageManager.canRequestPackageInstalls()182+183+    fun unknownSourcesSettingsIntent(): Intent =184+        Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))185+            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)186+187+    /**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+    }197+198+    /* ──────────────────────────────  what's new  ───────────────────────────── */199+200+    /**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+    }219+220+    fun markChangelogSeen() {221+        prefs.edit().putString(KEY_LAST_SEEN_VERSION, currentVersion).apply()222+        _whatsNew.value = null223+    }224+225+    /* ─────────────────────────────  notify-once  ───────────────────────────── */226+227+    /** 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) != version230+231+    fun markNotified(version: String) {232+        prefs.edit().putString(KEY_NOTIFIED_VERSION, version).apply()233+    }234+235+    private fun cachedAvailable(): ReleaseManifest? =236+        prefs.getString(KEY_LATEST_MANIFEST, null)237+            ?.let { ReleaseManifest.fromCachedJson(it) }238+            ?.takeIf { isNewerVersion(it.version, currentVersion) }239+240+    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"244+245+        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"248+249+        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+}

view file

app/src/main/res/xml/file_paths.xml+4 −0
@@ -3,4 +3,8 @@33     <cache-path44         name="codex"55         path="codex/" />6+    <!-- Downloaded, checksum-verified release APKs handed to the package installer. -->7+    <cache-path8+        name="updates"9+        path="updates/" />610 </paths>

view file

app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+111 −0
@@ -0,0 +1,111 @@1+package com.peachpasswords.updates2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Assert.assertTrue6+import org.junit.Assert.assertFalse7+import org.junit.Test8+9+class UpdateManifestTest {10+11+    private fun manifest(notesField: String) = """12+        {13+          "version": "2.3.68",14+          $notesField15+          "platforms": {16+            "$ANDROID_PLATFORM_KEY": {17+              "url": "https://kepr.uk/api/peach-android/releases/2.3.68/$ANDROID_PLATFORM_KEY",18+              "checksum": "blake3:${"a".repeat(64)}"19+            }20+          }21+        }22+    """.trimIndent()23+24+    @Test25+    fun `canonical notes key wins`() {26+        val parsed = parseReleaseManifest(manifest(""""notes": "fixed the thing", "release_notes": "old","""))27+        assertEquals("fixed the thing", parsed.notes)28+        assertEquals("2.3.68", parsed.version)29+    }30+31+    @Test32+    fun `legacy release_notes key still accepted`() {33+        assertEquals("legacy", parseReleaseManifest(manifest(""""release_notes": "legacy",""")).notes)34+    }35+36+    @Test37+    fun `omitted notes are absent, not the string null`() {38+        assertNull(parseReleaseManifest(manifest("")).notes)39+    }40+41+    @Test42+    fun `json null notes never render as the text null`() {43+        assertNull(parseReleaseManifest(manifest(""""notes": null,""")).notes)44+        assertNull(parseReleaseManifest(manifest(""""notes": "   ",""")).notes)45+    }46+47+    @Test48+    fun `missing platform falls back to the sole artifact`() {49+        val body = """50+            {"version":"2.3.68","platforms":{"android-arm64":51+              {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}52+        """.trimIndent()53+        assertEquals("https://x/y", parseReleaseManifest(body).url)54+    }55+56+    @Test(expected = ManifestFormatException::class)57+    fun `no platforms is a hard failure`() {58+        parseReleaseManifest("""{"version":"2.3.68"}""")59+    }60+61+    @Test62+    fun `version comparison is numeric, not lexical`() {63+        assertTrue(isNewerVersion("2.3.100", "2.3.67"))64+        assertTrue(isNewerVersion("2.4.0", "2.3.99"))65+        assertTrue(isNewerVersion("10.0.0", "9.9.9"))66+        assertFalse(isNewerVersion("2.3.67", "2.3.67"))67+        assertFalse(isNewerVersion("2.3.9", "2.3.10"))68+    }69+70+    @Test71+    fun `debug suffix compares as the release it was built from`() {72+        assertEquals("2.3.67", normalizeVersion("2.3.67-debug"))73+        assertEquals("2.3.67", normalizeVersion("v2.3.67"))74+        assertFalse(isNewerVersion("2.3.67", "2.3.67-debug"))75+        assertTrue(isNewerVersion("2.3.68", "2.3.67-debug"))76+    }77+78+    @Test79+    fun `only well-formed blake3 and sha256 checksums are accepted`() {80+        assertEquals(81+            ExpectedChecksum("blake3", "a".repeat(64)),82+            parseChecksum("blake3:${"A".repeat(64)}"),83+        )84+        assertEquals("sha256", parseChecksum("sha256:${"0".repeat(64)}")?.algorithm)85+        assertNull(parseChecksum("md5:${"a".repeat(64)}"))86+        assertNull(parseChecksum("blake3:tooshort"))87+        assertNull(parseChecksum("blake3:${"z".repeat(64)}"))88+        assertNull(parseChecksum("nocolon"))89+    }90+91+    /** BLAKE3 known-answer vectors — the checksum gate is only worth anything if this is right. */92+    @Test93+    fun `blake3 matches the reference vectors`() {94+        assertEquals(95+            "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",96+            hashStream(ByteArray(0).inputStream(), "blake3"),97+        )98+        assertEquals(99+            "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85",100+            hashStream("abc".toByteArray().inputStream(), "blake3"),101+        )102+    }103+104+    @Test105+    fun `sha256 matches the reference vector`() {106+        assertEquals(107+            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",108+            hashStream("abc".toByteArray().inputStream(), "sha256"),109+        )110+    }111+}

view file