4b98699ba304
ask paired users about persistent background sync, honor manual-only opt-out in settings, and default existing installs on (v2.5.13)
- time
- 2026-07-27 02:34 UTC · 2 months ago
- machine
- koh2
- agent
- MiniMax-M3
- session
- onboard background sync
- parent
- 4ebae050a135
↓ peach-android-4b98699b.tar.gzatlas at this save
files changed14 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/BackgroundSyncPreference.kt+34 −0app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+2 −1app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+0 −3app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+12 −1app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+7 −4app/src/main/java/com/peachpasswords/ui/pairing/BackgroundSyncDialog.kt+52 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+15 −7app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+7 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+28 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+13 −0app/src/test/java/com/peachpasswords/sync/BackgroundSyncPreferenceTest.kt+19 −0app/src/test/java/com/peachpasswords/ui/pairing/BackgroundSyncChoiceTest.kt+25 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24011220- versionName "2.5.12"19+ versionCode 24011320+ versionName "2.5.13"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/BackgroundSyncPreference.kt+34 −0
@@ -0,0 +1,34 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import dagger.hilt.android.qualifiers.ApplicationContext5+import javax.inject.Inject6+import javax.inject.Singleton7+import kotlinx.coroutines.flow.MutableStateFlow8+import kotlinx.coroutines.flow.StateFlow9+import kotlinx.coroutines.flow.asStateFlow10+11+@Singleton12+class BackgroundSyncPreference @Inject constructor(13+ @ApplicationContext context: Context,14+) {15+ private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)16+ private val _enabled = MutableStateFlow(readEnabled(prefs.contains(KEY_ENABLED)) {17+ prefs.getBoolean(KEY_ENABLED, true)18+ })19+ val enabled: StateFlow<Boolean> = _enabled.asStateFlow()20+21+ fun setEnabled(enabled: Boolean) {22+ prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()23+ _enabled.value = enabled24+ }25+26+ private companion object {27+ const val PREFS_FILE = "peach_background_sync"28+ const val KEY_ENABLED = "enabled"29+ }30+}31+32+// Existing paired installs default on so upgrading fixes the unreachable-phone state.33+internal fun readEnabled(hasStoredChoice: Boolean, storedValue: () -> Boolean): Boolean =34+ if (hasStoredChoice) storedValue() else trueapp/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+2 −1
@@ -16,6 +16,7 @@1616 class ForegroundSyncCoordinator @Inject constructor(1717 @ApplicationContext private val context: Context,1818 private val vault: VaultRepository,19+ private val backgroundSync: BackgroundSyncPreference,1920 private val deviceStore: PairedDeviceStore,2021 private val pendingVouchedStore: PendingVouchedDeviceStore,2122 private val syncAlertStore: SyncAlertStore,@@ -34,7 +35,7 @@3435 * See docs/sync-lan-retirement.md.3536 */3637 fun triggerForegroundSync() {37- if (!vault.isUnlocked.value) return38+ if (!vault.isUnlocked.value || !backgroundSync.enabled.value) return3839 if (foregroundSyncPeers(deviceStore.getAll()).isEmpty()) return3940 PeachSyncService.start(context)4041 }app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+0 −3
@@ -6,7 +6,6 @@66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope88 import com.peachpasswords.sync.ForegroundSyncCoordinator9-import com.peachpasswords.sync.PeachSyncService109 import com.peachpasswords.ui.PeachNav1110 import com.peachpasswords.ui.vault.entryTapDestination1211 import com.peachpasswords.vault.CreateVaultResult@@ -121,7 +120,6 @@121120 slotA != null -> {122121 vault.commitSlotAUnlock(slotA)123122 lockTimer.start()124- PeachSyncService.start(appContext)125123 foregroundSync.triggerForegroundSync()126124 _state.update { it.copy(loading = false, unlocked = true) }127125 }@@ -202,7 +200,6 @@202200 when (r) {203201 is UnlockResult.Success -> {204202 lockTimer.start()205- PeachSyncService.start(appContext)206203 foregroundSync.triggerForegroundSync()207204 _state.update { it.copy(unlocked = true) }208205 }app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+12 −1
@@ -47,6 +47,7 @@4747 var confirmation by remember { mutableStateOf("") }4848 var reveal by remember { mutableStateOf(false) }4949 var invitation by remember { mutableStateOf("") }50+ var showBackgroundSyncPrompt by remember { mutableStateOf(false) }5051 val score = scorePassword(password)5152 val passwordReady =5253 password.length >= 12 && password == confirmation && score >= 2@@ -281,7 +282,9 @@281282 },282283 )283284 Spacer(Modifier.height(PeachSpacing.lg))284- PillButton("Open vault", LocalPeachAccent.current, filled = true, onClick = onComplete)285+ PillButton("Open vault", LocalPeachAccent.current, filled = true) {286+ showBackgroundSyncPrompt = true287+ }285288 }286289 287290 is AdoptVaultUiState.Failed -> {@@ -300,6 +303,14 @@300303 Spacer(Modifier.height(PeachSpacing.md))301304 PillButton("Back", peachColors.T2, filled = false, onClick = onBack)302305 }306+ }307+ }308+309+ if (showBackgroundSyncPrompt) {310+ BackgroundSyncDialog { enabled ->311+ showBackgroundSyncPrompt = false312+ vm.setBackgroundSync(enabled)313+ onComplete()303314 }304315 }305316 }app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+7 −4
@@ -5,6 +5,7 @@55 import androidx.lifecycle.viewModelScope66 import com.peachpasswords.license.LicenseRepository77 import com.peachpasswords.sync.AdoptionReceiverDeps8+import com.peachpasswords.sync.BackgroundSyncPreference89 import com.peachpasswords.sync.DeviceType910 import com.peachpasswords.sync.LocalDeviceIdentity1011 import com.peachpasswords.sync.PairedDevice@@ -60,6 +61,7 @@6061 private val vault: VaultRepository,6162 private val devices: PairedDeviceStore,6263 private val relaySettings: RelaySettingsStore,64+ private val backgroundSync: BackgroundSyncPreference,6365 private val license: LicenseRepository,6466 @ApplicationContext private val appContext: Context,6567 ) : ViewModel() {@@ -219,10 +221,6 @@219221 220222 override suspend fun upsertPairedDevice(device: PairedDevice) {221223 devices.upsert(device)222- // Start listening only after the source identity and relay-bound223- // pair state are durable. This lets the browser source perform224- // its first sync immediately after adoption.225- PeachSyncService.start(appContext)226224 }227225 228226 override suspend fun applyLicense(licenseKey: String) {@@ -235,6 +233,11 @@235233 protocolError = error236234 }237235 }236+237+ fun setBackgroundSync(enabled: Boolean) {238+ backgroundSync.setEnabled(enabled)239+ if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)240+ }238241 239242 private fun clearPassword() {240243 password = nullapp/src/main/java/com/peachpasswords/ui/pairing/BackgroundSyncDialog.kt+52 −0
@@ -0,0 +1,52 @@1+package com.peachpasswords.ui.pairing2+3+import androidx.activity.compose.BackHandler4+import androidx.compose.material3.AlertDialog5+import androidx.compose.material3.Text6+import androidx.compose.material3.TextButton7+import androidx.compose.runtime.Composable8+import androidx.compose.ui.text.font.FontWeight9+import com.peachpasswords.ui.theme.LocalPeachAccent10+import com.peachpasswords.ui.theme.PeachType11+12+enum class BackgroundSyncAction { ALLOW, MANUAL_ONLY, DISMISS }13+14+internal fun shouldPromptForBackgroundSync(pairingSucceeded: Boolean): Boolean = pairingSucceeded15+16+internal fun backgroundSyncChoice(action: BackgroundSyncAction): Boolean =17+ action != BackgroundSyncAction.MANUAL_ONLY18+19+@Composable20+fun BackgroundSyncDialog(21+ onChoice: (Boolean) -> Unit,22+) {23+ // Dismissal takes the recommended default so pairing cannot fall back into the broken unreachable state.24+ BackHandler { onChoice(backgroundSyncChoice(BackgroundSyncAction.DISMISS)) }25+ AlertDialog(26+ onDismissRequest = { onChoice(backgroundSyncChoice(BackgroundSyncAction.DISMISS)) },27+ title = {28+ Text("Sync in the background?", fontFamily = PeachType.Display)29+ },30+ text = {31+ Text(32+ "Allow Peach to keep your devices in step in the background, even when the app isn't open. " +33+ "This keeps a small notification visible while sync is active and uses a tiny bit of battery.",34+ fontFamily = PeachType.Body,35+ )36+ },37+ confirmButton = {38+ TextButton(onClick = { onChoice(backgroundSyncChoice(BackgroundSyncAction.ALLOW)) }) {39+ Text(40+ "Allow background sync",41+ color = LocalPeachAccent.current,42+ fontWeight = FontWeight.Bold,43+ )44+ }45+ },46+ dismissButton = {47+ TextButton(onClick = { onChoice(backgroundSyncChoice(BackgroundSyncAction.MANUAL_ONLY)) }) {48+ Text("Sync manually only")49+ }50+ },51+ )52+}app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+15 −7
@@ -19,7 +19,9 @@1919 import androidx.compose.material3.Text2020 import androidx.compose.runtime.Composable2121 import androidx.compose.runtime.LaunchedEffect22+import androidx.compose.runtime.mutableStateOf2223 import androidx.compose.runtime.remember24+import androidx.compose.runtime.setValue2325 import androidx.compose.runtime.getValue2426 import androidx.compose.ui.platform.LocalContext2527 import androidx.compose.ui.Alignment@@ -58,16 +60,22 @@5860 val state by vm.state.collectAsStateWithLifecycle()5961 val context = LocalContext.current6062 63+ var showBackgroundSyncPrompt by remember { mutableStateOf(false) }64+6165 LaunchedEffect(Unit) { vm.start() }6266 63- // Asked after a successful pairing, not before: at that point the user has64- // just chosen to rely on this phone staying reachable, so the system's65- // "run in the background?" dialog has obvious context. Asking on arrival66- // would be a scary prompt for a feature they hadn't committed to yet.6767 LaunchedEffect(state) {68- if (state is CodePairUiState.Paired) {69- vm.batteryExemptionIntent()?.let { intent ->70- runCatching { context.startActivity(intent) }68+ if (shouldPromptForBackgroundSync(state is CodePairUiState.Paired)) showBackgroundSyncPrompt = true69+ }70+71+ if (showBackgroundSyncPrompt) {72+ BackgroundSyncDialog { enabled ->73+ showBackgroundSyncPrompt = false74+ vm.setBackgroundSync(enabled)75+ if (enabled) {76+ vm.batteryExemptionIntent()?.let { intent ->77+ runCatching { context.startActivity(intent) }78+ }7179 }7280 }7381 }app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+7 −0
@@ -5,6 +5,7 @@55 import com.peachpasswords.BuildConfig66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope8+import com.peachpasswords.sync.BackgroundSyncPreference89 import com.peachpasswords.sync.BatteryExemption910 import com.peachpasswords.sync.PairingSession1011 import com.peachpasswords.sync.PeachSyncService@@ -54,6 +55,7 @@5455 private val vault: VaultRepository,5556 private val relayHost: RelayAdoptionHost,5657 private val relaySettings: RelaySettingsStore,58+ private val backgroundSync: BackgroundSyncPreference,5759 private val deviceStore: PairedDeviceStore,5860 private val license: LicenseRepository,5961 @ApplicationContext private val appContext: Context,@@ -170,6 +172,11 @@170172 deviceStore.upsert(device)171173 }172174 }175+176+ fun setBackgroundSync(enabled: Boolean) {177+ backgroundSync.setEnabled(enabled)178+ if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)179+ }173180 174181 fun confirmSas(matches: Boolean) {175182 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+28 −0
@@ -61,6 +61,7 @@6161 import com.peachpasswords.ui.PeachNav6262 import com.peachpasswords.ui.components.PeachEmptyOrEndState6363 import com.peachpasswords.ui.components.PeachField64+import com.peachpasswords.ui.pairing.BackgroundSyncDialog6465 import com.peachpasswords.ui.sync.SyncViewModel6566 import com.peachpasswords.ui.theme.ACCENT_COLORS6667 import com.peachpasswords.ui.theme.PeachSpacing@@ -648,6 +649,8 @@648649 val syncStatus by syncVm.status.collectAsState()649650 val configuredRelayUrl by syncVm.privateRelayUrl.collectAsState()650651 val syncMode by syncVm.syncMode.collectAsState()652+ val backgroundSyncEnabled by syncVm.backgroundSyncEnabled.collectAsState()653+ var showBackgroundSyncPrompt by remember { mutableStateOf(false) }651654 val scope = rememberCoroutineScope()652655 var confirmUnpair by remember { mutableStateOf<String?>(null) }653656 var rotateConfirmOpen by remember { mutableStateOf(false) }@@ -701,6 +704,24 @@701704 // purpose — a phone with nothing paired is exactly the phone whose owner702705 // needs to be told why nothing is syncing.703706 SubLabel("SYNC STATUS", fg)707+ RowLine(fg) {708+ Column(Modifier.weight(1f)) {709+ Text("Background sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)710+ Text(711+ if (backgroundSyncEnabled) {712+ "Background sync is on. Tap to change."713+ } else {714+ "Background sync is off. Tap to enable."715+ },716+ color = fg.copy(alpha = 0.7f),717+ fontFamily = PeachType.Body,718+ fontSize = 11.sp,719+ lineHeight = 16.sp,720+ modifier = Modifier.padding(top = 2.dp),721+ )722+ }723+ SubtleButton("change", fg) { showBackgroundSyncPrompt = true }724+ }704725 val indicator = syncIndicator(syncStatus, pairedDevices.size)705726 RowLine(fg) {706727 SyncIndicatorDot(@@ -907,6 +928,13 @@907928 }908929 909930 Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)931+932+ if (showBackgroundSyncPrompt) {933+ BackgroundSyncDialog { enabled ->934+ showBackgroundSyncPrompt = false935+ syncVm.setBackgroundSyncEnabled(enabled)936+ }937+ }910938 }911939 912940 private fun deviceTypeLabel(type: String?): String = when (type) {app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+13 −0
@@ -3,6 +3,7 @@33 import android.content.Context44 import androidx.lifecycle.ViewModel55 import androidx.lifecycle.viewModelScope6+import com.peachpasswords.sync.BackgroundSyncPreference67 import com.peachpasswords.sync.SyncVaultAdapter78 import com.peachpasswords.sync.PairedDevice89 import com.peachpasswords.sync.PairedDeviceStore@@ -53,12 +54,24 @@5354 private val deviceStore: PairedDeviceStore,5455 private val pendingVouchedStore: PendingVouchedDeviceStore,5556 private val syncAlertStore: SyncAlertStore,57+ private val backgroundSync: BackgroundSyncPreference,5658 private val relaySettings: RelaySettingsStore,5759 private val syncOutcomes: SyncOutcomeStore,5860 ) : ViewModel() {5961 6062 /** The last sync attempt, whatever it was. Survives process death. */6163 val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary64+65+ val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled66+67+ fun setBackgroundSyncEnabled(enabled: Boolean) {68+ backgroundSync.setEnabled(enabled)69+ if (enabled && vault.isUnlocked.value) {70+ PeachSyncService.start(context)71+ } else if (!enabled) {72+ PeachSyncService.stop(context)73+ }74+ }6275 6376 /** Blank while no private relay is configured, so the field stays honest. */6477 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())app/src/test/java/com/peachpasswords/sync/BackgroundSyncPreferenceTest.kt+19 −0
@@ -0,0 +1,19 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertTrue5+import org.junit.Test6+7+class BackgroundSyncPreferenceTest {8+ @Test fun `missing choice defaults background sync on`() {9+ assertTrue(readEnabled(hasStoredChoice = false) { false })10+ }11+12+ @Test fun `stored manual-only choice survives restart`() {13+ assertFalse(readEnabled(hasStoredChoice = true) { false })14+ }15+16+ @Test fun `stored background choice survives restart`() {17+ assertTrue(readEnabled(hasStoredChoice = true) { true })18+ }19+}app/src/test/java/com/peachpasswords/ui/pairing/BackgroundSyncChoiceTest.kt+25 −0
@@ -0,0 +1,25 @@1+package com.peachpasswords.ui.pairing2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertFalse5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class BackgroundSyncChoiceTest {9+ @Test fun `successful pairing prompts before navigation`() {10+ assertTrue(shouldPromptForBackgroundSync(pairingSucceeded = true))11+ assertFalse(shouldPromptForBackgroundSync(pairingSucceeded = false))12+ }13+14+ @Test fun `back uses the recommended background default`() {15+ assertEquals(true, backgroundSyncChoice(BackgroundSyncAction.DISMISS))16+ }17+18+ @Test fun `secondary action selects manual sync`() {19+ assertEquals(false, backgroundSyncChoice(BackgroundSyncAction.MANUAL_ONLY))20+ }21+22+ @Test fun `primary action selects background sync`() {23+ assertEquals(true, backgroundSyncChoice(BackgroundSyncAction.ALLOW))24+ }25+}