AdoptVaultViewModel.kt

10.2 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.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    /**37     * Join-mode only: this device already holds a vault, so joining replaces38     * its root key and therefore its recovery words. Nothing connects until39     * the user has been told that and accepted it.40     */41    data object Disclosure : AdoptVaultUiState42    data object Pair : AdoptVaultUiState43    data object Connecting : AdoptVaultUiState44    data class ConfirmSas(val sas: String) : AdoptVaultUiState45    /**46     * Join-mode only, and the last point of no return: the SAS matched and the47     * root key has been granted, but nothing is written yet. Shows the words48     * that are about to replace this device's, so the user can record them or49     * confirm they already hold them.50     */51    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    /**67     * True when this device already has a vault, so the flow joins the scanned68     * vault (keeping local entries) instead of creating one. Read once: the69     * answer cannot change while this screen is open.70     */71    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    /** The user has read what joining does to this device's recovery words. */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                // The pair carries the source's relay, but the Sync card reads122                // this device's own setting — left alone it would say "Public123                // relay" on a phone dialling a private one. Peach's own URL124                // still lands on PUBLIC, so joining over the public relay is125                // not relabelled private.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        // In join mode the word-retirement gate comes next and owns the state.148        if (matches && !joining) _state.value = AdoptVaultUiState.Adopting149        pending.complete(matches)150    }151152    /** Proceed with the join, retiring this device's words. */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            // RelayAdoptionReceiver replaces this with the SAS bound to its178            // completed Noise transcript.179            override suspend fun confirmSas(): Boolean = false180181            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {182                // Same protocol seam, two local outcomes: a fresh device builds183                // a vault around the granted root key, while a device that184                // already has one keeps its entries and joins.185                val result = if (joining) {186                    // Last gate before the old words become unusable. The root187                    // key is granted but nothing is written yet, so declining188                    // here leaves this vault exactly as it was.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                // Start listening only after the source identity and relay-bound223                // pair state are durable. This lets the browser source perform224                // its first sync immediately after adoption.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}