diff options
author | Fabian Henneke <FabianHenneke@users.noreply.github.com> | 2020-04-06 22:56:52 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-04-07 02:26:52 +0530 |
commit | e3a49e2632cfda9c548728ae306ceb06374855ea (patch) | |
tree | 2f822eb194f6b4e0e74ab68f5009571d35c2a8e0 /app/src/main/java/com/zeapo | |
parent | 034babcbf40517e6abfce5aa7ae5ca647033f971 (diff) |
Modernize file listing and search in AutofillFilterActivity (#683)
* WIP: Modernize file listing and search
* Refactor
* Implement fuzzy search
* Improve ViewModel API and introduce Adapter
* Integrate new search into AutofillFilterActivity and dedebounce
* Improve no results layout
* Reformat
* Highlight origin in FileBased directory structure
* Extract highlighting logic into DirectoryStructure
* Trim whitespace before searching
* Remove debug logging
* Remove more debug logging
* Organize imports
* Remove imports
* Update app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt
Co-Authored-By: Harsh Shandilya <me@msfjarvis.dev>
* Address review comments
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/com/zeapo')
5 files changed, 362 insertions, 84 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index 52d3881e..fc0a0c00 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -159,11 +159,12 @@ class PasswordFragment : Fragment() { /** 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(getRepositoryDirectory(requireContext()), sortOrder) + getPasswords(currentDir, sortOrder) else - getPasswords(pathStack.peek(), getRepositoryDirectory(requireContext()), sortOrder) + getPasswords(currentDir, getRepositoryDirectory(requireContext()), sortOrder) ) } diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt new file mode 100644 index 00000000..a4e52b41 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt @@ -0,0 +1,274 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore + +import android.app.Application +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences +import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure +import com.zeapo.pwdstore.utils.PasswordItem +import com.zeapo.pwdstore.utils.PasswordRepository +import java.io.File +import java.text.Collator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.yield + +private fun File.toPasswordItem(root: File) = if (isFile) + PasswordItem.newPassword(name, this, root) +else + PasswordItem.newCategory(name, this, root) + +private fun PasswordItem.fuzzyMatch(filter: String): Int { + var i = 0 + var j = 0 + var score = 0 + var bonus = 0 + var bonusIncrement = 0 + + val toMatch = longName + + while (i < filter.length && j < toMatch.length) { + when { + filter[i].isWhitespace() -> i++ + filter[i].toLowerCase() == toMatch[j].toLowerCase() -> { + i++ + bonusIncrement += 1 + bonus += bonusIncrement + score += bonus + } + else -> { + bonus = 0 + bonusIncrement = 0 + } + } + j++ + } + return if (i == filter.length) score else 0 +} + +private val CaseInsensitiveComparator = Collator.getInstance().apply { + strength = Collator.PRIMARY +} + +private fun PasswordItem.Companion.makeComparator( + typeSortOrder: PasswordRepository.PasswordSortOrder, + directoryStructure: DirectoryStructure +): Comparator<PasswordItem> { + return when (typeSortOrder) { + PasswordRepository.PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } + PasswordRepository.PasswordSortOrder.INDEPENDENT -> compareBy<PasswordItem>() + PasswordRepository.PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + } + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { + directoryStructure.getIdentifierFor( + it.file + ) + }) + .then(compareBy(CaseInsensitiveComparator) { directoryStructure.getUsernameFor(it.file) }) +} + +enum class FilterMode { + ListOnly, + StrictDomain, + Fuzzy +} + +enum class SearchMode { + RecursivelyInSubdirectories, + InCurrentDirectoryOnly +} + +private data class SearchAction( + val currentDir: File, + val filter: String, + val filterMode: FilterMode, + val searchMode: SearchMode, + val listFilesOnly: Boolean +) + +@ExperimentalCoroutinesApi +@FlowPreview +class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { + private val root = PasswordRepository.getRepositoryDirectory(application) + 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)) { + 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 searchAction = MutableLiveData( + SearchAction( + currentDir = root, + filter = "", + filterMode = FilterMode.ListOnly, + searchMode = SearchMode.InCurrentDirectoryOnly, + listFilesOnly = true + ) + ) + private val searchActionFlow = searchAction.asFlow() + .map { it.copy(filter = it.filter.trim()) } + .distinctUntilChanged() + + private val passwordItemsFlow = 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) + } + 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 -> { + prefilteredResultFlow + .map { it.toPasswordItem(root) } + .toList() + .sortedWith(itemComparator) + } + FilterMode.StrictDomain -> { + check(searchAction.listFilesOnly) { "Searches with StrictDomain search mode can only list files" } + prefilteredResultFlow + .filter { file -> + val toMatch = + directoryStructure.getIdentifierFor(file) ?: return@filter false + // In strict domain mode, we match + // * the search term exactly, + // * subdomains of the search term, + // * or the search term plus an arbitrary protocol. + toMatch == searchAction.filter || + toMatch.endsWith(".${searchAction.filter}") || + toMatch.endsWith("://${searchAction.filter}") + } + .map { it.toPasswordItem(root) } + .toList() + .sortedWith(itemComparator) + } + FilterMode.Fuzzy -> { + prefilteredResultFlow + .map { + val item = it.toPasswordItem(root) + Pair(item.fuzzyMatch(searchAction.filter), item) + } + .filter { it.first > 0 } + .toList() + .sortedWith( + compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy( + itemComparator + ) { it.second }) + .map { it.second } + } + } + } + + 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 + } else { + !isHidden && file.extension == "gpg" + } + } + + private fun listFiles(dir: File): Flow<File> { + return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow() + } + + private fun listFilesRecursively(dir: File): Flow<File> { + return dir + .walkTopDown().onEnter { file -> shouldTake(file) } + .asFlow() + .map { + yield() + it + } + .filter { file -> shouldTake(file) } + } +} + +private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() { + override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = + oldItem.file.absolutePath == newItem.file.absolutePath + + override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem +} + +class DelegatedSearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( + private val layoutRes: Int, + private val viewHolderCreator: (view: View) -> T, + private val viewHolderBinder: T.(item: PasswordItem) -> Unit +) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback) { + + 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)) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt index c1ce51e9..bedb551d 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt @@ -25,8 +25,26 @@ enum class DirectoryStructure(val value: String) { } fun getIdentifierFor(file: File) = when (this) { - FileBased -> file.parentFile?.name - DirectoryBased -> file.parentFile?.parentFile?.name + FileBased -> file.parentFile.name + DirectoryBased -> file.parentFile.parentFile?.name + } + + /** + * Returns the path components of [file] until right before the component that contains the + * origin identifier according to the current [DirectoryStructure]. + * + * Examples: + * - /work/example.org/john@doe.org --> /work (FileBased) + * - /work/example.org/john@doe.org/password --> /work (DirectoryBased) + */ + fun getPathToIdentifierFor(file: File) = when (this) { + FileBased -> file.parentFile.parent + DirectoryBased -> file.parentFile.parentFile?.parent + } + + fun getAccountPartFor(file: File) = when (this) { + FileBased -> file.nameWithoutExtension + DirectoryBased -> "${file.parentFile.name}/${file.nameWithoutExtension}" } @RequiresApi(Build.VERSION_CODES.O) 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 e4517197..2bfe0ad3 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 @@ -13,26 +13,33 @@ import android.os.Build import android.os.Bundle import android.view.autofill.AutofillManager import android.widget.TextView +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.underline import androidx.core.widget.addTextChangedListener -import androidx.preference.PreferenceManager +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.DividerItemDecoration -import com.afollestad.recyclical.datasource.dataSourceOf -import com.afollestad.recyclical.setup -import com.afollestad.recyclical.withItem +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.R +import com.zeapo.pwdstore.SearchMode +import com.zeapo.pwdstore.SearchableRepositoryViewModel import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.FormOrigin import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.utils.PasswordRepository -import java.io.File -import java.nio.file.Paths -import java.util.Locale 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() { @@ -66,15 +73,13 @@ class AutofillFilterView : AppCompatActivity() { } } - private val dataSource = dataSourceOf() - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } - private val sortOrder - get() = PasswordRepository.PasswordSortOrder.getSortOrder(preferences) - private lateinit var formOrigin: FormOrigin - private lateinit var repositoryRoot: File private lateinit var directoryStructure: DirectoryStructure + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_oreo_autofill_filter) @@ -103,7 +108,6 @@ class AutofillFilterView : AppCompatActivity() { return } } - repositoryRoot = PasswordRepository.getRepositoryDirectory(this) directoryStructure = AutofillPreferences.directoryStructure(this) supportActionBar?.hide() @@ -112,35 +116,55 @@ class AutofillFilterView : AppCompatActivity() { } private fun bindUI() { - // setup is an extension method provided by recyclical - rvPassword.setup { - withDataSource(dataSource) - withItem<PasswordItem, PasswordViewHolder>(R.layout.oreo_autofill_filter_row) { - onBind(::PasswordViewHolder) { _, item -> - when (directoryStructure) { - DirectoryStructure.FileBased -> { - title.text = item.file.relativeTo(item.rootDir).parent - subtitle.text = item.file.nameWithoutExtension - } - DirectoryStructure.DirectoryBased -> { - title.text = - item.file.relativeTo(item.rootDir).parentFile?.parent ?: "/INVALID" - subtitle.text = - Paths.get(item.file.parentFile.name, item.file.nameWithoutExtension) - .toString() - } - } - } - onClick { decryptAndFill(item) } + val searchableAdapter = DelegatedSearchableRepositoryAdapter( + R.layout.oreo_autofill_filter_row, + ::PasswordViewHolder + ) { item -> + val file = item.file.relativeTo(item.rootDir) + val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) + val identifier = directoryStructure.getIdentifierFor(file) ?: "INVALID" + val accountPart = directoryStructure.getAccountPartFor(file) + title.text = buildSpannedString { + pathToIdentifier?.let { append("$it/") } + bold { underline { append(identifier) } } } + subtitle.text = accountPart + itemView.setOnClickListener { decryptAndFill(item) } + } + rvPassword.apply { + adapter = searchableAdapter + layoutManager = LinearLayoutManager(context) + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) } - rvPassword.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) - search.addTextChangedListener { recursiveFilter(it.toString(), strict = false) } - val initialFilter = - formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + val initialFilter = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) search.setText(initialFilter, TextView.BufferType.EDITABLE) - recursiveFilter(initialFilter, strict = formOrigin is FormOrigin.Web) + val filterMode = + if (formOrigin is FormOrigin.Web) FilterMode.StrictDomain else FilterMode.Fuzzy + model.search( + initialFilter, + filterMode = filterMode, + searchMode = SearchMode.RecursivelyInSubdirectories, + listFilesOnly = true + ) + search.addTextChangedListener { + model.search( + it.toString(), + filterMode = FilterMode.Fuzzy, + searchMode = SearchMode.RecursivelyInSubdirectories, + listFilesOnly = true + ) + } + 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() + }) shouldMatch.text = getString( R.string.oreo_autofill_match_with, @@ -172,43 +196,4 @@ class AutofillFilterView : AppCompatActivity() { finish() } } - - private fun File.matches(filter: String, strict: Boolean): Boolean { - return if (strict) { - val toMatch = directoryStructure.getIdentifierFor(this) ?: return false - // In strict mode, we match - // * the search term exactly, - // * subdomains of the search term, - // * or the search term plus an arbitrary protocol. - toMatch == filter || toMatch.endsWith(".$filter") || toMatch.endsWith("://$filter") - } else { - val toMatch = - "${relativeTo(repositoryRoot).path}/$nameWithoutExtension".toLowerCase(Locale.getDefault()) - toMatch.contains(filter.toLowerCase(Locale.getDefault())) - } - } - - private fun recursiveFilter(filter: String, dir: File? = null, strict: Boolean = true) { - // on the root the pathStack is empty - val passwordItems = if (dir == null) { - PasswordRepository.getPasswords(repositoryRoot, sortOrder) - } else { - PasswordRepository.getPasswords(dir, repositoryRoot, sortOrder) - } - - for (item in passwordItems) { - if (item.type == PasswordItem.TYPE_CATEGORY) { - recursiveFilter(filter, item.file, strict = strict) - } else { - // TODO: Implement fuzzy search if strict == false? - val matches = item.file.matches(filter, strict = strict) - val inAdapter = dataSource.contains(item) - if (matches && !inAdapter) { - dataSource.add(item) - } else if (!matches && inAdapter) { - dataSource.remove(item) - } - } - } - } } diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt index f6ad7a4d..71c7cf46 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt @@ -9,7 +9,7 @@ import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.zeapo.pwdstore.R -class PasswordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { +class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { val title: TextView = itemView.findViewById(R.id.title) val subtitle: TextView = itemView.findViewById(R.id.subtitle) } |