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.SignedDeviceManifest23import com.peachpasswords.sync.VaultSyncAdapter24import com.peachpasswords.sync.buildLocalIdentity25import com.peachpasswords.sync.deriveLocalSyncKeypair26import com.peachpasswords.sync.getOrCreateDeviceId27import com.peachpasswords.vault.VaultCrypto28import com.peachpasswords.vault.VaultRepository29import com.peachpasswords.license.LicenseRepository30import dagger.hilt.android.lifecycle.HiltViewModel31import dagger.hilt.android.qualifiers.ApplicationContext32import kotlinx.coroutines.Job33import kotlinx.coroutines.flow.MutableStateFlow34import kotlinx.coroutines.flow.StateFlow35import kotlinx.coroutines.flow.asStateFlow36import kotlinx.coroutines.launch37import javax.inject.Inject38394041424344454647sealed interface CodePairUiState {48 data object Starting : CodePairUiState49 data class ShowingInvitation(val invitation: String) : CodePairUiState50 data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState51 data class Paired(val deviceName: String) : CodePairUiState52 data object Expired : CodePairUiState53 data class Failed(val reason: String) : CodePairUiState54}5556@HiltViewModel57class CodePairViewModel @Inject constructor(58 private val vault: VaultRepository,59 private val relayHost: RelayAdoptionHost,60 private val relaySettings: RelaySettingsStore,61 private val backgroundSync: BackgroundSyncPreference,62 private val deviceStore: PairedDeviceStore,63 private val license: LicenseRepository,64 @ApplicationContext private val appContext: Context,65) : ViewModel() {6667 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)68 val state: StateFlow<CodePairUiState> = _state.asStateFlow()6970 private val _syncMode = MutableStateFlow(relaySettings.syncMode())71 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7273 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())74 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()7576 7778798081 private var collectJob: Job? = null8283 848586878889909192 fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =93 BatteryExemption.requestIntent(appContext, userConfirmed)9495 96 fun syncOnce() = BatteryExemption.syncOnce(appContext)9798 fun start() {99 val master = vault.syncMasterKey() ?: run {100 _state.value = CodePairUiState.Failed("vault_locked")101 return102 }103 val decision = relaySettings.relayInForce()104 if (decision is RelayDecision.Refuse) {105 _state.value = CodePairUiState.Failed(decision.reason.wire())106 return107 }108 PeachSyncService.start(appContext)109110 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))111 val identity = buildLocalIdentity(appContext, master)112 VaultCrypto.zero(master)113 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))114 }115116 private fun startRelay(117 privateKey: ByteArray,118 publicKey: ByteArray,119 identity: LocalDeviceIdentity,120 sourceDeps: AdoptionSourceDeps,121 ) {122 collectJob = viewModelScope.launch {123 try {124 relayHost.start(125 viewModelScope,126 privateKey,127 publicKey,128 identity,129 sourceDeps,130 ).collect { event ->131 _state.value = when (event) {132 is RelayAdoptionEvent.InvitationReady ->133 CodePairUiState.ShowingInvitation(event.invitation)134 is RelayAdoptionEvent.AwaitingPeerConfirmation ->135 CodePairUiState.AwaitingPeerConfirmation(136 event.sas,137 event.deviceName,138 )139 is RelayAdoptionEvent.Adopted ->140 CodePairUiState.Paired(event.deviceName)141 RelayAdoptionEvent.Expired -> CodePairUiState.Expired142 is RelayAdoptionEvent.Rejected -> {143 if (BuildConfig.DEBUG) {144 Log.w(145 "PeachPairing",146 "relay adoption rejected: ${event.reason}",147 )148 }149 CodePairUiState.Failed(event.reason)150 }151 }152 }153 } finally {154 VaultCrypto.zero(privateKey)155 VaultCrypto.zero(publicKey)156 }157 }158 }159160 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =161 object : AdoptionSourceDeps {162 override val sourceIdentity: LocalDeviceIdentity = identity163164 override suspend fun rootKeyHex(): String {165 val root = vault.syncMasterKey()166 ?: throw IllegalStateException("vault_locked")167 return try {168 root.joinToString("") { "%02x".format(it) }169 } finally {170 VaultCrypto.zero(root)171 }172 }173174 override suspend fun vaultId(): String =175 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")176177 override suspend fun license(): String? =178 license.getLicenseKey()?.takeIf(license::validateLicense)179180 override suspend fun applyLicense(licenseKey: String) {181 if (!license.validateLicense(licenseKey)) {182 throw IllegalStateException("license_transfer_failed")183 }184 license.storeLicense(licenseKey)185 if (!license.isProActive()) {186 throw IllegalStateException("license_transfer_failed")187 }188 }189190 191 192 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true193194 override suspend fun upsertPairedDevice(device: PairedDevice) {195 196 197 deviceStore.clearRevocation(device.deviceId)198 deviceStore.upsert(device)199 }200201 override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {202 val adapter = VaultSyncAdapter(203 repo = vault,204 ourDeviceId = getOrCreateDeviceId(appContext),205 pairedDevices = deviceStore,206 )207 val entryManifest = adapter.buildEntryManifest()208 val entryIds = entryManifest.keys.toList()209 val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()210 val tombstones = adapter.buildTombstoneManifest()211 return entries to tombstones212 }213214 override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {215 val adapter = VaultSyncAdapter(216 repo = vault,217 ourDeviceId = getOrCreateDeviceId(appContext),218 pairedDevices = deviceStore,219 )220 return adapter.buildOutgoingDeviceManifest()221 }222223 override suspend fun markSynced(deviceId: String) {224 deviceStore.updateLastSync(deviceId)225 }226227 override suspend fun duressSlotJson(): String? =228 vault.exportDuressSlotForAdoption()229 }230231 fun setBackgroundSync(enabled: Boolean) {232 backgroundSync.setEnabled(enabled)233 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)234 }235236 fun selectRelayMode(mode: SyncMode) {237 require(mode != SyncMode.OFF) { "Pairing requires a relay" }238 relaySettings.setSyncMode(mode)239 refreshRelayState()240 if (relaySettings.relayInForce() is RelayDecision.Dial) {241 restart()242 } else {243 stop()244 _state.value = CodePairUiState.Failed("private_relay_unset")245 }246 }247248 fun savePrivateRelayUrl(input: String): Boolean = runCatching {249 relaySettings.setRelayUrl(input)250 refreshRelayState()251 restart()252 true253 }.getOrDefault(false)254255 private fun refreshRelayState() {256 _syncMode.value = relaySettings.syncMode()257 _privateRelayUrl.value = relaySettings.privateRelayUrl()258 }259260 fun restart() {261 collectJob?.cancel()262 collectJob = null263 relayHost.stop()264 _state.value = CodePairUiState.Starting265 start()266 }267268 fun stop() {269 collectJob?.cancel()270 collectJob = null271 relayHost.stop()272 }273274 override fun onCleared() {275 stop()276 super.onCleared()277 }278}