SyncViewModel.kt

7.7 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.getOrCreateDeviceId21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.SharingStarted27import kotlinx.coroutines.flow.StateFlow28import kotlinx.coroutines.flow.asStateFlow29import kotlinx.coroutines.flow.stateIn30import kotlinx.coroutines.launch31import javax.inject.Inject3233/**34 * Drives a Chirp Sync round and surfaces its status to the Sync settings card.35 *36 * Trigger conditions (browser `sync-trigger.ts` parity):37 *   1. Vault mutation — a local add/update/delete should schedule a sync.38 *   2. App foreground — reconnect to nearby peers on resume.39 *   3. Manual — the "Sync now" control on the Sync card.40 *   4. mDNS peer discovery — a paired device appears on the LAN.41 *42 * Discovery, the WebSocket server role, and the paired-device store are wired43 * through [PairingDiscoveryClient], the single live Noise NK dial path.44 */45@HiltViewModel46class SyncViewModel @Inject constructor(47    @ApplicationContext private val context: Context,48    private val vault: VaultRepository,49    private val deviceStore: PairedDeviceStore,50    private val pendingVouchedStore: PendingVouchedDeviceStore,51    private val discoveryClient: PairingDiscoveryClient,52    private val syncAlertStore: SyncAlertStore,53    private val relaySettings: RelaySettingsStore,54) : ViewModel() {5556    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)57    val status: StateFlow<SyncStatus> = _status.asStateFlow()5859    /** Whether this phone forwards sync traffic between other paired devices. */60    private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())61    val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()62    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())63    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()64    private val _lanOnly = MutableStateFlow(relaySettings.isLanOnly())65    val lanOnly: StateFlow<Boolean> = _lanOnly.asStateFlow()6667    fun setRelayEnabled(on: Boolean) {68        relaySettings.setRelayEnabled(on)69        _relayEnabled.value = on70    }7172    fun setRelayUrl(input: String): Boolean = runCatching {73        _relayUrl.value = relaySettings.setRelayUrl(input)74    }.isSuccess7576    fun setLanOnly(on: Boolean) {77        relaySettings.setLanOnly(on)78        _lanOnly.value = on79    }8081    /** Live list of trusted peers, backing the Sync settings card device list (C7). */82    val pairedDevices: StateFlow<List<PairedDevice>> =83        deviceStore.observeAll()84            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8586    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =87        pendingVouchedStore.observeAll()88            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8990    /** Forget a paired device — it can no longer be dialled for sync. */91    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }9293    fun confirmVouchedDevice(id: String) = viewModelScope.launch {94        val pending = pendingVouchedStore.get(id) ?: return@launch95        deviceStore.upsert(96            PairedDevice(97                deviceId = pending.deviceId,98                deviceName = pending.deviceName,99                deviceType = pending.deviceType,100                longTermPublicKeyHex = pending.longTermPublicKeyHex,101                signingPublicKeyHex = pending.signingPublicKeyHex,102                trustSource = "vouched",103                wsPort = pending.wsPort,104                pairedAt = pending.pairedAt,105            ),106        )107        pendingVouchedStore.remove(id)108    }109110    fun rejectVouchedDevice(id: String) = viewModelScope.launch {111        pendingVouchedStore.remove(id)112    }113114    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */115    suspend fun rotateSyncIdentity(): Boolean {116        val ok = vault.rotateSyncIdentity()117        if (ok) {118            deviceStore.clear()119            _status.value = SyncStatus.Idle120        }121        return ok122    }123124    /** Adapter over the live vault; null while the vault is locked. */125    fun buildAdapter(): ChirpSyncVaultAdapter? {126        if (!vault.isUnlocked.value) return null127        return VaultSyncAdapter(128            repo = vault,129            ourDeviceId = getOrCreateDeviceId(context),130            pairedDevices = deviceStore,131            pendingVouched = pendingVouchedStore,132            syncAlerts = syncAlertStore,133        )134    }135136    /**137     * Manual trigger: discover each paired device on the LAN and run an initiator138     * sync round against it. Reports Offline when no peers are paired. mDNS139     * discovery + audio cannot be emulator-verified — device QA pending (C7).140     */141    fun syncNow() {142        if (!vault.isUnlocked.value) {143            _status.value = SyncStatus.Error("vault_locked")144            return145        }146        val peers = pairedDevices.value147        if (peers.isEmpty()) {148            _status.value = SyncStatus.Offline149            return150        }151        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }152        _status.value = SyncStatus.Syncing153        viewModelScope.launch {154            try {155                val identity = buildLocalIdentity(context, master)156                peers.forEach { dev ->157                    // The listener refreshes lastKnownIp on every inbound158                    // reconnect, so this is usually still valid and saves a159                    // discovery round — and works when mDNS does not.160                    val cached = dev.lastKnownIp?.let { ip ->161                        DiscoveredPeer(162                            deviceId = dev.deviceId,163                            deviceName = dev.deviceName ?: dev.deviceId,164                            host = ip,165                            port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,166                            remoteStaticPublicKey = dev.longTermPublicKeyHex167                                ?.takeIf { it.length == 64 }168                                ?.let(::hexToPubkey)169                                ?: return@forEach,170                        )171                    }172                    runCatching {173                        discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter, cached = cached)174                    }175                }176                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())177            } catch (e: Exception) {178                _status.value = SyncStatus.Error(e.message ?: "sync_failed")179            } finally {180                VaultCrypto.zero(master)181            }182        }183    }184185    /**186     * Legacy manual / discovery trigger kept for existing callers; now routes187     * through [syncNow] so a populated device store actually dials peers.188     */189    fun triggerSync() = syncNow()190191}