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.buildLocalIdentity import com.peachpasswords.sync.deriveLocalSyncKeypair import com.peachpasswords.sync.getOrCreateDeviceId import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext 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 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, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _state = MutableStateFlow(CodePairUiState.Starting) val state: StateFlow = _state.asStateFlow() /** * 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) // 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) } viewModelScope.launch { server.start(viewModelScope, keys.secretKey, keys.publicKey, identity).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 } } } } fun confirmSas(matches: Boolean) { (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches) } fun restart() { server.stop() _state.value = CodePairUiState.Starting start() } fun stop() { server.confirmationSink = null server.stop() } override fun onCleared() { stop() super.onCleared() } }