SyncViewModel.kt

8.6 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        val normalized = relaySettings.setRelayUrl(input)75        deviceStore.setRelayUrlForAll(normalized)76        _relayUrl.value = normalized77    }.isSuccess7879    fun setLanOnly(on: Boolean) {80        relaySettings.setLanOnly(on)81        _lanOnly.value = on82    }8384    /** Live list of trusted peers, backing the Sync settings card device list (C7). */85    val pairedDevices: StateFlow<List<PairedDevice>> =86        deviceStore.observeAll()87            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8889    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =90        pendingVouchedStore.observeAll()91            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9293    /**94     * Revoke a paired device. Removing it REVOKES rather than just dropping the95     * local row: a plain removal is undone the moment any other peer vouches it96     * back through a manifest, which is exactly what the relay makes easy.97     */98    fun unpairDevice(id: String) = viewModelScope.launch {99        deviceStore.revoke(id, getOrCreateDeviceId(context))100    }101102    fun confirmVouchedDevice(id: String) = viewModelScope.launch {103        val pending = pendingVouchedStore.get(id) ?: return@launch104        deviceStore.upsert(105            PairedDevice(106                deviceId = pending.deviceId,107                deviceName = pending.deviceName,108                deviceType = pending.deviceType,109                longTermPublicKeyHex = pending.longTermPublicKeyHex,110                signingPublicKeyHex = pending.signingPublicKeyHex,111                trustSource = "vouched",112                wsPort = pending.wsPort,113                pairedAt = pending.pairedAt,114            ),115        )116        pendingVouchedStore.remove(id)117    }118119    fun rejectVouchedDevice(id: String) = viewModelScope.launch {120        pendingVouchedStore.remove(id)121    }122123    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */124    suspend fun rotateSyncIdentity(): Boolean {125        val ok = vault.rotateSyncIdentity()126        if (ok) {127            deviceStore.clear()128            _status.value = SyncStatus.Idle129        }130        return ok131    }132133    /** Adapter over the live vault; null while the vault is locked. */134    fun buildAdapter(): ChirpSyncVaultAdapter? {135        if (!vault.isUnlocked.value) return null136        return VaultSyncAdapter(137            repo = vault,138            ourDeviceId = getOrCreateDeviceId(context),139            pairedDevices = deviceStore,140            pendingVouched = pendingVouchedStore,141            syncAlerts = syncAlertStore,142        )143    }144145    /**146     * Manual trigger: discover each paired device on the LAN and run an initiator147     * sync round against it. Reports Offline when no peers are paired. mDNS148     * discovery + audio cannot be emulator-verified — device QA pending (C7).149     */150    fun syncNow() {151        if (!vault.isUnlocked.value) {152            _status.value = SyncStatus.Error("vault_locked")153            return154        }155        val peers = pairedDevices.value156        if (peers.isEmpty()) {157            _status.value = SyncStatus.Offline158            return159        }160        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }161        _status.value = SyncStatus.Syncing162        viewModelScope.launch {163            try {164                val identity = buildLocalIdentity(context, master)165                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)166                try {167                    peers.forEach { dev ->168                        // The listener refreshes lastKnownIp on every inbound169                        // reconnect, so this is usually still valid and saves a170                        // discovery round — and works when mDNS does not.171                        val cached = dev.lastKnownIp?.let { ip ->172                            DiscoveredPeer(173                                deviceId = dev.deviceId,174                                deviceName = dev.deviceName ?: dev.deviceId,175                                host = ip,176                                port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,177                                remoteStaticPublicKey = dev.longTermPublicKeyHex178                                    ?.takeIf { it.length == 64 }179                                    ?.let(::hexToPubkey)180                                    ?: return@forEach,181                            )182                        }183                        runCatching {184                            discoveryClient.syncWithKnownDevice(185                                deviceId = dev.deviceId,186                                localIdentity = identity,187                                localStaticPrivateKey = localKeys.secretKey,188                                vaultFactory = ::buildAdapter,189                                cached = cached,190                            )191                        }192                    }193                } finally {194                    VaultCrypto.zero(localKeys.secretKey)195                }196                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())197            } catch (e: Exception) {198                _status.value = SyncStatus.Error(e.message ?: "sync_failed")199            } finally {200                VaultCrypto.zero(master)201            }202        }203    }204205    /**206     * Legacy manual / discovery trigger kept for existing callers; now routes207     * through [syncNow] so a populated device store actually dials peers.208     */209    fun triggerSync() = syncNow()210211}