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.BackgroundSyncPreference9import com.peachpasswords.sync.DeviceType10import com.peachpasswords.sync.LocalDeviceIdentity11import com.peachpasswords.sync.PairedDevice12import com.peachpasswords.sync.PairedDeviceStore13import com.peachpasswords.sync.PeachSyncService14import com.peachpasswords.sync.RelayAdoptionReceiver15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.buildLocalIdentity17import com.peachpasswords.sync.getDeviceName18import com.peachpasswords.sync.parsePairingInvitation19import com.peachpasswords.ui.security.scorePassword20import com.peachpasswords.vault.AdoptVaultResult21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import java.util.UUID26import javax.inject.Inject27import kotlinx.coroutines.CompletableDeferred28import kotlinx.coroutines.CancellationException29import kotlinx.coroutines.Job30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.launch3435sealed interface AdoptVaultUiState {36    data object Password : AdoptVaultUiState37    /**38     * Join-mode only: this device already holds a vault, so joining replaces39     * its root key and therefore its recovery words. Nothing connects until40     * the user has been told that and accepted it.41     */42    data object Disclosure : AdoptVaultUiState43    data object Pair : AdoptVaultUiState44    data object Connecting : AdoptVaultUiState45    data class ConfirmSas(val sas: String) : AdoptVaultUiState46    /**47     * Join-mode only, and the last point of no return: the SAS matched and the48     * root key has been granted, but nothing is written yet. Shows the words49     * that are about to replace this device's, so the user can record them or50     * confirm they already hold them.51     */52    data class RetireWords(val incomingWords: List<String>) : AdoptVaultUiState53    data object Adopting : AdoptVaultUiState54    data class Complete(val sourceName: String) : AdoptVaultUiState55    data class Failed(val reason: String) : AdoptVaultUiState56}5758@HiltViewModel59class AdoptVaultViewModel @Inject constructor(60    private val receiver: RelayAdoptionReceiver,61    private val vault: VaultRepository,62    private val devices: PairedDeviceStore,63    private val relaySettings: RelaySettingsStore,64    private val backgroundSync: BackgroundSyncPreference,65    private val license: LicenseRepository,66    @ApplicationContext private val appContext: Context,67) : ViewModel() {68    /**69     * True when this device already has a vault, so the flow joins the scanned70     * vault (keeping local entries) instead of creating one. Read once: the71     * answer cannot change while this screen is open.72     */73    val joining: Boolean = vault.hasVault()7475    private val _state = MutableStateFlow<AdoptVaultUiState>(76        if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,77    )78    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7980    private var password: String? = null81    private var confirmation: CompletableDeferred<Boolean>? = null82    private var wordRetirement: CompletableDeferred<Boolean>? = null83    private var adoptionJob: Job? = null84    private var protocolError: String? = null8586    fun acceptPassword(value: String, repeated: String): Boolean {87        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {88            return false89        }90        password = value91        _state.value = AdoptVaultUiState.Pair92        return true93    }9495    /** The user has read what joining does to this device's recovery words. */96    fun acceptDisclosure() {97        if (_state.value is AdoptVaultUiState.Disclosure) {98            _state.value = AdoptVaultUiState.Pair99        }100    }101102    fun adopt(invitation: String) {103        if (_state.value !is AdoptVaultUiState.Pair) return104        if (parsePairingInvitation(invitation) == null) {105            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")106            clearPassword()107            return108        }109        _state.value = AdoptVaultUiState.Connecting110        protocolError = null111        adoptionJob = viewModelScope.launch {112            try {113                val adopted = receiver.adopt(114                    encodedInvitation = invitation,115                    deps = receiverDeps(),116                    confirm = { sas ->117                        val pending = CompletableDeferred<Boolean>()118                        confirmation = pending119                        _state.value = AdoptVaultUiState.ConfirmSas(sas)120                        pending.await()121                    },122                )123                // The pair carries the source's relay, but the Sync card reads124                // this device's own setting — left alone it would say "Public125                // relay" on a phone dialling a private one. Peach's own URL126                // still lands on PUBLIC, so joining over the public relay is127                // not relabelled private.128                adopted.relayUrl?.let(relaySettings::setRelayUrl)129                _state.value = AdoptVaultUiState.Complete(130                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",131                )132            } catch (error: CancellationException) {133                throw error134            } catch (error: Throwable) {135                _state.value = AdoptVaultUiState.Failed(136                    protocolError ?: error.message ?: "adoption_failed",137                )138            } finally {139                confirmation = null140                wordRetirement = null141                clearPassword()142            }143        }144    }145146    fun confirmSas(matches: Boolean) {147        val pending = confirmation ?: return148        confirmation = null149        // In join mode the word-retirement gate comes next and owns the state.150        if (matches && !joining) _state.value = AdoptVaultUiState.Adopting151        pending.complete(matches)152    }153154    /** Proceed with the join, retiring this device's words. */155    fun acknowledgeWordRetirement(proceed: Boolean) {156        val pending = wordRetirement ?: return157        wordRetirement = null158        pending.complete(proceed)159    }160161    fun restart() {162        adoptionJob?.cancel()163        adoptionJob = null164        confirmation?.complete(false)165        confirmation = null166        wordRetirement?.complete(false)167        wordRetirement = null168        clearPassword()169        _state.value =170            if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password171    }172173    private fun receiverDeps(): AdoptionReceiverDeps =174        object : AdoptionReceiverDeps {175            override val deviceName = getDeviceName(appContext)176            override val deviceType = DeviceType.ANDROID177            override fun generateDeviceId(): String = UUID.randomUUID().toString()178179            // RelayAdoptionReceiver replaces this with the SAS bound to its180            // completed Noise transcript.181            override suspend fun confirmSas(): Boolean = false182183            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {184                // Same protocol seam, two local outcomes: a fresh device builds185                // a vault around the granted root key, while a device that186                // already has one keeps its entries and joins.187                val result = if (joining) {188                    // Last gate before the old words become unusable. The root189                    // key is granted but nothing is written yet, so declining190                    // here leaves this vault exactly as it was.191                    val incoming = vault.previewRecoveryWords(rootKeyHex)192                        ?: throw IllegalStateException("JOIN_UNRECOVERABLE_ROOT_KEY")193                    val acknowledged = CompletableDeferred<Boolean>()194                    wordRetirement = acknowledged195                    _state.value = AdoptVaultUiState.RetireWords(incoming)196                    if (!acknowledged.await()) {197                        throw IllegalStateException("join_declined_at_word_retirement")198                    }199                    _state.value = AdoptVaultUiState.Adopting200                    vault.joinVault(rootKeyHex)201                } else {202                    val localPassword = password203                        ?: throw IllegalStateException("adoption_password_unavailable")204                    vault.createVaultFromRootKey(localPassword, rootKeyHex)205                }206                return when (result) {207                    is AdoptVaultResult.Success -> result.vaultId208                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)209                }210            }211212            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {213                val master = vault.syncMasterKey()214                    ?: throw IllegalStateException("adopted_vault_unavailable")215                return try {216                    buildLocalIdentity(appContext, master)217                } finally {218                    VaultCrypto.zero(master)219                }220            }221222            override suspend fun upsertPairedDevice(device: PairedDevice) {223                devices.upsert(device)224            }225226            override suspend fun applyLicense(licenseKey: String) {227                if (license.validateLicense(licenseKey)) {228                    license.storeLicense(licenseKey)229                }230            }231232            override fun onError(error: String) {233                protocolError = error234            }235        }236237    fun setBackgroundSync(enabled: Boolean) {238        backgroundSync.setEnabled(enabled)239        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)240    }241242    private fun clearPassword() {243        password = null244    }245246    override fun onCleared() {247        adoptionJob?.cancel()248        confirmation?.complete(false)249        wordRetirement?.complete(false)250        clearPassword()251        super.onCleared()252    }253}