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 AwaitingPeerConfirmation(val sas: String, val deviceName: String) : 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.AwaitingPeerConfirmation ->118 CodePairUiState.AwaitingPeerConfirmation(119 event.sas,120 event.deviceName,121 )122 is RelayAdoptionEvent.Adopted ->123 CodePairUiState.Paired(event.deviceName)124 RelayAdoptionEvent.Expired -> CodePairUiState.Expired125 is RelayAdoptionEvent.Rejected -> {126 if (BuildConfig.DEBUG) {127 Log.w(128 "PeachPairing",129 "relay adoption rejected: ${event.reason}",130 )131 }132 CodePairUiState.Failed(event.reason)133 }134 }135 }136 } finally {137 VaultCrypto.zero(privateKey)138 VaultCrypto.zero(publicKey)139 }140 }141 }142143 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =144 object : AdoptionSourceDeps {145 override val sourceIdentity: LocalDeviceIdentity = identity146147 override suspend fun rootKeyHex(): String {148 val root = vault.syncMasterKey()149 ?: throw IllegalStateException("vault_locked")150 return try {151 root.joinToString("") { "%02x".format(it) }152 } finally {153 VaultCrypto.zero(root)154 }155 }156157 override suspend fun vaultId(): String =158 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")159160 override suspend fun license(): String? =161 license.getLicenseKey()?.takeIf(license::validateLicense)162163 164 165 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true166167 override suspend fun upsertPairedDevice(device: PairedDevice) {168 169 170 deviceStore.clearRevocation(device.deviceId)171 deviceStore.upsert(device)172 }173 }174175 fun setBackgroundSync(enabled: Boolean) {176 backgroundSync.setEnabled(enabled)177 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)178 }179180 fun restart() {181 collectJob?.cancel()182 collectJob = null183 relayHost.stop()184 _state.value = CodePairUiState.Starting185 start()186 }187188 fun stop() {189 collectJob?.cancel()190 collectJob = null191 relayHost.stop()192 }193194 override fun onCleared() {195 stop()196 super.onCleared()197 }198}