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
14 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.121+2.5.13

view file

app/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 {

view file

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 true

view file

app/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     }

view file

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                         }

view file

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 }

view file

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 = null

view file

app/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+}

view file

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     }

view file

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)

view file

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) {

view file

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())

view file

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+}

view file

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+}

view file