mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-09-01 06:45:19 +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:
@@ -78,11 +78,14 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation deps.androidx.annotation
|
implementation deps.androidx.annotation
|
||||||
|
implementation deps.androidx.activity_ktx
|
||||||
implementation deps.androidx.appcompat
|
implementation deps.androidx.appcompat
|
||||||
implementation deps.androidx.biometric
|
implementation deps.androidx.biometric
|
||||||
implementation deps.androidx.constraint_layout
|
implementation deps.androidx.constraint_layout
|
||||||
implementation deps.androidx.core_ktx
|
implementation deps.androidx.core_ktx
|
||||||
implementation deps.androidx.documentfile
|
implementation deps.androidx.documentfile
|
||||||
|
implementation deps.androidx.fragment_ktx
|
||||||
|
implementation deps.androidx.lifecycle_livedata_ktx
|
||||||
implementation deps.androidx.lifecycle_runtime_ktx
|
implementation deps.androidx.lifecycle_runtime_ktx
|
||||||
implementation deps.androidx.local_broadcast_manager
|
implementation deps.androidx.local_broadcast_manager
|
||||||
implementation deps.androidx.material
|
implementation deps.androidx.material
|
||||||
@@ -102,7 +105,6 @@ dependencies {
|
|||||||
implementation deps.third_party.jsch
|
implementation deps.third_party.jsch
|
||||||
implementation deps.third_party.openpgp_ktx
|
implementation deps.third_party.openpgp_ktx
|
||||||
implementation deps.third_party.publicsuffixlist
|
implementation deps.third_party.publicsuffixlist
|
||||||
implementation deps.third_party.recyclical
|
|
||||||
implementation deps.third_party.ssh_auth
|
implementation deps.third_party.ssh_auth
|
||||||
implementation deps.third_party.timber
|
implementation deps.third_party.timber
|
||||||
implementation deps.third_party.timberkt
|
implementation deps.third_party.timberkt
|
||||||
|
@@ -159,11 +159,12 @@ class PasswordFragment : Fragment() {
|
|||||||
/** refreshes the adapter with the latest opened category */
|
/** refreshes the adapter with the latest opened category */
|
||||||
fun refreshAdapter() {
|
fun refreshAdapter() {
|
||||||
recyclerAdapter.clear()
|
recyclerAdapter.clear()
|
||||||
|
val currentDir = if (pathStack.isEmpty()) getRepositoryDirectory(requireContext()) else pathStack.peek()
|
||||||
recyclerAdapter.addAll(
|
recyclerAdapter.addAll(
|
||||||
if (pathStack.isEmpty())
|
if (pathStack.isEmpty())
|
||||||
getPasswords(getRepositoryDirectory(requireContext()), sortOrder)
|
getPasswords(currentDir, sortOrder)
|
||||||
else
|
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) {
|
fun getIdentifierFor(file: File) = when (this) {
|
||||||
FileBased -> file.parentFile?.name
|
FileBased -> file.parentFile.name
|
||||||
DirectoryBased -> file.parentFile?.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)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@@ -13,26 +13,33 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.autofill.AutofillManager
|
import android.view.autofill.AutofillManager
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.core.widget.addTextChangedListener
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import com.afollestad.recyclical.datasource.dataSourceOf
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.afollestad.recyclical.setup
|
|
||||||
import com.afollestad.recyclical.withItem
|
|
||||||
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.R
|
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.AutofillMatcher
|
||||||
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
|
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
|
||||||
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
|
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 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.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() {
|
||||||
|
|
||||||
@@ -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 formOrigin: FormOrigin
|
||||||
private lateinit var repositoryRoot: File
|
|
||||||
private lateinit var directoryStructure: DirectoryStructure
|
private lateinit var directoryStructure: DirectoryStructure
|
||||||
|
|
||||||
|
private val model: SearchableRepositoryViewModel by viewModels {
|
||||||
|
ViewModelProvider.AndroidViewModelFactory(application)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_oreo_autofill_filter)
|
setContentView(R.layout.activity_oreo_autofill_filter)
|
||||||
@@ -103,7 +108,6 @@ class AutofillFilterView : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repositoryRoot = PasswordRepository.getRepositoryDirectory(this)
|
|
||||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||||
|
|
||||||
supportActionBar?.hide()
|
supportActionBar?.hide()
|
||||||
@@ -112,35 +116,55 @@ class AutofillFilterView : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun bindUI() {
|
private fun bindUI() {
|
||||||
// setup is an extension method provided by recyclical
|
val searchableAdapter = DelegatedSearchableRepositoryAdapter(
|
||||||
rvPassword.setup {
|
R.layout.oreo_autofill_filter_row,
|
||||||
withDataSource(dataSource)
|
::PasswordViewHolder
|
||||||
withItem<PasswordItem, PasswordViewHolder>(R.layout.oreo_autofill_filter_row) {
|
) { item ->
|
||||||
onBind(::PasswordViewHolder) { _, item ->
|
val file = item.file.relativeTo(item.rootDir)
|
||||||
when (directoryStructure) {
|
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
|
||||||
DirectoryStructure.FileBased -> {
|
val identifier = directoryStructure.getIdentifierFor(file) ?: "INVALID"
|
||||||
title.text = item.file.relativeTo(item.rootDir).parent
|
val accountPart = directoryStructure.getAccountPartFor(file)
|
||||||
subtitle.text = item.file.nameWithoutExtension
|
title.text = buildSpannedString {
|
||||||
|
pathToIdentifier?.let { append("$it/") }
|
||||||
|
bold { underline { append(identifier) } }
|
||||||
}
|
}
|
||||||
DirectoryStructure.DirectoryBased -> {
|
subtitle.text = accountPart
|
||||||
title.text =
|
itemView.setOnClickListener { decryptAndFill(item) }
|
||||||
item.file.relativeTo(item.rootDir).parentFile?.parent ?: "/INVALID"
|
|
||||||
subtitle.text =
|
|
||||||
Paths.get(item.file.parentFile.name, item.file.nameWithoutExtension)
|
|
||||||
.toString()
|
|
||||||
}
|
}
|
||||||
|
rvPassword.apply {
|
||||||
|
adapter = searchableAdapter
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
onClick { decryptAndFill(item) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
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(
|
shouldMatch.text = getString(
|
||||||
R.string.oreo_autofill_match_with,
|
R.string.oreo_autofill_match_with,
|
||||||
@@ -172,43 +196,4 @@ class AutofillFilterView : AppCompatActivity() {
|
|||||||
finish()
|
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 androidx.recyclerview.widget.RecyclerView
|
||||||
import com.zeapo.pwdstore.R
|
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 title: TextView = itemView.findViewById(R.id.title)
|
||||||
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
|
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
|
||||||
}
|
}
|
||||||
|
@@ -28,7 +28,7 @@
|
|||||||
android:layout_marginTop="@dimen/activity_vertical_margin"
|
android:layout_marginTop="@dimen/activity_vertical_margin"
|
||||||
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
android:layout_marginEnd="@dimen/activity_horizontal_margin"
|
||||||
app:endIconMode="clear_text"
|
app:endIconMode="clear_text"
|
||||||
app:layout_constraintBottom_toTopOf="@id/rvPassword"
|
app:layout_constraintBottom_toTopOf="@id/rvPasswordSwitcher"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/cover">
|
app:layout_constraintTop_toBottomOf="@id/cover">
|
||||||
@@ -42,20 +42,35 @@
|
|||||||
android:inputType="text"
|
android:inputType="text"
|
||||||
tools:text="example.com" />
|
tools:text="example.com" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
<ViewSwitcher
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
android:id="@+id/rvPasswordSwitcher"
|
||||||
android:id="@+id/rvPassword"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_marginTop="@dimen/activity_vertical_margin"
|
android:layout_marginTop="@dimen/activity_vertical_margin"
|
||||||
android:scrollbars="vertical"
|
|
||||||
app:layout_constraintBottom_toTopOf="@id/shouldMatch"
|
app:layout_constraintBottom_toTopOf="@id/shouldMatch"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/searchLayout"
|
app:layout_constraintTop_toBottomOf="@id/searchLayout"
|
||||||
|
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:itemCount="5"
|
||||||
tools:listitem="@layout/password_row_layout" />
|
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
|
<Switch
|
||||||
android:id="@+id/shouldMatch"
|
android:id="@+id/shouldMatch"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
@@ -67,7 +82,7 @@
|
|||||||
app:layout_constraintBottom_toTopOf="@id/shouldClear"
|
app:layout_constraintBottom_toTopOf="@id/shouldClear"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/rvPassword"
|
app:layout_constraintTop_toBottomOf="@id/rvPasswordSwitcher"
|
||||||
app:layout_constraintVertical_bias="1.0"
|
app:layout_constraintVertical_bias="1.0"
|
||||||
tools:text="Match with example.org" />
|
tools:text="Match with example.org" />
|
||||||
|
|
||||||
|
@@ -247,6 +247,7 @@
|
|||||||
<!-- Oreo Autofill -->
|
<!-- Oreo Autofill -->
|
||||||
<string name="oreo_autofill_match_with">Match with %1$s</string>
|
<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_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_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_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>
|
<string name="oreo_autofill_save_app_not_supported">This app is currently not supported</string>
|
||||||
|
@@ -29,11 +29,14 @@ ext.deps = [
|
|||||||
|
|
||||||
androidx: [
|
androidx: [
|
||||||
annotation: 'androidx.annotation:annotation:1.1.0',
|
annotation: 'androidx.annotation:annotation:1.1.0',
|
||||||
|
activity_ktx: 'androidx.activity:activity-ktx:1.1.0',
|
||||||
appcompat: 'androidx.appcompat:appcompat:1.2.0-alpha03',
|
appcompat: 'androidx.appcompat:appcompat:1.2.0-alpha03',
|
||||||
biometric: 'androidx.biometric:biometric:1.0.1',
|
biometric: 'androidx.biometric:biometric:1.0.1',
|
||||||
constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta4',
|
constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta4',
|
||||||
core_ktx: 'androidx.core:core-ktx:1.3.0-alpha02',
|
core_ktx: 'androidx.core:core-ktx:1.3.0-alpha02',
|
||||||
documentfile: 'androidx.documentfile:documentfile:1.0.1',
|
documentfile: 'androidx.documentfile:documentfile:1.0.1',
|
||||||
|
fragment_ktx: 'androidx.fragment:fragment-ktx:1.1.0',
|
||||||
|
lifecycle_livedata_ktx: 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01',
|
||||||
lifecycle_runtime_ktx: 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha01',
|
lifecycle_runtime_ktx: 'androidx.lifecycle:lifecycle-runtime-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',
|
||||||
@@ -55,7 +58,6 @@ ext.deps = [
|
|||||||
// a reference to the latest version is warranted.
|
// a reference to the latest version is warranted.
|
||||||
// See: https://github.com/mozilla-mobile/android-components/blob/master/components/lib/publicsuffixlist/README.md
|
// See: https://github.com/mozilla-mobile/android-components/blob/master/components/lib/publicsuffixlist/README.md
|
||||||
publicsuffixlist: 'org.mozilla.components:lib-publicsuffixlist:+',
|
publicsuffixlist: 'org.mozilla.components:lib-publicsuffixlist:+',
|
||||||
recyclical: 'com.afollestad:recyclical:1.1.1',
|
|
||||||
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
|
ssh_auth: 'org.sufficientlysecure:sshauthentication-api:1.0',
|
||||||
timber: 'com.jakewharton.timber:timber:4.7.1',
|
timber: 'com.jakewharton.timber:timber:4.7.1',
|
||||||
timberkt: 'com.github.ajalt:timberkt:1.5.1',
|
timberkt: 'com.github.ajalt:timberkt:1.5.1',
|
||||||
|
Reference in New Issue
Block a user