mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-08-30 13:57:47 +00:00
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>
This commit is contained in:
parent
034babcbf4
commit
e3a49e2632
@ -78,11 +78,14 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation deps.androidx.annotation
|
||||
implementation deps.androidx.activity_ktx
|
||||
implementation deps.androidx.appcompat
|
||||
implementation deps.androidx.biometric
|
||||
implementation deps.androidx.constraint_layout
|
||||
implementation deps.androidx.core_ktx
|
||||
implementation deps.androidx.documentfile
|
||||
implementation deps.androidx.fragment_ktx
|
||||
implementation deps.androidx.lifecycle_livedata_ktx
|
||||
implementation deps.androidx.lifecycle_runtime_ktx
|
||||
implementation deps.androidx.local_broadcast_manager
|
||||
implementation deps.androidx.material
|
||||
@ -102,7 +105,6 @@ dependencies {
|
||||
implementation deps.third_party.jsch
|
||||
implementation deps.third_party.openpgp_ktx
|
||||
implementation deps.third_party.publicsuffixlist
|
||||
implementation deps.third_party.recyclical
|
||||
implementation deps.third_party.ssh_auth
|
||||
implementation deps.third_party.timber
|
||||
implementation deps.third_party.timberkt
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -28,7 +28,7 @@
|
||||
android:layout_marginTop="@dimen/activity_vertical_margin"
|
||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||
app:endIconMode="clear_text"
|
||||
app:layout_constraintBottom_toTopOf="@id/rvPassword"
|
||||
app:layout_constraintBottom_toTopOf="@id/rvPasswordSwitcher"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/cover">
|
||||
@ -42,19 +42,34 @@
|
||||
android:inputType="text"
|
||||
tools:text="example.com" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvPassword"
|
||||
<ViewSwitcher
|
||||
android:id="@+id/rvPasswordSwitcher"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="@dimen/activity_vertical_margin"
|
||||
android:scrollbars="vertical"
|
||||
app:layout_constraintBottom_toTopOf="@id/shouldMatch"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/searchLayout"
|
||||
tools:itemCount="5"
|
||||
tools:listitem="@layout/password_row_layout" />
|
||||
android:layout_height="0dp">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
tools:itemCount="5"
|
||||
tools:listitem="@layout/password_row_layout" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rvPasswordEmpty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="@dimen/activity_horizontal_margin"
|
||||
android:layout_marginTop="@dimen/activity_vertical_margin"
|
||||
android:text="@string/oreo_autofill_filter_no_results"
|
||||
android:textSize="18sp" />
|
||||
|
||||
</ViewSwitcher>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/shouldMatch"
|
||||
@ -67,7 +82,7 @@
|
||||
app:layout_constraintBottom_toTopOf="@id/shouldClear"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/rvPassword"
|
||||
app:layout_constraintTop_toBottomOf="@id/rvPasswordSwitcher"
|
||||
app:layout_constraintVertical_bias="1.0"
|
||||
tools:text="Match with example.org" />
|
||||
|
||||
|
@ -247,6 +247,7 @@
|
||||
<!-- Oreo Autofill -->
|
||||
<string name="oreo_autofill_match_with">Match with %1$s</string>
|
||||
<string name="oreo_autofill_matches_clear_existing">Clear existing matches</string>
|
||||
<string name="oreo_autofill_filter_no_results">No results.</string>
|
||||
<string name="oreo_autofill_search_in_store">Search in store…</string>
|
||||
<string name="oreo_autofill_save_internal_error">Save failed due to an internal error</string>
|
||||
<string name="oreo_autofill_save_app_not_supported">This app is currently not supported</string>
|
||||
|
@ -29,11 +29,14 @@ ext.deps = [
|
||||
|
||||
androidx: [
|
||||
annotation: 'androidx.annotation:annotation:1.1.0',
|
||||
activity_ktx: 'androidx.activity:activity-ktx:1.1.0',
|
||||
appcompat: 'androidx.appcompat:appcompat:1.2.0-alpha03',
|
||||
biometric: 'androidx.biometric:biometric:1.0.1',
|
||||
constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta4',
|
||||
core_ktx: 'androidx.core:core-ktx:1.3.0-alpha02',
|
||||
documentfile: 'androidx.documentfile:documentfile:1.0.1',
|
||||
fragment_ktx: 'androidx.fragment:fragment-ktx:1.1.0',
|
||||
lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01',
|
||||
lifecycle_runtime_ktx: 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha01',
|
||||
local_broadcast_manager: 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01',
|
||||
material: 'com.google.android.material:material:1.2.0-alpha05',
|
||||
@ -55,7 +58,6 @@ ext.deps = [
|
||||
// a reference to the latest version is warranted.
|
||||
// See: https://github.com/mozilla-mobile/android-components/blob/master/components/lib/publicsuffixlist/README.md
|
||||
publicsuffixlist: 'org.mozilla.components:lib-publicsuffixlist:+',
|
||||
recyclical: 'com.afollestad:recyclical:1.1.1',
|
||||
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
|
||||
timber: 'com.jakewharton.timber:timber:4.7.1',
|
||||
timberkt: 'com.github.ajalt:timberkt:1.5.1',
|
||||
|
Loading…
x
Reference in New Issue
Block a user