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
↓ peach-android-d115d87b.tar.gzatlas at this save
files changed5 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 −0app/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 {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 fileapp/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. */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