SyncViewModel.kt

6.2 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    /** Adapter over the live vault; null while the vault is locked. */64    fun buildAdapter(): ChirpSyncVaultAdapter? {65        if (!vault.isUnlocked.value) return null66        return VaultSyncAdapter(vault, getOrCreateDeviceId(context))67    }6869    /**70     * Manual trigger: discover each paired device on the LAN and run an initiator71     * sync round against it. Reports Offline when no peers are paired. mDNS72     * discovery + audio cannot be emulator-verified — device QA pending (C7).73     */74    fun syncNow() {75        if (!vault.isUnlocked.value) {76            _status.value = SyncStatus.Error("vault_locked")77            return78        }79        val peers = pairedDevices.value80        if (peers.isEmpty()) {81            _status.value = SyncStatus.Offline82            return83        }84        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }85        _status.value = SyncStatus.Syncing86        viewModelScope.launch {87            try {88                val identity = buildLocalIdentity(context, master)89                peers.forEach { dev ->90                    runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }91                }92                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())93            } catch (e: Exception) {94                _status.value = SyncStatus.Error(e.message ?: "sync_failed")95            } finally {96                VaultCrypto.zero(master)97            }98        }99    }100101    /**102     * Legacy manual / discovery trigger kept for existing callers; now routes103     * through [syncNow] so a populated device store actually dials peers.104     */105    fun triggerSync() = syncNow()106107    /** Run one initiator round against a known peer endpoint. */108    fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {109        val master = vault.syncMasterKey() ?: run {110            _status.value = SyncStatus.Error("vault_locked")111            return112        }113        val adapter = buildAdapter() ?: run {114            _status.value = SyncStatus.Error("vault_locked")115            master.fill(0)116            return117        }118        _status.value = SyncStatus.Syncing119        viewModelScope.launch {120            try {121                val deviceId = getOrCreateDeviceId(context)122                val identity = deriveSyncIdentityKeypair(master, deviceId)123                val session = openChirpSyncSession(124                    endpoint = endpoint,125                    ourLongTermPrivateKey = identity.secretKey,126                    ourLongTermPublicKey = identity.publicKey,127                    expectedRemotePubkeyHex = expectedRemotePubkeyHex,128                )129                val outcome = try {130                    runChirpSyncInitiator(session, adapter)131                } finally {132                    session.close()133                }134                _status.value = when (outcome.status) {135                    SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())136                    SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")137                    SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")138                    SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")139                }140            } catch (e: Exception) {141                _status.value = SyncStatus.Error(e.message ?: "sync_failed")142            } finally {143                VaultCrypto.zero(master)144            }145        }146    }147}