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.SyncMode17import com.peachpasswords.sync.AdoptionSourceDeps18import com.peachpasswords.sync.AdoptionFrame19import com.peachpasswords.sync.LocalDeviceIdentity20import com.peachpasswords.sync.PairedDevice21import com.peachpasswords.sync.PairedDeviceStore22import com.peachpasswords.sync.buildLocalIdentity23import com.peachpasswords.sync.deriveLocalSyncKeypair24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.vault.VaultCrypto26import com.peachpasswords.vault.VaultRepository27import com.peachpasswords.license.LicenseRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.Job31import kotlinx.coroutines.flow.MutableStateFlow32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.launch35import javax.inject.Inject36373839404142434445sealed interface CodePairUiState {46 data object Starting : CodePairUiState47 data class ShowingInvitation(val invitation: String) : CodePairUiState48 data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState49 data class Paired(val deviceName: String) : CodePairUiState50 data object Expired : CodePairUiState51 data class Failed(val reason: String) : CodePairUiState52}5354@HiltViewModel55class CodePairViewModel @Inject constructor(56 private val vault: VaultRepository,57 private val relayHost: RelayAdoptionHost,58 private val relaySettings: RelaySettingsStore,59 private val backgroundSync: BackgroundSyncPreference,60 private val deviceStore: PairedDeviceStore,61 private val license: LicenseRepository,62 @ApplicationContext private val appContext: Context,63) : ViewModel() {6465 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)66 val state: StateFlow<CodePairUiState> = _state.asStateFlow()6768 private val _syncMode = MutableStateFlow(relaySettings.syncMode())69 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7071 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())72 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()7374 7576777879 private var collectJob: Job? = null8081 8283848586 fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)8788 fun start() {89 val master = vault.syncMasterKey() ?: run {90 _state.value = CodePairUiState.Failed("vault_locked")91 return92 }93 val decision = relaySettings.relayInForce()94 if (decision is RelayDecision.Refuse) {95 _state.value = CodePairUiState.Failed(decision.reason.wire())96 return97 }98 PeachSyncService.start(appContext)99100 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))101 val identity = buildLocalIdentity(appContext, master)102 VaultCrypto.zero(master)103 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))104 }105106 private fun startRelay(107 privateKey: ByteArray,108 publicKey: ByteArray,109 identity: LocalDeviceIdentity,110 sourceDeps: AdoptionSourceDeps,111 ) {112 collectJob = viewModelScope.launch {113 try {114 relayHost.start(115 viewModelScope,116 privateKey,117 publicKey,118 identity,119 sourceDeps,120 ).collect { event ->121 _state.value = when (event) {122 is RelayAdoptionEvent.InvitationReady ->123 CodePairUiState.ShowingInvitation(event.invitation)124 is RelayAdoptionEvent.AwaitingPeerConfirmation ->125 CodePairUiState.AwaitingPeerConfirmation(126 event.sas,127 event.deviceName,128 )129 is RelayAdoptionEvent.Adopted ->130 CodePairUiState.Paired(event.deviceName)131 RelayAdoptionEvent.Expired -> CodePairUiState.Expired132 is RelayAdoptionEvent.Rejected -> {133 if (BuildConfig.DEBUG) {134 Log.w(135 "PeachPairing",136 "relay adoption rejected: ${event.reason}",137 )138 }139 CodePairUiState.Failed(event.reason)140 }141 }142 }143 } finally {144 VaultCrypto.zero(privateKey)145 VaultCrypto.zero(publicKey)146 }147 }148 }149150 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =151 object : AdoptionSourceDeps {152 override val sourceIdentity: LocalDeviceIdentity = identity153154 override suspend fun rootKeyHex(): String {155 val root = vault.syncMasterKey()156 ?: throw IllegalStateException("vault_locked")157 return try {158 root.joinToString("") { "%02x".format(it) }159 } finally {160 VaultCrypto.zero(root)161 }162 }163164 override suspend fun vaultId(): String =165 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")166167 override suspend fun license(): String? =168 license.getLicenseKey()?.takeIf(license::validateLicense)169170 override suspend fun applyLicense(licenseKey: String) {171 if (!license.validateLicense(licenseKey)) {172 throw IllegalStateException("license_transfer_failed")173 }174 license.storeLicense(licenseKey)175 if (!license.isProActive()) {176 throw IllegalStateException("license_transfer_failed")177 }178 }179180 181 182 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true183184 override suspend fun upsertPairedDevice(device: PairedDevice) {185 186 187 deviceStore.clearRevocation(device.deviceId)188 deviceStore.upsert(device)189 }190 }191192 fun setBackgroundSync(enabled: Boolean) {193 backgroundSync.setEnabled(enabled)194 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)195 }196197 fun selectRelayMode(mode: SyncMode) {198 require(mode != SyncMode.OFF) { "Pairing requires a relay" }199 relaySettings.setSyncMode(mode)200 refreshRelayState()201 if (relaySettings.relayInForce() is RelayDecision.Dial) {202 restart()203 } else {204 stop()205 _state.value = CodePairUiState.Failed("private_relay_unset")206 }207 }208209 fun savePrivateRelayUrl(input: String): Boolean = runCatching {210 relaySettings.setRelayUrl(input)211 refreshRelayState()212 restart()213 true214 }.getOrDefault(false)215216 private fun refreshRelayState() {217 _syncMode.value = relaySettings.syncMode()218 _privateRelayUrl.value = relaySettings.privateRelayUrl()219 }220221 fun restart() {222 collectJob?.cancel()223 collectJob = null224 relayHost.stop()225 _state.value = CodePairUiState.Starting226 start()227 }228229 fun stop() {230 collectJob?.cancel()231 collectJob = null232 relayHost.stop()233 }234235 override fun onCleared() {236 stop()237 super.onCleared()238 }239}