CodePairViewModel.kt

10.7 KB · raw

1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import com.peachpasswords.BuildConfig6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.BackgroundSyncPreference9import com.peachpasswords.sync.BatteryExemption10import com.peachpasswords.sync.PairingSession11import com.peachpasswords.sync.PeachSyncService12import com.peachpasswords.sync.RelayAdoptionEvent13import com.peachpasswords.sync.RelayAdoptionHost14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.RelayDecision16import com.peachpasswords.sync.SyncMode17import com.peachpasswords.sync.AdoptionSourceDeps18import com.peachpasswords.sync.AdoptionFrame19import com.peachpasswords.sync.LocalDeviceIdentity20import com.peachpasswords.sync.PairedDevice21import com.peachpasswords.sync.PairedDeviceStore22import com.peachpasswords.sync.SignedDeviceManifest23import com.peachpasswords.sync.VaultSyncAdapter24import com.peachpasswords.sync.buildLocalIdentity25import com.peachpasswords.sync.deriveLocalSyncKeypair26import com.peachpasswords.sync.getOrCreateDeviceId27import com.peachpasswords.vault.VaultCrypto28import com.peachpasswords.vault.VaultRepository29import com.peachpasswords.license.LicenseRepository30import dagger.hilt.android.lifecycle.HiltViewModel31import dagger.hilt.android.qualifiers.ApplicationContext32import kotlinx.coroutines.Job33import kotlinx.coroutines.flow.MutableStateFlow34import kotlinx.coroutines.flow.StateFlow35import kotlinx.coroutines.flow.asStateFlow36import kotlinx.coroutines.launch37import javax.inject.Inject3839/**40 * The phone half of pairing: occupy a relay room, show the invitation, wait to41 * be scanned.42 *43 * There is one transport. LAN pairing codes are retired along with LAN sync44 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and45 * then presents the SAS for comparison.46 */47sealed interface CodePairUiState {48    data object Starting : CodePairUiState49    data class ShowingInvitation(val invitation: String) : CodePairUiState50    data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState51    data class Paired(val deviceName: String) : CodePairUiState52    data object Expired : CodePairUiState53    data class Failed(val reason: String) : CodePairUiState54}5556@HiltViewModel57class CodePairViewModel @Inject constructor(58    private val vault: VaultRepository,59    private val relayHost: RelayAdoptionHost,60    private val relaySettings: RelaySettingsStore,61    private val backgroundSync: BackgroundSyncPreference,62    private val deviceStore: PairedDeviceStore,63    private val license: LicenseRepository,64    @ApplicationContext private val appContext: Context,65) : ViewModel() {6667    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)68    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6970    private val _syncMode = MutableStateFlow(relaySettings.syncMode())71    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7273    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())74    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()7576    /**77     * The single live transport collector. Switching between relay and LAN78     * restarts the screen, and without cancelling the previous collector both79     * would keep writing [_state] — the older one landing last and winning.80     */81    private var collectJob: Job? = null8283    /**84     * The battery-exemption dialog to show, or null if it's already granted or85     * already been declined once. Consumed by the screen, which owns an86     * Activity to launch it from.87     *88     * [userConfirmed] must come from the tap itself — see [BatteryExemption]'s89     * distribution note. When it yields nothing, sync still works; it just runs90     * from the foreground ([BatteryExemption.syncOnce]).91     */92    fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =93        BatteryExemption.requestIntent(appContext, userConfirmed)9495    /** Degraded sync for a device with no Doze exemption — see [BatteryExemption.syncOnce]. */96    fun syncOnce() = BatteryExemption.syncOnce(appContext)9798    fun start() {99        val master = vault.syncMasterKey() ?: run {100            _state.value = CodePairUiState.Failed("vault_locked")101            return102        }103        val decision = relaySettings.relayInForce()104        if (decision is RelayDecision.Refuse) {105            _state.value = CodePairUiState.Failed(decision.reason.wire())106            return107        }108        PeachSyncService.start(appContext)109110        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))111        val identity = buildLocalIdentity(appContext, master)112        VaultCrypto.zero(master)113        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))114    }115116    private fun startRelay(117        privateKey: ByteArray,118        publicKey: ByteArray,119        identity: LocalDeviceIdentity,120        sourceDeps: AdoptionSourceDeps,121    ) {122        collectJob = viewModelScope.launch {123            try {124                relayHost.start(125                    viewModelScope,126                    privateKey,127                    publicKey,128                    identity,129                    sourceDeps,130                ).collect { event ->131                    _state.value = when (event) {132                        is RelayAdoptionEvent.InvitationReady ->133                            CodePairUiState.ShowingInvitation(event.invitation)134                        is RelayAdoptionEvent.AwaitingPeerConfirmation ->135                            CodePairUiState.AwaitingPeerConfirmation(136                                event.sas,137                                event.deviceName,138                            )139                        is RelayAdoptionEvent.Adopted ->140                            CodePairUiState.Paired(event.deviceName)141                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired142                        is RelayAdoptionEvent.Rejected -> {143                            if (BuildConfig.DEBUG) {144                                Log.w(145                                    "PeachPairing",146                                    "relay adoption rejected: ${event.reason}",147                                )148                            }149                            CodePairUiState.Failed(event.reason)150                        }151                    }152                }153            } finally {154                VaultCrypto.zero(privateKey)155                VaultCrypto.zero(publicKey)156            }157        }158    }159160    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =161        object : AdoptionSourceDeps {162            override val sourceIdentity: LocalDeviceIdentity = identity163164            override suspend fun rootKeyHex(): String {165                val root = vault.syncMasterKey()166                    ?: throw IllegalStateException("vault_locked")167                return try {168                    root.joinToString("") { "%02x".format(it) }169                } finally {170                    VaultCrypto.zero(root)171                }172            }173174            override suspend fun vaultId(): String =175                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")176177            override suspend fun license(): String? =178                license.getLicenseKey()?.takeIf(license::validateLicense)179180            override suspend fun applyLicense(licenseKey: String) {181                if (!license.validateLicense(licenseKey)) {182                    throw IllegalStateException("license_transfer_failed")183                }184                license.storeLicense(licenseKey)185                if (!license.isProActive()) {186                    throw IllegalStateException("license_transfer_failed")187                }188            }189190            // Opening this source screen while unlocked is the source-side191            // consent. The scanning device confirms the SAS before any grant.192            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true193194            override suspend fun upsertPairedDevice(device: PairedDevice) {195                // SAS-confirmed direct adoption — the one consent signal strong196                // enough to lift a terminal revocation.197                deviceStore.clearRevocation(device.deviceId)198                deviceStore.upsert(device)199            }200201            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {202                val adapter = VaultSyncAdapter(203                    repo = vault,204                    ourDeviceId = getOrCreateDeviceId(appContext),205                    pairedDevices = deviceStore,206                )207                val entryManifest = adapter.buildEntryManifest()208                val entryIds = entryManifest.keys.toList()209                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()210                val tombstones = adapter.buildTombstoneManifest()211                return entries to tombstones212            }213214            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {215                val adapter = VaultSyncAdapter(216                    repo = vault,217                    ourDeviceId = getOrCreateDeviceId(appContext),218                    pairedDevices = deviceStore,219                )220                return adapter.buildOutgoingDeviceManifest()221            }222223            override suspend fun markSynced(deviceId: String) {224                deviceStore.updateLastSync(deviceId)225            }226        }227228    fun setBackgroundSync(enabled: Boolean) {229        backgroundSync.setEnabled(enabled)230        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)231    }232233    fun selectRelayMode(mode: SyncMode) {234        require(mode != SyncMode.OFF) { "Pairing requires a relay" }235        relaySettings.setSyncMode(mode)236        refreshRelayState()237        if (relaySettings.relayInForce() is RelayDecision.Dial) {238            restart()239        } else {240            stop()241            _state.value = CodePairUiState.Failed("private_relay_unset")242        }243    }244245    fun savePrivateRelayUrl(input: String): Boolean = runCatching {246        relaySettings.setRelayUrl(input)247        refreshRelayState()248        restart()249        true250    }.getOrDefault(false)251252    private fun refreshRelayState() {253        _syncMode.value = relaySettings.syncMode()254        _privateRelayUrl.value = relaySettings.privateRelayUrl()255    }256257    fun restart() {258        collectJob?.cancel()259        collectJob = null260        relayHost.stop()261        _state.value = CodePairUiState.Starting262        start()263    }264265    fun stop() {266        collectJob?.cancel()267        collectJob = null268        relayHost.stop()269    }270271    override fun onCleared() {272        stop()273        super.onCleared()274    }275}