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.ui.PeachNav import com.peachpasswords.ui.vault.entryTapDestination import com.peachpasswords.vault.LastViewedEntryStore import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.VaultEntry 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.launch import javax.inject.Inject @HiltViewModel class EntryDetailViewModel @Inject constructor( private val vault: VaultRepository, @ApplicationContext private val appContext: Context, private val lastViewed: LastViewedEntryStore, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val id: String = savedStateHandle.get("id").orEmpty() 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> = vault.entries.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries()) /** Distinct prior username/email values for the identity-suggest dropdown. */ val identitySuggestions: StateFlow> = vault.entries .map { vault.getDistinctFieldValues("username").take(4) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) val allTags: StateFlow> = vault.entries .map { vault.getDistinctTags() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _revealed = MutableStateFlow(false) val revealed: StateFlow = _revealed.asStateFlow() private val _copiedField = MutableStateFlow(null) val copiedField: StateFlow = _copiedField.asStateFlow() /** 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]) } /** * 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, ), ) } } } /** * 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() } } }