diff options
Diffstat (limited to 'app/src/main/java')
14 files changed, 716 insertions, 704 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) } 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) - } -} |