SyncViewModel.kt

9.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.DiscoveredPeer12import com.peachpasswords.sync.SyncReconnectListener13import com.peachpasswords.sync.hexToPubkey14import com.peachpasswords.sync.PairingDiscoveryClient15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.SyncEndpoint17import com.peachpasswords.sync.SyncAlertStore18import com.peachpasswords.sync.SyncOutcomeStatus19import com.peachpasswords.sync.SyncStatus20import com.peachpasswords.sync.VaultSyncAdapter21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveSyncIdentityKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.sync.openChirpSyncSession25import com.peachpasswords.sync.runChirpSyncInitiator26import com.peachpasswords.vault.VaultCrypto27import com.peachpasswords.vault.VaultRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.SharingStarted32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.flow.stateIn35import kotlinx.coroutines.launch36import javax.inject.Inject3738/**39 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.40 *41 * Trigger conditions (browser `sync-trigger.ts` parity):42 *   1. Vault mutation — a local add/update/delete should schedule a sync.43 *   2. App foreground — reconnect to nearby peers on resume.44 *   3. Manual — the "Sync now" control on the Sync card.45 *   4. mDNS peer discovery — a paired device appears on the LAN.46 *47 * Discovery, the WebSocket server role, and the paired-device store land with48 * the pairing UI (Phase 10); [syncWith] already performs a real initiator round49 * against a known endpoint so those wirings have a finished target to call.50 */51@HiltViewModel52class SyncViewModel @Inject constructor(53    @ApplicationContext private val context: Context,54    private val vault: VaultRepository,55    private val deviceStore: PairedDeviceStore,56    private val pendingVouchedStore: PendingVouchedDeviceStore,57    private val discoveryClient: PairingDiscoveryClient,58    private val syncAlertStore: SyncAlertStore,59    private val relaySettings: RelaySettingsStore,60) : ViewModel() {6162    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)63    val status: StateFlow<SyncStatus> = _status.asStateFlow()6465    /** Whether this phone forwards sync traffic between other paired devices. */66    private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())67    val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()6869    fun setRelayEnabled(on: Boolean) {70        relaySettings.setRelayEnabled(on)71        _relayEnabled.value = on72    }7374    /** Live list of trusted peers, backing the Sync settings card device list (C7). */75    val pairedDevices: StateFlow<List<PairedDevice>> =76        deviceStore.observeAll()77            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())7879    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =80        pendingVouchedStore.observeAll()81            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8283    /** Forget a paired device — it can no longer be dialled for sync. */84    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }8586    fun confirmVouchedDevice(id: String) = viewModelScope.launch {87        val pending = pendingVouchedStore.get(id) ?: return@launch88        deviceStore.upsert(89            PairedDevice(90                deviceId = pending.deviceId,91                deviceName = pending.deviceName,92                deviceType = pending.deviceType,93                longTermPublicKeyHex = pending.longTermPublicKeyHex,94                signingPublicKeyHex = pending.signingPublicKeyHex,95                trustSource = "vouched",96                wsPort = pending.wsPort,97                pairedAt = pending.pairedAt,98            ),99        )100        pendingVouchedStore.remove(id)101    }102103    fun rejectVouchedDevice(id: String) = viewModelScope.launch {104        pendingVouchedStore.remove(id)105    }106107    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */108    suspend fun rotateSyncIdentity(): Boolean {109        val ok = vault.rotateSyncIdentity()110        if (ok) {111            deviceStore.clear()112            _status.value = SyncStatus.Idle113        }114        return ok115    }116117    /** Adapter over the live vault; null while the vault is locked. */118    fun buildAdapter(): ChirpSyncVaultAdapter? {119        if (!vault.isUnlocked.value) return null120        return VaultSyncAdapter(121            repo = vault,122            ourDeviceId = getOrCreateDeviceId(context),123            pairedDevices = deviceStore,124            pendingVouched = pendingVouchedStore,125            syncAlerts = syncAlertStore,126        )127    }128129    /**130     * Manual trigger: discover each paired device on the LAN and run an initiator131     * sync round against it. Reports Offline when no peers are paired. mDNS132     * discovery + audio cannot be emulator-verified — device QA pending (C7).133     */134    fun syncNow() {135        if (!vault.isUnlocked.value) {136            _status.value = SyncStatus.Error("vault_locked")137            return138        }139        val peers = pairedDevices.value140        if (peers.isEmpty()) {141            _status.value = SyncStatus.Offline142            return143        }144        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }145        _status.value = SyncStatus.Syncing146        viewModelScope.launch {147            try {148                val identity = buildLocalIdentity(context, master)149                peers.forEach { dev ->150                    // The listener refreshes lastKnownIp on every inbound151                    // reconnect, so this is usually still valid and saves a152                    // discovery round — and works when mDNS does not.153                    val cached = dev.lastKnownIp?.let { ip ->154                        DiscoveredPeer(155                            deviceId = dev.deviceId,156                            deviceName = dev.deviceName ?: dev.deviceId,157                            host = ip,158                            port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,159                            remoteStaticPublicKey = dev.longTermPublicKeyHex160                                ?.takeIf { it.length == 64 }161                                ?.let(::hexToPubkey)162                                ?: return@forEach,163                        )164                    }165                    runCatching {166                        discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter, cached = cached)167                    }168                }169                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())170            } catch (e: Exception) {171                _status.value = SyncStatus.Error(e.message ?: "sync_failed")172            } finally {173                VaultCrypto.zero(master)174            }175        }176    }177178    /**179     * Legacy manual / discovery trigger kept for existing callers; now routes180     * through [syncNow] so a populated device store actually dials peers.181     */182    fun triggerSync() = syncNow()183184    /** Run one initiator round against a known peer endpoint. */185    fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {186        val master = vault.syncMasterKey() ?: run {187            _status.value = SyncStatus.Error("vault_locked")188            return189        }190        val adapter = buildAdapter() ?: run {191            _status.value = SyncStatus.Error("vault_locked")192            master.fill(0)193            return194        }195        _status.value = SyncStatus.Syncing196        viewModelScope.launch {197            try {198                val deviceId = getOrCreateDeviceId(context)199                val identity = deriveSyncIdentityKeypair(master, deviceId)200                val session = openChirpSyncSession(201                    endpoint = endpoint,202                    ourLongTermPrivateKey = identity.secretKey,203                    ourLongTermPublicKey = identity.publicKey,204                    expectedRemotePubkeyHex = expectedRemotePubkeyHex,205                )206                val outcome = try {207                    runChirpSyncInitiator(session, adapter)208                } finally {209                    session.close()210                }211                _status.value = when (outcome.status) {212                    SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())213                    SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")214                    SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")215                    SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")216                }217            } catch (e: Exception) {218                _status.value = SyncStatus.Error(e.message ?: "sync_failed")219            } finally {220                VaultCrypto.zero(master)221            }222        }223    }224}