EntryEditViewModel.kt

15.3 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    /**66     * A note authored in the browser is HTML; the form edits its markup form and67     * writes the tags back, so editing a login's notes here cannot flatten them.68     */69    private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty())7071    private fun notesForStorage(edited: String): String? =72        noteStorageText(edited, notesAreHtml).ifBlank { null }7374    // Prefill values arrive on the EntryEdit route when the user follows the75    // autofill picker's "+ Create new entry" path. Only apply to fresh entries76    // — editing an existing entry must not be silently overwritten.77    private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }78    private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }79    private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }8081    private val _form = MutableStateFlow(82        existing?.let {83            EditForm(84                type = it.type ?: "login",85                title = it.title,86                username = it.username.orEmpty(),87                email = it.email.orEmpty(),88                password = it.password.orEmpty(),89                url = it.url.orEmpty(),90                additionalUrls = it.additionalUrls.orEmpty(),91                totp = it.totp.orEmpty(),92                notes = noteEditorText(it.notes.orEmpty()),93                cardNickname = it.cardNickname.orEmpty(),94                cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),95                cardholderName = it.cardholderName.orEmpty(),96                expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),97                cvc = it.cvc.orEmpty(),98                cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),99                tags = it.tags.orEmpty(),100                history = it.passwordHistory.orEmpty(),101                showTotp = !it.totp.isNullOrBlank(),102                showNotes = !it.notes.isNullOrBlank(),103            )104        } ?: EditForm(105            password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),106            url = prefillUrl.orEmpty(),107            username = prefillUsername.orEmpty(),108            email = prefillEmail.orEmpty(),109        ),110    )111    val form: StateFlow<EditForm> = _form.asStateFlow()112113    /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */114    val usernameSuggestions: StateFlow<List<String>> =115        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()116117    val emailSuggestions: StateFlow<List<String>> =118        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()119120    fun setType(t: String) = _form.update { it.copy(type = t) }121122    /**123     * Create a blank NOTE entry and hand its id to [onOpen] so the caller can124     * navigate straight into the full-screen notes editor (brief Fix 6 item 7) —125     * a new note never shows the generic edit form.126     */127    fun createNoteAndOpen(onOpen: (String) -> Unit) {128        viewModelScope.launch {129            runCatching {130                vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))131            }.onSuccess { onOpen(it.id) }132                .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }133        }134    }135    fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }136    fun setUsername(v: String) = _form.update { it.copy(username = v) }137    fun setEmail(v: String) = _form.update { it.copy(email = v) }138    fun setPassword(v: String) = _form.update { it.copy(password = v) }139    fun setUrl(v: String) = _form.update { it.copy(url = v) }140    fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->141        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })142    }143    fun addAdditionalUrl() = _form.update { form ->144        if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form145        else form.copy(additionalUrls = form.additionalUrls + "")146    }147    fun removeAdditionalUrl(index: Int) = _form.update { form ->148        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })149    }150    fun setTotp(v: String) = _form.update { it.copy(totp = v) }151    fun setNotes(v: String) = _form.update { it.copy(notes = v) }152    fun revealTotp() = _form.update { it.copy(showTotp = true) }153    fun revealNotes() = _form.update { it.copy(showNotes = true) }154    fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }155    fun setCardNumber(v: String) = _form.update {156        val formatted = CardFieldUtils.formatPan(v)157        it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)158    }159    fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }160    fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }161    fun setCvc(v: String) = _form.update {162        it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))163    }164    fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }165166    val allTags: List<String> = vault.getDistinctTags()167    fun addTag(raw: String) = _form.update { f ->168        val tag = raw.trim().lowercase().take(32)169        if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f170        else f.copy(tags = f.tags + tag)171    }172    fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }173174    fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }175    fun toggleGenerator() = _form.update { form ->176        val opening = !form.generatorOpen177        val next = form.copy(generatorOpen = opening, showHistory = false)178        if (opening && next.password.isBlank()) next.withGeneratedPassword() else next179    }180    fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }181182    /** Restore a past password into the editor; the save flow records the swap. */183    fun restorePassword(pw: String) =184        _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }185186    /** Copy the current generated/typed password to the clipboard with a drain latch. */187    fun copyGenerated() {188        val pw = _form.value.password189        if (pw.isEmpty()) return190        PeachClipboard.copy(appContext, "peach", pw)191        viewModelScope.launch {192            _form.update { it.copy(genCopied = true) }193            kotlinx.coroutines.delay(1200)194            _form.update { it.copy(genCopied = false) }195        }196    }197198    fun copyHistoryPassword(pw: String) {199        PeachClipboard.copy(appContext, "peach", pw)200    }201    fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }202    fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }203    fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }204    fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }205    fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }206207    fun generate() {208        _form.update { it.withGeneratedPassword() }209    }210211    fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }212213    fun save(onDone: () -> Unit) {214        val f = _form.value215        if (f.saving) return216        if (f.title.isBlank()) {217            _form.update { it.copy(error = "title is required") }218            return219        }220        if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {221            _form.update { it.copy(error = "Cardholder name and card number are required") }222            return223        }224        _form.update { it.copy(saving = true, error = null) }225        viewModelScope.launch {226            runCatching {227                if (isNew) {228                    vault.addEntry(229                        VaultEntry(230                            id = "", type = f.type, title = f.title.trim(),231                            username = f.username.ifBlank { null },232                            email = f.email.ifBlank { null },233                            password = f.password.ifBlank { null },234                            url = f.url.ifBlank { null },235                            additionalUrls = f.additionalUrls236                                .map { it.trim() }.filter { it.isNotBlank() }237                                .ifEmpty { null },238                            totp = f.totp.ifBlank { null },239                            notes = notesForStorage(f.notes),240                            cardNickname = f.cardNickname.ifBlank { null },241                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },242                            cardholderName = f.cardholderName.ifBlank { null },243                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },244                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },245                            cvc = f.cvc.ifBlank { null },246                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },247                            tags = f.tags.ifEmpty { null },248                            createdAt = 0, modifiedAt = 0,249                        ),250                    )251                } else {252                    vault.updateEntry(253                        existing!!.copy(254                            type = f.type, title = f.title.trim(),255                            username = f.username.ifBlank { null },256                            email = f.email.ifBlank { null },257                            password = f.password.ifBlank { null },258                            url = f.url.ifBlank { null },259                            additionalUrls = f.additionalUrls260                                .map { it.trim() }.filter { it.isNotBlank() }261                                .ifEmpty { null },262                            totp = f.totp.ifBlank { null },263                            notes = notesForStorage(f.notes),264                            cardNickname = f.cardNickname.ifBlank { null },265                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },266                            cardholderName = f.cardholderName.ifBlank { null },267                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },268                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },269                            cvc = f.cvc.ifBlank { null },270                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },271                            tags = f.tags.ifEmpty { null },272                        ),273                    )274                }275            }.onSuccess { onDone() }276                .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }277        }278    }279}280281internal const val BROWSER_ADD_GENERATOR_LENGTH = 64282283private fun EditForm.withGeneratedPassword(): EditForm =284    copy(285        password = generateEditPassword(286            length = genLength,287            upper = genUpper,288            lower = genLower,289            numbers = genNumbers,290            symbols = genSymbols,291        ),292        showPassword = true,293    )294295private fun generateEditPassword(296    length: Int,297    upper: Boolean = true,298    lower: Boolean = true,299    numbers: Boolean = true,300    symbols: Boolean = true,301): String =302    PasswordGenerator.generate(303        PasswordGenerator.Options(304            length = length,305            includeUppercase = upper,306            includeLowercase = lower,307            includeNumbers = numbers,308            includeSymbols = symbols,309        ),310    )311312internal enum class IdentityField { Username, Email }313314internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {315    val buckets = linkedMapOf<String, Pair<String, Int>>()316317    fun add(value: String) {318        val trimmed = value.trim()319        if (trimmed.isEmpty()) return320        val key = trimmed.lowercase()321        val current = buckets[key]322        buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)323    }324325    entries.filter { it.deletedAt == null }.forEach { entry ->326        val username = entry.username?.trim().orEmpty()327        val email = entry.email?.trim().orEmpty()328        when (field) {329            IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username)330            IdentityField.Email -> {331                if (username.contains("@")) add(username)332                if (email.isNotEmpty()) add(email)333            }334        }335    }336337    return buckets.values338        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })339        .map { it.first }340        .take(8)341}