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.DiscoveredPeer12import com.peachpasswords.sync.SyncReconnectListener13import com.peachpasswords.sync.hexToPubkey14import com.peachpasswords.sync.PairingDiscoveryClient15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.SyncAlertStore17import com.peachpasswords.sync.SyncStatus18import com.peachpasswords.sync.VaultSyncAdapter19import com.peachpasswords.sync.buildLocalIdentity20import com.peachpasswords.sync.deriveLocalSyncKeypair21import com.peachpasswords.sync.getOrCreateDeviceId22import com.peachpasswords.vault.VaultCrypto23import com.peachpasswords.vault.VaultRepository24import dagger.hilt.android.lifecycle.HiltViewModel25import dagger.hilt.android.qualifiers.ApplicationContext26import kotlinx.coroutines.flow.MutableStateFlow27import kotlinx.coroutines.flow.SharingStarted28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.flow.stateIn31import kotlinx.coroutines.launch32import javax.inject.Inject3334/**35 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.36 *37 * Trigger conditions (browser `sync-trigger.ts` parity):38 *   1. Vault mutation — a local add/update/delete should schedule a sync.39 *   2. App foreground — reconnect to nearby peers on resume.40 *   3. Manual — the "Sync now" control on the Sync card.41 *   4. mDNS peer discovery — a paired device appears on the LAN.42 *43 * Discovery, the WebSocket server role, and the paired-device store are wired44 * through [PairingDiscoveryClient], the live Noise KK reconnect dial path.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    private val syncAlertStore: SyncAlertStore,54    private val relaySettings: RelaySettingsStore,55) : ViewModel() {5657    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)58    val status: StateFlow<SyncStatus> = _status.asStateFlow()5960    /** Whether this phone forwards sync traffic between other paired devices. */61    private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())62    val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()63    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())64    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()65    private val _lanOnly = MutableStateFlow(relaySettings.isLanOnly())66    val lanOnly: StateFlow<Boolean> = _lanOnly.asStateFlow()6768    fun setRelayEnabled(on: Boolean) {69        relaySettings.setRelayEnabled(on)70        _relayEnabled.value = on71    }7273    fun setRelayUrl(input: String): Boolean = runCatching {74        _relayUrl.value = relaySettings.setRelayUrl(input)75    }.isSuccess7677    fun setLanOnly(on: Boolean) {78        relaySettings.setLanOnly(on)79        _lanOnly.value = on80    }8182    /** Live list of trusted peers, backing the Sync settings card device list (C7). */83    val pairedDevices: StateFlow<List<PairedDevice>> =84        deviceStore.observeAll()85            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8687    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =88        pendingVouchedStore.observeAll()89            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9091    /** Forget a paired device — it can no longer be dialled for sync. */92    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }9394    fun confirmVouchedDevice(id: String) = viewModelScope.launch {95        val pending = pendingVouchedStore.get(id) ?: return@launch96        deviceStore.upsert(97            PairedDevice(98                deviceId = pending.deviceId,99                deviceName = pending.deviceName,100                deviceType = pending.deviceType,101                longTermPublicKeyHex = pending.longTermPublicKeyHex,102                signingPublicKeyHex = pending.signingPublicKeyHex,103                trustSource = "vouched",104                wsPort = pending.wsPort,105                pairedAt = pending.pairedAt,106            ),107        )108        pendingVouchedStore.remove(id)109    }110111    fun rejectVouchedDevice(id: String) = viewModelScope.launch {112        pendingVouchedStore.remove(id)113    }114115    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */116    suspend fun rotateSyncIdentity(): Boolean {117        val ok = vault.rotateSyncIdentity()118        if (ok) {119            deviceStore.clear()120            _status.value = SyncStatus.Idle121        }122        return ok123    }124125    /** Adapter over the live vault; null while the vault is locked. */126    fun buildAdapter(): ChirpSyncVaultAdapter? {127        if (!vault.isUnlocked.value) return null128        return VaultSyncAdapter(129            repo = vault,130            ourDeviceId = getOrCreateDeviceId(context),131            pairedDevices = deviceStore,132            pendingVouched = pendingVouchedStore,133            syncAlerts = syncAlertStore,134        )135    }136137    /**138     * Manual trigger: discover each paired device on the LAN and run an initiator139     * sync round against it. Reports Offline when no peers are paired. mDNS140     * discovery + audio cannot be emulator-verified — device QA pending (C7).141     */142    fun syncNow() {143        if (!vault.isUnlocked.value) {144            _status.value = SyncStatus.Error("vault_locked")145            return146        }147        val peers = pairedDevices.value148        if (peers.isEmpty()) {149            _status.value = SyncStatus.Offline150            return151        }152        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }153        _status.value = SyncStatus.Syncing154        viewModelScope.launch {155            try {156                val identity = buildLocalIdentity(context, master)157                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)158                try {159                    peers.forEach { dev ->160                        // The listener refreshes lastKnownIp on every inbound161                        // reconnect, so this is usually still valid and saves a162                        // discovery round — and works when mDNS does not.163                        val cached = dev.lastKnownIp?.let { ip ->164                            DiscoveredPeer(165                                deviceId = dev.deviceId,166                                deviceName = dev.deviceName ?: dev.deviceId,167                                host = ip,168                                port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,169                                remoteStaticPublicKey = dev.longTermPublicKeyHex170                                    ?.takeIf { it.length == 64 }171                                    ?.let(::hexToPubkey)172                                    ?: return@forEach,173                            )174                        }175                        runCatching {176                            discoveryClient.syncWithKnownDevice(177                                deviceId = dev.deviceId,178                                localIdentity = identity,179                                localStaticPrivateKey = localKeys.secretKey,180                                vaultFactory = ::buildAdapter,181                                cached = cached,182                            )183                        }184                    }185                } finally {186                    VaultCrypto.zero(localKeys.secretKey)187                }188                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())189            } catch (e: Exception) {190                _status.value = SyncStatus.Error(e.message ?: "sync_failed")191            } finally {192                VaultCrypto.zero(master)193            }194        }195    }196197    /**198     * Legacy manual / discovery trigger kept for existing callers; now routes199     * through [syncNow] so a populated device store actually dials peers.200     */201    fun triggerSync() = syncNow()202203}