CodePairViewModel.kt

7.6 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.AdoptionSourceDeps17import com.peachpasswords.sync.AdoptionFrame18import com.peachpasswords.sync.LocalDeviceIdentity19import com.peachpasswords.sync.PairedDevice20import com.peachpasswords.sync.PairedDeviceStore21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import com.peachpasswords.license.LicenseRepository27import dagger.hilt.android.lifecycle.HiltViewModel28import dagger.hilt.android.qualifiers.ApplicationContext29import kotlinx.coroutines.Job30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.launch34import javax.inject.Inject3536/**37 * The phone half of pairing: occupy a relay room, show the invitation, wait to38 * be scanned.39 *40 * There is one transport. LAN pairing codes are retired along with LAN sync41 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and42 * then presents the SAS for comparison.43 */44sealed interface CodePairUiState {45    data object Starting : CodePairUiState46    data class ShowingInvitation(val invitation: String) : CodePairUiState47    data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState48    data class Paired(val deviceName: String) : CodePairUiState49    data object Expired : CodePairUiState50    data class Failed(val reason: String) : CodePairUiState51}5253@HiltViewModel54class CodePairViewModel @Inject constructor(55    private val vault: VaultRepository,56    private val relayHost: RelayAdoptionHost,57    private val relaySettings: RelaySettingsStore,58    private val backgroundSync: BackgroundSyncPreference,59    private val deviceStore: PairedDeviceStore,60    private val license: LicenseRepository,61    @ApplicationContext private val appContext: Context,62) : ViewModel() {6364    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)65    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6667    /**68     * The single live transport collector. Switching between relay and LAN69     * restarts the screen, and without cancelling the previous collector both70     * would keep writing [_state] — the older one landing last and winning.71     */72    private var collectJob: Job? = null7374    /**75     * The battery-exemption dialog to show, or null if it's already granted or76     * already been declined once. Consumed by the screen, which owns an77     * Activity to launch it from.78     */79    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)8081    fun start() {82        val master = vault.syncMasterKey() ?: run {83            _state.value = CodePairUiState.Failed("vault_locked")84            return85        }86        val decision = relaySettings.relayInForce()87        if (decision is RelayDecision.Refuse) {88            _state.value = CodePairUiState.Failed(decision.reason.wire())89            return90        }91        PeachSyncService.start(appContext)9293        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))94        val identity = buildLocalIdentity(appContext, master)95        VaultCrypto.zero(master)96        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))97    }9899    private fun startRelay(100        privateKey: ByteArray,101        publicKey: ByteArray,102        identity: LocalDeviceIdentity,103        sourceDeps: AdoptionSourceDeps,104    ) {105        collectJob = viewModelScope.launch {106            try {107                relayHost.start(108                    viewModelScope,109                    privateKey,110                    publicKey,111                    identity,112                    sourceDeps,113                ).collect { event ->114                    _state.value = when (event) {115                        is RelayAdoptionEvent.InvitationReady ->116                            CodePairUiState.ShowingInvitation(event.invitation)117                        is RelayAdoptionEvent.AwaitingPeerConfirmation ->118                            CodePairUiState.AwaitingPeerConfirmation(119                                event.sas,120                                event.deviceName,121                            )122                        is RelayAdoptionEvent.Adopted ->123                            CodePairUiState.Paired(event.deviceName)124                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired125                        is RelayAdoptionEvent.Rejected -> {126                            if (BuildConfig.DEBUG) {127                                Log.w(128                                    "PeachPairing",129                                    "relay adoption rejected: ${event.reason}",130                                )131                            }132                            CodePairUiState.Failed(event.reason)133                        }134                    }135                }136            } finally {137                VaultCrypto.zero(privateKey)138                VaultCrypto.zero(publicKey)139            }140        }141    }142143    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =144        object : AdoptionSourceDeps {145            override val sourceIdentity: LocalDeviceIdentity = identity146147            override suspend fun rootKeyHex(): String {148                val root = vault.syncMasterKey()149                    ?: throw IllegalStateException("vault_locked")150                return try {151                    root.joinToString("") { "%02x".format(it) }152                } finally {153                    VaultCrypto.zero(root)154                }155            }156157            override suspend fun vaultId(): String =158                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")159160            override suspend fun license(): String? =161                license.getLicenseKey()?.takeIf(license::validateLicense)162163            // Opening this source screen while unlocked is the source-side164            // consent. The scanning device confirms the SAS before any grant.165            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true166167            override suspend fun upsertPairedDevice(device: PairedDevice) {168                // SAS-confirmed direct adoption — the one consent signal strong169                // enough to lift a terminal revocation.170                deviceStore.clearRevocation(device.deviceId)171                deviceStore.upsert(device)172            }173        }174175    fun setBackgroundSync(enabled: Boolean) {176        backgroundSync.setEnabled(enabled)177        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)178    }179180    fun restart() {181        collectJob?.cancel()182        collectJob = null183        relayHost.stop()184        _state.value = CodePairUiState.Starting185        start()186    }187188    fun stop() {189        collectJob?.cancel()190        collectJob = null191        relayHost.stop()192    }193194    override fun onCleared() {195        stop()196        super.onCleared()197    }198}