d115d87bd22a

remove the locked-state gate from setBackgroundSyncEnabled and add a vault-unlock observer so the sync service starts whenever preference is on (v2.5.14)

time
2026-07-27 03:10 UTC · 2 months ago
machine
koh2
agent
claude-opus-5
session
background sync gate fix
parent
4b98699ba304
5 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/BackgroundSyncCoordinator.kt+41 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+23 −5app/src/test/java/com/peachpasswords/sync/BackgroundSyncCoordinatorTest.kt+150 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.131+2.5.14\ No newline at end of file

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24011320-        versionName "2.5.13"19+        versionCode 24011420+        versionName "2.5.14"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/BackgroundSyncCoordinator.kt+41 −0
@@ -0,0 +1,41 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CoroutineScope4+import kotlinx.coroutines.flow.StateFlow5+import kotlinx.coroutines.launch6+7+/**8+ * Owns the "when do we actually start the sync service?" decision for the9+ * background-sync preference. Split out from [com.peachpasswords.ui.sync.SyncViewModel]10+ * so the gate can be unit-tested against fake flows without standing up Hilt.11+ *12+ * Two paths to a started service:13+ *  - `setEnabled(true)` while locked -> start anyway. The service's own14+ *    locked-handling (`startReconnectListener` early-returns on15+ *    `vault.syncMasterKey() ?: return`) is the gate; refusing to even start16+ *    here used to leave the preference on with no notification and no listener.17+ *  - Unlock event with preference on -> start, catching the lock-then-unlock18+ *    case the v2.5.13 gate missed entirely.19+ *20+ * [start] / [stop] are injected so tests can count calls without touching21+ * Context or [PeachSyncService].22+ */23+internal class BackgroundSyncCoordinator(24+    private val start: () -> Unit,25+    private val stop: () -> Unit,26+    private val isUnlocked: StateFlow<Boolean>,27+    private val preferenceEnabled: StateFlow<Boolean>,28+) {29+    fun setEnabled(enabled: Boolean) {30+        if (enabled) start() else stop()31+    }32+33+    /** Fires the current value of [isUnlocked] immediately on subscription. */34+    fun observe(scope: CoroutineScope) {35+        scope.launch {36+            isUnlocked.collect { unlocked ->37+                if (unlocked && preferenceEnabled.value) start()38+            }39+        }40+    }41+}\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+23 −5
@@ -3,6 +3,7 @@33 import android.content.Context44 import androidx.lifecycle.ViewModel55 import androidx.lifecycle.viewModelScope6+import com.peachpasswords.sync.BackgroundSyncCoordinator67 import com.peachpasswords.sync.BackgroundSyncPreference78 import com.peachpasswords.sync.SyncVaultAdapter89 import com.peachpasswords.sync.PairedDevice@@ -64,13 +65,30 @@6465 6566     val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled6667 68+    /**69+     * Decides when the sync service actually runs for the background-sync70+     * preference. The service's own locked-handling is the gate — refusing to71+     * start here while locked used to leave the preference on with no72+     * notification, which was exactly the v2.5.13 "no peer answered" symptom.73+     */74+    private val backgroundSyncCoordinator = BackgroundSyncCoordinator(75+        start = { PeachSyncService.start(context) },76+        stop = { PeachSyncService.stop(context) },77+        isUnlocked = vault.isUnlocked,78+        preferenceEnabled = backgroundSync.enabled,79+    )80+81+    init {82+        // Catches the lock-then-unlock case: the user enables the setting while83+        // locked, then unlocks — the service should come up the moment a sync84+        // identity exists. StateFlow.collect fires the current value85+        // immediately, so the already-unlocked case is covered too.86+        backgroundSyncCoordinator.observe(viewModelScope)87+    }88+6789     fun setBackgroundSyncEnabled(enabled: Boolean) {6890         backgroundSync.setEnabled(enabled)69-        if (enabled && vault.isUnlocked.value) {70-            PeachSyncService.start(context)71-        } else if (!enabled) {72-            PeachSyncService.stop(context)73-        }91+        backgroundSyncCoordinator.setEnabled(enabled)7492     }7593 7694     /** Blank while no private relay is configured, so the field stays honest. */

view file

app/src/test/java/com/peachpasswords/sync/BackgroundSyncCoordinatorTest.kt+150 −0
@@ -0,0 +1,150 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CoroutineScope4+import kotlinx.coroutines.ExperimentalCoroutinesApi5+import kotlinx.coroutines.flow.MutableStateFlow6+import kotlinx.coroutines.test.StandardTestDispatcher7+import kotlinx.coroutines.test.TestCoroutineScheduler8+import kotlinx.coroutines.test.advanceUntilIdle9+import kotlinx.coroutines.test.runTest10+import org.junit.Assert.assertEquals11+import org.junit.Test12+13+/**14+ * The v2.5.13 gate `if (enabled && vault.isUnlocked.value)` was wrong: the user15+ * almost always enables background sync from Settings while the vault is16+ * locked, so the service never started and the device stayed invisible to17+ * paired peers. The fix lets the service decide the locked case itself and18+ * adds an observer that catches the lock-then-unlock path.19+ */20+@OptIn(ExperimentalCoroutinesApi::class)21+class BackgroundSyncCoordinatorTest {22+23+    /**24+     * Wires the coordinator against a [StandardTestDispatcher] backed by the25+     * same scheduler [runTest] drives, so `advanceUntilIdle` flushes the26+     * observer's `collect` along with the rest of the test body.27+     */28+    private fun fixture(29+        scheduler: TestCoroutineScheduler,30+        unlocked: Boolean = false,31+        preferenceOn: Boolean = false,32+    ): Fixture {33+        val isUnlocked = MutableStateFlow(unlocked)34+        val preferenceEnabled = MutableStateFlow(preferenceOn)35+        val starts = mutableListOf<Unit>()36+        val stops = mutableListOf<Unit>()37+        val coordinator = BackgroundSyncCoordinator(38+            start = { starts += Unit },39+            stop = { stops += Unit },40+            isUnlocked = isUnlocked,41+            preferenceEnabled = preferenceEnabled,42+        )43+        return Fixture(44+            coordinator = coordinator,45+            scope = CoroutineScope(StandardTestDispatcher(scheduler)),46+            isUnlocked = isUnlocked,47+            preferenceEnabled = preferenceEnabled,48+            starts = starts,49+            stops = stops,50+        )51+    }52+53+    private class Fixture(54+        val coordinator: BackgroundSyncCoordinator,55+        val scope: CoroutineScope,56+        val isUnlocked: MutableStateFlow<Boolean>,57+        val preferenceEnabled: MutableStateFlow<Boolean>,58+        val starts: MutableList<Unit>,59+        val stops: MutableList<Unit>,60+    )61+62+    @Test fun `enable while locked still starts the service`() = runTest {63+        val f = fixture(testScheduler, unlocked = false, preferenceOn = false)64+        f.coordinator.observe(f.scope)65+        f.coordinator.setEnabled(true)66+        advanceUntilIdle()67+68+        assertEquals(listOf(Unit), f.starts)69+        assertEquals(emptyList<Unit>(), f.stops)70+    }71+72+    @Test fun `disable while locked stops the service and never starts it`() = runTest {73+        // A service from a previous session may still be running; stopping is74+        // correct in either case, and we must not start a service the user75+        // just turned off.76+        val f = fixture(testScheduler, unlocked = false, preferenceOn = true)77+        f.coordinator.observe(f.scope)78+        advanceUntilIdle() // observer fires once on subscription; preference is on but locked79+        assertEquals(emptyList<Unit>(), f.starts) // locked -> no start from observer80+81+        f.coordinator.setEnabled(false)82+        advanceUntilIdle()83+84+        assertEquals(emptyList<Unit>(), f.starts)85+        assertEquals(listOf(Unit), f.stops)86+    }87+88+    @Test fun `unlocking with preference on starts the service`() = runTest {89+        val f = fixture(testScheduler, unlocked = false, preferenceOn = true)90+        f.coordinator.observe(f.scope)91+        advanceUntilIdle() // locked subscription: no start92+        assertEquals(emptyList<Unit>(), f.starts)93+94+        f.isUnlocked.value = true95+        advanceUntilIdle()96+97+        assertEquals(listOf(Unit), f.starts)98+    }99+100+    @Test fun `unlocking with preference off does not start the service`() = runTest {101+        val f = fixture(testScheduler, unlocked = false, preferenceOn = false)102+        f.coordinator.observe(f.scope)103+        advanceUntilIdle()104+105+        f.isUnlocked.value = true106+        advanceUntilIdle()107+108+        assertEquals(emptyList<Unit>(), f.starts)109+    }110+111+    @Test fun `already unlocked at observer registration starts immediately`() = runTest {112+        // StateFlow.collect fires the current value on subscription, so a vault113+        // that is already open when this ViewModel comes up still gets the114+        // service started.115+        val f = fixture(testScheduler, unlocked = true, preferenceOn = true)116+        f.coordinator.observe(f.scope)117+        advanceUntilIdle()118+119+        assertEquals(listOf(Unit), f.starts)120+    }121+122+    @Test fun `enable while unlocked starts the service`() = runTest {123+        val f = fixture(testScheduler, unlocked = true, preferenceOn = false)124+        f.coordinator.observe(f.scope)125+        advanceUntilIdle() // preference off -> no start even though unlocked126+127+        f.coordinator.setEnabled(true)128+        advanceUntilIdle()129+130+        assertEquals(listOf(Unit), f.starts)131+        assertEquals(emptyList<Unit>(), f.stops)132+    }133+134+    @Test fun `lock event with preference on does not stop the service`() = runTest {135+        // The unlock observer only starts; it does not stop on lock. Locking136+        // is its own (separate) concern handled by the VaultLockTimer path,137+        // and tearing down the listener mid-flight on every keystroke back to138+        // the lock screen would race with the foreground listener.139+        val f = fixture(testScheduler, unlocked = true, preferenceOn = true)140+        f.coordinator.observe(f.scope)141+        advanceUntilIdle()142+        assertEquals(listOf(Unit), f.starts)143+144+        f.isUnlocked.value = false145+        advanceUntilIdle()146+147+        assertEquals(listOf(Unit), f.starts)148+        assertEquals(emptyList<Unit>(), f.stops)149+    }150+}\ No newline at end of file

view file