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 38394041 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 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 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 147 private val _showCelebration = MutableStateFlow(false)148 val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()149150 151152153154155 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) }194 fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }195196 197198199200201202 fun enableBiometrics(activity: FragmentActivity) {203 val key = vault.currentVaultKey() ?: return204 val cipher = try {205 BiometricKeyStore.encryptCipher()206 } catch (_: Exception) {207 VaultCrypto.zero(key); return208 }209 Biometrics.authenticateCrypto(210 activity = activity,211 title = "Enable biometric unlock",212 subtitle = "Confirm to secure your vault key",213 crypto = BiometricPrompt.CryptoObject(cipher),214 onSuccess = { result ->215 val used = result.cryptoObject?.cipher216 if (used != null) {217 runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }218 .onSuccess { mutate { it.copy(biometricsEnabled = true) } }219 }220 VaultCrypto.zero(key)221 },222 onFail = { VaultCrypto.zero(key) },223 )224 }225226 227 fun disableBiometrics() {228 BiometricKeyStore.clear(appContext)229 mutate { it.copy(biometricsEnabled = false) }230 }231 fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }232 fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }233234 suspend fun changePassword(current: String, next: String): Boolean =235 vault.changePassword(current, next)236237 238 fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {239 CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"240 CardId.Protection ->241 "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"242 CardId.Vault ->243 "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"244 CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"245 CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"246 CardId.Shortcuts -> "bindings · what your fingers know"247 CardId.Feedback -> "tell us what would make it better"248 }249}