From 575ef8472654f49dd60f0e6909164dd8dbd098fb Mon Sep 17 00:00:00 2001 From: Fabian Henneke Date: Fri, 10 Apr 2020 13:18:42 +0200 Subject: Modernize legacy RecyclerView adapters (#694) * Modernize legacy RecyclerView adapters Introduces new adapters based on the SearchableRepositoryViewModel and using androidx.recyclerview.selection for multiselection support. The following positive effects in behavior are observable to end-users: - Search and navigation actions are executed on IO threads. - RecyclerViews are now animated during searches (but not navigations). - Exact scroll position is restored when navigating back. - The ActionBar title is updated with the current folder name. The following negative effects may warrant attention: - Support for the "always search from root" setting has been removed. - Due to a limitation of the fast scroll dependency, using the scroller may result in unwanted multiselections. If this is not fixed in the library, native fast scroller capabilities could be used, but these are more limited in appearance and to not offer popups. * Fix lint * Fix FastScroller/SelectionTracker incompatibility * Immediately react to settings changes * List directory entries when search term is blank * Use isEmpty() instead of == "" * Replace adapter inheritance with builders and fix selection drags * Remove dividers in password lists * Run spotlessApply * Use a more logical string in action mode * Commonize and constify path bundle key * Make lambda parameter name explicit Signed-off-by: Harsh Shandilya Co-authored-by: Harsh Shandilya --- .../pwdstore/SearchableRepositoryViewModel.kt | 344 ++++++++++++++++----- 1 file changed, 273 insertions(+), 71 deletions(-) (limited to 'app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt') 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, 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() + val searchResult = + listOf(newResultFlow, cachedResult.asFlow()).merge().asLiveData(Dispatchers.IO) + + private val _currentDir = MutableLiveData(root) + val currentDir = _currentDir as LiveData + + data class NavigationStackEntry( + val dir: File, + val items: List?, + val recyclerViewState: Parcelable? + ) + + private val navigationStack = Stack() + + 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() { @@ -256,19 +392,85 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback() override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem } -class DelegatedSearchableRepositoryAdapter( +open class SearchableRepositoryAdapter( private val layoutRes: Int, private val viewHolderCreator: (view: View) -> T, private val viewHolderBinder: T.(item: PasswordItem) -> Unit -) : ListAdapter(PasswordItemDiffCallback) { +) : ListAdapter(PasswordItemDiffCallback), PopupTextProvider { + + fun > 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() { + 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 { + check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" } + onItemClickedListener = listener + return this + } + + private var onSelectionChangedListener: ((selection: Selection) -> Unit)? = null + open fun onSelectionChanged(listener: (selection: Selection) -> Unit): SearchableRepositoryAdapter { + 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(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? = null + fun requireSelectionTracker() = selectionTracker!! + + private val selectedFiles + get() = requireSelectionTracker().selection.map { File(it) } + fun getSelectedItems(context: Context): List { + 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()) } } -- cgit v1.2.3