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 data object Pair : AdoptVaultUiState data object Connecting : AdoptVaultUiState data class ConfirmSas(val sas: String) : 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() { private val _state = MutableStateFlow(AdoptVaultUiState.Password) val state: StateFlow = _state.asStateFlow() private var password: String? = null private var confirmation: 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 } 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 clearPassword() } } } fun confirmSas(matches: Boolean) { val pending = confirmation ?: return confirmation = null if (matches) _state.value = AdoptVaultUiState.Adopting pending.complete(matches) } fun restart() { adoptionJob?.cancel() adoptionJob = null confirmation?.complete(false) confirmation = null clearPassword() _state.value = 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 { val localPassword = password ?: throw IllegalStateException("adoption_password_unavailable") return when ( val result = vault.createVaultFromRootKey(localPassword, rootKeyHex) ) { 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) clearPassword() super.onCleared() } }