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.BackgroundSyncPreference 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.RelayDecision 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 AwaitingPeerConfirmation(val sas: String, val deviceName: String) : 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 backgroundSync: BackgroundSyncPreference, 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() private val _syncMode = MutableStateFlow(relaySettings.syncMode()) val syncMode: StateFlow = _syncMode.asStateFlow() private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl()) val privateRelayUrl: StateFlow = _privateRelayUrl.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 } val decision = relaySettings.relayInForce() if (decision is RelayDecision.Refuse) { _state.value = CodePairUiState.Failed(decision.reason.wire()) 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.AwaitingPeerConfirmation -> CodePairUiState.AwaitingPeerConfirmation( event.sas, event.deviceName, ) 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) // Opening this source screen while unlocked is the source-side // consent. The scanning device confirms the SAS before any grant. 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 setBackgroundSync(enabled: Boolean) { backgroundSync.setEnabled(enabled) if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext) } fun selectRelayMode(mode: SyncMode) { require(mode != SyncMode.OFF) { "Pairing requires a relay" } relaySettings.setSyncMode(mode) refreshRelayState() if (relaySettings.relayInForce() is RelayDecision.Dial) { restart() } else { stop() _state.value = CodePairUiState.Failed("private_relay_unset") } } fun savePrivateRelayUrl(input: String): Boolean = runCatching { relaySettings.setRelayUrl(input) refreshRelayState() restart() true }.getOrDefault(false) private fun refreshRelayState() { _syncMode.value = relaySettings.syncMode() _privateRelayUrl.value = relaySettings.privateRelayUrl() } 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() } }