summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/build.gradle5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt331
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt115
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt344
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt4
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt84
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt47
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.java2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt71
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/adapters/EntryRecyclerAdapter.kt148
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/adapters/FolderRecyclerAdapter.kt23
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt88
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordRecyclerAdapter.kt120
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/widget/MultiselectableConstraintLayout.kt41
-rw-r--r--app/src/main/res/drawable/password_row_background.xml22
-rw-r--r--app/src/main/res/layout/oreo_autofill_filter_row.xml4
-rw-r--r--app/src/main/res/layout/password_row_layout.xml4
-rw-r--r--app/src/main/res/values-ru/strings.xml2
-rw-r--r--app/src/main/res/values/strings.xml7
-rw-r--r--app/src/main/res/xml/preference.xml5
-rw-r--r--dependencies.gradle7
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',