SettingsViewModel.kt

7.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.VaultSettings16import dagger.hilt.android.lifecycle.HiltViewModel17import dagger.hilt.android.qualifiers.ApplicationContext18import kotlinx.coroutines.flow.MutableStateFlow19import kotlinx.coroutines.flow.StateFlow20import kotlinx.coroutines.flow.asStateFlow21import kotlinx.coroutines.launch22import javax.inject.Inject2324@HiltViewModel25class SettingsViewModel @Inject constructor(26    private val vault: VaultRepository,27    private val license: LicenseRepository,28    private val feedback: FeedbackRepository,29    private val duress: com.peachpasswords.vault.DuressConfigStore,30    @ApplicationContext private val appContext: Context,31) : ViewModel() {3233    private val _duressConfigured = MutableStateFlow(duress.isConfigured())34    val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()3536    /**37     * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the38     * action policy. The passphrase keys slot B directly and is never stored as a hash.39     */40    suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {41        if (!vault.setDuressPassphrase(password)) return false42        val ok = duress.enable(action)43        if (ok) _duressConfigured.value = true44        return ok45    }4647    fun disableDuress() {48        viewModelScope.launch { vault.clearDuress() }49        duress.clear()50        _duressConfigured.value = false51    }5253    private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)54    val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()5556    fun sendFeedback(type: String, message: String) {57        if (_feedbackState.value is FeedbackState.Sending) return58        _feedbackState.value = FeedbackState.Sending59        viewModelScope.launch {60            _feedbackState.value = try {61                feedback.submit(type, message)62                FeedbackState.Sent63            } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {64                FeedbackState.Error("Feedback unavailable — endpoint not configured")65            } catch (e: Exception) {66                FeedbackState.Error("Couldn't send feedback — check your connection")67            }68        }69    }7071    /** Reset to Idle when the Feedback card leaves the screen. */72    fun resetFeedback() {73        _feedbackState.value = FeedbackState.Idle74    }75    val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries76    val settings: StateFlow<VaultSettings> = vault.settings7778    /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */79    private val _isPro = MutableStateFlow(license.isProActive())80    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()8182    private val _activating = MutableStateFlow(false)83    val activating: StateFlow<Boolean> = _activating.asStateFlow()8485    private val _activationError = MutableStateFlow<String?>(null)86    val activationError: StateFlow<String?> = _activationError.asStateFlow()8788    /** Set true after a fresh activation so the screen can fire ProCelebration once. */89    private val _showCelebration = MutableStateFlow(false)90    val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()9192    /**93     * Validate a license key locally (offline Ed25519 — there is no activation server)94     * and, on success, persist it and flip Pro on. Surfaces only outcomes that can95     * actually occur offline.96     */97    fun activateLicense(rawKey: String) {98        if (_activating.value) return99        _activating.value = true100        _activationError.value = null101        viewModelScope.launch {102            val key = rawKey.trim()103            if (license.validateLicense(key)) {104                license.storeLicense(key)105                _isPro.value = true106                _showCelebration.value = true107            } else {108                _activationError.value = "Invalid license key"109            }110            _activating.value = false111        }112    }113114    fun clearActivationError() { _activationError.value = null }115116    fun dismissCelebration() {117        license.markProCelebrated()118        _showCelebration.value = false119    }120121    fun deactivate() {122        license.clearLicense()123        _isPro.value = false124    }125126    private fun mutate(block: (VaultSettings) -> VaultSettings) {127        viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }128    }129130    fun setTheme(theme: String) = mutate { it.copy(theme = theme) }131    fun setAccent(name: String) = mutate { it.copy(accentColor = name) }132    fun setTextSize(size: String) = mutate { it.copy(textSize = size) }133    fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }134    fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }135    fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }136137    /**138     * Enable biometric unlock: prompt to wrap the live vault key under a hardware139     * KeyStore key and persist the escrow, then flip the setting on. Requires the140     * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt141     * leaves biometrics off.142     */143    fun enableBiometrics(activity: FragmentActivity) {144        val key = vault.currentVaultKey() ?: return145        val cipher = try {146            BiometricKeyStore.encryptCipher()147        } catch (_: Exception) {148            VaultCrypto.zero(key); return149        }150        Biometrics.authenticateCrypto(151            activity = activity,152            title = "Enable biometric unlock",153            subtitle = "Confirm to secure your vault key",154            crypto = BiometricPrompt.CryptoObject(cipher),155            onSuccess = { result ->156                val used = result.cryptoObject?.cipher157                if (used != null) {158                    runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }159                        .onSuccess { mutate { it.copy(biometricsEnabled = true) } }160                }161                VaultCrypto.zero(key)162            },163            onFail = { VaultCrypto.zero(key) },164        )165    }166167    /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */168    fun disableBiometrics() {169        BiometricKeyStore.clear(appContext)170        mutate { it.copy(biometricsEnabled = false) }171    }172    fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }173    fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }174175    suspend fun changePassword(current: String, next: String): Boolean =176        vault.changePassword(current, next)177178    /** One-line summary shown under each card title; mirrors the browser ledes. */179    fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {180        CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"181        CardId.Protection ->182            "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"183        CardId.Vault ->184            "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"185        CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"186        CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"187        CardId.Shortcuts -> "bindings · what your fingers know"188        CardId.Feedback -> "tell us what would make it better"189    }190}