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.Dispatchers23import kotlinx.coroutines.launch24import kotlinx.coroutines.withContext25import okhttp3.OkHttpClient26import okhttp3.Request27import java.security.MessageDigest28import javax.inject.Inject2930@HiltViewModel31class SettingsViewModel @Inject constructor(32 private val vault: VaultRepository,33 private val license: LicenseRepository,34 private val feedback: FeedbackRepository,35 private val duress: com.peachpasswords.vault.DuressConfigStore,36 @ApplicationContext private val appContext: Context,37) : ViewModel() {3839 private val _duressConfigured = MutableStateFlow(duress.isConfigured())40 val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()4142 43444546 suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {47 if (!vault.setDuressPassphrase(password)) return false48 val ok = duress.enable(action)49 if (ok) _duressConfigured.value = true50 return ok51 }5253 fun disableDuress() {54 viewModelScope.launch { vault.clearDuress() }55 duress.clear()56 _duressConfigured.value = false57 }5859 private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)60 val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()6162 private val _dedupCount = MutableStateFlow<Int?>(null)63 val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()6465 private val _dedupRunning = MutableStateFlow(false)66 val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()6768 private val _dedupDone = MutableStateFlow<Int?>(null)69 val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()7071 private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())72 val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()7374 private val _restoringSnapshotId = MutableStateFlow<String?>(null)75 val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()7677 private val _checkingBreaches = MutableStateFlow(false)78 val checkingBreaches: StateFlow<Boolean> = _checkingBreaches.asStateFlow()7980 private val _breachResult = MutableStateFlow<String?>(null)81 val breachResult: StateFlow<String?> = _breachResult.asStateFlow()8283 private val breachClient by lazy { OkHttpClient() }8485 fun refreshVaultHistory() {86 _vaultHistory.value = vault.getVaultHistory()87 }8889 fun sendFeedback(type: String, message: String) {90 if (_feedbackState.value is FeedbackState.Sending) return91 _feedbackState.value = FeedbackState.Sending92 viewModelScope.launch {93 _feedbackState.value = try {94 feedback.submit(type, message)95 FeedbackState.Sent96 } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {97 FeedbackState.Error("Feedback unavailable — endpoint not configured")98 } catch (e: Exception) {99 FeedbackState.Error("Couldn't send feedback — check your connection")100 }101 }102 }103104 105 fun resetFeedback() {106 _feedbackState.value = FeedbackState.Idle107 }108109 fun scanDuplicates() {110 if (_dedupRunning.value) return111 _dedupRunning.value = true112 _dedupDone.value = null113 viewModelScope.launch {114 _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)115 _dedupRunning.value = false116 }117 }118119 fun cancelDuplicateRemoval() {120 if (_dedupRunning.value) return121 _dedupCount.value = null122 }123124 fun removeDuplicates() {125 if (_dedupRunning.value) return126 _dedupRunning.value = true127 viewModelScope.launch {128 val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)129 _dedupDone.value = removed130 _dedupCount.value = 0131 refreshVaultHistory()132 _dedupRunning.value = false133 }134 }135136 fun restoreVaultSnapshot(snapshotId: String) {137 if (_restoringSnapshotId.value != null) return138 _restoringSnapshotId.value = snapshotId139 viewModelScope.launch {140 runCatching { vault.restoreVaultSnapshot(snapshotId) }141 _restoringSnapshotId.value = null142 refreshVaultHistory()143 }144 }145146 fun checkAllBreaches() {147 if (_checkingBreaches.value) return148 _checkingBreaches.value = true149 _breachResult.value = null150 viewModelScope.launch {151 val counts = withContext(Dispatchers.IO) {152 vault.getEntries()153 .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() }154 .map { entry ->155 val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0)156 if ((entry.breach ?: 0) != count) {157 runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) }158 }159 count160 }161 }162 val breached = counts.count { it > 0 }163 _breachResult.value =164 if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean"165 _checkingBreaches.value = false166 }167 }168169 private fun breachCountForPassword(password: String): Int {170 val hash = sha1Hex(password)171 val prefix = hash.take(5)172 val suffix = hash.drop(5)173 val request = Request.Builder()174 .url("https://api.pwnedpasswords.com/range/$prefix")175 .header("Add-Padding", "true")176 .header("User-Agent", "Peach-Android")177 .build()178 breachClient.newCall(request).execute().use { response ->179 if (!response.isSuccessful) error("hibp_${response.code}")180 return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix)181 }182 }183184 val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries185 val settings: StateFlow<VaultSettings> = vault.settings186187 188 private val _isPro = MutableStateFlow(license.isProActive())189 val isPro: StateFlow<Boolean> = _isPro.asStateFlow()190191 private val _activating = MutableStateFlow(false)192 val activating: StateFlow<Boolean> = _activating.asStateFlow()193194 private val _activationError = MutableStateFlow<String?>(null)195 val activationError: StateFlow<String?> = _activationError.asStateFlow()196197 198 private val _showCelebration = MutableStateFlow(false)199 val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()200201 202203204205206 fun activateLicense(rawKey: String) {207 if (_activating.value) return208 _activating.value = true209 _activationError.value = null210 viewModelScope.launch {211 val key = rawKey.trim()212 if (license.validateLicense(key)) {213 license.storeLicense(key)214 _isPro.value = true215 _showCelebration.value = true216 } else {217 _activationError.value = "Invalid license key"218 }219 _activating.value = false220 }221 }222223 fun clearActivationError() { _activationError.value = null }224225 fun dismissCelebration() {226 license.markProCelebrated()227 _showCelebration.value = false228 }229230 fun deactivate() {231 license.clearLicense()232 _isPro.value = false233 }234235 private fun mutate(block: (VaultSettings) -> VaultSettings) {236 viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }237 }238239 fun setTheme(theme: String) = mutate { it.copy(theme = theme) }240 fun setAccent(name: String) = mutate { it.copy(accentColor = name) }241 fun setTextSize(size: String) = mutate { it.copy(textSize = size) }242 fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }243 fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }244 fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }245 fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }246247 248249250251252253 fun enableBiometrics(activity: FragmentActivity) {254 val key = vault.currentVaultKey() ?: return255 val cipher = try {256 BiometricKeyStore.encryptCipher()257 } catch (_: Exception) {258 VaultCrypto.zero(key); return259 }260 Biometrics.authenticateCrypto(261 activity = activity,262 title = "Enable biometric unlock",263 subtitle = "Confirm to secure your vault key",264 crypto = BiometricPrompt.CryptoObject(cipher),265 onSuccess = { result ->266 val used = result.cryptoObject?.cipher267 if (used != null) {268 runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }269 .onSuccess { mutate { it.copy(biometricsEnabled = true) } }270 }271 VaultCrypto.zero(key)272 },273 onFail = { VaultCrypto.zero(key) },274 )275 }276277 278 fun disableBiometrics() {279 BiometricKeyStore.clear(appContext)280 mutate { it.copy(biometricsEnabled = false) }281 }282 fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }283 fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }284285 suspend fun changePassword(current: String, next: String): Boolean =286 vault.changePassword(current, next)287288 289 fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {290 CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"291 CardId.Protection ->292 "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"293 CardId.Vault ->294 "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"295 CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"296 CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"297 CardId.Shortcuts -> "bindings · what your fingers know"298 CardId.Feedback -> "tell us what would make it better"299 }300}301302internal fun sha1Hex(value: String): String =303 MessageDigest.getInstance("SHA-1")304 .digest(value.toByteArray(Charsets.UTF_8))305 .joinToString("") { "%02X".format(it) }306307internal fun parsePwnedRangeCount(body: String, suffix: String): Int {308 val wanted = suffix.uppercase()309 return body.lineSequence()310 .mapNotNull { line ->311 val parts = line.trim().split(':', limit = 2)312 if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null313 }314 .firstOrNull()315 ?: 0316}