VaultListViewModel.kt

9.7 KB · raw

1package com.peachpasswords.ui.vault23import android.content.Context4import com.peachpasswords.shared.PeachClipboard5import androidx.lifecycle.ViewModel6import androidx.lifecycle.viewModelScope7import com.peachpasswords.license.PendingProNotice8import com.peachpasswords.license.ProNoticeStore9import com.peachpasswords.vault.TOTPGenerator10import com.peachpasswords.vault.VaultEntry11import com.peachpasswords.vault.VaultLockTimer12import com.peachpasswords.vault.VaultRepository13import com.peachpasswords.vault.isLogin14import com.peachpasswords.vault.isNote15import dagger.hilt.android.lifecycle.HiltViewModel16import dagger.hilt.android.qualifiers.ApplicationContext17import kotlinx.coroutines.flow.MutableStateFlow18import kotlinx.coroutines.flow.SharingStarted19import kotlinx.coroutines.flow.StateFlow20import kotlinx.coroutines.flow.asStateFlow21import kotlinx.coroutines.flow.combine22import kotlinx.coroutines.flow.map23import kotlinx.coroutines.flow.stateIn24import kotlinx.coroutines.launch25import javax.inject.Inject2627enum class SortOrder(val label: String) { SMART("SMART"), AZ("A–Z"), ZA("Z–A"), NEWEST("NEWEST") }2829/** When the NOTES filter pill is active, keep only NOTE-type entries. */30internal fun filterNotesOnly(list: List<VaultEntry>, noteOnly: Boolean): List<VaultEntry> =31    if (noteOnly) list.filter { it.isNote() } else list3233@HiltViewModel34class VaultListViewModel @Inject constructor(35    private val vault: VaultRepository,36    private val proNoticeStore: ProNoticeStore,37    @ApplicationContext private val appContext: Context,38    private val lockTimer: VaultLockTimer,39) : ViewModel() {4041    private val _sort = MutableStateFlow(SortOrder.SMART)42    val sort: StateFlow<SortOrder> = _sort.asStateFlow()4344    private val _search = MutableStateFlow("")45    val search: StateFlow<String> = _search.asStateFlow()4647    private val _tagFilter = MutableStateFlow<String?>(null)48    val activeTagFilter: StateFlow<String?> = _tagFilter.asStateFlow()4950    private val _noteOnly = MutableStateFlow(false)51    val noteOnly: StateFlow<Boolean> = _noteOnly.asStateFlow()5253    private val _flashId = MutableStateFlow<String?>(null)54    val flashId: StateFlow<String?> = _flashId.asStateFlow()5556    val isUnlocked: StateFlow<Boolean> = vault.isUnlocked57    val proNotice: StateFlow<PendingProNotice?> = proNoticeStore.notice5859    /** Live, filtered + sorted active entries. */60    val entries: StateFlow<List<VaultEntry>> =61        combine(vault.entries, _sort, _search, _tagFilter, _noteOnly) { list, sort, query, tag, noteOnly ->62            val tagged = if (tag == null) list63                         else list.filter { e -> e.tags?.any { it.equals(tag, ignoreCase = true) } == true }64            applyFilterSort(filterNotesOnly(tagged, noteOnly), sort, query)65        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())6667    val allTags: StateFlow<List<String>> =68        vault.entries.map { vault.getDistinctTags() }69            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())7071    val totalCount: StateFlow<Int> =72        vault.entries.map { it.size }73            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)7475    fun setSort(order: SortOrder) { _sort.value = order }76    fun setSearch(q: String) { _search.value = q }77    fun setTagFilter(tag: String?) { _tagFilter.value = tag }78    fun toggleNoteFilter() { _noteOnly.value = !_noteOnly.value }7980    /** Lock the vault from the list's lock button; the nav host drops to Lock. */81    fun lock() {82        vault.lock()83        lockTimer.cancel()84    }8586    fun dismissProNotice() {87        proNoticeStore.dismiss()88    }8990    /** Create a blank note and invoke [onOpen] with its id for navigation. */91    fun createNote(onOpen: (String) -> Unit) {92        viewModelScope.launch {93            val created = vault.addEntry(94                VaultEntry(95                    id = "",96                    type = "note",97                    title = "Untitled",98                    notes = null,99                    createdAt = 0L,100                    modifiedAt = 0L,101                ),102            )103            onOpen(created.id)104        }105    }106107    // ── Multi-select ────────────────────────────────────────────────────────108    private val _isMultiSelectMode = MutableStateFlow(false)109    val isMultiSelectMode: StateFlow<Boolean> = _isMultiSelectMode.asStateFlow()110111    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())112    val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()113114    fun enterMultiSelect(id: String) {115        _isMultiSelectMode.value = true116        _selectedIds.value = setOf(id)117    }118119    fun toggleSelected(id: String) {120        val next = _selectedIds.value.toMutableSet()121        if (!next.add(id)) next.remove(id)122        _selectedIds.value = next123    }124125    fun exitMultiSelect() {126        _isMultiSelectMode.value = false127        _selectedIds.value = emptySet()128    }129130    // ── Soft delete + undo ──────────────────────────────────────────────────131    /** One-shot undo event for the snackbar: label + ids to restore. */132    data class UndoEvent(val message: String, val ids: List<String>)133134    private val _undoEvent = MutableStateFlow<UndoEvent?>(null)135    val undoEvent: StateFlow<UndoEvent?> = _undoEvent.asStateFlow()136    fun consumeUndoEvent() { _undoEvent.value = null }137138    fun softDelete(entry: VaultEntry) {139        viewModelScope.launch {140            runCatching { vault.deleteEntry(entry.id) }141            _undoEvent.value = UndoEvent("${entry.title} moved to trash", listOf(entry.id))142        }143    }144145    fun batchSoftDelete() {146        val ids = _selectedIds.value.toList()147        if (ids.isEmpty()) return148        viewModelScope.launch {149            ids.forEach { id -> runCatching { vault.deleteEntry(id) } }150            exitMultiSelect()151            _undoEvent.value = UndoEvent("${ids.size} entries moved to trash", ids)152        }153    }154155    fun restoreEntries(ids: List<String>) {156        viewModelScope.launch { ids.forEach { id -> runCatching { vault.restoreEntry(id) } } }157    }158159    // ── Context-menu copy helpers ───────────────────────────────────────────160    private fun copyText(value: String?) {161        val v = value?.takeIf { it.isNotEmpty() } ?: return162        PeachClipboard.copy(appContext, "peach", v)163    }164165    fun copyUsername(entry: VaultEntry) = copyText(entry.username ?: entry.email)166167    /** Copy the LIVE TOTP code, never the stored seed. */168    fun copyTotp(entry: VaultEntry) {169        val seed = entry.totp ?: return170        copyText(TOTPGenerator.generateCode(seed))171    }172173    /** Copy a login's password, flash the row, and fire the swipe-copy haptic. */174    fun copyPassword(entry: VaultEntry) {175        val pw = entry.password ?: return176        PeachClipboard.copy(appContext, "peach", pw)177        viewModelScope.launch {178            _flashId.value = entry.id179            kotlinx.coroutines.delay(600)180            if (_flashId.value == entry.id) _flashId.value = null181        }182    }183184    private fun applyFilterSort(list: List<VaultEntry>, sort: SortOrder, query: String): List<VaultEntry> =185        filterAndSortVaultEntries(list, sort, query)186187    fun canSwipeCopy(entry: VaultEntry): Boolean = entry.isLogin() && !entry.password.isNullOrEmpty()188189}190191internal fun filterAndSortVaultEntries(192    list: List<VaultEntry>,193    sort: SortOrder,194    query: String,195): List<VaultEntry> {196    val q = query.trim()197    if (q.isNotEmpty()) {198        return list199            .filter { fuzzyMatch(q, vaultEntrySearchText(it)) }200            .sortedWith(searchHitComparator(q))201    }202203    return when (sort) {204        SortOrder.AZ -> list.sortedWith(compareByTitleId())205        SortOrder.ZA -> list.sortedWith(compareByDescending<VaultEntry> { it.title.lowercase() }.thenBy { it.id })206        SortOrder.NEWEST,207        SortOrder.SMART -> list.sortedWith(compareByRecency())208    }209}210211internal fun vaultEntrySearchText(entry: VaultEntry): String {212    if (entry.isNote()) return listOf(entry.title, notePlainText(entry.notes.orEmpty())).joinToString(" ")213    val passkey = entry.passkey214    return listOfNotNull(215        entry.title,216        entry.url,217        entry.additionalUrls?.joinToString(" "),218        entry.email,219        entry.username,220        passkey?.rpId ?: entry.rpId,221        passkey?.rpName ?: entry.rpName,222        passkey?.userName ?: entry.userName,223        passkey?.userDisplayName ?: entry.userDisplayName,224    ).joinToString(" ")225}226227internal fun vaultEntryLastUsedAt(entry: VaultEntry): Long =228    entry.lastUsedAt ?: entry.modifiedAt229230internal fun fuzzyMatch(query: String, target: String): Boolean {231    val q = query.lowercase()232    val t = target.lowercase()233    var qi = 0234    for (ch in t) {235        if (qi < q.length && ch == q[qi]) qi += 1236        if (qi == q.length) return true237    }238    return q.isEmpty()239}240241private fun isExactPrefix(query: String, target: String): Boolean =242    target.lowercase().startsWith(query.lowercase())243244private fun compareByTitleId(): Comparator<VaultEntry> =245    compareBy<VaultEntry> { it.title.lowercase() }.thenBy { it.id }246247private fun compareByRecency(): Comparator<VaultEntry> =248    compareByDescending<VaultEntry> { vaultEntryLastUsedAt(it) }.then(compareByTitleId())249250private fun searchHitComparator(query: String): Comparator<VaultEntry> = Comparator { a, b ->251    val aExact = isExactPrefix(query, vaultEntrySearchText(a))252    val bExact = isExactPrefix(query, vaultEntrySearchText(b))253    when {254        aExact && !bExact -> -1255        !aExact && bExact -> 1256        else -> compareByRecency().compare(a, b)257    }258}