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") }282930internal 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 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 81 fun lock() {82 vault.lock()83 lockTimer.cancel()84 }8586 fun dismissProNotice() {87 proNoticeStore.dismiss()88 }8990 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 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 131 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 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 168 fun copyTotp(entry: VaultEntry) {169 val seed = entry.totp ?: return170 copyText(TOTPGenerator.generateCode(seed))171 }172173 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}