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 /** * 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, ) : 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. Reports Offline when no peers are paired; otherwise * brings the relay service up (or nudges it, if already running) so it * reconnects to every peer's room straight away. */ fun syncNow() { if (!vault.isUnlocked.value) { _status.value = SyncStatus.Error("vault_locked") return } if (pairedDevices.value.isEmpty()) { _status.value = SyncStatus.Offline return } 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() }