CodePairViewModel.kt

9.1 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.buildLocalIdentity23import com.peachpasswords.sync.deriveLocalSyncKeypair24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.vault.VaultCrypto26import com.peachpasswords.vault.VaultRepository27import com.peachpasswords.license.LicenseRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.Job31import kotlinx.coroutines.flow.MutableStateFlow32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.launch35import javax.inject.Inject3637/**38 * The phone half of pairing: occupy a relay room, show the invitation, wait to39 * be scanned.40 *41 * There is one transport. LAN pairing codes are retired along with LAN sync42 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and43 * then presents the SAS for comparison.44 */45sealed interface CodePairUiState {46    data object Starting : CodePairUiState47    data class ShowingInvitation(val invitation: String) : CodePairUiState48    data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState49    data class Paired(val deviceName: String) : CodePairUiState50    data object Expired : CodePairUiState51    data class Failed(val reason: String) : CodePairUiState52}5354@HiltViewModel55class CodePairViewModel @Inject constructor(56    private val vault: VaultRepository,57    private val relayHost: RelayAdoptionHost,58    private val relaySettings: RelaySettingsStore,59    private val backgroundSync: BackgroundSyncPreference,60    private val deviceStore: PairedDeviceStore,61    private val license: LicenseRepository,62    @ApplicationContext private val appContext: Context,63) : ViewModel() {6465    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)66    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6768    private val _syncMode = MutableStateFlow(relaySettings.syncMode())69    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7071    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())72    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()7374    /**75     * The single live transport collector. Switching between relay and LAN76     * restarts the screen, and without cancelling the previous collector both77     * would keep writing [_state] — the older one landing last and winning.78     */79    private var collectJob: Job? = null8081    /**82     * The battery-exemption dialog to show, or null if it's already granted or83     * already been declined once. Consumed by the screen, which owns an84     * Activity to launch it from.85     */86    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)8788    fun start() {89        val master = vault.syncMasterKey() ?: run {90            _state.value = CodePairUiState.Failed("vault_locked")91            return92        }93        val decision = relaySettings.relayInForce()94        if (decision is RelayDecision.Refuse) {95            _state.value = CodePairUiState.Failed(decision.reason.wire())96            return97        }98        PeachSyncService.start(appContext)99100        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))101        val identity = buildLocalIdentity(appContext, master)102        VaultCrypto.zero(master)103        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))104    }105106    private fun startRelay(107        privateKey: ByteArray,108        publicKey: ByteArray,109        identity: LocalDeviceIdentity,110        sourceDeps: AdoptionSourceDeps,111    ) {112        collectJob = viewModelScope.launch {113            try {114                relayHost.start(115                    viewModelScope,116                    privateKey,117                    publicKey,118                    identity,119                    sourceDeps,120                ).collect { event ->121                    _state.value = when (event) {122                        is RelayAdoptionEvent.InvitationReady ->123                            CodePairUiState.ShowingInvitation(event.invitation)124                        is RelayAdoptionEvent.AwaitingPeerConfirmation ->125                            CodePairUiState.AwaitingPeerConfirmation(126                                event.sas,127                                event.deviceName,128                            )129                        is RelayAdoptionEvent.Adopted ->130                            CodePairUiState.Paired(event.deviceName)131                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired132                        is RelayAdoptionEvent.Rejected -> {133                            if (BuildConfig.DEBUG) {134                                Log.w(135                                    "PeachPairing",136                                    "relay adoption rejected: ${event.reason}",137                                )138                            }139                            CodePairUiState.Failed(event.reason)140                        }141                    }142                }143            } finally {144                VaultCrypto.zero(privateKey)145                VaultCrypto.zero(publicKey)146            }147        }148    }149150    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =151        object : AdoptionSourceDeps {152            override val sourceIdentity: LocalDeviceIdentity = identity153154            override suspend fun rootKeyHex(): String {155                val root = vault.syncMasterKey()156                    ?: throw IllegalStateException("vault_locked")157                return try {158                    root.joinToString("") { "%02x".format(it) }159                } finally {160                    VaultCrypto.zero(root)161                }162            }163164            override suspend fun vaultId(): String =165                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")166167            override suspend fun license(): String? =168                license.getLicenseKey()?.takeIf(license::validateLicense)169170            override suspend fun applyLicense(licenseKey: String) {171                if (!license.validateLicense(licenseKey)) {172                    throw IllegalStateException("license_transfer_failed")173                }174                license.storeLicense(licenseKey)175                if (!license.isProActive()) {176                    throw IllegalStateException("license_transfer_failed")177                }178            }179180            // Opening this source screen while unlocked is the source-side181            // consent. The scanning device confirms the SAS before any grant.182            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true183184            override suspend fun upsertPairedDevice(device: PairedDevice) {185                // SAS-confirmed direct adoption — the one consent signal strong186                // enough to lift a terminal revocation.187                deviceStore.clearRevocation(device.deviceId)188                deviceStore.upsert(device)189            }190        }191192    fun setBackgroundSync(enabled: Boolean) {193        backgroundSync.setEnabled(enabled)194        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)195    }196197    fun selectRelayMode(mode: SyncMode) {198        require(mode != SyncMode.OFF) { "Pairing requires a relay" }199        relaySettings.setSyncMode(mode)200        refreshRelayState()201        if (relaySettings.relayInForce() is RelayDecision.Dial) {202            restart()203        } else {204            stop()205            _state.value = CodePairUiState.Failed("private_relay_unset")206        }207    }208209    fun savePrivateRelayUrl(input: String): Boolean = runCatching {210        relaySettings.setRelayUrl(input)211        refreshRelayState()212        restart()213        true214    }.getOrDefault(false)215216    private fun refreshRelayState() {217        _syncMode.value = relaySettings.syncMode()218        _privateRelayUrl.value = relaySettings.privateRelayUrl()219    }220221    fun restart() {222        collectJob?.cancel()223        collectJob = null224        relayHost.stop()225        _state.value = CodePairUiState.Starting226        start()227    }228229    fun stop() {230        collectJob?.cancel()231        collectJob = null232        relayHost.stop()233    }234235    override fun onCleared() {236        stop()237        super.onCleared()238    }239}