summaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt5
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt274
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt22
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt143
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt2
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)
}