CodePairViewModel.kt

7.5 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.BatteryExemption9import com.peachpasswords.sync.PairingSession10import com.peachpasswords.sync.PeachSyncService11import com.peachpasswords.sync.RelayAdoptionEvent12import com.peachpasswords.sync.RelayAdoptionHost13import com.peachpasswords.sync.RelaySettingsStore14import com.peachpasswords.sync.RelayDecision15import com.peachpasswords.sync.AdoptionSourceDeps16import com.peachpasswords.sync.AdoptionFrame17import com.peachpasswords.sync.LocalDeviceIdentity18import com.peachpasswords.sync.PairedDevice19import com.peachpasswords.sync.PairedDeviceStore20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.deriveLocalSyncKeypair22import com.peachpasswords.sync.getOrCreateDeviceId23import com.peachpasswords.vault.VaultCrypto24import com.peachpasswords.vault.VaultRepository25import com.peachpasswords.license.LicenseRepository26import dagger.hilt.android.lifecycle.HiltViewModel27import dagger.hilt.android.qualifiers.ApplicationContext28import kotlinx.coroutines.Job29import kotlinx.coroutines.flow.MutableStateFlow30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.launch33import javax.inject.Inject3435/**36 * The phone half of pairing: occupy a relay room, show the invitation, wait to37 * be scanned.38 *39 * There is one transport. LAN pairing codes are retired along with LAN sync40 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and41 * then presents the SAS for comparison.42 */43sealed interface CodePairUiState {44    data object Starting : CodePairUiState45    data class ShowingInvitation(val invitation: String) : CodePairUiState46    data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState47    data class Paired(val deviceName: String) : CodePairUiState48    data object Expired : CodePairUiState49    data class Failed(val reason: String) : CodePairUiState50}5152@HiltViewModel53class CodePairViewModel @Inject constructor(54    private val vault: VaultRepository,55    private val relayHost: RelayAdoptionHost,56    private val relaySettings: RelaySettingsStore,57    private val deviceStore: PairedDeviceStore,58    private val license: LicenseRepository,59    @ApplicationContext private val appContext: Context,60) : ViewModel() {6162    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)63    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6465    /**66     * The single live transport collector. Switching between relay and LAN67     * restarts the screen, and without cancelling the previous collector both68     * would keep writing [_state] — the older one landing last and winning.69     */70    private var collectJob: Job? = null7172    /**73     * The battery-exemption dialog to show, or null if it's already granted or74     * already been declined once. Consumed by the screen, which owns an75     * Activity to launch it from.76     */77    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)7879    fun start() {80        val master = vault.syncMasterKey() ?: run {81            _state.value = CodePairUiState.Failed("vault_locked")82            return83        }84        val decision = relaySettings.relayInForce()85        if (decision is RelayDecision.Refuse) {86            _state.value = CodePairUiState.Failed(decision.reason.wire())87            return88        }89        PeachSyncService.start(appContext)9091        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))92        val identity = buildLocalIdentity(appContext, master)93        VaultCrypto.zero(master)94        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))95    }9697    private fun startRelay(98        privateKey: ByteArray,99        publicKey: ByteArray,100        identity: LocalDeviceIdentity,101        sourceDeps: AdoptionSourceDeps,102    ) {103        collectJob = viewModelScope.launch {104            try {105                relayHost.start(106                    viewModelScope,107                    privateKey,108                    publicKey,109                    identity,110                    sourceDeps,111                ).collect { event ->112                    _state.value = when (event) {113                        is RelayAdoptionEvent.InvitationReady ->114                            CodePairUiState.ShowingInvitation(event.invitation)115                        is RelayAdoptionEvent.AwaitingConfirmation ->116                            CodePairUiState.ConfirmSas(117                                event.sas,118                                event.deviceName,119                                event.session,120                            )121                        is RelayAdoptionEvent.Adopted ->122                            CodePairUiState.Paired(event.deviceName)123                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired124                        is RelayAdoptionEvent.Rejected -> {125                            if (BuildConfig.DEBUG) {126                                Log.w(127                                    "PeachPairing",128                                    "relay adoption rejected: ${event.reason}",129                                )130                            }131                            CodePairUiState.Failed(event.reason)132                        }133                    }134                }135            } finally {136                VaultCrypto.zero(privateKey)137                VaultCrypto.zero(publicKey)138            }139        }140    }141142    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =143        object : AdoptionSourceDeps {144            override val sourceIdentity: LocalDeviceIdentity = identity145146            override suspend fun rootKeyHex(): String {147                val root = vault.syncMasterKey()148                    ?: throw IllegalStateException("vault_locked")149                return try {150                    root.joinToString("") { "%02x".format(it) }151                } finally {152                    VaultCrypto.zero(root)153                }154            }155156            override suspend fun vaultId(): String =157                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")158159            override suspend fun license(): String? =160                license.getLicenseKey()?.takeIf(license::validateLicense)161162            // RelayAdoptionHost owns the actual UI consent gate so it can bind163            // that decision to the session SAS before delegating here.164            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true165166            override suspend fun upsertPairedDevice(device: PairedDevice) {167                // SAS-confirmed direct adoption — the one consent signal strong168                // enough to lift a terminal revocation.169                deviceStore.clearRevocation(device.deviceId)170                deviceStore.upsert(device)171            }172        }173174    fun confirmSas(matches: Boolean) {175        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)176    }177178    fun restart() {179        collectJob?.cancel()180        collectJob = null181        relayHost.stop()182        _state.value = CodePairUiState.Starting183        start()184    }185186    fun stop() {187        collectJob?.cancel()188        collectJob = null189        relayHost.stop()190    }191192    override fun onCleared() {193        stop()194        super.onCleared()195    }196}