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.PendingVouchedDevice import com.peachpasswords.sync.PendingVouchedDeviceStore import com.peachpasswords.sync.AdoptionFrame import com.peachpasswords.sync.AdoptionPairState import com.peachpasswords.sync.AdoptionSourceDeps import com.peachpasswords.sync.AdoptionSourceV2Deps import com.peachpasswords.sync.LocalDeviceIdentity import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.SignedDeviceManifest import com.peachpasswords.sync.VaultSyncAdapter import com.peachpasswords.sync.buildLocalIdentity import com.peachpasswords.sync.loadOrCreateLocalIdentity import com.peachpasswords.sync.localSyncKeypair import com.peachpasswords.sync.rotateDeviceId 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 pendingVouchedStore: PendingVouchedDeviceStore, 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. * * [userConfirmed] must come from the tap itself โ€” see [BatteryExemption]'s * distribution note. When it yields nothing, sync still works; it just runs * from the foreground ([BatteryExemption.syncOnce]). */ fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? = BatteryExemption.requestIntent(appContext, userConfirmed) /** Degraded sync for a device with no Doze exemption โ€” see [BatteryExemption.syncOnce]. */ fun syncOnce() = BatteryExemption.syncOnce(appContext) /** * The vault owner's answer to the source-side SAS prompt (PB-26-116). The * scanning device's own confirmation is not enough: ยง10.2 puts a decision on * the device that hands over the root key too. */ fun confirmSas(matches: Boolean) = relayHost.confirmSas(matches) 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 credentials = loadOrCreateLocalIdentity(appContext) val keys = localSyncKeypair(credentials) val identity = buildLocalIdentity(appContext, credentials) VaultCrypto.zero(master) startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity)) } private fun startRelay( privateKey: ByteArray, publicKey: ByteArray, identity: LocalDeviceIdentity, sourceDeps: AdoptionSourceV2Deps, ) { 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): AdoptionSourceV2Deps = object : AdoptionSourceV2Deps { override val sourceIdentity: LocalDeviceIdentity = identity // The relay host binds the live pair state and the human SAS prompt // when it wraps this object. override val pairState: AdoptionPairState? get() = null override fun snapshotCapable(): Boolean = true override suspend fun onConsent(request: AdoptionFrame.RequestV2): Boolean = true // The v2 holder is keyed on the validated identity. ADOPT_REQUEST_V2 // carries one; there is no request label to key on. override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean { val deviceName = device.deviceName ?: return false val deviceType = device.deviceType ?: return false val longTermKey = device.longTermPublicKeyHex ?: return false val signingKey = device.signingPublicKeyHex ?: return false return runCatching { pendingVouchedStore.upsertAndReport( PendingVouchedDevice( deviceId = device.deviceId, deviceName = deviceName, deviceType = deviceType, longTermPublicKeyHex = longTermKey, signingPublicKeyHex = signingKey, wsPort = device.wsPort, pairedAt = nowMs, receivedAt = nowMs, vouchedByDeviceId = identity.deviceId, ), ) }.getOrDefault(false) } override suspend fun completePendingIdentity(device: PairedDevice): Boolean = runCatching { pendingVouchedStore.removeIdentityAndReport(device) }.getOrDefault(false) 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) override suspend fun applyLicense(licenseKey: String) { if (!license.storeLicense(licenseKey) || !license.isProActive()) { throw IllegalStateException("license_transfer_failed") } } // Opening this source screen while unlocked is the source-side // consent. The scanning device confirms the SAS before any grant. override suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long): Boolean = runCatching { pendingVouchedStore.upsertAndReport( PendingVouchedDevice( deviceId = request.sessionRequestId, deviceName = request.deviceName, deviceType = request.deviceType, longTermPublicKeyHex = identity.longTermPublicKeyHex, signingPublicKeyHex = identity.signingPublicKeyHex, pairedAt = nowMs, receivedAt = nowMs, vouchedByDeviceId = identity.deviceId, ), ) }.getOrDefault(false) override suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice): Boolean = runCatching { pendingVouchedStore.removeAndReport(request.sessionRequestId) }.getOrDefault(false) override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean = deviceStore.hasIdentityCollision(device) override suspend fun rotateSourceIdentity(): LocalDeviceIdentity { rotateDeviceId(appContext) val master = vault.syncMasterKey() ?: throw IllegalStateException("vault_locked") return try { buildLocalIdentity(appContext, master) } finally { VaultCrypto.zero(master) } } override suspend fun upsertPairedDevice(device: PairedDevice) { // SAS-confirmed direct adoption atomically validates identity, // lifts terminal revocation, and persists the paired row. deviceStore.upsertAndClearRevocation(device) } override suspend fun vaultSnapshot(): Pair, Map>? { val credentials = loadOrCreateLocalIdentity(appContext) val adapter = VaultSyncAdapter( repo = vault, ourDeviceId = credentials.deviceId, pairedDevices = deviceStore, localCredentials = credentials, ) val entryManifest = adapter.buildEntryManifest() val entryIds = entryManifest.keys.toList() val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap() val tombstones = adapter.buildTombstoneManifest() return entries to tombstones } override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? { val credentials = loadOrCreateLocalIdentity(appContext) val adapter = VaultSyncAdapter( repo = vault, ourDeviceId = credentials.deviceId, pairedDevices = deviceStore, localCredentials = credentials, ) return adapter.buildOutgoingDeviceManifest() } override suspend fun markSynced(deviceId: String) { deviceStore.updateLastSync(deviceId) } override suspend fun duressSlotJson(): String? = vault.exportDuressSlotForAdoption() } 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() } }