SyncViewModel.kt

12.0 KB · raw

1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.BackgroundSyncCoordinator7import com.peachpasswords.sync.BackgroundSyncPreference8import com.peachpasswords.sync.SyncVaultAdapter9import com.peachpasswords.sync.PairedDevice10import com.peachpasswords.sync.PairedDeviceStore11import com.peachpasswords.sync.PendingVouchedDevice12import com.peachpasswords.sync.PendingVouchedDeviceStore13import com.peachpasswords.sync.hexToPubkey14import com.peachpasswords.sync.PeachSyncService15import com.peachpasswords.sync.RelayDecision16import com.peachpasswords.sync.RelaySettingsStore17import com.peachpasswords.sync.SyncMode18import com.peachpasswords.sync.SyncAlertStore19import com.peachpasswords.sync.SyncOutcomeReason20import com.peachpasswords.sync.SyncOutcomeStore21import com.peachpasswords.sync.SyncOutcomeSummary22import com.peachpasswords.sync.VaultSyncAdapter23import com.peachpasswords.sync.buildLocalIdentity24import com.peachpasswords.sync.deriveLocalSyncKeypair25import com.peachpasswords.sync.getOrCreateDeviceId26import com.peachpasswords.vault.VaultCrypto27import com.peachpasswords.vault.VaultRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.SharingStarted32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.flow.stateIn35import kotlinx.coroutines.delay36import kotlinx.coroutines.Job37import kotlinx.coroutines.launch38import javax.inject.Inject3940/**41 * Backs the Sync settings card: relay mode, the paired-device list, and the42 * manual sync control.43 *44 * Sync itself runs in [PeachSyncService], which holds a socket in each paired45 * peer's rendezvous room. Which side opens the conversation there depends on46 * the peer: we wait for a browser extension, because an extension can only47 * dial, and for another phone the lower long-term key dials. So48 * "sync now" makes sure the service is up and asks it to re-read the device49 * list — the work happens there, not here. See docs/sync-lan-retirement.md.50 */51@HiltViewModel52class SyncViewModel @Inject constructor(53    @ApplicationContext private val context: Context,54    private val vault: VaultRepository,55    private val deviceStore: PairedDeviceStore,56    private val pendingVouchedStore: PendingVouchedDeviceStore,57    private val syncAlertStore: SyncAlertStore,58    private val backgroundSync: BackgroundSyncPreference,59    private val relaySettings: RelaySettingsStore,60    private val syncOutcomes: SyncOutcomeStore,61) : ViewModel() {6263    /** The last sync attempt, whatever it was. Survives process death. */64    val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6566    val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled6768    /**69     * Decides when the sync service actually runs for the background-sync70     * preference. The service's own locked-handling is the gate — refusing to71     * start here while locked used to leave the preference on with no72     * notification, which was exactly the v2.5.13 "no peer answered" symptom.73     */74    private val backgroundSyncCoordinator = BackgroundSyncCoordinator(75        start = { PeachSyncService.start(context) },76        stop = { PeachSyncService.stop(context) },77        isUnlocked = vault.isUnlocked,78        preferenceEnabled = backgroundSync.enabled,79    )8081    init {82        // Catches the lock-then-unlock case: the user enables the setting while83        // locked, then unlocks — the service should come up the moment a sync84        // identity exists. StateFlow.collect fires the current value85        // immediately, so the already-unlocked case is covered too.86        backgroundSyncCoordinator.observe(viewModelScope)87    }8889    fun setBackgroundSyncEnabled(enabled: Boolean) {90        backgroundSync.setEnabled(enabled)91        backgroundSyncCoordinator.setEnabled(enabled)92    }9394    /** Blank while no private relay is configured, so the field stays honest. */95    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())96    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()979899    private val _syncMode = MutableStateFlow(relaySettings.syncMode())100    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()101102    fun setSyncMode(mode: SyncMode) {103        relaySettings.setSyncMode(mode)104        _syncMode.value = relaySettings.syncMode()105        _privateRelayUrl.value = relaySettings.privateRelayUrl()106    }107108    /** A custom URL implies private mode. Returns false on an invalid URL. */109    fun setRelayUrl(input: String): Boolean = runCatching {110        relaySettings.setRelayUrl(input)111        _privateRelayUrl.value = relaySettings.privateRelayUrl()112        _syncMode.value = relaySettings.syncMode()113        true114    }.getOrDefault(false)115116117    /** Live list of trusted peers, backing the Sync settings card device list (C7). */118    val pairedDevices: StateFlow<List<PairedDevice>> =119        deviceStore.observeAll()120            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())121122    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =123        pendingVouchedStore.observeAll()124            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())125126    /**127     * Revoke a paired device. Removing it REVOKES rather than just dropping the128     * local row: a plain removal is undone the moment any other peer vouches it129     * back through a manifest, which is exactly what the relay makes easy.130     */131    fun unpairDevice(id: String) = viewModelScope.launch {132        deviceStore.revoke(id, getOrCreateDeviceId(context))133        // Revocation is mesh state, not a vault-entry mutation. Wake the relay134        // service immediately so the signed revocation reaches every remaining135        // peer instead of waiting for a later foreground or periodic trigger.136        PeachSyncService.start(context)137    }138139    fun renameDevice(id: String, name: String) = viewModelScope.launch {140        deviceStore.rename(id, name)141    }142143    fun confirmVouchedDevice(id: String) = viewModelScope.launch {144        val pending = pendingVouchedStore.get(id) ?: return@launch145        // Last gate before a vouched device becomes a sync peer. A queue entry146        // can predate the revocation that names it, so revocation is re-checked147        // here rather than trusted to have swept the queue.148        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {149            pendingVouchedStore.removeBatch(setOf(id))150            return@launch151        }152        // Never let a stale queue item overwrite an already trusted row (in153        // particular a direct SAS-paired row). The queued introduction is154        // consumed, but the established trust record remains authoritative.155        if (deviceStore.get(pending.deviceId) != null) {156            pendingVouchedStore.removeBatch(setOf(id))157            return@launch158        }159        // Remove the pending row synchronously before publishing trust. If the160        // subsequent upsert fails, the safe outcome is a lost introduction,161        // not a restart that replays stale pending trust over a revocation.162        if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch163        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch164        deviceStore.upsert(165            PairedDevice(166                deviceId = pending.deviceId,167                deviceName = pending.deviceName,168                deviceType = pending.deviceType,169                longTermPublicKeyHex = pending.longTermPublicKeyHex,170                signingPublicKeyHex = pending.signingPublicKeyHex,171                trustSource = "vouched",172                wsPort = pending.wsPort,173                pairedAt = pending.pairedAt,174            ),175        )176    }177178    fun rejectVouchedDevice(id: String) = viewModelScope.launch {179        pendingVouchedStore.remove(id)180    }181182    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */183    suspend fun rotateSyncIdentity(): Boolean {184        val ok = vault.rotateSyncIdentity()185        if (ok) {186            deviceStore.clear()187            syncOutcomes.reset()188        }189        return ok190    }191192    /** Adapter over the live vault; null while the vault is locked. */193    fun buildAdapter(): SyncVaultAdapter? {194        if (!vault.isUnlocked.value) return null195        return VaultSyncAdapter(196            repo = vault,197            ourDeviceId = getOrCreateDeviceId(context),198            pairedDevices = deviceStore,199            pendingVouched = pendingVouchedStore,200            syncAlerts = syncAlertStore,201        )202    }203204    /**205     * Manual trigger, and the one place a sync attempt is started on purpose.206     *207     * It used to claim a completed sync the instant the service was208     * asked to start, which is a success it had not earned — the service only209     * re-reads the device list, and the sync itself happens later in a210     * rendezvous room or not at all. Now the attempt goes busy and the outcome211     * comes from whoever actually finishes it.212     */213    fun syncNow() {214        val decision = relaySettings.relayInForce()215        if (decision is RelayDecision.Refuse) {216            syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))217            return218        }219        if (!vault.isUnlocked.value) {220            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))221            return222        }223        // Read the store, not `pairedDevices.value`: that flow only holds a real224        // list while something is collecting it, so pull-to-refresh on the vault225        // list — which collects `status` and nothing else — always saw an empty226        // list and reported "no devices paired" against a fully paired phone.227        val devices = deviceStore.getAll()228        if (devices.isEmpty()) {229            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))230            return231        }232        // The same filter CloudRelayReconnectListener applies when it decides233        // which rooms to hold — a peer with no rendezvous secret is skipped234        // there in silence, which is precisely the case that must not look like235        // a successful no-op.236        if (devices.none { it.rendezvousSecretHex != null }) {237            syncOutcomes.report(238                SyncOutcomeSummary(239                    reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,240                    detail = devices.joinToString("; ") {241                        "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"242                    },243                ),244            )245            return246        }247248        syncOutcomes.begin()249        PeachSyncService.start(context)250        // Nothing else can close this attempt: the phone usually WAITS to be251        // dialled, so if no peer turns up there is no session to report a252        // failure from and the light would pulse for ever. Deliberately shorter253        // than the dialler's own bound: this one answers the person holding the254        // phone, and a session still running will report over it.255        attemptTimeout?.cancel()256        attemptTimeout = viewModelScope.launch {257            delay(SYNC_ATTEMPT_TIMEOUT_MS)258            if (syncOutcomes.summary.value.syncing) {259                syncOutcomes.report(260                    SyncOutcomeSummary(261                        reason = SyncOutcomeReason.RELAY_UNREACHABLE,262                        detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",263                    ),264                )265            }266        }267    }268269    /**270     * Legacy manual / discovery trigger kept for existing callers; now routes271     * through [syncNow] so a populated device store actually dials peers.272     */273    fun triggerSync() = syncNow()274275    private var attemptTimeout: Job? = null276277    private companion object {278        const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L279    }280}