1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import com.peachpasswords.BuildConfig6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.BackgroundSyncPreference9import com.peachpasswords.sync.BatteryExemption10import com.peachpasswords.sync.PairingSession11import com.peachpasswords.sync.PeachSyncService12import com.peachpasswords.sync.RelayAdoptionEvent13import com.peachpasswords.sync.RelayAdoptionHost14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.RelayDecision16import com.peachpasswords.sync.AdoptionSourceDeps17import com.peachpasswords.sync.AdoptionFrame18import com.peachpasswords.sync.LocalDeviceIdentity19import 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.Inject35363738394041424344sealed interface CodePairUiState {45 data object Starting : CodePairUiState46 data class ShowingInvitation(val invitation: String) : 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 relayHost: RelayAdoptionHost,57 private val relaySettings: RelaySettingsStore,58 private val backgroundSync: BackgroundSyncPreference,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 val decision = relaySettings.relayInForce()87 if (decision is RelayDecision.Refuse) {88 _state.value = CodePairUiState.Failed(decision.reason.wire())89 return90 }91 PeachSyncService.start(appContext)9293 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))94 val identity = buildLocalIdentity(appContext, master)95 VaultCrypto.zero(master)96 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))97 }9899 private fun startRelay(100 privateKey: ByteArray,101 publicKey: ByteArray,102 identity: LocalDeviceIdentity,103 sourceDeps: AdoptionSourceDeps,104 ) {105 collectJob = viewModelScope.launch {106 try {107 relayHost.start(108 viewModelScope,109 privateKey,110 publicKey,111 identity,112 sourceDeps,113 ).collect { event ->114 _state.value = when (event) {115 is RelayAdoptionEvent.InvitationReady ->116 CodePairUiState.ShowingInvitation(event.invitation)117 is RelayAdoptionEvent.AwaitingConfirmation ->118 CodePairUiState.ConfirmSas(119 event.sas,120 event.deviceName,121 event.session,122 )123 is RelayAdoptionEvent.Adopted ->124 CodePairUiState.Paired(event.deviceName)125 RelayAdoptionEvent.Expired -> CodePairUiState.Expired126 is RelayAdoptionEvent.Rejected -> {127 if (BuildConfig.DEBUG) {128 Log.w(129 "PeachPairing",130 "relay adoption rejected: ${event.reason}",131 )132 }133 CodePairUiState.Failed(event.reason)134 }135 }136 }137 } finally {138 VaultCrypto.zero(privateKey)139 VaultCrypto.zero(publicKey)140 }141 }142 }143144 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =145 object : AdoptionSourceDeps {146 override val sourceIdentity: LocalDeviceIdentity = identity147148 override suspend fun rootKeyHex(): String {149 val root = vault.syncMasterKey()150 ?: throw IllegalStateException("vault_locked")151 return try {152 root.joinToString("") { "%02x".format(it) }153 } finally {154 VaultCrypto.zero(root)155 }156 }157158 override suspend fun vaultId(): String =159 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")160161 override suspend fun license(): String? =162 license.getLicenseKey()?.takeIf(license::validateLicense)163164 165 166 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true167168 override suspend fun upsertPairedDevice(device: PairedDevice) {169 170 171 deviceStore.clearRevocation(device.deviceId)172 deviceStore.upsert(device)173 }174 }175176 fun setBackgroundSync(enabled: Boolean) {177 backgroundSync.setEnabled(enabled)178 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)179 }180181 fun confirmSas(matches: Boolean) {182 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)183 }184185 fun restart() {186 collectJob?.cancel()187 collectJob = null188 relayHost.stop()189 _state.value = CodePairUiState.Starting190 start()191 }192193 fun stop() {194 collectJob?.cancel()195 collectJob = null196 relayHost.stop()197 }198199 override fun onCleared() {200 stop()201 super.onCleared()202 }203}