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 = "", 35 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 66 67 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 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 114115116117118 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 174 fun restorePassword(pw: String) =175 _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }176177 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}