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 | |
parent | 2738d7500fa0d4d925f698a04045d930c27a2d59 (diff) |
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>
22 files changed, 744 insertions, 732 deletions
diff --git a/app/build.gradle b/app/build.gradle index 4d410c46..3bef4675 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,12 +86,13 @@ dependencies { implementation deps.androidx.documentfile implementation deps.androidx.fragment_ktx implementation deps.androidx.lifecycle_livedata_ktx - implementation deps.androidx.lifecycle_runtime_ktx + implementation deps.androidx.lifecycle_viewmodel_ktx implementation deps.androidx.local_broadcast_manager implementation deps.androidx.material implementation deps.androidx.preference implementation deps.androidx.swiperefreshlayout - implementation(deps.androidx.recycler_view) + implementation deps.androidx.recycler_view + implementation deps.androidx.recycler_view_selection implementation deps.kotlin.coroutines.android implementation deps.kotlin.coroutines.core 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) } diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index c665a4c2..9844d5a2 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -22,6 +22,7 @@ import android.view.Menu import android.view.MenuItem import android.view.MenuItem.OnActionExpandListener import android.view.View +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.SearchView @@ -29,6 +30,8 @@ import androidx.appcompat.widget.SearchView.OnQueryTextListener import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.observe import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar @@ -38,7 +41,6 @@ import com.zeapo.pwdstore.crypto.PgpActivity.Companion.getLongName import com.zeapo.pwdstore.git.GitActivity import com.zeapo.pwdstore.git.GitAsyncTask import com.zeapo.pwdstore.git.GitOperation -import com.zeapo.pwdstore.ui.adapters.PasswordRecyclerAdapter import com.zeapo.pwdstore.ui.dialogs.FolderCreationDialogFragment import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordRepository @@ -52,6 +54,7 @@ import com.zeapo.pwdstore.utils.PasswordRepository.Companion.isInitialized import com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder import java.io.File import java.lang.Character.UnicodeBlock +import java.util.Stack import org.apache.commons.io.FileUtils import org.apache.commons.io.FilenameUtils import org.eclipse.jgit.api.Git @@ -68,6 +71,10 @@ class PasswordStore : AppCompatActivity() { private var plist: PasswordFragment? = null private var shortcutManager: ShortcutManager? = null + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { // open search view on search key, or Ctr+F if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && @@ -106,6 +113,16 @@ class PasswordStore : AppCompatActivity() { } super.onCreate(savedInstance) setContentView(R.layout.activity_pwdstore) + + model.currentDir.observe(this) { dir -> + val basePath = getRepositoryDirectory(applicationContext).absoluteFile + supportActionBar!!.apply { + if (dir != basePath) + title = dir.name + else + setTitle(R.string.app_name) + } + } } public override fun onResume() { @@ -165,19 +182,26 @@ class PasswordStore : AppCompatActivity() { } override fun onPrepareOptionsMenu(menu: Menu): Boolean { - // Inflate the menu; this adds items to the action bar if it is present. searchItem = menu.findItem(R.id.action_search) searchView = searchItem.actionView as SearchView searchView.setOnQueryTextListener( object : OnQueryTextListener { override fun onQueryTextSubmit(s: String): Boolean { - filterListAdapter(s) searchView.clearFocus() return true } override fun onQueryTextChange(s: String): Boolean { - filterListAdapter(s) + val filter = s.trim() + // List the contents of the current directory if the user enters a blank + // search term. + if (filter.isEmpty()) + model.navigateTo( + newDirectory = model.currentDir.value!!, + pushPreviousLocation = false + ) + else + model.search(filter) return true } }) @@ -187,7 +211,7 @@ class PasswordStore : AppCompatActivity() { searchItem.setOnActionExpandListener( object : OnActionExpandListener { override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - refreshListAdapter() + refreshPasswordList() return true } @@ -251,7 +275,7 @@ class PasswordStore : AppCompatActivity() { return true } R.id.refresh -> { - updateListAdapter() + refreshPasswordList() return true } android.R.id.home -> onBackPressed() @@ -266,6 +290,10 @@ class PasswordStore : AppCompatActivity() { super.onDestroy() } + fun clearSearch() { + searchItem.collapseActionView() + } + fun openSettings(view: View?) { val intent: Intent try { @@ -354,7 +382,7 @@ class PasswordStore : AppCompatActivity() { settings.edit().putBoolean("repo_changed", false).apply() plist = PasswordFragment() val args = Bundle() - args.putString("Path", getRepositoryDirectory(applicationContext).absolutePath) + args.putString(REQUEST_ARG_PATH, getRepositoryDirectory(applicationContext).absolutePath) // if the activity was started from the autofill settings, the // intent is to match a clicked pwd with app. pass this to fragment @@ -378,14 +406,8 @@ class PasswordStore : AppCompatActivity() { } override fun onBackPressed() { - if (null != plist && plist!!.isNotEmpty) { - plist!!.popBack() - } else { + if (plist?.onBackPressedInActivity() != true) super.onBackPressed() - } - if (null != plist && !plist!!.isNotEmpty) { - supportActionBar!!.setDisplayHomeAsUpEnabled(false) - } } private fun getRelativePath(fullPath: String, repositoryPath: String): String { @@ -450,7 +472,7 @@ class PasswordStore : AppCompatActivity() { val intent = Intent(this, PgpActivity::class.java) intent.putExtra("NAME", item.toString()) intent.putExtra("FILE_PATH", item.file.absolutePath) - intent.putExtra("PARENT_PATH", currentDir!!.absolutePath) + intent.putExtra("PARENT_PATH", item.file.parentFile.absolutePath) intent.putExtra("REPO_PATH", getRepositoryDirectory(applicationContext).absolutePath) intent.putExtra("OPERATION", "EDIT") startActivityForResult(intent, REQUEST_CODE_EDIT) @@ -481,7 +503,7 @@ class PasswordStore : AppCompatActivity() { fun createPassword() { if (!validateState()) return val currentDir = currentDir - Timber.tag(TAG).i("Adding file to : ${currentDir!!.absolutePath}") + Timber.tag(TAG).i("Adding file to : ${currentDir.absolutePath}") val intent = Intent(this, PgpActivity::class.java) intent.putExtra("FILE_PATH", currentDir.absolutePath) intent.putExtra("REPO_PATH", getRepositoryDirectory(applicationContext).absolutePath) @@ -491,17 +513,16 @@ class PasswordStore : AppCompatActivity() { fun createFolder() { if (!validateState()) return - FolderCreationDialogFragment.newInstance(currentDir!!.path).show(supportFragmentManager, null) + FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) } // deletes passwords in order from top to bottom - fun deletePasswords(adapter: PasswordRecyclerAdapter, selectedItems: MutableSet<Int>) { - val it: MutableIterator<*> = selectedItems.iterator() - if (!it.hasNext()) { + fun deletePasswords(selectedItems: Stack<PasswordItem>) { + if (selectedItems.isEmpty()) { + refreshPasswordList() return } - val position = it.next() as Int - val item = adapter.values[position] + val item = selectedItems.pop() MaterialAlertDialogBuilder(this) .setMessage(resources.getString(R.string.delete_dialog_text, item.longName)) .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> @@ -512,20 +533,16 @@ class PasswordStore : AppCompatActivity() { } AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) item.file.deleteRecursively() - adapter.remove(position) - it.remove() - adapter.updateSelectedItems(position, selectedItems) commitChange(resources.getString(R.string.git_commit_remove_text, item.longName)) - deletePasswords(adapter, selectedItems) + deletePasswords(selectedItems) } .setNegativeButton(this.resources.getString(R.string.dialog_no)) { _, _ -> - it.remove() - deletePasswords(adapter, selectedItems) + deletePasswords(selectedItems) } .show() } - fun movePasswords(values: ArrayList<PasswordItem>) { + fun movePasswords(values: List<PasswordItem>) { val intent = Intent(this, SelectFolderActivity::class.java) val fileLocations = ArrayList<String>() for ((_, _, _, file) in values) { @@ -536,21 +553,28 @@ class PasswordStore : AppCompatActivity() { startActivityForResult(intent, REQUEST_CODE_SELECT_FOLDER) } - /** clears adapter's content and updates it with a fresh list of passwords from the root */ - fun updateListAdapter() { - plist?.updateAdapter() - } - - /** Updates the adapter with the current view of passwords */ - private fun refreshListAdapter() { - plist?.refreshAdapter() + /** + * Resets navigation to the repository root and refreshes the password list accordingly. + * + * Use this rather than [refreshPasswordList] after major file system operations that may remove + * the current directory and thus require a full reset of the navigation stack. + */ + fun resetPasswordList() { + model.reset() + supportActionBar!!.setDisplayHomeAsUpEnabled(false) } - private fun filterListAdapter(filter: String) { - plist?.filterAdapter(filter) + /** + * Refreshes the password list by re-executing the last navigation or search action. + * + * Use this rather than [resetPasswordList] after file system operations limited to the current + * folder since it preserves the scroll position and navigation stack. + */ + fun refreshPasswordList() { + model.forceRefresh() } - private val currentDir: File? + private val currentDir: File get() = plist?.currentDir ?: getRepositoryDirectory(applicationContext) private fun commitChange(message: String) { @@ -578,14 +602,14 @@ class PasswordStore : AppCompatActivity() { data.extras!!.getString("LONG_NAME"))) } } - refreshListAdapter() + refreshPasswordList() } REQUEST_CODE_ENCRYPT -> { commitChange(this.resources .getString( R.string.git_commit_add_text, data!!.extras!!.getString("LONG_NAME"))) - refreshListAdapter() + refreshPasswordList() } REQUEST_CODE_EDIT -> { commitChange( @@ -593,10 +617,10 @@ class PasswordStore : AppCompatActivity() { .getString( R.string.git_commit_edit_text, data!!.extras!!.getString("LONG_NAME"))) - refreshListAdapter() + refreshPasswordList() } GitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo() - GitActivity.REQUEST_SYNC, GitActivity.REQUEST_PULL -> updateListAdapter() + GitActivity.REQUEST_SYNC, GitActivity.REQUEST_PULL -> resetPasswordList() HOME -> checkLocalRepository() // duplicate code CLONE_REPO_BUTTON -> { @@ -677,7 +701,7 @@ class PasswordStore : AppCompatActivity() { destinationLongName)) } } - updateListAdapter() + resetPasswordList() if (plist != null) { plist!!.dismissActionMode() } @@ -760,6 +784,7 @@ class PasswordStore : AppCompatActivity() { const val REQUEST_CODE_GET_KEY_IDS = 9915 const val REQUEST_CODE_EDIT = 9916 const val REQUEST_CODE_SELECT_FOLDER = 9917 + const val REQUEST_ARG_PATH = "PATH" private val TAG = PasswordStore::class.java.name private const val CLONE_REPO_BUTTON = 401 private const val NEW_REPO_BUTTON = 402 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()) } } diff --git a/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt b/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt index a1736003..4d8475df 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt @@ -27,7 +27,7 @@ class SelectFolderActivity : AppCompatActivity() { passwordList = SelectFolderFragment() val args = Bundle() - args.putString("Path", PasswordRepository.getRepositoryDirectory(applicationContext).absolutePath) + args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory(applicationContext).absolutePath) passwordList.arguments = args @@ -58,7 +58,7 @@ class SelectFolderActivity : AppCompatActivity() { } private fun selectFolder() { - intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir?.absolutePath) + intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) setResult(Activity.RESULT_OK, intent) finish() } diff --git a/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt b/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt index e60cb286..a7489893 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt @@ -11,40 +11,22 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity 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 com.google.android.material.floatingactionbutton.FloatingActionButton -import com.zeapo.pwdstore.ui.adapters.FolderRecyclerAdapter +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 - -/** - * A fragment representing a list of Items. - * - * Large screen devices (such as tablets) are supported by replacing the ListView with a - * GridView. - * - */ +import me.zhanghai.android.fastscroll.FastScrollerBuilder class SelectFolderFragment : Fragment() { - // store the pass files list in a stack - private var pathStack: Stack<File> = Stack() - private lateinit var recyclerAdapter: FolderRecyclerAdapter + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter private lateinit var recyclerView: RecyclerView private lateinit var listener: OnFragmentInteractionListener - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val path = requireNotNull(requireArguments().getString("Path")) - recyclerAdapter = FolderRecyclerAdapter(listener, getPasswords(File(path), getRepositoryDirectory(requireActivity()), sortOrder)) - } + private val model: SearchableRepositoryViewModel by activityViewModels() override fun onCreateView( inflater: LayoutInflater, @@ -52,35 +34,41 @@ class SelectFolderFragment : Fragment() { savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.password_recycler_view, container, false) - // use a linear layout manager - recyclerView = view.findViewById(R.id.pass_recycler) - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - // use divider - recyclerView.addItemDecoration( - DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) - // Set the adapter - recyclerView.adapter = recyclerAdapter + initializePasswordList(view) val fab: FloatingActionButton = view.findViewById(R.id.fab) fab.hide() - registerForContextMenu(recyclerView) return view } + private fun initializePasswordList(rootView: View) { + recyclerAdapter = PasswordItemRecyclerAdapter() + .onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + recyclerView = rootView.findViewById(R.id.pass_recycler) + recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = null + adapter = recyclerAdapter + } + + FastScrollerBuilder(recyclerView).build() + registerForContextMenu(recyclerView) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) + model.searchResult.observe(this) { result -> + recyclerAdapter.submitList(result.passwordItems) + } + } + 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 category were we're going - pathStack.push(item.file) - recyclerView.scrollToPosition(0) - recyclerAdapter.clear() - recyclerAdapter.addAll(getPasswords( - item.file, - getRepositoryDirectory(context), - sortOrder) - ) + model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) } } @@ -91,16 +79,8 @@ class SelectFolderFragment : Fragment() { } } - /** - * gets the current directory - * - * @return the current directory - */ - val currentDir: File? - get() = if (pathStack.isEmpty()) getRepositoryDirectory(requireContext()) else pathStack.peek() - - private val sortOrder: PasswordRepository.PasswordSortOrder - get() = getSortOrder(PreferenceManager.getDefaultSharedPreferences(requireContext())) + val currentDir: File + get() = model.currentDir.value!! interface OnFragmentInteractionListener { fun onFragmentInteraction(item: PasswordItem) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt index 2bfe0ad3..e1cbc496 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -19,15 +19,15 @@ import androidx.core.text.bold import androidx.core.text.buildSpannedString import androidx.core.text.underline import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.DividerItemDecoration +import androidx.lifecycle.observe import androidx.recyclerview.widget.LinearLayoutManager import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.DelegatedSearchableRepositoryAdapter import com.zeapo.pwdstore.FilterMode +import com.zeapo.pwdstore.ListMode import com.zeapo.pwdstore.R import com.zeapo.pwdstore.SearchMode +import com.zeapo.pwdstore.SearchableRepositoryAdapter import com.zeapo.pwdstore.SearchableRepositoryViewModel import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences @@ -35,11 +35,7 @@ import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.FormOrigin import com.zeapo.pwdstore.utils.PasswordItem import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.* -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -@FlowPreview -@ExperimentalCoroutinesApi @TargetApi(Build.VERSION_CODES.O) class AutofillFilterView : AppCompatActivity() { @@ -116,10 +112,9 @@ class AutofillFilterView : AppCompatActivity() { } private fun bindUI() { - val searchableAdapter = DelegatedSearchableRepositoryAdapter( + val recyclerAdapter = SearchableRepositoryAdapter( R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder - ) { item -> + ::PasswordViewHolder) { item -> val file = item.file.relativeTo(item.rootDir) val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) val identifier = directoryStructure.getIdentifierFor(file) ?: "INVALID" @@ -129,12 +124,12 @@ class AutofillFilterView : AppCompatActivity() { bold { underline { append(identifier) } } } subtitle.text = accountPart - itemView.setOnClickListener { decryptAndFill(item) } + }.onItemClicked { _, item -> + decryptAndFill(item) } rvPassword.apply { - adapter = searchableAdapter + adapter = recyclerAdapter layoutManager = LinearLayoutManager(context) - addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) } val initialFilter = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) @@ -145,26 +140,26 @@ class AutofillFilterView : AppCompatActivity() { initialFilter, filterMode = filterMode, searchMode = SearchMode.RecursivelyInSubdirectories, - listFilesOnly = true + listMode = ListMode.FilesOnly ) search.addTextChangedListener { model.search( - it.toString(), + it.toString().trim(), filterMode = FilterMode.Fuzzy, searchMode = SearchMode.RecursivelyInSubdirectories, - listFilesOnly = true + listMode = ListMode.FilesOnly ) } - model.passwordItemsList.observe( - this, - Observer { list -> - searchableAdapter.submitList(list) - // Switch RecyclerView out for a "no results" message if the new list is empty and - // the message is not yet shown (and vice versa). - if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || - (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)) - rvPasswordSwitcher.showNext() - }) + model.searchResult.observe(this) { result -> + val list = result.passwordItems + recyclerAdapter.submitList(list) + // Switch RecyclerView out for a "no results" message if the new list is empty and + // the message is not yet shown (and vice versa). + if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || + (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id) + ) + rvPasswordSwitcher.showNext() + } shouldMatch.text = getString( R.string.oreo_autofill_match_with, diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java index aa749e58..a33ec221 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java @@ -131,7 +131,7 @@ public class GitAsyncTask extends AsyncTask<GitCommand, Integer, String> { if (refreshListOnEnd) { try { - ((PasswordStore) this.getActivity()).updateListAdapter(); + ((PasswordStore) this.getActivity()).resetPasswordList(); } catch (ClassCastException e) { // oups, mistake } diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt b/app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt new file mode 100644 index 00000000..e394e656 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt @@ -0,0 +1,71 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.ui + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class OnOffItemAnimator : DefaultItemAnimator() { + + var isEnabled: Boolean = true + set(value) { + // Defer update until no animation is running anymore. + isRunning { field = value } + } + + private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animateAppearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo?, + postLayoutInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animateChange( + oldHolder: RecyclerView.ViewHolder, + newHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateChange(oldHolder, newHolder, preInfo, postInfo) + } else { + dontAnimate(oldHolder) + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + return if (isEnabled) { + super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animatePersistence( + viewHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animatePersistence(viewHolder, preInfo, postInfo) + } else { + dontAnimate(viewHolder) + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/EntryRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/EntryRecyclerAdapter.kt deleted file mode 100644 index 9cf477e2..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/EntryRecyclerAdapter.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.adapters - -import android.content.SharedPreferences -import android.text.SpannableString -import android.text.style.RelativeSizeSpan -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.AppCompatImageView -import androidx.appcompat.widget.AppCompatTextView -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.RecyclerView -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.widget.MultiselectableConstraintLayout -import java.io.File -import java.util.ArrayList -import java.util.Locale -import java.util.TreeSet -import me.zhanghai.android.fastscroll.PopupTextProvider - -abstract class EntryRecyclerAdapter internal constructor(val values: ArrayList<PasswordItem>) : RecyclerView.Adapter<EntryRecyclerAdapter.ViewHolder>(), PopupTextProvider { - internal val selectedItems: MutableSet<Int> = TreeSet() - internal var settings: SharedPreferences? = null - - // Return the size of your dataset (invoked by the layout manager) - override fun getItemCount(): Int { - return values.size - } - - override fun getPopupText(position: Int): String { - return values[position].name[0].toString().toUpperCase(Locale.getDefault()) - } - - fun clear() { - this.values.clear() - this.notifyDataSetChanged() - } - - fun addAll(list: ArrayList<PasswordItem>) { - this.values.addAll(list) - this.notifyDataSetChanged() - } - - fun add(item: PasswordItem) { - this.values.add(item) - this.notifyItemInserted(itemCount) - } - - internal fun toggleSelection(position: Int) { - if (!selectedItems.remove(position)) { - selectedItems.add(position) - } - } - - // use this after an item is removed to update the positions of items in set - // that followed the removed position - fun updateSelectedItems(position: Int, selectedItems: MutableSet<Int>) { - val temp = TreeSet<Int>() - for (selected in selectedItems) { - if (selected > position) { - temp.add(selected - 1) - } else { - temp.add(selected) - } - } - selectedItems.clear() - selectedItems.addAll(temp) - } - - fun remove(position: Int) { - this.values.removeAt(position) - this.notifyItemRemoved(position) - - // keep selectedItems updated so we know what to notifyItemChanged - // (instead of just using notifyDataSetChanged) - updateSelectedItems(position, selectedItems) - } - - internal open fun getOnLongClickListener(holder: ViewHolder, pass: PasswordItem): View.OnLongClickListener { - return View.OnLongClickListener { false } - } - - // Replace the contents of a view (invoked by the layout manager) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - settings = settings - ?: PreferenceManager.getDefaultSharedPreferences(holder.view.context.applicationContext) - val pass = values[position] - val showHidden = settings?.getBoolean("show_hidden_folders", false) ?: false - holder.name.text = pass.toString() - if (pass.type == PasswordItem.TYPE_CATEGORY) { - holder.typeImage.setImageResource(R.drawable.ic_multiple_files_24dp) - holder.folderIndicator.visibility = View.VISIBLE - val children = pass.file.listFiles { pathname -> - !(!showHidden && (pathname.isDirectory && pathname.isHidden)) - } ?: emptyArray<File>() - val childCount = children.size - holder.childCount.visibility = if (childCount > 0) View.VISIBLE else View.GONE - holder.childCount.text = "$childCount" - } else { - holder.typeImage.setImageResource(R.drawable.ic_action_secure_24dp) - val parentPath = pass.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") - val source = "$parentPath\n$pass" - val spannable = SpannableString(source) - spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) - holder.name.text = spannable - holder.childCount.visibility = View.GONE - holder.folderIndicator.visibility = View.GONE - } - holder.view.setOnClickListener(getOnClickListener(holder, pass)) - holder.view.setOnLongClickListener(getOnLongClickListener(holder, pass)) - - // after removal, everything is rebound for some reason; views are shuffled? - val selected = selectedItems.contains(position) - holder.view.isSelected = selected - (holder.itemView as MultiselectableConstraintLayout).setMultiSelected(selected) - } - - protected abstract fun getOnClickListener(holder: ViewHolder, pass: PasswordItem): View.OnClickListener - - // Create new views (invoked by the layout manager) - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): ViewHolder { - // create a new view - val v = LayoutInflater.from(parent.context) - .inflate(R.layout.password_row_layout, parent, false) - return ViewHolder(v) - } - - /* - Provide a reference to the views for each data item - Complex data items may need more than one view per item, and - you provide access to all the views for a data item in a view holder - each data item is just a string in this case - */ - class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { - val name: AppCompatTextView = view.findViewById(R.id.label) - val typeImage: AppCompatImageView = view.findViewById(R.id.type_image) - val childCount: AppCompatTextView = view.findViewById(R.id.child_count) - val folderIndicator: AppCompatImageView = view.findViewById(R.id.folder_indicator) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/FolderRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/FolderRecyclerAdapter.kt deleted file mode 100644 index 18aef509..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/FolderRecyclerAdapter.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.adapters - -import android.view.View -import com.zeapo.pwdstore.SelectFolderFragment -import com.zeapo.pwdstore.utils.PasswordItem -import java.util.ArrayList - -class FolderRecyclerAdapter( - private val listener: SelectFolderFragment.OnFragmentInteractionListener, - values: ArrayList<PasswordItem> -) : EntryRecyclerAdapter(values) { - - override fun getOnClickListener(holder: ViewHolder, pass: PasswordItem): View.OnClickListener { - return View.OnClickListener { - listener.onFragmentInteraction(pass) - notifyItemChanged(holder.adapterPosition) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt new file mode 100644 index 00000000..9a914be9 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt @@ -0,0 +1,88 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.ui.adapters + +import android.text.SpannableString +import android.text.style.RelativeSizeSpan +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.preference.PreferenceManager +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.widget.RecyclerView +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.SearchableRepositoryAdapter +import com.zeapo.pwdstore.stableId +import com.zeapo.pwdstore.utils.PasswordItem +import java.io.File + +open class PasswordItemRecyclerAdapter : + SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>( + R.layout.password_row_layout, + ::PasswordItemViewHolder, + PasswordItemViewHolder::bind + ) { + + fun makeSelectable(recyclerView: RecyclerView) { + makeSelectable(recyclerView, ::PasswordItemDetailsLookup) + } + + override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter { + return super.onItemClicked(listener) as PasswordItemRecyclerAdapter + } + + override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter { + return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter + } + + class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val name: AppCompatTextView = itemView.findViewById(R.id.label) + private val typeImage: AppCompatImageView = itemView.findViewById(R.id.type_image) + private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) + private val folderIndicator: AppCompatImageView = + itemView.findViewById(R.id.folder_indicator) + lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String> + + fun bind(item: PasswordItem) { + val settings = + PreferenceManager.getDefaultSharedPreferences(itemView.context.applicationContext) + val showHidden = settings.getBoolean("show_hidden_folders", false) + name.text = item.toString() + if (item.type == PasswordItem.TYPE_CATEGORY) { + typeImage.setImageResource(R.drawable.ic_multiple_files_24dp) + folderIndicator.visibility = View.VISIBLE + val children = item.file.listFiles { pathname -> + !(!showHidden && (pathname.isDirectory && pathname.isHidden)) + } ?: emptyArray<File>() + val count = children.size + childCount.visibility = if (count > 0) View.VISIBLE else View.GONE + childCount.text = "$count" + } else { + typeImage.setImageResource(R.drawable.ic_action_secure_24dp) + val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") + val source = "$parentPath\n$item" + val spannable = SpannableString(source) + spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) + name.text = spannable + childCount.visibility = View.GONE + folderIndicator.visibility = View.GONE + } + itemDetails = object : ItemDetailsLookup.ItemDetails<String>() { + override fun getPosition() = absoluteAdapterPosition + override fun getSelectionKey() = item.stableId + } + } + } + + class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : + ItemDetailsLookup<String>() { + override fun getItemDetails(event: MotionEvent): ItemDetails<String>? { + val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null + return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordRecyclerAdapter.kt deleted file mode 100644 index 39d73563..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordRecyclerAdapter.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.adapters - -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.appcompat.view.ActionMode -import com.zeapo.pwdstore.PasswordFragment -import com.zeapo.pwdstore.PasswordStore -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PasswordItem -import java.util.ArrayList -import java.util.TreeSet - -class PasswordRecyclerAdapter( - private val activity: PasswordStore, - private val listener: PasswordFragment.OnFragmentInteractionListener, - values: ArrayList<PasswordItem> -) : EntryRecyclerAdapter(values) { - var actionMode: ActionMode? = null - private var canEdit: Boolean = false - 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 - activity.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 = canEdit - 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 -> { - activity.deletePasswords(this@PasswordRecyclerAdapter, TreeSet(selectedItems)) - mode.finish() // Action picked, so close the CAB - return true - } - R.id.menu_edit_password -> { - activity.editPassword(values[selectedItems.iterator().next()]) - mode.finish() - return true - } - R.id.menu_move_password -> { - val selectedPasswords = ArrayList<PasswordItem>() - for (id in selectedItems) { - selectedPasswords.add(values[id]) - } - activity.movePasswords(selectedPasswords) - return false - } - else -> return false - } - } - - // Called when the user exits the action mode - override fun onDestroyActionMode(mode: ActionMode) { - val it = selectedItems.iterator() - while (it.hasNext()) { - // need the setSelected line in onBind - notifyItemChanged(it.next()) - it.remove() - } - actionMode = null - // show the fab - activity.findViewById<View>(R.id.fab).visibility = View.VISIBLE - } - } - - override fun getOnLongClickListener(holder: ViewHolder, pass: PasswordItem): View.OnLongClickListener { - return View.OnLongClickListener { - if (actionMode != null) { - return@OnLongClickListener false - } - toggleSelection(holder.adapterPosition) - canEdit = pass.type == PasswordItem.TYPE_PASSWORD - // Start the CAB using the ActionMode.Callback - actionMode = activity.startSupportActionMode(actionModeCallback) - actionMode?.title = "" + selectedItems.size - actionMode?.invalidate() - notifyItemChanged(holder.adapterPosition) - true - } - } - - override fun getOnClickListener(holder: ViewHolder, pass: PasswordItem): View.OnClickListener { - return View.OnClickListener { - if (actionMode != null) { - toggleSelection(holder.adapterPosition) - actionMode?.title = "" + selectedItems.size - if (selectedItems.isEmpty()) { - actionMode?.finish() - } else if (selectedItems.size == 1 && (canEdit.not())) { - if (values[selectedItems.iterator().next()].type == PasswordItem.TYPE_PASSWORD) { - canEdit = true - actionMode?.invalidate() - } - } else if (selectedItems.size >= 1 && canEdit) { - canEdit = false - actionMode?.invalidate() - } - } else { - listener.onFragmentInteraction(pass) - } - notifyItemChanged(holder.adapterPosition) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt index bff229b9..1426ef1e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt @@ -48,7 +48,7 @@ class FolderCreationDialogFragment : DialogFragment() { val materialTextView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) val folderName = materialTextView.text.toString() File("$currentDir/$folderName").mkdir() - (requireActivity() as PasswordStore).updateListAdapter() + (requireActivity() as PasswordStore).refreshPasswordList() dismiss() } diff --git a/app/src/main/java/com/zeapo/pwdstore/widget/MultiselectableConstraintLayout.kt b/app/src/main/java/com/zeapo/pwdstore/widget/MultiselectableConstraintLayout.kt deleted file mode 100644 index 65c6615b..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/widget/MultiselectableConstraintLayout.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.widget - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import androidx.constraintlayout.widget.ConstraintLayout -import com.zeapo.pwdstore.R - -class MultiselectableConstraintLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) { - private var multiselected: Boolean = false - - override fun onCreateDrawableState(extraSpace: Int): IntArray { - if (multiselected) { - val drawableState = super.onCreateDrawableState(extraSpace + 1) - View.mergeDrawableStates(drawableState, STATE_MULTISELECTED) - return drawableState - } - return super.onCreateDrawableState(extraSpace) - } - - fun setMultiSelected(on: Boolean) { - if (!multiselected) { - multiselected = true - refreshDrawableState() - } - isActivated = on - } - - companion object { - private val STATE_MULTISELECTED = intArrayOf(R.attr.state_multiselected) - } -} diff --git a/app/src/main/res/drawable/password_row_background.xml b/app/src/main/res/drawable/password_row_background.xml index 20b8540f..51f14173 100644 --- a/app/src/main/res/drawable/password_row_background.xml +++ b/app/src/main/res/drawable/password_row_background.xml @@ -1,12 +1,14 @@ <?xml version="1.0" encoding="utf-8"?> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - <item> - <selector> - <item app:state_multiselected="true" android:state_activated="true"> - <color android:color="@color/list_multiselect_background" /> - </item> - </selector> - </item> - <item android:drawable="?attr/selectableItemBackground" /> +<!-- +Requires a layer-list since attributes cannot be resolved in selectors, see: +https://stackoverflow.com/a/36424426/297261 + --> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> +<item> + <selector> + <item android:drawable="@color/list_multiselect_background" android:state_selected="true" /> + <item android:drawable="@android:color/transparent"/> + </selector> +</item> +<item android:drawable="?android:attr/selectableItemBackground"/> </layer-list> diff --git a/app/src/main/res/layout/oreo_autofill_filter_row.xml b/app/src/main/res/layout/oreo_autofill_filter_row.xml index c60c2693..8ba0c3dd 100644 --- a/app/src/main/res/layout/oreo_autofill_filter_row.xml +++ b/app/src/main/res/layout/oreo_autofill_filter_row.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<com.zeapo.pwdstore.widget.MultiselectableConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" @@ -61,4 +61,4 @@ app:layout_constraintStart_toEndOf="@id/title" app:layout_constraintTop_toTopOf="parent" /> -</com.zeapo.pwdstore.widget.MultiselectableConstraintLayout> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml index ae76d7a9..257b9d4c 100644 --- a/app/src/main/res/layout/password_row_layout.xml +++ b/app/src/main/res/layout/password_row_layout.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<com.zeapo.pwdstore.widget.MultiselectableConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" @@ -52,4 +52,4 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> -</com.zeapo.pwdstore.widget.MultiselectableConstraintLayout> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4d174061..a703ba9f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -318,8 +318,6 @@ <string name="button_create">Создать</string> <string name="pref_search_on_start">Открыть поиск на старте</string> <string name="pref_search_on_start_hint">Открыть панель поиска при запуске приложения</string> - <string name="pref_search_from_root">Всегда начинать поиск от корня</string> - <string name="pref_search_from_root_hint">Искать от корня хранилища независимо от текущей открытой директории</string> <string name="password_generator_category_title">Генератор паролей</string> <string name="tap_clear_clipboard">Нажмите здесь чтобы очистить буфер обмена</string> <string name="clone_git_repo">Для синхронизации изменений клонируйте git репозиторий</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87d97d5b..8637235b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <plurals name="delete_title"> + <item quantity="one">%d item selected</item> + <item quantity="other">%d items selected</item> + </plurals> + <!-- Activity names --> <string name="app_name" translatable="false">Password Store</string> @@ -330,8 +335,6 @@ <string name="button_create">Create</string> <string name="pref_search_on_start">Open search on start</string> <string name="pref_search_on_start_hint">Open search bar when app is launched</string> - <string name="pref_search_from_root">Always search from root</string> - <string name="pref_search_from_root_hint">Search from root of store regardless of currently open directory</string> <string name="password_generator_category_title">Password Generator</string> <string name="tap_clear_clipboard">Tap here to clear clipboard</string> <string name="clone_git_repo">Clone a git repository to sync changes</string> diff --git a/app/src/main/res/xml/preference.xml b/app/src/main/res/xml/preference.xml index 31c6de5a..fc041ee1 100644 --- a/app/src/main/res/xml/preference.xml +++ b/app/src/main/res/xml/preference.xml @@ -147,11 +147,6 @@ app:key="search_on_start" app:summary="@string/pref_search_on_start_hint" app:title="@string/pref_search_on_start" /> - <androidx.preference.CheckBoxPreference - app:defaultValue="false" - app:key="search_from_root" - app:summary="@string/pref_search_from_root_hint" - app:title="@string/pref_search_from_root" /> <androidx.preference.ListPreference app:title="@string/pref_sort_order_title" app:defaultValue="FOLDER_FIRST" diff --git a/dependencies.gradle b/dependencies.gradle index b5385778..b1b343c0 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -37,18 +37,19 @@ ext.deps = [ documentfile: 'androidx.documentfile:documentfile:1.0.1', fragment_ktx: 'androidx.fragment:fragment-ktx:1.1.0', lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01', - lifecycle_runtime_ktx: 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha01', + lifecycle_viewmodel_ktx: 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-alpha01', local_broadcast_manager: 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01', material: 'com.google.android.material:material:1.2.0-alpha05', preference: 'androidx.preference:preference:1.1.0', - recycler_view: 'androidx.recyclerview:recyclerview:1.2.0-alpha01', + recycler_view: 'androidx.recyclerview:recyclerview:1.2.0-alpha02', + recycler_view_selection: 'androidx.recyclerview:recyclerview-selection:1.1.0-rc01', swiperefreshlayout: 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01' ], third_party: [ commons_io: 'commons-io:commons-io:2.5', commons_codec: 'commons-codec:commons-codec:1.13', - fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.1', + fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.2', jsch: 'com.jcraft:jsch:0.1.55', jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r', leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.2', |