f4352f0fe746

feat: sync-retained background mode — vault stays warm for sync while UI-gated (v2.9.22)

- PeachApplication.onStop: UI gate instead of full lock when background sync enabled - VaultRepository: +uiGateLocked StateFlow, lockUiGate(), clearUiGate() - LockViewModel: biometric fast path (non-crypto confirm) when vault already unlocked - PeachAppNavHost: observes uiGateLocked, shows lock screen on resume - BootReceiver: restarts sync service on BOOT_COMPLETED - Auto-lock timer keeps running as safety net; full lock supersedes gate

time
2026-08-01 20:45 UTC · 2 months ago
machine
koh2
parent
98b525b7049e
9 files VERSION+1 −1app/build.gradle+1 −1app/src/main/AndroidManifest.xml+8 −0app/src/main/java/com/peachpasswords/MainActivity.kt+1 −1app/src/main/java/com/peachpasswords/PeachApplication.kt+13 −2app/src/main/java/com/peachpasswords/sync/BootReceiver.kt+20 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+3 −1app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+18 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+27 −0
VERSION+1 −1
@@ -1 +1 @@1-2.9.211+2.9.22

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24016435+        versionCode 2401653636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/AndroidManifest.xml+8 −0
@@ -110,6 +110,14 @@110110             android:name=".sync.SyncNotificationDismissedReceiver"111111             android:exported="false" />112112 113+        <receiver114+            android:name=".sync.BootReceiver"115+            android:exported="false">116+            <intent-filter>117+                <action android:name="android.intent.action.BOOT_COMPLETED" />118+            </intent-filter>119+        </receiver>120+113121         <service114122             android:name=".autofill.PeachAutofillService"115123             android:permission="android.permission.BIND_AUTOFILL_SERVICE"

view file

app/src/main/java/com/peachpasswords/MainActivity.kt+1 −1
@@ -116,7 +116,7 @@116116                 reduceMotion = settings.reduceMotion,117117             ) {118118                 WideScreenScale {119-                    PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked)119+                    PeachAppNavHost(startDestination = start, isUnlocked = vault.isUnlocked, uiGateLocked = vault.uiGateLocked)120120                 }121121             }122122         }

view file

app/src/main/java/com/peachpasswords/PeachApplication.kt+13 −2
@@ -6,6 +6,7 @@66 import androidx.lifecycle.LifecycleOwner77 import androidx.lifecycle.ProcessLifecycleOwner88 import androidx.work.Configuration9+import com.peachpasswords.sync.BackgroundSyncPreference910 import com.peachpasswords.sync.ForegroundSyncCoordinator1011 import com.peachpasswords.updates.UpdateCheckWorker1112 import com.peachpasswords.vault.VaultLockTimer@@ -18,6 +19,7 @@1819     @Inject lateinit var vault: VaultRepository1920     @Inject lateinit var lockTimer: VaultLockTimer2021     @Inject lateinit var foregroundSync: ForegroundSyncCoordinator22+    @Inject lateinit var backgroundSync: BackgroundSyncPreference2123     @Inject lateinit var workerFactory: HiltWorkerFactory2224 2325     override val workManagerConfiguration: Configuration@@ -46,8 +48,17 @@4648                 // import flow, not a real backgrounding, so it's exempted via4749                 // VaultRepository.expectTransientBackground().4850                 if (vault.consumeTransientBackgroundSuppression()) return49-                vault.lock()50-                lockTimer.cancel()51+                if (backgroundSync.enabled.value && vault.isUnlocked.value) {52+                    // Sync-retained mode: the vault stays unlocked in memory so53+                    // PeachSyncService can complete sessions while backgrounded.54+                    // The UI gate shows the lock screen on resume. The auto-lock55+                    // timer keeps running as a safety net — if it fires, it does56+                    // a full lock and sync stops.57+                    vault.lockUiGate()58+                } else {59+                    vault.lock()60+                    lockTimer.cancel()61+                }5162             }5263         })5364     }

view file

