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 828384858687888990 fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =91 BatteryExemption.requestIntent(appContext, userConfirmed)9293 94 fun syncOnce() = BatteryExemption.syncOnce(appContext)9596 fun start() {97 val master = vault.syncMasterKey() ?: run {98 _state.value = CodePairUiState.Failed("vault_locked")99 return100 }101 val decision = relaySettings.relayInForce()102 if (decision is RelayDecision.Refuse) {103 _state.value = CodePairUiState.Failed(decision.reason.wire())104 return105 }106 PeachSyncService.start(appContext)107108 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))109 val identity = buildLocalIdentity(appContext, master)110 VaultCrypto.zero(master)111 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))112 }113114 private fun startRelay(115 privateKey: ByteArray,116 publicKey: ByteArray,117 identity: LocalDeviceIdentity,118 sourceDeps: AdoptionSourceDeps,119 ) {120 collectJob = viewModelScope.launch {121 try {122 relayHost.start(123 viewModelScope,124 privateKey,125 publicKey,126 identity,127 sourceDeps,128 ).collect { event ->129 _state.value = when (event) {130 is RelayAdoptionEvent.InvitationReady ->131 CodePairUiState.ShowingInvitation(event.invitation)132 is RelayAdoptionEvent.AwaitingPeerConfirmation ->133 CodePairUiState.AwaitingPeerConfirmation(134 event.sas,135 event.deviceName,136 )137 is RelayAdoptionEvent.Adopted ->138 CodePairUiState.Paired(event.deviceName)139 RelayAdoptionEvent.Expired -> CodePairUiState.Expired140 is RelayAdoptionEvent.Rejected -> {141 if (BuildConfig.DEBUG) {142 Log.w(143 "PeachPairing",144 "relay adoption rejected: ${event.reason}",145 )146 }147 CodePairUiState.Failed(event.reason)148 }149 }150 }151 } finally {152 VaultCrypto.zero(privateKey)153 VaultCrypto.zero(publicKey)154 }155 }156 }157158 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =159 object : AdoptionSourceDeps {160 override val sourceIdentity: LocalDeviceIdentity = identity161162 override suspend fun rootKeyHex(): String {163 val root = vault.syncMasterKey()164 ?: throw IllegalStateException("vault_locked")165 return try {166 root.joinToString("") { "%02x".format(it) }167 } finally {168 VaultCrypto.zero(root)169 }170 }171172 override suspend fun vaultId(): String =173 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")174175 override suspend fun license(): String? =176 license.getLicenseKey()?.takeIf(license::validateLicense)177178 override suspend fun applyLicense(licenseKey: String) {179 if (!license.validateLicense(licenseKey)) {180 throw IllegalStateException("license_transfer_failed")181 }182 license.storeLicense(licenseKey)183 if (!license.isProActive()) {184 throw IllegalStateException("license_transfer_failed")185 }186 }187188 189 190 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true191192 override suspend fun upsertPairedDevice(device: PairedDevice) {193 194 195 deviceStore.clearRevocation(device.deviceId)196 deviceStore.upsert(device)197 }198 }199200 fun setBackgroundSync(enabled: Boolean) {201 backgroundSync.setEnabled(enabled)202 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)203 }204205 fun selectRelayMode(mode: SyncMode) {206 require(mode != SyncMode.OFF) { "Pairing requires a relay" }207 relaySettings.setSyncMode(mode)208 refreshRelayState()209 if (relaySettings.relayInForce() is RelayDecision.Dial) {210 restart()211 } else {212 stop()213 _state.value = CodePairUiState.Failed("private_relay_unset")214 }215 }216217 fun savePrivateRelayUrl(input: String): Boolean = runCatching {218 relaySettings.setRelayUrl(input)219 refreshRelayState()220 restart()221 true222 }.getOrDefault(false)223224 private fun refreshRelayState() {225 _syncMode.value = relaySettings.syncMode()226 _privateRelayUrl.value = relaySettings.privateRelayUrl()227 }228229 fun restart() {230 collectJob?.cancel()231 collectJob = null232 relayHost.stop()233 _state.value = CodePairUiState.Starting234 start()235 }236237 fun stop() {238 collectJob?.cancel()239 collectJob = null240 relayHost.stop()241 }242243 override fun onCleared() {244 stop()245 super.onCleared()246 }247}