EntryEditViewModel.kt

19.0 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 genMode: GeneratorMode = GeneratorMode.Password,56    val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH,57    val genWordCount: Int = PasswordGenerator.DEFAULT_PASSPHRASE_WORDS,58    val genSeparator: String = "-",59    val genUpper: Boolean = true,60    val genLower: Boolean = true,61    val genNumbers: Boolean = true,62    val genSymbols: Boolean = true,63    val saving: Boolean = false,64    val error: String? = null,65    val showTotp: Boolean = false,66    val showNotes: Boolean = false,67)6869@HiltViewModel70class EntryEditViewModel @Inject constructor(71    private val vault: VaultRepository,72    @ApplicationContext private val appContext: Context,73    private val license: LicenseRepository,74    savedStateHandle: SavedStateHandle,75) : ViewModel() {76    private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }77    val isNew: Boolean = id == null78    private val existing: VaultEntry? = id?.let { vault.getEntry(it) }79    val isPro: Boolean = license.isProActive()80    private val canaryClient by lazy { OkHttpClient() }8182    /**83     * A note authored in the browser is HTML; the form edits its markup form and84     * writes the tags back, so editing a login's notes here cannot flatten them.85     */86    private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty())8788    private fun notesForStorage(edited: String): String? =89        noteStorageText(edited, notesAreHtml).ifBlank { null }9091    // Prefill values arrive on the EntryEdit route when the user follows the92    // autofill picker's "+ Create new entry" path. Only apply to fresh entries93    // — editing an existing entry must not be silently overwritten.94    private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }95    private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }96    private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }9798    private val _form = MutableStateFlow(99        existing?.let {100            EditForm(101                type = it.type ?: "login",102                title = it.title,103                username = it.username.orEmpty(),104                email = it.email.orEmpty(),105                password = it.password.orEmpty(),106                url = it.url.orEmpty(),107                additionalUrls = it.additionalUrls.orEmpty(),108                totp = it.totp.orEmpty(),109                notes = noteEditorText(it.notes.orEmpty()),110                cardNickname = it.cardNickname.orEmpty(),111                cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),112                cardholderName = it.cardholderName.orEmpty(),113                expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),114                cvc = it.cvc.orEmpty(),115                cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),116                tags = it.tags.orEmpty(),117                canary = it.canary,118                history = it.passwordHistory.orEmpty(),119                showTotp = !it.totp.isNullOrBlank(),120                showNotes = !it.notes.isNullOrBlank(),121            )122        } ?: EditForm(123            password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),124            url = prefillUrl.orEmpty(),125            username = prefillUsername.orEmpty(),126            email = prefillEmail.orEmpty(),127        ),128    )129    val form: StateFlow<EditForm> = _form.asStateFlow()130131    /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */132    val usernameSuggestions: StateFlow<List<String>> =133        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()134135    val emailSuggestions: StateFlow<List<String>> =136        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()137138    fun setType(t: String) = _form.update { it.copy(type = t) }139140    fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }141    fun setUsername(v: String) = _form.update { it.copy(username = v) }142    fun setEmail(v: String) = _form.update { it.copy(email = v) }143    fun setPassword(v: String) = _form.update { it.copy(password = v) }144    fun setUrl(v: String) = _form.update { it.copy(url = v) }145    fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->146        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })147    }148    fun addAdditionalUrl() = _form.update { form ->149        if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form150        else form.copy(additionalUrls = form.additionalUrls + "")151    }152    fun removeAdditionalUrl(index: Int) = _form.update { form ->153        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })154    }155    fun setTotp(v: String) = _form.update { it.copy(totp = v) }156    fun setNotes(v: String) = _form.update { it.copy(notes = v) }157    fun revealTotp() = _form.update { it.copy(showTotp = true) }158    fun revealNotes() = _form.update { it.copy(showNotes = true) }159    fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }160    fun setCardNumber(v: String) = _form.update {161        val formatted = CardFieldUtils.formatPan(v)162        it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)163    }164    fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }165    fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }166    fun setCvc(v: String) = _form.update {167        it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))168    }169    fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }170171    val allTags: List<String> = vault.getDistinctTags()172    fun addTag(raw: String) = _form.update { f ->173        val tag = raw.trim().lowercase().take(32)174        if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f175        else f.copy(tags = f.tags + tag)176    }177    fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }178179    fun setCanary(enabled: Boolean, webhookUrl: String, label: String) {180        if (enabled && existing?.canary == null && !license.isProActive()) {181            _form.update { it.copy(canaryMessage = "Canary entries require Peach Pro") }182            return183        }184        _form.update { form ->185            form.copy(186                canary = if (enabled) {187                    Canary(188                        webhookUrl = webhookUrl.trim(),189                        label = label.trim().ifBlank { null },190                        lastPinged = form.canary?.lastPinged,191                        pingCount = form.canary?.pingCount,192                    )193                } else {194                    null195                },196                canaryMessage = null,197            )198        }199    }200201    fun testCanary(webhookUrl: String) {202        val url = webhookUrl.trim()203        if (!isAllowedCanaryWebhookUrl(url)) {204            _form.update { it.copy(canaryMessage = "enter a public HTTPS webhook URL") }205            return206        }207        viewModelScope.launch {208            _form.update { it.copy(canaryTesting = true, canaryMessage = null) }209            val result = runCatching {210                withContext(Dispatchers.IO) {211                    val body = """212                        {"event":"peach_canary_test","entry":${canaryJsonString(_form.value.title)},"trigger":"test","timestamp":${canaryJsonString(java.time.Instant.now().toString())},"source":"peach-android"}213                    """.trimIndent().toRequestBody("application/json".toMediaType())214                    val request = Request.Builder().url(url).post(body).build()215                    canaryClient.newCall(request).execute().use { it.isSuccessful }216                }217            }.getOrDefault(false)218            _form.update { it.copy(canaryTesting = false, canaryMessage = if (result) "sent" else "failed - check URL") }219        }220    }221222    fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }223    fun toggleGenerator() = _form.update { form ->224        val opening = !form.generatorOpen225        val next = form.copy(generatorOpen = opening, showHistory = false)226        if (opening && next.password.isBlank()) next.withGeneratedPassword() else next227    }228    fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }229230    /** Restore a past password into the editor; the save flow records the swap. */231    fun restorePassword(pw: String) =232        _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }233234    /** Copy the current generated/typed password to the clipboard with a drain latch. */235    fun copyGenerated() {236        val pw = _form.value.password237        if (pw.isEmpty()) return238        PeachClipboard.copy(appContext, "peach", pw)239        viewModelScope.launch {240            _form.update { it.copy(genCopied = true) }241            kotlinx.coroutines.delay(1200)242            _form.update { it.copy(genCopied = false) }243        }244    }245246    fun copyHistoryPassword(pw: String) {247        PeachClipboard.copy(appContext, "peach", pw)248    }249    fun setGenMode(mode: GeneratorMode) = _form.update { it.copy(genMode = mode).withGeneratedPassword() }250    fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 256)).withGeneratedPassword() }251    fun setGenWordCount(n: Int) = _form.update {252        it.copy(genWordCount = n.coerceIn(PasswordGenerator.MIN_PASSPHRASE_WORDS, PasswordGenerator.MAX_PASSPHRASE_WORDS))253            .withGeneratedPassword()254    }255    fun setGenSeparator(separator: String) = _form.update { it.copy(genSeparator = separator).withGeneratedPassword() }256    fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }257    fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }258    fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }259    fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }260261    fun generate() {262        _form.update { it.withGeneratedPassword() }263    }264265    fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }266267    fun save(onDone: () -> Unit) {268        val f = _form.value269        if (f.saving) return270        if (f.title.isBlank()) {271            _form.update { it.copy(error = "title is required") }272            return273        }274        if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {275            _form.update { it.copy(error = "Cardholder name and card number are required") }276            return277        }278        if (f.canary != null) {279            if (existing?.canary == null && !license.isProActive()) {280                _form.update { it.copy(error = "Canary entries require Peach Pro") }281                return282            }283            if (!isAllowedCanaryWebhookUrl(f.canary.webhookUrl)) {284                _form.update { it.copy(error = "enter a public HTTPS webhook URL") }285                return286            }287        }288        _form.update { it.copy(saving = true, error = null) }289        viewModelScope.launch {290            runCatching {291                if (isNew) {292                    vault.addEntry(293                        VaultEntry(294                            id = "", type = f.type, title = f.title.trim(),295                            username = f.username.ifBlank { null },296                            email = f.email.ifBlank { null },297                            password = f.password.ifBlank { null },298                            url = f.url.ifBlank { null },299                            additionalUrls = f.additionalUrls300                                .map { it.trim() }.filter { it.isNotBlank() }301                                .ifEmpty { null },302                            totp = f.totp.ifBlank { null },303                            notes = notesForStorage(f.notes),304                            cardNickname = f.cardNickname.ifBlank { null },305                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },306                            cardholderName = f.cardholderName.ifBlank { null },307                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },308                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },309                            cvc = f.cvc.ifBlank { null },310                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },311                            tags = f.tags.ifEmpty { null },312                            canary = f.canary,313                            createdAt = 0, modifiedAt = 0,314                        ),315                    )316                } else {317                    vault.updateEntry(318                        existing!!.copy(319                            type = f.type, title = f.title.trim(),320                            username = f.username.ifBlank { null },321                            email = f.email.ifBlank { null },322                            password = f.password.ifBlank { null },323                            url = f.url.ifBlank { null },324                            additionalUrls = f.additionalUrls325                                .map { it.trim() }.filter { it.isNotBlank() }326                                .ifEmpty { null },327                            totp = f.totp.ifBlank { null },328                            notes = notesForStorage(f.notes),329                            cardNickname = f.cardNickname.ifBlank { null },330                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },331                            cardholderName = f.cardholderName.ifBlank { null },332                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },333                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },334                            cvc = f.cvc.ifBlank { null },335                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },336                            tags = f.tags.ifEmpty { null },337                            canary = f.canary,338                        ),339                    )340                }341            }.onSuccess { onDone() }342                .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }343        }344    }345}346347private fun canaryJsonString(value: String): String =348    "\"" + value349        .replace("\\", "\\\\")350        .replace("\"", "\\\"")351        .replace("\n", "\\n")352        .replace("\r", "\\r") + "\""353354internal const val BROWSER_ADD_GENERATOR_LENGTH = 64355enum class GeneratorMode { Password, Passphrase }356357private fun EditForm.withGeneratedPassword(): EditForm =358    copy(359        password = generateEditCredential(this),360        showPassword = true,361    )362363internal fun generateEditCredential(form: EditForm): String = when (form.genMode) {364    GeneratorMode.Password -> generateEditPassword(365        length = form.genLength,366        upper = form.genUpper,367        lower = form.genLower,368        numbers = form.genNumbers,369        symbols = form.genSymbols,370    )371    GeneratorMode.Passphrase -> PasswordGenerator.generatePassphrase(372        wordCount = form.genWordCount,373        separator = form.genSeparator,374    )375}376377private fun generateEditPassword(378    length: Int,379    upper: Boolean = true,380    lower: Boolean = true,381    numbers: Boolean = true,382    symbols: Boolean = true,383): String =384    PasswordGenerator.generate(385        PasswordGenerator.Options(386            length = length,387            includeUppercase = upper,388            includeLowercase = lower,389            includeNumbers = numbers,390            includeSymbols = symbols,391        ),392    )393394internal enum class IdentityField { Username, Email }395396data class IdentitySuggestion(val value: String, val count: Int)397398internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {399    return identitySuggestionCounts(entries, field).map { it.value }400}401402internal fun identitySuggestionCounts(403    entries: List<VaultEntry>,404    field: IdentityField,405): List<IdentitySuggestion> {406    val buckets = linkedMapOf<String, Pair<String, Int>>()407408    fun add(value: String) {409        val trimmed = value.trim()410        if (trimmed.isEmpty()) return411        val key = trimmed.lowercase()412        val current = buckets[key]413        buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)414    }415416    entries.filter { it.deletedAt == null }.forEach { entry ->417        listOfNotNull(entry.username, entry.email).forEach { value ->418            val trimmed = value.trim()419            if (trimmed.isNotEmpty() && (trimmed.contains("@") == (field == IdentityField.Email))) add(trimmed)420        }421    }422423    return buckets.values424        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })425        .map { IdentitySuggestion(it.first, it.second) }426        .take(8)427}