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
↓ peach-android-df43a3a2.tar.gzatlas at this save
files changed14 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 −0app/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 }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"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 theapp/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.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 = {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 }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 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+}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+}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+}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) }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+}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>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+}