SettingsViewModel.kt

9.9 KB · raw

1package com.peachpasswords.ui.settings23import android.content.Context4import androidx.biometric.BiometricPrompt5import androidx.fragment.app.FragmentActivity6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.feedback.FeedbackRepository9import com.peachpasswords.feedback.FeedbackState10import com.peachpasswords.license.LicenseRepository11import com.peachpasswords.ui.lock.BiometricKeyStore12import com.peachpasswords.ui.lock.Biometrics13import com.peachpasswords.vault.VaultCrypto14import com.peachpasswords.vault.VaultRepository15import com.peachpasswords.vault.VaultSnapshot16import com.peachpasswords.vault.VaultSettings17import dagger.hilt.android.lifecycle.HiltViewModel18import dagger.hilt.android.qualifiers.ApplicationContext19import kotlinx.coroutines.flow.MutableStateFlow20import kotlinx.coroutines.flow.StateFlow21import kotlinx.coroutines.flow.asStateFlow22import kotlinx.coroutines.launch23import javax.inject.Inject2425@HiltViewModel26class SettingsViewModel @Inject constructor(27    private val vault: VaultRepository,28    private val license: LicenseRepository,29    private val feedback: FeedbackRepository,30    private val duress: com.peachpasswords.vault.DuressConfigStore,31    @ApplicationContext private val appContext: Context,32) : ViewModel() {3334    private val _duressConfigured = MutableStateFlow(duress.isConfigured())35    val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()3637    /**38     * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the39     * action policy. The passphrase keys slot B directly and is never stored as a hash.40     */41    suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {42        if (!vault.setDuressPassphrase(password)) return false43        val ok = duress.enable(action)44        if (ok) _duressConfigured.value = true45        return ok46    }4748    fun disableDuress() {49        viewModelScope.launch { vault.clearDuress() }50        duress.clear()51        _duressConfigured.value = false52    }5354    private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)55    val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()5657    private val _dedupCount = MutableStateFlow<Int?>(null)58    val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()5960    private val _dedupRunning = MutableStateFlow(false)61    val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()6263    private val _dedupDone = MutableStateFlow<Int?>(null)64    val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()6566    private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())67    val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()6869    private val _restoringSnapshotId = MutableStateFlow<String?>(null)70    val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()7172    fun refreshVaultHistory() {73        _vaultHistory.value = vault.getVaultHistory()74    }7576    fun sendFeedback(type: String, message: String) {77        if (_feedbackState.value is FeedbackState.Sending) return78        _feedbackState.value = FeedbackState.Sending79        viewModelScope.launch {80            _feedbackState.value = try {81                feedback.submit(type, message)82                FeedbackState.Sent83            } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {84                FeedbackState.Error("Feedback unavailable — endpoint not configured")85            } catch (e: Exception) {86                FeedbackState.Error("Couldn't send feedback — check your connection")87            }88        }89    }9091    /** Reset to Idle when the Feedback card leaves the screen. */92    fun resetFeedback() {93        _feedbackState.value = FeedbackState.Idle94    }9596    fun scanDuplicates() {97        if (_dedupRunning.value) return98        _dedupRunning.value = true99        _dedupDone.value = null100        viewModelScope.launch {101            _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)102            _dedupRunning.value = false103        }104    }105106    fun cancelDuplicateRemoval() {107        if (_dedupRunning.value) return108        _dedupCount.value = null109    }110111    fun removeDuplicates() {112        if (_dedupRunning.value) return113        _dedupRunning.value = true114        viewModelScope.launch {115            val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)116            _dedupDone.value = removed117            _dedupCount.value = 0118            refreshVaultHistory()119            _dedupRunning.value = false120        }121    }122123    fun restoreVaultSnapshot(snapshotId: String) {124        if (_restoringSnapshotId.value != null) return125        _restoringSnapshotId.value = snapshotId126        viewModelScope.launch {127            runCatching { vault.restoreVaultSnapshot(snapshotId) }128            _restoringSnapshotId.value = null129            refreshVaultHistory()130        }131    }132133    val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries134    val settings: StateFlow<VaultSettings> = vault.settings135136    /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */137    private val _isPro = MutableStateFlow(license.isProActive())138    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()139140    private val _activating = MutableStateFlow(false)141    val activating: StateFlow<Boolean> = _activating.asStateFlow()142143    private val _activationError = MutableStateFlow<String?>(null)144    val activationError: StateFlow<String?> = _activationError.asStateFlow()145146    /** Set true after a fresh activation so the screen can fire ProCelebration once. */147    private val _showCelebration = MutableStateFlow(false)148    val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()149150    /**151     * Validate a license key locally (offline Ed25519 — there is no activation server)152     * and, on success, persist it and flip Pro on. Surfaces only outcomes that can153     * actually occur offline.154     */155    fun activateLicense(rawKey: String) {156        if (_activating.value) return157        _activating.value = true158        _activationError.value = null159        viewModelScope.launch {160            val key = rawKey.trim()161            if (license.validateLicense(key)) {162                license.storeLicense(key)163                _isPro.value = true164                _showCelebration.value = true165            } else {166                _activationError.value = "Invalid license key"167            }168            _activating.value = false169        }170    }171172    fun clearActivationError() { _activationError.value = null }173174    fun dismissCelebration() {175        license.markProCelebrated()176        _showCelebration.value = false177    }178179    fun deactivate() {180        license.clearLicense()181        _isPro.value = false182    }183184    private fun mutate(block: (VaultSettings) -> VaultSettings) {185        viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }186    }187188    fun setTheme(theme: String) = mutate { it.copy(theme = theme) }189    fun setAccent(name: String) = mutate { it.copy(accentColor = name) }190    fun setTextSize(size: String) = mutate { it.copy(textSize = size) }191    fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }192    fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }193    fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }194195    /**196     * Enable biometric unlock: prompt to wrap the live vault key under a hardware197     * KeyStore key and persist the escrow, then flip the setting on. Requires the198     * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt199     * leaves biometrics off.200     */201    fun enableBiometrics(activity: FragmentActivity) {202        val key = vault.currentVaultKey() ?: return203        val cipher = try {204            BiometricKeyStore.encryptCipher()205        } catch (_: Exception) {206            VaultCrypto.zero(key); return207        }208        Biometrics.authenticateCrypto(209            activity = activity,210            title = "Enable biometric unlock",211            subtitle = "Confirm to secure your vault key",212            crypto = BiometricPrompt.CryptoObject(cipher),213            onSuccess = { result ->214                val used = result.cryptoObject?.cipher215                if (used != null) {216                    runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }217                        .onSuccess { mutate { it.copy(biometricsEnabled = true) } }218                }219                VaultCrypto.zero(key)220            },221            onFail = { VaultCrypto.zero(key) },222        )223    }224225    /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */226    fun disableBiometrics() {227        BiometricKeyStore.clear(appContext)228        mutate { it.copy(biometricsEnabled = false) }229    }230    fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }231    fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }232233    suspend fun changePassword(current: String, next: String): Boolean =234        vault.changePassword(current, next)235236    /** One-line summary shown under each card title; mirrors the browser ledes. */237    fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {238        CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"239        CardId.Protection ->240            "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"241        CardId.Vault ->242            "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"243        CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"244        CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"245        CardId.Shortcuts -> "bindings · what your fingers know"246        CardId.Feedback -> "tell us what would make it better"247    }248}