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 com.peachpasswords.vault.PinUnlockStore18import com.peachpasswords.vault.pinValidationError19import dagger.hilt.android.lifecycle.HiltViewModel20import dagger.hilt.android.qualifiers.ApplicationContext21import kotlinx.coroutines.flow.MutableStateFlow22import kotlinx.coroutines.flow.StateFlow23import kotlinx.coroutines.flow.asStateFlow24import kotlinx.coroutines.Dispatchers25import kotlinx.coroutines.launch26import kotlinx.coroutines.withContext27import okhttp3.OkHttpClient28import okhttp3.Request29import java.security.MessageDigest30import javax.inject.Inject3132internal const val MAX_FEEDBACK_MESSAGE_LENGTH = 40003334internal fun sanitizeFeedbackMessage(value: String): String {35 val trimmed = value.trim().take(MAX_FEEDBACK_MESSAGE_LENGTH)36 val withoutControlChars = buildString(trimmed.length) {37 trimmed.forEach { char ->38 val code = char.code39 val strip = code in 0x0000..0x0008 ||40 code == 0x000B ||41 code == 0x000C ||42 code in 0x000E..0x001F ||43 code == 0x007F44 if (!strip) append(char)45 }46 }47 return withoutControlChars48 .replace("&", "&")49 .replace("<", "<")50 .replace(">", ">")51 .replace("\"", """)52}5354internal fun feedbackSubmitLabel(state: FeedbackState): String = when (state) {55 FeedbackState.Sending -> "Sending..."56 is FeedbackState.Error -> "Failed - try again"57 else -> "Send"58}5960@HiltViewModel61class SettingsViewModel @Inject constructor(62 private val vault: VaultRepository,63 private val license: LicenseRepository,64 private val feedback: FeedbackRepository,65 private val duress: com.peachpasswords.vault.DuressConfigStore,66 private val pinUnlock: PinUnlockStore,67 @ApplicationContext private val appContext: Context,68) : ViewModel() {6970 val duressConfigured: StateFlow<Boolean> = vault.duressConfigured7172 private val _pinEnabled = MutableStateFlow(pinUnlock.isConfigured())73 val pinEnabled: StateFlow<Boolean> = _pinEnabled.asStateFlow()7475 76777879 suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {80 if (!vault.setDuressPassphrase(password)) return false81 val ok = duress.enable(action)82 return ok83 }8485 fun disableDuress() {86 viewModelScope.launch { vault.clearDuress() }87 duress.clear()88 }8990 private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)91 val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()9293 private val _dedupCount = MutableStateFlow<Int?>(null)94 val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()9596 private val _dedupRunning = MutableStateFlow(false)97 val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()9899 private val _dedupDone = MutableStateFlow<Int?>(null)100 val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()101102 private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())103 val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()104105 private val _restoringSnapshotId = MutableStateFlow<String?>(null)106 val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()107108 private val _checkingBreaches = MutableStateFlow(false)109 val checkingBreaches: StateFlow<Boolean> = _checkingBreaches.asStateFlow()110111 private val _breachResult = MutableStateFlow<String?>(null)112 val breachResult: StateFlow<String?> = _breachResult.asStateFlow()113114 private val breachClient by lazy { OkHttpClient() }115116 fun refreshVaultHistory() {117 _vaultHistory.value = vault.getVaultHistory()118 }119120 fun sendFeedback(type: String, message: String) {121 if (_feedbackState.value is FeedbackState.Sending) return122 val sanitized = sanitizeFeedbackMessage(message)123 if (sanitized.isBlank()) return124 _feedbackState.value = FeedbackState.Sending125 viewModelScope.launch {126 _feedbackState.value = try {127 feedback.submit(type, sanitized)128 FeedbackState.Sent129 } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {130 FeedbackState.Error("Feedback unavailable — endpoint not configured")131 } catch (e: Exception) {132 FeedbackState.Error("Couldn't send feedback — check your connection")133 }134 }135 }136137 138 fun resetFeedback() {139 _feedbackState.value = FeedbackState.Idle140 }141142 fun scanDuplicates() {143 if (_dedupRunning.value) return144 _dedupRunning.value = true145 _dedupDone.value = null146 viewModelScope.launch {147 _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)148 _dedupRunning.value = false149 }150 }151152 fun cancelDuplicateRemoval() {153 if (_dedupRunning.value) return154 _dedupCount.value = null155 }156157 fun removeDuplicates() {158 if (_dedupRunning.value) return159 _dedupRunning.value = true160 viewModelScope.launch {161 val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)162 _dedupDone.value = removed163 _dedupCount.value = 0164 refreshVaultHistory()165 _dedupRunning.value = false166 }167 }168169 fun restoreVaultSnapshot(snapshotId: String) {170 if (_restoringSnapshotId.value != null) return171 _restoringSnapshotId.value = snapshotId172 viewModelScope.launch {173 runCatching { vault.restoreVaultSnapshot(snapshotId) }174 _restoringSnapshotId.value = null175 refreshVaultHistory()176 }177 }178179 fun checkAllBreaches() {180 if (_checkingBreaches.value) return181 _checkingBreaches.value = true182 _breachResult.value = null183 viewModelScope.launch {184 val counts = withContext(Dispatchers.IO) {185 vault.getEntries()186 .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() }187 .map { entry ->188 val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0)189 if ((entry.breach ?: 0) != count) {190 runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) }191 }192 count193 }194 }195 val breached = counts.count { it > 0 }196 _breachResult.value =197 if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean"198 _checkingBreaches.value = false199 }200 }201202 private fun breachCountForPassword(password: String): Int {203 val hash = sha1Hex(password)204 val prefix = hash.take(5)205 val suffix = hash.drop(5)206 val request = Request.Builder()207 .url("https://api.pwnedpasswords.com/range/$prefix")208 .header("Add-Padding", "true")209 .header("User-Agent", "Peach-Android")210 .build()211 breachClient.newCall(request).execute().use { response ->212 if (!response.isSuccessful) error("hibp_${response.code}")213 return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix)214 }215 }216217 val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries218 val settings: StateFlow<VaultSettings> = vault.settings219220 221 private val _isPro = MutableStateFlow(license.isProActive())222 val isPro: StateFlow<Boolean> = _isPro.asStateFlow()223224 private val _licenseKey = MutableStateFlow(license.getLicenseKey())225 val licenseKey: StateFlow<String?> = _licenseKey.asStateFlow()226227 private val _licenseIssuedAt = MutableStateFlow(license.getLicenseIssuedAtMillis())228 val licenseIssuedAt: StateFlow<Long?> = _licenseIssuedAt.asStateFlow()229230 private val _activating = MutableStateFlow(false)231 val activating: StateFlow<Boolean> = _activating.asStateFlow()232233 private val _activationError = MutableStateFlow<String?>(null)234 val activationError: StateFlow<String?> = _activationError.asStateFlow()235236 237 private val _showCelebration = MutableStateFlow(false)238 val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()239240 241242243244245 fun activateLicense(rawKey: String) {246 if (_activating.value) return247 _activating.value = true248 _activationError.value = null249 viewModelScope.launch {250 val key = rawKey.trim()251 if (license.storeLicense(key)) {252 _isPro.value = true253 _licenseKey.value = key254 _licenseIssuedAt.value = license.licenseIssuedAtMillis(key)255 _showCelebration.value = true256 } else {257 _activationError.value = "Invalid license key"258 }259 _activating.value = false260 }261 }262263 fun clearActivationError() { _activationError.value = null }264265 fun dismissCelebration() {266 license.markProCelebrated()267 _showCelebration.value = false268 }269270 fun deactivate() {271 license.clearLicense()272 _isPro.value = false273 _licenseKey.value = null274 _licenseIssuedAt.value = null275 }276277 private fun mutate(block: (VaultSettings) -> VaultSettings) {278 viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }279 }280281 fun setAccent(name: String) = mutate { it.copy(accentColor = name) }282 fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }283 fun setTextSize(size: String) = mutate { it.copy(textSize = size) }284 fun setUiScale(scale: String) = mutate { it.copy(uiScale = scale) }285 fun setFaviconsEnabled(on: Boolean) = mutate {286 if (on) com.peachpasswords.vault.grantCurrentFaviconConsent(it)287 else it.copy(faviconsEnabled = false)288 }289 fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }290 fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }291 fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }292 fun setHideAutofillPreviews(on: Boolean) = mutate { it.copy(hideAutofillPreviews = on) }293 fun setAllowScreenshots(on: Boolean) = mutate { it.copy(allowScreenshots = on) }294295 296297298299300301 fun enableBiometrics(activity: FragmentActivity) {302 val key = vault.currentVaultKey() ?: return303 val cipher = try {304 BiometricKeyStore.encryptCipher()305 } catch (_: Exception) {306 VaultCrypto.zero(key); return307 }308 Biometrics.authenticateCrypto(309 activity = activity,310 title = "Enable biometric unlock",311 subtitle = "Confirm to secure your vault key",312 crypto = BiometricPrompt.CryptoObject(cipher),313 onSuccess = { result ->314 val used = result.cryptoObject?.cipher315 if (used != null) {316 runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }317 .onSuccess { mutate { it.copy(biometricsEnabled = true) } }318 }319 VaultCrypto.zero(key)320 },321 onFail = { VaultCrypto.zero(key) },322 )323 }324325 326 fun disableBiometrics() {327 BiometricKeyStore.clear(appContext)328 mutate { it.copy(biometricsEnabled = false) }329 }330 fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }331 fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }332333 suspend fun changePassword(current: String, next: String): Boolean {334 val changed = vault.changePassword(current, next)335 if (changed) {336 337 338 339 _pinEnabled.value = pinUnlock.isConfigured()340 }341 return changed342 }343344 suspend fun savePin(masterPassword: String, pin: String): String? {345 if (!pinUnlock.isSupported()) return "PIN unlock needs hardware that limits PIN attempts"346 if (masterPassword.isBlank()) return "enter your master password"347 pinValidationError(pin)?.let { return it }348 val masterMatches = withContext(Dispatchers.Default) {349 vault.tryUnlockSlotA(masterPassword)?.let {350 vault.discardSlotAUnlock(it)351 true352 } ?: false353 }354 if (!masterMatches) return "incorrect master password"355 val key = vault.currentVaultKey() ?: return "unlock the vault before setting a PIN"356 val saved = try {357 withContext(Dispatchers.Default) { pinUnlock.configure(pin, key) }358 } finally {359 VaultCrypto.zero(key)360 }361 if (!saved) return "couldn't enable PIN unlock"362 _pinEnabled.value = true363 return null364 }365366 suspend fun disablePin(masterPassword: String): String? {367 if (masterPassword.isBlank()) return "enter your master password"368 val masterMatches = withContext(Dispatchers.Default) {369 vault.tryUnlockSlotA(masterPassword)?.let {370 vault.discardSlotAUnlock(it)371 true372 } ?: false373 }374 if (!masterMatches) return "incorrect master password"375 pinUnlock.clear()376 _pinEnabled.value = false377 return null378 }379380 381 fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {382 CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"383 CardId.Protection ->384 "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"385 CardId.Vault ->386 "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"387 CardId.Sync -> if (_isPro.value) "End to End Encryption" else "Pro · encrypted device sync"388 CardId.Appearance -> "${s.accentColor} · ${s.textSize} · ${if (s.reduceMotion) "still" else "in motion"}"389 CardId.Feedback -> "tell us what would make it better"390 }391}392393internal fun sha1Hex(value: String): String =394 MessageDigest.getInstance("SHA-1")395 .digest(value.toByteArray(Charsets.UTF_8))396 .joinToString("") { "%02X".format(it) }397398internal fun parsePwnedRangeCount(body: String, suffix: String): Int {399 val wanted = suffix.uppercase()400 return body.lineSequence()401 .mapNotNull { line ->402 val parts = line.trim().split(':', limit = 2)403 if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null404 }405 .firstOrNull()406 ?: 0407}