Refactor navigation and search code (#2134)

This commit is contained in:
Harsh Shandilya
2022-09-21 02:20:31 +05:30
committed by GitHub
parent de1325e1fc
commit 1e033792d6
7 changed files with 153 additions and 109 deletions

View File

@@ -21,7 +21,7 @@ import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.underline
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import app.passwordstore.R
@@ -38,6 +38,8 @@ import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter
import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import logcat.LogPriority.ERROR
import logcat.logcat
@@ -85,9 +87,7 @@ class AutofillFilterView : AppCompatActivity() {
private lateinit var directoryStructure: DirectoryStructure
private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
private val model: SearchableRepositoryViewModel by viewModels {
ViewModelProvider.AndroidViewModelFactory(application)
}
private val model: SearchableRepositoryViewModel by viewModels()
private val decryptAction =
registerForActivityResult(StartActivityForResult()) { result ->
@@ -193,20 +193,23 @@ class AutofillFilterView : AppCompatActivity() {
R.string.oreo_autofill_match_with,
formOrigin.getPrettyIdentifier(applicationContext)
)
model.searchResult.observe(this@AutofillFilterView) { result ->
val list = result.passwordItems
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
rvPassword.scrollToPosition(0)
model.searchResult
.flowWithLifecycle(lifecycle)
.onEach { result ->
val list = result.passwordItems
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
rvPassword.scrollToPosition(0)
}
// 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()
}
}
// 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()
}
}
.launchIn(lifecycleScope)
}
}

View File

