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 171 172 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true173174 override suspend fun upsertPairedDevice(device: PairedDevice) {175 176 177 deviceStore.clearRevocation(device.deviceId)178 deviceStore.upsert(device)179 }180 }181182 fun setBackgroundSync(enabled: Boolean) {183 backgroundSync.setEnabled(enabled)184 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)185 }186187 fun selectRelayMode(mode: SyncMode) {188 require(mode != SyncMode.OFF) { "Pairing requires a relay" }189 relaySettings.setSyncMode(mode)190 refreshRelayState()191 if (relaySettings.relayInForce() is RelayDecision.Dial) {192 restart()193 } else {194 stop()195 _state.value = CodePairUiState.Failed("private_relay_unset")196 }197 }198199 fun savePrivateRelayUrl(input: String): Boolean = runCatching {200 relaySettings.setRelayUrl(input)201 refreshRelayState()202 restart()203 true204 }.getOrDefault(false)205206 private fun refreshRelayState() {207 _syncMode.value = relaySettings.syncMode()208 _privateRelayUrl.value = relaySettings.privateRelayUrl()209 }210211 fun restart() {212 collectJob?.cancel()213 collectJob = null214 relayHost.stop()215 _state.value = CodePairUiState.Starting216 start()217 }218219 fun stop() {220 collectJob?.cancel()221 collectJob = null222 relayHost.stop()223 }224225 override fun onCleared() {226 stop()227 super.onCleared()228 }229}