SyncViewModel.kt

6.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.SyncStatus17import com.peachpasswords.sync.VaultSyncAdapter18import com.peachpasswords.sync.buildLocalIdentity19import com.peachpasswords.sync.deriveLocalSyncKeypair20import com.peachpasswords.sync.getOrCreateDeviceId21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.SharingStarted27import kotlinx.coroutines.flow.StateFlow28import kotlinx.coroutines.flow.asStateFlow29import kotlinx.coroutines.flow.stateIn30import kotlinx.coroutines.launch31import javax.inject.Inject3233/**34 * Backs the Sync settings card: relay mode, the paired-device list, and the35 * manual sync control.36 *37 * Sync itself runs in [PeachSyncService], which holds a socket in each paired38 * peer's rendezvous room. Which side opens the conversation there depends on39 * the peer: we wait for a browser extension, because an extension can only40 * dial, and for another phone the lower long-term key dials. So41 * "sync now" makes sure the service is up and asks it to re-read the device42 * list — the work happens there, not here. See docs/sync-lan-retirement.md.43 */44@HiltViewModel45class SyncViewModel @Inject constructor(46    @ApplicationContext private val context: Context,47    private val vault: VaultRepository,48    private val deviceStore: PairedDeviceStore,49    private val pendingVouchedStore: PendingVouchedDeviceStore,50    private val syncAlertStore: SyncAlertStore,51    private val relaySettings: RelaySettingsStore,52) : ViewModel() {5354    private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)55    val status: StateFlow<SyncStatus> = _status.asStateFlow()5657    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())58    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()596061    private val _syncMode = MutableStateFlow(relaySettings.syncMode())62    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()6364    fun setSyncMode(mode: SyncMode) {65        relaySettings.setSyncMode(mode)66        _syncMode.value = relaySettings.syncMode()67        _relayUrl.value = relaySettings.relayUrl()68    }6970    /** Saving a URL implies private mode. Returns false on an invalid URL. */71    fun setPrivateRelayUrl(input: String): Boolean = runCatching {72        _relayUrl.value = relaySettings.setPrivateRelayUrl(input)73        _syncMode.value = relaySettings.syncMode()74        true75    }.getOrDefault(false)767778    /** Live list of trusted peers, backing the Sync settings card device list (C7). */79    val pairedDevices: StateFlow<List<PairedDevice>> =80        deviceStore.observeAll()81            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8283    val pendingVouched: StateFlow<List<PendingVouchedDevice>> =84        pendingVouchedStore.observeAll()85            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8687    /**88     * Revoke a paired device. Removing it REVOKES rather than just dropping the89     * local row: a plain removal is undone the moment any other peer vouches it90     * back through a manifest, which is exactly what the relay makes easy.91     */92    fun unpairDevice(id: String) = viewModelScope.launch {93        deviceStore.revoke(id, getOrCreateDeviceId(context))94    }9596    fun confirmVouchedDevice(id: String) = viewModelScope.launch {97        val pending = pendingVouchedStore.get(id) ?: return@launch98        deviceStore.upsert(99            PairedDevice(100                deviceId = pending.deviceId,101                deviceName = pending.deviceName,102                deviceType = pending.deviceType,103                longTermPublicKeyHex = pending.longTermPublicKeyHex,104                signingPublicKeyHex = pending.signingPublicKeyHex,105                trustSource = "vouched",106                wsPort = pending.wsPort,107                pairedAt = pending.pairedAt,108            ),109        )110        pendingVouchedStore.remove(id)111    }112113    fun rejectVouchedDevice(id: String) = viewModelScope.launch {114        pendingVouchedStore.remove(id)115    }116117    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */118    suspend fun rotateSyncIdentity(): Boolean {119        val ok = vault.rotateSyncIdentity()120        if (ok) {121            deviceStore.clear()122            _status.value = SyncStatus.Idle123        }124        return ok125    }126127    /** Adapter over the live vault; null while the vault is locked. */128    fun buildAdapter(): SyncVaultAdapter? {129        if (!vault.isUnlocked.value) return null130        return VaultSyncAdapter(131            repo = vault,132            ourDeviceId = getOrCreateDeviceId(context),133            pairedDevices = deviceStore,134            pendingVouched = pendingVouchedStore,135            syncAlerts = syncAlertStore,136        )137    }138139    /**140     * Manual trigger. Reports Offline when no peers are paired; otherwise141     * brings the relay service up (or nudges it, if already running) so it142     * reconnects to every peer's room straight away.143     */144    fun syncNow() {145        if (!vault.isUnlocked.value) {146            _status.value = SyncStatus.Error("vault_locked")147            return148        }149        if (pairedDevices.value.isEmpty()) {150            _status.value = SyncStatus.Offline151            return152        }153        PeachSyncService.start(context)154        _status.value = SyncStatus.LastSynced(System.currentTimeMillis())155    }156157    /**158     * Legacy manual / discovery trigger kept for existing callers; now routes159     * through [syncNow] so a populated device store actually dials peers.160     */161    fun triggerSync() = syncNow()162163}