aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt344
1 files changed, 273 insertions, 71 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
index a4e52b41..92498624 100644
--- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
@@ -5,14 +5,23 @@
package com.zeapo.pwdstore
import android.app.Application
+import android.content.Context
+import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.preference.PreferenceManager
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.ItemKeyProvider
+import androidx.recyclerview.selection.Selection
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
@@ -22,6 +31,8 @@ import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File
import java.text.Collator
+import java.util.Locale
+import java.util.Stack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -32,8 +43,10 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.yield
+import me.zhanghai.android.fastscroll.PopupTextProvider
private fun File.toPasswordItem(root: File) = if (isFile)
PasswordItem.newPassword(name, this, root)
@@ -89,8 +102,11 @@ private fun PasswordItem.Companion.makeComparator(
.then(compareBy(CaseInsensitiveComparator) { directoryStructure.getUsernameFor(it.file) })
}
+val PasswordItem.stableId: String
+ get() = file.absolutePath
+
enum class FilterMode {
- ListOnly,
+ NoFilter,
StrictDomain,
Fuzzy
}
@@ -100,65 +116,109 @@ enum class SearchMode {
InCurrentDirectoryOnly
}
-private data class SearchAction(
- val currentDir: File,
- val filter: String,
- val filterMode: FilterMode,
- val searchMode: SearchMode,
- val listFilesOnly: Boolean
-)
+enum class ListMode {
+ FilesOnly,
+ DirectoriesOnly,
+ AllEntries
+}
@ExperimentalCoroutinesApi
@FlowPreview
class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) {
- private val root = PasswordRepository.getRepositoryDirectory(application)
+
+ private var _updateCounter = 0
+ private val updateCounter: Int
+ get() = _updateCounter
+
+ private fun forceUpdateOnNextSearchAction() {
+ _updateCounter++
+ }
+
+ private val root
+ get() = PasswordRepository.getRepositoryDirectory(getApplication())
private val settings = PreferenceManager.getDefaultSharedPreferences(getApplication())
- private val showHiddenDirs = settings.getBoolean("show_hidden_folders", false)
- private val searchFromRoot = settings.getBoolean("search_from_root", false)
- private val defaultSearchMode = if (settings.getBoolean("filter_recursively", true)) {
+ private val showHiddenDirs
+ get() = settings.getBoolean("show_hidden_folders", false)
+ private val defaultSearchMode
+ get() = if (settings.getBoolean("filter_recursively", true)) {
SearchMode.RecursivelyInSubdirectories
} else {
SearchMode.InCurrentDirectoryOnly
}
- private val typeSortOrder = PasswordRepository.PasswordSortOrder.getSortOrder(settings)
- private val directoryStructure = AutofillPreferences.directoryStructure(application)
- private val itemComparator = PasswordItem.makeComparator(typeSortOrder, directoryStructure)
+ private val typeSortOrder
+ get() = PasswordRepository.PasswordSortOrder.getSortOrder(settings)
+ private val directoryStructure
+ get() = AutofillPreferences.directoryStructure(getApplication())
+ private val itemComparator
+ get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure)
+
+ private data class SearchAction(
+ val baseDirectory: File,
+ val filter: String,
+ val filterMode: FilterMode,
+ val searchMode: SearchMode,
+ val listMode: ListMode,
+ // This counter can be increased to force a reexecution of the search action even if all
+ // other arguments are left unchanged.
+ val updateCounter: Int
+ )
+
+ private fun makeSearchAction(
+ baseDirectory: File,
+ filter: String,
+ filterMode: FilterMode,
+ searchMode: SearchMode,
+ listMode: ListMode
+ ): SearchAction {
+ return SearchAction(
+ baseDirectory = baseDirectory,
+ filter = filter,
+ filterMode = filterMode,
+ searchMode = searchMode,
+ listMode = listMode,
+ updateCounter = updateCounter
+ )
+ }
+
+ private fun updateSearchAction(action: SearchAction) =
+ action.copy(updateCounter = updateCounter)
private val searchAction = MutableLiveData(
- SearchAction(
- currentDir = root,
+ makeSearchAction(
+ baseDirectory = root,
filter = "",
- filterMode = FilterMode.ListOnly,
+ filterMode = FilterMode.NoFilter,
searchMode = SearchMode.InCurrentDirectoryOnly,
- listFilesOnly = true
+ listMode = ListMode.AllEntries
)
)
- private val searchActionFlow = searchAction.asFlow()
- .map { it.copy(filter = it.filter.trim()) }
- .distinctUntilChanged()
+ private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
- private val passwordItemsFlow = searchActionFlow
+ data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
+
+ private val newResultFlow = searchActionFlow
.mapLatest { searchAction ->
- val dirToSearch =
- if (searchFromRoot && searchAction.filterMode != FilterMode.ListOnly) root else searchAction.currentDir
val listResultFlow = when (searchAction.searchMode) {
- SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(dirToSearch)
- SearchMode.InCurrentDirectoryOnly -> listFiles(dirToSearch)
+ SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory)
+ SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory)
+ }
+ val prefilteredResultFlow = when (searchAction.listMode) {
+ ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
+ ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
+ ListMode.AllEntries -> listResultFlow
}
- val prefilteredResultFlow =
- if (searchAction.listFilesOnly) listResultFlow.filter { it.isFile } else listResultFlow
val filterModeToUse =
- if (searchAction.filter == "") FilterMode.ListOnly else searchAction.filterMode
- when (filterModeToUse) {
- FilterMode.ListOnly -> {
+ if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode
+ val passwordList = when (filterModeToUse) {
+ FilterMode.NoFilter -> {
prefilteredResultFlow
.map { it.toPasswordItem(root) }
.toList()
.sortedWith(itemComparator)
}
FilterMode.StrictDomain -> {
- check(searchAction.listFilesOnly) { "Searches with StrictDomain search mode can only list files" }
+ check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" }
prefilteredResultFlow
.filter { file ->
val toMatch =
@@ -190,41 +250,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
.map { it.second }
}
}
+ SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
}
- val passwordItemsList = passwordItemsFlow.asLiveData(Dispatchers.IO)
-
- fun list(currentDir: File) {
- require(currentDir.isDirectory) { "Can only list files in a directory" }
- searchAction.postValue(
- SearchAction(
- filter = "",
- currentDir = currentDir,
- filterMode = FilterMode.ListOnly,
- searchMode = SearchMode.InCurrentDirectoryOnly,
- listFilesOnly = false
- )
- )
- }
-
- fun search(
- filter: String,
- currentDir: File? = null,
- filterMode: FilterMode = FilterMode.Fuzzy,
- searchMode: SearchMode? = null,
- listFilesOnly: Boolean = false
- ) {
- require(currentDir?.isDirectory != false) { "Can only search in a directory" }
- val action = SearchAction(
- filter = filter.trim(),
- currentDir = currentDir ?: searchAction.value!!.currentDir,
- filterMode = filterMode,
- searchMode = searchMode ?: defaultSearchMode,
- listFilesOnly = listFilesOnly
- )
- searchAction.postValue(action)
- }
-
private fun shouldTake(file: File) = with(file) {
if (isDirectory) {
!isHidden || showHiddenDirs
@@ -247,6 +275,114 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
}
.filter { file -> shouldTake(file) }
}
+
+ private val cachedResult = MutableLiveData<SearchResult>()
+ val searchResult =
+ listOf(newResultFlow, cachedResult.asFlow()).merge().asLiveData(Dispatchers.IO)
+
+ private val _currentDir = MutableLiveData(root)
+ val currentDir = _currentDir as LiveData<File>
+
+ data class NavigationStackEntry(
+ val dir: File,
+ val items: List<PasswordItem>?,
+ val recyclerViewState: Parcelable?
+ )
+
+ private val navigationStack = Stack<NavigationStackEntry>()
+
+ fun navigateTo(
+ newDirectory: File = root,
+ listMode: ListMode = ListMode.AllEntries,
+ recyclerViewState: Parcelable? = null,
+ pushPreviousLocation: Boolean = true
+ ) {
+ require(newDirectory.isDirectory) { "Can only navigate to a directory" }
+ if (pushPreviousLocation) {
+ // We cache the current list entries only if the current list has not been filtered,
+ // otherwise it will be regenerated when moving back.
+ if (searchAction.value?.filterMode == FilterMode.NoFilter) {
+ navigationStack.push(
+ NavigationStackEntry(
+ _currentDir.value!!,
+ searchResult.value?.passwordItems,
+ recyclerViewState
+ )
+ )
+ } else {
+ navigationStack.push(
+ NavigationStackEntry(
+ _currentDir.value!!,
+ null,
+ recyclerViewState
+ )
+ )
+ }
+ }
+ searchAction.postValue(
+ makeSearchAction(
+ filter = "",
+ baseDirectory = newDirectory,
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = listMode
+ )
+ )
+ _currentDir.postValue(newDirectory)
+ }
+
+ val canNavigateBack
+ get() = navigationStack.isNotEmpty()
+
+ /**
+ * Navigate back to the last location on the [navigationStack] using a cached list of entries
+ * if possible.
+ *
+ * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
+ */
+ fun navigateBack(): Parcelable? {
+ if (!canNavigateBack) return null
+ val (oldDir, oldPasswordItems, oldRecyclerViewState) = navigationStack.pop()
+ return if (oldPasswordItems != null) {
+ // We cached the contents of oldDir and restore them directly without file operations.
+ cachedResult.postValue(SearchResult(oldPasswordItems, isFiltered = false))
+ _currentDir.postValue(oldDir)
+ oldRecyclerViewState
+ } else {
+ navigateTo(oldDir, pushPreviousLocation = false)
+ null
+ }
+ }
+
+ fun reset() {
+ navigationStack.clear()
+ forceUpdateOnNextSearchAction()
+ navigateTo(pushPreviousLocation = false)
+ }
+
+ fun search(
+ filter: String,
+ baseDirectory: File? = null,
+ filterMode: FilterMode = FilterMode.Fuzzy,
+ searchMode: SearchMode? = null,
+ listMode: ListMode = ListMode.AllEntries
+ ) {
+ require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
+ searchAction.postValue(
+ makeSearchAction(
+ filter = filter,
+ baseDirectory = baseDirectory ?: _currentDir.value!!,
+ filterMode = filterMode,
+ searchMode = searchMode ?: defaultSearchMode,
+ listMode = listMode
+ )
+ )
+ }
+
+ fun forceRefresh() {
+ forceUpdateOnNextSearchAction()
+ searchAction.postValue(updateSearchAction(searchAction.value!!))
+ }
}
private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
@@ -256,19 +392,85 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>()
override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
}
-class DelegatedSearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
+open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
private val layoutRes: Int,
private val viewHolderCreator: (view: View) -> T,
private val viewHolderBinder: T.(item: PasswordItem) -> Unit
-) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback) {
+) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
+
+ fun <T : ItemDetailsLookup<String>> makeSelectable(
+ recyclerView: RecyclerView,
+ itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T
+ ) {
+ selectionTracker = SelectionTracker.Builder(
+ "SearchableRepositoryAdapter",
+ recyclerView,
+ itemKeyProvider,
+ itemDetailsLookupCreator(recyclerView),
+ StorageStrategy.createStringStorage()
+ ).withSelectionPredicate(SelectionPredicates.createSelectAnything()).build().apply {
+ addObserver(object : SelectionTracker.SelectionObserver<String>() {
+ override fun onSelectionChanged() {
+ this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
+ requireSelectionTracker().selection
+ )
+ }
+ })
+ }
+ }
+
+ private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
+ open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> {
+ check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" }
+ onItemClickedListener = listener
+ return this
+ }
+
+ private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
+ open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> {
+ check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" }
+ onSelectionChangedListener = listener
+ return this
+ }
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
+ private val itemKeyProvider = object : ItemKeyProvider<String>(SCOPE_MAPPED) {
+ override fun getKey(position: Int) = getItem(position).stableId
+
+ override fun getPosition(key: String) =
+ (0 until itemCount).firstOrNull { getItem(it).stableId == key }
+ ?: RecyclerView.NO_POSITION
+ }
+
+ private var selectionTracker: SelectionTracker<String>? = null
+ fun requireSelectionTracker() = selectionTracker!!
+
+ private val selectedFiles
+ get() = requireSelectionTracker().selection.map { File(it) }
+ fun getSelectedItems(context: Context): List<PasswordItem> {
+ val root = PasswordRepository.getRepositoryDirectory(context)
+ return selectedFiles.map { it.toPasswordItem(root) }
+ }
+
+ final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
val view = LayoutInflater.from(parent.context)
.inflate(layoutRes, parent, false)
return viewHolderCreator(view)
}
- override fun onBindViewHolder(holder: T, position: Int) {
- viewHolderBinder.invoke(holder, getItem(position))
+ final override fun onBindViewHolder(holder: T, position: Int) {
+ val item = getItem(position)
+ holder.apply {
+ viewHolderBinder.invoke(this, item)
+ selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) }
+ itemView.setOnClickListener {
+ // Do not emit custom click events while the user is selecting items.
+ if (selectionTracker?.hasSelection() != true)
+ onItemClickedListener?.invoke(holder, item)
+ }
+ }
+ }
+
+ final override fun getPopupText(position: Int): String {
+ return getItem(position).name[0].toString().toUpperCase(Locale.getDefault())
}
}