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.Inject383940private const val CONNECT_TIMEOUT_MS = 5_00041424344454647484950@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 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 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 127 128 return if (license.isProActive()) base.copy(license = license.getLicenseKey()) else base129 }130131 override suspend fun completePairing(paired: PairedDevice) {132 133 deviceStore.upsert(paired)134 }135136 override suspend fun activatePeerLicense(license: String) {137 138 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 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 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}