SyncViewModel.kt

6.6 KB · raw

1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.ChirpSyncVaultAdapter7import com.peachpasswords.sync.PairedDevice8import com.peachpasswords.sync.PairedDeviceStore9import com.peachpasswords.sync.PendingVouchedDevice10import com.peachpasswords.sync.PendingVouchedDeviceStore11import com.peachpasswords.sync.hexToPubkey12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelaySettingsStore14import com.peachpasswords.sync.SyncMode15import com.peachpasswords.sync.SyncAlertStore16import com.peachpasswords.sync.SyncStatus17import com.peachpasswords.sync.VaultSyncAdapter18import com.peachpasswords.sync.buildLocalIdentity19import com.peachpasswords.sync.deriveLocalSyncKeypair20import com.peachpasswords.sync.getOrCreateDeviceId21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.SharingStarted27import kotlinx.coroutines.flow.StateFlow28import kotlinx.coroutines.flow.asStateFlow29import kotlinx.coroutines.flow.stateIn30import kotlinx.coroutines.launch31import javax.inject.Inject3233/**34 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.35 *36 * Trigger conditions (browser `sync-trigger.ts` parity):37 *   1. Vault mutation — a local add/update/delete should schedule a sync.38 *   2. App foreground — reconnect to nearby peers on resume.39 *   3. Manual — the "Sync now" control on the Sync card.40 *   4. mDNS peer discovery — a paired device appears on the LAN.41 *42 * Discovery, the WebSocket server role, and the paired-device store are wired43 * Under the relay-only model this device WAITS in its rendezvous rooms as the44 * responder and the peer dials in, so "sync now" makes sure the listener is up45 * rather than dialling anyone. A browser extension cannot hold a relay room46 * open, so it has to be the dialler. See docs/sync-lan-retirement.md.47 */48@HiltViewModel49class SyncViewModel @Inject constructor(50    @ApplicationContext private val context: Context,51    private val vault: VaultRepository,52    private val deviceStore: PairedDeviceStore,53    private val pendingVouchedStore: PendingVouchedDeviceStore,54    private val syncAlertStore: SyncAlertStore,55    private val relaySettings: RelaySettingsStore,56) : ViewModel() {5758    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)59    val status: StateFlow<SyncStatus> = _status.asStateFlow()6061    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())62    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()636465    private val _syncMode = MutableStateFlow(relaySettings.syncMode())66    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()6768    fun setSyncMode(mode: SyncMode) {69        relaySettings.setSyncMode(mode)70        _syncMode.value = relaySettings.syncMode()71        _relayUrl.value = relaySettings.relayUrl()72    }7374    /** Saving a URL implies private mode. Returns false on an invalid URL. */75    fun setPrivateRelayUrl(input: String): Boolean = runCatching {76        _relayUrl.value = relaySettings.setPrivateRelayUrl(input)77        _syncMode.value = relaySettings.syncMode()78        true79    }.getOrDefault(false)808182    /** Live list of trusted peers, backing the Sync settings card device list (C7). */83    val pairedDevices: StateFlow<List<PairedDevice>> =84        deviceStore.observeAll()85            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8687    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =88        pendingVouchedStore.observeAll()89            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9091    /**92     * Revoke a paired device. Removing it REVOKES rather than just dropping the93     * local row: a plain removal is undone the moment any other peer vouches it94     * back through a manifest, which is exactly what the relay makes easy.95     */96    fun unpairDevice(id: String) = viewModelScope.launch {97        deviceStore.revoke(id, getOrCreateDeviceId(context))98    }99100    fun confirmVouchedDevice(id: String) = viewModelScope.launch {101        val pending = pendingVouchedStore.get(id) ?: return@launch102        deviceStore.upsert(103            PairedDevice(104                deviceId = pending.deviceId,105                deviceName = pending.deviceName,106                deviceType = pending.deviceType,107                longTermPublicKeyHex = pending.longTermPublicKeyHex,108                signingPublicKeyHex = pending.signingPublicKeyHex,109                trustSource = "vouched",110                wsPort = pending.wsPort,111                pairedAt = pending.pairedAt,112            ),113        )114        pendingVouchedStore.remove(id)115    }116117    fun rejectVouchedDevice(id: String) = viewModelScope.launch {118        pendingVouchedStore.remove(id)119    }120121    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */122    suspend fun rotateSyncIdentity(): Boolean {123        val ok = vault.rotateSyncIdentity()124        if (ok) {125            deviceStore.clear()126            _status.value = SyncStatus.Idle127        }128        return ok129    }130131    /** Adapter over the live vault; null while the vault is locked. */132    fun buildAdapter(): ChirpSyncVaultAdapter? {133        if (!vault.isUnlocked.value) return null134        return VaultSyncAdapter(135            repo = vault,136            ourDeviceId = getOrCreateDeviceId(context),137            pairedDevices = deviceStore,138            pendingVouched = pendingVouchedStore,139            syncAlerts = syncAlertStore,140        )141    }142143    /**144     * Manual trigger: discover each paired device on the LAN and run an initiator145     * sync round against it. Reports Offline when no peers are paired. mDNS146     * discovery + audio cannot be emulator-verified — device QA pending (C7).147     */148    fun syncNow() {149        if (!vault.isUnlocked.value) {150            _status.value = SyncStatus.Error("vault_locked")151            return152        }153        if (pairedDevices.value.isEmpty()) {154            _status.value = SyncStatus.Offline155            return156        }157        // Make sure we are registered on the relay and reachable. The peer158        // performs the dial; there is nothing to initiate from here.159        PeachSyncService.start(context)160        _status.value = SyncStatus.LastSynced(System.currentTimeMillis())161    }162163    /**164     * Legacy manual / discovery trigger kept for existing callers; now routes165     * through [syncNow] so a populated device store actually dials peers.166     */167    fun triggerSync() = syncNow()168169}