SettingsViewModel.kt

9.1 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    private val _dedupCount = MutableStateFlow<Int?>(null)57    val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()5859    private val _dedupRunning = MutableStateFlow(false)60    val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()6162    private val _dedupDone = MutableStateFlow<Int?>(null)63    val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()6465    fun sendFeedback(type: String, message: String) {66        if (_feedbackState.value is FeedbackState.Sending) return67        _feedbackState.value = FeedbackState.Sending68        viewModelScope.launch {69            _feedbackState.value = try {70                feedback.submit(type, message)71                FeedbackState.Sent72            } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {73                FeedbackState.Error("Feedback unavailable — endpoint not configured")74            } catch (e: Exception) {75                FeedbackState.Error("Couldn't send feedback — check your connection")76            }77        }78    }7980    /** Reset to Idle when the Feedback card leaves the screen. */81    fun resetFeedback() {82        _feedbackState.value = FeedbackState.Idle83    }8485    fun scanDuplicates() {86        if (_dedupRunning.value) return87        _dedupRunning.value = true88        _dedupDone.value = null89        viewModelScope.launch {90            _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)91            _dedupRunning.value = false92        }93    }9495    fun cancelDuplicateRemoval() {96        if (_dedupRunning.value) return97        _dedupCount.value = null98    }99100    fun removeDuplicates() {101        if (_dedupRunning.value) return102        _dedupRunning.value = true103        viewModelScope.launch {104            val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)105            _dedupDone.value = removed106            _dedupCount.value = 0107            _dedupRunning.value = false108        }109    }110111    val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries112    val settings: StateFlow<VaultSettings> = vault.settings113114    /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */115    private val _isPro = MutableStateFlow(license.isProActive())116    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()117118    private val _activating = MutableStateFlow(false)119    val activating: StateFlow<Boolean> = _activating.asStateFlow()120121    private val _activationError = MutableStateFlow<String?>(null)122    val activationError: StateFlow<String?> = _activationError.asStateFlow()123124    /** Set true after a fresh activation so the screen can fire ProCelebration once. */125    private val _showCelebration = MutableStateFlow(false)126    val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()127128    /**129     * Validate a license key locally (offline Ed25519 — there is no activation server)130     * and, on success, persist it and flip Pro on. Surfaces only outcomes that can131     * actually occur offline.132     */133    fun activateLicense(rawKey: String) {134        if (_activating.value) return135        _activating.value = true136        _activationError.value = null137        viewModelScope.launch {138            val key = rawKey.trim()139            if (license.validateLicense(key)) {140                license.storeLicense(key)141                _isPro.value = true142                _showCelebration.value = true143            } else {144                _activationError.value = "Invalid license key"145            }146            _activating.value = false147        }148    }149150    fun clearActivationError() { _activationError.value = null }151152    fun dismissCelebration() {153        license.markProCelebrated()154        _showCelebration.value = false155    }156157    fun deactivate() {158        license.clearLicense()159        _isPro.value = false160    }161162    private fun mutate(block: (VaultSettings) -> VaultSettings) {163        viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }164    }165166    fun setTheme(theme: String) = mutate { it.copy(theme = theme) }167    fun setAccent(name: String) = mutate { it.copy(accentColor = name) }168    fun setTextSize(size: String) = mutate { it.copy(textSize = size) }169    fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }170    fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }171    fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }172173    /**174     * Enable biometric unlock: prompt to wrap the live vault key under a hardware175     * KeyStore key and persist the escrow, then flip the setting on. Requires the176     * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt177     * leaves biometrics off.178     */179    fun enableBiometrics(activity: FragmentActivity) {180        val key = vault.currentVaultKey() ?: return181        val cipher = try {182            BiometricKeyStore.encryptCipher()183        } catch (_: Exception) {184            VaultCrypto.zero(key); return185        }186        Biometrics.authenticateCrypto(187            activity = activity,188            title = "Enable biometric unlock",189            subtitle = "Confirm to secure your vault key",190            crypto = BiometricPrompt.CryptoObject(cipher),191            onSuccess = { result ->192                val used = result.cryptoObject?.cipher193                if (used != null) {194                    runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }195                        .onSuccess { mutate { it.copy(biometricsEnabled = true) } }196                }197                VaultCrypto.zero(key)198            },199            onFail = { VaultCrypto.zero(key) },200        )201    }202203    /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */204    fun disableBiometrics() {205        BiometricKeyStore.clear(appContext)206        mutate { it.copy(biometricsEnabled = false) }207    }208    fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }209    fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }210211    suspend fun changePassword(current: String, next: String): Boolean =212        vault.changePassword(current, next)213214    /** One-line summary shown under each card title; mirrors the browser ledes. */215    fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {216        CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"217        CardId.Protection ->218            "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"219        CardId.Vault ->220            "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"221        CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"222        CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"223        CardId.Shortcuts -> "bindings · what your fingers know"224        CardId.Feedback -> "tell us what would make it better"225    }226}