ChirpPairViewModel.kt

7.6 KB · raw

1package com.peachpasswords.ui.pairing23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.BuildConfig7import com.peachpasswords.license.LicenseRepository8import com.peachpasswords.sync.AudioIO9import com.peachpasswords.sync.ChirpPairingDeps10import com.peachpasswords.sync.LocalChirpIdentity11import com.peachpasswords.sync.PairedDevice12import com.peachpasswords.sync.PairingEndpoint13import com.peachpasswords.sync.PairingPhase14import com.peachpasswords.sync.PairingProgress15import com.peachpasswords.sync.PairingRole16import com.peachpasswords.sync.PairingServer17import com.peachpasswords.sync.PairingTransport18import com.peachpasswords.sync.PairedDeviceStore19import com.peachpasswords.sync.SocketFrameIO20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.openAndroidAudioIO22import com.peachpasswords.sync.runChirpPairing23import com.peachpasswords.vault.VaultCrypto24import com.peachpasswords.vault.VaultRepository25import dagger.hilt.android.lifecycle.HiltViewModel26import dagger.hilt.android.qualifiers.ApplicationContext27import kotlinx.coroutines.CompletableDeferred28import kotlinx.coroutines.Dispatchers29import kotlinx.coroutines.delay30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.launch34import kotlinx.coroutines.withContext35import java.net.ServerSocket36import java.net.Socket37import javax.inject.Inject3839/** TCP connect timeout for the initiator's pairing socket. */40private const val CONNECT_TIMEOUT_MS = 5_0004142/**43 * Drives the Chirp pairing flow and surfaces its phase + SAS code to44 * [ChirpPairScreen]. The real audio bridge ([openAndroidAudioIO]) lands in45 * Phase 12; until then [startPairing] runs the genuine orchestrator and46 * surfaces the audio-unavailable phase honestly, while [runDemo] (DEBUG only)47 * steps the visuals through all three acts so the animation and SAS screen can48 * be verified on a single device.49 */50@HiltViewModel51class ChirpPairViewModel @Inject constructor(52    @ApplicationContext private val context: Context,53    private val vault: VaultRepository,54    private val deviceStore: PairedDeviceStore,55    private val license: LicenseRepository,56) : ViewModel() {5758    private val _progress = MutableStateFlow(PairingProgress(PairingPhase.IDLE))59    val progress: StateFlow<PairingProgress> = _progress.asStateFlow()6061    private val _sasCode = MutableStateFlow<String?>(null)62    val sasCode: StateFlow<String?> = _sasCode.asStateFlow()6364    val isDebug: Boolean = BuildConfig.DEBUG6566    private var sasGate: CompletableDeferred<Boolean>? = null6768    fun startPairing() {69        val master = vault.syncMasterKey() ?: run {70            _progress.value = PairingProgress(PairingPhase.HANDSHAKE_FAILED, errorMessage = "vault_locked")71            return72        }73        _progress.value = PairingProgress(PairingPhase.LISTENING)74        viewModelScope.launch {75            try {76                runChirpPairing(deps(master), role = PairingRole.INITIATOR)77            } catch (e: Exception) {78                if (_progress.value.phase.let { it != PairingPhase.TIMEOUT && it != PairingPhase.MIC_DENIED }) {79                    _progress.value = PairingProgress(80                        PairingPhase.HANDSHAKE_FAILED,81                        errorMessage = e.message ?: "pairing_failed",82                    )83                }84            } finally {85                VaultCrypto.zero(master)86            }87        }88    }8990    fun confirmSas(accepted: Boolean) {91        sasGate?.complete(accepted)92        _sasCode.value = null93    }9495    fun reset() {96        sasGate?.complete(false)97        sasGate = null98        _sasCode.value = null99        _progress.value = PairingProgress(PairingPhase.IDLE)100    }101102    /** DEBUG: walk the three acts so the screen can be exercised without a peer. */103    fun runDemo() {104        if (!isDebug) return105        viewModelScope.launch {106            _progress.value = PairingProgress(PairingPhase.IDLE)107            delay(700)108            _progress.value = PairingProgress(PairingPhase.LISTENING)109            delay(1800)110            _progress.value = PairingProgress(PairingPhase.HANDSHAKE, peerDeviceName = "June's Laptop")111            _sasCode.value = "418 207"112        }113    }114115    /** DEBUG: complete the demo into the paired act after SAS confirmation. */116    fun demoComplete() {117        _sasCode.value = null118        _progress.value = PairingProgress(PairingPhase.PAIRED, peerDeviceName = "June's Laptop")119    }120121    private fun deps(masterKey: ByteArray): ChirpPairingDeps = object : ChirpPairingDeps {122        override val chirpTimeoutMs: Long = 15_000123124        override suspend fun fetchLocalIdentity(): LocalChirpIdentity {125            val base = buildLocalIdentity(context, masterKey)126            // Advertise our signed license iff this device is Pro, so the peer127            // can activate it after SAS — pairing carries Pro across.128            return if (license.isProActive()) base.copy(license = license.getLicenseKey()) else base129        }130131        override suspend fun completePairing(paired: PairedDevice) {132            // Persist the newly-trusted peer so Sync can dial it later (C7 wiring).133            deviceStore.upsert(paired)134        }135136        override suspend fun activatePeerLicense(license: String) {137            // Receive Pro from an already-Pro peer. Verify the signature; keep138            // our own license if we already have one.139            if (this@ChirpPairViewModel.license.isProActive()) return140            if (this@ChirpPairViewModel.license.validateLicense(license)) {141                this@ChirpPairViewModel.license.storeLicense(license)142            }143        }144145        override suspend fun confirmSAS(sas: String): Boolean {146            val gate = CompletableDeferred<Boolean>()147            sasGate = gate148            _sasCode.value = sas149            return gate.await()150        }151152        override suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String) {153            deviceStore.getAll().firstOrNull { it.longTermPublicKeyHex == pubkeyHex }?.let {154                deviceStore.updateLastSeen(it.deviceId, ip)155            }156        }157        override suspend fun getLocalSubnet(): String? = null158159        // Initiator side: dial the peer and frame the TCP socket as a PairingTransport.160        override suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport =161            withContext(Dispatchers.IO) {162                val socket = Socket()163                socket.connect(java.net.InetSocketAddress(endpoint.ip, endpoint.port), CONNECT_TIMEOUT_MS)164                SocketFrameIO(socket.getInputStream(), socket.getOutputStream(), onClose = {165                    withContext(Dispatchers.IO) { runCatching { socket.close() } }166                })167            }168169        // Responder side: bind an ephemeral port, accept one peer, frame it.170        override suspend fun openPairingServer(): PairingServer =171            withContext(Dispatchers.IO) {172                val server = ServerSocket(0)173                val socket = server.accept()174                val peerIp = socket.inetAddress?.hostAddress ?: ""175                PairingServer(176                    transport = SocketFrameIO(socket.getInputStream(), socket.getOutputStream(), onClose = {177                        withContext(Dispatchers.IO) { runCatching { socket.close() }; runCatching { server.close() } }178                    }),179                    peerIp = peerIp,180                )181            }182        override suspend fun openAudio(): AudioIO = openAndroidAudioIO()183        override fun onProgress(progress: PairingProgress) {184            _progress.value = progress185        }186    }187}