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.SyncVaultAdapter import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.PendingVouchedDevice import com.peachpasswords.sync.PendingVouchedDeviceStore import com.peachpasswords.sync.hexToPubkey 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.buildLocalIdentity import com.peachpasswords.sync.deriveLocalSyncKeypair import com.peachpasswords.sync.getOrCreateDeviceId import com.peachpasswords.vault.VaultCrypto 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.stateIn import kotlinx.coroutines.delay import kotlinx.coroutines.Job import kotlinx.coroutines.launch 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, ) : ViewModel() { /** The last sync attempt, whatever it was. Survives process death. */ val status: StateFlow = syncOutcomes.summary val backgroundSyncEnabled: StateFlow = backgroundSync.enabled /** * 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, ) 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) { backgroundSync.setEnabled(enabled) backgroundSyncCoordinator.setEnabled(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) { 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 = runCatching { relaySettings.setRelayUrl(input) _privateRelayUrl.value = relaySettings.privateRelayUrl() _syncMode.value = relaySettings.syncMode() true }.getOrDefault(false) /** Live list of trusted peers, backing the Sync settings card device list (C7). */ val pairedDevices: StateFlow> = deviceStore.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) val pendingVouched: StateFlow> = pendingVouchedStore.observeAll() .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 { deviceStore.revoke(id, getOrCreateDeviceId(context)) // Revocation is mesh state, not a vault-entry mutation. Wake the relay // service immediately so the signed revocation reaches every remaining // peer instead of waiting for a later foreground or periodic trigger. PeachSyncService.start(context) } fun confirmVouchedDevice(id: String) = viewModelScope.launch { val pending = pendingVouchedStore.get(id) ?: return@launch // Last gate before a vouched device becomes a sync peer. A queue entry // can predate the revocation that names it, so revocation is re-checked // 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 { pendingVouchedStore.remove(id) } /** Browser-style nuclear recovery path: re-key sync and forget every peer. */ suspend fun rotateSyncIdentity(): Boolean { 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) return null return VaultSyncAdapter( repo = vault, ourDeviceId = getOrCreateDeviceId(context), pairedDevices = deviceStore, pendingVouched = pendingVouchedStore, syncAlerts = syncAlertStore, ) } /** * 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() { 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 } 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", ), ) } } } /** * 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 } }