EntryEditViewModel.kt

14.9 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    // Prefill values arrive on the EntryEdit route when the user follows the66    // autofill picker's "+ Create new entry" path. Only apply to fresh entries67    // — editing an existing entry must not be silently overwritten.68    private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }69    private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }70    private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }7172    private val _form = MutableStateFlow(73        existing?.let {74            EditForm(75                type = it.type ?: "login",76                title = it.title,77                username = it.username.orEmpty(),78                email = it.email.orEmpty(),79                password = it.password.orEmpty(),80                url = it.url.orEmpty(),81                additionalUrls = it.additionalUrls.orEmpty(),82                totp = it.totp.orEmpty(),83                notes = it.notes.orEmpty(),84                cardNickname = it.cardNickname.orEmpty(),85                cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),86                cardholderName = it.cardholderName.orEmpty(),87                expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),88                cvc = it.cvc.orEmpty(),89                cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),90                tags = it.tags.orEmpty(),91                history = it.passwordHistory.orEmpty(),92                showTotp = !it.totp.isNullOrBlank(),93                showNotes = !it.notes.isNullOrBlank(),94            )95        } ?: EditForm(96            password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),97            url = prefillUrl.orEmpty(),98            username = prefillUsername.orEmpty(),99            email = prefillEmail.orEmpty(),100        ),101    )102    val form: StateFlow<EditForm> = _form.asStateFlow()103104    /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */105    val usernameSuggestions: StateFlow<List<String>> =106        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()107108    val emailSuggestions: StateFlow<List<String>> =109        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()110111    fun setType(t: String) = _form.update { it.copy(type = t) }112113    /**114     * Create a blank NOTE entry and hand its id to [onOpen] so the caller can115     * navigate straight into the full-screen notes editor (brief Fix 6 item 7) —116     * a new note never shows the generic edit form.117     */118    fun createNoteAndOpen(onOpen: (String) -> Unit) {119        viewModelScope.launch {120            runCatching {121                vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))122            }.onSuccess { onOpen(it.id) }123                .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }124        }125    }126    fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }127    fun setUsername(v: String) = _form.update { it.copy(username = v) }128    fun setEmail(v: String) = _form.update { it.copy(email = v) }129    fun setPassword(v: String) = _form.update { it.copy(password = v) }130    fun setUrl(v: String) = _form.update { it.copy(url = v) }131    fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->132        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })133    }134    fun addAdditionalUrl() = _form.update { form ->135        if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form136        else form.copy(additionalUrls = form.additionalUrls + "")137    }138    fun removeAdditionalUrl(index: Int) = _form.update { form ->139        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })140    }141    fun setTotp(v: String) = _form.update { it.copy(totp = v) }142    fun setNotes(v: String) = _form.update { it.copy(notes = v) }143    fun revealTotp() = _form.update { it.copy(showTotp = true) }144    fun revealNotes() = _form.update { it.copy(showNotes = true) }145    fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }146    fun setCardNumber(v: String) = _form.update {147        val formatted = CardFieldUtils.formatPan(v)148        it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)149    }150    fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }151    fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }152    fun setCvc(v: String) = _form.update {153        it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))154    }155    fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }156157    val allTags: List<String> = vault.getDistinctTags()158    fun addTag(raw: String) = _form.update { f ->159        val tag = raw.trim().lowercase().take(32)160        if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f161        else f.copy(tags = f.tags + tag)162    }163    fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }164165    fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }166    fun toggleGenerator() = _form.update { form ->167        val opening = !form.generatorOpen168        val next = form.copy(generatorOpen = opening, showHistory = false)169        if (opening && next.password.isBlank()) next.withGeneratedPassword() else next170    }171    fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }172173    /** Restore a past password into the editor; the save flow records the swap. */174    fun restorePassword(pw: String) =175        _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }176177    /** Copy the current generated/typed password to the clipboard with a drain latch. */178    fun copyGenerated() {179        val pw = _form.value.password180        if (pw.isEmpty()) return181        PeachClipboard.copy(appContext, "peach", pw)182        viewModelScope.launch {183            _form.update { it.copy(genCopied = true) }184            kotlinx.coroutines.delay(1200)185            _form.update { it.copy(genCopied = false) }186        }187    }188189    fun copyHistoryPassword(pw: String) {190        PeachClipboard.copy(appContext, "peach", pw)191    }192    fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }193    fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }194    fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }195    fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }196    fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }197198    fun generate() {199        _form.update { it.withGeneratedPassword() }200    }201202    fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }203204    fun save(onDone: () -> Unit) {205        val f = _form.value206        if (f.saving) return207        if (f.title.isBlank()) {208            _form.update { it.copy(error = "title is required") }209            return210        }211        if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {212            _form.update { it.copy(error = "Cardholder name and card number are required") }213            return214        }215        _form.update { it.copy(saving = true, error = null) }216        viewModelScope.launch {217            runCatching {218                if (isNew) {219                    vault.addEntry(220                        VaultEntry(221                            id = "", type = f.type, title = f.title.trim(),222                            username = f.username.ifBlank { null },223                            email = f.email.ifBlank { null },224                            password = f.password.ifBlank { null },225                            url = f.url.ifBlank { null },226                            additionalUrls = f.additionalUrls227                                .map { it.trim() }.filter { it.isNotBlank() }228                                .ifEmpty { null },229                            totp = f.totp.ifBlank { null },230                            notes = f.notes.ifBlank { null },231                            cardNickname = f.cardNickname.ifBlank { null },232                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },233                            cardholderName = f.cardholderName.ifBlank { null },234                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },235                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },236                            cvc = f.cvc.ifBlank { null },237                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },238                            tags = f.tags.ifEmpty { null },239                            createdAt = 0, modifiedAt = 0,240                        ),241                    )242                } else {243                    vault.updateEntry(244                        existing!!.copy(245                            type = f.type, title = f.title.trim(),246                            username = f.username.ifBlank { null },247                            email = f.email.ifBlank { null },248                            password = f.password.ifBlank { null },249                            url = f.url.ifBlank { null },250                            additionalUrls = f.additionalUrls251                                .map { it.trim() }.filter { it.isNotBlank() }252                                .ifEmpty { null },253                            totp = f.totp.ifBlank { null },254                            notes = f.notes.ifBlank { null },255                            cardNickname = f.cardNickname.ifBlank { null },256                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },257                            cardholderName = f.cardholderName.ifBlank { null },258                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },259                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },260                            cvc = f.cvc.ifBlank { null },261                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },262                            tags = f.tags.ifEmpty { null },263                        ),264                    )265                }266            }.onSuccess { onDone() }267                .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }268        }269    }270}271272internal const val BROWSER_ADD_GENERATOR_LENGTH = 64273274private fun EditForm.withGeneratedPassword(): EditForm =275    copy(276        password = generateEditPassword(277            length = genLength,278            upper = genUpper,279            lower = genLower,280            numbers = genNumbers,281            symbols = genSymbols,282        ),283        showPassword = true,284    )285286private fun generateEditPassword(287    length: Int,288    upper: Boolean = true,289    lower: Boolean = true,290    numbers: Boolean = true,291    symbols: Boolean = true,292): String =293    PasswordGenerator.generate(294        PasswordGenerator.Options(295            length = length,296            includeUppercase = upper,297            includeLowercase = lower,298            includeNumbers = numbers,299            includeSymbols = symbols,300        ),301    )302303internal enum class IdentityField { Username, Email }304305internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {306    val buckets = linkedMapOf<String, Pair<String, Int>>()307308    fun add(value: String) {309        val trimmed = value.trim()310        if (trimmed.isEmpty()) return311        val key = trimmed.lowercase()312        val current = buckets[key]313        buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)314    }315316    entries.filter { it.deletedAt == null }.forEach { entry ->317        val username = entry.username?.trim().orEmpty()318        val email = entry.email?.trim().orEmpty()319        when (field) {320            IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username)321            IdentityField.Email -> {322                if (username.contains("@")) add(username)323                if (email.isNotEmpty()) add(email)324            }325        }326    }327328    return buckets.values329        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })330        .map { it.first }331        .take(8)332}