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.PairingSession10import com.peachpasswords.sync.PeachSyncService11import com.peachpasswords.sync.RelayAdoptionEvent12import com.peachpasswords.sync.RelayAdoptionHost13import com.peachpasswords.sync.RelaySettingsStore14import com.peachpasswords.sync.RelayDecision15import com.peachpasswords.sync.AdoptionSourceDeps16import com.peachpasswords.sync.AdoptionFrame17import com.peachpasswords.sync.LocalDeviceIdentity18import com.peachpasswords.sync.PairedDevice19import com.peachpasswords.sync.PairedDeviceStore20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.deriveLocalSyncKeypair22import com.peachpasswords.sync.getOrCreateDeviceId23import com.peachpasswords.vault.VaultCrypto24import com.peachpasswords.vault.VaultRepository25import com.peachpasswords.license.LicenseRepository26import dagger.hilt.android.lifecycle.HiltViewModel27import dagger.hilt.android.qualifiers.ApplicationContext28import kotlinx.coroutines.Job29import kotlinx.coroutines.flow.MutableStateFlow30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.launch33import javax.inject.Inject34353637383940414243sealed interface CodePairUiState {44 data object Starting : CodePairUiState45 data class ShowingInvitation(val invitation: String) : 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 relayHost: RelayAdoptionHost,56 private val relaySettings: RelaySettingsStore,57 private val deviceStore: PairedDeviceStore,58 private val license: LicenseRepository,59 @ApplicationContext private val appContext: Context,60) : ViewModel() {6162 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)63 val state: StateFlow<CodePairUiState> = _state.asStateFlow()6465 6667686970 private var collectJob: Job? = null7172 7374757677 fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)7879 fun start() {80 val master = vault.syncMasterKey() ?: run {81 _state.value = CodePairUiState.Failed("vault_locked")82 return83 }84 val decision = relaySettings.relayInForce()85 if (decision is RelayDecision.Refuse) {86 _state.value = CodePairUiState.Failed(decision.reason.wire())87 return88 }89 PeachSyncService.start(appContext)9091 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))92 val identity = buildLocalIdentity(appContext, master)93 VaultCrypto.zero(master)94 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))95 }9697 private fun startRelay(98 privateKey: ByteArray,99 publicKey: ByteArray,100 identity: LocalDeviceIdentity,101 sourceDeps: AdoptionSourceDeps,102 ) {103 collectJob = viewModelScope.launch {104 try {105 relayHost.start(106 viewModelScope,107 privateKey,108 publicKey,109 identity,110 sourceDeps,111 ).collect { event ->112 _state.value = when (event) {113 is RelayAdoptionEvent.InvitationReady ->114 CodePairUiState.ShowingInvitation(event.invitation)115 is RelayAdoptionEvent.AwaitingConfirmation ->116 CodePairUiState.ConfirmSas(117 event.sas,118 event.deviceName,119 event.session,120 )121 is RelayAdoptionEvent.Adopted ->122 CodePairUiState.Paired(event.deviceName)123 RelayAdoptionEvent.Expired -> CodePairUiState.Expired124 is RelayAdoptionEvent.Rejected -> {125 if (BuildConfig.DEBUG) {126 Log.w(127 "PeachPairing",128 "relay adoption rejected: ${event.reason}",129 )130 }131 CodePairUiState.Failed(event.reason)132 }133 }134 }135 } finally {136 VaultCrypto.zero(privateKey)137 VaultCrypto.zero(publicKey)138 }139 }140 }141142 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =143 object : AdoptionSourceDeps {144 override val sourceIdentity: LocalDeviceIdentity = identity145146 override suspend fun rootKeyHex(): String {147 val root = vault.syncMasterKey()148 ?: throw IllegalStateException("vault_locked")149 return try {150 root.joinToString("") { "%02x".format(it) }151 } finally {152 VaultCrypto.zero(root)153 }154 }155156 override suspend fun vaultId(): String =157 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")158159 override suspend fun license(): String? =160 license.getLicenseKey()?.takeIf(license::validateLicense)161162 163 164 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true165166 override suspend fun upsertPairedDevice(device: PairedDevice) {167 168 169 deviceStore.clearRevocation(device.deviceId)170 deviceStore.upsert(device)171 }172 }173174 fun confirmSas(matches: Boolean) {175 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)176 }177178 fun restart() {179 collectJob?.cancel()180 collectJob = null181 relayHost.stop()182 _state.value = CodePairUiState.Starting183 start()184 }185186 fun stop() {187 collectJob?.cancel()188 collectJob = null189 relayHost.stop()190 }191192 override fun onCleared() {193 stop()194 super.onCleared()195 }196}