package com.peachpasswords.ui.sync import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 relaySettings: RelaySettingsStore, private val syncOutcomes: SyncOutcomeStore, ) : ViewModel() { /** The last sync attempt, whatever it was. Survives process death. */ val status: StateFlow = syncOutcomes.summary /** 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)) } 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.remove(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() 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 } }