SettingsViewModel.kt

12.7 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.Inject2930@HiltViewModel31class SettingsViewModel @Inject constructor(32    private val vault: VaultRepository,33    private val license: LicenseRepository,34    private val feedback: FeedbackRepository,35    private val duress: com.peachpasswords.vault.DuressConfigStore,36    @ApplicationContext private val appContext: Context,37) : ViewModel() {3839    private val _duressConfigured = MutableStateFlow(duress.isConfigured())40    val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()4142    /**43     * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the44     * action policy. The passphrase keys slot B directly and is never stored as a hash.45     */46    suspend fun saveDuress(password: String, action: com.peachpasswords.vault.DuressAction): Boolean {47        if (!vault.setDuressPassphrase(password)) return false48        val ok = duress.enable(action)49        if (ok) _duressConfigured.value = true50        return ok51    }5253    fun disableDuress() {54        viewModelScope.launch { vault.clearDuress() }55        duress.clear()56        _duressConfigured.value = false57    }5859    private val _feedbackState = MutableStateFlow<FeedbackState>(FeedbackState.Idle)60    val feedbackState: StateFlow<FeedbackState> = _feedbackState.asStateFlow()6162    private val _dedupCount = MutableStateFlow<Int?>(null)63    val dedupCount: StateFlow<Int?> = _dedupCount.asStateFlow()6465    private val _dedupRunning = MutableStateFlow(false)66    val dedupRunning: StateFlow<Boolean> = _dedupRunning.asStateFlow()6768    private val _dedupDone = MutableStateFlow<Int?>(null)69    val dedupDone: StateFlow<Int?> = _dedupDone.asStateFlow()7071    private val _vaultHistory = MutableStateFlow(vault.getVaultHistory())72    val vaultHistory: StateFlow<List<VaultSnapshot>> = _vaultHistory.asStateFlow()7374    private val _restoringSnapshotId = MutableStateFlow<String?>(null)75    val restoringSnapshotId: StateFlow<String?> = _restoringSnapshotId.asStateFlow()7677    private val _checkingBreaches = MutableStateFlow(false)78    val checkingBreaches: StateFlow<Boolean> = _checkingBreaches.asStateFlow()7980    private val _breachResult = MutableStateFlow<String?>(null)81    val breachResult: StateFlow<String?> = _breachResult.asStateFlow()8283    private val breachClient by lazy { OkHttpClient() }8485    fun refreshVaultHistory() {86        _vaultHistory.value = vault.getVaultHistory()87    }8889    fun sendFeedback(type: String, message: String) {90        if (_feedbackState.value is FeedbackState.Sending) return91        _feedbackState.value = FeedbackState.Sending92        viewModelScope.launch {93            _feedbackState.value = try {94                feedback.submit(type, message)95                FeedbackState.Sent96            } catch (e: com.peachpasswords.feedback.FeedbackUnavailableException) {97                FeedbackState.Error("Feedback unavailable — endpoint not configured")98            } catch (e: Exception) {99                FeedbackState.Error("Couldn't send feedback — check your connection")100            }101        }102    }103104    /** Reset to Idle when the Feedback card leaves the screen. */105    fun resetFeedback() {106        _feedbackState.value = FeedbackState.Idle107    }108109    fun scanDuplicates() {110        if (_dedupRunning.value) return111        _dedupRunning.value = true112        _dedupDone.value = null113        viewModelScope.launch {114            _dedupCount.value = runCatching { vault.countDuplicateEntries() }.getOrDefault(0)115            _dedupRunning.value = false116        }117    }118119    fun cancelDuplicateRemoval() {120        if (_dedupRunning.value) return121        _dedupCount.value = null122    }123124    fun removeDuplicates() {125        if (_dedupRunning.value) return126        _dedupRunning.value = true127        viewModelScope.launch {128            val removed = runCatching { vault.deduplicateEntries() }.getOrDefault(0)129            _dedupDone.value = removed130            _dedupCount.value = 0131            refreshVaultHistory()132            _dedupRunning.value = false133        }134    }135136    fun restoreVaultSnapshot(snapshotId: String) {137        if (_restoringSnapshotId.value != null) return138        _restoringSnapshotId.value = snapshotId139        viewModelScope.launch {140            runCatching { vault.restoreVaultSnapshot(snapshotId) }141            _restoringSnapshotId.value = null142            refreshVaultHistory()143        }144    }145146    fun checkAllBreaches() {147        if (_checkingBreaches.value) return148        _checkingBreaches.value = true149        _breachResult.value = null150        viewModelScope.launch {151            val counts = withContext(Dispatchers.IO) {152                vault.getEntries()153                    .filter { (it.type == null || it.type == "login") && !it.password.isNullOrBlank() }154                    .map { entry ->155                        val count = runCatching { breachCountForPassword(entry.password.orEmpty()) }.getOrDefault(entry.breach ?: 0)156                        if ((entry.breach ?: 0) != count) {157                            runCatching { vault.updateEntry(entry.copy(breach = count.takeIf { it > 0 })) }158                        }159                        count160                    }161            }162            val breached = counts.count { it > 0 }163            _breachResult.value =164                if (breached > 0) "$breached password${if (breached == 1) "" else "s"} found in breaches" else "All passwords clean"165            _checkingBreaches.value = false166        }167    }168169    private fun breachCountForPassword(password: String): Int {170        val hash = sha1Hex(password)171        val prefix = hash.take(5)172        val suffix = hash.drop(5)173        val request = Request.Builder()174            .url("https://api.pwnedpasswords.com/range/$prefix")175            .header("Add-Padding", "true")176            .header("User-Agent", "Peach-Android")177            .build()178        breachClient.newCall(request).execute().use { response ->179            if (!response.isSuccessful) error("hibp_${response.code}")180            return parsePwnedRangeCount(response.body?.string().orEmpty(), suffix)181        }182    }183184    val entries: StateFlow<List<com.peachpasswords.vault.VaultEntry>> = vault.entries185    val settings: StateFlow<VaultSettings> = vault.settings186187    /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */188    private val _isPro = MutableStateFlow(license.isProActive())189    val isPro: StateFlow<Boolean> = _isPro.asStateFlow()190191    private val _activating = MutableStateFlow(false)192    val activating: StateFlow<Boolean> = _activating.asStateFlow()193194    private val _activationError = MutableStateFlow<String?>(null)195    val activationError: StateFlow<String?> = _activationError.asStateFlow()196197    /** Set true after a fresh activation so the screen can fire ProCelebration once. */198    private val _showCelebration = MutableStateFlow(false)199    val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()200201    /**202     * Validate a license key locally (offline Ed25519 — there is no activation server)203     * and, on success, persist it and flip Pro on. Surfaces only outcomes that can204     * actually occur offline.205     */206    fun activateLicense(rawKey: String) {207        if (_activating.value) return208        _activating.value = true209        _activationError.value = null210        viewModelScope.launch {211            val key = rawKey.trim()212            if (license.validateLicense(key)) {213                license.storeLicense(key)214                _isPro.value = true215                _showCelebration.value = true216            } else {217                _activationError.value = "Invalid license key"218            }219            _activating.value = false220        }221    }222223    fun clearActivationError() { _activationError.value = null }224225    fun dismissCelebration() {226        license.markProCelebrated()227        _showCelebration.value = false228    }229230    fun deactivate() {231        license.clearLicense()232        _isPro.value = false233    }234235    private fun mutate(block: (VaultSettings) -> VaultSettings) {236        viewModelScope.launch { vault.updateSettings(block(vault.settings.value)) }237    }238239    fun setTheme(theme: String) = mutate { it.copy(theme = theme) }240    fun setAccent(name: String) = mutate { it.copy(accentColor = name) }241    fun setTextSize(size: String) = mutate { it.copy(textSize = size) }242    fun setReduceMotion(on: Boolean) = mutate { it.copy(reduceMotion = on) }243    fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }244    fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }245    fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }246247    /**248     * Enable biometric unlock: prompt to wrap the live vault key under a hardware249     * KeyStore key and persist the escrow, then flip the setting on. Requires the250     * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt251     * leaves biometrics off.252     */253    fun enableBiometrics(activity: FragmentActivity) {254        val key = vault.currentVaultKey() ?: return255        val cipher = try {256            BiometricKeyStore.encryptCipher()257        } catch (_: Exception) {258            VaultCrypto.zero(key); return259        }260        Biometrics.authenticateCrypto(261            activity = activity,262            title = "Enable biometric unlock",263            subtitle = "Confirm to secure your vault key",264            crypto = BiometricPrompt.CryptoObject(cipher),265            onSuccess = { result ->266                val used = result.cryptoObject?.cipher267                if (used != null) {268                    runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }269                        .onSuccess { mutate { it.copy(biometricsEnabled = true) } }270                }271                VaultCrypto.zero(key)272            },273            onFail = { VaultCrypto.zero(key) },274        )275    }276277    /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */278    fun disableBiometrics() {279        BiometricKeyStore.clear(appContext)280        mutate { it.copy(biometricsEnabled = false) }281    }282    fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }283    fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }284285    suspend fun changePassword(current: String, next: String): Boolean =286        vault.changePassword(current, next)287288    /** One-line summary shown under each card title; mirrors the browser ledes. */289    fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {290        CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"291        CardId.Protection ->292            "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"293        CardId.Vault ->294            "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"295        CardId.Sync -> if (_isPro.value) "pair by chirp, keep in step" else "Pro · encrypted device pairing"296        CardId.Appearance -> "${s.theme} · ${s.accentColor} · ${s.textSize}"297        CardId.Shortcuts -> "bindings · what your fingers know"298        CardId.Feedback -> "tell us what would make it better"299    }300}301302internal fun sha1Hex(value: String): String =303    MessageDigest.getInstance("SHA-1")304        .digest(value.toByteArray(Charsets.UTF_8))305        .joinToString("") { "%02X".format(it) }306307internal fun parsePwnedRangeCount(body: String, suffix: String): Int {308    val wanted = suffix.uppercase()309    return body.lineSequence()310        .mapNotNull { line ->311            val parts = line.trim().split(':', limit = 2)312            if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null313        }314        .firstOrNull()315        ?: 0316}