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.license.LicenseRepository import com.peachpasswords.vault.PasswordGenerator import com.peachpasswords.vault.PasswordHistoryItem import com.peachpasswords.vault.Canary 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.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody 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 canary: Canary? = null, val canaryMessage: String? = null, val canaryTesting: Boolean = false, 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, private val license: LicenseRepository, 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) } val isPro: Boolean = license.isProActive() private val canaryClient by lazy { OkHttpClient() } /** * 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(), canary = it.canary, 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 setCanary(enabled: Boolean, webhookUrl: String, label: String) { if (enabled && existing?.canary == null && !license.isProActive()) { _form.update { it.copy(canaryMessage = "Canary entries require Peach Pro") } return } _form.update { form -> form.copy( canary = if (enabled) { Canary( webhookUrl = webhookUrl.trim(), label = label.trim().ifBlank { null }, lastPinged = form.canary?.lastPinged, pingCount = form.canary?.pingCount, ) } else { null }, canaryMessage = null, ) } } fun testCanary(webhookUrl: String) { val url = webhookUrl.trim() if (!isAllowedCanaryWebhookUrl(url)) { _form.update { it.copy(canaryMessage = "enter a public HTTPS webhook URL") } return } viewModelScope.launch { _form.update { it.copy(canaryTesting = true, canaryMessage = null) } val result = runCatching { withContext(Dispatchers.IO) { val body = """ {"event":"peach_canary_test","entry":${canaryJsonString(_form.value.title)},"trigger":"test","timestamp":${canaryJsonString(java.time.Instant.now().toString())},"source":"peach-android"} """.trimIndent().toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() canaryClient.newCall(request).execute().use { it.isSuccessful } } }.getOrDefault(false) _form.update { it.copy(canaryTesting = false, canaryMessage = if (result) "sent" else "failed - check URL") } } } 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 } if (f.canary != null) { if (existing?.canary == null && !license.isProActive()) { _form.update { it.copy(error = "Canary entries require Peach Pro") } return } if (!isAllowedCanaryWebhookUrl(f.canary.webhookUrl)) { _form.update { it.copy(error = "enter a public HTTPS webhook URL") } 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 }, canary = f.canary, 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 }, canary = f.canary, ), ) } }.onSuccess { onDone() } .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } } } } } private fun canaryJsonString(value: String): String = "\"" + value .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") + "\"" 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 } data class IdentitySuggestion(val value: String, val count: Int) internal fun identitySuggestionsFor(entries: List, field: IdentityField): List { return identitySuggestionCounts(entries, field).map { it.value } } internal fun identitySuggestionCounts( 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 -> listOfNotNull(entry.username, entry.email).forEach { value -> val trimmed = value.trim() if (trimmed.isNotEmpty() && (trimmed.contains("@") == (field == IdentityField.Email))) add(trimmed) } } return buckets.values .sortedWith(compareByDescending> { it.second }.thenBy { it.first.lowercase() }) .map { IdentitySuggestion(it.first, it.second) } .take(8) }