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.SyncMode15import 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 if (relaySettings.syncMode() == SyncMode.OFF) {85 _state.value = CodePairUiState.Failed("sync_disabled")86 return87 }88 PeachSyncService.start(appContext)8990 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))91 val identity = buildLocalIdentity(appContext, master)92 VaultCrypto.zero(master)93 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))94 }9596 private fun startRelay(97 privateKey: ByteArray,98 publicKey: ByteArray,99 identity: LocalDeviceIdentity,100 sourceDeps: AdoptionSourceDeps,101 ) {102 collectJob = viewModelScope.launch {103 try {104 relayHost.start(105 viewModelScope,106 privateKey,107 publicKey,108 identity,109 sourceDeps,110 ).collect { event ->111 _state.value = when (event) {112 is RelayAdoptionEvent.InvitationReady ->113 CodePairUiState.ShowingInvitation(event.invitation)114 is RelayAdoptionEvent.AwaitingConfirmation ->115 CodePairUiState.ConfirmSas(116 event.sas,117 event.deviceName,118 event.session,119 )120 is RelayAdoptionEvent.Adopted ->121 CodePairUiState.Paired(event.deviceName)122 RelayAdoptionEvent.Expired -> CodePairUiState.Expired123 is RelayAdoptionEvent.Rejected -> {124 if (BuildConfig.DEBUG) {125 Log.w(126 "PeachPairing",127 "relay adoption rejected: ${event.reason}",128 )129 }130 CodePairUiState.Failed(event.reason)131 }132 }133 }134 } finally {135 VaultCrypto.zero(privateKey)136 VaultCrypto.zero(publicKey)137 }138 }139 }140141 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =142 object : AdoptionSourceDeps {143 override val sourceIdentity: LocalDeviceIdentity = identity144145 override suspend fun rootKeyHex(): String {146 val root = vault.syncMasterKey()147 ?: throw IllegalStateException("vault_locked")148 return try {149 root.joinToString("") { "%02x".format(it) }150 } finally {151 VaultCrypto.zero(root)152 }153 }154155 override suspend fun vaultId(): String =156 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")157158 override suspend fun license(): String? =159 license.getLicenseKey()?.takeIf(license::validateLicense)160161 162 163 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true164165 override suspend fun upsertPairedDevice(device: PairedDevice) {166 167 168 deviceStore.clearRevocation(device.deviceId)169 deviceStore.upsert(device)170 }171 }172173 fun confirmSas(matches: Boolean) {174 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)175 }176177 fun restart() {178 collectJob?.cancel()179 collectJob = null180 relayHost.stop()181 _state.value = CodePairUiState.Starting182 start()183 }184185 fun stop() {186 collectJob?.cancel()187 collectJob = null188 relayHost.stop()189 }190191 override fun onCleared() {192 stop()193 super.onCleared()194 }195}