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.Inject2930internal const val MAX_FEEDBACK_MESSAGE_LENGTH = 40003132internal fun sanitizeFeedbackMessage(value: String): String {33 val trimmed = value.trim().take(MAX_FEEDBACK_MESSAGE_LENGTH)34 val withoutControlChars = buildString(trimmed.length) {35 trimmed.forEach { char ->36 val code = char.code37 val strip = code in 0x0000..0x0008 ||38 code == 0x000B ||39 code == 0x000C ||40 code in 0x000E..0x001F ||41 code == 0x007F42 if (!strip) append(char)43 }44 }45 return withoutControlChars46 .replace("&", "&")47 .replace("<", "<")48 .replace(">", ">")49 .replace("\"", """)50}5152internal fun feedbackSubmitLabel(state: FeedbackState): String = when (state) {53 FeedbackState.Sending -> "Sending..."54 is FeedbackState.Error -> "Failed - try again"55 else -> "Send"56}5758@HiltViewModel59class SettingsViewModel @Inject constructor(60 private val vault: VaultRepository,61 private val license: LicenseRepository,62 private val feedback: FeedbackRepository,63 private val duress: com.peachpasswords.vault.DuressConfigStore,64 @ApplicationContext private val appContext: Context,65) : ViewModel() {6667 private val _duressConfigured = MutableStateFlow(duress.isConfigured())68 val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()6970 71727374 suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {75 if (!vault.setDuressPassphrase(password)) return false76 val ok = duress.enable(action)77 if (ok) _duressConfigured.value = true78 return ok79 }8081 fun disableDuress() {82 viewModelScope.launch { vault.clearDuress() }83 duress.clear()84 _duressConfigured.value = false85 }8687 private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)88 val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()8990 private val _dedupCount = MutableStateFlow<Int?>(null)91 val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()9293 private val _dedupRunning = MutableStateFlow(false)94 val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()9596 private val _dedupDone = MutableStateFlow<Int?>(null)97 val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()9899 private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())100 val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()101102 private val _restoringSnapshotId = MutableStateFlow<String?>(null)103 val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()104105 private val _checkingBreaches = MutableStateFlow(false)106 val checkingBreaches: StateFlow<Boolean> = _checkingBreaches.asStateFlow()107108 private val _breachResult = MutableStateFlow<String?>(null)109 val breachResult: StateFlow<String?> = _breachResult.asStateFlow()110111 private val breachClient by lazy { OkHttpClient() }112113 fun refreshVaultHistory() {114 _vaultHistory.value = vault.getVaultHistory()115 }116117 fun sendFeedback(type: String, message: String) {118 if (_feedbackState.value is FeedbackState.Sending) return119 val sanitized = sanitizeFeedbackMessage(message)120 if (sanitized.isBlank()) return121 _feedbackState.value = FeedbackState.Sending122 viewModelScope.launch {123 _feedbackState.value = try {124 feedback.submit(type, sanitized)125 FeedbackState.Sent126 } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {127 FeedbackState.Error("Feedback unavailable — endpoint not configured")128 } catch (e: Exception) {129 FeedbackState.Error("Couldn't send feedback — check your connection")130 }131 }132 }133134 135 fun resetFeedback() {136 _feedbackState.value = FeedbackState.Idle137 }138139 fun scanDuplicates() {140 if (_dedupRunning.value) return141 _dedupRunning.value = true142 _dedupDone.value = null143 viewModelScope.launch {144 _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)145 _dedupRunning.value = false146 }147 }148149 fun cancelDuplicateRemoval() {150 if (_dedupRunning.value) return151 _dedupCount.value = null152 }153154 fun removeDuplicates() {155 if (_dedupRunning.value) return156 _dedupRunning.value = true157 viewModelScope.launch {158 val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)159 _dedupDone.value = removed160 _dedupCount.value = 0161 refreshVaultHistory()162 _dedupRunning.value = false163 }164 }165166 fun restoreVaultSnapshot(snapshotId: String) {167 if (_restoringSnapshotId.value != null) return168 _restoringSnapshotId.value = snapshotId169 viewModelScope.launch {170 runCatching { vault.restoreVaultSnapshot(snapshotId) }171 _restoringSnapshotId.value = null172 refreshVaultHistory()173 }174 }175176 fun checkAllBreaches() {177 if (_checkingBreaches.value) return178 _checkingBreaches.value = true179 _breachResult.value = null180 viewModelScope.launch {181 val counts = withContext(Dispatchers.IO) {182 vault.getEntries()183 .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() }184 .map { entry ->185 val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0)186 if ((entry.breach ?: 0) != count) {187 runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) }188 }189 count190 }191 }192 val breached = counts.count { it > 0 }193 _breachResult.value =194 if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean"195 _checkingBreaches.value = false196 }197 }198199 private fun breachCountForPassword(password: String): Int {200 val hash = sha1Hex(password)201 val prefix = hash.take(5)202 val suffix = hash.drop(5)203 val request = Request.Builder()204 .url("https://api.pwnedpasswords.com/range/$prefix")205 .header("Add-Padding", "true")206 .header("User-Agent", "Peach-Android")207 .build()208 breachClient.newCall(request).execute().use { response ->209 if (!response.isSuccessful) error("hibp_${response.code}")210 return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix)211 }212 }213214 val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries215 val settings: StateFlow<VaultSettings> = vault.settings216217 218 private val _isPro = MutableStateFlow(license.isProActive())219 val isPro: StateFlow<Boolean> = _isPro.asStateFlow()220221 private val _licenseKey = MutableStateFlow(license.getLicenseKey())222 val licenseKey: StateFlow<String?> = _licenseKey.asStateFlow()223224 private val _licenseIssuedAt = MutableStateFlow(license.getLicenseIssuedAtMillis())225 val licenseIssuedAt: StateFlow<Long?> = _licenseIssuedAt.asStateFlow()226227 private val _activating = MutableStateFlow(false)228 val activating: StateFlow<Boolean> = _activating.asStateFlow()229230 private val _activationError = MutableStateFlow<String?>(null)231 val activationError: StateFlow<String?> = _activationError.asStateFlow()232233 234 private val _showCelebration = MutableStateFlow(false)235 val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()236237 238239240241242 fun activateLicense(rawKey: String) {243 if (_activating.value) return244 _activating.value = true245 _activationError.value = null246 viewModelScope.launch {247 val key = rawKey.trim()248 if (license.validateLicense(key)) {249 license.storeLicense(key)250 _isPro.value = true251 _licenseKey.value = key252 _licenseIssuedAt.value = license.licenseIssuedAtMillis(key)253 _showCelebration.value = true254 } else {255 _activationError.value = "Invalid license key"256 }257 _activating.value = false258 }259 }260261 fun clearActivationError() { _activationError.value = null }262263 fun dismissCelebration() {264 license.markProCelebrated()265 _showCelebration.value = false266 }267268 fun deactivate() {269 license.clearLicense()270 _isPro.value = false271 _licenseKey.value = null272 _licenseIssuedAt.value = null273 }274275 private fun mutate(block: (VaultSettings) -> VaultSettings) {276 viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }277 }278279 fun setAccent(name: String) = mutate { it.copy(accentColor = name) }280 fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }281 fun setTextSize(size: String) = mutate { it.copy(textSize = size) }282 fun setFaviconsEnabled(on: Boolean) = mutate { it.copy(faviconsEnabled = on) }283 fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }284 fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }285 fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }286 fun setHideAutofillPreviews(on: Boolean) = mutate { it.copy(hideAutofillPreviews = on) }287 fun setAllowScreenshots(on: Boolean) = mutate { it.copy(allowScreenshots = on) }288289 290291292293294295 fun enableBiometrics(activity: FragmentActivity) {296 val key = vault.currentVaultKey() ?: return297 val cipher = try {298 BiometricKeyStore.encryptCipher()299 } catch (_: Exception) {300 VaultCrypto.zero(key); return301 }302 Biometrics.authenticateCrypto(303 activity = activity,304 title = "Enable biometric unlock",305 subtitle = "Confirm to secure your vault key",306 crypto = BiometricPrompt.CryptoObject(cipher),307 onSuccess = { result ->308 val used = result.cryptoObject?.cipher309 if (used != null) {310 runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }311 .onSuccess { mutate { it.copy(biometricsEnabled = true) } }312 }313 VaultCrypto.zero(key)314 },315 onFail = { VaultCrypto.zero(key) },316 )317 }318319 320 fun disableBiometrics() {321 BiometricKeyStore.clear(appContext)322 mutate { it.copy(biometricsEnabled = false) }323 }324 fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }325 fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }326327 suspend fun changePassword(current: String, next: String): Boolean =328 vault.changePassword(current, next)329330 331 fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {332 CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"333 CardId.Protection ->334 "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"335 CardId.Vault ->336 "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"337 CardId.Sync -> if (_isPro.value) "your devices, one vault" else "Pro · encrypted device sync"338 CardId.Appearance -> "${s.accentColor} · ${s.textSize} · ${if (s.reduceMotion) "still" else "in motion"}"339 CardId.Feedback -> "tell us what would make it better"340 }341}342343internal fun sha1Hex(value: String): String =344 MessageDigest.getInstance("SHA-1")345 .digest(value.toByteArray(Charsets.UTF_8))346 .joinToString("") { "%02X".format(it) }347348internal fun parsePwnedRangeCount(body: String, suffix: String): Int {349 val wanted = suffix.uppercase()350 return body.lineSequence()351 .mapNotNull { line ->352 val parts = line.trim().split(':', limit = 2)353 if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null354 }355 .firstOrNull()356 ?: 0357}