CodePairViewModel.kt

7.4 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.SyncMode15import com.peachpasswords.sync.AdoptionSourceDeps16import com.peachpasswords.sync.AdoptionFrame17import com.peachpasswords.sync.LocalChirpIdentity18import 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        if (relaySettings.syncMode() == SyncMode.OFF) {85            _state.value = CodePairUiState.Failed("sync_disabled")86            return87        }88        PeachSyncService.start(appContext)8990        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))91        val identity = buildLocalIdentity(appContext, master)92        VaultCrypto.zero(master)93        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))94    }9596    private fun startRelay(97        privateKey: ByteArray,98        publicKey: ByteArray,99        identity: LocalChirpIdentity,100        sourceDeps: AdoptionSourceDeps,101    ) {102        collectJob = viewModelScope.launch {103            try {104                relayHost.start(105                    viewModelScope,106                    privateKey,107                    publicKey,108                    identity,109                    sourceDeps,110                ).collect { event ->111                    _state.value = when (event) {112                        is RelayAdoptionEvent.InvitationReady ->113                            CodePairUiState.ShowingInvitation(event.invitation)114                        is RelayAdoptionEvent.AwaitingConfirmation ->115                            CodePairUiState.ConfirmSas(116                                event.sas,117                                event.deviceName,118                                event.session,119                            )120                        is RelayAdoptionEvent.Adopted ->121                            CodePairUiState.Paired(event.deviceName)122                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired123                        is RelayAdoptionEvent.Rejected -> {124                            if (BuildConfig.DEBUG) {125                                Log.w(126                                    "PeachPairing",127                                    "relay adoption rejected: ${event.reason}",128                                )129                            }130                            CodePairUiState.Failed(event.reason)131                        }132                    }133                }134            } finally {135                VaultCrypto.zero(privateKey)136                VaultCrypto.zero(publicKey)137            }138        }139    }140141    private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =142        object : AdoptionSourceDeps {143            override val sourceIdentity: LocalChirpIdentity = identity144145            override suspend fun rootKeyHex(): String {146                val root = vault.syncMasterKey()147                    ?: throw IllegalStateException("vault_locked")148                return try {149                    root.joinToString("") { "%02x".format(it) }150                } finally {151                    VaultCrypto.zero(root)152                }153            }154155            override suspend fun vaultId(): String =156                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")157158            override suspend fun license(): String? =159                license.getLicenseKey()?.takeIf(license::validateLicense)160161            // RelayAdoptionHost owns the actual UI consent gate so it can bind162            // that decision to the session SAS before delegating here.163            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true164165            override suspend fun upsertPairedDevice(device: PairedDevice) {166                // SAS-confirmed direct adoption — the one consent signal strong167                // enough to lift a terminal revocation.168                deviceStore.clearRevocation(device.deviceId)169                deviceStore.upsert(device)170            }171        }172173    fun confirmSas(matches: Boolean) {174        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)175    }176177    fun restart() {178        collectJob?.cancel()179        collectJob = null180        relayHost.stop()181        _state.value = CodePairUiState.Starting182        start()183    }184185    fun stop() {186        collectJob?.cancel()187        collectJob = null188        relayHost.stop()189    }190191    override fun onCleared() {192        stop()193        super.onCleared()194    }195}