diff options
| author | Fabian Henneke <FabianHenneke@users.noreply.github.com> | 2020-04-10 13:18:42 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-04-10 16:48:42 +0530 |
| commit | 575ef8472654f49dd60f0e6909164dd8dbd098fb (patch) | |
| tree | bd94860a1dcb4a8c630ff5954c262716ac307b7a /app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt | |
| parent | 2738d7500fa0d4d925f698a04045d930c27a2d59 (diff) | |
| download | HardenedPass-575ef8472654f49dd60f0e6909164dd8dbd098fb.tar.gz HardenedPass-575ef8472654f49dd60f0e6909164dd8dbd098fb.zip | |
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 <me@msfjarvis.dev>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt')
| -rw-r--r-- | app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt | 331 |
1 files changed, 157 insertions, 174 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index fc0a0c00..0e6a832b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -6,57 +6,43 @@ package com.zeapo.pwdstore import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode import androidx.fragment.app.Fragment -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DividerItemDecoration +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.observe import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.git.GitActivity -import com.zeapo.pwdstore.ui.adapters.PasswordRecyclerAdapter +import com.zeapo.pwdstore.ui.OnOffItemAnimator +import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getPasswords -import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirectory -import com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder import java.io.File import java.util.Stack import me.zhanghai.android.fastscroll.FastScrollerBuilder -/** - * A fragment representing a list of Items. - * - * Large screen devices (such as tablets) are supported by replacing the ListView with a - * GridView. - * - */ - class PasswordFragment : Fragment() { - // store the pass files list in a stack - private var passListStack: Stack<ArrayList<PasswordItem>> = Stack() - private var pathStack: Stack<File> = Stack() - private var scrollPosition: Stack<Int> = Stack() - private lateinit var recyclerAdapter: PasswordRecyclerAdapter + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter private lateinit var recyclerView: RecyclerView private lateinit var listener: OnFragmentInteractionListener - private lateinit var settings: SharedPreferences private lateinit var swipeRefresher: SwipeRefreshLayout - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val path = requireNotNull(requireArguments().getString("Path")) - settings = PreferenceManager.getDefaultSharedPreferences(requireActivity()) - recyclerAdapter = PasswordRecyclerAdapter((requireActivity() as PasswordStore), - listener, getPasswords(File(path), getRepositoryDirectory(requireContext()), sortOrder)) - } + private var recyclerViewStateToRestore: Parcelable? = null + private var actionMode: ActionMode? = null + + private val model: SearchableRepositoryViewModel by activityViewModels() + + private fun requireStore() = requireActivity() as PasswordStore override fun onCreateView( inflater: LayoutInflater, @@ -64,12 +50,28 @@ class PasswordFragment : Fragment() { savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.password_recycler_view, container, false) - // use a linear layout manager - val layoutManager = LinearLayoutManager(requireContext()) - swipeRefresher = view.findViewById(R.id.swipe_refresher) + initializePasswordList(view) + val fab = view.findViewById<FloatingActionButton>(R.id.fab) + fab.setOnClickListener { + toggleFabExpand(fab) + } + view.findViewById<FloatingActionButton>(R.id.create_folder).setOnClickListener { + requireStore().createFolder() + toggleFabExpand(fab) + } + view.findViewById<FloatingActionButton>(R.id.create_password).setOnClickListener { + requireStore().createPassword() + toggleFabExpand(fab) + } + return view + } + + private fun initializePasswordList(rootView: View) { + swipeRefresher = rootView.findViewById(R.id.swipe_refresher) swipeRefresher.setOnRefreshListener { if (!PasswordRepository.isGitRepo()) { - Snackbar.make(view, getString(R.string.clone_git_repo), Snackbar.LENGTH_SHORT).show() + Snackbar.make(rootView, getString(R.string.clone_git_repo), Snackbar.LENGTH_SHORT) + .show() swipeRefresher.isRefreshing = false } else { val intent = Intent(context, GitActivity::class.java) @@ -77,30 +79,54 @@ class PasswordFragment : Fragment() { startActivityForResult(intent, GitActivity.REQUEST_SYNC) } } - recyclerView = view.findViewById(R.id.pass_recycler) - recyclerView.layoutManager = layoutManager - // use divider - recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) - // Set the adapter - recyclerView.adapter = recyclerAdapter - // Setup fast scroller - FastScrollerBuilder(recyclerView).build() - val fab = view.findViewById<FloatingActionButton>(R.id.fab) - fab.setOnClickListener { - toggleFabExpand(fab) - } - view.findViewById<FloatingActionButton>(R.id.create_folder).setOnClickListener { - (requireActivity() as PasswordStore).createFolder() - toggleFabExpand(fab) + recyclerAdapter = PasswordItemRecyclerAdapter() + .onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + .onSelectionChanged { selection -> + // In order to not interfere with drag selection, we disable the SwipeRefreshLayout + // once an item is selected. + swipeRefresher.isEnabled = selection.isEmpty + + if (actionMode == null) + actionMode = requireStore().startSupportActionMode(actionModeCallback) + ?: return@onSelectionChanged + + if (!selection.isEmpty) { + actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size()) + actionMode!!.invalidate() + } else { + actionMode!!.finish() + } + } + recyclerView = rootView.findViewById(R.id.pass_recycler) + recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = OnOffItemAnimator() + adapter = recyclerAdapter } - view.findViewById<FloatingActionButton>(R.id.create_password).setOnClickListener { - (requireActivity() as PasswordStore).createPassword() - toggleFabExpand(fab) - } + // FastScrollerBuilder.build() needs to be called *before* recyclerAdapter.makeSelectable(), + // as otherwise dragging the fast scroller will lead to items being selected. + // See https://github.com/zhanghai/AndroidFastScroll/issues/13 + FastScrollerBuilder(recyclerView).build() + recyclerAdapter.makeSelectable(recyclerView) registerForContextMenu(recyclerView) - return view + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), pushPreviousLocation = false) + model.searchResult.observe(this) { result -> + // Only run animations when the new list is filtered, i.e., the user submitted a search, + // and not on folder navigations since the latter leads to too many removal animations. + (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered + recyclerAdapter.submitList(result.passwordItems) { + recyclerViewStateToRestore?.let { + recyclerView.layoutManager!!.onRestoreInstanceState(it) + } + recyclerViewStateToRestore = null + } + } } private fun toggleFabExpand(fab: FloatingActionButton) = with(fab) { @@ -109,30 +135,80 @@ class PasswordFragment : Fragment() { animate().rotationBy(if (isExpanded) -45f else 45f).setDuration(100).start() } + private val actionModeCallback = object : ActionMode.Callback { + + // Called when the action mode is created; startActionMode() was called + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // Inflate a menu resource providing context menu items + mode.menuInflater.inflate(R.menu.context_pass, menu) + // hide the fab + requireActivity().findViewById<View>(R.id.fab).visibility = View.GONE + return true + } + + // Called each time the action mode is shown. Always called after onCreateActionMode, but + // may be called multiple times if the mode is invalidated. + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.findItem(R.id.menu_edit_password).isVisible = + recyclerAdapter.getSelectedItems(requireContext()) + .map { it.type == PasswordItem.TYPE_PASSWORD } + .singleOrNull() == true + return true // Return false if nothing is done + } + + // Called when the user selects a contextual menu item + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + when (item.itemId) { + R.id.menu_delete_password -> { + requireStore().deletePasswords( + Stack<PasswordItem>().apply { + recyclerAdapter.getSelectedItems(requireContext()).forEach { push(it) } + } + ) + mode.finish() // Action picked, so close the CAB + return true + } + R.id.menu_edit_password -> { + requireStore().editPassword( + recyclerAdapter.getSelectedItems(requireContext()).first() + ) + mode.finish() + return true + } + R.id.menu_move_password -> { + requireStore().movePasswords(recyclerAdapter.getSelectedItems(requireContext())) + return false + } + else -> return false + } + } + + // Called when the user exits the action mode + override fun onDestroyActionMode(mode: ActionMode) { + recyclerAdapter.requireSelectionTracker().clearSelection() + actionMode = null + // show the fab + requireActivity().findViewById<View>(R.id.fab).visibility = View.VISIBLE + } + } + override fun onAttach(context: Context) { super.onAttach(context) try { listener = object : OnFragmentInteractionListener { override fun onFragmentInteraction(item: PasswordItem) { - if (item.type == PasswordItem.TYPE_CATEGORY) { // push the current password list (non filtered plz!) - passListStack.push( - if (pathStack.isEmpty()) - getPasswords(getRepositoryDirectory(context), sortOrder) - else - getPasswords(pathStack.peek(), getRepositoryDirectory(context), sortOrder) + if (item.type == PasswordItem.TYPE_CATEGORY) { + requireStore().clearSearch() + model.navigateTo( + item.file, + recyclerViewState = recyclerView.layoutManager!!.onSaveInstanceState() ) - // push the category were we're going - pathStack.push(item.file) - scrollPosition.push((recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()) - recyclerView.scrollToPosition(0) - recyclerAdapter.clear() - recyclerAdapter.addAll(getPasswords(item.file, getRepositoryDirectory(context), sortOrder)) - (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) } else { if (requireArguments().getBoolean("matchWith", false)) { - (requireActivity() as PasswordStore).matchPasswordWithApp(item) + requireStore().matchPasswordWithApp(item) } else { - (requireActivity() as PasswordStore).decryptPassword(item) + requireStore().decryptPassword(item) } } } @@ -146,120 +222,27 @@ class PasswordFragment : Fragment() { swipeRefresher.isRefreshing = false } - /** clears the adapter content and sets it back to the root view */ - fun updateAdapter() { - passListStack.clear() - pathStack.clear() - scrollPosition.clear() - recyclerAdapter.clear() - recyclerAdapter.addAll(getPasswords(getRepositoryDirectory(requireContext()), sortOrder)) - (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(false) - } - - /** refreshes the adapter with the latest opened category */ - fun refreshAdapter() { - recyclerAdapter.clear() - val currentDir = if (pathStack.isEmpty()) getRepositoryDirectory(requireContext()) else pathStack.peek() - recyclerAdapter.addAll( - if (pathStack.isEmpty()) - getPasswords(currentDir, sortOrder) - else - getPasswords(currentDir, getRepositoryDirectory(requireContext()), sortOrder) - ) - } - /** - * filters the list adapter - * - * @param filter the filter to apply + * Returns true if the back press was handled by the [Fragment]. */ - fun filterAdapter(filter: String) { - if (filter.isEmpty()) { - refreshAdapter() - } else { - recursiveFilter( - filter, - if (pathStack.isEmpty() || - settings.getBoolean("search_from_root", false)) - null - else pathStack.peek()) - } + fun onBackPressedInActivity(): Boolean { + if (!model.canNavigateBack) + return false + // The RecyclerView state is restored when the asynchronous update operation on the + // adapter is completed. + recyclerViewStateToRestore = model.navigateBack() + if (!model.canNavigateBack) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) + return true } - /** - * fuzzy matches the filter against the given string - * - * based on https://www.forrestthewoods.com/blog/reverse_engineering_sublime_texts_fuzzy_match/ - * - * @param filter the filter to apply - * @param str the string to filter against - * - * @return true if the filter fuzzymatches the string - */ - private fun fuzzyMatch(filter: String, str: String): Boolean { - var i = 0 - var j = 0 - while (i < filter.length && j < str.length) { - if (filter[i].isWhitespace() || filter[i].toLowerCase() == str[j].toLowerCase()) - i++ - j++ - } - return i == filter.length - } - - /** - * recursively filters a directory and extract all the matching items - * - * @param filter the filter to apply - * @param dir the directory to filter - */ - private fun recursiveFilter(filter: String, dir: File?) { // on the root the pathStack is empty - val passwordItems = if (dir == null) - getPasswords(getRepositoryDirectory(requireContext()), sortOrder) - else - getPasswords(dir, getRepositoryDirectory(requireContext()), sortOrder) - val rec = settings.getBoolean("filter_recursively", true) - for (item in passwordItems) { - if (item.type == PasswordItem.TYPE_CATEGORY && rec) { - recursiveFilter(filter, item.file) - } - val matches = fuzzyMatch(filter, item.longName) - val inAdapter = recyclerAdapter.values.contains(item) - if (matches && !inAdapter) { - recyclerAdapter.add(item) - } else if (!matches && inAdapter) { - recyclerAdapter.remove(recyclerAdapter.values.indexOf(item)) - } - } - } - - /** Goes back one level back in the path */ - fun popBack() { - if (passListStack.isEmpty()) return - (recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(scrollPosition.pop()) - recyclerAdapter.clear() - recyclerAdapter.addAll(passListStack.pop()) - pathStack.pop() - } - - /** - * gets the current directory - * - * @return the current directory - */ - val currentDir: File? - get() = if (pathStack.isEmpty()) getRepositoryDirectory(requireContext()) else pathStack.peek() - - val isNotEmpty: Boolean - get() = !passListStack.isEmpty() + val currentDir: File + get() = model.currentDir.value!! fun dismissActionMode() { - recyclerAdapter.actionMode?.finish() + actionMode?.finish() } - private val sortOrder: PasswordRepository.PasswordSortOrder - get() = getSortOrder(settings) - interface OnFragmentInteractionListener { fun onFragmentInteraction(item: PasswordItem) } |