@@ -10,6 +10,7 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import app.passwordstore.R
@@ -23,6 +24,8 @@ import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import java.io.File
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import me.zhanghai.android.fastscroll.FastScrollerBuilder
class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
@@ -51,9 +54,10 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result ->
recyclerAdapter.submitList(result.passwordItems)
}
model.searchResult
.flowWithLifecycle(lifecycle)
.onEach { result -> recyclerAdapter.submitList(result.passwordItems) }
.launchIn(lifecycleScope)
}
override fun onAttach(context: Context) {
@@ -77,7 +81,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
}
val currentDir: File
get() = model.currentDir.value!!
get() = model.currentDir.value
interface OnFragmentInteractionListener {

View File

@@ -18,6 +18,7 @@ import androidx.appcompat.view.ActionMode
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@@ -48,6 +49,8 @@ import com.github.michaelbull.result.runCatching
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import me.zhanghai.android.fastscroll.FastScrollerBuilder
@@ -74,7 +77,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
}
val currentDir: File
get() = model.currentDir.value!!
get() = model.currentDir.value
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -174,36 +177,37 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result ->
// Only run animations when the new list is filtered, i.e., the user submitted a search,
// and not on folder navigations since the latter leads to too many removal animations.
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
recyclerAdapter.submitList(result.passwordItems) {
when {
result.isFiltered -> {
// When the result is filtered, we always scroll to the top since that is
// where
// the best fuzzy match appears.
recyclerView.scrollToPosition(0)
}
scrollTarget != null -> {
scrollTarget?.let {
recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
model.searchResult
.flowWithLifecycle(lifecycle)
.onEach { result ->
// Only run animations when the new list is filtered, i.e., the user submitted a search,
// and not on folder navigation since the latter leads to too many removal animations.
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
recyclerAdapter.submitList(result.passwordItems) {
when {
result.isFiltered -> {
// When the result is filtered, we always scroll to the top since that is
// where the best fuzzy match appears.
recyclerView.scrollToPosition(0)
}
scrollTarget = null
}
else -> {
// When the result is not filtered and there is a saved scroll position for
// it,
// we try to restore it.
recyclerViewStateToRestore?.let {
recyclerView.layoutManager!!.onRestoreInstanceState(it)
scrollTarget != null -> {
scrollTarget?.let {
recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
}
scrollTarget = null
}
else -> {
// When the result is not filtered and there is a saved scroll position for
// it, we try to restore it.
recyclerViewStateToRestore?.let {
recyclerView.layoutManager!!.onRestoreInstanceState(it)
}
recyclerViewStateToRestore = null
}
recyclerViewStateToRestore = null
}
}
}
}
.launchIn(lifecycleScope)
}
private val actionModeCallback =

View File

@@ -20,7 +20,7 @@ import androidx.appcompat.widget.SearchView.OnQueryTextListener
import androidx.core.content.edit
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import app.passwordstore.R
import app.passwordstore.data.password.PasswordItem
@@ -71,9 +71,7 @@ class PasswordStore : BaseGitActivity() {
private lateinit var searchItem: MenuItem
private val settings by lazy { sharedPrefs }
private val model: SearchableRepositoryViewModel by viewModels {
ViewModelProvider.AndroidViewModelFactory(application)
}
private val model: SearchableRepositoryViewModel by viewModels()
private val listRefreshAction =
registerForActivityResult(StartActivityForResult()) { result ->
@@ -186,10 +184,12 @@ class PasswordStore : BaseGitActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_pwdstore)
model.currentDir.observe(this) { dir ->
val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
supportActionBar?.apply {
if (dir != basePath) title = dir.name else setTitle(R.string.app_name)
lifecycleScope.launchWhenCreated {
model.currentDir.flowWithLifecycle(lifecycle).collect { dir ->
val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
supportActionBar?.apply {
if (dir != basePath) title = dir.name else setTitle(R.string.app_name)
}
}
}
}
@@ -238,7 +238,7 @@ class PasswordStore : BaseGitActivity() {
// List the contents of the current directory if the user enters a blank
// search term.
if (filter.isEmpty())
model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false)
model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false)
else model.search(filter)
return true
}
@@ -544,12 +544,12 @@ class PasswordStore : BaseGitActivity() {
*/
fun refreshPasswordList(target: File? = null) {
val plist = getPasswordFragment()
if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) {
if (target?.isDirectory == true && model.currentDir.value.contains(target)) {
plist?.navigateTo(target)
} else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) {
} else if (target?.isFile == true && model.currentDir.value.contains(target)) {
// Creating new passwords is handled by an activity, so we will refresh in onStart.
plist?.scrollToOnNextRefresh(target)
} else if (model.currentDir.value?.isDirectory == true) {
} else if (model.currentDir.value.isDirectory) {
model.forceRefresh()
} else {
model.reset()

View File

@@ -0,0 +1,39 @@
// It's okay if this stays unused for the most part since it is development tooling.
@file:Suppress("Unused")
package app.passwordstore.util
import android.os.Looper
import android.os.SystemClock
import logcat.logcat
/**
* Small helper to execute a given [block] and log the time it took to execute it. Intended for use
* in day-to-day perf investigations and code using it should probably not be shipped.
*/
suspend fun <T> logExecutionTime(tag: String, block: suspend () -> T): T {
val start = SystemClock.uptimeMillis()
val res = block()
val end = SystemClock.uptimeMillis()
logcat(tag) { "Finished in ${end - start}ms" }
return res
}
fun <T> logExecutionTimeBlocking(tag: String, block: () -> T): T {
val start = SystemClock.uptimeMillis()
val res = block()
val end = SystemClock.uptimeMillis()
logcat(tag) { "Finished in ${end - start}ms" }
return res
}
/**
* Throws if called on the main thread, used to ensure an operation being offloaded to a background
* thread is correctly being moved off the main thread.
*/
@Suppress("NOTHING_TO_INLINE")
inline fun checkMainThread() {
require(Looper.myLooper() != Looper.getMainLooper()) {
"This operation must not run on the main thread"
}
}

View File

@@ -6,7 +6,6 @@ package app.passwordstore.util.extensions
import app.passwordstore.data.repo.PasswordRepository
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
@@ -16,14 +15,6 @@ import logcat.asLog
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit
/** The default OpenPGP provider for the app */
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
/** Clears the given [flag] from the value of this [Int] */
fun Int.clearFlag(flag: Int): Int {
return this and flag.inv()
}
/** Checks if this [Int] contains the given [flag] */
infix fun Int.hasFlag(flag: Int): Boolean {
return this and flag == flag
@@ -73,25 +64,12 @@ val RevCommit.time: Date
return Date(epochMilliseconds)
}
/**
* Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
* of any empty lines.
*/
fun String.splitLines(): Array<String> {
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
}
/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() }
/** A convenience extension to turn a [Throwable] with a message into a loggable string. */
fun Throwable.asLog(message: String): String = "$message\n${asLog()}"
/** Extension on [Result] that returns if the type is [Ok] */
fun <V, E> Result<V, E>.isOk(): Boolean {
return this is Ok<V>
}
/** Extension on [Result] that returns if the type is [Err] */
fun <V, E> Result<V, E>.isErr(): Boolean {
return this is Err<E>

View File

@@ -5,16 +5,13 @@
package app.passwordstore.util.viewmodel
import android.app.Application
import android.content.SharedPreferences
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.recyclerview.selection.ItemDetailsLookup
import androidx.recyclerview.selection.ItemKeyProvider
import androidx.recyclerview.selection.Selection
@@ -26,30 +23,35 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.DirectoryStructure
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.checkMainThread
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.settings.PasswordSortOrder
import app.passwordstore.util.settings.PreferenceKeys
import com.github.androidpasswordstore.sublimefuzzy.Fuzzy
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.text.Collator
import java.util.Locale
import java.util.Stack
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import me.zhanghai.android.fastscroll.PopupTextProvider
@@ -108,8 +110,15 @@ enum class ListMode {
AllEntries
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) {
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class SearchableRepositoryViewModel
@Inject
constructor(
application: Application,
dispatcherProvider: DispatcherProvider,
@SettingsPreferences private val settings: SharedPreferences,
) : AndroidViewModel(application) {
private var _updateCounter = 0
private val updateCounter: Int
@@ -121,7 +130,6 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
private val root
get() = PasswordRepository.getRepositoryDirectory()
private val settings by unsafeLazy { application.sharedPrefs }
private val showHiddenContents
get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
private val defaultSearchMode
@@ -169,8 +177,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
private fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter)
private val searchAction =
MutableLiveData(
private val searchActionFlow =
MutableStateFlow(
makeSearchAction(
baseDirectory = root,
filter = "",
@@ -179,13 +187,13 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
listMode = ListMode.AllEntries
)
)
private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
val searchResult =
searchActionFlow
.mapLatest { searchAction ->
checkMainThread()
val listResultFlow =
when (searchAction.searchMode) {
SearchMode.RecursivelyInSubdirectories ->
@@ -194,14 +202,20 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
}
val prefilteredResultFlow =
when (searchAction.listMode) {
ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
ListMode.FilesOnly ->
listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io())
ListMode.DirectoriesOnly ->
listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io())
ListMode.AllEntries -> listResultFlow
}
val passwordList =
when (if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode) {
FilterMode.NoFilter -> {
prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator)
prefilteredResultFlow
.map { it.toPasswordItem() }
.flowOn(dispatcherProvider.io())
.toList()
.sortedWith(itemComparator)
}
FilterMode.StrictDomain -> {
check(searchAction.listMode == ListMode.FilesOnly) {
@@ -214,6 +228,7 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
regex.containsMatchIn(absoluteFile.relativeTo(root).path)
}
.map { it.toPasswordItem() }
.flowOn(dispatcherProvider.io())
.toList()
.sortedWith(itemComparator)
} else {
@@ -227,6 +242,7 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
Pair(item.fuzzyMatch(searchAction.filter), item)
}
.filter { it.first > 0 }
.flowOn(dispatcherProvider.io())
.toList()
.sortedWith(
compareByDescending<Pair<Int, PasswordItem>> { it.first }
@@ -237,7 +253,7 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
}
SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
}
.asLiveData(Dispatchers.IO)
.flowOn(dispatcherProvider.io())
private fun shouldTake(file: File) =
with(file) {
@@ -270,8 +286,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
.filter(::shouldTake)
}
private val _currentDir = MutableLiveData(root)
val currentDir = _currentDir as LiveData<File>
private val _currentDir = MutableStateFlow(root)
val currentDir = _currentDir.asStateFlow()
data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
@@ -286,9 +302,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
if (!newDirectory.exists()) return
require(newDirectory.isDirectory) { "Can only navigate to a directory" }
if (pushPreviousLocation) {
navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState))
navigationStack.push(NavigationStackEntry(_currentDir.value, recyclerViewState))
}
searchAction.postValue(
searchActionFlow.update {
makeSearchAction(
filter = "",
baseDirectory = newDirectory,
@@ -296,8 +312,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
searchMode = SearchMode.InCurrentDirectoryOnly,
listMode = listMode
)
)
_currentDir.postValue(newDirectory)
}
_currentDir.update { newDirectory }
}
val canNavigateBack
@@ -330,20 +346,20 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
listMode: ListMode = ListMode.AllEntries
) {
require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
searchAction.postValue(
searchActionFlow.update {
makeSearchAction(
filter = filter,
baseDirectory = baseDirectory ?: _currentDir.value!!,
baseDirectory = baseDirectory ?: _currentDir.value,
filterMode = filterMode,
searchMode = searchMode ?: defaultSearchMode,
listMode = listMode
)
)
}
}
fun forceRefresh() {
forceUpdateOnNextSearchAction()
searchAction.postValue(updateSearchAction(searchAction.value!!))
searchActionFlow.update { updateSearchAction(searchActionFlow.value) }
}
companion object {