EntryEditViewModel.kt

18.6 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.license.LicenseRepository9import com.peachpasswords.vault.PasswordGenerator10import com.peachpasswords.vault.PasswordHistoryItem11import com.peachpasswords.vault.Canary12import com.peachpasswords.vault.VaultEntry13import com.peachpasswords.vault.VaultRepository14import dagger.hilt.android.lifecycle.HiltViewModel15import dagger.hilt.android.qualifiers.ApplicationContext16import kotlinx.coroutines.flow.MutableStateFlow17import kotlinx.coroutines.flow.StateFlow18import kotlinx.coroutines.flow.asStateFlow19import kotlinx.coroutines.flow.update20import kotlinx.coroutines.Dispatchers21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import okhttp3.MediaType.Companion.toMediaType24import okhttp3.OkHttpClient25import okhttp3.Request26import okhttp3.RequestBody.Companion.toRequestBody27import javax.inject.Inject2829data class EditForm(30    val type: String = "login",31    val title: String = "",32    val username: String = "",33    val email: String = "",34    val password: String = "",35    val url: String = "",36    val additionalUrls: List<String> = emptyList(),37    val totp: String = "",38    val notes: String = "",39    val cardNickname: String = "",40    val cardNumber: String = "",41    val cardholderName: String = "",42    val expiry: String = "",        // "MM/YY" or "MM/YYYY"; split into expMonth/expYear on save43    val cvc: String = "",44    val showCvc: Boolean = false,45    val cardNetwork: String = "unknown",46    val tags: List<String> = emptyList(),47    val canary: Canary? = null,48    val canaryMessage: String? = null,49    val canaryTesting: Boolean = false,50    val showPassword: Boolean = false,51    val generatorOpen: Boolean = false,52    val genCopied: Boolean = false,53    val history: List<PasswordHistoryItem> = emptyList(),54    val showHistory: Boolean = false,55    val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH,56    val genUpper: Boolean = true,57    val genLower: Boolean = true,58    val genNumbers: Boolean = true,59    val genSymbols: Boolean = true,60    val saving: Boolean = false,61    val error: String? = null,62    val showTotp: Boolean = false,63    val showNotes: Boolean = false,64)6566@HiltViewModel67class EntryEditViewModel @Inject constructor(68    private val vault: VaultRepository,69    @ApplicationContext private val appContext: Context,70    private val license: LicenseRepository,71    savedStateHandle: SavedStateHandle,72) : ViewModel() {73    private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }74    val isNew: Boolean = id == null75    private val existing: VaultEntry? = id?.let { vault.getEntry(it) }76    val isPro: Boolean = license.isProActive()77    private val canaryClient by lazy { OkHttpClient() }7879    /**80     * A note authored in the browser is HTML; the form edits its markup form and81     * writes the tags back, so editing a login's notes here cannot flatten them.82     */83    private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty())8485    private fun notesForStorage(edited: String): String? =86        noteStorageText(edited, notesAreHtml).ifBlank { null }8788    // Prefill values arrive on the EntryEdit route when the user follows the89    // autofill picker's "+ Create new entry" path. Only apply to fresh entries90    // — editing an existing entry must not be silently overwritten.91    private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }92    private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }93    private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }9495    private val _form = MutableStateFlow(96        existing?.let {97            EditForm(98                type = it.type ?: "login",99                title = it.title,100                username = it.username.orEmpty(),101                email = it.email.orEmpty(),102                password = it.password.orEmpty(),103                url = it.url.orEmpty(),104                additionalUrls = it.additionalUrls.orEmpty(),105                totp = it.totp.orEmpty(),106                notes = noteEditorText(it.notes.orEmpty()),107                cardNickname = it.cardNickname.orEmpty(),108                cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),109                cardholderName = it.cardholderName.orEmpty(),110                expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),111                cvc = it.cvc.orEmpty(),112                cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),113                tags = it.tags.orEmpty(),114                canary = it.canary,115                history = it.passwordHistory.orEmpty(),116                showTotp = !it.totp.isNullOrBlank(),117                showNotes = !it.notes.isNullOrBlank(),118            )119        } ?: EditForm(120            password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),121            url = prefillUrl.orEmpty(),122            username = prefillUsername.orEmpty(),123            email = prefillEmail.orEmpty(),124        ),125    )126    val form: StateFlow<EditForm> = _form.asStateFlow()127128    /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */129    val usernameSuggestions: StateFlow<List<String>> =130        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()131132    val emailSuggestions: StateFlow<List<String>> =133        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()134135    fun setType(t: String) = _form.update { it.copy(type = t) }136137    /**138     * Create a blank NOTE entry and hand its id to [onOpen] so the caller can139     * navigate straight into the full-screen notes editor (brief Fix 6 item 7) —140     * a new note never shows the generic edit form.141     */142    fun createNoteAndOpen(onOpen: (String) -> Unit) {143        viewModelScope.launch {144            runCatching {145                vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))146            }.onSuccess { onOpen(it.id) }147                .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }148        }149    }150    fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }151    fun setUsername(v: String) = _form.update { it.copy(username = v) }152    fun setEmail(v: String) = _form.update { it.copy(email = v) }153    fun setPassword(v: String) = _form.update { it.copy(password = v) }154    fun setUrl(v: String) = _form.update { it.copy(url = v) }155    fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->156        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })157    }158    fun addAdditionalUrl() = _form.update { form ->159        if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form160        else form.copy(additionalUrls = form.additionalUrls + "")161    }162    fun removeAdditionalUrl(index: Int) = _form.update { form ->163        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })164    }165    fun setTotp(v: String) = _form.update { it.copy(totp = v) }166    fun setNotes(v: String) = _form.update { it.copy(notes = v) }167    fun revealTotp() = _form.update { it.copy(showTotp = true) }168    fun revealNotes() = _form.update { it.copy(showNotes = true) }169    fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }170    fun setCardNumber(v: String) = _form.update {171        val formatted = CardFieldUtils.formatPan(v)172        it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)173    }174    fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }175    fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }176    fun setCvc(v: String) = _form.update {177        it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))178    }179    fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }180181    val allTags: List<String> = vault.getDistinctTags()182    fun addTag(raw: String) = _form.update { f ->183        val tag = raw.trim().lowercase().take(32)184        if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f185        else f.copy(tags = f.tags + tag)186    }187    fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }188189    fun setCanary(enabled: Boolean, webhookUrl: String, label: String) {190        if (enabled && existing?.canary == null && !license.isProActive()) {191            _form.update { it.copy(canaryMessage = "Canary entries require Peach Pro") }192            return193        }194        _form.update { form ->195            form.copy(196                canary = if (enabled) {197                    Canary(198                        webhookUrl = webhookUrl.trim(),199                        label = label.trim().ifBlank { null },200                        lastPinged = form.canary?.lastPinged,201                        pingCount = form.canary?.pingCount,202                    )203                } else {204                    null205                },206                canaryMessage = null,207            )208        }209    }210211    fun testCanary(webhookUrl: String) {212        val url = webhookUrl.trim()213        if (!isAllowedCanaryWebhookUrl(url)) {214            _form.update { it.copy(canaryMessage = "enter a public HTTPS webhook URL") }215            return216        }217        viewModelScope.launch {218            _form.update { it.copy(canaryTesting = true, canaryMessage = null) }219            val result = runCatching {220                withContext(Dispatchers.IO) {221                    val body = """222                        {"event":"peach_canary_test","entry":${canaryJsonString(_form.value.title)},"trigger":"test","timestamp":${canaryJsonString(java.time.Instant.now().toString())},"source":"peach-android"}223                    """.trimIndent().toRequestBody("application/json".toMediaType())224                    val request = Request.Builder().url(url).post(body).build()225                    canaryClient.newCall(request).execute().use { it.isSuccessful }226                }227            }.getOrDefault(false)228            _form.update { it.copy(canaryTesting = false, canaryMessage = if (result) "sent" else "failed - check URL") }229        }230    }231232    fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }233    fun toggleGenerator() = _form.update { form ->234        val opening = !form.generatorOpen235        val next = form.copy(generatorOpen = opening, showHistory = false)236        if (opening && next.password.isBlank()) next.withGeneratedPassword() else next237    }238    fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }239240    /** Restore a past password into the editor; the save flow records the swap. */241    fun restorePassword(pw: String) =242        _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }243244    /** Copy the current generated/typed password to the clipboard with a drain latch. */245    fun copyGenerated() {246        val pw = _form.value.password247        if (pw.isEmpty()) return248        PeachClipboard.copy(appContext, "peach", pw)249        viewModelScope.launch {250            _form.update { it.copy(genCopied = true) }251            kotlinx.coroutines.delay(1200)252            _form.update { it.copy(genCopied = false) }253        }254    }255256    fun copyHistoryPassword(pw: String) {257        PeachClipboard.copy(appContext, "peach", pw)258    }259    fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }260    fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }261    fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }262    fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }263    fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }264265    fun generate() {266        _form.update { it.withGeneratedPassword() }267    }268269    fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }270271    fun save(onDone: () -> Unit) {272        val f = _form.value273        if (f.saving) return274        if (f.title.isBlank()) {275            _form.update { it.copy(error = "title is required") }276            return277        }278        if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {279            _form.update { it.copy(error = "Cardholder name and card number are required") }280            return281        }282        if (f.canary != null) {283            if (existing?.canary == null && !license.isProActive()) {284                _form.update { it.copy(error = "Canary entries require Peach Pro") }285                return286            }287            if (!isAllowedCanaryWebhookUrl(f.canary.webhookUrl)) {288                _form.update { it.copy(error = "enter a public HTTPS webhook URL") }289                return290            }291        }292        _form.update { it.copy(saving = true, error = null) }293        viewModelScope.launch {294            runCatching {295                if (isNew) {296                    vault.addEntry(297                        VaultEntry(298                            id = "", type = f.type, title = f.title.trim(),299                            username = f.username.ifBlank { null },300                            email = f.email.ifBlank { null },301                            password = f.password.ifBlank { null },302                            url = f.url.ifBlank { null },303                            additionalUrls = f.additionalUrls304                                .map { it.trim() }.filter { it.isNotBlank() }305                                .ifEmpty { null },306                            totp = f.totp.ifBlank { null },307                            notes = notesForStorage(f.notes),308                            cardNickname = f.cardNickname.ifBlank { null },309                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },310                            cardholderName = f.cardholderName.ifBlank { null },311                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },312                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },313                            cvc = f.cvc.ifBlank { null },314                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },315                            tags = f.tags.ifEmpty { null },316                            canary = f.canary,317                            createdAt = 0, modifiedAt = 0,318                        ),319                    )320                } else {321                    vault.updateEntry(322                        existing!!.copy(323                            type = f.type, title = f.title.trim(),324                            username = f.username.ifBlank { null },325                            email = f.email.ifBlank { null },326                            password = f.password.ifBlank { null },327                            url = f.url.ifBlank { null },328                            additionalUrls = f.additionalUrls329                                .map { it.trim() }.filter { it.isNotBlank() }330                                .ifEmpty { null },331                            totp = f.totp.ifBlank { null },332                            notes = notesForStorage(f.notes),333                            cardNickname = f.cardNickname.ifBlank { null },334                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },335                            cardholderName = f.cardholderName.ifBlank { null },336                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },337                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },338                            cvc = f.cvc.ifBlank { null },339                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },340                            tags = f.tags.ifEmpty { null },341                            canary = f.canary,342                        ),343                    )344                }345            }.onSuccess { onDone() }346                .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }347        }348    }349}350351private fun canaryJsonString(value: String): String =352    "\"" + value353        .replace("\\", "\\\\")354        .replace("\"", "\\\"")355        .replace("\n", "\\n")356        .replace("\r", "\\r") + "\""357358internal const val BROWSER_ADD_GENERATOR_LENGTH = 64359360private fun EditForm.withGeneratedPassword(): EditForm =361    copy(362        password = generateEditPassword(363            length = genLength,364            upper = genUpper,365            lower = genLower,366            numbers = genNumbers,367            symbols = genSymbols,368        ),369        showPassword = true,370    )371372private fun generateEditPassword(373    length: Int,374    upper: Boolean = true,375    lower: Boolean = true,376    numbers: Boolean = true,377    symbols: Boolean = true,378): String =379    PasswordGenerator.generate(380        PasswordGenerator.Options(381            length = length,382            includeUppercase = upper,383            includeLowercase = lower,384            includeNumbers = numbers,385            includeSymbols = symbols,386        ),387    )388389internal enum class IdentityField { Username, Email }390391data class IdentitySuggestion(val value: String, val count: Int)392393internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {394    return identitySuggestionCounts(entries, field).map { it.value }395}396397internal fun identitySuggestionCounts(398    entries: List<VaultEntry>,399    field: IdentityField,400): List<IdentitySuggestion> {401    val buckets = linkedMapOf<String, Pair<String, Int>>()402403    fun add(value: String) {404        val trimmed = value.trim()405        if (trimmed.isEmpty()) return406        val key = trimmed.lowercase()407        val current = buckets[key]408        buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)409    }410411    entries.filter { it.deletedAt == null }.forEach { entry ->412        listOfNotNull(entry.username, entry.email).forEach { value ->413            val trimmed = value.trim()414            if (trimmed.isNotEmpty() && (trimmed.contains("@") == (field == IdentityField.Email))) add(trimmed)415        }416    }417418    return buckets.values419        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })420        .map { IdentitySuggestion(it.first, it.second) }421        .take(8)422}