EntryDetailViewModel.kt

8.8 KB · raw

1package com.peachpasswords.ui.vault23import android.content.Context4import com.peachpasswords.shared.PeachClipboard5import androidx.lifecycle.SavedStateHandle6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.ui.PeachNav9import com.peachpasswords.ui.vault.entryTapDestination10import com.peachpasswords.vault.LastViewedEntryStore11import com.peachpasswords.vault.TOTPGenerator12import 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.SharingStarted18import kotlinx.coroutines.flow.StateFlow19import kotlinx.coroutines.flow.asStateFlow20import kotlinx.coroutines.flow.map21import kotlinx.coroutines.flow.stateIn22import kotlinx.coroutines.launch23import javax.inject.Inject2425@HiltViewModel26class EntryDetailViewModel @Inject constructor(27    private val vault: VaultRepository,28    @ApplicationContext private val appContext: Context,29    private val lastViewed: LastViewedEntryStore,30    savedStateHandle: SavedStateHandle,31) : ViewModel() {32    private val id: String = savedStateHandle.get<String>("id").orEmpty()3334    init {35        // Remember this as the entry to reopen on the next unlock (brief Fix 3).36        // Only record a real, live entry so a bad deep-link id never becomes the target.37        if (id.isNotBlank() && vault.getEntry(id) != null) lastViewed.set(id)38    }3940    val entry: StateFlow<VaultEntry?> =41        vault.entries.map { list -> list.firstOrNull { it.id == id } }42            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getEntry(id))4344    val activeEntries: StateFlow<List<VaultEntry>> =45        vault.entries.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), vault.getActiveEntries())4647    /** Distinct prior username/email values for the identity-suggest dropdown. */48    val identitySuggestions: StateFlow<List<String>> =49        vault.entries50            .map { vault.getDistinctFieldValues("username").take(4) }51            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())5253    val allTags: StateFlow<List<String>> =54        vault.entries55            .map { vault.getDistinctTags() }56            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())5758    private val _revealed = MutableStateFlow(false)59    val revealed: StateFlow<Boolean> = _revealed.asStateFlow()6061    private val _copiedField = MutableStateFlow<String?>(null)62    val copiedField: StateFlow<String?> = _copiedField.asStateFlow()6364    /** Reveal the password and auto-mask after 10 seconds (matches the browser). */65    fun reveal() {66        _revealed.value = true67        viewModelScope.launch {68            kotlinx.coroutines.delay(10_000)69            _revealed.value = false70        }71    }7273    fun mask() { _revealed.value = false }7475    private val _cvcRevealed = MutableStateFlow(false)76    val cvcRevealed: StateFlow<Boolean> = _cvcRevealed.asStateFlow()7778    /** Reveal the CVC and auto-mask after 10s — same policy as the password reveal. */79    fun revealCvc() {80        _cvcRevealed.value = true81        viewModelScope.launch {82            kotlinx.coroutines.delay(10_000)83            _cvcRevealed.value = false84        }85    }8687    fun maskCvc() { _cvcRevealed.value = false }8889    fun copy(field: String, value: String?) {90        val v = value ?: return91        PeachClipboard.copy(appContext, "peach", v)92        viewModelScope.launch {93            _copiedField.value = field94            kotlinx.coroutines.delay(1200)95            if (_copiedField.value == field) _copiedField.value = null96        }97    }9899    /** Copy the live TOTP code (never the seed). */100    fun copyTotp() {101        val totp = entry.value?.totp ?: return102        copy("totp", TOTPGenerator.generateCode(totp))103    }104105    fun adjacentDestination(offset: Int): PeachNav? {106        val list = activeEntries.value107        if (list.isEmpty()) return null108        val currentIndex = list.indexOfFirst { it.id == id }109        if (currentIndex < 0) return null110        val nextIndex = (currentIndex + offset + list.size) % list.size111        return entryTapDestination(list[nextIndex])112    }113114    /**115     * Apply a generated password via updateEntry — VaultRepository captures the116     * prior password into history automatically, so we only pass the new value.117     */118    fun applyGeneratedPassword(newPassword: String, onDone: () -> Unit = {}) {119        val current = entry.value ?: return120        if (newPassword.isEmpty() || newPassword == current.password) { onDone(); return }121        viewModelScope.launch {122            runCatching { vault.updateEntry(current.copy(password = newPassword)) }123            onDone()124        }125    }126127    /**128     * Persist a single field's inline edit via a full-entry updateEntry patch129     * (which handles password-history capture + timestamps). No-op if entry gone.130     */131    fun updateField(field: String, value: String) {132        val current = entry.value ?: return133        val patch = when (field) {134            "title" -> current.copy(title = value.trim().ifBlank { current.title })135            "url" -> current.copy(url = value.ifBlank { null })136            "username" -> current.copy(username = value.ifBlank { null })137            "email" -> current.copy(email = value.ifBlank { null })138            "password" -> current.copy(password = value.ifBlank { null })139            "notes" -> current.copy(notes = value.ifBlank { null })140            "totp" -> current.copy(totp = value.ifBlank { null })141            else -> return142        }143        viewModelScope.launch {144            runCatching { vault.updateEntry(patch) }145        }146    }147148    fun updateAdditionalUrl(index: Int, value: String) {149        val current = entry.value ?: return150        val urls = current.additionalUrls.orEmpty().toMutableList()151        if (index !in urls.indices) return152        urls[index] = value153        val patch = current.copy(154            additionalUrls = urls.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null },155        )156        viewModelScope.launch {157            runCatching { vault.updateEntry(patch) }158        }159    }160161    fun addAdditionalUrl(value: String) {162        val current = entry.value ?: return163        val next = (current.additionalUrls.orEmpty() + value)164            .map { it.trim() }165            .filter { it.isNotBlank() }166            .distinct()167            .ifEmpty { null }168        viewModelScope.launch {169            runCatching { vault.updateEntry(current.copy(additionalUrls = next)) }170        }171    }172173    fun addTag(raw: String) {174        val current = entry.value ?: return175        val tag = normalizeTag(raw)176        if (tag.isEmpty()) return177        val next = (current.tags.orEmpty() + tag).distinct().take(10)178        viewModelScope.launch {179            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }180        }181    }182183    fun removeTag(tag: String) {184        val current = entry.value ?: return185        val next = current.tags.orEmpty().filterNot { it == tag }186        viewModelScope.launch {187            runCatching { vault.updateEntry(current.copy(tags = next.ifEmpty { null })) }188        }189    }190191    fun updateCard(cardNickname: String, cardNumber: String, cardholderName: String, expiry: String, cvc: String) {192        val current = entry.value ?: return193        val digits = cardNumber.filter { it.isDigit() }194        val expDigits = expiry.filter { it.isDigit() }195        val network = CardFieldUtils.detectNetwork(digits).takeIf { it != "unknown" }196        viewModelScope.launch {197            runCatching {198                vault.updateEntry(199                    current.copy(200                        cardNickname = cardNickname.ifBlank { null },201                        cardNumber = digits.ifBlank { null },202                        cardholderName = cardholderName.ifBlank { null },203                        expMonth = expDigits.take(2).ifBlank { null },204                        expYear = expDigits.drop(2).take(2).ifBlank { null },205                        cvc = cvc.ifBlank { null },206                        cardNetwork = network,207                    ),208                )209            }210        }211    }212213    /**214     * Restore a past password as the current one. updateEntry captures the215     * now-outgoing password into history, so this is a normal password swap.216     */217    fun restorePassword(entryId: String, oldPassword: String) {218        viewModelScope.launch {219            val current = vault.getEntry(entryId) ?: return@launch220            if (current.password == oldPassword) return@launch221            runCatching { vault.updateEntry(current.copy(password = oldPassword)) }222        }223    }224225    fun delete(onDone: () -> Unit) {226        viewModelScope.launch {227            runCatching { vault.deleteEntry(id) }228            onDone()229        }230    }231}