SettingsViewModel.kt

16.3 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 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("&", "&amp;")49        .replace("<", "&lt;")50        .replace(">", "&gt;")51        .replace("\"", "&quot;")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    /**77     * MF-6: seal a decoy vault (slot B) under the duress passphrase, then record the78     * action policy. The passphrase keys slot B directly and is never stored as a hash.79     */80    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    /** Reset to Idle when the Feedback card leaves the screen. */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    /** Live Pro entitlement, seeded from the stored (locally Ed25519-validated) license. */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    /** Set true after a fresh activation so the screen can fire ProCelebration once. */240    private val _showCelebration = MutableStateFlow(false)241    val showCelebration: StateFlow<Boolean> = _showCelebration.asStateFlow()242243    /**244     * Validate a license key locally (offline Ed25519 — there is no activation server)245     * and, on success, persist it and flip Pro on. Surfaces only outcomes that can246     * actually occur offline.247     */248    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 {290        if (on) com.peachpasswords.vault.grantCurrentFaviconConsent(it)291        else it.copy(faviconsEnabled = false)292    }293    fun setBreachMonitoring(on: Boolean) = mutate { it.copy(breachMonitoring = on) }294    fun setBiometrics(on: Boolean) = mutate { it.copy(biometricsEnabled = on) }295    fun setPasskeyProviderEnabled(on: Boolean) = mutate { it.copy(passkeyProviderEnabled = on) }296    fun setHideAutofillPreviews(on: Boolean) = mutate { it.copy(hideAutofillPreviews = on) }297    fun setAllowScreenshots(on: Boolean) = mutate { it.copy(allowScreenshots = on) }298299    /**300     * Enable biometric unlock: prompt to wrap the live vault key under a hardware301     * KeyStore key and persist the escrow, then flip the setting on. Requires the302     * vault to be unlocked (it is, inside Settings). A failed/cancelled prompt303     * leaves biometrics off.304     */305    fun enableBiometrics(activity: FragmentActivity) {306        val key = vault.currentVaultKey() ?: return307        val cipher = try {308            BiometricKeyStore.encryptCipher()309        } catch (_: Exception) {310            VaultCrypto.zero(key); return311        }312        Biometrics.authenticateCrypto(313            activity = activity,314            title = "Enable biometric unlock",315            subtitle = "Confirm to secure your vault key",316            crypto = BiometricPrompt.CryptoObject(cipher),317            onSuccess = { result ->318                val used = result.cryptoObject?.cipher319                if (used != null) {320                    runCatching { BiometricKeyStore.wrapAndStore(appContext, used, key) }321                        .onSuccess { mutate { it.copy(biometricsEnabled = true) } }322                }323                VaultCrypto.zero(key)324            },325            onFail = { VaultCrypto.zero(key) },326        )327    }328329    /** Disable biometric unlock: drop the escrowed key + KeyStore entry, flip the setting off. */330    fun disableBiometrics() {331        BiometricKeyStore.clear(appContext)332        mutate { it.copy(biometricsEnabled = false) }333    }334    fun setAutoLockMinutes(min: Int?) = mutate { it.copy(autoLockMinutes = min) }335    fun setTrashRetentionDays(days: Int?) = mutate { it.copy(trashRetentionDays = days) }336337    suspend fun changePassword(current: String, next: String): Boolean {338        val changed = vault.changePassword(current, next)339        if (changed && pinUnlock.isConfigured()) {340            pinUnlock.clear()341            _pinEnabled.value = false342        }343        return changed344    }345346    suspend fun savePin(masterPassword: String, pin: String): String? {347        if (!pinUnlock.isSupported()) return "PIN unlock needs hardware that limits PIN attempts"348        if (masterPassword.isBlank()) return "enter your master password"349        pinValidationError(pin)?.let { return it }350        val masterMatches = withContext(Dispatchers.Default) {351            vault.tryUnlockSlotA(masterPassword)?.let {352                vault.discardSlotAUnlock(it)353                true354            } ?: false355        }356        if (!masterMatches) return "incorrect master password"357        val key = vault.currentVaultKey() ?: return "unlock the vault before setting a PIN"358        val saved = try {359            withContext(Dispatchers.Default) { pinUnlock.configure(pin, key) }360        } finally {361            VaultCrypto.zero(key)362        }363        if (!saved) return "couldn't enable PIN unlock"364        _pinEnabled.value = true365        return null366    }367368    suspend fun disablePin(masterPassword: String): String? {369        if (masterPassword.isBlank()) return "enter your master password"370        val masterMatches = withContext(Dispatchers.Default) {371            vault.tryUnlockSlotA(masterPassword)?.let {372                vault.discardSlotAUnlock(it)373                true374            } ?: false375        }376        if (!masterMatches) return "incorrect master password"377        pinUnlock.clear()378        _pinEnabled.value = false379        return null380    }381382    /** One-line summary shown under each card title; mirrors the browser ledes. */383    fun lede(id: CardId, entriesCount: Int, s: VaultSettings): String = when (id) {384        CardId.Pro -> if (_isPro.value) "active · paid once, yours forever" else "$39 once · yours forever"385        CardId.Protection ->386            "${if (s.breachMonitoring) "breaches watched" else "breach watch off"} · manual lock"387        CardId.Vault ->388            "$entriesCount ${if (entriesCount == 1) "entry" else "entries"} · backups, history, cleanup"389        CardId.Sync -> if (_isPro.value) "your devices, one vault" else "Pro · encrypted device sync"390        CardId.Appearance -> "${s.accentColor} · ${s.textSize} · ${if (s.reduceMotion) "still" else "in motion"}"391        CardId.Feedback -> "tell us what would make it better"392    }393}394395internal fun sha1Hex(value: String): String =396    MessageDigest.getInstance("SHA-1")397        .digest(value.toByteArray(Charsets.UTF_8))398        .joinToString("") { "%02X".format(it) }399400internal fun parsePwnedRangeCount(body: String, suffix: String): Int {401    val wanted = suffix.uppercase()402    return body.lineSequence()403        .mapNotNull { line ->404            val parts = line.trim().split(':', limit = 2)405            if (parts.size == 2 && parts[0].uppercase() == wanted) parts[1].toIntOrNull() else null406        }407        .firstOrNull()408        ?: 0409}