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 = "", 34 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 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 100101102103104 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 159 fun restorePassword(pw: String) =160 _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }161162 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}