package com.peachpasswords.ui.vault import android.content.Context import com.peachpasswords.shared.PeachClipboard import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.license.LicenseRepository import com.peachpasswords.ui.PeachNav import com.peachpasswords.ui.vault.entryTapDestination import com.peachpasswords.vault.Canary import com.peachpasswords.vault.LastViewedEntryStore import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultLockTimer import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject @HiltViewModel class EntryDetailViewModel @Inject constructor( private val vault: VaultRepository, @ApplicationContext private val appContext: Context, private val lastViewed: LastViewedEntryStore, private val license: LicenseRepository, private val lockTimer: VaultLockTimer, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val id: String = savedStateHandle.get("id").orEmpty() /** Lock the vault directly from the entry-deck landing surface (C1: this * screen has no "back" when it's the post-unlock root, so it needs its * own lock affordance rather than relying on a parent screen's). */ fun lock() { lockTimer.cancel() vault.lock() } init { // Remember this as the entry to reopen on the next unlock (brief Fix 3). // Only record a real, live entry so a bad deep-link id never becomes the target. if (id.isNotBlank() && vault.getEntry(id) != null) lastViewed.set(id) } val entry: StateFlow = vault.entries.map { list -> list.firstOrNull { it.id == id } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id)) val activeEntries: StateFlow> = // Notes are not a destination of the Entry view (NotesMode is the // notes surface). Filtering once here means the prev/next buttons, // the index lookup and the initial entry picker all share the same // view of "what is navigable" — mirroring the browser's // `nonNoteEntries` memo so a user can never get stuck inside a note. vault.entries .map { it.filter(::isNavigable) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries().filter(::isNavigable)) /** Prior identities, classified by @ and ranked by frequency for inline editing. */ val usernameSuggestions: StateFlow> = vault.entries .map { identitySuggestionCounts(it, IdentityField.Username) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val emailSuggestions: StateFlow> = vault.entries .map { identitySuggestionCounts(it, IdentityField.Email) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val allTags: StateFlow> = vault.entries .map { vault.getDistinctTags() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _isPro = MutableStateFlow(license.isProActive()) val isPro: StateFlow = _isPro.asStateFlow() private val _revealed = MutableStateFlow(false) val revealed: StateFlow = _revealed.asStateFlow() private val _copiedField = MutableStateFlow(null) val copiedField: StateFlow = _copiedField.asStateFlow() private val _canaryMessage = MutableStateFlow(null) val canaryMessage: StateFlow = _canaryMessage.asStateFlow() private val _canaryTesting = MutableStateFlow(false) val canaryTesting: StateFlow = _canaryTesting.asStateFlow() private val canaryClient by lazy { OkHttpClient() } /** Reveal the password and auto-mask after 10 seconds (matches the browser). */ fun reveal() { _revealed.value = true viewModelScope.launch { kotlinx.coroutines.delay(10_000) _revealed.value = false } } fun mask() { _revealed.value = false } private val _cvcRevealed = MutableStateFlow(false) val cvcRevealed: StateFlow = _cvcRevealed.asStateFlow() /** Reveal the CVC and auto-mask after 10s — same policy as the password reveal. */ fun revealCvc() { _cvcRevealed.value = true viewModelScope.launch { kotlinx.coroutines.delay(10_000) _cvcRevealed.value = false } } fun maskCvc() { _cvcRevealed.value = false } fun copy(field: String, value: String?) { val v = value ?: return PeachClipboard.copy(appContext, "peach", v) viewModelScope.launch { _copiedField.value = field kotlinx.coroutines.delay(1200) if (_copiedField.value == field) _copiedField.value = null } } /** Copy the live TOTP code (never the seed). */ fun copyTotp() { val totp = entry.value?.totp ?: return copy("totp", TOTPGenerator.generateCode(totp)) } fun adjacentDestination(offset: Int): PeachNav? { val list = activeEntries.value if (list.isEmpty()) return null val currentIndex = list.indexOfFirst { it.id == id } if (currentIndex < 0) return null val nextIndex = (currentIndex + offset + list.size) % list.size return entryTapDestination(list[nextIndex]) } private fun isNavigable(entry: VaultEntry): Boolean = entry.deletedAt == null && entry.type != "note" /** * Apply a generated password via updateEntry — VaultRepository captures the * prior password into history automatically, so we only pass the new value. */ fun applyGeneratedPassword(newPassword: String, onDone: () -> Unit = {}) { val current = entry.value ?: return if (newPassword.isEmpty() || newPassword == current.password) { onDone(); return } viewModelScope.launch { runCatching { vault.updateEntry(current.copy(password = newPassword)) } onDone() } } /** * Persist a single field's inline edit via a full-entry updateEntry patch * (which handles password-history capture + timestamps). No-op if entry gone. */ fun updateField(field: String, value: String) { val current = entry.value ?: return val patch = when (field) { "title" -> current.copy(title = value.trim().ifBlank { current.title }) "url" -> current.copy(url = value.ifBlank { null }) "username" -> current.copy(username = value.ifBlank { null }) "email" -> current.copy(email = value.ifBlank { null }) "password" -> current.copy(password = value.ifBlank { null }) "notes" -> current.copy(notes = value.ifBlank { null }) "totp" -> current.copy(totp = value.ifBlank { null }) else -> return } viewModelScope.launch { runCatching { vault.updateEntry(patch) } } } fun updateAdditionalUrl(index: Int, value: String) { val current = entry.value ?: return val urls = current.additionalUrls.orEmpty().toMutableList() if (index !in urls.indices) return urls[index] = value val patch = current.copy( additionalUrls = urls.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null }, ) viewModelScope.launch { runCatching { vault.updateEntry(patch) } } } fun addAdditionalUrl(value: String) { val current = entry.value ?: return val next = (current.additionalUrls.orEmpty() + value) .map { it.trim() } .filter { it.isNotBlank() } .distinct() .ifEmpty { null } viewModelScope.launch { runCatching { vault.updateEntry(current.copy(additionalUrls = next)) } } } fun addTag(raw: String) { val current = entry.value ?: return val tag = normalizeTag(raw) if (tag.isEmpty()) return val next = (current.tags.orEmpty() + tag).distinct().take(10) viewModelScope.launch { runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) } } } fun removeTag(tag: String) { val current = entry.value ?: return val next = current.tags.orEmpty().filterNot { it == tag } viewModelScope.launch { runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) } } } fun updateCard(cardNickname: String, cardNumber: String, cardholderName: String, expiry: String, cvc: String) { val current = entry.value ?: return val digits = cardNumber.filter { it.isDigit() } val expDigits = expiry.filter { it.isDigit() } val network = CardFieldUtils.detectNetwork(digits).takeIf { it != "unknown" } viewModelScope.launch { runCatching { vault.updateEntry( current.copy( cardNickname = cardNickname.ifBlank { null }, cardNumber = digits.ifBlank { null }, cardholderName = cardholderName.ifBlank { null }, expMonth = expDigits.take(2).ifBlank { null }, expYear = expDigits.drop(2).take(4).ifBlank { null }, cvc = cvc.ifBlank { null }, cardNetwork = network, ), ) } } } fun saveCanary(enabled: Boolean, webhookUrl: String, label: String) { val current = entry.value ?: return val url = webhookUrl.trim() val nextLabel = label.trim().ifBlank { null } val nextCanary = if (enabled) { if (current.canary == null && !license.isProActive()) { _canaryMessage.value = "Canary entries require Peach Pro" return } if (!isAllowedCanaryWebhookUrl(url)) { _canaryMessage.value = "enter a public HTTPS webhook URL" return } Canary( webhookUrl = url, label = nextLabel, lastPinged = current.canary?.lastPinged, pingCount = current.canary?.pingCount, ) } else { null } viewModelScope.launch { runCatching { vault.updateEntry(current.copy(canary = nextCanary)) } .onSuccess { _canaryMessage.value = if (nextCanary == null) "canary disabled" else "canary saved" } .onFailure { _canaryMessage.value = it.message ?: "could not save canary" } } } fun testCanary(webhookUrl: String) { val current = entry.value ?: return val url = webhookUrl.trim() if (!isAllowedCanaryWebhookUrl(url)) { _canaryMessage.value = "enter a public HTTPS webhook URL" return } viewModelScope.launch { _canaryTesting.value = true _canaryMessage.value = null val result = runCatching { withContext(Dispatchers.IO) { val body = """ {"event":"peach_canary_test","entry":${jsonString(current.title)},"trigger":"test","timestamp":${jsonString(java.time.Instant.now().toString())},"source":"peach-android"} """.trimIndent().toRequestBody("application/json".toMediaType()) val request = Request.Builder().url(url).post(body).build() canaryClient.newCall(request).execute().use { it.isSuccessful } } }.getOrDefault(false) _canaryMessage.value = if (result) "sent" else "failed - check URL" _canaryTesting.value = false } } /** * Restore a past password as the current one. updateEntry captures the * now-outgoing password into history, so this is a normal password swap. */ fun restorePassword(entryId: String, oldPassword: String) { viewModelScope.launch { val current = vault.getEntry(entryId) ?: return@launch if (current.password == oldPassword) return@launch runCatching { vault.updateEntry(current.copy(password = oldPassword)) } } } fun delete(onDone: () -> Unit) { viewModelScope.launch { runCatching { vault.deleteEntry(id) } onDone() } } } internal fun isAllowedCanaryWebhookUrl(value: String): Boolean { return try { val uri = java.net.URI(value) val host = uri.host?.lowercase() ?: return false if (uri.scheme != "https") return false if (host == "localhost" || host.endsWith(".localhost")) return false if (isPrivateIpv4(host) || isPrivateIpv6(host)) return false true } catch (_: Exception) { false } } private fun isPrivateIpv4(host: String): Boolean { val parts = host.split(".") if (parts.size != 4) return false val octets = parts.map { it.toIntOrNull() ?: return false } if (octets.any { it !in 0..255 }) return false return octets[0] == 10 || (octets[0] == 172 && octets[1] in 16..31) || (octets[0] == 192 && octets[1] == 168) || octets[0] == 127 || (octets[0] == 169 && octets[1] == 254) } private fun isPrivateIpv6(host: String): Boolean { val normalized = host.trim('[', ']').lowercase() return normalized == "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") } private fun jsonString(value: String): String = "\"" + value .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") + "\""