AdoptVaultViewModel.kt

6.8 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    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            // RelayAdoptionReceiver replaces this with the SAS bound to its128            // completed Noise transcript.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                // Start listening only after the source identity and relay-bound155                // pair state are durable. This lets the browser source perform156                // its first sync immediately after adoption.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}