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.VaultSnapshot import com.peachpasswords.vault.VaultSettings import com.peachpasswords.vault.PinUnlockStore import com.peachpasswords.vault.pinValidationError 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.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.security.MessageDigest import javax.inject.Inject internal const val MAX_FEEDBACK_MESSAGE_LENGTH = 4000 internal fun sanitizeFeedbackMessage(value: String): String { val trimmed = value.trim().take(MAX_FEEDBACK_MESSAGE_LENGTH) val withoutControlChars = buildString(trimmed.length) { trimmed.forEach { char -> val code = char.code val strip = code in 0x0000..0x0008 || code == 0x000B || code == 0x000C || code in 0x000E..0x001F || code == 0x007F if (!strip) append(char) } } return withoutControlChars .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) } internal fun feedbackSubmitLabel(state: FeedbackState): String = when (state) { FeedbackState.Sending -> "Sending..." is FeedbackState.Error -> "Failed - try again" else -> "Send" } @HiltViewModel class SettingsViewModel @Inject constructor( private val vault: VaultRepository, private val license: LicenseRepository, private val feedback: FeedbackRepository, private val duress: com.peachpasswords.vault.DuressConfigStore, private val pinUnlock: PinUnlockStore, @ApplicationContext private val appContext: Context, ) : ViewModel() { val duressConfigured: StateFlow = vault.duressConfigured private val _pinEnabled = MutableStateFlow(pinUnlock.isConfigured()) val pinEnabled: StateFlow = _pinEnabled.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) return ok } fun disableDuress() { viewModelScope.launch { vault.clearDuress() } duress.clear() } 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() private val _vaultHistory = MutableStateFlow(vault.getVaultHistory()) val vaultHistory: StateFlow> = _vaultHistory.asStateFlow() private val _restoringSnapshotId = MutableStateFlow(null) val restoringSnapshotId: StateFlow = _restoringSnapshotId.asStateFlow() private val _checkingBreaches = MutableStateFlow(false) val checkingBreaches: StateFlow = _checkingBreaches.asStateFlow() private val _breachResult = MutableStateFlow(null) val breachResult: StateFlow = _breachResult.asStateFlow() private val breachClient by lazy { OkHttpClient() } fun refreshVaultHistory() { _vaultHistory.value = vault.getVaultHistory() } fun sendFeedback(type: String, message: String) { if (_feedbackState.value is FeedbackState.Sending) return val sanitized = sanitizeFeedbackMessage(message) if (sanitized.isBlank()) return _feedbackState.value = FeedbackState.Sending viewModelScope.launch { _feedbackState.value = try { feedback.submit(type, sanitized) 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 refreshVaultHistory() _dedupRunning.value = false } } fun restoreVaultSnapshot(snapshotId: String) { if (_restoringSnapshotId.value != null) return _restoringSnapshotId.value = snapshotId viewModelScope.launch { runCatching { vault.restoreVaultSnapshot(snapshotId) } _restoringSnapshotId.value = null refreshVaultHistory() } } fun checkAllBreaches() { if (_checkingBreaches.value) return _checkingBreaches.value = true _breachResult.value = null viewModelScope.launch { val counts = withContext(Dispatchers.IO) { vault.getEntries() .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() } .map { entry -> val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0) if ((entry.breach ?: 0) != count) { runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) } } count } } val breached = counts.count { it > 0 } _breachResult.value = if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean" _checkingBreaches.value = false } } private fun breachCountForPassword(password: String): Int { val hash = sha1Hex(password) val prefix = hash.take(5) val suffix = hash.drop(5) val request = Request.Builder() .url("https://api.pwnedpasswords.com/range/$prefix") .header("Add-Padding", "true") .header("User-Agent", "Peach-Android") .build() breachClient.newCall(request).execute().use { response -> if (!response.isSuccessful) error("hibp_${response.code}") return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix) } } 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 _licenseKey = MutableStateFlow(license.getLicenseKey()) val licenseKey: StateFlow = _licenseKey.asStateFlow() private val _licenseIssuedAt = MutableStateFlow(license.getLicenseIssuedAtMillis()) val licenseIssuedAt: StateFlow = _licenseIssuedAt.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.storeLicense(key)) { _isPro.value = true _licenseKey.value = key _licenseIssuedAt.value = license.licenseIssuedAtMillis(key) _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 _licenseKey.value = null _licenseIssuedAt.value = null } private fun mutate(block: (VaultSettings) -> VaultSettings) { viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) } } fun setAccent(name: String) = mutate { it.copy(accentColor = name) } fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) } fun setTextSize(size: String) = mutate { it.copy(textSize = size) } fun setUiScale(scale: String) = mutate { it.copy(uiScale = scale) } fun setFaviconsEnabled(on: Boolean) = mutate { if (on) com.peachpasswords.vault.grantCurrentFaviconConsent(it) else it.copy(faviconsEnabled = false) } fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) } fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) } fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) } fun setHideAutofillPreviews(on: Boolean) = mutate { it.copy(hideAutofillPreviews = on) } fun setAllowScreenshots(on: Boolean) = mutate { it.copy(allowScreenshots = 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 { val changed = vault.changePassword(current, next) if (changed) { // The rotation hook already cleared the PIN and biometric escrows; // read the store back so the toggle cannot claim a credential that // no longer exists. _pinEnabled.value = pinUnlock.isConfigured() } return changed } suspend fun savePin(masterPassword: String, pin: String): String? { if (!pinUnlock.isSupported()) return "PIN unlock needs hardware that limits PIN attempts" if (masterPassword.isBlank()) return "enter your master password" pinValidationError(pin)?.let { return it } val masterMatches = withContext(Dispatchers.Default) { vault.tryUnlockSlotA(masterPassword)?.let { vault.discardSlotAUnlock(it) true } ?: false } if (!masterMatches) return "incorrect master password" val key = vault.currentVaultKey() ?: return "unlock the vault before setting a PIN" val saved = try { withContext(Dispatchers.Default) { pinUnlock.configure(pin, key) } } finally { VaultCrypto.zero(key) } if (!saved) return "couldn't enable PIN unlock" _pinEnabled.value = true return null } suspend fun disablePin(masterPassword: String): String? { if (masterPassword.isBlank()) return "enter your master password" val masterMatches = withContext(Dispatchers.Default) { vault.tryUnlockSlotA(masterPassword)?.let { vault.discardSlotAUnlock(it) true } ?: false } if (!masterMatches) return "incorrect master password" pinUnlock.clear() _pinEnabled.value = false return null } /** 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) "End to End Encryption" else "Pro · encrypted device sync" CardId.Appearance -> "${s.accentColor} · ${s.textSize} · ${if (s.reduceMotion) "still" else "in motion"}" CardId.Feedback -> "tell us what would make it better" } } internal fun sha1Hex(value: String): String = MessageDigest.getInstance("SHA-1") .digest(value.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02X".format(it) } internal fun parsePwnedRangeCount(body: String, suffix: String): Int { val wanted = suffix.uppercase() return body.lineSequence() .mapNotNull { line -> val parts = line.trim().split(':', limit = 2) if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null } .firstOrNull() ?: 0 }