package com.peachpasswords.ui.vault import android.content.Context import com.peachpasswords.shared.PeachClipboard import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.vault.PasswordGenerator import com.peachpasswords.vault.PasswordHistoryItem import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject data class EditForm( val type: String = "login", val title: String = "", val username: String = "", val email: String = "", val password: String = "", val url: String = "", val additionalUrls: List = emptyList(), val totp: String = "", val notes: String = "", val cardNickname: String = "", val cardNumber: String = "", val cardholderName: String = "", val expiry: String = "", // "MM/YY" or "MM/YYYY"; split into expMonth/expYear on save val cvc: String = "", val showCvc: Boolean = false, val cardNetwork: String = "unknown", val tags: List = emptyList(), val showPassword: Boolean = false, val generatorOpen: Boolean = false, val genCopied: Boolean = false, val history: List = emptyList(), val showHistory: Boolean = false, val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH, val genUpper: Boolean = true, val genLower: Boolean = true, val genNumbers: Boolean = true, val genSymbols: Boolean = true, val saving: Boolean = false, val error: String? = null, val showTotp: Boolean = false, val showNotes: Boolean = false, ) @HiltViewModel class EntryEditViewModel @Inject constructor( private val vault: VaultRepository, @ApplicationContext private val appContext: Context, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val id: String? = savedStateHandle.get("id")?.takeIf { it.isNotBlank() } val isNew: Boolean = id == null private val existing: VaultEntry? = id?.let { vault.getEntry(it) } /** * A note authored in the browser is HTML; the form edits its markup form and * writes the tags back, so editing a login's notes here cannot flatten them. */ private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty()) private fun notesForStorage(edited: String): String? = noteStorageText(edited, notesAreHtml).ifBlank { null } // Prefill values arrive on the EntryEdit route when the user follows the // autofill picker's "+ Create new entry" path. Only apply to fresh entries // — editing an existing entry must not be silently overwritten. private val prefillUrl: String? = savedStateHandle.get("prefillUrl")?.takeIf { it.isNotBlank() } private val prefillUsername: String? = savedStateHandle.get("prefillUsername")?.takeIf { it.isNotBlank() } private val prefillEmail: String? = savedStateHandle.get("prefillEmail")?.takeIf { it.isNotBlank() } private val _form = MutableStateFlow( existing?.let { EditForm( type = it.type ?: "login", title = it.title, username = it.username.orEmpty(), email = it.email.orEmpty(), password = it.password.orEmpty(), url = it.url.orEmpty(), additionalUrls = it.additionalUrls.orEmpty(), totp = it.totp.orEmpty(), notes = noteEditorText(it.notes.orEmpty()), cardNickname = it.cardNickname.orEmpty(), cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()), cardholderName = it.cardholderName.orEmpty(), expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear), cvc = it.cvc.orEmpty(), cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()), tags = it.tags.orEmpty(), history = it.passwordHistory.orEmpty(), showTotp = !it.totp.isNullOrBlank(), showNotes = !it.notes.isNullOrBlank(), ) } ?: EditForm( password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH), url = prefillUrl.orEmpty(), username = prefillUsername.orEmpty(), email = prefillEmail.orEmpty(), ), ) val form: StateFlow = _form.asStateFlow() /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */ val usernameSuggestions: StateFlow> = MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow() val emailSuggestions: StateFlow> = MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow() fun setType(t: String) = _form.update { it.copy(type = t) } /** * Create a blank NOTE entry and hand its id to [onOpen] so the caller can * navigate straight into the full-screen notes editor (brief Fix 6 item 7) — * a new note never shows the generic edit form. */ fun createNoteAndOpen(onOpen: (String) -> Unit) { viewModelScope.launch { runCatching { vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0)) }.onSuccess { onOpen(it.id) } .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } } } } fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) } fun setUsername(v: String) = _form.update { it.copy(username = v) } fun setEmail(v: String) = _form.update { it.copy(email = v) } fun setPassword(v: String) = _form.update { it.copy(password = v) } fun setUrl(v: String) = _form.update { it.copy(url = v) } fun setAdditionalUrl(index: Int, v: String) = _form.update { form -> form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v }) } fun addAdditionalUrl() = _form.update { form -> if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form else form.copy(additionalUrls = form.additionalUrls + "") } fun removeAdditionalUrl(index: Int) = _form.update { form -> form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) }) } fun setTotp(v: String) = _form.update { it.copy(totp = v) } fun setNotes(v: String) = _form.update { it.copy(notes = v) } fun revealTotp() = _form.update { it.copy(showTotp = true) } fun revealNotes() = _form.update { it.copy(showNotes = true) } fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) } fun setCardNumber(v: String) = _form.update { val formatted = CardFieldUtils.formatPan(v) it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null) } fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) } fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) } fun setCvc(v: String) = _form.update { it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork))) } fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) } val allTags: List = vault.getDistinctTags() fun addTag(raw: String) = _form.update { f -> val tag = raw.trim().lowercase().take(32) if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f else f.copy(tags = f.tags + tag) } fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) } fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) } fun toggleGenerator() = _form.update { form -> val opening = !form.generatorOpen val next = form.copy(generatorOpen = opening, showHistory = false) if (opening && next.password.isBlank()) next.withGeneratedPassword() else next } fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) } /** Restore a past password into the editor; the save flow records the swap. */ fun restorePassword(pw: String) = _form.update { it.copy(password = pw, showPassword = true, showHistory = false) } /** Copy the current generated/typed password to the clipboard with a drain latch. */ fun copyGenerated() { val pw = _form.value.password if (pw.isEmpty()) return PeachClipboard.copy(appContext, "peach", pw) viewModelScope.launch { _form.update { it.copy(genCopied = true) } kotlinx.coroutines.delay(1200) _form.update { it.copy(genCopied = false) } } } fun copyHistoryPassword(pw: String) { PeachClipboard.copy(appContext, "peach", pw) } fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() } fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() } fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() } fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() } fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() } fun generate() { _form.update { it.withGeneratedPassword() } } fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) } fun save(onDone: () -> Unit) { val f = _form.value if (f.saving) return if (f.title.isBlank()) { _form.update { it.copy(error = "title is required") } return } if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) { _form.update { it.copy(error = "Cardholder name and card number are required") } return } _form.update { it.copy(saving = true, error = null) } viewModelScope.launch { runCatching { if (isNew) { vault.addEntry( VaultEntry( id = "", type = f.type, title = f.title.trim(), username = f.username.ifBlank { null }, email = f.email.ifBlank { null }, password = f.password.ifBlank { null }, url = f.url.ifBlank { null }, additionalUrls = f.additionalUrls .map { it.trim() }.filter { it.isNotBlank() } .ifEmpty { null }, totp = f.totp.ifBlank { null }, notes = notesForStorage(f.notes), cardNickname = f.cardNickname.ifBlank { null }, cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null }, cardholderName = f.cardholderName.ifBlank { null }, expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null }, expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null }, cvc = f.cvc.ifBlank { null }, cardNetwork = f.cardNetwork.takeIf { it != "unknown" }, tags = f.tags.ifEmpty { null }, createdAt = 0, modifiedAt = 0, ), ) } else { vault.updateEntry( existing!!.copy( type = f.type, title = f.title.trim(), username = f.username.ifBlank { null }, email = f.email.ifBlank { null }, password = f.password.ifBlank { null }, url = f.url.ifBlank { null }, additionalUrls = f.additionalUrls .map { it.trim() }.filter { it.isNotBlank() } .ifEmpty { null }, totp = f.totp.ifBlank { null }, notes = notesForStorage(f.notes), cardNickname = f.cardNickname.ifBlank { null }, cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null }, cardholderName = f.cardholderName.ifBlank { null }, expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null }, expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null }, cvc = f.cvc.ifBlank { null }, cardNetwork = f.cardNetwork.takeIf { it != "unknown" }, tags = f.tags.ifEmpty { null }, ), ) } }.onSuccess { onDone() } .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } } } } } internal const val BROWSER_ADD_GENERATOR_LENGTH = 64 private fun EditForm.withGeneratedPassword(): EditForm = copy( password = generateEditPassword( length = genLength, upper = genUpper, lower = genLower, numbers = genNumbers, symbols = genSymbols, ), showPassword = true, ) private fun generateEditPassword( length: Int, upper: Boolean = true, lower: Boolean = true, numbers: Boolean = true, symbols: Boolean = true, ): String = PasswordGenerator.generate( PasswordGenerator.Options( length = length, includeUppercase = upper, includeLowercase = lower, includeNumbers = numbers, includeSymbols = symbols, ), ) internal enum class IdentityField { Username, Email } internal fun identitySuggestionsFor(entries: List, field: IdentityField): List { val buckets = linkedMapOf>() fun add(value: String) { val trimmed = value.trim() if (trimmed.isEmpty()) return val key = trimmed.lowercase() val current = buckets[key] buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1) } entries.filter { it.deletedAt == null }.forEach { entry -> val username = entry.username?.trim().orEmpty() val email = entry.email?.trim().orEmpty() when (field) { IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username) IdentityField.Email -> { if (username.contains("@")) add(username) if (email.isNotEmpty()) add(email) } } } return buckets.values .sortedWith(compareByDescending> { it.second }.thenBy { it.first.lowercase() }) .map { it.first } .take(8) }