Modernize legacy RecyclerView adapters (#694)

* Modernize legacy RecyclerView adapters

Introduces new adapters based on the SearchableRepositoryViewModel and
using androidx.recyclerview.selection for multiselection support.

The following positive effects in behavior are observable to end-users:

- Search and navigation actions are executed on IO threads.
- RecyclerViews are now animated during searches (but not navigations).
- Exact scroll position is restored when navigating back.
- The ActionBar title is updated with the current folder name.

The following negative effects may warrant attention:

- Support for the "always search from root" setting has been removed.
- Due to a limitation of the fast scroll dependency, using the scroller
  may result in unwanted multiselections. If this is not fixed in the
  library, native fast scroller capabilities could be used, but these
  are more limited in appearance and to not offer popups.

* Fix lint

* Fix FastScroller/SelectionTracker incompatibility

* Immediately react to settings changes

* List directory entries when search term is blank

* Use isEmpty() instead of == ""

* Replace adapter inheritance with builders and fix selection drags

* Remove dividers in password lists

* Run spotlessApply

* Use a more logical string in action mode

* Commonize and constify path bundle key

* Make lambda parameter name explicit

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Fabian Henneke 2020-04-10 13:18:42 +02:00 committed by GitHub
parent 2738d7500f
commit 575ef84726
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 746 additions and 734 deletions

View file

@ -86,12 +86,13 @@ dependencies {
implementation deps.androidx.documentfile implementation deps.androidx.documentfile
implementation deps.androidx.fragment_ktx implementation deps.androidx.fragment_ktx
implementation deps.androidx.lifecycle_livedata_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.local_broadcast_manager
implementation deps.androidx.material implementation deps.androidx.material
implementation deps.androidx.preference implementation deps.androidx.preference
implementation deps.androidx.swiperefreshlayout 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.android
implementation deps.kotlin.coroutines.core implementation deps.kotlin.coroutines.core

View file

@ -6,57 +6,43 @@ package com.zeapo.pwdstore
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.DividerItemDecoration import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.git.GitActivity 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.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository 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.io.File
import java.util.Stack import java.util.Stack
import me.zhanghai.android.fastscroll.FastScrollerBuilder 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() { class PasswordFragment : Fragment() {
// store the pass files list in a stack private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
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 recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
private lateinit var listener: OnFragmentInteractionListener private lateinit var listener: OnFragmentInteractionListener
private lateinit var settings: SharedPreferences
private lateinit var swipeRefresher: SwipeRefreshLayout private lateinit var swipeRefresher: SwipeRefreshLayout
override fun onCreate(savedInstanceState: Bundle?) { private var recyclerViewStateToRestore: Parcelable? = null
super.onCreate(savedInstanceState) private var actionMode: ActionMode? = null
val path = requireNotNull(requireArguments().getString("Path"))
settings = PreferenceManager.getDefaultSharedPreferences(requireActivity()) private val model: SearchableRepositoryViewModel by activityViewModels()
recyclerAdapter = PasswordRecyclerAdapter((requireActivity() as PasswordStore),
listener, getPasswords(File(path), getRepositoryDirectory(requireContext()), sortOrder)) private fun requireStore() = requireActivity() as PasswordStore
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -64,12 +50,28 @@ class PasswordFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
val view = inflater.inflate(R.layout.password_recycler_view, container, false) val view = inflater.inflate(R.layout.password_recycler_view, container, false)
// use a linear layout manager initializePasswordList(view)
val layoutManager = LinearLayoutManager(requireContext()) val fab = view.findViewById<FloatingActionButton>(R.id.fab)
swipeRefresher = view.findViewById(R.id.swipe_refresher) 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 { swipeRefresher.setOnRefreshListener {
if (!PasswordRepository.isGitRepo()) { 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 swipeRefresher.isRefreshing = false
} else { } else {
val intent = Intent(context, GitActivity::class.java) val intent = Intent(context, GitActivity::class.java)
@ -77,30 +79,54 @@ class PasswordFragment : Fragment() {
startActivityForResult(intent, GitActivity.REQUEST_SYNC) startActivityForResult(intent, GitActivity.REQUEST_SYNC)
} }
} }
recyclerView = view.findViewById(R.id.pass_recycler)
recyclerView.layoutManager = layoutManager recyclerAdapter = PasswordItemRecyclerAdapter()
// use divider .onItemClicked { _, item ->
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) listener.onFragmentInteraction(item)
// Set the adapter }
recyclerView.adapter = recyclerAdapter .onSelectionChanged { selection ->
// Setup fast scroller // 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
}
// 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() FastScrollerBuilder(recyclerView).build()
val fab = view.findViewById<FloatingActionButton>(R.id.fab) recyclerAdapter.makeSelectable(recyclerView)
fab.setOnClickListener {
toggleFabExpand(fab)
}
view.findViewById<FloatingActionButton>(R.id.create_folder).setOnClickListener {
(requireActivity() as PasswordStore).createFolder()
toggleFabExpand(fab)
}
view.findViewById<FloatingActionButton>(R.id.create_password).setOnClickListener {
(requireActivity() as PasswordStore).createPassword()
toggleFabExpand(fab)
}
registerForContextMenu(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) { private fun toggleFabExpand(fab: FloatingActionButton) = with(fab) {
@ -109,30 +135,80 @@ class PasswordFragment : Fragment() {
animate().rotationBy(if (isExpanded) -45f else 45f).setDuration(100).start() 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) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
try { try {
listener = object : OnFragmentInteractionListener { listener = object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) { override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) { // push the current password list (non filtered plz!) if (item.type == PasswordItem.TYPE_CATEGORY) {
passListStack.push( requireStore().clearSearch()
if (pathStack.isEmpty()) model.navigateTo(
getPasswords(getRepositoryDirectory(context), sortOrder) item.file,
else recyclerViewState = recyclerView.layoutManager!!.onSaveInstanceState()
getPasswords(pathStack.peek(), getRepositoryDirectory(context), sortOrder)
) )
// push the category were we're going requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
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)
} else { } else {
if (requireArguments().getBoolean("matchWith", false)) { if (requireArguments().getBoolean("matchWith", false)) {
(requireActivity() as PasswordStore).matchPasswordWithApp(item) requireStore().matchPasswordWithApp(item)
} else { } else {
(requireActivity() as PasswordStore).decryptPassword(item) requireStore().decryptPassword(item)
} }
} }
} }
@ -146,120 +222,27 @@ class PasswordFragment : Fragment() {
swipeRefresher.isRefreshing = false 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 * Returns true if the back press was handled by the [Fragment].
*
* @param filter the filter to apply
*/ */
fun filterAdapter(filter: String) { fun onBackPressedInActivity(): Boolean {
if (filter.isEmpty()) { if (!model.canNavigateBack)
refreshAdapter() return false
} else { // The RecyclerView state is restored when the asynchronous update operation on the
recursiveFilter( // adapter is completed.
filter, recyclerViewStateToRestore = model.navigateBack()
if (pathStack.isEmpty() || if (!model.canNavigateBack)
settings.getBoolean("search_from_root", false)) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
null return true
else pathStack.peek())
}
} }
/** val currentDir: File
* fuzzy matches the filter against the given string get() = model.currentDir.value!!
*
* 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()
fun dismissActionMode() { fun dismissActionMode() {
recyclerAdapter.actionMode?.finish() actionMode?.finish()
} }
private val sortOrder: PasswordRepository.PasswordSortOrder
get() = getSortOrder(settings)
interface OnFragmentInteractionListener { interface OnFragmentInteractionListener {
fun onFragmentInteraction(item: PasswordItem) fun onFragmentInteraction(item: PasswordItem)
} }

View file

@ -22,6 +22,7 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.MenuItem.OnActionExpandListener import android.view.MenuItem.OnActionExpandListener
import android.view.View import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
@ -29,6 +30,8 @@ import androidx.appcompat.widget.SearchView.OnQueryTextListener
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.observe
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar 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.GitActivity
import com.zeapo.pwdstore.git.GitAsyncTask import com.zeapo.pwdstore.git.GitAsyncTask
import com.zeapo.pwdstore.git.GitOperation import com.zeapo.pwdstore.git.GitOperation
import com.zeapo.pwdstore.ui.adapters.PasswordRecyclerAdapter
import com.zeapo.pwdstore.ui.dialogs.FolderCreationDialogFragment import com.zeapo.pwdstore.ui.dialogs.FolderCreationDialogFragment
import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository 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 com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder
import java.io.File import java.io.File
import java.lang.Character.UnicodeBlock import java.lang.Character.UnicodeBlock
import java.util.Stack
import org.apache.commons.io.FileUtils import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.Git
@ -68,6 +71,10 @@ class PasswordStore : AppCompatActivity() {
private var plist: PasswordFragment? = null private var plist: PasswordFragment? = null
private var shortcutManager: ShortcutManager? = null private var shortcutManager: ShortcutManager? = null
private val model: SearchableRepositoryViewModel by viewModels {
ViewModelProvider.AndroidViewModelFactory(application)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
// open search view on search key, or Ctr+F // open search view on search key, or Ctr+F
if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) &&
@ -106,6 +113,16 @@ class PasswordStore : AppCompatActivity() {
} }
super.onCreate(savedInstance) super.onCreate(savedInstance)
setContentView(R.layout.activity_pwdstore) 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() { public override fun onResume() {
@ -165,19 +182,26 @@ class PasswordStore : AppCompatActivity() {
} }
override fun onPrepareOptionsMenu(menu: Menu): Boolean { 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) searchItem = menu.findItem(R.id.action_search)
searchView = searchItem.actionView as SearchView searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener( searchView.setOnQueryTextListener(
object : OnQueryTextListener { object : OnQueryTextListener {
override fun onQueryTextSubmit(s: String): Boolean { override fun onQueryTextSubmit(s: String): Boolean {
filterListAdapter(s)
searchView.clearFocus() searchView.clearFocus()
return true return true
} }
override fun onQueryTextChange(s: String): Boolean { 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 return true
} }
}) })
@ -187,7 +211,7 @@ class PasswordStore : AppCompatActivity() {
searchItem.setOnActionExpandListener( searchItem.setOnActionExpandListener(
object : OnActionExpandListener { object : OnActionExpandListener {
override fun onMenuItemActionCollapse(item: MenuItem): Boolean { override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
refreshListAdapter() refreshPasswordList()
return true return true
} }
@ -251,7 +275,7 @@ class PasswordStore : AppCompatActivity() {
return true return true
} }
R.id.refresh -> { R.id.refresh -> {
updateListAdapter() refreshPasswordList()
return true return true
} }
android.R.id.home -> onBackPressed() android.R.id.home -> onBackPressed()
@ -266,6 +290,10 @@ class PasswordStore : AppCompatActivity() {
super.onDestroy() super.onDestroy()
} }
fun clearSearch() {
searchItem.collapseActionView()
}
fun openSettings(view: View?) { fun openSettings(view: View?) {
val intent: Intent val intent: Intent
try { try {
@ -354,7 +382,7 @@ class PasswordStore : AppCompatActivity() {
settings.edit().putBoolean("repo_changed", false).apply() settings.edit().putBoolean("repo_changed", false).apply()
plist = PasswordFragment() plist = PasswordFragment()
val args = Bundle() 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 // if the activity was started from the autofill settings, the
// intent is to match a clicked pwd with app. pass this to fragment // intent is to match a clicked pwd with app. pass this to fragment
@ -378,14 +406,8 @@ class PasswordStore : AppCompatActivity() {
} }
override fun onBackPressed() { override fun onBackPressed() {
if (null != plist && plist!!.isNotEmpty) { if (plist?.onBackPressedInActivity() != true)
plist!!.popBack()
} else {
super.onBackPressed() super.onBackPressed()
}
if (null != plist && !plist!!.isNotEmpty) {
supportActionBar!!.setDisplayHomeAsUpEnabled(false)
}
} }
private fun getRelativePath(fullPath: String, repositoryPath: String): String { private fun getRelativePath(fullPath: String, repositoryPath: String): String {
@ -450,7 +472,7 @@ class PasswordStore : AppCompatActivity() {
val intent = Intent(this, PgpActivity::class.java) val intent = Intent(this, PgpActivity::class.java)
intent.putExtra("NAME", item.toString()) intent.putExtra("NAME", item.toString())
intent.putExtra("FILE_PATH", item.file.absolutePath) 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("REPO_PATH", getRepositoryDirectory(applicationContext).absolutePath)
intent.putExtra("OPERATION", "EDIT") intent.putExtra("OPERATION", "EDIT")
startActivityForResult(intent, REQUEST_CODE_EDIT) startActivityForResult(intent, REQUEST_CODE_EDIT)
@ -481,7 +503,7 @@ class PasswordStore : AppCompatActivity() {
fun createPassword() { fun createPassword() {
if (!validateState()) return if (!validateState()) return
val currentDir = currentDir 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) val intent = Intent(this, PgpActivity::class.java)
intent.putExtra("FILE_PATH", currentDir.absolutePath) intent.putExtra("FILE_PATH", currentDir.absolutePath)
intent.putExtra("REPO_PATH", getRepositoryDirectory(applicationContext).absolutePath) intent.putExtra("REPO_PATH", getRepositoryDirectory(applicationContext).absolutePath)
@ -491,17 +513,16 @@ class PasswordStore : AppCompatActivity() {
fun createFolder() { fun createFolder() {
if (!validateState()) return 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 // deletes passwords in order from top to bottom
fun deletePasswords(adapter: PasswordRecyclerAdapter, selectedItems: MutableSet<Int>) { fun deletePasswords(selectedItems: Stack<PasswordItem>) {
val it: MutableIterator<*> = selectedItems.iterator() if (selectedItems.isEmpty()) {
if (!it.hasNext()) { refreshPasswordList()
return return
} }
val position = it.next() as Int val item = selectedItems.pop()
val item = adapter.values[position]
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setMessage(resources.getString(R.string.delete_dialog_text, item.longName)) .setMessage(resources.getString(R.string.delete_dialog_text, item.longName))
.setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
@ -512,20 +533,16 @@ class PasswordStore : AppCompatActivity() {
} }
AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
item.file.deleteRecursively() item.file.deleteRecursively()
adapter.remove(position)
it.remove()
adapter.updateSelectedItems(position, selectedItems)
commitChange(resources.getString(R.string.git_commit_remove_text, item.longName)) commitChange(resources.getString(R.string.git_commit_remove_text, item.longName))
deletePasswords(adapter, selectedItems) deletePasswords(selectedItems)
} }
.setNegativeButton(this.resources.getString(R.string.dialog_no)) { _, _ -> .setNegativeButton(this.resources.getString(R.string.dialog_no)) { _, _ ->
it.remove() deletePasswords(selectedItems)
deletePasswords(adapter, selectedItems)
} }
.show() .show()
} }
fun movePasswords(values: ArrayList<PasswordItem>) { fun movePasswords(values: List<PasswordItem>) {
val intent = Intent(this, SelectFolderActivity::class.java) val intent = Intent(this, SelectFolderActivity::class.java)
val fileLocations = ArrayList<String>() val fileLocations = ArrayList<String>()
for ((_, _, _, file) in values) { for ((_, _, _, file) in values) {
@ -536,21 +553,28 @@ class PasswordStore : AppCompatActivity() {
startActivityForResult(intent, REQUEST_CODE_SELECT_FOLDER) startActivityForResult(intent, REQUEST_CODE_SELECT_FOLDER)
} }
/** clears adapter's content and updates it with a fresh list of passwords from the root */ /**
fun updateListAdapter() { * Resets navigation to the repository root and refreshes the password list accordingly.
plist?.updateAdapter() *
* 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)
} }
/** Updates the adapter with the current view of passwords */ /**
private fun refreshListAdapter() { * Refreshes the password list by re-executing the last navigation or search action.
plist?.refreshAdapter() *
* 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 fun filterListAdapter(filter: String) { private val currentDir: File
plist?.filterAdapter(filter)
}
private val currentDir: File?
get() = plist?.currentDir ?: getRepositoryDirectory(applicationContext) get() = plist?.currentDir ?: getRepositoryDirectory(applicationContext)
private fun commitChange(message: String) { private fun commitChange(message: String) {
@ -578,14 +602,14 @@ class PasswordStore : AppCompatActivity() {
data.extras!!.getString("LONG_NAME"))) data.extras!!.getString("LONG_NAME")))
} }
} }
refreshListAdapter() refreshPasswordList()
} }
REQUEST_CODE_ENCRYPT -> { REQUEST_CODE_ENCRYPT -> {
commitChange(this.resources commitChange(this.resources
.getString( .getString(
R.string.git_commit_add_text, R.string.git_commit_add_text,
data!!.extras!!.getString("LONG_NAME"))) data!!.extras!!.getString("LONG_NAME")))
refreshListAdapter() refreshPasswordList()
} }
REQUEST_CODE_EDIT -> { REQUEST_CODE_EDIT -> {
commitChange( commitChange(
@ -593,10 +617,10 @@ class PasswordStore : AppCompatActivity() {
.getString( .getString(
R.string.git_commit_edit_text, R.string.git_commit_edit_text,
data!!.extras!!.getString("LONG_NAME"))) data!!.extras!!.getString("LONG_NAME")))
refreshListAdapter() refreshPasswordList()
} }
GitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo() GitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo()
GitActivity.REQUEST_SYNC, GitActivity.REQUEST_PULL -> updateListAdapter() GitActivity.REQUEST_SYNC, GitActivity.REQUEST_PULL -> resetPasswordList()
HOME -> checkLocalRepository() HOME -> checkLocalRepository()
// duplicate code // duplicate code
CLONE_REPO_BUTTON -> { CLONE_REPO_BUTTON -> {
@ -677,7 +701,7 @@ class PasswordStore : AppCompatActivity() {
destinationLongName)) destinationLongName))
} }
} }
updateListAdapter() resetPasswordList()
if (plist != null) { if (plist != null) {
plist!!.dismissActionMode() plist!!.dismissActionMode()
} }
@ -760,6 +784,7 @@ class PasswordStore : AppCompatActivity() {
const val REQUEST_CODE_GET_KEY_IDS = 9915 const val REQUEST_CODE_GET_KEY_IDS = 9915
const val REQUEST_CODE_EDIT = 9916 const val REQUEST_CODE_EDIT = 9916
const val REQUEST_CODE_SELECT_FOLDER = 9917 const val REQUEST_CODE_SELECT_FOLDER = 9917
const val REQUEST_ARG_PATH = "PATH"
private val TAG = PasswordStore::class.java.name private val TAG = PasswordStore::class.java.name
private const val CLONE_REPO_BUTTON = 401 private const val CLONE_REPO_BUTTON = 401
private const val NEW_REPO_BUTTON = 402 private const val NEW_REPO_BUTTON = 402

View file

@ -5,14 +5,23 @@
package com.zeapo.pwdstore package com.zeapo.pwdstore
import android.app.Application import android.app.Application
import android.content.Context
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.preference.PreferenceManager 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.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -22,6 +31,8 @@ import com.zeapo.pwdstore.utils.PasswordItem
import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File import java.io.File
import java.text.Collator import java.text.Collator
import java.util.Locale
import java.util.Stack
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -32,8 +43,10 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import me.zhanghai.android.fastscroll.PopupTextProvider
private fun File.toPasswordItem(root: File) = if (isFile) private fun File.toPasswordItem(root: File) = if (isFile)
PasswordItem.newPassword(name, this, root) PasswordItem.newPassword(name, this, root)
@ -89,8 +102,11 @@ private fun PasswordItem.Companion.makeComparator(
.then(compareBy(CaseInsensitiveComparator) { directoryStructure.getUsernameFor(it.file) }) .then(compareBy(CaseInsensitiveComparator) { directoryStructure.getUsernameFor(it.file) })
} }
val PasswordItem.stableId: String
get() = file.absolutePath
enum class FilterMode { enum class FilterMode {
ListOnly, NoFilter,
StrictDomain, StrictDomain,
Fuzzy Fuzzy
} }
@ -100,65 +116,109 @@ enum class SearchMode {
InCurrentDirectoryOnly InCurrentDirectoryOnly
} }
private data class SearchAction( enum class ListMode {
val currentDir: File, FilesOnly,
val filter: String, DirectoriesOnly,
val filterMode: FilterMode, AllEntries
val searchMode: SearchMode, }
val listFilesOnly: Boolean
)
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@FlowPreview @FlowPreview
class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { 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 settings = PreferenceManager.getDefaultSharedPreferences(getApplication())
private val showHiddenDirs = settings.getBoolean("show_hidden_folders", false) private val showHiddenDirs
private val searchFromRoot = settings.getBoolean("search_from_root", false) get() = settings.getBoolean("show_hidden_folders", false)
private val defaultSearchMode = if (settings.getBoolean("filter_recursively", true)) { private val defaultSearchMode
get() = if (settings.getBoolean("filter_recursively", true)) {
SearchMode.RecursivelyInSubdirectories SearchMode.RecursivelyInSubdirectories
} else { } else {
SearchMode.InCurrentDirectoryOnly SearchMode.InCurrentDirectoryOnly
} }
private val typeSortOrder = PasswordRepository.PasswordSortOrder.getSortOrder(settings) private val typeSortOrder
private val directoryStructure = AutofillPreferences.directoryStructure(application) get() = PasswordRepository.PasswordSortOrder.getSortOrder(settings)
private val itemComparator = PasswordItem.makeComparator(typeSortOrder, directoryStructure) 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( private val searchAction = MutableLiveData(
SearchAction( makeSearchAction(
currentDir = root, baseDirectory = root,
filter = "", filter = "",
filterMode = FilterMode.ListOnly, filterMode = FilterMode.NoFilter,
searchMode = SearchMode.InCurrentDirectoryOnly, searchMode = SearchMode.InCurrentDirectoryOnly,
listFilesOnly = true listMode = ListMode.AllEntries
) )
) )
private val searchActionFlow = searchAction.asFlow() private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
.map { it.copy(filter = it.filter.trim()) }
.distinctUntilChanged()
private val passwordItemsFlow = searchActionFlow data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
private val newResultFlow = searchActionFlow
.mapLatest { searchAction -> .mapLatest { searchAction ->
val dirToSearch =
if (searchFromRoot && searchAction.filterMode != FilterMode.ListOnly) root else searchAction.currentDir
val listResultFlow = when (searchAction.searchMode) { val listResultFlow = when (searchAction.searchMode) {
SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(dirToSearch) SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory)
SearchMode.InCurrentDirectoryOnly -> listFiles(dirToSearch) 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 = val filterModeToUse =
if (searchAction.filter == "") FilterMode.ListOnly else searchAction.filterMode if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode
when (filterModeToUse) { val passwordList = when (filterModeToUse) {
FilterMode.ListOnly -> { FilterMode.NoFilter -> {
prefilteredResultFlow prefilteredResultFlow
.map { it.toPasswordItem(root) } .map { it.toPasswordItem(root) }
.toList() .toList()
.sortedWith(itemComparator) .sortedWith(itemComparator)
} }
FilterMode.StrictDomain -> { 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 prefilteredResultFlow
.filter { file -> .filter { file ->
val toMatch = val toMatch =
@ -190,41 +250,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
.map { it.second } .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) { private fun shouldTake(file: File) = with(file) {
if (isDirectory) { if (isDirectory) {
!isHidden || showHiddenDirs !isHidden || showHiddenDirs
@ -247,6 +275,114 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
} }
.filter { file -> shouldTake(file) } .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>() { 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 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 layoutRes: Int,
private val viewHolderCreator: (view: View) -> T, private val viewHolderCreator: (view: View) -> T,
private val viewHolderBinder: T.(item: PasswordItem) -> Unit private val viewHolderBinder: T.(item: PasswordItem) -> Unit
) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback) { ) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { 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
}
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) val view = LayoutInflater.from(parent.context)
.inflate(layoutRes, parent, false) .inflate(layoutRes, parent, false)
return viewHolderCreator(view) return viewHolderCreator(view)
} }
override fun onBindViewHolder(holder: T, position: Int) { final override fun onBindViewHolder(holder: T, position: Int) {
viewHolderBinder.invoke(holder, getItem(position)) 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())
} }
} }

View file

@ -27,7 +27,7 @@ class SelectFolderActivity : AppCompatActivity() {
passwordList = SelectFolderFragment() passwordList = SelectFolderFragment()
val args = Bundle() val args = Bundle()
args.putString("Path", PasswordRepository.getRepositoryDirectory(applicationContext).absolutePath) args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory(applicationContext).absolutePath)
passwordList.arguments = args passwordList.arguments = args
@ -58,7 +58,7 @@ class SelectFolderActivity : AppCompatActivity() {
} }
private fun selectFolder() { private fun selectFolder() {
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir?.absolutePath) intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
setResult(Activity.RESULT_OK, intent) setResult(Activity.RESULT_OK, intent)
finish() finish()
} }

View file

@ -11,40 +11,22 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.DividerItemDecoration import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.FloatingActionButton 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.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.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 SelectFolderFragment : Fragment() { class SelectFolderFragment : Fragment() {
// store the pass files list in a stack private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
private var pathStack: Stack<File> = Stack()
private lateinit var recyclerAdapter: FolderRecyclerAdapter
private lateinit var recyclerView: RecyclerView private lateinit var recyclerView: RecyclerView
private lateinit var listener: OnFragmentInteractionListener private lateinit var listener: OnFragmentInteractionListener
override fun onCreate(savedInstanceState: Bundle?) { private val model: SearchableRepositoryViewModel by activityViewModels()
super.onCreate(savedInstanceState)
val path = requireNotNull(requireArguments().getString("Path"))
recyclerAdapter = FolderRecyclerAdapter(listener, getPasswords(File(path), getRepositoryDirectory(requireActivity()), sortOrder))
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -52,35 +34,41 @@ class SelectFolderFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
val view = inflater.inflate(R.layout.password_recycler_view, container, false) val view = inflater.inflate(R.layout.password_recycler_view, container, false)
// use a linear layout manager initializePasswordList(view)
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
val fab: FloatingActionButton = view.findViewById(R.id.fab) val fab: FloatingActionButton = view.findViewById(R.id.fab)
fab.hide() fab.hide()
registerForContextMenu(recyclerView)
return view 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) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
try { try {
listener = object : OnFragmentInteractionListener { listener = object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) { override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) { if (item.type == PasswordItem.TYPE_CATEGORY) {
// push the category were we're going model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
pathStack.push(item.file)
recyclerView.scrollToPosition(0)
recyclerAdapter.clear()
recyclerAdapter.addAll(getPasswords(
item.file,
getRepositoryDirectory(context),
sortOrder)
)
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
} }
} }
@ -91,16 +79,8 @@ class SelectFolderFragment : Fragment() {
} }
} }
/** val currentDir: File
* gets the current directory get() = model.currentDir.value!!
*
* @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()))
interface OnFragmentInteractionListener { interface OnFragmentInteractionListener {
fun onFragmentInteraction(item: PasswordItem) fun onFragmentInteraction(item: PasswordItem)

View file

@ -19,15 +19,15 @@ import androidx.core.text.bold
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.underline import androidx.core.text.underline
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DividerItemDecoration import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.DelegatedSearchableRepositoryAdapter
import com.zeapo.pwdstore.FilterMode import com.zeapo.pwdstore.FilterMode
import com.zeapo.pwdstore.ListMode
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.SearchMode import com.zeapo.pwdstore.SearchMode
import com.zeapo.pwdstore.SearchableRepositoryAdapter
import com.zeapo.pwdstore.SearchableRepositoryViewModel import com.zeapo.pwdstore.SearchableRepositoryViewModel
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences 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.autofill.oreo.FormOrigin
import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordItem
import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.* import kotlinx.android.synthetic.main.activity_oreo_autofill_filter.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@FlowPreview
@ExperimentalCoroutinesApi
@TargetApi(Build.VERSION_CODES.O) @TargetApi(Build.VERSION_CODES.O)
class AutofillFilterView : AppCompatActivity() { class AutofillFilterView : AppCompatActivity() {
@ -116,10 +112,9 @@ class AutofillFilterView : AppCompatActivity() {
} }
private fun bindUI() { private fun bindUI() {
val searchableAdapter = DelegatedSearchableRepositoryAdapter( val recyclerAdapter = SearchableRepositoryAdapter(
R.layout.oreo_autofill_filter_row, R.layout.oreo_autofill_filter_row,
::PasswordViewHolder ::PasswordViewHolder) { item ->
) { item ->
val file = item.file.relativeTo(item.rootDir) val file = item.file.relativeTo(item.rootDir)
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
val identifier = directoryStructure.getIdentifierFor(file) ?: "INVALID" val identifier = directoryStructure.getIdentifierFor(file) ?: "INVALID"
@ -129,12 +124,12 @@ class AutofillFilterView : AppCompatActivity() {
bold { underline { append(identifier) } } bold { underline { append(identifier) } }
} }
subtitle.text = accountPart subtitle.text = accountPart
itemView.setOnClickListener { decryptAndFill(item) } }.onItemClicked { _, item ->
decryptAndFill(item)
} }
rvPassword.apply { rvPassword.apply {
adapter = searchableAdapter adapter = recyclerAdapter
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
} }
val initialFilter = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) val initialFilter = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
@ -145,26 +140,26 @@ class AutofillFilterView : AppCompatActivity() {
initialFilter, initialFilter,
filterMode = filterMode, filterMode = filterMode,
searchMode = SearchMode.RecursivelyInSubdirectories, searchMode = SearchMode.RecursivelyInSubdirectories,
listFilesOnly = true listMode = ListMode.FilesOnly
) )
search.addTextChangedListener { search.addTextChangedListener {
model.search( model.search(
it.toString(), it.toString().trim(),
filterMode = FilterMode.Fuzzy, filterMode = FilterMode.Fuzzy,
searchMode = SearchMode.RecursivelyInSubdirectories, searchMode = SearchMode.RecursivelyInSubdirectories,
listFilesOnly = true listMode = ListMode.FilesOnly
) )
} }
model.passwordItemsList.observe( model.searchResult.observe(this) { result ->
this, val list = result.passwordItems
Observer { list -> recyclerAdapter.submitList(list)
searchableAdapter.submitList(list) // Switch RecyclerView out for a "no results" message if the new list is empty and
// Switch RecyclerView out for a "no results" message if the new list is empty and // the message is not yet shown (and vice versa).
// the message is not yet shown (and vice versa). if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)) )
rvPasswordSwitcher.showNext() rvPasswordSwitcher.showNext()
}) }
shouldMatch.text = getString( shouldMatch.text = getString(
R.string.oreo_autofill_match_with, R.string.oreo_autofill_match_with,

View file

@ -131,7 +131,7 @@ public class GitAsyncTask extends AsyncTask<GitCommand, Integer, String> {
if (refreshListOnEnd) { if (refreshListOnEnd) {
try { try {
((PasswordStore) this.getActivity()).updateListAdapter(); ((PasswordStore) this.getActivity()).resetPasswordList();
} catch (ClassCastException e) { } catch (ClassCastException e) {
// oups, mistake // oups, mistake
} }

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}
}

View file

@ -48,7 +48,7 @@ class FolderCreationDialogFragment : DialogFragment() {
val materialTextView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) val materialTextView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
val folderName = materialTextView.text.toString() val folderName = materialTextView.text.toString()
File("$currentDir/$folderName").mkdir() File("$currentDir/$folderName").mkdir()
(requireActivity() as PasswordStore).updateListAdapter() (requireActivity() as PasswordStore).refreshPasswordList()
dismiss() dismiss()
} }

View file

@ -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)
}
}

View file

@ -1,12 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> Requires a layer-list since attributes cannot be resolved in selectors, see:
<item> https://stackoverflow.com/a/36424426/297261
<selector> -->
<item app:state_multiselected="true" android:state_activated="true"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<color android:color="@color/list_multiselect_background" /> <item>
</item> <selector>
</selector> <item android:drawable="@color/list_multiselect_background" android:state_selected="true" />
</item> <item android:drawable="@android:color/transparent"/>
<item android:drawable="?attr/selectableItemBackground" /> </selector>
</item>
<item android:drawable="?android:attr/selectableItemBackground"/>
</layer-list> </layer-list>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?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:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -61,4 +61,4 @@
app:layout_constraintStart_toEndOf="@id/title" app:layout_constraintStart_toEndOf="@id/title"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
</com.zeapo.pwdstore.widget.MultiselectableConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?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:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -52,4 +52,4 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" /> app:layout_constraintBottom_toBottomOf="parent" />
</com.zeapo.pwdstore.widget.MultiselectableConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -318,8 +318,6 @@
<string name="button_create">Создать</string> <string name="button_create">Создать</string>
<string name="pref_search_on_start">Открыть поиск на старте</string> <string name="pref_search_on_start">Открыть поиск на старте</string>
<string name="pref_search_on_start_hint">Открыть панель поиска при запуске приложения</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="password_generator_category_title">Генератор паролей</string>
<string name="tap_clear_clipboard">Нажмите здесь чтобы очистить буфер обмена</string> <string name="tap_clear_clipboard">Нажмите здесь чтобы очистить буфер обмена</string>
<string name="clone_git_repo">Для синхронизации изменений клонируйте git репозиторий</string> <string name="clone_git_repo">Для синхронизации изменений клонируйте git репозиторий</string>

View file

@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<plurals name="delete_title">
<item quantity="one">%d item selected</item>
<item quantity="other">%d items selected</item>
</plurals>
<!-- Activity names --> <!-- Activity names -->
<string name="app_name" translatable="false">Password Store</string> <string name="app_name" translatable="false">Password Store</string>
@ -330,8 +335,6 @@
<string name="button_create">Create</string> <string name="button_create">Create</string>
<string name="pref_search_on_start">Open search on start</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_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="password_generator_category_title">Password Generator</string>
<string name="tap_clear_clipboard">Tap here to clear clipboard</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> <string name="clone_git_repo">Clone a git repository to sync changes</string>

View file

@ -147,11 +147,6 @@
app:key="search_on_start" app:key="search_on_start"
app:summary="@string/pref_search_on_start_hint" app:summary="@string/pref_search_on_start_hint"
app:title="@string/pref_search_on_start" /> 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 <androidx.preference.ListPreference
app:title="@string/pref_sort_order_title" app:title="@string/pref_sort_order_title"
app:defaultValue="FOLDER_FIRST" app:defaultValue="FOLDER_FIRST"

View file

@ -37,18 +37,19 @@ ext.deps = [
documentfile: 'androidx.documentfile:documentfile:1.0.1', documentfile: 'androidx.documentfile:documentfile:1.0.1',
fragment_ktx: 'androidx.fragment:fragment-ktx:1.1.0', fragment_ktx: 'androidx.fragment:fragment-ktx:1.1.0',
lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01', 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', local_broadcast_manager: 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01',
material: 'com.google.android.material:material:1.2.0-alpha05', material: 'com.google.android.material:material:1.2.0-alpha05',
preference: 'androidx.preference:preference:1.1.0', 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' swiperefreshlayout: 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-beta01'
], ],
third_party: [ third_party: [
commons_io: 'commons-io:commons-io:2.5', commons_io: 'commons-io:commons-io:2.5',
commons_codec: 'commons-codec:commons-codec:1.13', 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', jsch: 'com.jcraft:jsch:0.1.55',
jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r', jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r',
leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.2', leakcanary: 'com.squareup.leakcanary:leakcanary-android:2.2',