diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt index 6c4f21cb243f..b41f0f2522ac 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt @@ -176,4 +176,7 @@ interface AutofillFeature { @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) fun canDetectSystemAutofillEngagement(): Toggle + + @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) + fun prioritizeDomainMatchesOnSearch(): Toggle } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillSimpleCredentialsListFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillSimpleCredentialsListFragment.kt index 0bd8545bb8d4..61b905e58159 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillSimpleCredentialsListFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/service/AutofillSimpleCredentialsListFragment.kt @@ -231,7 +231,12 @@ class AutofillSimpleCredentialsListFragment : DuckDuckGoFragment(R.layout.fragme logcat(INFO) { "DDGAutofillService showSuggestionsFor: $showSuggestionsFor" } val directSuggestions = suggestionMatcher.getDirectSuggestions(showSuggestionsFor, credentials) val shareableCredentials = suggestionMatcher.getShareableSuggestions(showSuggestionsFor) - val directSuggestionsListItems = suggestionListBuilder.build(directSuggestions, shareableCredentials, allowBreakageReporting = false) + val directSuggestionsListItems = suggestionListBuilder.build( + unsortedQuerySuggestions = listOf(), + unsortedDirectSuggestions = directSuggestions, + unsortedSharableSuggestions = shareableCredentials, + allowBreakageReporting = false, + ) val groupedCredentials = credentialGrouper.group(credentials) withContext(dispatchers.main()) { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillPasswordsManagementViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillPasswordsManagementViewModel.kt index e622a8fcde4e..7f77f34d876d 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillPasswordsManagementViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/AutofillPasswordsManagementViewModel.kt @@ -428,7 +428,10 @@ class AutofillPasswordsManagementViewModel @Inject constructor( fun onViewCreated() { if (combineJob != null) return combineJob = viewModelScope.launch(dispatchers.io()) { - _viewState.value = _viewState.value.copy(autofillEnabled = autofillStore.autofillEnabled) + _viewState.value = _viewState.value.copy( + autofillEnabled = autofillStore.autofillEnabled, + prioritizeDomainMatchesOnSearch = autofillFeature.prioritizeDomainMatchesOnSearch().isEnabled(), + ) val allCredentials = autofillStore.getAllCredentials().distinctUntilChanged() val combined = allCredentials.combine(searchQueryFilter) { credentials, filter -> @@ -820,6 +823,7 @@ class AutofillPasswordsManagementViewModel @Inject constructor( val reportBreakageState: ReportBreakageState = ReportBreakageState(), val canShowPromo: Boolean = false, val canImportFromGooglePasswords: Boolean = false, + val prioritizeDomainMatchesOnSearch: Boolean = false, ) data class ReportBreakageState( diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilder.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilder.kt index 4927d2a29dd6..3405f4026a20 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilder.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilder.kt @@ -32,19 +32,21 @@ class SuggestionListBuilder @Inject constructor( ) { fun build( + unsortedQuerySuggestions: List, unsortedDirectSuggestions: List, unsortedSharableSuggestions: List, allowBreakageReporting: Boolean, ): List { val list = mutableListOf() - if (unsortedDirectSuggestions.isNotEmpty() || unsortedSharableSuggestions.isNotEmpty()) { + if (unsortedQuerySuggestions.isNotEmpty() || unsortedDirectSuggestions.isNotEmpty() || unsortedSharableSuggestions.isNotEmpty()) { list.add(GroupHeading(context.getString(string.credentialManagementSuggestionsLabel))) + val sortedQuerySuggestions = sorter.sort(unsortedQuerySuggestions) val sortedDirectSuggestions = sorter.sort(unsortedDirectSuggestions) val sortedSharableSuggestions = sorter.sort(unsortedSharableSuggestions) - val allSuggestions = sortedDirectSuggestions + sortedSharableSuggestions + val allSuggestions = (sortedQuerySuggestions + sortedDirectSuggestions + sortedSharableSuggestions).distinct() list.addAll(allSuggestions.map { SuggestedCredential(it) }) if (allowBreakageReporting) { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcher.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcher.kt index 3e07eac0db2f..07d5fdfa02bd 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcher.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcher.kt @@ -45,6 +45,14 @@ class SuggestionMatcher @Inject constructor( } } + fun getQuerySuggestions( + query: String?, + credentials: List, + ): List { + if (query.isNullOrBlank()) return emptyList() + return credentials.filter { it.domain?.contains(query, ignoreCase = true) == true } + } + /** * Returns a list of credentials that are not a direct match for the current URL, but are considered shareable. */ diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt index 1c0efddc83b3..41f462bf80d1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/ui/credential/management/viewing/AutofillManagementListMode.kt @@ -322,6 +322,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill canShowImportGooglePasswordsButton = state.canImportFromGooglePasswords, showAutofillToggle = state.showAutofillEnabledToggle, promotionView = promotionView, + query = state.credentialSearchQuery, + prioritizeDomainMatchesOnSearch = state.prioritizeDomainMatchesOnSearch, ) parentActivity()?.invalidateOptionsMenu() } @@ -413,6 +415,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill canShowImportGooglePasswordsButton: Boolean, showAutofillToggle: Boolean, promotionView: View?, + query: String, + prioritizeDomainMatchesOnSearch: Boolean, ) { if (credentials == null) { logcat(VERBOSE) { "Credentials is null, meaning we haven't retrieved them yet. Don't know if empty or not yet" } @@ -423,12 +427,16 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill autofillEnabled = viewModel.viewState.value.autofillEnabled, promotionView = promotionView, showGoogleImportPasswordsButton = canShowImportGooglePasswordsButton, + query = query, + prioritizeDomainMatchesOnSearch = prioritizeDomainMatchesOnSearch, ) } else if (credentials.isEmpty() && credentialSearchQuery.isEmpty()) { showEmptyCredentialsPlaceholders( canShowImportGooglePasswordsButton = canShowImportGooglePasswordsButton, showAutofillToggle = showAutofillToggle, promotionView = promotionView, + query = query, + prioritizeDomainMatchesOnSearch = prioritizeDomainMatchesOnSearch, ) } else if (credentials.isEmpty()) { showNoResultsPlaceholders(credentialSearchQuery) @@ -440,6 +448,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill autofillEnabled = viewModel.viewState.value.autofillEnabled, promotionView = promotionView, showGoogleImportPasswordsButton = canShowImportGooglePasswordsButton, + query = query, + prioritizeDomainMatchesOnSearch = prioritizeDomainMatchesOnSearch, ) } } @@ -452,6 +462,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill canShowImportGooglePasswordsButton: Boolean, showAutofillToggle: Boolean, promotionView: View?, + query: String, + prioritizeDomainMatchesOnSearch: Boolean, ) { renderCredentialList( credentials = emptyList(), @@ -460,6 +472,8 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill autofillEnabled = viewModel.viewState.value.autofillEnabled, promotionView = promotionView, showGoogleImportPasswordsButton = canShowImportGooglePasswordsButton, + query = query, + prioritizeDomainMatchesOnSearch = prioritizeDomainMatchesOnSearch, ) if (canShowImportGooglePasswordsButton) { @@ -474,16 +488,24 @@ class AutofillManagementListMode : DuckDuckGoFragment(R.layout.fragment_autofill autofillEnabled: Boolean, promotionView: View?, showGoogleImportPasswordsButton: Boolean, + query: String, + prioritizeDomainMatchesOnSearch: Boolean, ) { withContext(dispatchers.io()) { val currentUrl = getCurrentSiteUrl() - val credentialLoadingState = if (credentials == null) { Loading } else { + val querySuggestions = + if (prioritizeDomainMatchesOnSearch) { suggestionMatcher.getQuerySuggestions(query, credentials) } else emptyList() val directSuggestions = suggestionMatcher.getDirectSuggestions(currentUrl, credentials) val shareableCredentials = suggestionMatcher.getShareableSuggestions(currentUrl) - val directSuggestionsListItems = suggestionListBuilder.build(directSuggestions, shareableCredentials, allowBreakageReporting) + val directSuggestionsListItems = suggestionListBuilder.build( + querySuggestions, + directSuggestions, + shareableCredentials, + allowBreakageReporting, + ) val groupedCredentials = grouper.group(credentials) val hasSuggestions = directSuggestions.isNotEmpty() || shareableCredentials.isNotEmpty() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilderTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilderTest.kt index 268c35ad9be2..b395f9e4acca 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilderTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionListBuilderTest.kt @@ -21,6 +21,9 @@ import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.encoding.TestUrlUnicodeNormalizer import com.duckduckgo.autofill.impl.ui.credential.management.sorting.CredentialListSorterByTitleAndDomain +import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilderTest.Type.DIRECT +import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilderTest.Type.QUERY +import com.duckduckgo.autofill.impl.ui.credential.management.suggestion.SuggestionListBuilderTest.Type.SHAREABLE import com.duckduckgo.autofill.impl.ui.credential.management.viewing.list.ListItem import com.duckduckgo.autofill.impl.ui.credential.management.viewing.list.ListItem.CredentialListItem.SuggestedCredential import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher @@ -39,95 +42,144 @@ class SuggestionListBuilderTest { @Test fun whenNoSuggestionThenEmptyListReturned() { - assertTrue(testee.build(emptyList(), emptyList(), allowBreakageReporting = false).isEmpty()) + assertTrue(testee.build(emptyList(), emptyList(), emptyList(), allowBreakageReporting = false).isEmpty()) } @Test fun whenOneDirectSuggestionThenDividerAddedLast() { - val suggestions = buildSuggestions(1) - val list = testee.build(suggestions, emptyList(), allowBreakageReporting = false) + val suggestions = buildSuggestions(1, type = DIRECT) + val list = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false) + assertTrue(list.last() is ListItem.Divider) + } + + @Test + fun whenOneQuerySuggestionThenDividerAddedLast() { + val suggestions = buildSuggestions(1, type = QUERY) + val list = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false) assertTrue(list.last() is ListItem.Divider) } @Test fun whenTwoDirectSuggestionsThenDividerAddedLast() { - val suggestions = buildSuggestions(2) - val list = testee.build(suggestions, emptyList(), allowBreakageReporting = false) + val suggestions = buildSuggestions(2, type = DIRECT) + val list = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false) assertTrue(list.last() is ListItem.Divider) } @Test fun whenOneDirectSuggestionThenCorrectNumberOfListItemsReturned() { - val suggestions = buildSuggestions(1) - val list = testee.build(suggestions, emptyList(), allowBreakageReporting = false) + val suggestions = buildSuggestions(1, type = DIRECT) + val list = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + suggestions.size, list.size) } @Test fun whenTwoDirectSuggestionsThenCorrectNumberOfListItemsReturned() { - val suggestions = buildSuggestions(2) - val list = testee.build(suggestions, emptyList(), allowBreakageReporting = false) + val suggestions = buildSuggestions(2, type = DIRECT) + val list = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + suggestions.size, list.size) } @Test fun whenTenDirectSuggestionsThenThirteenListItemsReturned() { - val suggestions = buildSuggestions(10) - val list = testee.build(suggestions, emptyList(), allowBreakageReporting = false) + val suggestions = buildSuggestions(10, type = DIRECT) + val list = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + suggestions.size, list.size) } @Test fun whenDirectSuggestionAddedThenGroupNameIsCorrect() { - val suggestions = buildSuggestions(1) - val heading = testee.build(suggestions, emptyList(), allowBreakageReporting = false).first() + val suggestions = buildSuggestions(1, type = DIRECT) + val heading = testee.build(emptyList(), suggestions, emptyList(), allowBreakageReporting = false).first() assertTrue(heading is ListItem.GroupHeading) } @Test fun whenNoDirectSuggestionsButOneShareableThenCorrectNumberOfListItemsReturned() { - val suggestions = buildSuggestions(1) - val list = testee.build(emptyList(), suggestions, allowBreakageReporting = false) + val suggestions = buildSuggestions(1, type = DIRECT) + val list = testee.build(emptyList(), emptyList(), suggestions, allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + suggestions.size, list.size) } @Test fun whenNoDirectSuggestionsButMultipleShareableThenCorrectNumberOfListItemsReturned() { - val suggestions = buildSuggestions(10) - val list = testee.build(emptyList(), suggestions, allowBreakageReporting = false) + val suggestions = buildSuggestions(10, type = DIRECT) + val list = testee.build(emptyList(), emptyList(), suggestions, allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + suggestions.size, list.size) } @Test fun whenOneDirectAndOneShareableThenDirectSuggestionsAppearFirst() { - val directSuggestions = buildSuggestions(1) - val sharableSuggestions = buildSuggestions(1, startingIndex = directSuggestions.size) - val list = testee.build(directSuggestions, sharableSuggestions, allowBreakageReporting = false) + val directSuggestions = buildSuggestions(1, type = DIRECT) + val sharableSuggestions = buildSuggestions(1, startingIndex = directSuggestions.size, type = SHAREABLE) + val list = testee.build(emptyList(), directSuggestions, sharableSuggestions, allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + directSuggestions.size + sharableSuggestions.size, list.size) assertTrue(list[0] is ListItem.GroupHeading) - assertEquals(0L, (list[1] as SuggestedCredential).credentials.id) - assertEquals(1L, (list[2] as SuggestedCredential).credentials.id) + assertEquals(DIRECT.name, (list[1] as SuggestedCredential).credentials.username) + assertEquals(SHAREABLE.name, (list[2] as SuggestedCredential).credentials.username) + } + + @Test + fun whenOneQueryAndOneDirectAndOneShareableThenQuerySuggestionsAppearFirst() { + val querySuggestions = buildSuggestions(1, type = QUERY) + val directSuggestions = buildSuggestions(1, startingIndex = querySuggestions.size, type = DIRECT) + val sharableSuggestions = buildSuggestions(1, startingIndex = querySuggestions.size + directSuggestions.size, type = SHAREABLE) + val list = testee.build(querySuggestions, directSuggestions, sharableSuggestions, allowBreakageReporting = false) + assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + querySuggestions.size + directSuggestions.size + sharableSuggestions.size, list.size) + assertTrue(list[0] is ListItem.GroupHeading) + assertEquals(QUERY.name, (list[1] as SuggestedCredential).credentials.username) + assertEquals(DIRECT.name, (list[2] as SuggestedCredential).credentials.username) + assertEquals(SHAREABLE.name, (list[3] as SuggestedCredential).credentials.username) + } + + @Test + fun whenOneQueryAndOneDirectWithDuplicatesThenRemoveDuplicates() { + val credential = aCredential(username = "test") + val querySuggestions = listOf(credential) + val directSuggestions = listOf(credential) + val sharableSuggestions = emptyList() + val list = testee.build(querySuggestions, directSuggestions, sharableSuggestions, allowBreakageReporting = false) + assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + 1, list.size) } @Test fun whenMultipleDirectAndSomeShareableThenDirectSuggestionsAppearFirst() { - val directSuggestions = buildSuggestions(10, isShareable = false) - val sharableSuggestions = buildSuggestions(1, isShareable = true, startingIndex = directSuggestions.size) - val list = testee.build(directSuggestions, sharableSuggestions, allowBreakageReporting = false) + val directSuggestions = buildSuggestions(10, type = DIRECT) + val sharableSuggestions = buildSuggestions(1, type = SHAREABLE, startingIndex = directSuggestions.size) + val list = testee.build(emptyList(), directSuggestions, sharableSuggestions, allowBreakageReporting = false) assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + directSuggestions.size + sharableSuggestions.size, list.size) assertTrue(list[0] is ListItem.GroupHeading) - assertEquals("direct", (list[1] as SuggestedCredential).credentials.username) - assertEquals("shareable", (list[11] as SuggestedCredential).credentials.username) + assertEquals(DIRECT.name, (list[1] as SuggestedCredential).credentials.username) + assertEquals(SHAREABLE.name, (list[11] as SuggestedCredential).credentials.username) + } + + @Test + fun whenMultipleQueryAndMultipleDirectAndSomeShareableThenQuerySuggestionsAppearFirst() { + val querySuggestions = buildSuggestions(10, type = QUERY) + val directSuggestions = buildSuggestions(10, type = DIRECT, startingIndex = querySuggestions.size) + val sharableSuggestions = buildSuggestions(1, type = SHAREABLE, startingIndex = querySuggestions.size + directSuggestions.size) + val list = testee.build(querySuggestions, directSuggestions, sharableSuggestions, allowBreakageReporting = false) + assertEquals(NUM_SUGGESTION_HEADERS + NUM_DIVIDERS + querySuggestions.size + directSuggestions.size + sharableSuggestions.size, list.size) + assertTrue(list[0] is ListItem.GroupHeading) + assertEquals(QUERY.name, (list[1] as SuggestedCredential).credentials.username) + assertEquals(DIRECT.name, (list[11] as SuggestedCredential).credentials.username) + assertEquals(SHAREABLE.name, (list[21] as SuggestedCredential).credentials.username) } - private fun buildSuggestions(listSize: Int, startingIndex: Int = 0, isShareable: Boolean = false): List { + private fun buildSuggestions(listSize: Int, startingIndex: Int = 0, type: Type): List { val list = mutableListOf() for (i in 0 until listSize) { - list.add(aCredential(id = startingIndex + i.toLong(), username = if (isShareable) "shareable" else "direct")) + list.add(aCredential(id = startingIndex + i.toLong(), username = type.name)) } return list } + private enum class Type { + QUERY, + DIRECT, + SHAREABLE, + } + private fun aCredential(id: Long = 0, username: String): LoginCredentials { return LoginCredentials(id = id, domain = null, password = null, username = username) } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcherTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcherTest.kt index 3ab57af5b79d..89ab21f2225b 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcherTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/ui/credential/management/suggestion/SuggestionMatcherTest.kt @@ -127,6 +127,14 @@ class SuggestionMatcherTest { assertEquals(1, suggestions.size) } + @Test + fun whenQueryIsNotUrlButMatchesSuggestionsDomainThenSuggestionsOffered() = runTest { + configureNoShareableCredentials() + val creds = listOf(creds("example.com")) + val suggestions = testee.getQuerySuggestions("example", creds) + assertEquals(1, suggestions.size) + } + private suspend fun configureNoShareableCredentials() { whenever(shareableCredentials.shareableCredentials(any())).thenReturn(emptyList()) }