package com.peachpasswords.ui.pairing import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.BuildConfig import com.peachpasswords.license.LicenseRepository import com.peachpasswords.sync.AudioIO import com.peachpasswords.sync.ChirpPairingDeps import com.peachpasswords.sync.LocalChirpIdentity import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairingEndpoint import com.peachpasswords.sync.PairingPhase import com.peachpasswords.sync.PairingProgress import com.peachpasswords.sync.PairingRole import com.peachpasswords.sync.PairingServer import com.peachpasswords.sync.PairingTransport import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.SocketFrameIO import com.peachpasswords.sync.buildLocalIdentity import com.peachpasswords.sync.openAndroidAudioIO import com.peachpasswords.sync.runChirpPairing 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.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.ServerSocket import java.net.Socket import javax.inject.Inject /** TCP connect timeout for the initiator's pairing socket. */ private const val CONNECT_TIMEOUT_MS = 5_000 /** * Drives the Chirp pairing flow and surfaces its phase + SAS code to * [ChirpPairScreen]. The real audio bridge ([openAndroidAudioIO]) lands in * Phase 12; until then [startPairing] runs the genuine orchestrator and * surfaces the audio-unavailable phase honestly, while [runDemo] (DEBUG only) * steps the visuals through all three acts so the animation and SAS screen can * be verified on a single device. */ @HiltViewModel class ChirpPairViewModel @Inject constructor( @ApplicationContext private val context: Context, private val vault: VaultRepository, private val deviceStore: PairedDeviceStore, private val license: LicenseRepository, ) : ViewModel() { private val _progress = MutableStateFlow(PairingProgress(PairingPhase.IDLE)) val progress: StateFlow = _progress.asStateFlow() private val _sasCode = MutableStateFlow(null) val sasCode: StateFlow = _sasCode.asStateFlow() val isDebug: Boolean = BuildConfig.DEBUG private var sasGate: CompletableDeferred? = null fun startPairing() { val master = vault.syncMasterKey() ?: run { _progress.value = PairingProgress(PairingPhase.HANDSHAKE_FAILED, errorMessage = "vault_locked") return } _progress.value = PairingProgress(PairingPhase.LISTENING) viewModelScope.launch { try { runChirpPairing(deps(master), role = PairingRole.INITIATOR) } catch (e: Exception) { if (_progress.value.phase.let { it != PairingPhase.TIMEOUT && it != PairingPhase.MIC_DENIED }) { _progress.value = PairingProgress( PairingPhase.HANDSHAKE_FAILED, errorMessage = e.message ?: "pairing_failed", ) } } finally { VaultCrypto.zero(master) } } } fun confirmSas(accepted: Boolean) { sasGate?.complete(accepted) _sasCode.value = null } fun reset() { sasGate?.complete(false) sasGate = null _sasCode.value = null _progress.value = PairingProgress(PairingPhase.IDLE) } /** DEBUG: walk the three acts so the screen can be exercised without a peer. */ fun runDemo() { if (!isDebug) return viewModelScope.launch { _progress.value = PairingProgress(PairingPhase.IDLE) delay(700) _progress.value = PairingProgress(PairingPhase.LISTENING) delay(1800) _progress.value = PairingProgress(PairingPhase.HANDSHAKE, peerDeviceName = "June's Laptop") _sasCode.value = "418 207" } } /** DEBUG: complete the demo into the paired act after SAS confirmation. */ fun demoComplete() { _sasCode.value = null _progress.value = PairingProgress(PairingPhase.PAIRED, peerDeviceName = "June's Laptop") } private fun deps(masterKey: ByteArray): ChirpPairingDeps = object : ChirpPairingDeps { override val chirpTimeoutMs: Long = 15_000 override suspend fun fetchLocalIdentity(): LocalChirpIdentity { val base = buildLocalIdentity(context, masterKey) // Advertise our signed license iff this device is Pro, so the peer // can activate it after SAS — pairing carries Pro across. return if (license.isProActive()) base.copy(license = license.getLicenseKey()) else base } override suspend fun completePairing(paired: PairedDevice) { // Persist the newly-trusted peer so Sync can dial it later (C7 wiring). deviceStore.upsert(paired) } override suspend fun activatePeerLicense(license: String) { // Receive Pro from an already-Pro peer. Verify the signature; keep // our own license if we already have one. if (this@ChirpPairViewModel.license.isProActive()) return if (this@ChirpPairViewModel.license.validateLicense(license)) { this@ChirpPairViewModel.license.storeLicense(license) } } override suspend fun confirmSAS(sas: String): Boolean { val gate = CompletableDeferred() sasGate = gate _sasCode.value = sas return gate.await() } override suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String) { deviceStore.getAll().firstOrNull { it.longTermPublicKeyHex == pubkeyHex }?.let { deviceStore.updateLastSeen(it.deviceId, ip) } } override suspend fun getLocalSubnet(): String? = null // Initiator side: dial the peer and frame the TCP socket as a PairingTransport. override suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport = withContext(Dispatchers.IO) { val socket = Socket() socket.connect(java.net.InetSocketAddress(endpoint.ip, endpoint.port), CONNECT_TIMEOUT_MS) SocketFrameIO(socket.getInputStream(), socket.getOutputStream(), onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } }) } // Responder side: bind an ephemeral port, accept one peer, frame it. override suspend fun openPairingServer(): PairingServer = withContext(Dispatchers.IO) { val server = ServerSocket(0) val socket = server.accept() val peerIp = socket.inetAddress?.hostAddress ?: "" PairingServer( transport = SocketFrameIO(socket.getInputStream(), socket.getOutputStream(), onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() }; runCatching { server.close() } } }), peerIp = peerIp, ) } override suspend fun openAudio(): AudioIO = openAndroidAudioIO() override fun onProgress(progress: PairingProgress) { _progress.value = progress } } }