SyncViewModel.kt

8.3 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.RelaySettingsStore13import com.peachpasswords.sync.SyncEndpoint14import com.peachpasswords.sync.SyncAlertStore15import com.peachpasswords.sync.SyncOutcomeStatus16import com.peachpasswords.sync.SyncStatus17import com.peachpasswords.sync.VaultSyncAdapter18import com.peachpasswords.sync.buildLocalIdentity19import com.peachpasswords.sync.deriveSyncIdentityKeypair20import com.peachpasswords.sync.getOrCreateDeviceId21import com.peachpasswords.sync.openChirpSyncSession22import com.peachpasswords.sync.runChirpSyncInitiator23import com.peachpasswords.vault.VaultCrypto24import com.peachpasswords.vault.VaultRepository25import dagger.hilt.android.lifecycle.HiltViewModel26import dagger.hilt.android.qualifiers.ApplicationContext27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.SharingStarted29import kotlinx.coroutines.flow.StateFlow30import kotlinx.coroutines.flow.asStateFlow31import kotlinx.coroutines.flow.stateIn32import kotlinx.coroutines.launch33import javax.inject.Inject3435/**36 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.37 *38 * Trigger conditions (browser `sync-trigger.ts` parity):39 *   1. Vault mutation — a local add/update/delete should schedule a sync.40 *   2. App foreground — reconnect to nearby peers on resume.41 *   3. Manual — the "Sync now" control on the Sync card.42 *   4. mDNS peer discovery — a paired device appears on the LAN.43 *44 * Discovery, the WebSocket server role, and the paired-device store land with45 * the pairing UI (Phase 10); [syncWith] already performs a real initiator round46 * against a known endpoint so those wirings have a finished target to call.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 discoveryClient: PairingDiscoveryClient,55    private val syncAlertStore: SyncAlertStore,56    private val relaySettings: RelaySettingsStore,57) : ViewModel() {5859    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)60    val status: StateFlow<SyncStatus> = _status.asStateFlow()6162    /** Whether this phone forwards sync traffic between other paired devices. */63    private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())64    val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()6566    fun setRelayEnabled(on: Boolean) {67        relaySettings.setRelayEnabled(on)68        _relayEnabled.value = on69    }7071    /** Live list of trusted peers, backing the Sync settings card device list (C7). */72    val pairedDevices: StateFlow<List<PairedDevice>> =73        deviceStore.observeAll()74            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())7576    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =77        pendingVouchedStore.observeAll()78            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())7980    /** Forget a paired device — it can no longer be dialled for sync. */81    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }8283    fun confirmVouchedDevice(id: String) = viewModelScope.launch {84        val pending = pendingVouchedStore.get(id) ?: return@launch85        deviceStore.upsert(86            PairedDevice(87                deviceId = pending.deviceId,88                deviceName = pending.deviceName,89                deviceType = pending.deviceType,90                longTermPublicKeyHex = pending.longTermPublicKeyHex,91                signingPublicKeyHex = pending.signingPublicKeyHex,92                trustSource = "vouched",93                wsPort = pending.wsPort,94                pairedAt = pending.pairedAt,95            ),96        )97        pendingVouchedStore.remove(id)98    }99100    fun rejectVouchedDevice(id: String) = viewModelScope.launch {101        pendingVouchedStore.remove(id)102    }103104    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */105    suspend fun rotateSyncIdentity(): Boolean {106        val ok = vault.rotateSyncIdentity()107        if (ok) {108            deviceStore.clear()109            _status.value = SyncStatus.Idle110        }111        return ok112    }113114    /** Adapter over the live vault; null while the vault is locked. */115    fun buildAdapter(): ChirpSyncVaultAdapter? {116        if (!vault.isUnlocked.value) return null117        return VaultSyncAdapter(118            repo = vault,119            ourDeviceId = getOrCreateDeviceId(context),120            pairedDevices = deviceStore,121            pendingVouched = pendingVouchedStore,122            syncAlerts = syncAlertStore,123        )124    }125126    /**127     * Manual trigger: discover each paired device on the LAN and run an initiator128     * sync round against it. Reports Offline when no peers are paired. mDNS129     * discovery + audio cannot be emulator-verified — device QA pending (C7).130     */131    fun syncNow() {132        if (!vault.isUnlocked.value) {133            _status.value = SyncStatus.Error("vault_locked")134            return135        }136        val peers = pairedDevices.value137        if (peers.isEmpty()) {138            _status.value = SyncStatus.Offline139            return140        }141        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }142        _status.value = SyncStatus.Syncing143        viewModelScope.launch {144            try {145                val identity = buildLocalIdentity(context, master)146                peers.forEach { dev ->147                    runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }148                }149                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())150            } catch (e: Exception) {151                _status.value = SyncStatus.Error(e.message ?: "sync_failed")152            } finally {153                VaultCrypto.zero(master)154            }155        }156    }157158    /**159     * Legacy manual / discovery trigger kept for existing callers; now routes160     * through [syncNow] so a populated device store actually dials peers.161     */162    fun triggerSync() = syncNow()163164    /** Run one initiator round against a known peer endpoint. */165    fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {166        val master = vault.syncMasterKey() ?: run {167            _status.value = SyncStatus.Error("vault_locked")168            return169        }170        val adapter = buildAdapter() ?: run {171            _status.value = SyncStatus.Error("vault_locked")172            master.fill(0)173            return174        }175        _status.value = SyncStatus.Syncing176        viewModelScope.launch {177            try {178                val deviceId = getOrCreateDeviceId(context)179                val identity = deriveSyncIdentityKeypair(master, deviceId)180                val session = openChirpSyncSession(181                    endpoint = endpoint,182                    ourLongTermPrivateKey = identity.secretKey,183                    ourLongTermPublicKey = identity.publicKey,184                    expectedRemotePubkeyHex = expectedRemotePubkeyHex,185                )186                val outcome = try {187                    runChirpSyncInitiator(session, adapter)188                } finally {189                    session.close()190                }191                _status.value = when (outcome.status) {192                    SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())193                    SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")194                    SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")195                    SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")196                }197            } catch (e: Exception) {198                _status.value = SyncStatus.Error(e.message ?: "sync_failed")199            } finally {200                VaultCrypto.zero(master)201            }202        }203    }204}