SyncViewModel.kt

6.5 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.PairingDiscoveryClient10import com.peachpasswords.sync.SyncEndpoint11import com.peachpasswords.sync.SyncOutcomeStatus12import com.peachpasswords.sync.SyncStatus13import com.peachpasswords.sync.VaultSyncAdapter14import com.peachpasswords.sync.buildLocalIdentity15import com.peachpasswords.sync.deriveSyncIdentityKeypair16import com.peachpasswords.sync.getOrCreateDeviceId17import com.peachpasswords.sync.openChirpSyncSession18import com.peachpasswords.sync.runChirpSyncInitiator19import com.peachpasswords.vault.VaultCrypto20import com.peachpasswords.vault.VaultRepository21import dagger.hilt.android.lifecycle.HiltViewModel22import dagger.hilt.android.qualifiers.ApplicationContext23import kotlinx.coroutines.flow.MutableStateFlow24import kotlinx.coroutines.flow.SharingStarted25import kotlinx.coroutines.flow.StateFlow26import kotlinx.coroutines.flow.asStateFlow27import kotlinx.coroutines.flow.stateIn28import kotlinx.coroutines.launch29import javax.inject.Inject3031/**32 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.33 *34 * Trigger conditions (browser `sync-trigger.ts` parity):35 *   1. Vault mutation — a local add/update/delete should schedule a sync.36 *   2. App foreground — reconnect to nearby peers on resume.37 *   3. Manual — the "Sync now" control on the Sync card.38 *   4. mDNS peer discovery — a paired device appears on the LAN.39 *40 * Discovery, the WebSocket server role, and the paired-device store land with41 * the pairing UI (Phase 10); [syncWith] already performs a real initiator round42 * against a known endpoint so those wirings have a finished target to call.43 */44@HiltViewModel45class SyncViewModel @Inject constructor(46    @ApplicationContext private val context: Context,47    private val vault: VaultRepository,48    private val deviceStore: PairedDeviceStore,49    private val discoveryClient: PairingDiscoveryClient,50) : ViewModel() {5152    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)53    val status: StateFlow<SyncStatus> = _status.asStateFlow()5455    /** Live list of trusted peers, backing the Sync settings card device list (C7). */56    val pairedDevices: StateFlow<List<PairedDevice>> =57        deviceStore.observeAll()58            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())5960    /** Forget a paired device — it can no longer be dialled for sync. */61    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }6263    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */64    suspend fun rotateSyncIdentity(): Boolean {65        val ok = vault.rotateSyncIdentity()66        if (ok) {67            deviceStore.clear()68            _status.value = SyncStatus.Idle69        }70        return ok71    }7273    /** Adapter over the live vault; null while the vault is locked. */74    fun buildAdapter(): ChirpSyncVaultAdapter? {75        if (!vault.isUnlocked.value) return null76        return VaultSyncAdapter(vault, getOrCreateDeviceId(context))77    }7879    /**80     * Manual trigger: discover each paired device on the LAN and run an initiator81     * sync round against it. Reports Offline when no peers are paired. mDNS82     * discovery + audio cannot be emulator-verified — device QA pending (C7).83     */84    fun syncNow() {85        if (!vault.isUnlocked.value) {86            _status.value = SyncStatus.Error("vault_locked")87            return88        }89        val peers = pairedDevices.value90        if (peers.isEmpty()) {91            _status.value = SyncStatus.Offline92            return93        }94        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }95        _status.value = SyncStatus.Syncing96        viewModelScope.launch {97            try {98                val identity = buildLocalIdentity(context, master)99                peers.forEach { dev ->100                    runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }101                }102                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())103            } catch (e: Exception) {104                _status.value = SyncStatus.Error(e.message ?: "sync_failed")105            } finally {106                VaultCrypto.zero(master)107            }108        }109    }110111    /**112     * Legacy manual / discovery trigger kept for existing callers; now routes113     * through [syncNow] so a populated device store actually dials peers.114     */115    fun triggerSync() = syncNow()116117    /** Run one initiator round against a known peer endpoint. */118    fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {119        val master = vault.syncMasterKey() ?: run {120            _status.value = SyncStatus.Error("vault_locked")121            return122        }123        val adapter = buildAdapter() ?: run {124            _status.value = SyncStatus.Error("vault_locked")125            master.fill(0)126            return127        }128        _status.value = SyncStatus.Syncing129        viewModelScope.launch {130            try {131                val deviceId = getOrCreateDeviceId(context)132                val identity = deriveSyncIdentityKeypair(master, deviceId)133                val session = openChirpSyncSession(134                    endpoint = endpoint,135                    ourLongTermPrivateKey = identity.secretKey,136                    ourLongTermPublicKey = identity.publicKey,137                    expectedRemotePubkeyHex = expectedRemotePubkeyHex,138                )139                val outcome = try {140                    runChirpSyncInitiator(session, adapter)141                } finally {142                    session.close()143                }144                _status.value = when (outcome.status) {145                    SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())146                    SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")147                    SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")148                    SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")149                }150            } catch (e: Exception) {151                _status.value = SyncStatus.Error(e.message ?: "sync_failed")152            } finally {153                VaultCrypto.zero(master)154            }155        }156    }157}