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 3637383940 data object Disclosure : AdoptVaultUiState41 data object Pair : AdoptVaultUiState42 data object Connecting : AdoptVaultUiState43 data class ConfirmSas(val sas: String) : AdoptVaultUiState44 454647484950 data class RetireWords(val incomingWords: List<String>) : AdoptVaultUiState51 data object Adopting : AdoptVaultUiState52 data class Complete(val sourceName: String) : AdoptVaultUiState53 data class Failed(val reason: String) : AdoptVaultUiState54}5556@HiltViewModel57class AdoptVaultViewModel @Inject constructor(58 private val receiver: RelayAdoptionReceiver,59 private val vault: VaultRepository,60 private val devices: PairedDeviceStore,61 private val license: LicenseRepository,62 @ApplicationContext private val appContext: Context,63) : ViewModel() {64 6566676869 val joining: Boolean = vault.hasVault()7071 private val _state = MutableStateFlow<AdoptVaultUiState>(72 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,73 )74 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7576 private var password: String? = null77 private var confirmation: CompletableDeferred<Boolean>? = null78 private var wordRetirement: CompletableDeferred<Boolean>? = null79 private var adoptionJob: Job? = null80 private var protocolError: String? = null8182 fun acceptPassword(value: String, repeated: String): Boolean {83 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {84 return false85 }86 password = value87 _state.value = AdoptVaultUiState.Pair88 return true89 }9091 92 fun acceptDisclosure() {93 if (_state.value is AdoptVaultUiState.Disclosure) {94 _state.value = AdoptVaultUiState.Pair95 }96 }9798 fun adopt(invitation: String) {99 if (_state.value !is AdoptVaultUiState.Pair) return100 if (parsePairingInvitation(invitation) == null) {101 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")102 clearPassword()103 return104 }105 _state.value = AdoptVaultUiState.Connecting106 protocolError = null107 adoptionJob = viewModelScope.launch {108 try {109 val adopted = receiver.adopt(110 encodedInvitation = invitation,111 deps = receiverDeps(),112 confirm = { sas ->113 val pending = CompletableDeferred<Boolean>()114 confirmation = pending115 _state.value = AdoptVaultUiState.ConfirmSas(sas)116 pending.await()117 },118 )119 _state.value = AdoptVaultUiState.Complete(120 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",121 )122 } catch (error: CancellationException) {123 throw error124 } catch (error: Throwable) {125 _state.value = AdoptVaultUiState.Failed(126 protocolError ?: error.message ?: "adoption_failed",127 )128 } finally {129 confirmation = null130 wordRetirement = null131 clearPassword()132 }133 }134 }135136 fun confirmSas(matches: Boolean) {137 val pending = confirmation ?: return138 confirmation = null139 140 if (matches && !joining) _state.value = AdoptVaultUiState.Adopting141 pending.complete(matches)142 }143144 145 fun acknowledgeWordRetirement(proceed: Boolean) {146 val pending = wordRetirement ?: return147 wordRetirement = null148 pending.complete(proceed)149 }150151 fun restart() {152 adoptionJob?.cancel()153 adoptionJob = null154 confirmation?.complete(false)155 confirmation = null156 wordRetirement?.complete(false)157 wordRetirement = null158 clearPassword()159 _state.value =160 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password161 }162163 private fun receiverDeps(): AdoptionReceiverDeps =164 object : AdoptionReceiverDeps {165 override val deviceName = getDeviceName(appContext)166 override val deviceType = DeviceType.ANDROID167 override fun generateDeviceId(): String = UUID.randomUUID().toString()168169 170 171 override suspend fun confirmSas(): Boolean = false172173 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {174 175 176 177 val result = if (joining) {178 179 180 181 val incoming = vault.previewRecoveryWords(rootKeyHex)182 ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY")183 val acknowledged = CompletableDeferred<Boolean>()184 wordRetirement = acknowledged185 _state.value = AdoptVaultUiState.RetireWords(incoming)186 if (!acknowledged.await()) {187 throw IllegalStateException("join_declined_at_word_retirement")188 }189 _state.value = AdoptVaultUiState.Adopting190 vault.joinVault(rootKeyHex)191 } else {192 val localPassword = password193 ?: throw IllegalStateException("adoption_password_unavailable")194 vault.createVaultFromRootKey(localPassword, rootKeyHex)195 }196 return when (result) {197 is AdoptVaultResult.Success -> result.vaultId198 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)199 }200 }201202 override suspend fun deriveLocalIdentity(): LocalChirpIdentity {203 val master = vault.syncMasterKey()204 ?: throw IllegalStateException("adopted_vault_unavailable")205 return try {206 buildLocalIdentity(appContext, master)207 } finally {208 VaultCrypto.zero(master)209 }210 }211212 override suspend fun upsertPairedDevice(device: PairedDevice) {213 devices.upsert(device)214 215 216 217 PeachSyncService.start(appContext)218 }219220 override suspend fun applyLicense(licenseKey: String) {221 if (license.validateLicense(licenseKey)) {222 license.storeLicense(licenseKey)223 }224 }225226 override fun onError(error: String) {227 protocolError = error228 }229 }230231 private fun clearPassword() {232 password = null233 }234235 override fun onCleared() {236 adoptionJob?.cancel()237 confirmation?.complete(false)238 wordRetirement?.complete(false)239 clearPassword()240 super.onCleared()241 }242}