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 genMode: GeneratorMode = GeneratorMode.Password,56 val genLength: Int = BROWSER_ADD_GENERATOR_LENGTH,57 val genWordCount: Int = PasswordGenerator.DEFAULT_PASSPHRASE_WORDS,58 val genSeparator: String = "-",59 val genUpper: Boolean = true,60 val genLower: Boolean = true,61 val genNumbers: Boolean = true,62 val genSymbols: Boolean = true,63 val saving: Boolean = false,64 val error: String? = null,65 val showTotp: Boolean = false,66 val showNotes: Boolean = false,67)6869@HiltViewModel70class EntryEditViewModel @Inject constructor(71 private val vault: VaultRepository,72 @ApplicationContext private val appContext: Context,73 private val license: LicenseRepository,74 savedStateHandle: SavedStateHandle,75) : ViewModel() {76 private val id: String? = savedStateHandle.get<String>("id")?.takeIf { it.isNotBlank() }77 val isNew: Boolean = id == null78 private val existing: VaultEntry? = id?.let { vault.getEntry(it) }79 val isPro: Boolean = license.isProActive()80 private val canaryClient by lazy { OkHttpClient() }8182 83848586 private val notesAreHtml: Boolean = isNoteHtml(existing?.notes.orEmpty())8788 private fun notesForStorage(edited: String): String? =89 noteStorageText(edited, notesAreHtml).ifBlank { null }9091 92 93 94 private val prefillUrl: String? = savedStateHandle.get<String>("prefillUrl")?.takeIf { it.isNotBlank() }95 private val prefillUsername: String? = savedStateHandle.get<String>("prefillUsername")?.takeIf { it.isNotBlank() }96 private val prefillEmail: String? = savedStateHandle.get<String>("prefillEmail")?.takeIf { it.isNotBlank() }9798 private val _form = MutableStateFlow(99 existing?.let {100 EditForm(101 type = it.type ?: "login",102 title = it.title,103 username = it.username.orEmpty(),104 email = it.email.orEmpty(),105 password = it.password.orEmpty(),106 url = it.url.orEmpty(),107 additionalUrls = it.additionalUrls.orEmpty(),108 totp = it.totp.orEmpty(),109 notes = noteEditorText(it.notes.orEmpty()),110 cardNickname = it.cardNickname.orEmpty(),111 cardNumber = CardFieldUtils.formatPan(it.cardNumber.orEmpty()),112 cardholderName = it.cardholderName.orEmpty(),113 expiry = CardFieldUtils.displayExpiry(it.expMonth, it.expYear),114 cvc = it.cvc.orEmpty(),115 cardNetwork = it.cardNetwork ?: CardFieldUtils.detectNetwork(it.cardNumber.orEmpty()),116 tags = it.tags.orEmpty(),117 canary = it.canary,118 history = it.passwordHistory.orEmpty(),119 showTotp = !it.totp.isNullOrBlank(),120 showNotes = !it.notes.isNullOrBlank(),121 )122 } ?: EditForm(123 password = generateEditPassword(BROWSER_ADD_GENERATOR_LENGTH),124 url = prefillUrl.orEmpty(),125 username = prefillUsername.orEmpty(),126 email = prefillEmail.orEmpty(),127 ),128 )129 val form: StateFlow<EditForm> = _form.asStateFlow()130131 132 val usernameSuggestions: StateFlow<List<String>> =133 MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Username)).asStateFlow()134135 val emailSuggestions: StateFlow<List<String>> =136 MutableStateFlow(identitySuggestionsFor(vault.getActiveEntries(), IdentityField.Email)).asStateFlow()137138 fun setType(t: String) = _form.update { it.copy(type = t) }139140 fun setTitle(v: String) = _form.update { it.copy(title = v, error = null) }141 fun setUsername(v: String) = _form.update { it.copy(username = v) }142 fun setEmail(v: String) = _form.update { it.copy(email = v) }143 fun setPassword(v: String) = _form.update { it.copy(password = v) }144 fun setUrl(v: String) = _form.update { it.copy(url = v) }145 fun setAdditionalUrl(index: Int, v: String) = _form.update { form ->146 form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it[index] = v })147 }148 fun addAdditionalUrl() = _form.update { form ->149 if (form.additionalUrls.size >= MAX_ADDITIONAL_URLS) form150 else form.copy(additionalUrls = form.additionalUrls + "")151 }152 fun removeAdditionalUrl(index: Int) = _form.update { form ->153 form.copy(additionalUrls = form.additionalUrls.toMutableList().also { it.removeAt(index) })154 }155 fun setTotp(v: String) = _form.update { it.copy(totp = v) }156 fun setNotes(v: String) = _form.update { it.copy(notes = v) }157 fun revealTotp() = _form.update { it.copy(showTotp = true) }158 fun revealNotes() = _form.update { it.copy(showNotes = true) }159 fun setCardNickname(v: String) = _form.update { it.copy(cardNickname = v, error = null) }160 fun setCardNumber(v: String) = _form.update {161 val formatted = CardFieldUtils.formatPan(v)162 it.copy(cardNumber = formatted, cardNetwork = CardFieldUtils.detectNetwork(formatted), error = null)163 }164 fun setCardholderName(v: String) = _form.update { it.copy(cardholderName = v, error = null) }165 fun setExpiry(v: String) = _form.update { it.copy(expiry = CardFieldUtils.formatExpiry(v), error = null) }166 fun setCvc(v: String) = _form.update {167 it.copy(cvc = v.filter { c -> c.isDigit() }.take(CardFieldUtils.cvcMaxDigits(it.cardNetwork)))168 }169 fun toggleShowCvc() = _form.update { it.copy(showCvc = !it.showCvc) }170171 val allTags: List<String> = vault.getDistinctTags()172 fun addTag(raw: String) = _form.update { f ->173 val tag = raw.trim().lowercase().take(32)174 if (tag.isEmpty() || f.tags.size >= 10 || f.tags.any { it == tag }) f175 else f.copy(tags = f.tags + tag)176 }177 fun removeTag(tag: String) = _form.update { it.copy(tags = it.tags - tag) }178179 fun setCanary(enabled: Boolean, webhookUrl: String, label: String) {180 if (enabled && existing?.canary == null && !license.isProActive()) {181 _form.update { it.copy(canaryMessage = "Canary entries require Peach Pro") }182 return183 }184 _form.update { form ->185 form.copy(186 canary = if (enabled) {187 Canary(188 webhookUrl = webhookUrl.trim(),189 label = label.trim().ifBlank { null },190 lastPinged = form.canary?.lastPinged,191 pingCount = form.canary?.pingCount,192 )193 } else {194 null195 },196 canaryMessage = null,197 )198 }199 }200201 fun testCanary(webhookUrl: String) {202 val url = webhookUrl.trim()203 if (!isAllowedCanaryWebhookUrl(url)) {204 _form.update { it.copy(canaryMessage = "enter a public HTTPS webhook URL") }205 return206 }207 viewModelScope.launch {208 _form.update { it.copy(canaryTesting = true, canaryMessage = null) }209 val result = runCatching {210 withContext(Dispatchers.IO) {211 val body = """212 {"event":"peach_canary_test","entry":${canaryJsonString(_form.value.title)},"trigger":"test","timestamp":${canaryJsonString(java.time.Instant.now().toString())},"source":"peach-android"}213 """.trimIndent().toRequestBody("application/json".toMediaType())214 val request = Request.Builder().url(url).post(body).build()215 canaryClient.newCall(request).execute().use { it.isSuccessful }216 }217 }.getOrDefault(false)218 _form.update { it.copy(canaryTesting = false, canaryMessage = if (result) "sent" else "failed - check URL") }219 }220 }221222 fun toggleShowPassword() = _form.update { it.copy(showPassword = !it.showPassword) }223 fun toggleGenerator() = _form.update { form ->224 val opening = !form.generatorOpen225 val next = form.copy(generatorOpen = opening, showHistory = false)226 if (opening && next.password.isBlank()) next.withGeneratedPassword() else next227 }228 fun toggleHistory() = _form.update { it.copy(showHistory = !it.showHistory, generatorOpen = false) }229230 231 fun restorePassword(pw: String) =232 _form.update { it.copy(password = pw, showPassword = true, showHistory = false) }233234 235 fun copyGenerated() {236 val pw = _form.value.password237 if (pw.isEmpty()) return238 PeachClipboard.copy(appContext, "peach", pw)239 viewModelScope.launch {240 _form.update { it.copy(genCopied = true) }241 kotlinx.coroutines.delay(1200)242 _form.update { it.copy(genCopied = false) }243 }244 }245246 fun copyHistoryPassword(pw: String) {247 PeachClipboard.copy(appContext, "peach", pw)248 }249 fun setGenMode(mode: GeneratorMode) = _form.update { it.copy(genMode = mode).withGeneratedPassword() }250 fun setGenLength(n: Int) = _form.update { it.copy(genLength = n.coerceIn(8, 256)).withGeneratedPassword() }251 fun setGenWordCount(n: Int) = _form.update {252 it.copy(genWordCount = n.coerceIn(PasswordGenerator.MIN_PASSPHRASE_WORDS, PasswordGenerator.MAX_PASSPHRASE_WORDS))253 .withGeneratedPassword()254 }255 fun setGenSeparator(separator: String) = _form.update { it.copy(genSeparator = separator).withGeneratedPassword() }256 fun setGenUpper(b: Boolean) = _form.update { it.copy(genUpper = b).withGeneratedPassword() }257 fun setGenLower(b: Boolean) = _form.update { it.copy(genLower = b).withGeneratedPassword() }258 fun setGenNumbers(b: Boolean) = _form.update { it.copy(genNumbers = b).withGeneratedPassword() }259 fun setGenSymbols(b: Boolean) = _form.update { it.copy(genSymbols = b).withGeneratedPassword() }260261 fun generate() {262 _form.update { it.withGeneratedPassword() }263 }264265 fun useGeneratedPassword() = _form.update { it.copy(generatorOpen = false, showPassword = false) }266267 fun save(onDone: () -> Unit) {268 val f = _form.value269 if (f.saving) return270 if (f.title.isBlank()) {271 _form.update { it.copy(error = "title is required") }272 return273 }274 if (f.type == "card" && (f.cardNumber.isBlank() || f.cardholderName.isBlank())) {275 _form.update { it.copy(error = "Cardholder name and card number are required") }276 return277 }278 if (f.canary != null) {279 if (existing?.canary == null && !license.isProActive()) {280 _form.update { it.copy(error = "Canary entries require Peach Pro") }281 return282 }283 if (!isAllowedCanaryWebhookUrl(f.canary.webhookUrl)) {284 _form.update { it.copy(error = "enter a public HTTPS webhook URL") }285 return286 }287 }288 _form.update { it.copy(saving = true, error = null) }289 viewModelScope.launch {290 runCatching {291 if (isNew) {292 vault.addEntry(293 VaultEntry(294 id = "", type = f.type, title = f.title.trim(),295 username = f.username.ifBlank { null },296 email = f.email.ifBlank { null },297 password = f.password.ifBlank { null },298 url = f.url.ifBlank { null },299 additionalUrls = f.additionalUrls300 .map { it.trim() }.filter { it.isNotBlank() }301 .ifEmpty { null },302 totp = f.totp.ifBlank { null },303 notes = notesForStorage(f.notes),304 cardNickname = f.cardNickname.ifBlank { null },305 cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },306 cardholderName = f.cardholderName.ifBlank { null },307 expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },308 expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },309 cvc = f.cvc.ifBlank { null },310 cardNetwork = f.cardNetwork.takeIf { it != "unknown" },311 tags = f.tags.ifEmpty { null },312 canary = f.canary,313 createdAt = 0, modifiedAt = 0,314 ),315 )316 } else {317 vault.updateEntry(318 existing!!.copy(319 type = f.type, title = f.title.trim(),320 username = f.username.ifBlank { null },321 email = f.email.ifBlank { null },322 password = f.password.ifBlank { null },323 url = f.url.ifBlank { null },324 additionalUrls = f.additionalUrls325 .map { it.trim() }.filter { it.isNotBlank() }326 .ifEmpty { null },327 totp = f.totp.ifBlank { null },328 notes = notesForStorage(f.notes),329 cardNickname = f.cardNickname.ifBlank { null },330 cardNumber = f.cardNumber.filter { it.isDigit() }.ifBlank { null },331 cardholderName = f.cardholderName.ifBlank { null },332 expMonth = f.expiry.filter { it.isDigit() }.take(2).ifBlank { null },333 expYear = f.expiry.filter { it.isDigit() }.drop(2).take(4).ifBlank { null },334 cvc = f.cvc.ifBlank { null },335 cardNetwork = f.cardNetwork.takeIf { it != "unknown" },336 tags = f.tags.ifEmpty { null },337 canary = f.canary,338 ),339 )340 }341 }.onSuccess { onDone() }342 .onFailure { e -> _form.update { it.copy(saving = false, error = e.message ?: "save failed") } }343 }344 }345}346347private fun canaryJsonString(value: String): String =348 "\"" + value349 .replace("\\", "\\\\")350 .replace("\"", "\\\"")351 .replace("\n", "\\n")352 .replace("\r", "\\r") + "\""353354internal const val BROWSER_ADD_GENERATOR_LENGTH = 64355enum class GeneratorMode { Password, Passphrase }356357private fun EditForm.withGeneratedPassword(): EditForm =358 copy(359 password = generateEditCredential(this),360 showPassword = true,361 )362363internal fun generateEditCredential(form: EditForm): String = when (form.genMode) {364 GeneratorMode.Password -> generateEditPassword(365 length = form.genLength,366 upper = form.genUpper,367 lower = form.genLower,368 numbers = form.genNumbers,369 symbols = form.genSymbols,370 )371 GeneratorMode.Passphrase -> PasswordGenerator.generatePassphrase(372 wordCount = form.genWordCount,373 separator = form.genSeparator,374 )375}376377private fun generateEditPassword(378 length: Int,379 upper: Boolean = true,380 lower: Boolean = true,381 numbers: Boolean = true,382 symbols: Boolean = true,383): String =384 PasswordGenerator.generate(385 PasswordGenerator.Options(386 length = length,387 includeUppercase = upper,388 includeLowercase = lower,389 includeNumbers = numbers,390 includeSymbols = symbols,391 ),392 )393394internal enum class IdentityField { Username, Email }395396data class IdentitySuggestion(val value: String, val count: Int)397398internal fun identitySuggestionsFor(entries: List<VaultEntry>, field: IdentityField): List<String> {399 return identitySuggestionCounts(entries, field).map { it.value }400}401402internal fun identitySuggestionCounts(403 entries: List<VaultEntry>,404 field: IdentityField,405): List<IdentitySuggestion> {406 val buckets = linkedMapOf<String, Pair<String, Int>>()407408 fun add(value: String) {409 val trimmed = value.trim()410 if (trimmed.isEmpty()) return411 val key = trimmed.lowercase()412 val current = buckets[key]413 buckets[key] = (current?.first ?: trimmed) to ((current?.second ?: 0) + 1)414 }415416 entries.filter { it.deletedAt == null }.forEach { entry ->417 listOfNotNull(entry.username, entry.email).forEach { value ->418 val trimmed = value.trim()419 if (trimmed.isNotEmpty() && (trimmed.contains("@") == (field == IdentityField.Email))) add(trimmed)420 }421 }422423 return buckets.values424 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })425 .map { IdentitySuggestion(it.first, it.second) }426 .take(8)427}