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.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.SyncMode import com.peachpasswords.sync.AdoptionSourceDeps import com.peachpasswords.sync.AdoptionFrame import com.peachpasswords.sync.LocalDeviceIdentity 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 pairing: occupy a relay room, show the invitation, wait to * be scanned. * * There is one transport. LAN pairing codes are retired along with LAN sync * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and * then presents the SAS for comparison. */ sealed interface CodePairUiState { data object Starting : CodePairUiState data class ShowingInvitation(val invitation: String) : 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 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 } if (relaySettings.syncMode() == SyncMode.OFF) { _state.value = CodePairUiState.Failed("sync_disabled") return } PeachSyncService.start(appContext) val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext)) val identity = buildLocalIdentity(appContext, master) VaultCrypto.zero(master) startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity)) } private fun startRelay( privateKey: ByteArray, publicKey: ByteArray, identity: LocalDeviceIdentity, 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: LocalDeviceIdentity): AdoptionSourceDeps = object : AdoptionSourceDeps { override val sourceIdentity: LocalDeviceIdentity = 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() _state.value = CodePairUiState.Starting start() } fun stop() { collectJob?.cancel() collectJob = null relayHost.stop() } override fun onCleared() { stop() super.onCleared() } }