SyncViewModel.kt

9.5 KB · raw

1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.SyncVaultAdapter7import com.peachpasswords.sync.PairedDevice8import com.peachpasswords.sync.PairedDeviceStore9import com.peachpasswords.sync.PendingVouchedDevice10import com.peachpasswords.sync.PendingVouchedDeviceStore11import com.peachpasswords.sync.hexToPubkey12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelayDecision14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.SyncMode16import com.peachpasswords.sync.SyncAlertStore17import com.peachpasswords.sync.SyncOutcomeReason18import com.peachpasswords.sync.SyncOutcomeStore19import com.peachpasswords.sync.SyncOutcomeSummary20import com.peachpasswords.sync.VaultSyncAdapter21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import dagger.hilt.android.lifecycle.HiltViewModel27import dagger.hilt.android.qualifiers.ApplicationContext28import kotlinx.coroutines.flow.MutableStateFlow29import kotlinx.coroutines.flow.SharingStarted30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.flow.stateIn33import kotlinx.coroutines.delay34import kotlinx.coroutines.Job35import kotlinx.coroutines.launch36import javax.inject.Inject3738/**39 * Backs the Sync settings card: relay mode, the paired-device list, and the40 * manual sync control.41 *42 * Sync itself runs in [PeachSyncService], which holds a socket in each paired43 * peer's rendezvous room. Which side opens the conversation there depends on44 * the peer: we wait for a browser extension, because an extension can only45 * dial, and for another phone the lower long-term key dials. So46 * "sync now" makes sure the service is up and asks it to re-read the device47 * list — the work happens there, not here. See docs/sync-lan-retirement.md.48 */49@HiltViewModel50class SyncViewModel @Inject constructor(51    @ApplicationContext private val context: Context,52    private val vault: VaultRepository,53    private val deviceStore: PairedDeviceStore,54    private val pendingVouchedStore: PendingVouchedDeviceStore,55    private val syncAlertStore: SyncAlertStore,56    private val relaySettings: RelaySettingsStore,57    private val syncOutcomes: SyncOutcomeStore,58) : ViewModel() {5960    /** The last sync attempt, whatever it was. Survives process death. */61    val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6263    /** Blank while no private relay is configured, so the field stays honest. */64    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())65    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()666768    private val _syncMode = MutableStateFlow(relaySettings.syncMode())69    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7071    fun setSyncMode(mode: SyncMode) {72        relaySettings.setSyncMode(mode)73        _syncMode.value = relaySettings.syncMode()74        _privateRelayUrl.value = relaySettings.privateRelayUrl()75    }7677    /** A custom URL implies private mode. Returns false on an invalid URL. */78    fun setRelayUrl(input: String): Boolean = runCatching {79        relaySettings.setRelayUrl(input)80        _privateRelayUrl.value = relaySettings.privateRelayUrl()81        _syncMode.value = relaySettings.syncMode()82        true83    }.getOrDefault(false)848586    /** Live list of trusted peers, backing the Sync settings card device list (C7). */87    val pairedDevices: StateFlow<List<PairedDevice>> =88        deviceStore.observeAll()89            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9091    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =92        pendingVouchedStore.observeAll()93            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9495    /**96     * Revoke a paired device. Removing it REVOKES rather than just dropping the97     * local row: a plain removal is undone the moment any other peer vouches it98     * back through a manifest, which is exactly what the relay makes easy.99     */100    fun unpairDevice(id: String) = viewModelScope.launch {101        deviceStore.revoke(id, getOrCreateDeviceId(context))102    }103104    fun confirmVouchedDevice(id: String) = viewModelScope.launch {105        val pending = pendingVouchedStore.get(id) ?: return@launch106        // Last gate before a vouched device becomes a sync peer. A queue entry107        // can predate the revocation that names it, so revocation is re-checked108        // here rather than trusted to have swept the queue.109        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {110            pendingVouchedStore.remove(id)111            return@launch112        }113        deviceStore.upsert(114            PairedDevice(115                deviceId = pending.deviceId,116                deviceName = pending.deviceName,117                deviceType = pending.deviceType,118                longTermPublicKeyHex = pending.longTermPublicKeyHex,119                signingPublicKeyHex = pending.signingPublicKeyHex,120                trustSource = "vouched",121                wsPort = pending.wsPort,122                pairedAt = pending.pairedAt,123            ),124        )125        pendingVouchedStore.remove(id)126    }127128    fun rejectVouchedDevice(id: String) = viewModelScope.launch {129        pendingVouchedStore.remove(id)130    }131132    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */133    suspend fun rotateSyncIdentity(): Boolean {134        val ok = vault.rotateSyncIdentity()135        if (ok) {136            deviceStore.clear()137            syncOutcomes.reset()138        }139        return ok140    }141142    /** Adapter over the live vault; null while the vault is locked. */143    fun buildAdapter(): SyncVaultAdapter? {144        if (!vault.isUnlocked.value) return null145        return VaultSyncAdapter(146            repo = vault,147            ourDeviceId = getOrCreateDeviceId(context),148            pairedDevices = deviceStore,149            pendingVouched = pendingVouchedStore,150            syncAlerts = syncAlertStore,151        )152    }153154    /**155     * Manual trigger, and the one place a sync attempt is started on purpose.156     *157     * It used to claim a completed sync the instant the service was158     * asked to start, which is a success it had not earned — the service only159     * re-reads the device list, and the sync itself happens later in a160     * rendezvous room or not at all. Now the attempt goes busy and the outcome161     * comes from whoever actually finishes it.162     */163    fun syncNow() {164        val decision = relaySettings.relayInForce()165        if (decision is RelayDecision.Refuse) {166            syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))167            return168        }169        if (!vault.isUnlocked.value) {170            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))171            return172        }173        // Read the store, not `pairedDevices.value`: that flow only holds a real174        // list while something is collecting it, so pull-to-refresh on the vault175        // list — which collects `status` and nothing else — always saw an empty176        // list and reported "no devices paired" against a fully paired phone.177        val devices = deviceStore.getAll()178        if (devices.isEmpty()) {179            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))180            return181        }182        // The same filter CloudRelayReconnectListener applies when it decides183        // which rooms to hold — a peer with no rendezvous secret is skipped184        // there in silence, which is precisely the case that must not look like185        // a successful no-op.186        if (devices.none { it.rendezvousSecretHex != null }) {187            syncOutcomes.report(188                SyncOutcomeSummary(189                    reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,190                    detail = devices.joinToString("; ") {191                        "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"192                    },193                ),194            )195            return196        }197198        syncOutcomes.begin()199        PeachSyncService.start(context)200        // Nothing else can close this attempt: the phone usually WAITS to be201        // dialled, so if no peer turns up there is no session to report a202        // failure from and the light would pulse for ever. Long enough to cover203        // a dial round-trip (DIAL_SESSION_TIMEOUT_MS), short enough to answer204        // the person holding the phone.205        attemptTimeout?.cancel()206        attemptTimeout = viewModelScope.launch {207            delay(SYNC_ATTEMPT_TIMEOUT_MS)208            if (syncOutcomes.summary.value.syncing) {209                syncOutcomes.report(210                    SyncOutcomeSummary(211                        reason = SyncOutcomeReason.RELAY_UNREACHABLE,212                        detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",213                    ),214                )215            }216        }217    }218219    /**220     * Legacy manual / discovery trigger kept for existing callers; now routes221     * through [syncNow] so a populated device store actually dials peers.222     */223    fun triggerSync() = syncNow()224225    private var attemptTimeout: Job? = null226227    private companion object {228        const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L229    }230}