SyncViewModel.kt

7.7 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.SyncOutcomeStatus14import com.peachpasswords.sync.SyncStatus15import com.peachpasswords.sync.VaultSyncAdapter16import com.peachpasswords.sync.buildLocalIdentity17import com.peachpasswords.sync.deriveSyncIdentityKeypair18import com.peachpasswords.sync.getOrCreateDeviceId19import com.peachpasswords.sync.openChirpSyncSession20import com.peachpasswords.sync.runChirpSyncInitiator21import 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 land with43 * the pairing UI (Phase 10); [syncWith] already performs a real initiator round44 * against a known endpoint so those wirings have a finished target to call.45 */46@HiltViewModel47class SyncViewModel @Inject constructor(48    @ApplicationContext private val context: Context,49    private val vault: VaultRepository,50    private val deviceStore: PairedDeviceStore,51    private val pendingVouchedStore: PendingVouchedDeviceStore,52    private val discoveryClient: PairingDiscoveryClient,53) : ViewModel() {5455    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)56    val status: StateFlow<SyncStatus> = _status.asStateFlow()5758    /** Live list of trusted peers, backing the Sync settings card device list (C7). */59    val pairedDevices: StateFlow<List<PairedDevice>> =60        deviceStore.observeAll()61            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6263    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =64        pendingVouchedStore.observeAll()65            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6667    /** Forget a paired device — it can no longer be dialled for sync. */68    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }6970    fun confirmVouchedDevice(id: String) = viewModelScope.launch {71        val pending = pendingVouchedStore.get(id) ?: return@launch72        deviceStore.upsert(73            PairedDevice(74                deviceId = pending.deviceId,75                deviceName = pending.deviceName,76                deviceType = pending.deviceType,77                longTermPublicKeyHex = pending.longTermPublicKeyHex,78                signingPublicKeyHex = pending.signingPublicKeyHex,79                trustSource = "vouched",80                wsPort = pending.wsPort,81                pairedAt = pending.pairedAt,82            ),83        )84        pendingVouchedStore.remove(id)85    }8687    fun rejectVouchedDevice(id: String) = viewModelScope.launch {88        pendingVouchedStore.remove(id)89    }9091    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */92    suspend fun rotateSyncIdentity(): Boolean {93        val ok = vault.rotateSyncIdentity()94        if (ok) {95            deviceStore.clear()96            _status.value = SyncStatus.Idle97        }98        return ok99    }100101    /** Adapter over the live vault; null while the vault is locked. */102    fun buildAdapter(): ChirpSyncVaultAdapter? {103        if (!vault.isUnlocked.value) return null104        return VaultSyncAdapter(105            repo = vault,106            ourDeviceId = getOrCreateDeviceId(context),107            pairedDevices = deviceStore,108            pendingVouched = pendingVouchedStore,109        )110    }111112    /**113     * Manual trigger: discover each paired device on the LAN and run an initiator114     * sync round against it. Reports Offline when no peers are paired. mDNS115     * discovery + audio cannot be emulator-verified — device QA pending (C7).116     */117    fun syncNow() {118        if (!vault.isUnlocked.value) {119            _status.value = SyncStatus.Error("vault_locked")120            return121        }122        val peers = pairedDevices.value123        if (peers.isEmpty()) {124            _status.value = SyncStatus.Offline125            return126        }127        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }128        _status.value = SyncStatus.Syncing129        viewModelScope.launch {130            try {131                val identity = buildLocalIdentity(context, master)132                peers.forEach { dev ->133                    runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }134                }135                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())136            } catch (e: Exception) {137                _status.value = SyncStatus.Error(e.message ?: "sync_failed")138            } finally {139                VaultCrypto.zero(master)140            }141        }142    }143144    /**145     * Legacy manual / discovery trigger kept for existing callers; now routes146     * through [syncNow] so a populated device store actually dials peers.147     */148    fun triggerSync() = syncNow()149150    /** Run one initiator round against a known peer endpoint. */151    fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {152        val master = vault.syncMasterKey() ?: run {153            _status.value = SyncStatus.Error("vault_locked")154            return155        }156        val adapter = buildAdapter() ?: run {157            _status.value = SyncStatus.Error("vault_locked")158            master.fill(0)159            return160        }161        _status.value = SyncStatus.Syncing162        viewModelScope.launch {163            try {164                val deviceId = getOrCreateDeviceId(context)165                val identity = deriveSyncIdentityKeypair(master, deviceId)166                val session = openChirpSyncSession(167                    endpoint = endpoint,168                    ourLongTermPrivateKey = identity.secretKey,169                    ourLongTermPublicKey = identity.publicKey,170                    expectedRemotePubkeyHex = expectedRemotePubkeyHex,171                )172                val outcome = try {173                    runChirpSyncInitiator(session, adapter)174                } finally {175                    session.close()176                }177                _status.value = when (outcome.status) {178                    SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())179                    SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")180                    SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")181                    SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")182                }183            } catch (e: Exception) {184                _status.value = SyncStatus.Error(e.message ?: "sync_failed")185            } finally {186                VaultCrypto.zero(master)187            }188        }189    }190}