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.DeviceType9import com.peachpasswords.sync.LocalChirpIdentity10import com.peachpasswords.sync.PairedDevice11import com.peachpasswords.sync.PairedDeviceStore12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelayAdoptionReceiver14import com.peachpasswords.sync.buildLocalIdentity15import com.peachpasswords.sync.getDeviceName16import com.peachpasswords.sync.parsePairingInvitation17import com.peachpasswords.ui.security.scorePassword18import com.peachpasswords.vault.AdoptVaultResult19import com.peachpasswords.vault.VaultCrypto20import com.peachpasswords.vault.VaultRepository21import dagger.hilt.android.lifecycle.HiltViewModel22import dagger.hilt.android.qualifiers.ApplicationContext23import java.util.UUID24import javax.inject.Inject25import kotlinx.coroutines.CompletableDeferred26import kotlinx.coroutines.CancellationException27import kotlinx.coroutines.Job28import kotlinx.coroutines.flow.MutableStateFlow29import kotlinx.coroutines.flow.StateFlow30import kotlinx.coroutines.flow.asStateFlow31import kotlinx.coroutines.launch3233sealed interface AdoptVaultUiState {34 data object Password : AdoptVaultUiState35 data object Pair : AdoptVaultUiState36 data object Connecting : AdoptVaultUiState37 data class ConfirmSas(val sas: String) : AdoptVaultUiState38 data object Adopting : AdoptVaultUiState39 data class Complete(val sourceName: String) : AdoptVaultUiState40 data class Failed(val reason: String) : AdoptVaultUiState41}4243@HiltViewModel44class AdoptVaultViewModel @Inject constructor(45 private val receiver: RelayAdoptionReceiver,46 private val vault: VaultRepository,47 private val devices: PairedDeviceStore,48 private val license: LicenseRepository,49 @ApplicationContext private val appContext: Context,50) : ViewModel() {51 private val _state = MutableStateFlow<AdoptVaultUiState>(AdoptVaultUiState.Password)52 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()5354 private var password: String? = null55 private var confirmation: CompletableDeferred<Boolean>? = null56 private var adoptionJob: Job? = null57 private var protocolError: String? = null5859 fun acceptPassword(value: String, repeated: String): Boolean {60 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {61 return false62 }63 password = value64 _state.value = AdoptVaultUiState.Pair65 return true66 }6768 fun adopt(invitation: String) {69 if (_state.value !is AdoptVaultUiState.Pair) return70 if (parsePairingInvitation(invitation) == null) {71 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")72 clearPassword()73 return74 }75 _state.value = AdoptVaultUiState.Connecting76 protocolError = null77 adoptionJob = viewModelScope.launch {78 try {79 val adopted = receiver.adopt(80 encodedInvitation = invitation,81 deps = receiverDeps(),82 confirm = { sas ->83 val pending = CompletableDeferred<Boolean>()84 confirmation = pending85 _state.value = AdoptVaultUiState.ConfirmSas(sas)86 pending.await()87 },88 )89 _state.value = AdoptVaultUiState.Complete(90 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",91 )92 } catch (error: CancellationException) {93 throw error94 } catch (error: Throwable) {95 _state.value = AdoptVaultUiState.Failed(96 protocolError ?: error.message ?: "adoption_failed",97 )98 } finally {99 confirmation = null100 clearPassword()101 }102 }103 }104105 fun confirmSas(matches: Boolean) {106 val pending = confirmation ?: return107 confirmation = null108 if (matches) _state.value = AdoptVaultUiState.Adopting109 pending.complete(matches)110 }111112 fun restart() {113 adoptionJob?.cancel()114 adoptionJob = null115 confirmation?.complete(false)116 confirmation = null117 clearPassword()118 _state.value = AdoptVaultUiState.Password119 }120121 private fun receiverDeps(): AdoptionReceiverDeps =122 object : AdoptionReceiverDeps {123 override val deviceName = getDeviceName(appContext)124 override val deviceType = DeviceType.ANDROID125 override fun generateDeviceId(): String = UUID.randomUUID().toString()126127 128 129 override suspend fun confirmSas(): Boolean = false130131 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {132 val localPassword = password133 ?: throw IllegalStateException("adoption_password_unavailable")134 return when (135 val result = vault.createVaultFromRootKey(localPassword, rootKeyHex)136 ) {137 is AdoptVaultResult.Success -> result.vaultId138 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)139 }140 }141142 override suspend fun deriveLocalIdentity(): LocalChirpIdentity {143 val master = vault.syncMasterKey()144 ?: throw IllegalStateException("adopted_vault_unavailable")145 return try {146 buildLocalIdentity(appContext, master)147 } finally {148 VaultCrypto.zero(master)149 }150 }151152 override suspend fun upsertPairedDevice(device: PairedDevice) {153 devices.upsert(device)154 155 156 157 PeachSyncService.start(appContext)158 }159160 override suspend fun applyLicense(licenseKey: String) {161 if (license.validateLicense(licenseKey)) {162 license.storeLicense(licenseKey)163 }164 }165166 override fun onError(error: String) {167 protocolError = error168 }169 }170171 private fun clearPassword() {172 password = null173 }174175 override fun onCleared() {176 adoptionJob?.cancel()177 confirmation?.complete(false)178 clearPassword()179 super.onCleared()180 }181}