EntryEditViewModel.kt

14.2 KB · raw

1package com.peachpasswords.ui.vault23import android.content.Context4import com.peachpasswords.shared.PeachClipboard5import androidx.lifecycle.SavedStateHandle6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.vault.PasswordGenerator9import com.peachpasswords.vault.PasswordHistoryItem10import com.peachpasswords.vault.VaultEntry11import com.peachpasswords.vault.VaultRepository12import dagger.hilt.android.lifecycle.HiltViewModel13import dagger.hilt.android.qualifiers.ApplicationContext14import kotlinx.coroutines.flow.MutableStateFlow15import kotlinx.coroutines.flow.StateFlow16import kotlinx.coroutines.flow.asStateFlow17import kotlinx.coroutines.flow.update18import kotlinx.coroutines.launch19import javax.inject.Inject2021data class EditForm(22    val type: String = "login",23    val title: String = "",24    val username: String = "",25    val email: String = "",26    val password: String = "",27    val url: String = "",28    val additionalUrls: List<String> = emptyList(),29    val totp: String = "",30    val notes: String = "",31    val cardNickname: String = "",32    val cardNumber: String = "",33    val cardholderName: String = "",34    val expiry: String = "",        // "MM/YY" or "MM/YYYY"; split into expMonth/expYear on save35    val cvc: String = "",36    val showCvc: Boolean = false,37    val cardNetwork: String = "unknown",38    val tags: List<String> = emptyList(),39    val showPassword: Boolean = false,40    val generatorOpen: Boolean = false,41    val genCopied: Boolean = false,42    val history: List<PasswordHistoryItem> = emptyList(),43    val showHistory: Boolean = false,44    val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH,45    val genUpper: Boolean = true,46    val genLower: Boolean = true,47    val genNumbers: Boolean = true,48    val genSymbols: Boolean = true,49    val saving: Boolean = false,50    val error: String? = null,51    val showTotp: Boolean = false,52    val showNotes: Boolean = false,53)5455@HiltViewModel56class EntryEditViewModel @Inject constructor(57    private val vault: VaultRepository,58    @ApplicationContext private val appContext: Context,59    savedStateHandle: SavedStateHandle,60) : ViewModel() {61    private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }62    val isNew: Boolean = id == null63    private val existing: VaultEntry? = id?.let { vault.getEntry(it) }6465    private val _form = MutableStateFlow(66        existing?.let {67            EditForm(68                type = it.type ?: "login",69                title = it.title,70                username = it.username.orEmpty(),71                email = it.email.orEmpty(),72                password = it.password.orEmpty(),73                url = it.url.orEmpty(),74                additionalUrls = it.additionalUrls.orEmpty(),75                totp = it.totp.orEmpty(),76                notes = it.notes.orEmpty(),77                cardNickname = it.cardNickname.orEmpty(),78                cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),79                cardholderName = it.cardholderName.orEmpty(),80                expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),81                cvc = it.cvc.orEmpty(),82                cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),83                tags = it.tags.orEmpty(),84                history = it.passwordHistory.orEmpty(),85                showTotp = !it.totp.isNullOrBlank(),86                showNotes = !it.notes.isNullOrBlank(),87            )88        } ?: EditForm(password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH)),89    )90    val form: StateFlow<EditForm> = _form.asStateFlow()9192    /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */93    val usernameSuggestions: StateFlow<List<String>> =94        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()9596    val emailSuggestions: StateFlow<List<String>> =97        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()9899    fun setType(t: String) = _form.update { it.copy(type = t) }100101    /**102     * Create a blank NOTE entry and hand its id to [onOpen] so the caller can103     * navigate straight into the full-screen notes editor (brief Fix 6 item 7) —104     * a new note never shows the generic edit form.105     */106    fun createNoteAndOpen(onOpen: (String) -> Unit) {107        viewModelScope.launch {108            runCatching {109                vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))110            }.onSuccess { onOpen(it.id) }111                .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }112        }113    }114    fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }115    fun setUsername(v: String) = _form.update { it.copy(username = v) }116    fun setEmail(v: String) = _form.update { it.copy(email = v) }117    fun setPassword(v: String) = _form.update { it.copy(password = v) }118    fun setUrl(v: String) = _form.update { it.copy(url = v) }119    fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->120        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })121    }122    fun addAdditionalUrl() = _form.update { form ->123        if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form124        else form.copy(additionalUrls = form.additionalUrls + "")125    }126    fun removeAdditionalUrl(index: Int) = _form.update { form ->127        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })128    }129    fun setTotp(v: String) = _form.update { it.copy(totp = v) }130    fun setNotes(v: String) = _form.update { it.copy(notes = v) }131    fun revealTotp() = _form.update { it.copy(showTotp = true) }132    fun revealNotes() = _form.update { it.copy(showNotes = true) }133    fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }134    fun setCardNumber(v: String) = _form.update {135        val formatted = CardFieldUtils.formatPan(v)136        it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)137    }138    fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }139    fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }140    fun setCvc(v: String) = _form.update {141        it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))142    }143    fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }144145    val allTags: List<String> = vault.getDistinctTags()146    fun addTag(raw: String) = _form.update { f ->147        val tag = raw.trim().lowercase().take(32)148        if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f149        else f.copy(tags = f.tags + tag)150    }151    fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }152153    fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }154    fun toggleGenerator() = _form.update { form ->155        val opening = !form.generatorOpen156        val next = form.copy(generatorOpen = opening, showHistory = false)157        if (opening && next.password.isBlank()) next.withGeneratedPassword() else next158    }159    fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }160161    /** Restore a past password into the editor; the save flow records the swap. */162    fun restorePassword(pw: String) =163        _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }164165    /** Copy the current generated/typed password to the clipboard with a drain latch. */166    fun copyGenerated() {167        val pw = _form.value.password168        if (pw.isEmpty()) return169        PeachClipboard.copy(appContext, "peach", pw)170        viewModelScope.launch {171            _form.update { it.copy(genCopied = true) }172            kotlinx.coroutines.delay(1200)173            _form.update { it.copy(genCopied = false) }174        }175    }176177    fun copyHistoryPassword(pw: String) {178        PeachClipboard.copy(appContext, "peach", pw)179    }180    fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }181    fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }182    fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }183    fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }184    fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }185186    fun generate() {187        _form.update { it.withGeneratedPassword() }188    }189190    fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }191192    fun save(onDone: () -> Unit) {193        val f = _form.value194        if (f.saving) return195        if (f.title.isBlank()) {196            _form.update { it.copy(error = "title is required") }197            return198        }199        if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {200            _form.update { it.copy(error = "Cardholder name and card number are required") }201            return202        }203        _form.update { it.copy(saving = true, error = null) }204        viewModelScope.launch {205            runCatching {206                if (isNew) {207                    vault.addEntry(208                        VaultEntry(209                            id = "", type = f.type, title = f.title.trim(),210                            username = f.username.ifBlank { null },211                            email = f.email.ifBlank { null },212                            password = f.password.ifBlank { null },213                            url = f.url.ifBlank { null },214                            additionalUrls = f.additionalUrls215                                .map { it.trim() }.filter { it.isNotBlank() }216                                .ifEmpty { null },217                            totp = f.totp.ifBlank { null },218                            notes = f.notes.ifBlank { null },219                            cardNickname = f.cardNickname.ifBlank { null },220                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },221                            cardholderName = f.cardholderName.ifBlank { null },222                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },223                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },224                            cvc = f.cvc.ifBlank { null },225                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },226                            tags = f.tags.ifEmpty { null },227                            createdAt = 0, modifiedAt = 0,228                        ),229                    )230                } else {231                    vault.updateEntry(232                        existing!!.copy(233                            type = f.type, title = f.title.trim(),234                            username = f.username.ifBlank { null },235                            email = f.email.ifBlank { null },236                            password = f.password.ifBlank { null },237                            url = f.url.ifBlank { null },238                            additionalUrls = f.additionalUrls239                                .map { it.trim() }.filter { it.isNotBlank() }240                                .ifEmpty { null },241                            totp = f.totp.ifBlank { null },242                            notes = f.notes.ifBlank { null },243                            cardNickname = f.cardNickname.ifBlank { null },244                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },245                            cardholderName = f.cardholderName.ifBlank { null },246                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },247                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },248                            cvc = f.cvc.ifBlank { null },249                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },250                            tags = f.tags.ifEmpty { null },251                        ),252                    )253                }254            }.onSuccess { onDone() }255                .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }256        }257    }258}259260internal const val BROWSER_ADD_GENERATOR_LENGTH = 64261262private fun EditForm.withGeneratedPassword(): EditForm =263    copy(264        password = generateEditPassword(265            length = genLength,266            upper = genUpper,267            lower = genLower,268            numbers = genNumbers,269            symbols = genSymbols,270        ),271        showPassword = true,272    )273274private fun generateEditPassword(275    length: Int,276    upper: Boolean = true,277    lower: Boolean = true,278    numbers: Boolean = true,279    symbols: Boolean = true,280): String =281    PasswordGenerator.generate(282        PasswordGenerator.Options(283            length = length,284            includeUppercase = upper,285            includeLowercase = lower,286            includeNumbers = numbers,287            includeSymbols = symbols,288        ),289    )290291internal enum class IdentityField { Username, Email }292293internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {294    val buckets = linkedMapOf<String, Pair<String, Int>>()295296    fun add(value: String) {297        val trimmed = value.trim()298        if (trimmed.isEmpty()) return299        val key = trimmed.lowercase()300        val current = buckets[key]301        buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)302    }303304    entries.filter { it.deletedAt == null }.forEach { entry ->305        val username = entry.username?.trim().orEmpty()306        val email = entry.email?.trim().orEmpty()307        when (field) {308            IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username)309            IdentityField.Email -> {310                if (username.contains("@")) add(username)311                if (email.isNotEmpty()) add(email)312            }313        }314    }315316    return buckets.values317        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })318        .map { it.first }319        .take(8)320}