AdoptVaultViewModel.kt

9.7 KB · raw

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    /**36     * Join-mode only: this device already holds a vault, so joining replaces37     * its root key and therefore its recovery words. Nothing connects until38     * the user has been told that and accepted it.39     */40    data object Disclosure : AdoptVaultUiState41    data object Pair : AdoptVaultUiState42    data object Connecting : AdoptVaultUiState43    data class ConfirmSas(val sas: String) : AdoptVaultUiState44    /**45     * Join-mode only, and the last point of no return: the SAS matched and the46     * root key has been granted, but nothing is written yet. Shows the words47     * that are about to replace this device's, so the user can record them or48     * confirm they already hold them.49     */50    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    /**65     * True when this device already has a vault, so the flow joins the scanned66     * vault (keeping local entries) instead of creating one. Read once: the67     * answer cannot change while this screen is open.68     */69    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    /** The user has read what joining does to this device's recovery words. */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        // In join mode the word-retirement gate comes next and owns the state.140        if (matches && !joining) _state.value = AdoptVaultUiState.Adopting141        pending.complete(matches)142    }143144    /** Proceed with the join, retiring this device's words. */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            // RelayAdoptionReceiver replaces this with the SAS bound to its170            // completed Noise transcript.171            override suspend fun confirmSas(): Boolean = false172173            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {174                // Same protocol seam, two local outcomes: a fresh device builds175                // a vault around the granted root key, while a device that176                // already has one keeps its entries and joins.177                val result = if (joining) {178                    // Last gate before the old words become unusable. The root179                    // key is granted but nothing is written yet, so declining180                    // here leaves this vault exactly as it was.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                // Start listening only after the source identity and relay-bound215                // pair state are durable. This lets the browser source perform216                // its first sync immediately after adoption.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}