SyncViewModel.kt

7.9 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.PairingDiscoveryClient12import com.peachpasswords.sync.SyncEndpoint13import com.peachpasswords.sync.SyncAlertStore14import com.peachpasswords.sync.SyncOutcomeStatus15import com.peachpasswords.sync.SyncStatus16import com.peachpasswords.sync.VaultSyncAdapter17import com.peachpasswords.sync.buildLocalIdentity18import com.peachpasswords.sync.deriveSyncIdentityKeypair19import com.peachpasswords.sync.getOrCreateDeviceId20import com.peachpasswords.sync.openChirpSyncSession21import com.peachpasswords.sync.runChirpSyncInitiator22import com.peachpasswords.vault.VaultCrypto23import com.peachpasswords.vault.VaultRepository24import dagger.hilt.android.lifecycle.HiltViewModel25import dagger.hilt.android.qualifiers.ApplicationContext26import kotlinx.coroutines.flow.MutableStateFlow27import kotlinx.coroutines.flow.SharingStarted28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.flow.stateIn31import kotlinx.coroutines.launch32import javax.inject.Inject3334/**35 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.36 *37 * Trigger conditions (browser `sync-trigger.ts` parity):38 *   1. Vault mutation — a local add/update/delete should schedule a sync.39 *   2. App foreground — reconnect to nearby peers on resume.40 *   3. Manual — the "Sync now" control on the Sync card.41 *   4. mDNS peer discovery — a paired device appears on the LAN.42 *43 * Discovery, the WebSocket server role, and the paired-device store land with44 * the pairing UI (Phase 10); [syncWith] already performs a real initiator round45 * against a known endpoint so those wirings have a finished target to call.46 */47@HiltViewModel48class SyncViewModel @Inject constructor(49    @ApplicationContext private val context: Context,50    private val vault: VaultRepository,51    private val deviceStore: PairedDeviceStore,52    private val pendingVouchedStore: PendingVouchedDeviceStore,53    private val discoveryClient: PairingDiscoveryClient,54    private val syncAlertStore: SyncAlertStore,55) : ViewModel() {5657    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)58    val status: StateFlow<SyncStatus> = _status.asStateFlow()5960    /** Live list of trusted peers, backing the Sync settings card device list (C7). */61    val pairedDevices: StateFlow<List<PairedDevice>> =62        deviceStore.observeAll()63            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6465    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =66        pendingVouchedStore.observeAll()67            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6869    /** Forget a paired device — it can no longer be dialled for sync. */70    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }7172    fun confirmVouchedDevice(id: String) = viewModelScope.launch {73        val pending = pendingVouchedStore.get(id) ?: return@launch74        deviceStore.upsert(75            PairedDevice(76                deviceId = pending.deviceId,77                deviceName = pending.deviceName,78                deviceType = pending.deviceType,79                longTermPublicKeyHex = pending.longTermPublicKeyHex,80                signingPublicKeyHex = pending.signingPublicKeyHex,81                trustSource = "vouched",82                wsPort = pending.wsPort,83                pairedAt = pending.pairedAt,84            ),85        )86        pendingVouchedStore.remove(id)87    }8889    fun rejectVouchedDevice(id: String) = viewModelScope.launch {90        pendingVouchedStore.remove(id)91    }9293    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */94    suspend fun rotateSyncIdentity(): Boolean {95        val ok = vault.rotateSyncIdentity()96        if (ok) {97            deviceStore.clear()98            _status.value = SyncStatus.Idle99        }100        return ok101    }102103    /** Adapter over the live vault; null while the vault is locked. */104    fun buildAdapter(): ChirpSyncVaultAdapter? {105        if (!vault.isUnlocked.value) return null106        return VaultSyncAdapter(107            repo = vault,108            ourDeviceId = getOrCreateDeviceId(context),109            pairedDevices = deviceStore,110            pendingVouched = pendingVouchedStore,111            syncAlerts = syncAlertStore,112        )113    }114115    /**116     * Manual trigger: discover each paired device on the LAN and run an initiator117     * sync round against it. Reports Offline when no peers are paired. mDNS118     * discovery + audio cannot be emulator-verified — device QA pending (C7).119     */120    fun syncNow() {121        if (!vault.isUnlocked.value) {122            _status.value = SyncStatus.Error("vault_locked")123            return124        }125        val peers = pairedDevices.value126        if (peers.isEmpty()) {127            _status.value = SyncStatus.Offline128            return129        }130        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }131        _status.value = SyncStatus.Syncing132        viewModelScope.launch {133            try {134                val identity = buildLocalIdentity(context, master)135                peers.forEach { dev ->136                    runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }137                }138                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())139            } catch (e: Exception) {140                _status.value = SyncStatus.Error(e.message ?: "sync_failed")141            } finally {142                VaultCrypto.zero(master)143            }144        }145    }146147    /**148     * Legacy manual / discovery trigger kept for existing callers; now routes149     * through [syncNow] so a populated device store actually dials peers.150     */151    fun triggerSync() = syncNow()152153    /** Run one initiator round against a known peer endpoint. */154    fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {155        val master = vault.syncMasterKey() ?: run {156            _status.value = SyncStatus.Error("vault_locked")157            return158        }159        val adapter = buildAdapter() ?: run {160            _status.value = SyncStatus.Error("vault_locked")161            master.fill(0)162            return163        }164        _status.value = SyncStatus.Syncing165        viewModelScope.launch {166            try {167                val deviceId = getOrCreateDeviceId(context)168                val identity = deriveSyncIdentityKeypair(master, deviceId)169                val session = openChirpSyncSession(170                    endpoint = endpoint,171                    ourLongTermPrivateKey = identity.secretKey,172                    ourLongTermPublicKey = identity.publicKey,173                    expectedRemotePubkeyHex = expectedRemotePubkeyHex,174                )175                val outcome = try {176                    runChirpSyncInitiator(session, adapter)177                } finally {178                    session.close()179                }180                _status.value = when (outcome.status) {181                    SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())182                    SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")183                    SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")184                    SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")185                }186            } catch (e: Exception) {187                _status.value = SyncStatus.Error(e.message ?: "sync_failed")188            } finally {189                VaultCrypto.zero(master)190            }191        }192    }193}