package com.peachpasswords.ui.settings import android.content.Context import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.feedback.FeedbackRepository import com.peachpasswords.feedback.FeedbackState import com.peachpasswords.license.LicenseRepository import com.peachpasswords.ui.lock.BiometricKeyStore import com.peachpasswords.ui.lock.Biometrics import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import com.peachpasswords.vault.VaultSettings import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val vault: VaultRepository, private val license: LicenseRepository, private val feedback: FeedbackRepository, private val duress: com.peachpasswords.vault.DuressConfigStore, @ApplicationContext private val appContext: Context, ) : ViewModel() { private val _duressConfigured = MutableStateFlow(duress.isConfigured()) val duressConfigured: StateFlow = _duressConfigured.asStateFlow() /** * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the * action policy. The passphrase keys slot B directly and is never stored as a hash. */ suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean { if (!vault.setDuressPassphrase(password)) return false val ok = duress.enable(action) if (ok) _duressConfigured.value = true return ok } fun disableDuress() { viewModelScope.launch { vault.clearDuress() } duress.clear() _duressConfigured.value = false } private val _feedbackState = MutableStateFlow(FeedbackState.Idle) val feedbackState: StateFlow = _feedbackState.asStateFlow() private val _dedupCount = MutableStateFlow(null) val dedupCount: StateFlow = _dedupCount.asStateFlow() private val _dedupRunning = MutableStateFlow(false) val dedupRunning: StateFlow = _dedupRunning.asStateFlow() private val _dedupDone = MutableStateFlow(null) val dedupDone: StateFlow = _dedupDone.asStateFlow() fun sendFeedback(type: String, message: String) { if (_feedbackState.value is FeedbackState.Sending) return _feedbackState.value = FeedbackState.Sending viewModelScope.launch { _feedbackState.value = try { feedback.submit(type, message) FeedbackState.Sent } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) { FeedbackState.Error("Feedback unavailable — endpoint not configured") } catch (e: Exception) { FeedbackState.Error("Couldn't send feedback — check your connection") } } } /** Reset to Idle when the Feedback card leaves the screen. */ fun resetFeedback() { _feedbackState.value = FeedbackState.Idle } fun scanDuplicates() { if (_dedupRunning.value) return _dedupRunning.value = true _dedupDone.value = null viewModelScope.launch { _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0) _dedupRunning.value = false } } fun cancelDuplicateRemoval() { if (_dedupRunning.value) return _dedupCount.value = null } fun removeDuplicates() { if (_dedupRunning.value) return _dedupRunning.value = true viewModelScope.launch { val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0) _dedupDone.value = removed _dedupCount.value = 0 _dedupRunning.value = false } } val entries: StateFlow> = vault.entries val settings: StateFlow = vault.settings /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */ private val _isPro = MutableStateFlow(license.isProActive()) val isPro: StateFlow = _isPro.asStateFlow() private val _activating = MutableStateFlow(false) val activating: StateFlow = _activating.asStateFlow() private val _activationError = MutableStateFlow(null) val activationError: StateFlow = _activationError.asStateFlow() /** Set true after a fresh activation so the screen can fire ProCelebration once. */ private val _showCelebration = MutableStateFlow(false) val showCelebration: StateFlow = _showCelebration.asStateFlow() /** * Validate a license key locally (offline Ed25519 — there is no activation server) * and, on success, persist it and flip Pro on. Surfaces only outcomes that can * actually occur offline. */ fun activateLicense(rawKey: String) { if (_activating.value) return _activating.value = true _activationError.value = null viewModelScope.launch { val key = rawKey.trim() if (license.validateLicense(key)) { license.storeLicense(key) _isPro.value = true _showCelebration.value = true } else { _activationError.value = "Invalid license key" } _activating.value = false } } fun clearActivationError() { _activationError.value = null } fun dismissCelebration() { license.markProCelebrated() _showCelebration.value = false } fun deactivate() { license.clearLicense() _isPro.value = false } private fun mutate(block: (VaultSettings) -> VaultSettings) { viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) } } fun setTheme(theme: String) = mutate { it.copy(theme = theme) } fun setAccent(name: String) = mutate { it.copy(accentColor = name) } fun setTextSize(size: String) = mutate { it.copy(textSize = size) } fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) } fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) } fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) } /** * Enable biometric unlock: prompt to wrap the live vault key under a hardware * KeyStore key and persist the escrow, then flip the setting on. Requires the * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt * leaves biometrics off. */ fun enableBiometrics(activity: FragmentActivity) { val key = vault.currentVaultKey() ?: return val cipher = try { BiometricKeyStore.encryptCipher() } catch (_: Exception) { VaultCrypto.zero(key); return } Biometrics.authenticateCrypto( activity = activity, title = "Enable biometric unlock", subtitle = "Confirm to secure your vault key", crypto = BiometricPrompt.CryptoObject(cipher), onSuccess = { result -> val used = result.cryptoObject?.cipher if (used != null) { runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) } .onSuccess { mutate { it.copy(biometricsEnabled = true) } } } VaultCrypto.zero(key) }, onFail = { VaultCrypto.zero(key) }, ) } /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */ fun disableBiometrics() { BiometricKeyStore.clear(appContext) mutate { it.copy(biometricsEnabled = false) } } fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) } fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) } suspend fun changePassword(current: String, next: String): Boolean = vault.changePassword(current, next) /** One-line summary shown under each card title; mirrors the browser ledes. */ fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) { CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever" CardId.Protection -> "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock" CardId.Vault -> "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup" CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing" CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}" CardId.Shortcuts -> "bindings · what your fingers know" CardId.Feedback -> "tell us what would make it better" } }