EntryEditViewModel.kt

13.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 cardNumber: String = "",32    val cardholderName: String = "",33    val expiry: String = "",        // "MM/YY"; split into expMonth/expYear on save34    val cvc: String = "",35    val showCvc: Boolean = false,36    val cardNetwork: String = "unknown",37    val tags: List<String> = emptyList(),38    val showPassword: Boolean = false,39    val generatorOpen: Boolean = false,40    val genCopied: Boolean = false,41    val history: List<PasswordHistoryItem> = emptyList(),42    val showHistory: Boolean = false,43    val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH,44    val genUpper: Boolean = true,45    val genLower: Boolean = true,46    val genNumbers: Boolean = true,47    val genSymbols: Boolean = true,48    val saving: Boolean = false,49    val error: String? = null,50    val showTotp: Boolean = false,51    val showNotes: Boolean = false,52)5354@HiltViewModel55class EntryEditViewModel @Inject constructor(56    private val vault: VaultRepository,57    @ApplicationContext private val appContext: Context,58    savedStateHandle: SavedStateHandle,59) : ViewModel() {60    private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }61    val isNew: Boolean = id == null62    private val existing: VaultEntry? = id?.let { vault.getEntry(it) }6364    private val _form = MutableStateFlow(65        existing?.let {66            EditForm(67                type = it.type ?: "login",68                title = it.title,69                username = it.username.orEmpty(),70                email = it.email.orEmpty(),71                password = it.password.orEmpty(),72                url = it.url.orEmpty(),73                additionalUrls = it.additionalUrls.orEmpty(),74                totp = it.totp.orEmpty(),75                notes = it.notes.orEmpty(),76                cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),77                cardholderName = it.cardholderName.orEmpty(),78                expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),79                cvc = it.cvc.orEmpty(),80                cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),81                tags = it.tags.orEmpty(),82                history = it.passwordHistory.orEmpty(),83                showTotp = !it.totp.isNullOrBlank(),84                showNotes = !it.notes.isNullOrBlank(),85            )86        } ?: EditForm(password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH)),87    )88    val form: StateFlow<EditForm> = _form.asStateFlow()8990    /** Browser AddView parity: field-specific identity suggestions, frequency-sorted. */91    val usernameSuggestions: StateFlow<List<String>> =92        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()9394    val emailSuggestions: StateFlow<List<String>> =95        MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()9697    fun setType(t: String) = _form.update { it.copy(type = t) }9899    /**100     * Create a blank NOTE entry and hand its id to [onOpen] so the caller can101     * navigate straight into the full-screen notes editor (brief Fix 6 item 7) —102     * a new note never shows the generic edit form.103     */104    fun createNoteAndOpen(onOpen: (String) -> Unit) {105        viewModelScope.launch {106            runCatching {107                vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))108            }.onSuccess { onOpen(it.id) }109                .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }110        }111    }112    fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }113    fun setUsername(v: String) = _form.update { it.copy(username = v) }114    fun setEmail(v: String) = _form.update { it.copy(email = v) }115    fun setPassword(v: String) = _form.update { it.copy(password = v) }116    fun setUrl(v: String) = _form.update { it.copy(url = v) }117    fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->118        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })119    }120    fun addAdditionalUrl() = _form.update { form ->121        if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form122        else form.copy(additionalUrls = form.additionalUrls + "")123    }124    fun removeAdditionalUrl(index: Int) = _form.update { form ->125        form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })126    }127    fun setTotp(v: String) = _form.update { it.copy(totp = v) }128    fun setNotes(v: String) = _form.update { it.copy(notes = v) }129    fun revealTotp() = _form.update { it.copy(showTotp = true) }130    fun revealNotes() = _form.update { it.copy(showNotes = true) }131    fun setCardNumber(v: String) = _form.update {132        val formatted = CardFieldUtils.formatPan(v)133        it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)134    }135    fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }136    fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }137    fun setCvc(v: String) = _form.update {138        it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))139    }140    fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }141142    val allTags: List<String> = vault.getDistinctTags()143    fun addTag(raw: String) = _form.update { f ->144        val tag = raw.trim().lowercase().take(32)145        if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f146        else f.copy(tags = f.tags + tag)147    }148    fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }149150    fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }151    fun toggleGenerator() = _form.update { form ->152        val opening = !form.generatorOpen153        val next = form.copy(generatorOpen = opening, showHistory = false)154        if (opening && next.password.isBlank()) next.withGeneratedPassword() else next155    }156    fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }157158    /** Restore a past password into the editor; the save flow records the swap. */159    fun restorePassword(pw: String) =160        _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }161162    /** Copy the current generated/typed password to the clipboard with a drain latch. */163    fun copyGenerated() {164        val pw = _form.value.password165        if (pw.isEmpty()) return166        PeachClipboard.copy(appContext, "peach", pw)167        viewModelScope.launch {168            _form.update { it.copy(genCopied = true) }169            kotlinx.coroutines.delay(1200)170            _form.update { it.copy(genCopied = false) }171        }172    }173174    fun copyHistoryPassword(pw: String) {175        PeachClipboard.copy(appContext, "peach", pw)176    }177    fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }178    fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }179    fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }180    fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }181    fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }182183    fun generate() {184        _form.update { it.withGeneratedPassword() }185    }186187    fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }188189    fun save(onDone: () -> Unit) {190        val f = _form.value191        if (f.saving) return192        if (f.title.isBlank()) {193            _form.update { it.copy(error = "title is required") }194            return195        }196        if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {197            _form.update { it.copy(error = "Cardholder name and card number are required") }198            return199        }200        _form.update { it.copy(saving = true, error = null) }201        viewModelScope.launch {202            runCatching {203                if (isNew) {204                    vault.addEntry(205                        VaultEntry(206                            id = "", type = f.type, title = f.title.trim(),207                            username = f.username.ifBlank { null },208                            email = f.email.ifBlank { null },209                            password = f.password.ifBlank { null },210                            url = f.url.ifBlank { null },211                            additionalUrls = f.additionalUrls212                                .map { it.trim() }.filter { it.isNotBlank() }213                                .ifEmpty { null },214                            totp = f.totp.ifBlank { null },215                            notes = f.notes.ifBlank { null },216                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },217                            cardholderName = f.cardholderName.ifBlank { null },218                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },219                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(2).ifBlank { null },220                            cvc = f.cvc.ifBlank { null },221                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },222                            tags = f.tags.ifEmpty { null },223                            createdAt = 0, modifiedAt = 0,224                        ),225                    )226                } else {227                    vault.updateEntry(228                        existing!!.copy(229                            type = f.type, title = f.title.trim(),230                            username = f.username.ifBlank { null },231                            email = f.email.ifBlank { null },232                            password = f.password.ifBlank { null },233                            url = f.url.ifBlank { null },234                            additionalUrls = f.additionalUrls235                                .map { it.trim() }.filter { it.isNotBlank() }236                                .ifEmpty { null },237                            totp = f.totp.ifBlank { null },238                            notes = f.notes.ifBlank { null },239                            cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },240                            cardholderName = f.cardholderName.ifBlank { null },241                            expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },242                            expYear = f.expiry.filter { it.isDigit() }.drop(2).take(2).ifBlank { null },243                            cvc = f.cvc.ifBlank { null },244                            cardNetwork = f.cardNetwork.takeIf { it != "unknown" },245                            tags = f.tags.ifEmpty { null },246                        ),247                    )248                }249            }.onSuccess { onDone() }250                .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }251        }252    }253}254255internal const val BROWSER_ADD_GENERATOR_LENGTH = 64256257private fun EditForm.withGeneratedPassword(): EditForm =258    copy(259        password = generateEditPassword(260            length = genLength,261            upper = genUpper,262            lower = genLower,263            numbers = genNumbers,264            symbols = genSymbols,265        ),266        showPassword = true,267    )268269private fun generateEditPassword(270    length: Int,271    upper: Boolean = true,272    lower: Boolean = true,273    numbers: Boolean = true,274    symbols: Boolean = true,275): String =276    PasswordGenerator.generate(277        PasswordGenerator.Options(278            length = length,279            includeUppercase = upper,280            includeLowercase = lower,281            includeNumbers = numbers,282            includeSymbols = symbols,283        ),284    )285286internal enum class IdentityField { Username, Email }287288internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {289    val buckets = linkedMapOf<String, Pair<String, Int>>()290291    fun add(value: String) {292        val trimmed = value.trim()293        if (trimmed.isEmpty()) return294        val key = trimmed.lowercase()295        val current = buckets[key]296        buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)297    }298299    entries.filter { it.deletedAt == null }.forEach { entry ->300        val username = entry.username?.trim().orEmpty()301        val email = entry.email?.trim().orEmpty()302        when (field) {303            IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username)304            IdentityField.Email -> {305                if (username.contains("@")) add(username)306                if (email.isNotEmpty()) add(email)307            }308        }309    }310311    return buckets.values312        .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })313        .map { it.first }314        .take(8)315}