SyncViewModel.kt

8.4 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    /** Forget a paired device — it can no longer be dialled for sync. */94    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }9596    fun confirmVouchedDevice(id: String) = viewModelScope.launch {97        val pending = pendingVouchedStore.get(id) ?: return@launch98        deviceStore.upsert(99            PairedDevice(100                deviceId = pending.deviceId,101                deviceName = pending.deviceName,102                deviceType = pending.deviceType,103                longTermPublicKeyHex = pending.longTermPublicKeyHex,104                signingPublicKeyHex = pending.signingPublicKeyHex,105                trustSource = "vouched",106                wsPort = pending.wsPort,107                pairedAt = pending.pairedAt,108            ),109        )110        pendingVouchedStore.remove(id)111    }112113    fun rejectVouchedDevice(id: String) = viewModelScope.launch {114        pendingVouchedStore.remove(id)115    }116117    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */118    suspend fun rotateSyncIdentity(): Boolean {119        val ok = vault.rotateSyncIdentity()120        if (ok) {121            deviceStore.clear()122            _status.value = SyncStatus.Idle123        }124        return ok125    }126127    /** Adapter over the live vault; null while the vault is locked. */128    fun buildAdapter(): ChirpSyncVaultAdapter? {129        if (!vault.isUnlocked.value) return null130        return VaultSyncAdapter(131            repo = vault,132            ourDeviceId = getOrCreateDeviceId(context),133            pairedDevices = deviceStore,134            pendingVouched = pendingVouchedStore,135            syncAlerts = syncAlertStore,136        )137    }138139    /**140     * Manual trigger: discover each paired device on the LAN and run an initiator141     * sync round against it. Reports Offline when no peers are paired. mDNS142     * discovery + audio cannot be emulator-verified — device QA pending (C7).143     */144    fun syncNow() {145        if (!vault.isUnlocked.value) {146            _status.value = SyncStatus.Error("vault_locked")147            return148        }149        val peers = pairedDevices.value150        if (peers.isEmpty()) {151            _status.value = SyncStatus.Offline152            return153        }154        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }155        _status.value = SyncStatus.Syncing156        viewModelScope.launch {157            try {158                val identity = buildLocalIdentity(context, master)159                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)160                try {161                    peers.forEach { dev ->162                        // The listener refreshes lastKnownIp on every inbound163                        // reconnect, so this is usually still valid and saves a164                        // discovery round — and works when mDNS does not.165                        val cached = dev.lastKnownIp?.let { ip ->166                            DiscoveredPeer(167                                deviceId = dev.deviceId,168                                deviceName = dev.deviceName ?: dev.deviceId,169                                host = ip,170                                port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,171                                remoteStaticPublicKey = dev.longTermPublicKeyHex172                                    ?.takeIf { it.length == 64 }173                                    ?.let(::hexToPubkey)174                                    ?: return@forEach,175                            )176                        }177                        runCatching {178                            discoveryClient.syncWithKnownDevice(179                                deviceId = dev.deviceId,180                                localIdentity = identity,181                                localStaticPrivateKey = localKeys.secretKey,182                                vaultFactory = ::buildAdapter,183                                cached = cached,184                            )185                        }186                    }187                } finally {188                    VaultCrypto.zero(localKeys.secretKey)189                }190                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())191            } catch (e: Exception) {192                _status.value = SyncStatus.Error(e.message ?: "sync_failed")193            } finally {194                VaultCrypto.zero(master)195            }196        }197    }198199    /**200     * Legacy manual / discovery trigger kept for existing callers; now routes201     * through [syncNow] so a populated device store actually dials peers.202     */203    fun triggerSync() = syncNow()204205}