SyncViewModel.kt

10.0 KB · raw

1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.BackgroundSyncPreference7import com.peachpasswords.sync.SyncVaultAdapter8import com.peachpasswords.sync.PairedDevice9import com.peachpasswords.sync.PairedDeviceStore10import com.peachpasswords.sync.PendingVouchedDevice11import com.peachpasswords.sync.PendingVouchedDeviceStore12import com.peachpasswords.sync.hexToPubkey13import com.peachpasswords.sync.PeachSyncService14import com.peachpasswords.sync.RelayDecision15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.SyncMode17import com.peachpasswords.sync.SyncAlertStore18import com.peachpasswords.sync.SyncOutcomeReason19import com.peachpasswords.sync.SyncOutcomeStore20import com.peachpasswords.sync.SyncOutcomeSummary21import com.peachpasswords.sync.VaultSyncAdapter22import com.peachpasswords.sync.buildLocalIdentity23import com.peachpasswords.sync.deriveLocalSyncKeypair24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.vault.VaultCrypto26import com.peachpasswords.vault.VaultRepository27import dagger.hilt.android.lifecycle.HiltViewModel28import dagger.hilt.android.qualifiers.ApplicationContext29import kotlinx.coroutines.flow.MutableStateFlow30import kotlinx.coroutines.flow.SharingStarted31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.flow.stateIn34import kotlinx.coroutines.delay35import kotlinx.coroutines.Job36import kotlinx.coroutines.launch37import javax.inject.Inject3839/**40 * Backs the Sync settings card: relay mode, the paired-device list, and the41 * manual sync control.42 *43 * Sync itself runs in [PeachSyncService], which holds a socket in each paired44 * peer's rendezvous room. Which side opens the conversation there depends on45 * the peer: we wait for a browser extension, because an extension can only46 * dial, and for another phone the lower long-term key dials. So47 * "sync now" makes sure the service is up and asks it to re-read the device48 * list — the work happens there, not here. See docs/sync-lan-retirement.md.49 */50@HiltViewModel51class SyncViewModel @Inject constructor(52    @ApplicationContext private val context: Context,53    private val vault: VaultRepository,54    private val deviceStore: PairedDeviceStore,55    private val pendingVouchedStore: PendingVouchedDeviceStore,56    private val syncAlertStore: SyncAlertStore,57    private val backgroundSync: BackgroundSyncPreference,58    private val relaySettings: RelaySettingsStore,59    private val syncOutcomes: SyncOutcomeStore,60) : ViewModel() {6162    /** The last sync attempt, whatever it was. Survives process death. */63    val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6465    val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled6667    fun setBackgroundSyncEnabled(enabled: Boolean) {68        backgroundSync.setEnabled(enabled)69        if (enabled && vault.isUnlocked.value) {70            PeachSyncService.start(context)71        } else if (!enabled) {72            PeachSyncService.stop(context)73        }74    }7576    /** Blank while no private relay is configured, so the field stays honest. */77    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())78    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()798081    private val _syncMode = MutableStateFlow(relaySettings.syncMode())82    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()8384    fun setSyncMode(mode: SyncMode) {85        relaySettings.setSyncMode(mode)86        _syncMode.value = relaySettings.syncMode()87        _privateRelayUrl.value = relaySettings.privateRelayUrl()88    }8990    /** A custom URL implies private mode. Returns false on an invalid URL. */91    fun setRelayUrl(input: String): Boolean = runCatching {92        relaySettings.setRelayUrl(input)93        _privateRelayUrl.value = relaySettings.privateRelayUrl()94        _syncMode.value = relaySettings.syncMode()95        true96    }.getOrDefault(false)979899    /** Live list of trusted peers, backing the Sync settings card device list (C7). */100    val pairedDevices: StateFlow<List<PairedDevice>> =101        deviceStore.observeAll()102            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())103104    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =105        pendingVouchedStore.observeAll()106            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())107108    /**109     * Revoke a paired device. Removing it REVOKES rather than just dropping the110     * local row: a plain removal is undone the moment any other peer vouches it111     * back through a manifest, which is exactly what the relay makes easy.112     */113    fun unpairDevice(id: String) = viewModelScope.launch {114        deviceStore.revoke(id, getOrCreateDeviceId(context))115    }116117    fun confirmVouchedDevice(id: String) = viewModelScope.launch {118        val pending = pendingVouchedStore.get(id) ?: return@launch119        // Last gate before a vouched device becomes a sync peer. A queue entry120        // can predate the revocation that names it, so revocation is re-checked121        // here rather than trusted to have swept the queue.122        if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {123            pendingVouchedStore.remove(id)124            return@launch125        }126        deviceStore.upsert(127            PairedDevice(128                deviceId = pending.deviceId,129                deviceName = pending.deviceName,130                deviceType = pending.deviceType,131                longTermPublicKeyHex = pending.longTermPublicKeyHex,132                signingPublicKeyHex = pending.signingPublicKeyHex,133                trustSource = "vouched",134                wsPort = pending.wsPort,135                pairedAt = pending.pairedAt,136            ),137        )138        pendingVouchedStore.remove(id)139    }140141    fun rejectVouchedDevice(id: String) = viewModelScope.launch {142        pendingVouchedStore.remove(id)143    }144145    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */146    suspend fun rotateSyncIdentity(): Boolean {147        val ok = vault.rotateSyncIdentity()148        if (ok) {149            deviceStore.clear()150            syncOutcomes.reset()151        }152        return ok153    }154155    /** Adapter over the live vault; null while the vault is locked. */156    fun buildAdapter(): SyncVaultAdapter? {157        if (!vault.isUnlocked.value) return null158        return VaultSyncAdapter(159            repo = vault,160            ourDeviceId = getOrCreateDeviceId(context),161            pairedDevices = deviceStore,162            pendingVouched = pendingVouchedStore,163            syncAlerts = syncAlertStore,164        )165    }166167    /**168     * Manual trigger, and the one place a sync attempt is started on purpose.169     *170     * It used to claim a completed sync the instant the service was171     * asked to start, which is a success it had not earned — the service only172     * re-reads the device list, and the sync itself happens later in a173     * rendezvous room or not at all. Now the attempt goes busy and the outcome174     * comes from whoever actually finishes it.175     */176    fun syncNow() {177        val decision = relaySettings.relayInForce()178        if (decision is RelayDecision.Refuse) {179            syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))180            return181        }182        if (!vault.isUnlocked.value) {183            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))184            return185        }186        // Read the store, not `pairedDevices.value`: that flow only holds a real187        // list while something is collecting it, so pull-to-refresh on the vault188        // list — which collects `status` and nothing else — always saw an empty189        // list and reported "no devices paired" against a fully paired phone.190        val devices = deviceStore.getAll()191        if (devices.isEmpty()) {192            syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))193            return194        }195        // The same filter CloudRelayReconnectListener applies when it decides196        // which rooms to hold — a peer with no rendezvous secret is skipped197        // there in silence, which is precisely the case that must not look like198        // a successful no-op.199        if (devices.none { it.rendezvousSecretHex != null }) {200            syncOutcomes.report(201                SyncOutcomeSummary(202                    reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,203                    detail = devices.joinToString("; ") {204                        "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"205                    },206                ),207            )208            return209        }210211        syncOutcomes.begin()212        PeachSyncService.start(context)213        // Nothing else can close this attempt: the phone usually WAITS to be214        // dialled, so if no peer turns up there is no session to report a215        // failure from and the light would pulse for ever. Deliberately shorter216        // than the dialler's own bound: this one answers the person holding the217        // phone, and a session still running will report over it.218        attemptTimeout?.cancel()219        attemptTimeout = viewModelScope.launch {220            delay(SYNC_ATTEMPT_TIMEOUT_MS)221            if (syncOutcomes.summary.value.syncing) {222                syncOutcomes.report(223                    SyncOutcomeSummary(224                        reason = SyncOutcomeReason.RELAY_UNREACHABLE,225                        detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",226                    ),227                )228            }229        }230    }231232    /**233     * Legacy manual / discovery trigger kept for existing callers; now routes234     * through [syncNow] so a populated device store actually dials peers.235     */236    fun triggerSync() = syncNow()237238    private var attemptTimeout: Job? = null239240    private companion object {241        const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L242    }243}