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.flow.MutableStateFlow30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.launch33import javax.inject.Inject343536373839404142sealed interface CodePairUiState {43 data object Starting : CodePairUiState44 data class ShowingInvitation(val invitation: String) : CodePairUiState45 data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState46 data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState47 data class Paired(val deviceName: String) : CodePairUiState48 data object Expired : CodePairUiState49 data class Failed(val reason: String) : CodePairUiState50}5152@HiltViewModel53class CodePairViewModel @Inject constructor(54 private val vault: VaultRepository,55 private val server: CodePairingServer,56 private val relayHost: RelayAdoptionHost,57 private val relaySettings: RelaySettingsStore,58 private val deviceStore: PairedDeviceStore,59 private val license: LicenseRepository,60 @ApplicationContext private val appContext: Context,61) : ViewModel() {6263 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)64 val state: StateFlow<CodePairUiState> = _state.asStateFlow()6566 6768697071 fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)7273 fun start() {74 val master = vault.syncMasterKey() ?: run {75 _state.value = CodePairUiState.Failed("vault_locked")76 return77 }78 PeachSyncService.start(appContext)7980 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))81 val identity = buildLocalIdentity(appContext, master)82 VaultCrypto.zero(master)83 val sourceDeps = sourceDeps(identity)8485 if (!relaySettings.isLanOnly()) {86 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)87 return88 }8990 91 92 93 server.confirmationSink = { pending ->94 _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)95 }9697 viewModelScope.launch {98 try {99 server.start(100 viewModelScope,101 keys.secretKey,102 keys.publicKey,103 identity,104 adoptionSourceDeps = sourceDeps,105 ).collect { event ->106 _state.value = when (event) {107 is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)108 is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)109 PairingEvent.Expired -> CodePairUiState.Expired110 is PairingEvent.Rejected -> {111 112 113 114 if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")115 CodePairUiState.Failed(event.reason)116 }117 118 is PairingEvent.AwaitingConfirmation -> _state.value119 }120 }121 } finally {122 VaultCrypto.zero(keys.secretKey)123 VaultCrypto.zero(keys.publicKey)124 }125 }126 }127128 private fun startRelay(129 privateKey: ByteArray,130 publicKey: ByteArray,131 identity: LocalChirpIdentity,132 sourceDeps: AdoptionSourceDeps,133 ) {134 viewModelScope.launch {135 try {136 relayHost.start(137 viewModelScope,138 privateKey,139 publicKey,140 identity,141 sourceDeps,142 ).collect { event ->143 _state.value = when (event) {144 is RelayAdoptionEvent.InvitationReady ->145 CodePairUiState.ShowingInvitation(event.invitation)146 is RelayAdoptionEvent.AwaitingConfirmation ->147 CodePairUiState.ConfirmSas(148 event.sas,149 event.deviceName,150 event.session,151 )152 is RelayAdoptionEvent.Adopted ->153 CodePairUiState.Paired(event.deviceName)154 RelayAdoptionEvent.Expired -> CodePairUiState.Expired155 is RelayAdoptionEvent.Rejected -> {156 if (BuildConfig.DEBUG) {157 Log.w(158 "PeachPairing",159 "relay adoption rejected: ${event.reason}",160 )161 }162 CodePairUiState.Failed(event.reason)163 }164 }165 }166 } finally {167 VaultCrypto.zero(privateKey)168 VaultCrypto.zero(publicKey)169 }170 }171 }172173 private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =174 object : AdoptionSourceDeps {175 override val sourceIdentity: LocalChirpIdentity = identity176177 override suspend fun rootKeyHex(): String {178 val root = vault.syncMasterKey()179 ?: throw IllegalStateException("vault_locked")180 return try {181 root.joinToString("") { "%02x".format(it) }182 } finally {183 VaultCrypto.zero(root)184 }185 }186187 override suspend fun vaultId(): String =188 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")189190 override suspend fun license(): String? =191 license.getLicenseKey()?.takeIf(license::validateLicense)192193 194 195 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true196197 override suspend fun upsertPairedDevice(device: PairedDevice) {198 deviceStore.upsert(device)199 }200 }201202 fun confirmSas(matches: Boolean) {203 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)204 }205206 fun restart() {207 relayHost.stop()208 server.stop()209 _state.value = CodePairUiState.Starting210 start()211 }212213 fun useLanOnly(lanOnly: Boolean) {214 relaySettings.setLanOnly(lanOnly)215 restart()216 }217218 fun stop() {219 relayHost.stop()220 server.confirmationSink = null221 server.stop()222 }223224 override fun onCleared() {225 stop()226 super.onCleared()227 }228}