package com.peachpasswords.ui.sync import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.sync.BackgroundSyncCoordinator import com.peachpasswords.sync.BackgroundSyncPreference import com.peachpasswords.sync.RESET_NOT_COMPLETED import com.peachpasswords.sync.ResetResult import com.peachpasswords.sync.SyncGeneration import com.peachpasswords.sync.SyncGenerationStore import com.peachpasswords.sync.SyncVaultAdapter import com.peachpasswords.sync.confirmSecureReset import com.peachpasswords.sync.enterResetRequired import com.peachpasswords.sync.resolveGeneration import com.peachpasswords.sync.syncAllowedNow import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.TrustDataCorruptedException import com.peachpasswords.sync.PendingVouchedDevice import com.peachpasswords.sync.PendingVouchedDeviceStore import com.peachpasswords.sync.PeachSyncService import com.peachpasswords.sync.RelayDecision import com.peachpasswords.sync.RelaySettingsStore import com.peachpasswords.sync.SyncMode import com.peachpasswords.sync.SyncAlertStore import com.peachpasswords.sync.SyncOutcomeReason import com.peachpasswords.sync.SyncOutcomeStore import com.peachpasswords.sync.SyncOutcomeSummary import com.peachpasswords.sync.VaultSyncAdapter import com.peachpasswords.sync.loadOrCreateLocalIdentity import com.peachpasswords.sync.loadLocalIdentityOrNull import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.catch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject /** * Backs the Sync settings card: relay mode, the paired-device list, and the * manual sync control. * * Sync itself runs in [PeachSyncService], which holds a socket in each paired * peer's rendezvous room. Which side opens the conversation there depends on * the peer: we wait for a browser extension, because an extension can only * dial, and for another phone the lower long-term key dials. So * "sync now" makes sure the service is up and asks it to re-read the device * list — the work happens there, not here. See docs/sync-lan-retirement.md. */ @HiltViewModel class SyncViewModel @Inject constructor( @ApplicationContext private val context: Context, private val vault: VaultRepository, private val deviceStore: PairedDeviceStore, private val pendingVouchedStore: PendingVouchedDeviceStore, private val syncAlertStore: SyncAlertStore, private val backgroundSync: BackgroundSyncPreference, private val relaySettings: RelaySettingsStore, private val syncOutcomes: SyncOutcomeStore, private val generation: SyncGenerationStore, ) : ViewModel() { /** The last sync attempt, whatever it was. Survives process death. */ val status: StateFlow = combine(syncOutcomes.summary, vault.isUnlocked) { summary, unlocked -> if (unlocked && vault.syncVaultId() == null) SyncOutcomeSummary() else summary }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5_000), if (vault.isUnlocked.value && vault.syncVaultId() == null) { SyncOutcomeSummary() } else { syncOutcomes.summary.value }, ) /** * Decides when the sync service actually runs for the background-sync * preference. The service's own locked-handling is the gate — refusing to * start here while locked used to leave the preference on with no * notification, which was exactly the v2.5.13 "no peer answered" symptom. */ private val backgroundSyncCoordinator = BackgroundSyncCoordinator( start = { PeachSyncService.start(context) }, stop = { PeachSyncService.stop(context) }, isUnlocked = vault.isUnlocked, preferenceEnabled = backgroundSync.enabled, syncVaultId = vault::syncVaultId, syncAllowed = { syncAllowedNow(generation) }, ) init { // Catches the lock-then-unlock case: the user enables the setting while // locked, then unlocks — the service should come up the moment a sync // identity exists. StateFlow.collect fires the current value // immediately, so the already-unlocked case is covered too. backgroundSyncCoordinator.observe(viewModelScope) } fun setBackgroundSyncEnabled(enabled: Boolean) { if (isDecoySession()) return backgroundSync.setEnabled(enabled) backgroundSyncCoordinator.setEnabled(enabled) } val backgroundSyncEnabled: StateFlow = backgroundSync.enabled /** Blank while no private relay is configured, so the field stays honest. */ private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl()) val privateRelayUrl: StateFlow = _privateRelayUrl.asStateFlow() private val _syncMode = MutableStateFlow(relaySettings.syncMode()) val syncMode: StateFlow = _syncMode.asStateFlow() fun setSyncMode(mode: SyncMode) { if (isDecoySession()) return relaySettings.setSyncMode(mode) _syncMode.value = relaySettings.syncMode() _privateRelayUrl.value = relaySettings.privateRelayUrl() } /** A custom URL implies private mode. Returns false on an invalid URL. */ fun setRelayUrl(input: String): Boolean { if (isDecoySession()) return false return runCatching { relaySettings.setRelayUrl(input) _privateRelayUrl.value = relaySettings.privateRelayUrl() _syncMode.value = relaySettings.syncMode() true }.getOrDefault(false) } /** Live list of trusted peers, redacted while a decoy session is active (C7). */ val pairedDevices: StateFlow> = deviceStore.observeAll() .catch { e -> if (e is TrustDataCorruptedException) { syncOutcomes.report( SyncOutcomeSummary(reason = SyncOutcomeReason.TRUST_DATA_CORRUPTED, detail = e.message), ) emit(emptyList()) } else { throw e } } .combine(vault.isUnlocked) { devices, unlocked -> if (unlocked && vault.syncVaultId() == null) emptyList() else devices } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) val pendingVouched: StateFlow> = combine(pendingVouchedStore.observeAll(), vault.isUnlocked) { pending, unlocked -> if (unlocked && vault.syncVaultId() == null) emptyList() else pending }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) /** * Revoke a paired device. Removing it REVOKES rather than just dropping the * local row: a plain removal is undone the moment any other peer vouches it * back through a manifest, which is exactly what the relay makes easy. */ fun unpairDevice(id: String) = viewModelScope.launch { if (isDecoySession()) return@launch try { val identity = loadLocalIdentityOrNull(context) ?: return@launch deviceStore.revoke(id, identity.deviceId) PeachSyncService.start(context) } catch (e: TrustDataCorruptedException) { syncOutcomes.report( SyncOutcomeSummary(reason = SyncOutcomeReason.TRUST_DATA_CORRUPTED, detail = e.message), ) } } fun renameDevice(id: String, name: String) = viewModelScope.launch { if (isDecoySession()) return@launch deviceStore.rename(id, name) } fun confirmVouchedDevice(id: String) = viewModelScope.launch { if (isDecoySession()) return@launch val candidate = pendingVouchedStore.get(id) ?: return@launch val pending = pendingVouchedStore.get( candidate.deviceId, candidate.longTermPublicKeyHex, candidate.signingPublicKeyHex, ) ?: return@launch // here rather than trusted to have swept the queue. if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) { pendingVouchedStore.removeBatch(setOf(id)) return@launch } // Never let a stale queue item overwrite an already trusted row (in // particular a direct SAS-paired row). The queued introduction is // consumed, but the established trust record remains authoritative. if (deviceStore.get(pending.deviceId) != null) { pendingVouchedStore.removeBatch(setOf(id)) return@launch } // Remove the pending row synchronously before publishing trust. If the // subsequent upsert fails, the safe outcome is a lost introduction, // not a restart that replays stale pending trust over a revocation. if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch deviceStore.upsert( PairedDevice( deviceId = pending.deviceId, deviceName = pending.deviceName, deviceType = pending.deviceType, longTermPublicKeyHex = pending.longTermPublicKeyHex, signingPublicKeyHex = pending.signingPublicKeyHex, trustSource = "vouched", wsPort = pending.wsPort, pairedAt = pending.pairedAt, ), ) } fun rejectVouchedDevice(id: String) = viewModelScope.launch { if (isDecoySession()) return@launch pendingVouchedStore.remove(id) } /** Browser-style nuclear recovery path: re-key sync and forget every peer. */ suspend fun rotateSyncIdentity(): Boolean { if (isDecoySession()) return false val ok = vault.rotateSyncIdentity() if (ok) { deviceStore.clear() syncOutcomes.reset() } return ok } /** Adapter over the live vault; null while the vault is locked. */ fun buildAdapter(): SyncVaultAdapter? { if (!vault.isUnlocked.value || isDecoySession()) return null if (!syncAllowedNow(generation)) return null val credentials = loadOrCreateLocalIdentity(context) return VaultSyncAdapter( repo = vault, ourDeviceId = credentials.deviceId, pairedDevices = deviceStore, pendingVouched = pendingVouchedStore, syncAlerts = syncAlertStore, localCredentials = credentials, ) } /** * The §11 migration panel: what this device's Sync generation is, and what * it still owes the user. Not secret — it is a version and a state name — * so a decoy session shows the same thing without leaking anything. */ private val _generationState = MutableStateFlow(generationRecord()) val generationState: StateFlow = _generationState.asStateFlow() private fun generationRecord(): SyncGeneration = resolveGeneration(generation, System.currentTimeMillis()) /** * Enter the state that blocks sync, then run the migration. Entry is * separate from confirmation so a crash between them leaves the device * refusing sync rather than mid-reset with trust still live (§11.3). * * The master password confirms a trust-resetting action (§11.9), then the * archive → identity → pointer-flip sequence runs in that order. Returns * null on success, or the line to show the user. */ suspend fun confirmReset(masterPassword: String): String? { if (isDecoySession()) return "Reset is unavailable in this session" if (masterPassword.isBlank()) return "Enter your master password" val masterMatches = withContext(Dispatchers.Default) { vault.tryUnlockSlotA(masterPassword)?.let { vault.discardSlotAUnlock(it) true } ?: false } if (!masterMatches) return "Incorrect master password" val result = withContext(Dispatchers.Default) { _generationState.value = enterResetRequired(generation, System.currentTimeMillis()) confirmSecureReset(generation, System.currentTimeMillis()) } _generationState.value = generationRecord() return when (result) { ResetResult.V2_ANCHOR -> null ResetResult.RESET_NOT_COMPLETED -> "The reset did not complete — nothing was lost, try again ($RESET_NOT_COMPLETED)" } } /** * Manual trigger, and the one place a sync attempt is started on purpose. * * It used to claim a completed sync the instant the service was * asked to start, which is a success it had not earned — the service only * re-reads the device list, and the sync itself happens later in a * rendezvous room or not at all. Now the attempt goes busy and the outcome * comes from whoever actually finishes it. */ fun syncNow() { // A decoy session must not touch the real outcome store or start the // sync service: either would expose or mutate real-session history. if (isDecoySession()) return val decision = relaySettings.relayInForce() if (decision is RelayDecision.Refuse) { syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason)) return } if (!vault.isUnlocked.value) { syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED)) return } // Read the store, not `pairedDevices.value`: that flow only holds a real // list while something is collecting it, so pull-to-refresh on the vault // list — which collects `status` and nothing else — always saw an empty // list and reported "no devices paired" against a fully paired phone. val devices = deviceStore.getAll() if (devices.isEmpty()) { syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES)) return } // The same filter CloudRelayReconnectListener applies when it decides // which rooms to hold — a peer with no rendezvous secret is skipped // there in silence, which is precisely the case that must not look like // a successful no-op. if (devices.none { it.rendezvousSecretHex != null }) { syncOutcomes.report( SyncOutcomeSummary( reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = devices.joinToString("; ") { "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]" }, ), ) return } if (!syncAllowedNow(generation)) return syncOutcomes.begin() PeachSyncService.start(context) // Nothing else can close this attempt: the phone usually WAITS to be // dialled, so if no peer turns up there is no session to report a // failure from and the light would pulse for ever. Deliberately shorter // than the dialler's own bound: this one answers the person holding the // phone, and a session still running will report over it. attemptTimeout?.cancel() attemptTimeout = viewModelScope.launch { delay(SYNC_ATTEMPT_TIMEOUT_MS) if (syncOutcomes.summary.value.syncing) { syncOutcomes.report( SyncOutcomeSummary( reason = SyncOutcomeReason.RELAY_UNREACHABLE, detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s", ), ) } } } private fun isDecoySession(): Boolean = vault.isUnlocked.value && vault.syncVaultId() == null /** * Legacy manual / discovery trigger kept for existing callers; now routes * through [syncNow] so a populated device store actually dials peers. */ fun triggerSync() = syncNow() private var attemptTimeout: Job? = null private companion object { const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L } }