app/src/main/java/com/peachpasswords/sync/BootReceiver.kt+20 −0
@@ -0,0 +1,20 @@1+package com.peachpasswords.sync2+3+import android.content.BroadcastReceiver4+import android.content.Context5+import android.content.Intent6+7+/**8+ * Restarts the sync service after a reboot. The service's own locked-handling9+ * ([PeachSyncService.startReconnectListener] early-returns on a null master10+ * key) is the gate — it comes up properly once the user unlocks for the first11+ * time post-boot. Without this, a rebooted phone is unreachable until the app12+ * is manually opened.13+ */14+class BootReceiver : BroadcastReceiver() {15+    override fun onReceive(context: Context, intent: Intent?) {16+        if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return17+        if (!BackgroundSyncPreference.isEnabled(context)) return18+        PeachSyncService.start(context)19+    }20+}

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+3 −1
@@ -58,6 +58,7 @@5858     navController: NavHostController = rememberNavController(),5959     startDestination: PeachNav = PeachNav.Lock,6060     isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,61+    uiGateLocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,6162     autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,6263 ) {6364     val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }@@ -67,7 +68,8 @@6768     // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no6869     // post-unlock screen lingers behind it. Guarded so it never fires on the lock6970     // or onboarding screens (where the vault is legitimately locked).70-    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value71+    val uiGated = uiGateLocked?.collectAsStateWithLifecycle()?.value ?: false72+    val unlocked = if (isUnlocked == null) true else (isUnlocked.collectAsStateWithLifecycle().value && !uiGated)7173     val currentBackStackEntry by navController.currentBackStackEntryAsState()7274     val currentRoute = currentBackStackEntry?.destination?.route7375 

view file

app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+18 −1
@@ -194,6 +194,7 @@194194                             when (val result = vault.commitSlotAUnlock(slotAUnlock)) {195195                                 is UnlockResult.Success -> {196196                                     lockTimer.start()197+                                    vault.clearUiGate()197198                                     foregroundSync.triggerForegroundSync()198199                                     _state.update { it.copy(loading = false, unlocked = true) }199200                                 }@@ -237,7 +238,7 @@237238     private suspend fun handleDuress(duressUnlock: VaultRepository.DuressUnlock) {238239         when (duress.action()) {239240             DuressAction.DECOY -> when (vault.commitDuressUnlock(duressUnlock)) {240-                is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(loading = false, unlocked = true) } }241+                is UnlockResult.Success -> { lockTimer.start(); vault.clearUiGate(); _state.update { it.copy(loading = false, unlocked = true) } }241242                 else -> _state.update {242243                     it.copy(loading = false, error = "incorrect password", shakeNonce = it.shakeNonce + 1)243244                 }@@ -285,6 +286,21 @@285286      */286287     fun biometricUnlock(activity: FragmentActivity) {287288         if (_state.value.loading) return289+        // Sync-retained mode: vault is warm, just confirm identity.290+        if (vault.isUnlocked.value) {291+            Biometrics.authenticate(292+                activity = activity,293+                title = "Unlock Peach",294+                subtitle = "Confirm your identity to access your vault",295+                onSuccess = {296+                    vault.clearUiGate()297+                    lockTimer.poke()298+                    foregroundSync.triggerForegroundSync()299+                    _state.update { it.copy(unlocked = true) }300+                },301+            )302+            return303+        }288304         val cipher = BiometricKeyStore.decryptCipher(appContext) ?: return289305         Biometrics.authenticateCrypto(290306             activity = activity,@@ -308,6 +324,7 @@308324                     when (r) {309325                         is UnlockResult.Success -> {310326                             lockTimer.start()327+                            vault.clearUiGate()311328                             foregroundSync.triggerForegroundSync()312329                             _state.update { it.copy(unlocked = true) }313330                         }

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+27 −0
@@ -189,6 +189,16 @@189189     private val _isUnlocked = MutableStateFlow(false)190190     val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191191 192+193+    /**194+     * UI-only gate for sync-retained background mode. The vault stays unlocked195+     * in memory (sync can proceed) but the UI shows the lock screen until the196+     * user authenticates on resume. Cleared by a full [lock] or an explicit197+     * [clearUiGate] from the lock screen.198+     */199+    private val _uiGateLocked = MutableStateFlow(false)200+    val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()201+192202     private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())193203     /** Active (non-deleted) entryList. */194204     val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()@@ -1180,9 +1190,26 @@11801190         try {11811191             sessionEpoch = nextSessionEpoch(sessionEpoch)11821192             clearSessionStateLocked()1193+            _uiGateLocked.value = false11831194         } finally {11841195             stateLock.unlock()11851196         }1197+    }1198+1199+    /**1200+     * Enter sync-retained background mode: the UI shows the lock screen but the1201+     * vault stays fully unlocked in memory so [PeachSyncService] can complete1202+     * sync sessions. Called from [PeachApplication.onStop] when background sync1203+     * is enabled. A subsequent full [lock] (auto-lock timer, manual lock)1204+     * supersedes this and zeroes everything.1205+     */1206+    fun lockUiGate() {1207+        if (_isUnlocked.value) _uiGateLocked.value = true1208+    }1209+1210+    /** Dismiss the UI gate after the user authenticates on resume. */1211+    fun clearUiGate() {1212+        _uiGateLocked.value = false11861213     }11871214 11881215     @Volatile private var suppressNextBackgroundLockUntil: Long = 0L

view file