f4352f0fe746
feat: sync-retained background mode — vault stays warm for sync while UI-gated (v2.9.22)
- time
- 2026-08-01 20:45 UTC · 2 months ago
- machine
- koh2
- parent
- 98b525b7049e
↓ peach-android-f4352f0f.tar.gzatlas at this save
files changed9 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 −0app/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"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"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 }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 }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+}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 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 }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