diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-04 11:23:25 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-04 11:23:25 +0100 |
| commit | fa95b737778ab08203c679d716c1279f51cbd409 (patch) | |
| tree | b5de9fa41937d2007d8dc30576f6be59c5d74a94 /app/src/main/java | |
| parent | f5a736755a151be6753a2f939c493a7f9ad5bf06 (diff) | |
| download | HardenedPass-fa95b737778ab08203c679d716c1279f51cbd409.tar.gz HardenedPass-fa95b737778ab08203c679d716c1279f51cbd409.zip | |
feat(app): move the autofill picker to Compose
AutofillFilterView draws the picker with the shared theme: origin
header, search field, entry rows in a grouped surface and the three
switches, over the same SearchableRepositoryViewModel search. Rows
name the origin identifier and show the account and folders below it.
The first tap wins, so a double tap no longer starts two decryptions.
PasswordViewHolder, the RecyclerView adapter, the two layouts, their
drawables, the multiselect color and their lint baseline entries go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
4 files changed, 215 insertions, 250 deletions
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt new file mode 100644 index 00000000..49e19799 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.EntryRow +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.list.ListRow +import app.passwordstore.ui.settings.PreferenceSwitch +import kotlinx.collections.immutable.ImmutableList + +/** + * The autofill picker: which site or app is being filled ([origin], as the user can check it), a + * search over the store's entries, the matching [rows] (null while the first search runs), and the + * switches for phishing-resistant search ([showStrict]: websites only), recording the pick as a + * match ([matchLabel]) and clearing earlier matches. [onPick] takes the entry's absolute path. + */ +@Composable +fun AutofillFilterScreen( + origin: String, + query: String, + rows: ImmutableList<ListRow>?, + showStrict: Boolean, + strict: Boolean, + matchLabel: String, + match: Boolean, + clear: Boolean, + onQueryChange: (String) -> Unit, + onStrictChange: (Boolean) -> Unit, + onMatchChange: (Boolean) -> Unit, + onClearChange: (Boolean) -> Unit, + onPick: (path: String) -> Unit, + modifier: Modifier = Modifier, +) { + val fillInto = stringResource(R.string.oreo_autofill_select_and_fill_into) + Surface(modifier.fillMaxSize()) { + Column(Modifier.padding(vertical = SpacingLarge)) { + Image( + painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.size(60.dp).align(Alignment.CenterHorizontally), + ) + Text( + buildAnnotatedString { + append(fillInto) + append("\n") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(origin) } + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge), + textAlign = TextAlign.Center, + ) + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth().padding(SpacingLarge), + label = { Text(stringResource(R.string.action_search)) }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null) + }, + trailingIcon = + if (query.isNotEmpty()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search)) + } + } + } else null, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + Box(Modifier.weight(1f).fillMaxWidth()) { + when { + rows == null -> Unit + rows.isEmpty() -> + Text( + stringResource(R.string.oreo_autofill_filter_no_results), + modifier = Modifier.padding(horizontal = SpacingLarge), + style = MaterialTheme.typography.titleMedium, + ) + else -> + // One lazy item per row: a cleared search lists the whole store. The surface wraps + // a short list and is capped by the box for a long one. + GroupedSurface(Modifier.fillMaxWidth()) { + LazyColumn { + items(rows, key = { it.path }) { row -> + EntryRow( + name = row.label, + subtitle = row.subtitle, + onClick = { onPick(row.path) }, + ) + } + } + } + } + } + if (showStrict) + PreferenceSwitch( + stringResource(R.string.oreo_autofill_strict_domain_search), + strict, + onStrictChange, + ) + PreferenceSwitch(matchLabel, match, onMatchChange) + PreferenceSwitch( + stringResource(R.string.oreo_autofill_matches_clear_existing), + clear, + onClearChange, + ) + } + } +} 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 b027657e..44994646 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -10,36 +10,35 @@ import android.content.Intent import android.content.IntentSender import android.os.Build import android.os.Bundle -import android.view.View import android.view.autofill.AutofillManager -import android.widget.TextView +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult 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.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.list.ListRow import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.DirectoryStructure import app.passwordstore.util.viewmodel.FilterMode import app.passwordstore.util.viewmodel.ListMode import app.passwordstore.util.viewmodel.SearchMode -import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel import com.github.androidpasswordstore.autofillparser.FormOrigin import dagger.hilt.android.AndroidEntryPoint +import java.io.File import javax.inject.Inject -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import logcat.LogPriority.ERROR import logcat.logcat @@ -85,11 +84,12 @@ class AutofillFilterView : AppCompatActivity() { } private lateinit var formOrigin: FormOrigin - private lateinit var directoryStructure: DirectoryStructure - private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) private val model: SearchableRepositoryViewModel by viewModels() + /** Set by the first tap on a row: a double tap must not start a second decryption. */ + private var picked = false + private val decryptAction = registerForActivityResult(StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { @@ -100,7 +100,6 @@ class AutofillFilterView : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) setFinishOnTouchOutside(true) val params = window.attributes @@ -129,111 +128,76 @@ class AutofillFilterView : AppCompatActivity() { return } } - directoryStructure = AutofillPreferences.directoryStructure(this) - supportActionBar?.hide() - bindUI() - updateSearch() setResult(RESULT_CANCELED) - } - private fun bindUI() { - with(binding) { - rvPassword.apply { - adapter = - SearchableRepositoryAdapter( - R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder, - lifecycleScope, - dispatcherProvider, - ) { item, _ -> - val file = item.file.relativeTo(item.rootDir) - val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) - val identifier = directoryStructure.getIdentifierFor(file) - val accountPart = directoryStructure.getAccountPartFor(file) - check(identifier != null || accountPart != null) { - "At least one of identifier and accountPart should always be non-null" - } - title.text = - if (identifier != null) { - buildSpannedString { - if (pathToIdentifier != null) append("$pathToIdentifier/") - bold { underline { append(identifier) } } - } - } else { - accountPart - } - subtitle.apply { - if (identifier != null && accountPart != null) { - text = accountPart - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - .onItemClicked { _, item -> decryptAndFill(item) } - layoutManager = LinearLayoutManager(context) - } - search.apply { - val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) - setText(initialSearch, TextView.BufferType.EDITABLE) - addTextChangedListener { updateSearch() } - } - origin.text = buildSpannedString { - append(getString(R.string.oreo_autofill_select_and_fill_into)) - append("\n") - bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) } - } - strictDomainSearch.apply { - visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE - isChecked = formOrigin is FormOrigin.Web - setOnCheckedChangeListener { _, _ -> updateSearch() } - } - shouldMatch.text = - getString( - R.string.oreo_autofill_match_with, - formOrigin.getPrettyIdentifier(applicationContext), + val isWeb = formOrigin is FormOrigin.Web + val initialQuery = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + // Before anything is composed, so the ViewModel's default root listing (folders included) is + // never shown. The LaunchedEffect below repeats it with equal arguments, which emits nothing. + search(initialQuery, isWeb) + val root = PasswordRepository.getRepositoryDirectory() + val directoryStructure = AutofillPreferences.directoryStructure(this) + // ponytail: cold flow, so each return to STARTED walks the store again; fine for a picker open + // a few seconds. Share it with stateIn in the ViewModel if a large store makes it slow. + val rows = + model.searchResult + .map { result -> + autofillRows(result.passwordItems.map { it.file }, root, directoryStructure) + } + .flowOn(dispatcherProvider.io()) + val origin = formOrigin.getPrettyIdentifier(applicationContext, untrusted = true) + val matchLabel = + getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext), + ) + + setContent { + APSTheme { + var query by rememberSaveable { mutableStateOf(initialQuery) } + var strict by rememberSaveable { mutableStateOf(isWeb) } + var match by rememberSaveable { mutableStateOf(true) } + var clear by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(query, strict) { search(query, strict) } + val shown by rows.collectAsStateWithLifecycle<ImmutableList<ListRow>?>(initialValue = null) + AutofillFilterScreen( + origin = origin, + query = query, + rows = shown, + showStrict = isWeb, + strict = strict, + matchLabel = matchLabel, + match = match, + clear = clear, + onQueryChange = { query = it }, + onStrictChange = { strict = it }, + onMatchChange = { match = it }, + onClearChange = { clear = it }, + onPick = { path -> decryptAndFill(File(path), match, clear) }, ) - lifecycleScope.launch { handleSearchResults() } - } - } - - private suspend fun handleSearchResults() { - model.searchResult.collect { result -> - val list = result.passwordItems - (binding.rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { - binding.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() && binding.rvPasswordSwitcher.nextView.id == binding.rvPasswordEmpty.id) || - (list.isNotEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPassword.id) - ) { - binding.rvPasswordSwitcher.showNext() } } } - private fun updateSearch() { + /** The old picker's search: fuzzy or strict domain, recursive, entries only. */ + private fun search(query: String, strict: Boolean) { model.search( - binding.search.text.toString().trim(), - filterMode = - if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + query.trim(), + filterMode = if (strict) FilterMode.StrictDomain else FilterMode.Fuzzy, searchMode = SearchMode.RecursivelyInSubdirectories, listMode = ListMode.FilesOnly, ) } - private fun decryptAndFill(item: PasswordItem) { - if (binding.shouldClear.isChecked) - AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) - if (binding.shouldMatch.isChecked) - AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) + private fun decryptAndFill(file: File, match: Boolean, clear: Boolean) { + if (picked) return + picked = true + if (clear) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) + if (match) AutofillMatcher.addMatchFor(applicationContext, formOrigin, file) // intent?.extras? is checked to be non-null in onCreate decryptAction.launch( - AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this) + AutofillDecryptActivity.makeDecryptFileIntent(file, intent!!.extras!!, this) ) } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt deleted file mode 100644 index 24a28564..00000000 --- a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.autofill - -import android.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R - -class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - val title: TextView = itemView.findViewById(R.id.title) - val subtitle: TextView = itemView.findViewById(R.id.subtitle) -} 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 f8cbf1e4..25ea2cf7 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -7,20 +7,8 @@ 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.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.ItemKeyProvider -import androidx.recyclerview.selection.Selection -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.DiffUtil -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 @@ -35,9 +23,7 @@ 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 javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -51,9 +37,7 @@ 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 private fun File.toPasswordItem() = if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) @@ -399,114 +383,3 @@ constructor( } } } - -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 -} - -open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( - private val layoutRes: Int, - private val viewHolderCreator: (view: View) -> T, - private val coroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider, - private val viewHolderBinder: - suspend T.(item: PasswordItem, dispatcherProvider: DispatcherProvider) -> Unit, -) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider { - - fun <T : ItemDetailsLookup<String>> makeSelectable( - recyclerView: RecyclerView, - itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T, - ) { - selectionTracker = - SelectionTracker.Builder( - "SearchableRepositoryAdapter", - recyclerView, - itemKeyProvider, - itemDetailsLookupCreator(recyclerView), - StorageStrategy.createStringStorage(), - ) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - addObserver( - object : SelectionTracker.SelectionObserver<String>() { - override fun onSelectionChanged() { - this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( - requireSelectionTracker().selection - ) - } - } - ) - } - } - - private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null - - open fun onItemClicked( - listener: (holder: T, item: PasswordItem) -> Unit - ): SearchableRepositoryAdapter<T> { - check(onItemClickedListener == null) { - "Only a single listener can be registered for onItemClicked" - } - onItemClickedListener = listener - return this - } - - private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null - - open fun onSelectionChanged( - listener: (selection: Selection<String>) -> Unit - ): SearchableRepositoryAdapter<T> { - check(onSelectionChangedListener == null) { - "Only a single listener can be registered for onSelectionChanged" - } - onSelectionChangedListener = listener - return this - } - - private val itemKeyProvider = - object : ItemKeyProvider<String>(SCOPE_MAPPED) { - override fun getKey(position: Int) = getItem(position).stableId - - override fun getPosition(key: String) = - (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION - } - - private var selectionTracker: SelectionTracker<String>? = null - - fun requireSelectionTracker() = selectionTracker!! - - private val selectedFiles - get() = requireSelectionTracker().selection.map { File(it) } - - fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } - - fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) - - final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { - val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) - return viewHolderCreator(view) - } - - final override fun onBindViewHolder(holder: T, position: Int) { - val item = getItem(position) - holder.apply { - coroutineScope.launch(dispatcherProvider.mainImmediate()) { - viewHolderBinder.invoke(holder, item, dispatcherProvider) - } - selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } - itemView.setOnClickListener { - // Do not emit custom click events while the user is selecting items. - if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item) - } - } - } - - final override fun getPopupText(view: View, position: Int): String { - return getItem(position).name[0].toString().uppercase(Locale.getDefault()) - } -} |
