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.LocalDeviceIdentity10import com.peachpasswords.sync.PairedDevice11import com.peachpasswords.sync.PairedDeviceStore12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelayAdoptionReceiver14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.buildLocalIdentity16import com.peachpasswords.sync.getDeviceName17import com.peachpasswords.sync.parsePairingInvitation18import com.peachpasswords.ui.security.scorePassword19import com.peachpasswords.vault.AdoptVaultResult20import com.peachpasswords.vault.VaultCrypto21import com.peachpasswords.vault.VaultRepository22import dagger.hilt.android.lifecycle.HiltViewModel23import dagger.hilt.android.qualifiers.ApplicationContext24import java.util.UUID25import javax.inject.Inject26import kotlinx.coroutines.CompletableDeferred27import kotlinx.coroutines.CancellationException28import kotlinx.coroutines.Job29import kotlinx.coroutines.flow.MutableStateFlow30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.launch3334sealed interface AdoptVaultUiState {35 data object Password : AdoptVaultUiState36 3738394041 data object Disclosure : AdoptVaultUiState42 data object Pair : AdoptVaultUiState43 data object Connecting : AdoptVaultUiState44 data class ConfirmSas(val sas: String) : AdoptVaultUiState45 464748495051 data class RetireWords(val incomingWords: List<String>) : AdoptVaultUiState52 data object Adopting : AdoptVaultUiState53 data class Complete(val sourceName: String) : AdoptVaultUiState54 data class Failed(val reason: String) : AdoptVaultUiState55}5657@HiltViewModel58class AdoptVaultViewModel @Inject constructor(59 private val receiver: RelayAdoptionReceiver,60 private val vault: VaultRepository,61 private val devices: PairedDeviceStore,62 private val relaySettings: RelaySettingsStore,63 private val license: LicenseRepository,64 @ApplicationContext private val appContext: Context,65) : ViewModel() {66 6768697071 val joining: Boolean = vault.hasVault()7273 private val _state = MutableStateFlow<AdoptVaultUiState>(74 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,75 )76 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7778 private var password: String? = null79 private var confirmation: CompletableDeferred<Boolean>? = null80 private var wordRetirement: CompletableDeferred<Boolean>? = null81 private var adoptionJob: Job? = null82 private var protocolError: String? = null8384 fun acceptPassword(value: String, repeated: String): Boolean {85 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {86 return false87 }88 password = value89 _state.value = AdoptVaultUiState.Pair90 return true91 }9293 94 fun acceptDisclosure() {95 if (_state.value is AdoptVaultUiState.Disclosure) {96 _state.value = AdoptVaultUiState.Pair97 }98 }99100 fun adopt(invitation: String) {101 if (_state.value !is AdoptVaultUiState.Pair) return102 if (parsePairingInvitation(invitation) == null) {103 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")104 clearPassword()105 return106 }107 _state.value = AdoptVaultUiState.Connecting108 protocolError = null109 adoptionJob = viewModelScope.launch {110 try {111 val adopted = receiver.adopt(112 encodedInvitation = invitation,113 deps = receiverDeps(),114 confirm = { sas ->115 val pending = CompletableDeferred<Boolean>()116 confirmation = pending117 _state.value = AdoptVaultUiState.ConfirmSas(sas)118 pending.await()119 },120 )121 122 123 124 125 126 adopted.relayUrl?.let(relaySettings::setRelayUrl)127 _state.value = AdoptVaultUiState.Complete(128 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",129 )130 } catch (error: CancellationException) {131 throw error132 } catch (error: Throwable) {133 _state.value = AdoptVaultUiState.Failed(134 protocolError ?: error.message ?: "adoption_failed",135 )136 } finally {137 confirmation = null138 wordRetirement = null139 clearPassword()140 }141 }142 }143144 fun confirmSas(matches: Boolean) {145 val pending = confirmation ?: return146 confirmation = null147 148 if (matches && !joining) _state.value = AdoptVaultUiState.Adopting149 pending.complete(matches)150 }151152 153 fun acknowledgeWordRetirement(proceed: Boolean) {154 val pending = wordRetirement ?: return155 wordRetirement = null156 pending.complete(proceed)157 }158159 fun restart() {160 adoptionJob?.cancel()161 adoptionJob = null162 confirmation?.complete(false)163 confirmation = null164 wordRetirement?.complete(false)165 wordRetirement = null166 clearPassword()167 _state.value =168 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password169 }170171 private fun receiverDeps(): AdoptionReceiverDeps =172 object : AdoptionReceiverDeps {173 override val deviceName = getDeviceName(appContext)174 override val deviceType = DeviceType.ANDROID175 override fun generateDeviceId(): String = UUID.randomUUID().toString()176177 178 179 override suspend fun confirmSas(): Boolean = false180181 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {182 183 184 185 val result = if (joining) {186 187 188 189 val incoming = vault.previewRecoveryWords(rootKeyHex)190 ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY")191 val acknowledged = CompletableDeferred<Boolean>()192 wordRetirement = acknowledged193 _state.value = AdoptVaultUiState.RetireWords(incoming)194 if (!acknowledged.await()) {195 throw IllegalStateException("join_declined_at_word_retirement")196 }197 _state.value = AdoptVaultUiState.Adopting198 vault.joinVault(rootKeyHex)199 } else {200 val localPassword = password201 ?: throw IllegalStateException("adoption_password_unavailable")202 vault.createVaultFromRootKey(localPassword, rootKeyHex)203 }204 return when (result) {205 is AdoptVaultResult.Success -> result.vaultId206 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)207 }208 }209210 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {211 val master = vault.syncMasterKey()212 ?: throw IllegalStateException("adopted_vault_unavailable")213 return try {214 buildLocalIdentity(appContext, master)215 } finally {216 VaultCrypto.zero(master)217 }218 }219220 override suspend fun upsertPairedDevice(device: PairedDevice) {221 devices.upsert(device)222 223 224 225 PeachSyncService.start(appContext)226 }227228 override suspend fun applyLicense(licenseKey: String) {229 if (license.validateLicense(licenseKey)) {230 license.storeLicense(licenseKey)231 }232 }233234 override fun onError(error: String) {235 protocolError = error236 }237 }238239 private fun clearPassword() {240 password = null241 }242243 override fun onCleared() {244 adoptionJob?.cancel()245 confirmation?.complete(false)246 wordRetirement?.complete(false)247 clearPassword()248 super.onCleared()249 }250}