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 private val _duressConfigured = MutableStateFlow(duress.isConfigured())71 val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()7273 private val _pinEnabled = MutableStateFlow(pinUnlock.isConfigured())74 val pinEnabled: StateFlow<Boolean> = _pinEnabled.asStateFlow()7576 77787980 suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {81 if (!vault.setDuressPassphrase(password)) return false82 val ok = duress.enable(action)83 if (ok) _duressConfigured.value = true84 return ok85 }8687 fun disableDuress() {88 viewModelScope.launch { vault.clearDuress() }89 duress.clear()90 _duressConfigured.value = false91 }9293 private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)94 val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()9596 private val _dedupCount = MutableStateFlow<Int?>(null)97 val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()9899 private val _dedupRunning = MutableStateFlow(false)100 val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()101102 private val _dedupDone = MutableStateFlow<Int?>(null)103 val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()104105 private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())106 val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()107108 private val _restoringSnapshotId = MutableStateFlow<String?>(null)109 val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()110111 private val _checkingBreaches = MutableStateFlow(false)112 val checkingBreaches: StateFlow<Boolean> = _checkingBreaches.asStateFlow()113114 private val _breachResult = MutableStateFlow<String?>(null)115 val breachResult: StateFlow<String?> = _breachResult.asStateFlow()116117 private val breachClient by lazy { OkHttpClient() }118119 fun refreshVaultHistory() {120 _vaultHistory.value = vault.getVaultHistory()121 }122123 fun sendFeedback(type: String, message: String) {124 if (_feedbackState.value is FeedbackState.Sending) return125 val sanitized = sanitizeFeedbackMessage(message)126 if (sanitized.isBlank()) return127 _feedbackState.value = FeedbackState.Sending128 viewModelScope.launch {129 _feedbackState.value = try {130 feedback.submit(type, sanitized)131 FeedbackState.Sent132 } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {133 FeedbackState.Error("Feedback unavailable — endpoint not configured")134 } catch (e: Exception) {135 FeedbackState.Error("Couldn't send feedback — check your connection")136 }137 }138 }139140 141 fun resetFeedback() {142 _feedbackState.value = FeedbackState.Idle143 }144145 fun scanDuplicates() {146 if (_dedupRunning.value) return147 _dedupRunning.value = true148 _dedupDone.value = null149 viewModelScope.launch {150 _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)151 _dedupRunning.value = false152 }153 }154155 fun cancelDuplicateRemoval() {156 if (_dedupRunning.value) return157 _dedupCount.value = null158 }159160 fun removeDuplicates() {161 if (_dedupRunning.value) return162 _dedupRunning.value = true163 viewModelScope.launch {164 val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)165 _dedupDone.value = removed166 _dedupCount.value = 0167 refreshVaultHistory()168 _dedupRunning.value = false169 }170 }171172 fun restoreVaultSnapshot(snapshotId: String) {173 if (_restoringSnapshotId.value != null) return174 _restoringSnapshotId.value = snapshotId175 viewModelScope.launch {176 runCatching { vault.restoreVaultSnapshot(snapshotId) }177 _restoringSnapshotId.value = null178 refreshVaultHistory()179 }180 }181182 fun checkAllBreaches() {183 if (_checkingBreaches.value) return184 _checkingBreaches.value = true185 _breachResult.value = null186 viewModelScope.launch {187 val counts = withContext(Dispatchers.IO) {188 vault.getEntries()189 .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() }190 .map { entry ->191 val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0)192 if ((entry.breach ?: 0) != count) {193 runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) }194 }195 count196 }197 }198 val breached = counts.count { it > 0 }199 _breachResult.value =200 if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean"201 _checkingBreaches.value = false202 }203 }204205 private fun breachCountForPassword(password: String): Int {206 val hash = sha1Hex(password)207 val prefix = hash.take(5)208 val suffix = hash.drop(5)209 val request = Request.Builder()210 .url("https://api.pwnedpasswords.com/range/$prefix")211 .header("Add-Padding", "true")212 .header("User-Agent", "Peach-Android")213 .build()214 breachClient.newCall(request).execute().use { response ->215 if (!response.isSuccessful) error("hibp_${response.code}")216 return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix)217 }218 }219220 val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries221 val settings: StateFlow<VaultSettings> = vault.settings222223 224 private val _isPro = MutableStateFlow(license.isProActive())225 val isPro: StateFlow<Boolean> = _isPro.asStateFlow()226227 private val _licenseKey = MutableStateFlow(license.getLicenseKey())228 val licenseKey: StateFlow<String?> = _licenseKey.asStateFlow()229230 private val _licenseIssuedAt = MutableStateFlow(license.getLicenseIssuedAtMillis())231 val licenseIssuedAt: StateFlow<Long?> = _licenseIssuedAt.asStateFlow()232233 private val _activating = MutableStateFlow(false)234 val activating: StateFlow<Boolean> = _activating.asStateFlow()235236 private val _activationError = MutableStateFlow<String?>(null)237 val activationError: StateFlow<String?> = _activationError.asStateFlow()238239 240 private val _showCelebration = MutableStateFlow(false)241 val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()242243 244245246247248 fun activateLicense(rawKey: String) {249 if (_activating.value) return250 _activating.value = true251 _activationError.value = null252 viewModelScope.launch {253 val key = rawKey.trim()254 if (license.validateLicense(key)) {255 license.storeLicense(key)256 _isPro.value = true257 _licenseKey.value = key258 _licenseIssuedAt.value = license.licenseIssuedAtMillis(key)259 _showCelebration.value = true260 } else {261 _activationError.value = "Invalid license key"262 }263 _activating.value = false264 }265 }266267 fun clearActivationError() { _activationError.value = null }268269 fun dismissCelebration() {270 license.markProCelebrated()271 _showCelebration.value = false272 }273274 fun deactivate() {275 license.clearLicense()276 _isPro.value = false277 _licenseKey.value = null278 _licenseIssuedAt.value = null279 }280281 private fun mutate(block: (VaultSettings) -> VaultSettings) {282 viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }283 }284285 fun setAccent(name: String) = mutate { it.copy(accentColor = name) }286 fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }287 fun setTextSize(size: String) = mutate { it.copy(textSize = size) }288 fun setUiScale(scale: String) = mutate { it.copy(uiScale = scale) }289 fun setFaviconsEnabled(on: Boolean) = mutate { it.copy(faviconsEnabled = on) }290 fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }291 fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }292 fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }293 fun setHideAutofillPreviews(on: Boolean) = mutate { it.copy(hideAutofillPreviews = on) }294 fun setAllowScreenshots(on: Boolean) = mutate { it.copy(allowScreenshots = on) }295296 297298299300301302 fun enableBiometrics(activity: FragmentActivity) {303 val key = vault.currentVaultKey() ?: return304 val cipher = try {305 BiometricKeyStore.encryptCipher()306 } catch (_: Exception) {307 VaultCrypto.zero(key); return308 }309 Biometrics.authenticateCrypto(310 activity = activity,311 title = "Enable biometric unlock",312 subtitle = "Confirm to secure your vault key",313 crypto = BiometricPrompt.CryptoObject(cipher),314 onSuccess = { result ->315 val used = result.cryptoObject?.cipher316 if (used != null) {317 runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }318 .onSuccess { mutate { it.copy(biometricsEnabled = true) } }319 }320 VaultCrypto.zero(key)321 },322 onFail = { VaultCrypto.zero(key) },323 )324 }325326 327 fun disableBiometrics() {328 BiometricKeyStore.clear(appContext)329 mutate { it.copy(biometricsEnabled = false) }330 }331 fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }332 fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }333334 suspend fun changePassword(current: String, next: String): Boolean {335 val changed = vault.changePassword(current, next)336 if (changed && pinUnlock.isConfigured()) {337 pinUnlock.clear()338 _pinEnabled.value = false339 }340 return changed341 }342343 suspend fun savePin(masterPassword: String, pin: String): String? {344 if (masterPassword.isBlank()) return "enter your master password"345 pinValidationError(pin)?.let { return it }346 val masterMatches = withContext(Dispatchers.Default) {347 vault.tryUnlockSlotA(masterPassword)?.let {348 vault.discardSlotAUnlock(it)349 true350 } ?: false351 }352 if (!masterMatches) return "incorrect master password"353 val key = vault.currentVaultKey() ?: return "unlock the vault before setting a PIN"354 val saved = try {355 withContext(Dispatchers.Default) { pinUnlock.configure(pin, key) }356 } finally {357 VaultCrypto.zero(key)358 }359 if (!saved) return "couldn't enable PIN unlock"360 _pinEnabled.value = true361 return null362 }363364 suspend fun disablePin(masterPassword: String): String? {365 if (masterPassword.isBlank()) return "enter your master password"366 val masterMatches = withContext(Dispatchers.Default) {367 vault.tryUnlockSlotA(masterPassword)?.let {368 vault.discardSlotAUnlock(it)369 true370 } ?: false371 }372 if (!masterMatches) return "incorrect master password"373 pinUnlock.clear()374 _pinEnabled.value = false375 return null376 }377378 379 fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {380 CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"381 CardId.Protection ->382 "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"383 CardId.Vault ->384 "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"385 CardId.Sync -> if (_isPro.value) "your devices, one vault" else "Pro · encrypted device sync"386 CardId.Appearance -> "${s.accentColor} · ${s.textSize} · ${if (s.reduceMotion) "still" else "in motion"}"387 CardId.Feedback -> "tell us what would make it better"388 }389}390391internal fun sha1Hex(value: String): String =392 MessageDigest.getInstance("SHA-1")393 .digest(value.toByteArray(Charsets.UTF_8))394 .joinToString("") { "%02X".format(it) }395396internal fun parsePwnedRangeCount(body: String, suffix: String): Int {397 val wanted = suffix.uppercase()398 return body.lineSequence()399 .mapNotNull { line ->400 val parts = line.trim().split(':', limit = 2)401 if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null402 }403 .firstOrNull()404 ?: 0405}