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 66676869 private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty())7071 private fun notesForStorage(edited: String): String? =72 noteStorageText(edited, notesAreHtml).ifBlank { null }7374 75 76 77 private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }78 private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }79 private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }8081 private val _form = MutableStateFlow(82 existing?.let {83 EditForm(84 type = it.type ?: "login",85 title = it.title,86 username = it.username.orEmpty(),87 email = it.email.orEmpty(),88 password = it.password.orEmpty(),89 url = it.url.orEmpty(),90 additionalUrls = it.additionalUrls.orEmpty(),91 totp = it.totp.orEmpty(),92 notes = noteEditorText(it.notes.orEmpty()),93 cardNickname = it.cardNickname.orEmpty(),94 cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),95 cardholderName = it.cardholderName.orEmpty(),96 expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),97 cvc = it.cvc.orEmpty(),98 cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),99 tags = it.tags.orEmpty(),100 history = it.passwordHistory.orEmpty(),101 showTotp = !it.totp.isNullOrBlank(),102 showNotes = !it.notes.isNullOrBlank(),103 )104 } ?: EditForm(105 password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),106 url = prefillUrl.orEmpty(),107 username = prefillUsername.orEmpty(),108 email = prefillEmail.orEmpty(),109 ),110 )111 val form: StateFlow<EditForm> = _form.asStateFlow()112113 114 val usernameSuggestions: StateFlow<List<String>> =115 MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()116117 val emailSuggestions: StateFlow<List<String>> =118 MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()119120 fun setType(t: String) = _form.update { it.copy(type = t) }121122 123124125126127 fun createNoteAndOpen(onOpen: (String) -> Unit) {128 viewModelScope.launch {129 runCatching {130 vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))131 }.onSuccess { onOpen(it.id) }132 .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }133 }134 }135 fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }136 fun setUsername(v: String) = _form.update { it.copy(username = v) }137 fun setEmail(v: String) = _form.update { it.copy(email = v) }138 fun setPassword(v: String) = _form.update { it.copy(password = v) }139 fun setUrl(v: String) = _form.update { it.copy(url = v) }140 fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->141 form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })142 }143 fun addAdditionalUrl() = _form.update { form ->144 if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form145 else form.copy(additionalUrls = form.additionalUrls + "")146 }147 fun removeAdditionalUrl(index: Int) = _form.update { form ->148 form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })149 }150 fun setTotp(v: String) = _form.update { it.copy(totp = v) }151 fun setNotes(v: String) = _form.update { it.copy(notes = v) }152 fun revealTotp() = _form.update { it.copy(showTotp = true) }153 fun revealNotes() = _form.update { it.copy(showNotes = true) }154 fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }155 fun setCardNumber(v: String) = _form.update {156 val formatted = CardFieldUtils.formatPan(v)157 it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)158 }159 fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }160 fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }161 fun setCvc(v: String) = _form.update {162 it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))163 }164 fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }165166 val allTags: List<String> = vault.getDistinctTags()167 fun addTag(raw: String) = _form.update { f ->168 val tag = raw.trim().lowercase().take(32)169 if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f170 else f.copy(tags = f.tags + tag)171 }172 fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }173174 fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }175 fun toggleGenerator() = _form.update { form ->176 val opening = !form.generatorOpen177 val next = form.copy(generatorOpen = opening, showHistory = false)178 if (opening && next.password.isBlank()) next.withGeneratedPassword() else next179 }180 fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }181182 183 fun restorePassword(pw: String) =184 _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }185186 187 fun copyGenerated() {188 val pw = _form.value.password189 if (pw.isEmpty()) return190 PeachClipboard.copy(appContext, "peach", pw)191 viewModelScope.launch {192 _form.update { it.copy(genCopied = true) }193 kotlinx.coroutines.delay(1200)194 _form.update { it.copy(genCopied = false) }195 }196 }197198 fun copyHistoryPassword(pw: String) {199 PeachClipboard.copy(appContext, "peach", pw)200 }201 fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }202 fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }203 fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }204 fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }205 fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }206207 fun generate() {208 _form.update { it.withGeneratedPassword() }209 }210211 fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }212213 fun save(onDone: () -> Unit) {214 val f = _form.value215 if (f.saving) return216 if (f.title.isBlank()) {217 _form.update { it.copy(error = "title is required") }218 return219 }220 if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {221 _form.update { it.copy(error = "Cardholder name and card number are required") }222 return223 }224 _form.update { it.copy(saving = true, error = null) }225 viewModelScope.launch {226 runCatching {227 if (isNew) {228 vault.addEntry(229 VaultEntry(230 id = "", type = f.type, title = f.title.trim(),231 username = f.username.ifBlank { null },232 email = f.email.ifBlank { null },233 password = f.password.ifBlank { null },234 url = f.url.ifBlank { null },235 additionalUrls = f.additionalUrls236 .map { it.trim() }.filter { it.isNotBlank() }237 .ifEmpty { null },238 totp = f.totp.ifBlank { null },239 notes = notesForStorage(f.notes),240 cardNickname = f.cardNickname.ifBlank { null },241 cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },242 cardholderName = f.cardholderName.ifBlank { null },243 expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },244 expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },245 cvc = f.cvc.ifBlank { null },246 cardNetwork = f.cardNetwork.takeIf { it != "unknown" },247 tags = f.tags.ifEmpty { null },248 createdAt = 0, modifiedAt = 0,249 ),250 )251 } else {252 vault.updateEntry(253 existing!!.copy(254 type = f.type, title = f.title.trim(),255 username = f.username.ifBlank { null },256 email = f.email.ifBlank { null },257 password = f.password.ifBlank { null },258 url = f.url.ifBlank { null },259 additionalUrls = f.additionalUrls260 .map { it.trim() }.filter { it.isNotBlank() }261 .ifEmpty { null },262 totp = f.totp.ifBlank { null },263 notes = notesForStorage(f.notes),264 cardNickname = f.cardNickname.ifBlank { null },265 cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },266 cardholderName = f.cardholderName.ifBlank { null },267 expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },268 expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },269 cvc = f.cvc.ifBlank { null },270 cardNetwork = f.cardNetwork.takeIf { it != "unknown" },271 tags = f.tags.ifEmpty { null },272 ),273 )274 }275 }.onSuccess { onDone() }276 .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }277 }278 }279}280281internal const val BROWSER_ADD_GENERATOR_LENGTH = 64282283private fun EditForm.withGeneratedPassword(): EditForm =284 copy(285 password = generateEditPassword(286 length = genLength,287 upper = genUpper,288 lower = genLower,289 numbers = genNumbers,290 symbols = genSymbols,291 ),292 showPassword = true,293 )294295private fun generateEditPassword(296 length: Int,297 upper: Boolean = true,298 lower: Boolean = true,299 numbers: Boolean = true,300 symbols: Boolean = true,301): String =302 PasswordGenerator.generate(303 PasswordGenerator.Options(304 length = length,305 includeUppercase = upper,306 includeLowercase = lower,307 includeNumbers = numbers,308 includeSymbols = symbols,309 ),310 )311312internal enum class IdentityField { Username, Email }313314internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {315 val buckets = linkedMapOf<String, Pair<String, Int>>()316317 fun add(value: String) {318 val trimmed = value.trim()319 if (trimmed.isEmpty()) return320 val key = trimmed.lowercase()321 val current = buckets[key]322 buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)323 }324325 entries.filter { it.deletedAt == null }.forEach { entry ->326 val username = entry.username?.trim().orEmpty()327 val email = entry.email?.trim().orEmpty()328 when (field) {329 IdentityField.Username -> if (username.isNotEmpty() && !username.contains("@")) add(username)330 IdentityField.Email -> {331 if (username.contains("@")) add(username)332 if (email.isNotEmpty()) add(email)333 }334 }335 }336337 return buckets.values338 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })339 .map { it.first }340 .take(8)341}