SettingsViewModel.kt

14.1 KB · raw

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("&", "&amp;")47        .replace("<", "&lt;")48        .replace(">", "&gt;")49        .replace("\"", "&quot;")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    /**71     * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the72     * action policy. The passphrase keys slot B directly and is never stored as a hash.73     */74    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    /** Reset to Idle when the Feedback card leaves the screen. */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    /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */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    /** Set true after a fresh activation so the screen can fire ProCelebration once. */234    private val _showCelebration = MutableStateFlow(false)235    val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()236237    /**238     * Validate a license key locally (offline Ed25519 — there is no activation server)239     * and, on success, persist it and flip Pro on. Surfaces only outcomes that can240     * actually occur offline.241     */242    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 setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }283    fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }284    fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }285    fun setAllowScreenshots(on: Boolean) = mutate { it.copy(allowScreenshots = on) }286287    /**288     * Enable biometric unlock: prompt to wrap the live vault key under a hardware289     * KeyStore key and persist the escrow, then flip the setting on. Requires the290     * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt291     * leaves biometrics off.292     */293    fun enableBiometrics(activity: FragmentActivity) {294        val key = vault.currentVaultKey() ?: return295        val cipher = try {296            BiometricKeyStore.encryptCipher()297        } catch (_: Exception) {298            VaultCrypto.zero(key); return299        }300        Biometrics.authenticateCrypto(301            activity = activity,302            title = "Enable biometric unlock",303            subtitle = "Confirm to secure your vault key",304            crypto = BiometricPrompt.CryptoObject(cipher),305            onSuccess = { result ->306                val used = result.cryptoObject?.cipher307                if (used != null) {308                    runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }309                        .onSuccess { mutate { it.copy(biometricsEnabled = true) } }310                }311                VaultCrypto.zero(key)312            },313            onFail = { VaultCrypto.zero(key) },314        )315    }316317    /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */318    fun disableBiometrics() {319        BiometricKeyStore.clear(appContext)320        mutate { it.copy(biometricsEnabled = false) }321    }322    fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }323    fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }324325    suspend fun changePassword(current: String, next: String): Boolean =326        vault.changePassword(current, next)327328    /** One-line summary shown under each card title; mirrors the browser ledes. */329    fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {330        CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"331        CardId.Protection ->332            "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"333        CardId.Vault ->334            "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"335        CardId.Sync -> if (_isPro.value) "your devices, one vault" else "Pro · encrypted device sync"336        CardId.Appearance -> "${s.accentColor} · ${s.textSize} · ${if (s.reduceMotion) "still" else "in motion"}"337        CardId.Feedback -> "tell us what would make it better"338    }339}340341internal fun sha1Hex(value: String): String =342    MessageDigest.getInstance("SHA-1")343        .digest(value.toByteArray(Charsets.UTF_8))344        .joinToString("") { "%02X".format(it) }345346internal fun parsePwnedRangeCount(body: String, suffix: String): Int {347    val wanted = suffix.uppercase()348    return body.lineSequence()349        .mapNotNull { line ->350            val parts = line.trim().split(':', limit = 2)351            if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null352        }353        .firstOrNull()354        ?: 0355}