SyncViewModel.kt

16.6 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.RESET_NOT_COMPLETED9import com.peachpasswords.sync.ResetResult10import com.peachpasswords.sync.SyncGeneration11import com.peachpasswords.sync.SyncGenerationStore12import com.peachpasswords.sync.SyncVaultAdapter13import com.peachpasswords.sync.confirmSecureReset14import com.peachpasswords.sync.enterResetRequired15import com.peachpasswords.sync.resolveGeneration16import com.peachpasswords.sync.syncAllowedNow17import com.peachpasswords.sync.PairedDevice18import com.peachpasswords.sync.PairedDeviceStore19import com.peachpasswords.sync.TrustDataCorruptedException20import com.peachpasswords.sync.PendingVouchedDevice21import com.peachpasswords.sync.PendingVouchedDeviceStore22import com.peachpasswords.sync.PeachSyncService23import com.peachpasswords.sync.RelayDecision24import com.peachpasswords.sync.RelaySettingsStore25import com.peachpasswords.sync.SyncMode26import com.peachpasswords.sync.SyncAlertStore27import com.peachpasswords.sync.SyncOutcomeReason28import com.peachpasswords.sync.SyncOutcomeStore29import com.peachpasswords.sync.SyncOutcomeSummary30import com.peachpasswords.sync.VaultSyncAdapter31import com.peachpasswords.sync.loadOrCreateLocalIdentity32import com.peachpasswords.sync.loadLocalIdentityOrNull33import com.peachpasswords.vault.VaultRepository34import dagger.hilt.android.lifecycle.HiltViewModel35import dagger.hilt.android.qualifiers.ApplicationContext36import kotlinx.coroutines.flow.MutableStateFlow37import kotlinx.coroutines.flow.SharingStarted38import kotlinx.coroutines.flow.StateFlow39import kotlinx.coroutines.flow.asStateFlow40import kotlinx.coroutines.flow.combine41import kotlinx.coroutines.flow.stateIn42import kotlinx.coroutines.flow.catch43import kotlinx.coroutines.Dispatchers44import kotlinx.coroutines.delay45import kotlinx.coroutines.Job46import kotlinx.coroutines.launch47import kotlinx.coroutines.withContext48import javax.inject.Inject4950/**51 * Backs the Sync settings card: relay mode, the paired-device list, and the52 * manual sync control.53 *54 * Sync itself runs in [PeachSyncService], which holds a socket in each paired55 * peer's rendezvous room. Which side opens the conversation there depends on56 * the peer: we wait for a browser extension, because an extension can only57 * dial, and for another phone the lower long-term key dials. So58 * "sync now" makes sure the service is up and asks it to re-read the device59 * list — the work happens there, not here. See docs/sync-lan-retirement.md.60 */61@HiltViewModel62class SyncViewModel @Inject constructor(63    @ApplicationContext private val context: Context,64    private val vault: VaultRepository,65    private val deviceStore: PairedDeviceStore,66    private val pendingVouchedStore: PendingVouchedDeviceStore,67    private val syncAlertStore: SyncAlertStore,68    private val backgroundSync: BackgroundSyncPreference,69    private val relaySettings: RelaySettingsStore,70    private val syncOutcomes: SyncOutcomeStore,71    private val generation: SyncGenerationStore,72) : ViewModel() {7374    /** The last sync attempt, whatever it was. Survives process death. */75    val status: StateFlow<SyncOutcomeSummary> =76        combine(syncOutcomes.summary, vault.isUnlocked) { summary, unlocked ->77            if (unlocked && vault.syncVaultId() == null) SyncOutcomeSummary() else summary78        }.stateIn(79            viewModelScope,80            SharingStarted.WhileSubscribed(5_000),81            if (vault.isUnlocked.value && vault.syncVaultId() == null) {82                SyncOutcomeSummary()83            } else {84                syncOutcomes.summary.value85            },86        )8788    /**89     * Decides when the sync service actually runs for the background-sync90     * preference. The service's own locked-handling is the gate — refusing to91     * start here while locked used to leave the preference on with no92     * notification, which was exactly the v2.5.13 "no peer answered" symptom.93     */94    private val backgroundSyncCoordinator = BackgroundSyncCoordinator(95        start = { PeachSyncService.start(context) },96        stop = { PeachSyncService.stop(context) },97        isUnlocked = vault.isUnlocked,98        preferenceEnabled = backgroundSync.enabled,99        syncVaultId = vault::syncVaultId,100        syncAllowed = { syncAllowedNow(generation) },101    )102103    init {104        // Catches the lock-then-unlock case: the user enables the setting while105        // locked, then unlocks — the service should come up the moment a sync106        // identity exists. StateFlow.collect fires the current value107        // immediately, so the already-unlocked case is covered too.108        backgroundSyncCoordinator.observe(viewModelScope)109    }110111    fun setBackgroundSyncEnabled(enabled: Boolean) {112        if (isDecoySession()) return113        backgroundSync.setEnabled(enabled)114        backgroundSyncCoordinator.setEnabled(enabled)115    }116    val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled117118    /** Blank while no private relay is configured, so the field stays honest. */119    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())120    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()121122123    private val _syncMode = MutableStateFlow(relaySettings.syncMode())124    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()125126    fun setSyncMode(mode: SyncMode) {127        if (isDecoySession()) return128        relaySettings.setSyncMode(mode)129        _syncMode.value = relaySettings.syncMode()130        _privateRelayUrl.value = relaySettings.privateRelayUrl()131    }132133    /** A custom URL implies private mode. Returns false on an invalid URL. */134    fun setRelayUrl(input: String): Boolean {135        if (isDecoySession()) return false136        return runCatching {137            relaySettings.setRelayUrl(input)138            _privateRelayUrl.value = relaySettings.privateRelayUrl()139            _syncMode.value = relaySettings.syncMode()140            true141        }.getOrDefault(false)142    }143144145    /** Live list of trusted peers, redacted while a decoy session is active (C7). */146    val pairedDevices: StateFlow<List<PairedDevice>> =147        deviceStore.observeAll()148            .catch { e ->149                if (e is TrustDataCorruptedException) {150                    syncOutcomes.report(151                        SyncOutcomeSummary(reason = SyncOutcomeReason.TRUST_DATA_CORRUPTED, detail = e.message),152                    )153                    emit(emptyList())154                } else {155                    throw e156                }157            }158            .combine(vault.isUnlocked) { devices, unlocked ->159                if (unlocked && vault.syncVaultId() == null) emptyList() else devices160            }161            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())162163    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =164        combine(pendingVouchedStore.observeAll(), vault.isUnlocked) { pending, unlocked ->165            if (unlocked && vault.syncVaultId() == null) emptyList() else pending166        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())167168    /**169     * Revoke a paired device. Removing it REVOKES rather than just dropping the170     * local row: a plain removal is undone the moment any other peer vouches it171     * back through a manifest, which is exactly what the relay makes easy.172     */173    fun unpairDevice(id: String) = viewModelScope.launch {174        if (isDecoySession()) return@launch175        try {176            val identity = loadLocalIdentityOrNull(context) ?: return@launch177            deviceStore.revoke(id, identity.deviceId)178            PeachSyncService.start(context)179        } catch (e: TrustDataCorruptedException) {180            syncOutcomes.report(181                SyncOutcomeSummary(reason = SyncOutcomeReason.TRUST_DATA_CORRUPTED, detail = e.message),182            )183        }184    }185186    fun renameDevice(id: String, name: String) = viewModelScope.launch {187        if (isDecoySession()) return@launch188        deviceStore.rename(id, name)189    }190191    fun confirmVouchedDevice(id: String) = viewModelScope.launch {192        if (isDecoySession()) return@launch193        val candidate = pendingVouchedStore.get(id) ?: return@launch194        val pending = pendingVouchedStore.get(195            candidate.deviceId,196            candidate.longTermPublicKeyHex,197            candidate.signingPublicKeyHex,198        ) ?: return@launch199        // here rather than trusted to have swept the queue.200        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {201            pendingVouchedStore.removeBatch(setOf(id))202            return@launch203        }204        // Never let a stale queue item overwrite an already trusted row (in205        // particular a direct SAS-paired row). The queued introduction is206        // consumed, but the established trust record remains authoritative.207        if (deviceStore.get(pending.deviceId) != null) {208            pendingVouchedStore.removeBatch(setOf(id))209            return@launch210        }211        // Remove the pending row synchronously before publishing trust. If the212        // subsequent upsert fails, the safe outcome is a lost introduction,213        // not a restart that replays stale pending trust over a revocation.214        if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch215        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch216        deviceStore.upsert(217            PairedDevice(218                deviceId = pending.deviceId,219                deviceName = pending.deviceName,220                deviceType = pending.deviceType,221                longTermPublicKeyHex = pending.longTermPublicKeyHex,222                signingPublicKeyHex = pending.signingPublicKeyHex,223                trustSource = "vouched",224                wsPort = pending.wsPort,225                pairedAt = pending.pairedAt,226            ),227        )228    }229230    fun rejectVouchedDevice(id: String) = viewModelScope.launch {231        if (isDecoySession()) return@launch232        pendingVouchedStore.remove(id)233    }234235236    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */237    suspend fun rotateSyncIdentity(): Boolean {238        if (isDecoySession()) return false239        val ok = vault.rotateSyncIdentity()240        if (ok) {241            deviceStore.clear()242            syncOutcomes.reset()243        }244        return ok245    }246247    /** Adapter over the live vault; null while the vault is locked. */248    fun buildAdapter(): SyncVaultAdapter? {249        if (!vault.isUnlocked.value || isDecoySession()) return null250        if (!syncAllowedNow(generation)) return null251        val credentials = loadOrCreateLocalIdentity(context)252        return VaultSyncAdapter(253            repo = vault,254            ourDeviceId = credentials.deviceId,255            pairedDevices = deviceStore,256            pendingVouched = pendingVouchedStore,257            syncAlerts = syncAlertStore,258            localCredentials = credentials,259        )260    }261262    /**263     * The §11 migration panel: what this device's Sync generation is, and what264     * it still owes the user. Not secret — it is a version and a state name —265     * so a decoy session shows the same thing without leaking anything.266     */267    private val _generationState = MutableStateFlow(generationRecord())268    val generationState: StateFlow<SyncGeneration> = _generationState.asStateFlow()269270    private fun generationRecord(): SyncGeneration = resolveGeneration(generation, System.currentTimeMillis())271272    /**273     * Enter the state that blocks sync, then run the migration. Entry is274     * separate from confirmation so a crash between them leaves the device275     * refusing sync rather than mid-reset with trust still live (§11.3).276     *277     * The master password confirms a trust-resetting action (§11.9), then the278     * archive → identity → pointer-flip sequence runs in that order. Returns279     * null on success, or the line to show the user.280     */281    suspend fun confirmReset(masterPassword: String): String? {282        if (isDecoySession()) return "Reset is unavailable in this session"283        if (masterPassword.isBlank()) return "Enter your master password"284        val masterMatches = withContext(Dispatchers.Default) {285            vault.tryUnlockSlotA(masterPassword)?.let {286                vault.discardSlotAUnlock(it)287                true288            } ?: false289        }290        if (!masterMatches) return "Incorrect master password"291        val result = withContext(Dispatchers.Default) {292            _generationState.value = enterResetRequired(generation, System.currentTimeMillis())293            confirmSecureReset(generation, System.currentTimeMillis())294        }295        _generationState.value = generationRecord()296        return when (result) {297            ResetResult.V2_ANCHOR -> null298            ResetResult.RESET_NOT_COMPLETED ->299                "The reset did not complete — nothing was lost, try again ($RESET_NOT_COMPLETED)"300        }301    }302303    /**304     * Manual trigger, and the one place a sync attempt is started on purpose.305     *306     * It used to claim a completed sync the instant the service was307     * asked to start, which is a success it had not earned — the service only308     * re-reads the device list, and the sync itself happens later in a309     * rendezvous room or not at all. Now the attempt goes busy and the outcome310     * comes from whoever actually finishes it.311     */312    fun syncNow() {313        // A decoy session must not touch the real outcome store or start the314        // sync service: either would expose or mutate real-session history.315        if (isDecoySession()) return316317        val decision = relaySettings.relayInForce()318        if (decision is RelayDecision.Refuse) {319            syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))320            return321        }322        if (!vault.isUnlocked.value) {323            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))324            return325        }326327        // Read the store, not `pairedDevices.value`: that flow only holds a real328        // list while something is collecting it, so pull-to-refresh on the vault329        // list — which collects `status` and nothing else — always saw an empty330        // list and reported "no devices paired" against a fully paired phone.331        val devices = deviceStore.getAll()332        if (devices.isEmpty()) {333            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))334            return335        }336        // The same filter CloudRelayReconnectListener applies when it decides337        // which rooms to hold — a peer with no rendezvous secret is skipped338        // there in silence, which is precisely the case that must not look like339        // a successful no-op.340        if (devices.none { it.rendezvousSecretHex != null }) {341            syncOutcomes.report(342                SyncOutcomeSummary(343                    reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,344                    detail = devices.joinToString("; ") {345                        "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"346                    },347                ),348            )349            return350        }351352        if (!syncAllowedNow(generation)) return353        syncOutcomes.begin()354        PeachSyncService.start(context)355        // Nothing else can close this attempt: the phone usually WAITS to be356        // dialled, so if no peer turns up there is no session to report a357        // failure from and the light would pulse for ever. Deliberately shorter358        // than the dialler's own bound: this one answers the person holding the359        // phone, and a session still running will report over it.360        attemptTimeout?.cancel()361        attemptTimeout = viewModelScope.launch {362            delay(SYNC_ATTEMPT_TIMEOUT_MS)363            if (syncOutcomes.summary.value.syncing) {364                syncOutcomes.report(365                    SyncOutcomeSummary(366                        reason = SyncOutcomeReason.RELAY_UNREACHABLE,367                        detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",368                    ),369                )370            }371        }372    }373    private fun isDecoySession(): Boolean =374        vault.isUnlocked.value && vault.syncVaultId() == null375376    /**377     * Legacy manual / discovery trigger kept for existing callers; now routes378     * through [syncNow] so a populated device store actually dials peers.379     */380    fun triggerSync() = syncNow()381382    private var attemptTimeout: Job? = null383384    private companion object {385        const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L386    }387}