SyncViewModel.kt

9.2 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.RelaySettingsStore14import com.peachpasswords.sync.SyncMode15import com.peachpasswords.sync.SyncAlertStore16import com.peachpasswords.sync.SyncOutcomeReason17import com.peachpasswords.sync.SyncOutcomeStore18import com.peachpasswords.sync.SyncOutcomeSummary19import com.peachpasswords.sync.VaultSyncAdapter20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.deriveLocalSyncKeypair22import com.peachpasswords.sync.getOrCreateDeviceId23import com.peachpasswords.vault.VaultCrypto24import com.peachpasswords.vault.VaultRepository25import dagger.hilt.android.lifecycle.HiltViewModel26import dagger.hilt.android.qualifiers.ApplicationContext27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.SharingStarted29import kotlinx.coroutines.flow.StateFlow30import kotlinx.coroutines.flow.asStateFlow31import kotlinx.coroutines.flow.stateIn32import kotlinx.coroutines.delay33import kotlinx.coroutines.Job34import kotlinx.coroutines.launch35import javax.inject.Inject3637/**38 * Backs the Sync settings card: relay mode, the paired-device list, and the39 * manual sync control.40 *41 * Sync itself runs in [PeachSyncService], which holds a socket in each paired42 * peer's rendezvous room. Which side opens the conversation there depends on43 * the peer: we wait for a browser extension, because an extension can only44 * dial, and for another phone the lower long-term key dials. So45 * "sync now" makes sure the service is up and asks it to re-read the device46 * list — the work happens there, not here. See docs/sync-lan-retirement.md.47 */48@HiltViewModel49class SyncViewModel @Inject constructor(50    @ApplicationContext private val context: Context,51    private val vault: VaultRepository,52    private val deviceStore: PairedDeviceStore,53    private val pendingVouchedStore: PendingVouchedDeviceStore,54    private val syncAlertStore: SyncAlertStore,55    private val relaySettings: RelaySettingsStore,56    private val syncOutcomes: SyncOutcomeStore,57) : ViewModel() {5859    /** The last sync attempt, whatever it was. Survives process death. */60    val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6162    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())63    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()646566    private val _syncMode = MutableStateFlow(relaySettings.syncMode())67    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()6869    fun setSyncMode(mode: SyncMode) {70        relaySettings.setSyncMode(mode)71        _syncMode.value = relaySettings.syncMode()72        _relayUrl.value = relaySettings.relayUrl()73    }7475    /** Saving a URL implies private mode. Returns false on an invalid URL. */76    fun setPrivateRelayUrl(input: String): Boolean = runCatching {77        _relayUrl.value = relaySettings.setPrivateRelayUrl(input)78        _syncMode.value = relaySettings.syncMode()79        true80    }.getOrDefault(false)818283    /** Live list of trusted peers, backing the Sync settings card device list (C7). */84    val pairedDevices: StateFlow<List<PairedDevice>> =85        deviceStore.observeAll()86            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8788    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =89        pendingVouchedStore.observeAll()90            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9192    /**93     * Revoke a paired device. Removing it REVOKES rather than just dropping the94     * local row: a plain removal is undone the moment any other peer vouches it95     * back through a manifest, which is exactly what the relay makes easy.96     */97    fun unpairDevice(id: String) = viewModelScope.launch {98        deviceStore.revoke(id, getOrCreateDeviceId(context))99    }100101    fun confirmVouchedDevice(id: String) = viewModelScope.launch {102        val pending = pendingVouchedStore.get(id) ?: return@launch103        // Last gate before a vouched device becomes a sync peer. A queue entry104        // can predate the revocation that names it, so revocation is re-checked105        // here rather than trusted to have swept the queue.106        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {107            pendingVouchedStore.remove(id)108            return@launch109        }110        deviceStore.upsert(111            PairedDevice(112                deviceId = pending.deviceId,113                deviceName = pending.deviceName,114                deviceType = pending.deviceType,115                longTermPublicKeyHex = pending.longTermPublicKeyHex,116                signingPublicKeyHex = pending.signingPublicKeyHex,117                trustSource = "vouched",118                wsPort = pending.wsPort,119                pairedAt = pending.pairedAt,120            ),121        )122        pendingVouchedStore.remove(id)123    }124125    fun rejectVouchedDevice(id: String) = viewModelScope.launch {126        pendingVouchedStore.remove(id)127    }128129    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */130    suspend fun rotateSyncIdentity(): Boolean {131        val ok = vault.rotateSyncIdentity()132        if (ok) {133            deviceStore.clear()134            syncOutcomes.reset()135        }136        return ok137    }138139    /** Adapter over the live vault; null while the vault is locked. */140    fun buildAdapter(): SyncVaultAdapter? {141        if (!vault.isUnlocked.value) return null142        return VaultSyncAdapter(143            repo = vault,144            ourDeviceId = getOrCreateDeviceId(context),145            pairedDevices = deviceStore,146            pendingVouched = pendingVouchedStore,147            syncAlerts = syncAlertStore,148        )149    }150151    /**152     * Manual trigger, and the one place a sync attempt is started on purpose.153     *154     * It used to claim a completed sync the instant the service was155     * asked to start, which is a success it had not earned — the service only156     * re-reads the device list, and the sync itself happens later in a157     * rendezvous room or not at all. Now the attempt goes busy and the outcome158     * comes from whoever actually finishes it.159     */160    fun syncNow() {161        if (!relaySettings.isSyncEnabled()) {162            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED))163            return164        }165        if (!vault.isUnlocked.value) {166            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))167            return168        }169        // Read the store, not `pairedDevices.value`: that flow only holds a real170        // list while something is collecting it, so pull-to-refresh on the vault171        // list — which collects `status` and nothing else — always saw an empty172        // list and reported "no devices paired" against a fully paired phone.173        val devices = deviceStore.getAll()174        if (devices.isEmpty()) {175            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))176            return177        }178        // The same filter CloudRelayReconnectListener applies when it decides179        // which rooms to hold — a peer with no rendezvous secret is skipped180        // there in silence, which is precisely the case that must not look like181        // a successful no-op.182        if (devices.none { it.rendezvousSecretHex != null }) {183            syncOutcomes.report(184                SyncOutcomeSummary(185                    reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,186                    detail = devices.joinToString("; ") {187                        "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"188                    },189                ),190            )191            return192        }193194        syncOutcomes.begin()195        PeachSyncService.start(context)196        // Nothing else can close this attempt: the phone usually WAITS to be197        // dialled, so if no peer turns up there is no session to report a198        // failure from and the light would pulse for ever. Long enough to cover199        // a dial round-trip (DIAL_SESSION_TIMEOUT_MS), short enough to answer200        // the person holding the phone.201        attemptTimeout?.cancel()202        attemptTimeout = viewModelScope.launch {203            delay(SYNC_ATTEMPT_TIMEOUT_MS)204            if (syncOutcomes.summary.value.syncing) {205                syncOutcomes.report(206                    SyncOutcomeSummary(207                        reason = SyncOutcomeReason.RELAY_UNREACHABLE,208                        detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",209                    ),210                )211            }212        }213    }214215    /**216     * Legacy manual / discovery trigger kept for existing callers; now routes217     * through [syncNow] so a populated device store actually dials peers.218     */219    fun triggerSync() = syncNow()220221    private var attemptTimeout: Job? = null222223    private companion object {224        const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L225    }226}