diff options
Diffstat (limited to 'app/src/main/java')
7 files changed, 153 insertions, 109 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt index 8825bc58..3795f7e6 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -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) - } - // 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() + 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() + } } - } + .launchIn(lifecycleScope) } } diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt index 577777f7..44aaa63f 100644 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt @@ -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 { diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt index 9c7011a4..65570bcc 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt @@ -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 = diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt index c97782b0..e09ece7e 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -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() diff --git a/app/src/main/java/app/passwordstore/util/Perf.kt b/app/src/main/java/app/passwordstore/util/Perf.kt new file mode 100644 index 00000000..8b3769e9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/Perf.kt @@ -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" + } +} diff --git a/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt b/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt index 9d81587d..c0b40aa4 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt @@ -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> diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 8cb18309..ba42bb34 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -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 { |