package com.peachpasswords.ui.pairing import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.license.LicenseRepository import com.peachpasswords.sync.AdoptionReceiverDeps import com.peachpasswords.sync.DeviceType import com.peachpasswords.sync.LocalChirpIdentity import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.PeachSyncService import com.peachpasswords.sync.RelayAdoptionReceiver import com.peachpasswords.sync.buildLocalIdentity import com.peachpasswords.sync.getDeviceName import com.peachpasswords.sync.parsePairingInvitation import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.vault.AdoptVaultResult import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed interface AdoptVaultUiState { data object Password : AdoptVaultUiState /** * Join-mode only: this device already holds a vault, so joining replaces * its root key and therefore its recovery words. Nothing connects until * the user has been told that and accepted it. */ data object Disclosure : AdoptVaultUiState data object Pair : AdoptVaultUiState data object Connecting : AdoptVaultUiState data class ConfirmSas(val sas: String) : AdoptVaultUiState /** * Join-mode only, and the last point of no return: the SAS matched and the * root key has been granted, but nothing is written yet. Shows the words * that are about to replace this device's, so the user can record them or * confirm they already hold them. */ data class RetireWords(val incomingWords: List) : AdoptVaultUiState data object Adopting : AdoptVaultUiState data class Complete(val sourceName: String) : AdoptVaultUiState data class Failed(val reason: String) : AdoptVaultUiState } @HiltViewModel class AdoptVaultViewModel @Inject constructor( private val receiver: RelayAdoptionReceiver, private val vault: VaultRepository, private val devices: PairedDeviceStore, private val license: LicenseRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { /** * True when this device already has a vault, so the flow joins the scanned * vault (keeping local entries) instead of creating one. Read once: the * answer cannot change while this screen is open. */ val joining: Boolean = vault.hasVault() private val _state = MutableStateFlow( if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password, ) val state: StateFlow = _state.asStateFlow() private var password: String? = null private var confirmation: CompletableDeferred? = null private var wordRetirement: CompletableDeferred? = null private var adoptionJob: Job? = null private var protocolError: String? = null fun acceptPassword(value: String, repeated: String): Boolean { if (value.length < 12 || value != repeated || scorePassword(value) < 2) { return false } password = value _state.value = AdoptVaultUiState.Pair return true } /** The user has read what joining does to this device's recovery words. */ fun acceptDisclosure() { if (_state.value is AdoptVaultUiState.Disclosure) { _state.value = AdoptVaultUiState.Pair } } fun adopt(invitation: String) { if (_state.value !is AdoptVaultUiState.Pair) return if (parsePairingInvitation(invitation) == null) { _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation") clearPassword() return } _state.value = AdoptVaultUiState.Connecting protocolError = null adoptionJob = viewModelScope.launch { try { val adopted = receiver.adopt( encodedInvitation = invitation, deps = receiverDeps(), confirm = { sas -> val pending = CompletableDeferred() confirmation = pending _state.value = AdoptVaultUiState.ConfirmSas(sas) pending.await() }, ) _state.value = AdoptVaultUiState.Complete( adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device", ) } catch (error: CancellationException) { throw error } catch (error: Throwable) { _state.value = AdoptVaultUiState.Failed( protocolError ?: error.message ?: "adoption_failed", ) } finally { confirmation = null wordRetirement = null clearPassword() } } } fun confirmSas(matches: Boolean) { val pending = confirmation ?: return confirmation = null // In join mode the word-retirement gate comes next and owns the state. if (matches && !joining) _state.value = AdoptVaultUiState.Adopting pending.complete(matches) } /** Proceed with the join, retiring this device's words. */ fun acknowledgeWordRetirement(proceed: Boolean) { val pending = wordRetirement ?: return wordRetirement = null pending.complete(proceed) } fun restart() { adoptionJob?.cancel() adoptionJob = null confirmation?.complete(false) confirmation = null wordRetirement?.complete(false) wordRetirement = null clearPassword() _state.value = if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password } private fun receiverDeps(): AdoptionReceiverDeps = object : AdoptionReceiverDeps { override val deviceName = getDeviceName(appContext) override val deviceType = DeviceType.ANDROID override fun generateDeviceId(): String = UUID.randomUUID().toString() // RelayAdoptionReceiver replaces this with the SAS bound to its // completed Noise transcript. override suspend fun confirmSas(): Boolean = false override suspend fun createVaultFromRootKey(rootKeyHex: String): String { // Same protocol seam, two local outcomes: a fresh device builds // a vault around the granted root key, while a device that // already has one keeps its entries and joins. val result = if (joining) { // Last gate before the old words become unusable. The root // key is granted but nothing is written yet, so declining // here leaves this vault exactly as it was. val incoming = vault.previewRecoveryWords(rootKeyHex) ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY") val acknowledged = CompletableDeferred() wordRetirement = acknowledged _state.value = AdoptVaultUiState.RetireWords(incoming) if (!acknowledged.await()) { throw IllegalStateException("join_declined_at_word_retirement") } _state.value = AdoptVaultUiState.Adopting vault.joinVault(rootKeyHex) } else { val localPassword = password ?: throw IllegalStateException("adoption_password_unavailable") vault.createVaultFromRootKey(localPassword, rootKeyHex) } return when (result) { is AdoptVaultResult.Success -> result.vaultId is AdoptVaultResult.Error -> throw IllegalStateException(result.reason) } } override suspend fun deriveLocalIdentity(): LocalChirpIdentity { val master = vault.syncMasterKey() ?: throw IllegalStateException("adopted_vault_unavailable") return try { buildLocalIdentity(appContext, master) } finally { VaultCrypto.zero(master) } } override suspend fun upsertPairedDevice(device: PairedDevice) { devices.upsert(device) // Start listening only after the source identity and relay-bound // pair state are durable. This lets the browser source perform // its first sync immediately after adoption. PeachSyncService.start(appContext) } override suspend fun applyLicense(licenseKey: String) { if (license.validateLicense(licenseKey)) { license.storeLicense(licenseKey) } } override fun onError(error: String) { protocolError = error } } private fun clearPassword() { password = null } override fun onCleared() { adoptionJob?.cancel() confirmation?.complete(false) wordRetirement?.complete(false) clearPassword() super.onCleared() } }