1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import com.peachpasswords.BuildConfig6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.BatteryExemption9import com.peachpasswords.sync.CodePairingServer10import com.peachpasswords.sync.PairingEvent11import com.peachpasswords.sync.PairingSession12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelayAdoptionEvent14import com.peachpasswords.sync.RelayAdoptionHost15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.AdoptionSourceDeps17import com.peachpasswords.sync.AdoptionFrame18import com.peachpasswords.sync.LocalChirpIdentity19import com.peachpasswords.sync.PairedDevice20import com.peachpasswords.sync.PairedDeviceStore21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import com.peachpasswords.license.LicenseRepository27import dagger.hilt.android.lifecycle.HiltViewModel28import dagger.hilt.android.qualifiers.ApplicationContext29import kotlinx.coroutines.Job30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.launch34import javax.inject.Inject353637383940414243sealed interface CodePairUiState {44 data object Starting : CodePairUiState45 data class ShowingInvitation(val invitation: String) : CodePairUiState46 data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState47 data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState48 data class Paired(val deviceName: String) : CodePairUiState49 data object Expired : CodePairUiState50 data class Failed(val reason: String) : CodePairUiState51}5253@HiltViewModel54class CodePairViewModel @Inject constructor(55 private val vault: VaultRepository,56 private val server: CodePairingServer,57 private val relayHost: RelayAdoptionHost,58 private val relaySettings: RelaySettingsStore,59 private val deviceStore: PairedDeviceStore,60 private val license: LicenseRepository,61 @ApplicationContext private val appContext: Context,62) : ViewModel() {6364 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)65 val state: StateFlow<CodePairUiState> = _state.asStateFlow()6667 6869707172 private var collectJob: Job? = null7374 7576777879 fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)8081 fun start() {82 val master = vault.syncMasterKey() ?: run {83 _state.value = CodePairUiState.Failed("vault_locked")84 return85 }86 PeachSyncService.start(appContext)8788 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))89 val identity = buildLocalIdentity(appContext, master)90 VaultCrypto.zero(master)91 val sourceDeps = sourceDeps(identity)9293 if (!relaySettings.isLanOnly()) {94 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)95 return96 }9798 99 100 101 server.confirmationSink = { pending ->102 _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)103 }104105 collectJob = viewModelScope.launch {106 try {107 server.start(108 viewModelScope,109 keys.secretKey,110 keys.publicKey,111 identity,112 adoptionSourceDeps = sourceDeps,113 ).collect { event ->114 _state.value = when (event) {115 is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)116 is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)117 PairingEvent.Expired -> CodePairUiState.Expired118 is PairingEvent.Rejected -> {119 120 121 122 if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")123 CodePairUiState.Failed(event.reason)124 }125 126 is PairingEvent.AwaitingConfirmation -> _state.value127 }128 }129 } finally {130 VaultCrypto.zero(keys.secretKey)131 VaultCrypto.zero(keys.publicKey)132 }133 }134 }135136 private fun startRelay(137 privateKey: ByteArray,138 publicKey: ByteArray,139 identity: LocalChirpIdentity,140 sourceDeps: AdoptionSourceDeps,141 ) {142 collectJob = viewModelScope.launch {143 try {144 relayHost.start(145 viewModelScope,146 privateKey,147 publicKey,148 identity,149 sourceDeps,150 ).collect { event ->151 _state.value = when (event) {152 is RelayAdoptionEvent.InvitationReady ->153 CodePairUiState.ShowingInvitation(event.invitation)154 is RelayAdoptionEvent.AwaitingConfirmation ->155 CodePairUiState.ConfirmSas(156 event.sas,157 event.deviceName,158 event.session,159 )160 is RelayAdoptionEvent.Adopted ->161 CodePairUiState.Paired(event.deviceName)162 RelayAdoptionEvent.Expired -> CodePairUiState.Expired163 is RelayAdoptionEvent.Rejected -> {164 if (BuildConfig.DEBUG) {165 Log.w(166 "PeachPairing",167 "relay adoption rejected: ${event.reason}",168 )169 }170 CodePairUiState.Failed(event.reason)171 }172 }173 }174 } finally {175 VaultCrypto.zero(privateKey)176 VaultCrypto.zero(publicKey)177 }178 }179 }180181 private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =182 object : AdoptionSourceDeps {183 override val sourceIdentity: LocalChirpIdentity = identity184185 override suspend fun rootKeyHex(): String {186 val root = vault.syncMasterKey()187 ?: throw IllegalStateException("vault_locked")188 return try {189 root.joinToString("") { "%02x".format(it) }190 } finally {191 VaultCrypto.zero(root)192 }193 }194195 override suspend fun vaultId(): String =196 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")197198 override suspend fun license(): String? =199 license.getLicenseKey()?.takeIf(license::validateLicense)200201 202 203 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true204205 override suspend fun upsertPairedDevice(device: PairedDevice) {206 207 208 deviceStore.clearRevocation(device.deviceId)209 deviceStore.upsert(device)210 }211 }212213 fun confirmSas(matches: Boolean) {214 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)215 }216217 fun restart() {218 collectJob?.cancel()219 collectJob = null220 relayHost.stop()221 server.stop()222 _state.value = CodePairUiState.Starting223 start()224 }225226 fun useLanOnly(lanOnly: Boolean) {227 relaySettings.setLanOnly(lanOnly)228 restart()229 }230231 fun stop() {232 collectJob?.cancel()233 collectJob = null234 relayHost.stop()235 server.confirmationSink = null236 server.stop()237 }238239 override fun onCleared() {240 stop()241 super.onCleared()242 }243}