package com.peachpasswords.ui.sync import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.sync.ChirpSyncVaultAdapter 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.RelaySettingsStore import com.peachpasswords.sync.SyncMode import com.peachpasswords.sync.SyncAlertStore import com.peachpasswords.sync.SyncStatus 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.launch import javax.inject.Inject /** * Drives a Chirp Sync round and surfaces its status to the Sync settings card. * * Trigger conditions (browser `sync-trigger.ts` parity): * 1. Vault mutation — a local add/update/delete should schedule a sync. * 2. App foreground — reconnect to nearby peers on resume. * 3. Manual — the "Sync now" control on the Sync card. * 4. mDNS peer discovery — a paired device appears on the LAN. * * Discovery, the WebSocket server role, and the paired-device store are wired * Under the relay-only model this device WAITS in its rendezvous rooms as the * responder and the peer dials in, so "sync now" makes sure the listener is up * rather than dialling anyone. A browser extension cannot hold a relay room * open, so it has to be the dialler. 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 relaySettings: RelaySettingsStore, ) : ViewModel() { private val _status = MutableStateFlow(SyncStatus.Idle) val status: StateFlow = _status.asStateFlow() private val _relayUrl = MutableStateFlow(relaySettings.relayUrl()) val relayUrl: StateFlow = _relayUrl.asStateFlow() private val _syncMode = MutableStateFlow(relaySettings.syncMode()) val syncMode: StateFlow = _syncMode.asStateFlow() fun setSyncMode(mode: SyncMode) { relaySettings.setSyncMode(mode) _syncMode.value = relaySettings.syncMode() _relayUrl.value = relaySettings.relayUrl() } /** Saving a URL implies private mode. Returns false on an invalid URL. */ fun setPrivateRelayUrl(input: String): Boolean = runCatching { _relayUrl.value = relaySettings.setPrivateRelayUrl(input) _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)) } fun confirmVouchedDevice(id: String) = viewModelScope.launch { val pending = pendingVouchedStore.get(id) ?: 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, ), ) pendingVouchedStore.remove(id) } 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() _status.value = SyncStatus.Idle } return ok } /** Adapter over the live vault; null while the vault is locked. */ fun buildAdapter(): ChirpSyncVaultAdapter? { if (!vault.isUnlocked.value) return null return VaultSyncAdapter( repo = vault, ourDeviceId = getOrCreateDeviceId(context), pairedDevices = deviceStore, pendingVouched = pendingVouchedStore, syncAlerts = syncAlertStore, ) } /** * Manual trigger: discover each paired device on the LAN and run an initiator * sync round against it. Reports Offline when no peers are paired. mDNS * discovery + audio cannot be emulator-verified — device QA pending (C7). */ fun syncNow() { if (!vault.isUnlocked.value) { _status.value = SyncStatus.Error("vault_locked") return } if (pairedDevices.value.isEmpty()) { _status.value = SyncStatus.Offline return } // Make sure we are registered on the relay and reachable. The peer // performs the dial; there is nothing to initiate from here. PeachSyncService.start(context) _status.value = SyncStatus.LastSynced(System.currentTimeMillis()) } /** * Legacy manual / discovery trigger kept for existing callers; now routes * through [syncNow] so a populated device store actually dials peers. */ fun triggerSync() = syncNow() }