1package com.peachpasswords.ui.vault23import android.content.Context4import com.peachpasswords.shared.PeachClipboard5import androidx.lifecycle.SavedStateHandle6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.license.LicenseRepository9import com.peachpasswords.vault.PasswordGenerator10import com.peachpasswords.vault.PasswordHistoryItem11import com.peachpasswords.vault.Canary12import com.peachpasswords.vault.VaultEntry13import com.peachpasswords.vault.VaultRepository14import dagger.hilt.android.lifecycle.HiltViewModel15import dagger.hilt.android.qualifiers.ApplicationContext16import kotlinx.coroutines.flow.MutableStateFlow17import kotlinx.coroutines.flow.StateFlow18import kotlinx.coroutines.flow.asStateFlow19import kotlinx.coroutines.flow.update20import kotlinx.coroutines.Dispatchers21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import okhttp3.MediaType.Companion.toMediaType24import okhttp3.OkHttpClient25import okhttp3.Request26import okhttp3.RequestBody.Companion.toRequestBody27import javax.inject.Inject2829data class EditForm(30 val type: String = "login",31 val title: String = "",32 val username: String = "",33 val email: String = "",34 val password: String = "",35 val url: String = "",36 val additionalUrls: List<String> = emptyList(),37 val totp: String = "",38 val notes: String = "",39 val cardNickname: String = "",40 val cardNumber: String = "",41 val cardholderName: String = "",42 val expiry: String = "", 43 val cvc: String = "",44 val showCvc: Boolean = false,45 val cardNetwork: String = "unknown",46 val tags: List<String> = emptyList(),47 val canary: Canary? = null,48 val canaryMessage: String? = null,49 val canaryTesting: Boolean = false,50 val showPassword: Boolean = false,51 val generatorOpen: Boolean = false,52 val genCopied: Boolean = false,53 val history: List<PasswordHistoryItem> = emptyList(),54 val showHistory: Boolean = false,55 val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH,56 val genUpper: Boolean = true,57 val genLower: Boolean = true,58 val genNumbers: Boolean = true,59 val genSymbols: Boolean = true,60 val saving: Boolean = false,61 val error: String? = null,62 val showTotp: Boolean = false,63 val showNotes: Boolean = false,64)6566@HiltViewModel67class EntryEditViewModel @Inject constructor(68 private val vault: VaultRepository,69 @ApplicationContext private val appContext: Context,70 private val license: LicenseRepository,71 savedStateHandle: SavedStateHandle,72) : ViewModel() {73 private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }74 val isNew: Boolean = id == null75 private val existing: VaultEntry? = id?.let { vault.getEntry(it) }76 val isPro: Boolean = license.isProActive()77 private val canaryClient by lazy { OkHttpClient() }7879 80818283 private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty())8485 private fun notesForStorage(edited: String): String? =86 noteStorageText(edited, notesAreHtml).ifBlank { null }8788 89 90 91 private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }92 private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }93 private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }9495 private val _form = MutableStateFlow(96 existing?.let {97 EditForm(98 type = it.type ?: "login",99 title = it.title,100 username = it.username.orEmpty(),101 email = it.email.orEmpty(),102 password = it.password.orEmpty(),103 url = it.url.orEmpty(),104 additionalUrls = it.additionalUrls.orEmpty(),105 totp = it.totp.orEmpty(),106 notes = noteEditorText(it.notes.orEmpty()),107 cardNickname = it.cardNickname.orEmpty(),108 cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),109 cardholderName = it.cardholderName.orEmpty(),110 expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),111 cvc = it.cvc.orEmpty(),112 cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),113 tags = it.tags.orEmpty(),114 canary = it.canary,115 history = it.passwordHistory.orEmpty(),116 showTotp = !it.totp.isNullOrBlank(),117 showNotes = !it.notes.isNullOrBlank(),118 )119 } ?: EditForm(120 password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),121 url = prefillUrl.orEmpty(),122 username = prefillUsername.orEmpty(),123 email = prefillEmail.orEmpty(),124 ),125 )126 val form: StateFlow<EditForm> = _form.asStateFlow()127128 129 val usernameSuggestions: StateFlow<List<String>> =130 MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()131132 val emailSuggestions: StateFlow<List<String>> =133 MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()134135 fun setType(t: String) = _form.update { it.copy(type = t) }136137 138139140141142 fun createNoteAndOpen(onOpen: (String) -> Unit) {143 viewModelScope.launch {144 runCatching {145 vault.addEntry(VaultEntry(id = "", type = "note", title = "", createdAt = 0, modifiedAt = 0))146 }.onSuccess { onOpen(it.id) }147 .onFailure { e -> _form.update { it.copy(error = e.message ?: "could not create note") } }148 }149 }150 fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }151 fun setUsername(v: String) = _form.update { it.copy(username = v) }152 fun setEmail(v: String) = _form.update { it.copy(email = v) }153 fun setPassword(v: String) = _form.update { it.copy(password = v) }154 fun setUrl(v: String) = _form.update { it.copy(url = v) }155 fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->156 form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })157 }158 fun addAdditionalUrl() = _form.update { form ->159 if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form160 else form.copy(additionalUrls = form.additionalUrls + "")161 }162 fun removeAdditionalUrl(index: Int) = _form.update { form ->163 form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })164 }165 fun setTotp(v: String) = _form.update { it.copy(totp = v) }166 fun setNotes(v: String) = _form.update { it.copy(notes = v) }167 fun revealTotp() = _form.update { it.copy(showTotp = true) }168 fun revealNotes() = _form.update { it.copy(showNotes = true) }169 fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }170 fun setCardNumber(v: String) = _form.update {171 val formatted = CardFieldUtils.formatPan(v)172 it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)173 }174 fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }175 fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }176 fun setCvc(v: String) = _form.update {177 it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))178 }179 fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }180181 val allTags: List<String> = vault.getDistinctTags()182 fun addTag(raw: String) = _form.update { f ->183 val tag = raw.trim().lowercase().take(32)184 if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f185 else f.copy(tags = f.tags + tag)186 }187 fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }188189 fun setCanary(enabled: Boolean, webhookUrl: String, label: String) {190 if (enabled && existing?.canary == null && !license.isProActive()) {191 _form.update { it.copy(canaryMessage = "Canary entries require Peach Pro") }192 return193 }194 _form.update { form ->195 form.copy(196 canary = if (enabled) {197 Canary(198 webhookUrl = webhookUrl.trim(),199 label = label.trim().ifBlank { null },200 lastPinged = form.canary?.lastPinged,201 pingCount = form.canary?.pingCount,202 )203 } else {204 null205 },206 canaryMessage = null,207 )208 }209 }210211 fun testCanary(webhookUrl: String) {212 val url = webhookUrl.trim()213 if (!isAllowedCanaryWebhookUrl(url)) {214 _form.update { it.copy(canaryMessage = "enter a public HTTPS webhook URL") }215 return216 }217 viewModelScope.launch {218 _form.update { it.copy(canaryTesting = true, canaryMessage = null) }219 val result = runCatching {220 withContext(Dispatchers.IO) {221 val body = """222 {"event":"peach_canary_test","entry":${canaryJsonString(_form.value.title)},"trigger":"test","timestamp":${canaryJsonString(java.time.Instant.now().toString())},"source":"peach-android"}223 """.trimIndent().toRequestBody("application/json".toMediaType())224 val request = Request.Builder().url(url).post(body).build()225 canaryClient.newCall(request).execute().use { it.isSuccessful }226 }227 }.getOrDefault(false)228 _form.update { it.copy(canaryTesting = false, canaryMessage = if (result) "sent" else "failed - check URL") }229 }230 }231232 fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }233 fun toggleGenerator() = _form.update { form ->234 val opening = !form.generatorOpen235 val next = form.copy(generatorOpen = opening, showHistory = false)236 if (opening && next.password.isBlank()) next.withGeneratedPassword() else next237 }238 fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }239240 241 fun restorePassword(pw: String) =242 _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }243244 245 fun copyGenerated() {246 val pw = _form.value.password247 if (pw.isEmpty()) return248 PeachClipboard.copy(appContext, "peach", pw)249 viewModelScope.launch {250 _form.update { it.copy(genCopied = true) }251 kotlinx.coroutines.delay(1200)252 _form.update { it.copy(genCopied = false) }253 }254 }255256 fun copyHistoryPassword(pw: String) {257 PeachClipboard.copy(appContext, "peach", pw)258 }259 fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 128)).withGeneratedPassword() }260 fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }261 fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }262 fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }263 fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }264265 fun generate() {266 _form.update { it.withGeneratedPassword() }267 }268269 fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }270271 fun save(onDone: () -> Unit) {272 val f = _form.value273 if (f.saving) return274 if (f.title.isBlank()) {275 _form.update { it.copy(error = "title is required") }276 return277 }278 if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {279 _form.update { it.copy(error = "Cardholder name and card number are required") }280 return281 }282 if (f.canary != null) {283 if (existing?.canary == null && !license.isProActive()) {284 _form.update { it.copy(error = "Canary entries require Peach Pro") }285 return286 }287 if (!isAllowedCanaryWebhookUrl(f.canary.webhookUrl)) {288 _form.update { it.copy(error = "enter a public HTTPS webhook URL") }289 return290 }291 }292 _form.update { it.copy(saving = true, error = null) }293 viewModelScope.launch {294 runCatching {295 if (isNew) {296 vault.addEntry(297 VaultEntry(298 id = "", type = f.type, title = f.title.trim(),299 username = f.username.ifBlank { null },300 email = f.email.ifBlank { null },301 password = f.password.ifBlank { null },302 url = f.url.ifBlank { null },303 additionalUrls = f.additionalUrls304 .map { it.trim() }.filter { it.isNotBlank() }305 .ifEmpty { null },306 totp = f.totp.ifBlank { null },307 notes = notesForStorage(f.notes),308 cardNickname = f.cardNickname.ifBlank { null },309 cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },310 cardholderName = f.cardholderName.ifBlank { null },311 expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },312 expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },313 cvc = f.cvc.ifBlank { null },314 cardNetwork = f.cardNetwork.takeIf { it != "unknown" },315 tags = f.tags.ifEmpty { null },316 canary = f.canary,317 createdAt = 0, modifiedAt = 0,318 ),319 )320 } else {321 vault.updateEntry(322 existing!!.copy(323 type = f.type, title = f.title.trim(),324 username = f.username.ifBlank { null },325 email = f.email.ifBlank { null },326 password = f.password.ifBlank { null },327 url = f.url.ifBlank { null },328 additionalUrls = f.additionalUrls329 .map { it.trim() }.filter { it.isNotBlank() }330 .ifEmpty { null },331 totp = f.totp.ifBlank { null },332 notes = notesForStorage(f.notes),333 cardNickname = f.cardNickname.ifBlank { null },334 cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },335 cardholderName = f.cardholderName.ifBlank { null },336 expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },337 expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },338 cvc = f.cvc.ifBlank { null },339 cardNetwork = f.cardNetwork.takeIf { it != "unknown" },340 tags = f.tags.ifEmpty { null },341 canary = f.canary,342 ),343 )344 }345 }.onSuccess { onDone() }346 .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }347 }348 }349}350351private fun canaryJsonString(value: String): String =352 "\"" + value353 .replace("\\", "\\\\")354 .replace("\"", "\\\"")355 .replace("\n", "\\n")356 .replace("\r", "\\r") + "\""357358internal const val BROWSER_ADD_GENERATOR_LENGTH = 64359360private fun EditForm.withGeneratedPassword(): EditForm =361 copy(362 password = generateEditPassword(363 length = genLength,364 upper = genUpper,365 lower = genLower,366 numbers = genNumbers,367 symbols = genSymbols,368 ),369 showPassword = true,370 )371372private fun generateEditPassword(373 length: Int,374 upper: Boolean = true,375 lower: Boolean = true,376 numbers: Boolean = true,377 symbols: Boolean = true,378): String =379 PasswordGenerator.generate(380 PasswordGenerator.Options(381 length = length,382 includeUppercase = upper,383 includeLowercase = lower,384 includeNumbers = numbers,385 includeSymbols = symbols,386 ),387 )388389internal enum class IdentityField { Username, Email }390391data class IdentitySuggestion(val value: String, val count: Int)392393internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {394 return identitySuggestionCounts(entries, field).map { it.value }395}396397internal fun identitySuggestionCounts(398 entries: List<VaultEntry>,399 field: IdentityField,400): List<IdentitySuggestion> {401 val buckets = linkedMapOf<String, Pair<String, Int>>()402403 fun add(value: String) {404 val trimmed = value.trim()405 if (trimmed.isEmpty()) return406 val key = trimmed.lowercase()407 val current = buckets[key]408 buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)409 }410411 entries.filter { it.deletedAt == null }.forEach { entry ->412 listOfNotNull(entry.username, entry.email).forEach { value ->413 val trimmed = value.trim()414 if (trimmed.isNotEmpty() && (trimmed.contains("@") == (field == IdentityField.Email))) add(trimmed)415 }416 }417418 return buckets.values419 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })420 .map { IdentitySuggestion(it.first, it.second) }421 .take(8)422}