package com.peachpasswords.ui.pairing import android.content.Context import android.util.Log import com.peachpasswords.BuildConfig import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.sync.BatteryExemption import com.peachpasswords.sync.CodePairingServer import com.peachpasswords.sync.PairingEvent import com.peachpasswords.sync.PairingSession import com.peachpasswords.sync.PeachSyncService import com.peachpasswords.sync.RelayAdoptionEvent import com.peachpasswords.sync.RelayAdoptionHost import com.peachpasswords.sync.RelaySettingsStore import com.peachpasswords.sync.AdoptionSourceDeps import com.peachpasswords.sync.AdoptionFrame import com.peachpasswords.sync.LocalChirpIdentity import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.buildLocalIdentity import com.peachpasswords.sync.deriveLocalSyncKeypair import com.peachpasswords.sync.getOrCreateDeviceId import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import com.peachpasswords.license.LicenseRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject /** * The phone half of code pairing: show a code, wait to be dialed. * * The browser drives the conversation from here on — it is always the dialer * (see [CodePairingServer] for the wire contract), so there is nothing for this * screen to do but publish the code and then present the SAS for comparison. */ sealed interface CodePairUiState { data object Starting : CodePairUiState data class ShowingInvitation(val invitation: String) : CodePairUiState data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState data class Paired(val deviceName: String) : CodePairUiState data object Expired : CodePairUiState data class Failed(val reason: String) : CodePairUiState } @HiltViewModel class CodePairViewModel @Inject constructor( private val vault: VaultRepository, private val server: CodePairingServer, private val relayHost: RelayAdoptionHost, private val relaySettings: RelaySettingsStore, private val deviceStore: PairedDeviceStore, private val license: LicenseRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow(CodePairUiState.Starting) val state: StateFlow = _state.asStateFlow() /** * The single live transport collector. Switching between relay and LAN * restarts the screen, and without cancelling the previous collector both * would keep writing [_state] — the older one landing last and winning. */ private var collectJob: Job? = null /** * The battery-exemption dialog to show, or null if it's already granted or * already been declined once. Consumed by the screen, which owns an * Activity to launch it from. */ fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext) fun start() { val master = vault.syncMasterKey() ?: run { _state.value = CodePairUiState.Failed("vault_locked") return } PeachSyncService.start(appContext) val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext)) val identity = buildLocalIdentity(appContext, master) VaultCrypto.zero(master) val sourceDeps = sourceDeps(identity) if (!relaySettings.isLanOnly()) { startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps) return } // The handler blocks inside the live connection while the user compares // digits, so the SAS has to reach the UI out of band rather than as the // connection's result. server.confirmationSink = { pending -> _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session) } collectJob = viewModelScope.launch { try { server.start( viewModelScope, keys.secretKey, keys.publicKey, identity, adoptionSourceDeps = sourceDeps, ).collect { event -> _state.value = when (event) { is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port) is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName) PairingEvent.Expired -> CodePairUiState.Expired is PairingEvent.Rejected -> { // Nothing else records why a pairing attempt died, and // the on-screen copy is deliberately vague. Without this // a failure on someone else's phone is undiagnosable. if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}") CodePairUiState.Failed(event.reason) } // Delivered via confirmationSink; the flow only sees the outcome. is PairingEvent.AwaitingConfirmation -> _state.value } } } finally { VaultCrypto.zero(keys.secretKey) VaultCrypto.zero(keys.publicKey) } } } private fun startRelay( privateKey: ByteArray, publicKey: ByteArray, identity: LocalChirpIdentity, sourceDeps: AdoptionSourceDeps, ) { collectJob = viewModelScope.launch { try { relayHost.start( viewModelScope, privateKey, publicKey, identity, sourceDeps, ).collect { event -> _state.value = when (event) { is RelayAdoptionEvent.InvitationReady -> CodePairUiState.ShowingInvitation(event.invitation) is RelayAdoptionEvent.AwaitingConfirmation -> CodePairUiState.ConfirmSas( event.sas, event.deviceName, event.session, ) is RelayAdoptionEvent.Adopted -> CodePairUiState.Paired(event.deviceName) RelayAdoptionEvent.Expired -> CodePairUiState.Expired is RelayAdoptionEvent.Rejected -> { if (BuildConfig.DEBUG) { Log.w( "PeachPairing", "relay adoption rejected: ${event.reason}", ) } CodePairUiState.Failed(event.reason) } } } } finally { VaultCrypto.zero(privateKey) VaultCrypto.zero(publicKey) } } } private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps = object : AdoptionSourceDeps { override val sourceIdentity: LocalChirpIdentity = identity override suspend fun rootKeyHex(): String { val root = vault.syncMasterKey() ?: throw IllegalStateException("vault_locked") return try { root.joinToString("") { "%02x".format(it) } } finally { VaultCrypto.zero(root) } } override suspend fun vaultId(): String = vault.syncVaultId() ?: throw IllegalStateException("vault_locked") override suspend fun license(): String? = license.getLicenseKey()?.takeIf(license::validateLicense) // RelayAdoptionHost owns the actual UI consent gate so it can bind // that decision to the session SAS before delegating here. override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true override suspend fun upsertPairedDevice(device: PairedDevice) { // SAS-confirmed direct adoption — the one consent signal strong // enough to lift a terminal revocation. deviceStore.clearRevocation(device.deviceId) deviceStore.upsert(device) } } fun confirmSas(matches: Boolean) { (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches) } fun restart() { collectJob?.cancel() collectJob = null relayHost.stop() server.stop() _state.value = CodePairUiState.Starting start() } fun useLanOnly(lanOnly: Boolean) { relaySettings.setLanOnly(lanOnly) restart() } fun stop() { collectJob?.cancel() collectJob = null relayHost.stop() server.confirmationSink = null server.stop() } override fun onCleared() { stop() super.onCleared() } }