1package com.peachpasswords.ui.pairing23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.license.LicenseRepository7import com.peachpasswords.sync.AdoptionReceiverDeps8import com.peachpasswords.sync.AdoptionSourceDeps9import com.peachpasswords.sync.BackgroundSyncPreference10import com.peachpasswords.sync.DeviceType11import com.peachpasswords.sync.AdoptionFrame12import com.peachpasswords.sync.LocalDeviceIdentity13import com.peachpasswords.sync.PairedDevice14import com.peachpasswords.sync.PairedDeviceStore15import com.peachpasswords.sync.PeachSyncService16import com.peachpasswords.sync.RelayAdoptionReceiver17import com.peachpasswords.sync.RelaySettingsStore18import com.peachpasswords.sync.RelayDecision19import com.peachpasswords.sync.VaultStatus20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.computeVaultId22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getDeviceName24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.sync.hexToPubkey26import com.peachpasswords.sync.parsePairingInvitation27import com.peachpasswords.ui.security.scorePassword28import com.peachpasswords.vault.AdoptVaultResult29import com.peachpasswords.vault.VaultCrypto30import com.peachpasswords.vault.VaultRepository31import dagger.hilt.android.lifecycle.HiltViewModel32import dagger.hilt.android.qualifiers.ApplicationContext33import java.util.UUID34import javax.inject.Inject35import kotlinx.coroutines.CompletableDeferred36import kotlinx.coroutines.CancellationException37import kotlinx.coroutines.Job38import kotlinx.coroutines.flow.MutableStateFlow39import kotlinx.coroutines.flow.StateFlow40import kotlinx.coroutines.flow.asStateFlow41import kotlinx.coroutines.launch4243sealed interface AdoptVaultUiState {44 data object Password : AdoptVaultUiState45 data object Pair : AdoptVaultUiState46 data object Connecting : AdoptVaultUiState47 4849505152 data class ConfirmSas(53 val sas: String,54 val isSource: Boolean = false,55 val peerName: String? = null,56 ) : AdoptVaultUiState57 data object Adopting : AdoptVaultUiState58 data class Complete(val sourceName: String) : AdoptVaultUiState59 data class Failed(val reason: String) : AdoptVaultUiState60}6162@HiltViewModel63class AdoptVaultViewModel @Inject constructor(64 private val receiver: RelayAdoptionReceiver,65 private val vault: VaultRepository,66 private val devices: PairedDeviceStore,67 private val relaySettings: RelaySettingsStore,68 private val backgroundSync: BackgroundSyncPreference,69 private val license: LicenseRepository,70 @ApplicationContext private val appContext: Context,71) : ViewModel() {72 7374757677 val joining: Boolean = vault.hasVault()7879 private val _state = MutableStateFlow<AdoptVaultUiState>(80 initialAdoptVaultState(joining),81 )82 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8384 private var password: String? = null85 private var confirmation: CompletableDeferred<Boolean>? = null86 private var adoptionJob: Job? = null87 private var protocolError: String? = null8889 fun acceptPassword(value: String, repeated: String): Boolean {90 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {91 return false92 }93 password = value94 _state.value = AdoptVaultUiState.Pair95 return true96 }9798 fun adopt(invitation: String) {99 if (_state.value !is AdoptVaultUiState.Pair) return100 val parsed = parsePairingInvitation(invitation)101 if (parsed == null) {102 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")103 clearPassword()104 return105 }106 when (parsed.vaultStatus) {107 VaultStatus.POPULATED -> startReceiverFlow(invitation)108 VaultStatus.EMPTY -> startSourceFlow(invitation)109 }110 }111112 private fun startReceiverFlow(invitation: String) {113 _state.value = AdoptVaultUiState.Connecting114 protocolError = null115 adoptionJob = viewModelScope.launch {116 try {117 val adopted = receiver.adopt(118 encodedInvitation = invitation,119 deps = receiverDeps(),120 confirm = { sas ->121 val pending = CompletableDeferred<Boolean>()122 confirmation = pending123 _state.value = AdoptVaultUiState.ConfirmSas(124 sas = sas,125 isSource = false,126 )127 pending.await()128 },129 )130 131 132 133 134 135 adopted.relayUrl?.let(relaySettings::setRelayUrl)136 _state.value = AdoptVaultUiState.Complete(137 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",138 )139 } catch (error: CancellationException) {140 throw error141 } catch (error: Throwable) {142 _state.value = AdoptVaultUiState.Failed(143 protocolError ?: error.message ?: "adoption_failed",144 )145 } finally {146 confirmation = null147 clearPassword()148 }149 }150 }151152 153154155156157158159 private fun startSourceFlow(invitation: String) {160 val master = vault.syncMasterKey()161 if (master == null) {162 _state.value = AdoptVaultUiState.Failed("vault_locked")163 clearPassword()164 return165 }166 when (val decision = relaySettings.relayInForce()) {167 is RelayDecision.Refuse -> {168 VaultCrypto.zero(master)169 _state.value = AdoptVaultUiState.Failed(decision.reason.wire())170 clearPassword()171 return172 }173 is RelayDecision.Dial -> Unit174 }175 _state.value = AdoptVaultUiState.Connecting176 protocolError = null177178 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))179 val identity = buildLocalIdentity(appContext, master)180 VaultCrypto.zero(master)181 adoptionJob = viewModelScope.launch {182 try {183 val paired = receiver.grantFromInvitation(184 encodedInvitation = invitation,185 sourceDeps = sourceDeps(identity),186 confirm = { sas, request ->187 val pending = CompletableDeferred<Boolean>()188 confirmation = pending189 _state.value = AdoptVaultUiState.ConfirmSas(190 sas = sas,191 isSource = true,192 peerName = request.deviceName.ifBlank { "New device" },193 )194 pending.await()195 },196 )197 paired.relayUrl?.let(relaySettings::setRelayUrl)198 _state.value = AdoptVaultUiState.Complete(199 paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",200 )201 } catch (error: CancellationException) {202 throw error203 } catch (error: Throwable) {204 _state.value = AdoptVaultUiState.Failed(205 protocolError ?: error.message ?: "adoption_failed",206 )207 } finally {208 VaultCrypto.zero(keys.secretKey)209 VaultCrypto.zero(keys.publicKey)210 confirmation = null211 clearPassword()212 }213 }214 }215216 fun confirmSas(matches: Boolean) {217 val pending = confirmation ?: return218 confirmation = null219 if (matches) _state.value = AdoptVaultUiState.Adopting220 pending.complete(matches)221 }222223 fun restart() {224 adoptionJob?.cancel()225 adoptionJob = null226 confirmation?.complete(false)227 confirmation = null228 clearPassword()229 _state.value = initialAdoptVaultState(joining)230 }231232 private fun receiverDeps(): AdoptionReceiverDeps =233 object : AdoptionReceiverDeps {234 override val deviceName = getDeviceName(appContext)235 override val deviceType = DeviceType.ANDROID236 override fun generateDeviceId(): String = UUID.randomUUID().toString()237238 239 240 override suspend fun confirmSas(): Boolean = false241242 override suspend fun validateSource(source: AdoptionFrame.Source) {243 244 245 246 247 248 249 if (!joining) return250 }251252 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {253 254 255 256 val result = if (joining) {257 val incomingRoot = hexToPubkey(rootKeyHex)258 val incomingVaultId = try {259 computeVaultId(incomingRoot)260 } finally {261 VaultCrypto.zero(incomingRoot)262 }263 AdoptVaultResult.Success(264 requireSameVaultForPopulatedRePair(265 localVaultId = vault.syncVaultId(),266 incomingVaultId = incomingVaultId,267 ),268 )269 } else {270 val localPassword = password271 ?: throw IllegalStateException("adoption_password_unavailable")272 vault.createVaultFromRootKey(localPassword, rootKeyHex)273 }274 return when (result) {275 is AdoptVaultResult.Success -> result.vaultId276 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)277 }278 }279280 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {281 val master = vault.syncMasterKey()282 ?: throw IllegalStateException("adopted_vault_unavailable")283 return try {284 buildLocalIdentity(appContext, master)285 } finally {286 VaultCrypto.zero(master)287 }288 }289290 override suspend fun clearPairedDeviceRevocation(deviceId: String) {291 devices.clearRevocation(deviceId)292 }293294 override suspend fun upsertPairedDevice(device: PairedDevice) {295 devices.upsert(device)296 }297298 override suspend fun applyLicense(licenseKey: String) {299 if (license.validateLicense(licenseKey)) {300 license.storeLicense(licenseKey)301 }302 }303304 override fun onError(error: String) {305 protocolError = error306 }307 }308309 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =310 object : AdoptionSourceDeps {311 override val sourceIdentity: LocalDeviceIdentity = identity312313 override suspend fun rootKeyHex(): String {314 val root = vault.syncMasterKey()315 ?: throw IllegalStateException("vault_locked")316 return try {317 root.joinToString("") { "%02x".format(it) }318 } finally {319 VaultCrypto.zero(root)320 }321 }322323 override suspend fun vaultId(): String =324 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")325326 override suspend fun license(): String? =327 license.getLicenseKey()?.takeIf(license::validateLicense)328329 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true330331 override suspend fun upsertPairedDevice(device: PairedDevice) {332 devices.clearRevocation(device.deviceId)333 devices.upsert(device)334 }335 }336337 fun setBackgroundSync(enabled: Boolean) {338 backgroundSync.setEnabled(enabled)339 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)340 }341342 private fun clearPassword() {343 password = null344 }345346 override fun onCleared() {347 adoptionJob?.cancel()348 confirmation?.complete(false)349 clearPassword()350 super.onCleared()351 }352}353354internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =355 if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password356357358359360361362internal fun requireSameVaultForPopulatedRePair(363 localVaultId: String?,364 incomingVaultId: String,365): String {366 if (localVaultId == null) throw IllegalStateException("vault_locked")367 if (localVaultId != incomingVaultId) {368 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")369 }370 return localVaultId371}