6c48f1b25b6e

feat: Trigger foreground sync (v2.3.49)

- Add a foreground sync coordinator for trusted sync-capable peers - Trigger sync on app foreground and successful real or biometric unlock - Reuse the vault adapter so foreground merges feed sync-alert banners

time
2026-06-26 14:10 UTC · 4 months ago
machine
koh2
parent
228056e22836
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/PeachApplication.kt+6 −0app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+75 −0app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+8 −1app/src/test/java/com/peachpasswords/sync/ForegroundSyncCoordinatorTest.kt+29 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23480020-        versionName "2.3.48"19+        versionCode 23490020+        versionName "2.3.49"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/PeachApplication.kt+6 −0
@@ -4,6 +4,7 @@44 import androidx.lifecycle.DefaultLifecycleObserver55 import androidx.lifecycle.LifecycleOwner66 import androidx.lifecycle.ProcessLifecycleOwner7+import com.peachpasswords.sync.ForegroundSyncCoordinator78 import com.peachpasswords.vault.VaultLockTimer89 import com.peachpasswords.vault.VaultRepository910 import dagger.hilt.android.HiltAndroidApp@@ -13,6 +14,7 @@1314 class PeachApplication : Application() {1415     @Inject lateinit var vault: VaultRepository1516     @Inject lateinit var lockTimer: VaultLockTimer17+    @Inject lateinit var foregroundSync: ForegroundSyncCoordinator1618 1719     override fun onCreate() {1820         super.onCreate()@@ -20,6 +22,10 @@2022         // ProcessLifecycleOwner is process-wide and debounced, so a rotation or a2123         // brief Activity restart does not trip it — only a genuine backgrounding.2224         ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {25+            override fun onStart(owner: LifecycleOwner) {26+                foregroundSync.triggerForegroundSync()27+            }28+2329             override fun onStop(owner: LifecycleOwner) {2430                 vault.lock()2531                 lockTimer.cancel()

view file

app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+75 −0
@@ -0,0 +1,75 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import com.peachpasswords.vault.VaultCrypto5+import com.peachpasswords.vault.VaultRepository6+import dagger.hilt.android.qualifiers.ApplicationContext7+import kotlinx.coroutines.CoroutineScope8+import kotlinx.coroutines.Dispatchers9+import kotlinx.coroutines.Job10+import kotlinx.coroutines.SupervisorJob11+import kotlinx.coroutines.launch12+import javax.inject.Inject13+import javax.inject.Singleton14+15+@Singleton16+class ForegroundSyncCoordinator @Inject constructor(17+    @ApplicationContext private val context: Context,18+    private val vault: VaultRepository,19+    private val deviceStore: PairedDeviceStore,20+    private val pendingVouchedStore: PendingVouchedDeviceStore,21+    private val discoveryClient: PairingDiscoveryClient,22+    private val syncAlertStore: SyncAlertStore,23+) {24+    private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())25+    private var inFlight: Job? = null26+27+    fun triggerForegroundSync() {28+        if (inFlight?.isActive == true) return29+        if (!vault.isUnlocked.value) return30+        val peers = foregroundSyncPeers(deviceStore.getAll())31+        if (peers.isEmpty()) return32+        val master = vault.syncMasterKey() ?: return33+        inFlight = scope.launch {34+            try {35+                val identity = buildLocalIdentity(context, master)36+                peers.forEach { peer ->37+                    runCatching {38+                        discoveryClient.syncWithKnownDevice(39+                            deviceId = peer.deviceId,40+                            localIdentity = identity,41+                            vaultFactory = ::buildAdapter,42+                        )43+                    }44+                }45+            } finally {46+                VaultCrypto.zero(master)47+            }48+        }49+    }50+51+    private fun buildAdapter(): ChirpSyncVaultAdapter? {52+        if (!vault.isUnlocked.value) return null53+        return VaultSyncAdapter(54+            repo = vault,55+            ourDeviceId = getOrCreateDeviceId(context),56+            pairedDevices = deviceStore,57+            pendingVouched = pendingVouchedStore,58+            syncAlerts = syncAlertStore,59+        )60+    }61+}62+63+internal fun foregroundSyncPeers(devices: List<PairedDevice>): List<PairedDevice> =64+    devices.filter { device ->65+        val trusted = device.trustSource == null || device.trustSource == "direct"66+        val syncCapable = when (device.deviceType) {67+            null,68+            "android",69+            "ios",70+            "desktop",71+            "extension" -> true72+            else -> false73+        }74+        trusted && syncCapable75+    }

view file

app/src/main/java/com/peachpasswords/ui/lock/LockViewModel.kt+8 −1
@@ -5,6 +5,7 @@55 import androidx.fragment.app.FragmentActivity66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope8+import com.peachpasswords.sync.ForegroundSyncCoordinator89 import com.peachpasswords.ui.PeachNav910 import com.peachpasswords.ui.vault.entryTapDestination1011 import com.peachpasswords.vault.CreateVaultResult@@ -85,6 +86,7 @@8586     private val duress: DuressConfigStore,8687     private val lockTimer: VaultLockTimer,8788     private val lastViewed: LastViewedEntryStore,89+    private val foregroundSync: ForegroundSyncCoordinator,8890     @ApplicationContext private val appContext: Context,8991 ) : ViewModel() {9092     private val _state = MutableStateFlow(LockUiState(hasVault = vault.hasVault()))@@ -118,6 +120,7 @@118120                 slotA != null -> {119121                     vault.commitSlotAUnlock(slotA)120122                     lockTimer.start()123+                    foregroundSync.triggerForegroundSync()121124                     _state.update { it.copy(loading = false, unlocked = true) }122125                 }123126                 else -> _state.update {@@ -195,7 +198,11 @@195198                     val r = vault.unlockWithKey(key)196199                     VaultCrypto.zero(key)197200                     when (r) {198-                        is UnlockResult.Success -> { lockTimer.start(); _state.update { it.copy(unlocked = true) } }201+                        is UnlockResult.Success -> {202+                            lockTimer.start()203+                            foregroundSync.triggerForegroundSync()204+                            _state.update { it.copy(unlocked = true) }205+                        }199206                         else -> _state.update { it.copy(error = "biometric unlock failed", shakeNonce = it.shakeNonce + 1) }200207                     }201208                 }

view file

app/src/test/java/com/peachpasswords/sync/ForegroundSyncCoordinatorTest.kt+29 −0
@@ -0,0 +1,29 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Test5+6+class ForegroundSyncCoordinatorTest {7+    @Test fun foreground_sync_targets_direct_sync_capable_devices_only() {8+        val devices = listOf(9+            peer("direct-android", type = "android", trust = "direct"),10+            peer("legacy-null-trust", type = "extension", trust = null),11+            peer("vouched", type = "android", trust = "vouched"),12+            peer("unknown-kind", type = "printer", trust = "direct"),13+        )14+15+        val eligible = foregroundSyncPeers(devices).map { it.deviceId }16+17+        assertEquals(listOf("direct-android", "legacy-null-trust"), eligible)18+    }19+20+    private fun peer(id: String, type: String?, trust: String?) = PairedDevice(21+        deviceId = id,22+        deviceName = id,23+        deviceType = type,24+        longTermPublicKeyHex = "aa".repeat(32),25+        signingPublicKeyHex = "bb".repeat(32),26+        trustSource = trust,27+        pairedAt = 1L,28+    )29+}

view file