tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit bd71d9a75c3b86425a20a5d57f630be0ceeca0af
parent 0b6014c45e45c9e2e13d6cf49a1ad9669e615087
Author: Mugurell <Mugurell@users.noreply.github.com>
Date:   Tue, 28 Oct 2025 18:43:52 +0000

Bug 1994013 - New composable to support entering text in the composable toolbar r=android-reviewers,Roger

This also allowed to move the complex logic of querying different autocomplete
providers for the suggestion to show as inline autocompletion logic to
integrators like Fenix while having the new composable only responsible for how
to how the received suggestion.

Differential Revision: https://phabricator.services.mozilla.com/D268687

Diffstat:
Mmobile/android/android-components/components/compose/browser-toolbar/build.gradle | 1+
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/BrowserEditToolbar.kt | 116+++++++++++++++++--------------------------------------------------------------
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/BrowserToolbar.kt | 13++++++-------
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/concept/BrowserToolbarTestTags.kt | 22++++++++++++++++++++++
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarAction.kt | 22+++++++++-------------
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarState.kt | 9+++++----
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStore.kt | 10+++++-----
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt | 534+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Dmobile/android/android-components/components/compose/browser-toolbar/src/main/res/values/ids.xml | 7-------
Mmobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStoreTest.kt | 13+++++++------
Amobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/android-components/config/detekt-baseline.xml | 2+-
Mmobile/android/android-components/samples/compose-browser/src/main/java/org/mozilla/samples/compose/browser/browser/BrowserScreen.kt | 9+++++----
Mmobile/android/android-components/samples/toolbar/src/main/java/org/mozilla/samples/toolbar/compose/BrowserToolbar.kt | 8++++----
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTestCompose.kt | 74++++++++++++++++++++++++++++++++++++++------------------------------------
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt | 12+++++++-----
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt | 44+++++++++++++++++++++++++++++++-------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksScreen.kt | 3++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddleware.kt | 5+++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddleware.kt | 3++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/HomeToolbarComposable.kt | 3++-
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt | 4++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddleware.kt | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddleware.kt | 4++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/FenixSearchMiddleware.kt | 5+++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComposable.kt | 5+++--
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddlewareTest.kt | 7++++---
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddlewareTest.kt | 6+++---
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddlewareTest.kt | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddlewareTest.kt | 17++++++++++-------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/FenixSearchMiddlewareTest.kt | 5+++--
Mmobile/android/fenix/benchmark/src/main/java/org/mozilla/fenix/benchmark/utils/UiDevice.kt | 2+-
32 files changed, 1013 insertions(+), 568 deletions(-)

diff --git a/mobile/android/android-components/components/compose/browser-toolbar/build.gradle b/mobile/android/android-components/components/compose/browser-toolbar/build.gradle @@ -50,6 +50,7 @@ dependencies { testImplementation project(':components:support-test') testImplementation libs.androidx.compose.ui.test + testImplementation libs.androidx.compose.ui.test.manifest testImplementation libs.androidx.test.core testImplementation libs.androidx.test.junit testImplementation libs.robolectric diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/BrowserEditToolbar.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/BrowserEditToolbar.kt @@ -12,22 +12,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import mozilla.components.compose.base.theme.AcornTheme @@ -40,8 +32,9 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteractio import mozilla.components.compose.browser.toolbar.store.ToolbarGravity import mozilla.components.compose.browser.toolbar.store.ToolbarGravity.Bottom import mozilla.components.compose.browser.toolbar.store.ToolbarGravity.Top +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.compose.browser.toolbar.ui.InlineAutocompleteTextField -import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.concept.toolbar.AutocompleteResult import mozilla.components.ui.icons.R as iconsR private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(90.dp) @@ -52,13 +45,10 @@ private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(90.dp) * * @param query The current query. * @param hint Hint to show in the absence of a query. + * @param suggestion [AutocompleteResult] to show as an inline autocomplete suggestion for the current [query]. * @param isQueryPrefilled Whether [query] is prefilled and not user entered. * @param usePrivateModeQueries Whether queries should be done in private / incognito mode. * @param gravity [ToolbarGravity] for where the toolbar is being placed on the screen. - * @param autocompleteProviders Optional list of [AutocompleteProvider]s to be used for - * inline autocompleting the current query. - * @param useComposeTextField Whether or not to use the Compose [TextField] or a view-based - * inline autocomplete text field. * @param editActionsStart List of [Action]s to be displayed at the start of the URL of * the edit toolbar. * @param editActionsEnd List of [Action]s to be displayed at the end of the URL of @@ -72,21 +62,18 @@ private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(90.dp) * @param onInteraction Callback for handling [BrowserToolbarEvent]s on user interactions. */ @Composable -@Suppress("LongMethod") fun BrowserEditToolbar( query: String, hint: String, + suggestion: AutocompleteResult? = null, isQueryPrefilled: Boolean = false, usePrivateModeQueries: Boolean = false, gravity: ToolbarGravity = Top, - autocompleteProviders: List<AutocompleteProvider> = emptyList(), - useComposeTextField: Boolean = false, editActionsStart: List<Action> = emptyList(), editActionsEnd: List<Action> = emptyList(), - onUrlEdit: (String) -> Unit = {}, + onUrlEdit: (BrowserToolbarQuery) -> Unit = {}, onUrlEditAborted: () -> Unit = {}, onUrlCommitted: (String) -> Unit = {}, - onUrlSuggestionAutocompleted: (String) -> Unit = {}, onInteraction: (BrowserToolbarEvent) -> Unit, ) { Box( @@ -104,79 +91,27 @@ fun BrowserEditToolbar( .background(color = AcornTheme.colors.layer3), verticalAlignment = Alignment.CenterVertically, ) { - if (useComposeTextField) { - TextField( - value = query, - onValueChange = { value -> - onUrlEdit(value) - }, - placeholder = { - Text( - text = hint, - color = AcornTheme.colors.textSecondary, - ) - }, - colors = TextFieldDefaults.colors( - focusedTextColor = AcornTheme.colors.textPrimary, - unfocusedTextColor = AcornTheme.colors.textPrimary, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - unfocusedContainerColor = AcornTheme.colors.layer3, - focusedContainerColor = AcornTheme.colors.layer3, - disabledContainerColor = AcornTheme.colors.layer3, - errorContainerColor = AcornTheme.colors.layer3, - ), - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = KeyboardActions( - onGo = { onUrlCommitted(query) }, - ), - modifier = Modifier.fillMaxWidth(), - shape = ROUNDED_CORNER_SHAPE, - leadingIcon = { - ActionContainer( - actions = editActionsStart, - onInteraction = onInteraction, - ) - }, - trailingIcon = { - Row(verticalAlignment = Alignment.CenterVertically) { - ActionContainer( - actions = editActionsEnd, - onInteraction = onInteraction, - ) - } - }, - ) - } else { - ActionContainer( - actions = editActionsStart, - onInteraction = onInteraction, - ) + ActionContainer( + actions = editActionsStart, + onInteraction = onInteraction, + ) - InlineAutocompleteTextField( - query = query, - hint = hint, - showQueryAsPreselected = isQueryPrefilled, - usePrivateModeQueries = usePrivateModeQueries, - autocompleteProviders = autocompleteProviders, - modifier = Modifier.weight(1f), - onUrlEdit = onUrlEdit, - onUrlCommitted = onUrlCommitted, - onUrlEditAborted = onUrlEditAborted, - onUrlSuggestionAutocompleted = onUrlSuggestionAutocompleted, - ) + InlineAutocompleteTextField( + query = query, + hint = hint, + suggestion = suggestion, + showQueryAsPreselected = isQueryPrefilled, + usePrivateModeQueries = usePrivateModeQueries, + modifier = Modifier.weight(1f), + onUrlEdit = onUrlEdit, + onUrlCommitted = onUrlCommitted, + onUrlEditAborted = onUrlEditAborted, + ) - ActionContainer( - actions = editActionsEnd, - onInteraction = onInteraction, - ) - } + ActionContainer( + actions = editActionsEnd, + onInteraction = onInteraction, + ) } HorizontalDivider( @@ -198,8 +133,7 @@ private fun BrowserEditToolbarPreview() { query = "http://www.mozilla.org", hint = "Search or enter address", gravity = Top, - autocompleteProviders = emptyList(), - useComposeTextField = true, + suggestion = null, editActionsStart = listOf( SearchSelectorAction( icon = DrawableIcon( diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/BrowserToolbar.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/BrowserToolbar.kt @@ -12,7 +12,6 @@ import mozilla.components.compose.base.theme.AcornTheme import mozilla.components.compose.browser.toolbar.concept.PageOrigin import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchAborted import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated -import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.UrlSuggestionAutocompleted import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.CommitUrl import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState @@ -20,6 +19,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.DisplayState import mozilla.components.compose.browser.toolbar.store.EditState import mozilla.components.compose.browser.toolbar.store.Mode +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.ext.observeAsComposableState /** @@ -39,18 +39,17 @@ fun BrowserToolbar( if (uiState.isEditMode()) { BrowserEditToolbar( - query = uiState.editState.query, + query = uiState.editState.query.current, hint = stringResource(uiState.editState.hint), isQueryPrefilled = uiState.editState.isQueryPrefilled, usePrivateModeQueries = uiState.editState.isQueryPrivate, gravity = uiState.gravity, - autocompleteProviders = uiState.editState.autocompleteProviders, + suggestion = uiState.editState.suggestion, editActionsStart = uiState.editState.editActionsStart, editActionsEnd = uiState.editState.editActionsEnd, onUrlCommitted = { text -> store.dispatch(CommitUrl(text)) }, - onUrlEdit = { text -> store.dispatch(SearchQueryUpdated(text)) }, + onUrlEdit = { store.dispatch(SearchQueryUpdated(it)) }, onUrlEditAborted = { store.dispatch(SearchAborted) }, - onUrlSuggestionAutocompleted = { store.dispatch(UrlSuggestionAutocompleted(it)) }, onInteraction = { store.dispatch(it) }, ) } else { @@ -72,8 +71,8 @@ fun BrowserToolbar( private fun BrowserToolbarPreview_EditMode() { // Mock edit state val editState = EditState( - query = "https://www.mozilla.org", - autocompleteProviders = emptyList(), + query = BrowserToolbarQuery("https://www.mozilla.org"), + suggestion = null, editActionsStart = emptyList(), editActionsEnd = emptyList(), ) diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/concept/BrowserToolbarTestTags.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/concept/BrowserToolbarTestTags.kt @@ -10,8 +10,30 @@ import mozilla.components.compose.browser.toolbar.BrowserToolbar * Test tags for the [BrowserToolbar] composable. */ object BrowserToolbarTestTags { + /** + * Test tag for the website origin box while in "display" mode. + */ const val ADDRESSBAR_URL_BOX = "ADDRESSBAR_URL_BOX" + + /** + * Test tag for the title shown while in "display" mode. + * Webpage title is shown (if available) in custom tabs. + */ const val ADDRESSBAR_TITLE = "ADDRESSBAR_TITLE" + + /** + * Test tag for the search term / URL shown while in "display" mode. + */ const val ADDRESSBAR_URL = "ADDRESSBAR_URL" + + /** + * Test tag for the unified search selector. + */ const val SEARCH_SELECTOR = "SEARCH_SELECTOR" + + /** + * Test tag for the toolbar while in "edit" mode. + * Useful for entering text to search or an URL to load. + */ + const val ADDRESSBAR_SEARCH_BOX = "ADDRESSBAR_SEARCH_BOX" } diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarAction.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarAction.kt @@ -6,7 +6,8 @@ package mozilla.components.compose.browser.toolbar.store import androidx.annotation.StringRes import mozilla.components.compose.browser.toolbar.concept.PageOrigin -import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery +import mozilla.components.concept.toolbar.AutocompleteResult import mozilla.components.lib.state.Action import mozilla.components.compose.browser.toolbar.concept.Action as ToolbarAction @@ -121,11 +122,11 @@ sealed class BrowserEditToolbarAction : BrowserToolbarAction { /** * Updates the text of the toolbar that is currently being edited (in "edit" mode). * - * @property query The text in the toolbar that is being edited. - * @property isQueryPrefilled Whether [query] is prefilled and not user entered. + * @property query Information about the text in the toolbar that is being edited. + * @property isQueryPrefilled Whether the new text in [query] is prefilled and not user entered. */ data class SearchQueryUpdated( - val query: String, + val query: BrowserToolbarQuery, val isQueryPrefilled: Boolean = false, ) : BrowserEditToolbarAction() @@ -141,17 +142,12 @@ sealed class BrowserEditToolbarAction : BrowserToolbarAction { data object SearchAborted : BrowserEditToolbarAction() /** - * Indicates that a new url suggestion has been autocompleted in the search toolbar. - */ - data class UrlSuggestionAutocompleted(val url: String) : BrowserEditToolbarAction() - - /** - * Indicates that a new list of toolbar autocomplete providers is available. + * Indicates that a new autocomplete suggestion is available or that the previous one is not valid anymore. * - * @property autocompleteProviders The new list of [AutocompleteProvider]s. + * @property autocompletedSuggestion The new autocomplete suggestion. `null` if none is available. */ - data class AutocompleteProvidersUpdated( - val autocompleteProviders: List<AutocompleteProvider>, + data class AutocompleteSuggestionUpdated( + val autocompletedSuggestion: AutocompleteResult?, ) : BrowserEditToolbarAction() /** diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarState.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarState.kt @@ -11,7 +11,8 @@ import mozilla.components.compose.browser.toolbar.R import mozilla.components.compose.browser.toolbar.concept.Action import mozilla.components.compose.browser.toolbar.concept.PageOrigin import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent -import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery +import mozilla.components.concept.toolbar.AutocompleteResult import mozilla.components.lib.state.State /** @@ -92,7 +93,7 @@ data class DisplayState( /** * Wrapper containing the toolbar edit state. * - * @property query The text the user is editing in "edit" mode. + * @property query Information about the text the user is editing while in "edit" mode. * @property hint The hint to show in the edit toolbar. * @property isQueryPrefilled Whether [query] is prefilled and not user entered. * @property isQueryPrivate Whether queries should be done in private / incognito mode. @@ -102,11 +103,11 @@ data class DisplayState( * the edit toolbar. */ data class EditState( - val query: String = "", + val query: BrowserToolbarQuery = BrowserToolbarQuery(""), @param:StringRes val hint: Int = R.string.mozac_browser_toolbar_search_hint, val isQueryPrefilled: Boolean = false, val isQueryPrivate: Boolean = false, - val autocompleteProviders: List<AutocompleteProvider> = emptyList(), + val suggestion: AutocompleteResult? = null, val editActionsStart: List<Action> = emptyList(), val editActionsEnd: List<Action> = emptyList(), ) : State diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStore.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStore.kt @@ -10,9 +10,10 @@ import mozilla.components.compose.browser.toolbar.store.BrowserDisplayToolbarAct import mozilla.components.compose.browser.toolbar.store.BrowserDisplayToolbarAction.PageActionsEndUpdated import mozilla.components.compose.browser.toolbar.store.BrowserDisplayToolbarAction.PageActionsStartUpdated import mozilla.components.compose.browser.toolbar.store.BrowserDisplayToolbarAction.PageOriginUpdated +import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.AutocompleteSuggestionUpdated import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchAborted -import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.UrlSuggestionAutocompleted import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.UiStore @@ -57,7 +58,7 @@ private fun reduce(state: BrowserToolbarState, action: BrowserToolbarAction): Br is BrowserToolbarAction.ExitEditMode -> state.copy( mode = Mode.DISPLAY, editState = state.editState.copy( - query = "", + query = BrowserToolbarQuery(""), ), ) @@ -116,9 +117,9 @@ private fun reduce(state: BrowserToolbarState, action: BrowserToolbarAction): Br ), ) - is BrowserEditToolbarAction.AutocompleteProvidersUpdated -> state.copy( + is AutocompleteSuggestionUpdated -> state.copy( editState = state.editState.copy( - autocompleteProviders = action.autocompleteProviders, + suggestion = action.autocompletedSuggestion, ), ) @@ -143,7 +144,6 @@ private fun reduce(state: BrowserToolbarState, action: BrowserToolbarAction): Br is EnvironmentRehydrated, is EnvironmentCleared, is SearchAborted, - is UrlSuggestionAutocompleted, is BrowserToolbarEvent, -> { // no-op diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt @@ -4,325 +4,345 @@ package mozilla.components.compose.browser.toolbar.ui -import android.content.Context -import android.graphics.drawable.GradientDrawable -import android.os.Build -import android.text.InputType.TYPE_CLASS_TEXT -import android.text.InputType.TYPE_TEXT_VARIATION_URI -import android.util.TypedValue -import android.view.Gravity -import android.view.KeyEvent -import android.view.View import android.view.inputmethod.EditorInfo -import androidx.annotation.ColorInt +import androidx.annotation.DoNotInline +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.PlatformTextInputMethodRequest +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat.Type.ime -import androidx.core.view.inputmethod.EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import mozilla.components.compose.base.theme.AcornTheme -import mozilla.components.compose.browser.toolbar.BrowserEditToolbar -import mozilla.components.compose.browser.toolbar.R -import mozilla.components.concept.toolbar.AutocompleteDelegate -import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_SEARCH_BOX import mozilla.components.concept.toolbar.AutocompleteResult -import mozilla.components.support.base.log.logger.Logger -import mozilla.components.support.base.utils.NamedThreadFactory -import mozilla.components.support.ktx.android.view.showKeyboard -import mozilla.components.ui.autocomplete.AutocompleteView -import mozilla.components.ui.autocomplete.InlineAutocompleteEditText -import mozilla.components.ui.autocomplete.OnFilterListener -import java.util.concurrent.Executors -import kotlin.coroutines.CoroutineContext private const val TEXT_SIZE = 15f private const val TEXT_HIGHLIGHT_COLOR = "#5C592ACB" -private const val AUTOCOMPLETE_QUERY_THREADS = 3 -private const val AUTOCOMPLETE_THREADS_FACTORY_NAME = "EditToolbar" -private const val LETTER_SPACING_SP = 0.5f /** - * Sub-component of the [BrowserEditToolbar] responsible for displaying a text field that is - * capable of inline autocompletion. + * A text field composable that displays a suggestion inline with the user's input, + * styled differently to distinguish it from the typed text. + * + * @param query The query to show. + * @param hint Placeholder text tpo show if [query] is empty. + * @param suggestion The autocomplete suggestion to display. `null` if no suggestion is active. + * @param showQueryAsPreselected If `true`, the initial query text will be fully selected. + * @param usePrivateModeQueries If `true`, instructs the keyboard to disable personalized learning, + * suitable for private/incognito modes. + * @param modifier The [Modifier] to be applied to this text field. + * @param onUrlEdit Callback invoked when the user types or deletes text, providing [BrowserToolbarQuery] + * with information about the previous and the new query. + * @param onUrlEditAborted A callback for when an edit is aborted. + * @param onUrlCommitted A callback for when the user commits the text via an IME action like "Go". */ +@OptIn(ExperimentalComposeUiApi::class) // for InterceptPlatformTextInput +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -@Suppress("LongMethod") internal fun InlineAutocompleteTextField( query: String, hint: String, + suggestion: AutocompleteResult?, showQueryAsPreselected: Boolean, usePrivateModeQueries: Boolean, - autocompleteProviders: List<AutocompleteProvider>, modifier: Modifier = Modifier, - onUrlEdit: (String) -> Unit = {}, + onUrlEdit: (BrowserToolbarQuery) -> Unit = {}, onUrlEditAborted: () -> Unit = {}, onUrlCommitted: (String) -> Unit = {}, - onUrlSuggestionAutocompleted: (String) -> Unit = {}, ) { - val context = LocalContext.current - val textColor = AcornTheme.colors.textPrimary - val hintColor = AcornTheme.colors.textSecondary - val backgroundColor = AcornTheme.colors.layer3 - val backgroundDrawable = remember { buildBackground(context, backgroundColor.toArgb()) } - val autocompletedTextColor = remember { TEXT_HIGHLIGHT_COLOR.toColorInt() } - val logger = remember { Logger("InlineAutocompleteTextField") } - - val autocompleteDispatcher = remember { - SupervisorJob() + - Executors.newFixedThreadPool( - AUTOCOMPLETE_QUERY_THREADS, - NamedThreadFactory(AUTOCOMPLETE_THREADS_FACTORY_NAME), - ).asCoroutineDispatcher() + - CoroutineExceptionHandler { _, throwable -> - logger.error("Error while processing autocomplete input", throwable) - } + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + LaunchedEffect(query) { + if (query != textFieldValue.text) { + textFieldValue = TextFieldValue( + text = query, + selection = when (showQueryAsPreselected) { + true -> TextRange(0, query.length) + false -> TextRange(query.length) + }, + ) + } } - var editText by remember { mutableStateOf<InlineAutocompleteEditText?>(null) } - - // Doing this here and not in the "update" block to change the autocomplete filter - // only when the autocomplete providers change, and not for every recomposition / other parameter changes. - LaunchedEffect(autocompleteProviders) { - logger.debug("Refreshing autocomplete suggestions from ${autocompleteProviders.size} providers.") - - editText?.let { - it.setOnFilterListener( - AsyncFilterListener( - it, autocompleteDispatcher, - object : suspend (String, AutocompleteDelegate) -> Unit { - override suspend fun invoke( - query: String, - delegate: AutocompleteDelegate, - ) { - if (autocompleteProviders.isEmpty() || query.isBlank()) { - delegate.noAutocompleteResult(query) - } else { - val result = autocompleteProviders - .firstNotNullOfOrNull { it.getAutocompleteSuggestion(query) } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } - if (result != null) { - delegate.applyAutocompleteResult(result) { - onUrlSuggestionAutocompleted(result.url) - } - } else { - delegate.noAutocompleteResult(query) - } - } - } - }, - ), + var currentSuggestion: AutocompleteResult? by remember(suggestion) { mutableStateOf(suggestion) } + val suggestionTextColor = AcornTheme.colors.textPrimary + val highlightBackgroundColor = Color(TEXT_HIGHLIGHT_COLOR.toColorInt()) + val suggestionVisualTransformation = remember(currentSuggestion, textFieldValue) { + when (textFieldValue.text.isEmpty()) { + true -> VisualTransformation.None + false -> AutocompleteVisualTransformation( + userInput = textFieldValue, + suggestion = currentSuggestion, + textColor = suggestionTextColor, + textBackground = highlightBackgroundColor, ) - - it.refreshAutocompleteSuggestions() } } - AndroidView( - factory = { context -> - InlineAutocompleteEditText(context).apply { - id = R.id.mozac_addressbar_search_query_input - - imeOptions = EditorInfo.IME_ACTION_GO or - EditorInfo.IME_FLAG_NO_EXTRACT_UI or - EditorInfo.IME_FLAG_NO_FULLSCREEN - imeOptions = when (usePrivateModeQueries) { - true -> imeOptions or IME_FLAG_NO_PERSONALIZED_LEARNING - false -> imeOptions and (IME_FLAG_NO_PERSONALIZED_LEARNING.inv()) - } - inputType = TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_URI - setLines(1) - gravity = Gravity.CENTER_VERTICAL - setTextSize(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE) - setFocusable(true) - background = backgroundDrawable - autoCompleteBackgroundColor = autocompletedTextColor - setTextColor(textColor.toArgb()) - this.hint = hint - setHintTextColor(hintColor.toArgb()) - - // Used to match the same style that is used for Compose texts to ensure a smooth transition - letterSpacing = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, - LETTER_SPACING_SP, context.resources.displayMetrics, - ) / textSize - - updateText(query) - if (showQueryAsPreselected && query.isNotBlank()) { - post { selectAll() } - } + val localView = LocalView.current + LaunchedEffect(currentSuggestion) { + currentSuggestion?.text?.let { + @Suppress("DEPRECATION") + localView.announceForAccessibility(it) + } + } - setOnCommitListener { - onUrlCommitted(text.toString()) - } + var suggestionBounds by remember { mutableStateOf<Rect?>(null) } + val deviceLayoutDirection = LocalLayoutDirection.current - setOnTextChangeListener { text, _ -> - onUrlEdit(text) + // Always want the text to be entered left to right. + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + // Set incognito mode for the keyboard when needed. + InterceptPlatformTextInput( + interceptor = { request, nextHandler -> + val modifiedRequest = PlatformTextInputMethodRequest { outAttributes -> + request.createInputConnection(outAttributes).also { + if (usePrivateModeQueries) { + NoPersonalizedLearningHelper.addNoPersonalizedLearning(outAttributes) + } + } } + nextHandler.startInputMethod(modifiedRequest) + }, + ) { + BasicTextField( + value = textFieldValue, + onValueChange = { newValue -> + // Remove suggestion if cursor placement changed + val onlySelectionChanged = textFieldValue.text == newValue.text && + textFieldValue.composition == newValue.composition && + textFieldValue.annotatedString == newValue.annotatedString + if (onlySelectionChanged) { + currentSuggestion = null + textFieldValue = newValue + return@BasicTextField + } - setOnDispatchKeyEventPreImeListener { event -> - if (event?.keyCode == KeyEvent.KEYCODE_BACK && isImeVisible()) { - onUrlEditAborted() + // Remove suggestion if user pressed backspace and + // only delete query characters for the next backspace after the suggestion was removed. + val originalText = textFieldValue.text + val newText = newValue.text + val isBackspaceHidingSuggestion = originalText.length == newText.length + 1 && + originalText.startsWith(newText) && + currentSuggestion?.text?.startsWith(originalText) == true + if (isBackspaceHidingSuggestion) { + currentSuggestion = null + } else { + onUrlEdit( + BrowserToolbarQuery( + previous = originalText, + current = newText, + ), + ) + textFieldValue = newValue } - false - } - }.also { - editText = it - } - }, - modifier = modifier, - update = { - it.post { - if (query != it.originalText) { - it.updateText(query) - it.refreshAutocompleteSuggestions() - } - if (it.hint != hint) { - it.hint = hint - it.setHintTextColor(hintColor.toArgb()) - } - } - }, - ) + }, + modifier = modifier + .testTag(ADDRESSBAR_SEARCH_BOX) + .fillMaxWidth() + .onFocusChanged { focusState -> + if (focusState.isFocused) { + keyboardController?.show() + } + } + .focusRequester(focusRequester), + textStyle = TextStyle( + fontSize = TEXT_SIZE.sp, + color = AcornTheme.colors.textPrimary, + textAlign = when (deviceLayoutDirection) { + LayoutDirection.Ltr -> TextAlign.Start + LayoutDirection.Rtl -> TextAlign.End + }, + ), + keyboardOptions = KeyboardOptions( + showKeyboardOnFocus = true, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + autoCorrectEnabled = !usePrivateModeQueries, + ), + singleLine = true, + visualTransformation = suggestionVisualTransformation, + onTextLayout = { layoutResult -> + val currentInput = textFieldValue.text + suggestionBounds = when (currentInput.isEmpty()) { + true -> null + false -> try { + layoutResult.getBoundingBox(currentInput.length - 1) + } catch (_: IllegalArgumentException) { + null + } + } + }, + cursorBrush = SolidColor(AcornTheme.colors.textPrimary), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + // Commit the suggestion when users tap on the outside of the typed in text. + .pointerInput(currentSuggestion, suggestionBounds) { + awaitEachGesture { + val downEvent = awaitFirstDown(requireUnconsumed = false) + val bounds = suggestionBounds + val suggestion = currentSuggestion?.text + if (bounds != null && suggestion != null && + bounds.right < downEvent.position.x + ) { + onUrlEdit( + BrowserToolbarQuery( + previous = textFieldValue.text, + current = suggestion, + ), + ) + textFieldValue = TextFieldValue( + text = suggestion, + selection = TextRange(suggestion.length), + ) + } + } + }, + contentAlignment = when (deviceLayoutDirection) { + LayoutDirection.Ltr -> Alignment.CenterStart + LayoutDirection.Rtl -> Alignment.CenterEnd + }, + ) { + innerTextField() + } + }, + ) + } + } } /** - * Wraps [filter] execution in a coroutine context, cancelling prior executions on every invocation. - * [coroutineContext] must be of type that doesn't propagate cancellation of its children upwards. + * Information about the current browser toolbar query. + * + * @property current The current query. + * @property previous The previous query, if any. */ -private class AsyncFilterListener( - private val urlView: AutocompleteView, - override val coroutineContext: CoroutineContext, - private val filter: suspend (String, AutocompleteDelegate) -> Unit, - private val uiContext: CoroutineContext = Dispatchers.Main, -) : OnFilterListener, CoroutineScope { - override fun invoke(text: String) { - // We got a new input, so whatever past autocomplete queries we still have running are - // irrelevant. We cancel them, but do not depend on cancellation to take place. - coroutineContext.cancelChildren() - - CoroutineScope(coroutineContext).launch { - filter(text, AsyncAutocompleteDelegate(urlView, this, uiContext)) - } - } -} +data class BrowserToolbarQuery( + val current: String, + val previous: String? = null, +) /** - * An autocomplete delegate which is aware of its parent scope (to check for cancellations). - * Responsible for processing autocompletion results and discarding stale results when [urlView] moved on. + * Helper for showing the autocomplete suggestion inline with user's input. */ -private class AsyncAutocompleteDelegate( - private val urlView: AutocompleteView, - private val parentScope: CoroutineScope, - override val coroutineContext: CoroutineContext, - private val logger: Logger = Logger("AsyncAutocompleteDelegate"), -) : AutocompleteDelegate, CoroutineScope { - override fun applyAutocompleteResult(result: AutocompleteResult, onApplied: () -> Unit) { - // Bail out if we were cancelled already. - if (!parentScope.isActive) { - logger.debug("Autocomplete request cancelled. Discarding results.") - return +private class AutocompleteVisualTransformation( + private val userInput: TextFieldValue, + private val suggestion: AutocompleteResult?, + private val textColor: Color, + private val textBackground: Color, +) : VisualTransformation { + + override fun filter(text: AnnotatedString): TransformedText { + if (suggestion?.text.isNullOrEmpty() || !suggestion.text.startsWith(userInput.text)) { + return TransformedText(AnnotatedString(userInput.text), OffsetMapping.Identity) } - // Process results on the UI dispatcher. - CoroutineScope(coroutineContext).launch { - // Ignore this result if the query is stale. - if (result.input == urlView.originalText.lowercase()) { - urlView.applyAutocompleteResult( - InlineAutocompleteEditText.AutocompleteResult( - text = result.text, - source = result.source, - totalItems = result.totalItems, + val transformed = buildAnnotatedString { + append(userInput.text) + append( + AnnotatedString( + suggestion.text.removePrefix(userInput.text), + spanStyle = SpanStyle( + color = textColor, + background = textBackground, ), - ) - onApplied() - } else { - logger.debug("Discarding stale autocomplete result.") - } + ), + ) } - } - override fun noAutocompleteResult(input: String) { - // Bail out if we were cancelled already. - if (!parentScope.isActive) { - logger.debug("Autocomplete request cancelled. Discarding 'noAutocompleteResult'.") - return - } + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return offset + } - // Process results on the UI thread. - CoroutineScope(coroutineContext).launch { - // Ignore this result if the query is stale. - if (input == urlView.originalText) { - urlView.noAutocompleteResult() - } else { - logger.debug("Discarding stale lack of autocomplete results.") + override fun transformedToOriginal(offset: Int): Int { + return offset.coerceIn(0, userInput.text.length) } } - } -} -private fun buildBackground( - context: Context, - @ColorInt color: Int, - cornerRadius: Float = 8f, -): GradientDrawable { - val cornerRadiusPx = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - cornerRadius, - context.resources.displayMetrics, - ) - - return GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - setColor(color) - this.cornerRadius = cornerRadiusPx + return TransformedText(transformed, offsetMapping) } } -private fun View.isImeVisible() = ViewCompat.getRootWindowInsets(this)?.isVisible(ime()) == true - -private fun InlineAutocompleteEditText.updateText(newText: String) { - // Avoid running the code for focusing this if the updated text is the one user already typed. - // But ensure focusing this if just starting to type. - if (text.toString() == newText && newText.isNotEmpty()) return - - setText(text = newText, shouldAutoComplete = false) - setSelection(newText.length) - if (!hasFocus()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - // On Android 14 this needs to be called before requestFocus() in order to receive focus. - isFocusableInTouchMode = true - } - requestFocus() - showKeyboard() +/** + * Temporary helper for putting the toolbar in incognito mode. + * See https://issuetracker.google.com/issues/359257538. + */ +@VisibleForTesting +internal object NoPersonalizedLearningHelper { + @DoNotInline + fun addNoPersonalizedLearning(info: EditorInfo) { + info.imeOptions = info.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING } } @PreviewLightDark @Composable -private fun BrowserEditToolbarPreview() { - InlineAutocompleteTextField( - query = "http://www.mozilla.org", - hint = "", - showQueryAsPreselected = false, - usePrivateModeQueries = false, - autocompleteProviders = emptyList(), - ) +private fun InlineAutocompleteTextFieldWithSuggestion() { + AcornTheme { + Box( + Modifier.background(AcornTheme.colors.layer1), + ) { + InlineAutocompleteTextField( + query = "wiki", + hint = "preview", + showQueryAsPreselected = false, + usePrivateModeQueries = false, + suggestion = AutocompleteResult( + "wiki", + "wikipedia.org", + "https://wikipedia.org", + "test", + 1, + ), + ) + } + } } diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/res/values/ids.xml b/mobile/android/android-components/components/compose/browser-toolbar/src/main/res/values/ids.xml @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- This Source Code Form is subject to the terms of the Mozilla Public - - License, v. 2.0. If a copy of the MPL was not distributed with this - - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -<resources> - <item name="mozac_addressbar_search_query_input" type="id"/> -</resources> diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStoreTest.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/store/BrowserToolbarStoreTest.kt @@ -17,6 +17,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.Too import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent import mozilla.components.compose.browser.toolbar.store.ToolbarGravity.Bottom import mozilla.components.compose.browser.toolbar.store.ToolbarGravity.Top +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Test @@ -35,7 +36,7 @@ class BrowserToolbarStoreTest { store.dispatch(BrowserToolbarAction.EnterEditMode) assertEquals(Mode.EDIT, store.state.mode) - assertEquals("", store.state.editState.query) + assertEquals("", store.state.editState.query.current) } @Test @@ -44,7 +45,7 @@ class BrowserToolbarStoreTest { initialState = BrowserToolbarState( mode = Mode.EDIT, editState = EditState( - query = "Mozilla", + query = BrowserToolbarQuery("Mozilla"), ), ), ) @@ -54,7 +55,7 @@ class BrowserToolbarStoreTest { store.dispatch(BrowserToolbarAction.ExitEditMode) assertEquals(Mode.DISPLAY, store.state.mode) - assertEquals("", store.state.editState.query) + assertEquals("", store.state.editState.query.current) } @Test @@ -62,11 +63,11 @@ class BrowserToolbarStoreTest { val store = BrowserToolbarStore() val text = "Mozilla" - assertEquals("", store.state.editState.query) + assertEquals("", store.state.editState.query.current) - store.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(query = text)) + store.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(query = BrowserToolbarQuery(text))) - assertEquals(text, store.state.editState.query) + assertEquals(text, store.state.editState.query.current) } @Test diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.compose.browser.toolbar.ui + +import android.view.inputmethod.EditorInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.performTouchInput +import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_SEARCH_BOX +import mozilla.components.concept.toolbar.AutocompleteResult +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InlineAutocompleteTextFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `WHEN the query is updated THEN inform callbacks`() { + val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock() + + composeTestRule.setContent { + InlineAutocompleteTextField( + query = "", + hint = "Search or enter address", + suggestion = null, + showQueryAsPreselected = false, + usePrivateModeQueries = false, + onUrlEdit = onUrlEdit, + ) + } + + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).performTextReplacement("hello") + verify(onUrlEdit).invoke(BrowserToolbarQuery(current = "hello", previous = "")) + + composeTestRule.onNodeWithText("hello").performTextInput(" world") + verify(onUrlEdit).invoke(BrowserToolbarQuery(current = "hello world", previous = "hello")) + } + + @Test + fun `GIVEN a query WHEN an autocomplete suggestion is available THEN display the autocompleted query`() { + val suggestion = AutocompleteResult( + input = "moz", + text = "mozilla.org", + url = "https://mozilla.org", + source = "test", + totalItems = 1, + ) + + composeTestRule.setContent { + InlineAutocompleteTextField( + query = "moz", + hint = "", + suggestion = suggestion, + showQueryAsPreselected = false, + usePrivateModeQueries = false, + ) + } + + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).assertTextEquals("mozilla.org") + } + + @Test + fun `GIVEN an autocomplete suggestion is shown WHEN the query is tapped, THEN don't do anything`() { + val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock() + val suggestion = AutocompleteResult( + input = "w", + text = "wikipedia.org", + url = "https://wikipedia.org", + source = "test", + totalItems = 1, + ) + + composeTestRule.setContent { + InlineAutocompleteTextField( + query = "w", + hint = "", + suggestion = suggestion, + showQueryAsPreselected = false, + usePrivateModeQueries = false, + onUrlEdit = onUrlEdit, + ) + } + + // Tapping on the very left is where the query is shown. + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).performTouchInput { click(position = centerLeft) } + + verify(onUrlEdit, never()).invoke(any()) + } + + @Test + fun `GIVEN a query and suggestion are shown WHEN backspace is first pressed THEN only clear the suggestion`() { + val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock() + val suggestion = AutocompleteResult( + input = "moz", + text = "mozilla.org", + url = "https://mozilla.org", + source = "test", + totalItems = 1, + ) + + composeTestRule.setContent { + InlineAutocompleteTextField( + query = "moz", + hint = "", + suggestion = suggestion, + showQueryAsPreselected = false, + usePrivateModeQueries = false, + onUrlEdit = onUrlEdit, + ) + } + + composeTestRule.onNodeWithText("mozilla.org").assertIsDisplayed() + + // Simulate a backspace normally deleting text. + composeTestRule.onNodeWithText("mozilla.org").performTextReplacement("mo") + // The text should now be the original query, without the suggestion. + composeTestRule.onNodeWithText("moz").assertIsDisplayed() + composeTestRule.onNodeWithText("mozilla.org").assertDoesNotExist() + verify(onUrlEdit, never()).invoke(any()) + + // Simulate a second backspace. + composeTestRule.onNodeWithText("moz").performTextReplacement("mo") + // Now the last character of the query should be deleted and the callback notified. + composeTestRule.onNodeWithText("mo").assertIsDisplayed() + composeTestRule.onNodeWithText("moz").assertDoesNotExist() + verify(onUrlEdit).invoke(BrowserToolbarQuery(previous = "moz", current = "mo")) + } + + @Test + fun `WHEN disabling personalized learning for the IME THEN set the right ime option`() { + val editorInfo = EditorInfo() + + NoPersonalizedLearningHelper.addNoPersonalizedLearning(editorInfo) + + assertTrue(editorInfo.imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING != 0) + } +} diff --git a/mobile/android/android-components/config/detekt-baseline.xml b/mobile/android/android-components/config/detekt-baseline.xml @@ -84,7 +84,7 @@ <ID>ForbiddenSuppress:IconResourceComparator.kt$@Suppress("MagicNumber")</ID> <ID>ForbiddenSuppress:ImageDecoder.kt$ImageDecoder.Companion.ImageMagicNumbers$@Suppress("MagicNumber")</ID> <ID>ForbiddenSuppress:InlineAutocompleteEditText.kt$InlineAutocompleteEditText$@SuppressWarnings("ComplexMethod")</ID> - <ID>ForbiddenSuppress:InlineAutocompleteTextField.kt$@Suppress("LongMethod")</ID> + <ID>ForbiddenSuppress:InlineAutocompleteTextField.kt$@Suppress("LongMethod", "CyclomaticComplexMethod")</ID> <ID>ForbiddenSuppress:InputResultDetail.kt$InputResultDetail$@Suppress("MagicNumber")</ID> <ID>ForbiddenSuppress:Lexer.kt$Lexer$@Suppress("ComplexMethod")</ID> <ID>ForbiddenSuppress:Lexer.kt$Lexer$@Suppress("ComplexMethod", "LongMethod")</ID> diff --git a/mobile/android/android-components/samples/compose-browser/src/main/java/org/mozilla/samples/compose/browser/browser/BrowserScreen.kt b/mobile/android/android-components/samples/compose-browser/src/main/java/org/mozilla/samples/compose/browser/browser/BrowserScreen.kt @@ -30,6 +30,7 @@ import mozilla.components.compose.browser.toolbar.BrowserToolbar import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.compose.engine.WebContent import mozilla.components.compose.tabstray.TabList import mozilla.components.concept.awesomebar.AwesomeBar @@ -92,10 +93,10 @@ fun BrowserScreen(navController: NavController) { Target.SelectedTab, ) - val url = toolbarState.editState.query - if (toolbarState.isEditMode() && url.isNotEmpty()) { + val query = toolbarState.editState.query + if (toolbarState.isEditMode() && query.current.isNotEmpty()) { Suggestions( - url, + query.current, onSuggestionClicked = { suggestion -> toolbarStore.dispatch(BrowserToolbarAction.ExitEditMode) suggestion.onSuggestionClicked?.invoke() @@ -103,7 +104,7 @@ fun BrowserScreen(navController: NavController) { onAutoComplete = { suggestion -> toolbarStore.dispatch( BrowserEditToolbarAction.SearchQueryUpdated( - suggestion.editSuggestion!!, + BrowserToolbarQuery(suggestion.editSuggestion!!), ), ) }, diff --git a/mobile/android/android-components/samples/toolbar/src/main/java/org/mozilla/samples/toolbar/compose/BrowserToolbar.kt b/mobile/android/android-components/samples/toolbar/src/main/java/org/mozilla/samples/toolbar/compose/BrowserToolbar.kt @@ -11,6 +11,7 @@ import mozilla.components.compose.browser.toolbar.BrowserDisplayToolbar import mozilla.components.compose.browser.toolbar.BrowserEditToolbar import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.ToolbarGravity +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.ext.observeAsComposableState import mozilla.components.lib.state.ext.observeAsState @@ -25,18 +26,17 @@ import mozilla.components.lib.state.ext.observeAsState * @param onTextCommit Invoked when the user has finished editing the URL and wants * to commit the entered text. */ -@Suppress("MagicNumber") @Composable fun BrowserToolbar( store: BrowserToolbarStore, - onTextEdit: (String) -> Unit, + onTextEdit: (BrowserToolbarQuery) -> Unit, onTextCommit: (String) -> Unit, url: String = "", ) { val uiState by store.observeAsState(initialValue = store.state) { it } val progressBarConfig = store.observeAsComposableState { it.displayState.progressBarConfig }.value - val input = when (val editText = uiState.editState.query) { + val input = when (val editText = uiState.editState.query.current) { "" -> url else -> editText } @@ -49,7 +49,7 @@ fun BrowserToolbar( editActionsEnd = uiState.editState.editActionsEnd, hint = stringResource(uiState.editState.hint), onUrlCommitted = { text -> onTextCommit(text) }, - onUrlEdit = { text -> onTextEdit(text) }, + onUrlEdit = { query -> onTextEdit(query) }, onInteraction = { store.dispatch(it) }, ) } else { diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTestCompose.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/NavigationToolbarTestCompose.kt @@ -50,7 +50,6 @@ import org.mozilla.fenix.helpers.TestHelper.clickSnackbarButton import org.mozilla.fenix.helpers.TestHelper.exitMenu import org.mozilla.fenix.helpers.TestHelper.verifyDarkThemeApplied import org.mozilla.fenix.helpers.TestHelper.verifyLightThemeApplied -import org.mozilla.fenix.helpers.TestHelper.waitForAppWindowToBeUpdated import org.mozilla.fenix.helpers.TestSetup import org.mozilla.fenix.helpers.perf.DetectMemoryLeaksRule import org.mozilla.fenix.nimbus.FxNimbus @@ -263,11 +262,11 @@ class NavigationToolbarTestCompose : TestSetup() { }.openBookmarks(composeTestRule) { }.clickSearchButton { // Search for a valid term - typeSearchWithComposableToolbar(firstWebPage.title) + typeSearchWithComposableToolbar(composeTestRule, firstWebPage.title) verifySearchSuggestionsAreDisplayed(composeTestRule, firstWebPage.url.toString()) verifySuggestionsAreNotDisplayed(composeTestRule, secondWebPage.url.toString()) // Search for invalid term - typeSearchWithComposableToolbar("Android") + typeSearchWithComposableToolbar(composeTestRule, "Android") verifySuggestionsAreNotDisplayed(composeTestRule, firstWebPage.url.toString()) verifySuggestionsAreNotDisplayed(composeTestRule, secondWebPage.url.toString()) } @@ -314,7 +313,7 @@ class NavigationToolbarTestCompose : TestSetup() { // Bugzilla ticket: https://bugzilla.mozilla.org/show_bug.cgi?id=1813587 clickSearchSelectorButtonWithComposableToolbar(composeTestRule) selectTemporarySearchMethodWithComposableToolbar(composeTestRule, "DuckDuckGo") - typeSearchWithComposableToolbar("mozilla ") + typeSearchWithComposableToolbar(composeTestRule, "mozilla ") verifySearchSuggestionsAreDisplayed(composeTestRule, "mozilla firefox") }.dismissSearchBar { }.openThreeDotMenuWithComposableToolbar(composeTestRule) { @@ -328,7 +327,7 @@ class NavigationToolbarTestCompose : TestSetup() { // Bugzilla ticket: https://bugzilla.mozilla.org/show_bug.cgi?id=1813587 clickSearchSelectorButtonWithComposableToolbar(composeTestRule) selectTemporarySearchMethodWithComposableToolbar(composeTestRule, "DuckDuckGo") - typeSearchWithComposableToolbar("mozilla") + typeSearchWithComposableToolbar(composeTestRule, "mozilla") verifySuggestionsWithComposableToolbarAreNotDisplayed(composeTestRule) } } @@ -404,10 +403,10 @@ class NavigationToolbarTestCompose : TestSetup() { composeTestRule, searchEngineName = "History", ) - typeSearchWithComposableToolbar(searchTerm = "Mozilla") + typeSearchWithComposableToolbar(composeTestRule, searchTerm = "Mozilla") verifySuggestionsAreNotDisplayed(rule = composeTestRule, "Mozilla") clickClearButtonWithComposableToolbar(composeTestRule) - typeSearchWithComposableToolbar(searchTerm = "generic") + typeSearchWithComposableToolbar(composeTestRule, searchTerm = "generic") // verifyTypedToolbarText("generic", exists = true) verifySearchSuggestionsAreDisplayed( rule = composeTestRule, @@ -438,7 +437,7 @@ class NavigationToolbarTestCompose : TestSetup() { verifySearchShortcutListWithComposableToolbar(composeTestRule, it) selectTemporarySearchMethodWithComposableToolbar(composeTestRule, it) verifySearchEngineIconWithComposableToolbar(composeTestRule, it) - }.submitQueryWithComposableToolbar("mozilla ") { + }.submitQueryWithComposableToolbar(composeTestRule, "mozilla ") { verifyUrlWithComposableToolbar(composeTestRule, "mozilla") }.goToHomescreenWithComposableToolbar(composeTestRule) { } @@ -464,13 +463,13 @@ class NavigationToolbarTestCompose : TestSetup() { homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - }.submitQueryWithComposableToolbar("test page 1") { + }.submitQueryWithComposableToolbar(composeTestRule, "test page 1") { }.goToHomescreenWithComposableToolbar(composeTestRule) { togglePrivateBrowsingModeOnOff(composeTestRule) }.openSearchWithComposableToolbar(composeTestRule) { - }.submitQueryWithComposableToolbar("test page 2") { + }.submitQueryWithComposableToolbar(composeTestRule, "test page 2") { }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar(searchTerm = "test page") + typeSearchWithComposableToolbar(composeTestRule, searchTerm = "test page") verifyTheSuggestionsHeader(composeTestRule, firefoxSuggestHeader) verifyTheSuggestionsHeader(composeTestRule, "TestSearchEngine search") verifySearchSuggestionsAreDisplayed( @@ -513,7 +512,7 @@ class NavigationToolbarTestCompose : TestSetup() { // Performs a search and opens 2 dummy search results links to create a search group homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - }.submitQueryWithComposableToolbar(queryString) { + }.submitQueryWithComposableToolbar(composeTestRule, queryString) { longClickPageObject(itemWithText("Link 1")) clickContextMenuItem("Open link in new tab") clickSnackbarButton(composeTestRule, "SWITCH") @@ -549,7 +548,7 @@ class NavigationToolbarTestCompose : TestSetup() { // Performs a search and opens 2 dummy search results links to create a search group homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - }.submitQueryWithComposableToolbar(queryString) { + }.submitQueryWithComposableToolbar(composeTestRule, queryString) { longClickPageObject(itemWithText("Link 1")) clickContextMenuItem("Open link in private tab") longClickPageObject(itemWithText("Link 2")) @@ -594,7 +593,7 @@ class NavigationToolbarTestCompose : TestSetup() { }.enterURLAndEnterToBrowserWithComposableToolbar(composeTestRule, firstWebPage.url) { }.openTabDrawerWithComposableToolbar(composeTestRule) { }.openNewTab { - }.submitQueryWithComposableToolbar(secondWebPage.url.toString()) { + }.submitQueryWithComposableToolbar(composeTestRule, secondWebPage.url.toString()) { swipeNavBarRightWithComposableToolbar(composeTestRule, secondWebPage.url.toString()) verifyUrlWithComposableToolbar(composeTestRule, firstWebPage.url.toString()) swipeNavBarLeftWithComposableToolbar(composeTestRule, firstWebPage.url.toString()) @@ -666,10 +665,10 @@ class NavigationToolbarTestCompose : TestSetup() { // If true it will use the hardcoded list of "top domain" suggestions for the address bar's autocomplete suggestions homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar("mo") - verifyTypedToolbarTextWithComposableToolbar("monster.com", exists = true) - typeSearchWithComposableToolbar("moz") - verifyTypedToolbarTextWithComposableToolbar("mozilla.org", exists = true) + typeSearchWithComposableToolbar(composeTestRule, "mo") + verifyTypedToolbarTextWithComposableToolbar(composeTestRule, "monster.com", exists = true) + typeSearchWithComposableToolbar(composeTestRule, "moz") + verifyTypedToolbarTextWithComposableToolbar(composeTestRule, "mozilla.org", exists = true) } } else { // The suggestions for the address bar's autocomplete will take use of the user's local browsing history and bookmarks @@ -682,26 +681,29 @@ class NavigationToolbarTestCompose : TestSetup() { homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar("moz") + typeSearchWithComposableToolbar(composeTestRule, "moz") // "Top domain" suggestions from the address bar's autocomplete are disabled, "moz" shouldn't autocomplete to mozilla.org - verifyTypedToolbarTextWithComposableToolbar("mozilla.org", exists = false) + verifyTypedToolbarTextWithComposableToolbar(composeTestRule, "mozilla.org", exists = false) // The address bar's autocomplete should take use of the browsing history // Autocomplete with the history items url - typeSearchWithComposableToolbar("github.com/mozilla-mobile/f") + typeSearchWithComposableToolbar(composeTestRule, "github.com/mozilla-mobile/f") verifyTypedToolbarTextWithComposableToolbar( + composeTestRule, "github.com/mozilla-mobile/fenix", exists = true, ) // The address bar's autocomplete should also take use of the saved bookmarks // Autocomplete with the bookmarked items url - typeSearchWithComposableToolbar("github.com/mozilla-mobile/fo") + typeSearchWithComposableToolbar(composeTestRule, "github.com/mozilla-mobile/fo") verifyTypedToolbarTextWithComposableToolbar( + composeTestRule, "github.com/mozilla-mobile/focus-android", exists = true, ) // It should not autocomplete with links that are not part of browsing history or bookmarks - typeSearchWithComposableToolbar("github.com/mozilla-mobile/fi") + typeSearchWithComposableToolbar(composeTestRule, "github.com/mozilla-mobile/fi") verifyTypedToolbarTextWithComposableToolbar( + composeTestRule, "github.com/mozilla-mobile/firefox-android", exists = false, ) @@ -731,7 +733,7 @@ class NavigationToolbarTestCompose : TestSetup() { homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar("test") + typeSearchWithComposableToolbar(composeTestRule, "test") verifySuggestionsAreNotDisplayed( composeTestRule, "Firefox Suggest", @@ -765,7 +767,7 @@ class NavigationToolbarTestCompose : TestSetup() { homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar("test") + typeSearchWithComposableToolbar(composeTestRule, "test") verifySuggestionsAreNotDisplayed( composeTestRule, "Firefox Suggest", @@ -812,7 +814,7 @@ class NavigationToolbarTestCompose : TestSetup() { homeScreen { togglePrivateBrowsingModeOnOff(composeTestRule = composeTestRule) }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar("mozilla") + typeSearchWithComposableToolbar(composeTestRule, "mozilla") verifyAllowSuggestionsInPrivateModeDialogWithComposableToolbar(composeTestRule) denySuggestionsInPrivateMode() verifySuggestionsAreNotDisplayed(composeTestRule, "mozilla firefox") @@ -829,9 +831,9 @@ class NavigationToolbarTestCompose : TestSetup() { ) { homeScreen { }.openSearchWithComposableToolbar(composeTestRule) { - typeSearchWithComposableToolbar(queryString) + typeSearchWithComposableToolbar(composeTestRule, queryString) clickClearButtonWithComposableToolbar(composeTestRule) - verifySearchBarPlaceholderWithComposableToolbar("Search or enter address") +// verifySearchBarPlaceholderWithComposableToolbar("Search or enter address") } } } @@ -855,12 +857,12 @@ class NavigationToolbarTestCompose : TestSetup() { }.openHistory { }.clickSearchButton { // Search for a valid term - typeSearchWithComposableToolbar(firstWebPage.title) + typeSearchWithComposableToolbar(composeTestRule, firstWebPage.title) verifySearchSuggestionsAreDisplayed(composeTestRule, firstWebPage.url.toString()) verifySuggestionsAreNotDisplayed(composeTestRule, secondWebPage.url.toString()) clickClearButtonWithComposableToolbar(composeTestRule) // Search for invalid term - typeSearchWithComposableToolbar("Android") + typeSearchWithComposableToolbar(composeTestRule, "Android") verifySuggestionsAreNotDisplayed(composeTestRule, firstWebPage.url.toString()) verifySuggestionsAreNotDisplayed(composeTestRule, secondWebPage.url.toString()) } @@ -878,10 +880,10 @@ class NavigationToolbarTestCompose : TestSetup() { }.openSearchWithComposableToolbar(composeTestRule) { clickSearchSelectorButtonWithComposableToolbar(composeTestRule) selectTemporarySearchMethodWithComposableToolbar(composeTestRule, "History") - typeSearchWithComposableToolbar(searchTerm = "Mozilla") + typeSearchWithComposableToolbar(composeTestRule, searchTerm = "Mozilla") verifySuggestionsAreNotDisplayed(rule = composeTestRule, "Mozilla") clickClearButtonWithComposableToolbar(composeTestRule) - verifySearchBarPlaceholderWithComposableToolbar("Search history") +// verifySearchBarPlaceholderWithComposableToolbar("Search history") } } } @@ -901,8 +903,8 @@ class NavigationToolbarTestCompose : TestSetup() { verifyKeyboardVisibility(isExpectedToBeVisible = true) verifyScanButtonWithComposableToolbar(composeTestRule, isDisplayed = true) verifyVoiceSearchButtonVisibility(enabled = true) - verifySearchBarPlaceholderWithComposableToolbar("Search or enter address") - typeSearchWithComposableToolbar("mozilla ") +// verifySearchBarPlaceholderWithComposableToolbar("Search or enter address") + typeSearchWithComposableToolbar(composeTestRule, "mozilla ") verifyScanButtonWithComposableToolbar(composeTestRule, isDisplayed = false) verifyVoiceSearchButtonVisibility(enabled = true) } @@ -941,7 +943,7 @@ class NavigationToolbarTestCompose : TestSetup() { clickSearchSelectorButtonWithComposableToolbar(composeTestRule) selectTemporarySearchMethodWithComposableToolbar(composeTestRule, "Tabs") verifyVoiceSearchButtonWithComposableToolbar(composeTestRule, isDisplayed = true) - verifySearchBarPlaceholderWithComposableToolbar("Search tabs") +// verifySearchBarPlaceholderWithComposableToolbar("Search tabs") verifyScanButtonWithComposableToolbar(composeTestRule, isDisplayed = false) } } @@ -959,7 +961,7 @@ class NavigationToolbarTestCompose : TestSetup() { clickSearchSelectorButtonWithComposableToolbar(composeTestRule) selectTemporarySearchMethodWithComposableToolbar(composeTestRule, "History") verifyVoiceSearchButtonWithComposableToolbar(composeTestRule, isDisplayed = true) - verifySearchBarPlaceholderWithComposableToolbar("Search history") +// verifySearchBarPlaceholderWithComposableToolbar("Search history") verifyScanButtonWithComposableToolbar(composeTestRule, isDisplayed = false) } } diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/NavigationToolbarRobot.kt @@ -20,13 +20,14 @@ import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextReplacement import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.AppNotIdleException import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.action.ViewActions.pressImeActionButton -import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.assertion.PositionAssertions.isCompletelyAbove import androidx.test.espresso.assertion.PositionAssertions.isPartiallyBelow import androidx.test.espresso.assertion.ViewAssertions.matches @@ -41,6 +42,7 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.By.textContains import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until +import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_SEARCH_BOX import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_URL_BOX import org.hamcrest.CoreMatchers.allOf import org.junit.Assert.assertTrue @@ -72,7 +74,6 @@ import org.mozilla.fenix.helpers.matchers.hasItemsCount import org.mozilla.fenix.tabstray.TabsTrayTestTag import mozilla.components.browser.menu.R as menuR import mozilla.components.browser.toolbar.R as toolbarR -import mozilla.components.compose.browser.toolbar.R as composeToolbarR import mozilla.components.ui.tabcounter.R as tabcounterR /** @@ -345,9 +346,10 @@ class NavigationToolbarRobot { composeTestRule.onAllNodesWithTag(ADDRESSBAR_URL_BOX).onLast().performClick() Log.i(TAG, "enterURLAndEnterToBrowserWithComposableToolbar: Clicked navigation toolbar") Log.i(TAG, "enterURLAndEnterToBrowserWithComposableToolbar: Trying to set toolbar text to: $url and perform IME action") - onView(withId(composeToolbarR.id.mozac_addressbar_search_query_input)).perform( - replaceText(url.toString()), pressImeActionButton(), - ) + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).apply { + performTextReplacement(url.toString()) + performImeAction() + } Log.i(TAG, "enterURLAndEnterToBrowserWithComposableToolbar: Toolbar text was set to: $url and IME action performed") BrowserRobot().interact() diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SearchRobot.kt @@ -22,10 +22,11 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextReplacement import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.pressImeActionButton -import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.assertion.PositionAssertions import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers @@ -33,6 +34,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.uiautomator.By import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector +import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_SEARCH_BOX import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.SEARCH_SELECTOR import org.junit.Assert.assertTrue import org.mozilla.fenix.R @@ -62,7 +64,6 @@ import org.mozilla.fenix.helpers.TestHelper.appName import org.mozilla.fenix.helpers.TestHelper.mDevice import org.mozilla.fenix.helpers.TestHelper.packageName import mozilla.components.browser.toolbar.R as toolbarR -import mozilla.components.compose.browser.toolbar.R as composeToolbarR import mozilla.components.feature.qr.R as qrR /** @@ -476,9 +477,9 @@ class SearchRobot { Log.i(TAG, "typeSearch: Waited for device to be idle") } - fun typeSearchWithComposableToolbar(searchTerm: String) { + fun typeSearchWithComposableToolbar(composeTestRule: ComposeTestRule, searchTerm: String) { Log.i(TAG, "typeSearchWithComposableToolbar: Trying to set the edit mode toolbar text to $searchTerm") - onView(withId(composeToolbarR.id.mozac_addressbar_search_query_input)).perform(replaceText(searchTerm)) + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).performTextReplacement(searchTerm) Log.i(TAG, "typeSearchWithComposableToolbar: Edit mode toolbar text was set to $searchTerm") } @@ -537,13 +538,24 @@ class SearchRobot { waitingTime = waitingTimeShort, ) - fun verifyTypedToolbarTextWithComposableToolbar(expectedText: String, exists: Boolean) = - assertUIObjectExists( - itemWithResIdAndText("$packageName:id/mozac_addressbar_search_query_input", expectedText), - exists = exists, - waitingTime = waitingTimeShort, + fun verifyTypedToolbarTextWithComposableToolbar( + composeTestRule: ComposeTestRule, expectedText: String, exists: Boolean, + ) { + Log.i(TAG, "verifyTypedToolbarTextWithComposableToolbar: Verifying that text '$expectedText' exists?: $exists") + + val editToolbar = composeTestRule.onNode( + hasTestTag(ADDRESSBAR_SEARCH_BOX) and hasText(expectedText), + useUnmergedTree = true, ) + when (exists) { + true -> editToolbar.assertIsDisplayed() + false -> editToolbar.assertIsNotDisplayed() + } + + Log.i(TAG, "verifyTypedToolbarTextWithComposableToolbar: Verification successful.") + } + fun verifySearchBarPosition(bottomPosition: Boolean) { Log.i(TAG, "verifySearchBarPosition: Trying to verify that the search bar is set to bottom: $bottomPosition") onView(withId(R.id.toolbar)) @@ -624,12 +636,18 @@ class SearchRobot { return BrowserRobot.Transition() } - fun submitQueryWithComposableToolbar(query: String, interact: BrowserRobot.() -> Unit): BrowserRobot.Transition { + fun submitQueryWithComposableToolbar( + composeTestRule: ComposeTestRule, + query: String, interact: BrowserRobot.() -> Unit, + ): BrowserRobot.Transition { Log.i(TAG, "submitQueryWithComposableToolbar: Trying to set toolbar text to: $query and pressing IME action") - onView(withId(composeToolbarR.id.mozac_addressbar_search_query_input)).perform( - replaceText(query), pressImeActionButton(), - ) + + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).apply { + performTextReplacement(query) + performImeAction() + } Log.i(TAG, "submitQueryWithComposableToolbar: Toolbar text was set to: $query and IME action performed") + BrowserRobot().interact() return BrowserRobot.Transition() } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/bookmarks/BookmarksScreen.kt @@ -110,6 +110,7 @@ import mozilla.components.compose.browser.awesomebar.AwesomeBarOrientation import mozilla.components.compose.browser.toolbar.BrowserToolbar import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.base.profiler.Profiler import mozilla.components.lib.state.ext.observeAsComposableState import mozilla.components.lib.state.ext.observeAsState @@ -315,7 +316,7 @@ private fun BookmarksList( } false -> { appStore.dispatch(AppAction.SearchAction.SearchEnded) - toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated("")) + toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(BrowserToolbarQuery(""))) browserStore.dispatch(AwesomeBarAction.EngagementFinished(abandoned = true)) focusManager.clearFocus() keyboardController?.hide() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddleware.kt @@ -56,6 +56,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.ProgressBarConfig +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.EngineSession.LoadUrlFlags import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage import mozilla.components.concept.engine.permission.SitePermissions @@ -375,7 +376,7 @@ class BrowserToolbarMiddleware( ) } } else { - context.dispatch(SearchQueryUpdated(searchTerms)) + context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(searchTerms))) appStore.dispatch(SearchStarted(selectedTab.id)) } } @@ -395,7 +396,7 @@ class BrowserToolbarMiddleware( } } is PasteFromClipboardClicked -> runWithinEnvironment { - context.dispatch(SearchQueryUpdated(clipboard.text.orEmpty())) + context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(clipboard.text.orEmpty()))) appStore.dispatch(SearchStarted(browserStore.state.selectedTabId)) } is LoadFromClipboardClicked -> { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddleware.kt @@ -45,6 +45,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.Mode +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext import mozilla.components.lib.state.State @@ -246,7 +247,7 @@ class BrowserToolbarMiddleware( ) { runWithinEnvironment { browsingMode?.let { browsingModeManager.mode = it } - context.dispatch(SearchQueryUpdated(searchTerms ?: "")) + context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(searchTerms ?: ""))) appStore.dispatch(SearchStarted()) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/HomeToolbarComposable.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/toolbar/HomeToolbarComposable.kt @@ -34,6 +34,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.ToolbarGravity import mozilla.components.compose.browser.toolbar.store.ToolbarGravity.Bottom import mozilla.components.compose.browser.toolbar.store.ToolbarGravity.Top +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.ext.observeAsComposableState import mozilla.components.support.ktx.android.view.ImeInsetsSynchronizer import org.mozilla.fenix.R @@ -204,7 +205,7 @@ internal class HomeToolbarComposable( browserStore.state.findTab(directToSearchConfig.sessionId)?.let { toolbarStore.dispatch( SearchQueryUpdated( - query = it.getUrl() ?: "", + query = BrowserToolbarQuery(it.getUrl() ?: ""), isQueryPrefilled = true, ), ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -4,7 +4,6 @@ package org.mozilla.fenix.library.history -import android.R.id.undo import android.app.Dialog import android.content.DialogInterface import android.content.Intent @@ -86,6 +85,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.Mode +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.flowScoped @@ -568,7 +568,7 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler, } searchLayout?.isVisible = false requireComponents.appStore.dispatch(AppAction.SearchAction.SearchEnded) - toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated("")) + toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(BrowserToolbarQuery(""))) requireComponents.core.store.dispatch(EngagementFinished(abandoned = true)) } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddleware.kt @@ -14,12 +14,17 @@ import androidx.lifecycle.Lifecycle.State.RESUMED import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mozilla.components.browser.state.action.AwesomeBarAction.EngagementFinished import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.search.SearchEngine.Type.APPLICATION @@ -31,13 +36,12 @@ import mozilla.components.compose.browser.toolbar.concept.Action import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton import mozilla.components.compose.browser.toolbar.concept.Action.ActionButtonRes import mozilla.components.compose.browser.toolbar.concept.Action.SearchSelectorAction -import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.AutocompleteProvidersUpdated +import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.HintUpdated import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchAborted import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchActionsEndUpdated import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchActionsStartUpdated import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated -import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.UrlSuggestionAutocompleted import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.CommitUrl import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.EnterEditMode @@ -51,12 +55,17 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarState import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.concept.toolbar.AutocompleteResult import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext import mozilla.components.lib.state.State import mozilla.components.lib.state.Store import mozilla.components.lib.state.ext.flow +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.utils.NamedThreadFactory import mozilla.components.support.ktx.kotlin.isUrl import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.Toolbar @@ -93,6 +102,8 @@ import org.mozilla.fenix.telemetry.ACTION_QR_CLICKED import org.mozilla.fenix.telemetry.ACTION_SEARCH_ENGINE_SELECTOR_CLICKED import org.mozilla.fenix.telemetry.SOURCE_ADDRESS_BAR import org.mozilla.fenix.utils.Settings +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext import mozilla.components.browser.toolbar.R as toolbarR import mozilla.components.compose.browser.toolbar.concept.Action.SearchSelectorAction.ContentDescription.StringContentDescription as SearchSelectorDescription import mozilla.components.compose.browser.toolbar.concept.Action.SearchSelectorAction.Icon.DrawableIcon as SearchSelectorIcon @@ -133,12 +144,14 @@ internal sealed class EditPageEndActionsInteractions : BrowserToolbarEvent { * @param browserStore [BrowserStore] used for querying and updating browser state. * @param components [Components] for accessing other functionalities of the application. * @param settings [Settings] for accessing application settings. + * @param autocompleteDispatcher [CoroutineContext] used for querying autocomplete suggestions. */ class BrowserToolbarSearchMiddleware( private val appStore: AppStore, private val browserStore: BrowserStore, private val components: Components, private val settings: Settings, + private val autocompleteDispatcher: CoroutineContext = defaultAutocompleteDispatcher, ) : Middleware<BrowserToolbarState, BrowserToolbarAction> { @VisibleForTesting internal var environment: BrowserToolbarEnvironment? = null @@ -146,6 +159,7 @@ class BrowserToolbarSearchMiddleware( private var syncAvailableSearchEnginesJob: Job? = null private var observeQRScannerInputJob: Job? = null private var observeVoiceInputJob: Job? = null + private var updateAutocompleteJob: Job? = null @Suppress("CyclomaticComplexMethod", "LongMethod") override fun invoke( @@ -168,7 +182,6 @@ class BrowserToolbarSearchMiddleware( is EnvironmentCleared -> { environment = null - context.dispatch(AutocompleteProvidersUpdated(emptyList())) } is EnterEditMode -> { @@ -214,7 +227,7 @@ class BrowserToolbarSearchMiddleware( } is SearchSettingsItemClicked -> { - context.dispatch(SearchQueryUpdated("")) + context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) appStore.dispatch(SearchEnded) browserStore.dispatch(EngagementFinished(abandoned = true)) environment?.navController?.navigate( @@ -229,10 +242,6 @@ class BrowserToolbarSearchMiddleware( updateSearchEndPageActions(context) // to update the visibility of the qr scanner button } - is UrlSuggestionAutocompleted -> { - components.core.engine.speculativeConnect(action.url) - } - is CommitUrl -> { // Do not load URL if application search engine is selected. if (reconcileSelectedEngine()?.type == SearchEngine.Type.APPLICATION) { @@ -280,10 +289,11 @@ class BrowserToolbarSearchMiddleware( Toolbar.buttonTapped.record( Toolbar.ButtonTappedExtra(source = SOURCE_ADDRESS_BAR, item = ACTION_CLEAR_CLICKED), ) - context.dispatch(SearchQueryUpdated("")) + context.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) } is SearchQueryUpdated -> { + updateAutocompletions(context, action.query) updateSearchEndPageActions(context) } @@ -353,7 +363,7 @@ class BrowserToolbarSearchMiddleware( searchEngine: SearchEngine?, ) { updateSearchSelectorMenu(context, searchEngine, browserStore.state.search.searchEngineShortcuts) - updateAutocompleteProviders(context, searchEngine) + updateAutocompletions(context, context.state.editState.query) updateToolbarHint(context, searchEngine) } @@ -389,18 +399,6 @@ class BrowserToolbarSearchMiddleware( ) } - private fun updateAutocompleteProviders( - context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>, - selectedSearchEngine: SearchEngine?, - ) { - if (settings.shouldAutocompleteInAwesomebar) { - val autocompleteProviders = buildAutocompleteProvidersList(selectedSearchEngine) - context.dispatch(AutocompleteProvidersUpdated(autocompleteProviders)) - } else { - context.dispatch(AutocompleteProvidersUpdated(emptyList())) - } - } - private fun buildAutocompleteProvidersList(selectedSearchEngine: SearchEngine?) = when (selectedSearchEngine?.id) { browserStore.state.search.selectedOrDefaultSearchEngine?.id -> listOfNotNull( when (settings.shouldShowHistorySuggestions) { @@ -430,6 +428,46 @@ class BrowserToolbarSearchMiddleware( else -> emptyList() } + private fun updateAutocompletions( + context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>, + query: BrowserToolbarQuery, + ) { + updateAutocompleteJob?.cancelChildren() + + // Update suggestions only if feature is not disabled and user is not backspacing. + val shouldCheckForSuggestions = settings.shouldAutocompleteInAwesomebar && query.current.isNotEmpty() + val isBackspacing = query.previous?.startsWith(query.current) == true && + query.previous?.length == query.current.length + 1 + if (shouldCheckForSuggestions && !isBackspacing) { + updateAutocompleteJob = environment?.fragment?.viewLifecycleOwner?.lifecycleScope?.launch { + context.dispatch( + BrowserEditToolbarAction.AutocompleteSuggestionUpdated( + withContext(autocompleteDispatcher) { + fetchAutocomplete( + buildAutocompleteProvidersList(reconcileSelectedEngine()), + context.state.editState.query.current, + )?.also { + components.core.engine.speculativeConnect(it.url) + } + }, + ), + ) + } + } else { + context.dispatch(BrowserEditToolbarAction.AutocompleteSuggestionUpdated(null)) + } + } + + @VisibleForTesting + internal suspend fun fetchAutocomplete( + autocompleteProviders: List<AutocompleteProvider>, + input: String, + ): AutocompleteResult? { + if (autocompleteProviders.isEmpty()) return null + + return autocompleteProviders.firstNotNullOfOrNull { it.getAutocompleteSuggestion(input) } + } + private fun syncCurrentSearchEngine(context: MiddlewareContext<BrowserToolbarState, BrowserToolbarAction>) { syncCurrentSearchEngineJob?.cancel() syncCurrentSearchEngineJob = appStore.observeWhileActive { @@ -465,7 +503,7 @@ class BrowserToolbarSearchMiddleware( ) = context.dispatch( SearchActionsEndUpdated( buildSearchEndPageActions( - context.state.editState.query, + context.state.editState.query.current, selectedSearchEngine, ), ), @@ -517,7 +555,11 @@ class BrowserToolbarSearchMiddleware( observeQRScannerInputJob?.cancel() appStore.dispatch(AppAction.QrScannerAction.QrScannerInputConsumed) - context.dispatch(SearchQueryUpdated(it.qrScannerState.lastScanData)) + context.dispatch( + SearchQueryUpdated( + BrowserToolbarQuery(it.qrScannerState.lastScanData), + ), + ) components.useCases.fenixBrowserUseCases.loadUrlOrSearch( searchTermOrURL = it.qrScannerState.lastScanData, newTab = appStore.state.searchState.sourceTabId == null, @@ -541,7 +583,7 @@ class BrowserToolbarSearchMiddleware( if (!voiceInputResult.isNullOrEmpty()) { context.dispatch( SearchQueryUpdated( - query = voiceInputResult, + query = BrowserToolbarQuery(voiceInputResult), isQueryPrefilled = true, ), ) @@ -647,5 +689,17 @@ class BrowserToolbarSearchMiddleware( onClick = SearchSelectorItemClicked(searchEngine), ) } + + private const val AUTOCOMPLETE_QUERY_THREADS = 3 + private const val AUTOCOMPLETE_THREADS_FACTORY_NAME = "BrowserToolbarSearchMiddleware" + private val defaultAutocompleteDispatcher by lazy { + SupervisorJob() + Executors.newFixedThreadPool( + AUTOCOMPLETE_QUERY_THREADS, + NamedThreadFactory(AUTOCOMPLETE_THREADS_FACTORY_NAME), + ).asCoroutineDispatcher() + CoroutineExceptionHandler { _, throwable -> + Logger(AUTOCOMPLETE_THREADS_FACTORY_NAME) + .error("Error while processing autocomplete input", throwable) + } + } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddleware.kt @@ -77,7 +77,7 @@ class BrowserToolbarToFenixSearchMapperMiddleware( isUserSelected = true, inPrivateMode = environment?.browsingModeManager?.mode?.isPrivate == true, searchStartedForCurrentUrl = editState.isQueryPrefilled && - browserStore?.state?.selectedTab?.content?.url == editState.query, + browserStore?.state?.selectedTab?.content?.url == editState.query.current, ), ) @@ -101,7 +101,7 @@ class BrowserToolbarToFenixSearchMapperMiddleware( SearchFragmentAction.UpdateQuery( when (isSearchStartedForCurrentUrl && isQueryPrefilled) { true -> "" // consider a prefilled query for the current URL as not entered by user - false -> query + false -> query.current }, ), ) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/FenixSearchMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/FenixSearchMiddleware.kt @@ -19,6 +19,7 @@ import mozilla.components.browser.state.store.BrowserStore import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.PrivateModeUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.EngineSession.LoadUrlFlags @@ -164,13 +165,13 @@ class FenixSearchMiddleware( } } browserStore.dispatch(AwesomeBarAction.SuggestionClicked(suggestion)) - toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated("")) + toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(BrowserToolbarQuery(""))) suggestion.onSuggestionClicked?.invoke() } is SuggestionSelected -> { action.suggestion.editSuggestion?.let { - toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(it)) + toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(BrowserToolbarQuery(it))) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComposable.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/search/awesomebar/AwesomeBarComposable.kt @@ -36,6 +36,7 @@ import mozilla.components.compose.browser.awesomebar.AwesomeBarDefaults import mozilla.components.compose.browser.awesomebar.AwesomeBarOrientation import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.ext.observeAsComposableState import mozilla.components.support.ktx.android.view.hideKeyboard import org.mozilla.fenix.HomeActivity @@ -143,7 +144,7 @@ class AwesomeBarComposable( onClick = { url?.let { toolbarStore.dispatch( - SearchQueryUpdated(query = url, isQueryPrefilled = false), + SearchQueryUpdated(query = BrowserToolbarQuery(url), isQueryPrefilled = false), ) } }, @@ -245,7 +246,7 @@ class AwesomeBarComposable( onClick = { url?.let { toolbarStore.dispatch( - SearchQueryUpdated(query = url, isQueryPrefilled = false), + SearchQueryUpdated(query = BrowserToolbarQuery(url), isQueryPrefilled = false), ) } }, diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/components/toolbar/BrowserToolbarMiddlewareTest.kt @@ -58,7 +58,7 @@ import mozilla.components.compose.browser.toolbar.concept.PageOrigin.Companion.C import mozilla.components.compose.browser.toolbar.concept.PageOrigin.Companion.PageOriginContextualMenuInteractions.CopyToClipboardClicked import mozilla.components.compose.browser.toolbar.concept.PageOrigin.Companion.PageOriginContextualMenuInteractions.LoadFromClipboardClicked import mozilla.components.compose.browser.toolbar.concept.PageOrigin.Companion.PageOriginContextualMenuInteractions.PasteFromClipboardClicked -import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction +import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent.Source @@ -73,6 +73,7 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated import mozilla.components.compose.browser.toolbar.store.ProgressBarConfig +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.engine.EngineSession.LoadUrlFlags import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage import mozilla.components.concept.engine.permission.SitePermissionsStorage @@ -713,7 +714,7 @@ class BrowserToolbarMiddlewareTest { verify(exactly = 0) { navController.navigate(any<NavDirections>()) } verify { appStore.dispatch(SearchStarted(currentTab.id)) } - assertEquals(currentTab.content.searchTerms, toolbarStore.state.editState.query) + assertEquals(currentTab.content.searchTerms, toolbarStore.state.editState.query.current) } @Test @@ -843,7 +844,7 @@ class BrowserToolbarMiddlewareTest { toolbarStore.dispatch(PasteFromClipboardClicked) verify { - toolbarStore.dispatch(BrowserEditToolbarAction.SearchQueryUpdated(queryText)) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery(queryText))) appStore.dispatch(SearchStarted(currentTab.id)) } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/home/toolbar/BrowserToolbarMiddlewareTest.kt @@ -553,7 +553,7 @@ class BrowserToolbarMiddlewareTest { toolbarStore.dispatch((tabCounterMenuItems[0] as BrowserToolbarMenuButton).onClick!!) assertEquals(Private, browsingModeManager.mode) - assertEquals("", toolbarStore.state.editState.query) + assertEquals("", toolbarStore.state.editState.query.current) verify { appStore.dispatch(SearchStarted()) } } @@ -574,7 +574,7 @@ class BrowserToolbarMiddlewareTest { toolbarStore.dispatch((tabCounterMenuItems[0] as BrowserToolbarMenuButton).onClick!!) assertEquals(Normal, browsingModeManager.mode) - assertEquals("", toolbarStore.state.editState.query) + assertEquals("", toolbarStore.state.editState.query.current) verify { appStore.dispatch(SearchStarted()) } } @@ -625,7 +625,7 @@ class BrowserToolbarMiddlewareTest { toolbarStore.dispatch(PasteFromClipboardClicked) - assertEquals(clipboard.text, toolbarStore.state.editState.query) + assertEquals(clipboard.text, toolbarStore.state.editState.query.current) verify { appStore.dispatch(SearchStarted()) } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarSearchMiddlewareTest.kt @@ -11,12 +11,17 @@ import androidx.lifecycle.LifecycleOwner import androidx.navigation.NavController import androidx.navigation.NavDirections import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.slot import io.mockk.spyk import io.mockk.verify import io.mockk.verifyOrder +import kotlinx.coroutines.Dispatchers +import mozilla.components.browser.domains.autocomplete.BaseDomainAutocompleteProvider import mozilla.components.browser.state.action.AwesomeBarAction.EngagementFinished import mozilla.components.browser.state.action.SearchAction.ApplicationSearchEnginesLoaded import mozilla.components.browser.state.search.RegionState @@ -24,6 +29,8 @@ import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.state.SearchState import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.storage.sync.PlacesBookmarksStorage +import mozilla.components.browser.storage.sync.PlacesHistoryStorage import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton import mozilla.components.compose.browser.toolbar.concept.Action.ActionButtonRes import mozilla.components.compose.browser.toolbar.concept.Action.SearchSelectorAction @@ -36,8 +43,13 @@ import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteractio import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore import mozilla.components.compose.browser.toolbar.store.EnvironmentCleared import mozilla.components.compose.browser.toolbar.store.EnvironmentRehydrated +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery +import mozilla.components.concept.engine.Engine import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.toolbar.AutocompleteProvider +import mozilla.components.concept.toolbar.AutocompleteResult +import mozilla.components.feature.awesomebar.provider.SessionAutocompleteProvider +import mozilla.components.feature.syncedtabs.SyncedTabsAutocompleteProvider import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.middleware.CaptureActionsMiddleware import mozilla.components.support.test.mock @@ -137,7 +149,6 @@ class BrowserToolbarSearchMiddlewareTest { store.dispatch(EnvironmentCleared) assertNull(middleware.environment) - assertEquals(emptyList<AutocompleteProvider>(), store.state.editState.autocompleteProviders) } @Test @@ -157,7 +168,7 @@ class BrowserToolbarSearchMiddlewareTest { val (_, store) = buildMiddlewareAndAddToStore() store.dispatch(EnterEditMode) - store.dispatch(SearchQueryUpdated("test")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) assertEquals( expectedClearButton, @@ -170,7 +181,7 @@ class BrowserToolbarSearchMiddlewareTest { val (_, store) = buildMiddlewareAndAddToStore() store.dispatch(EnterEditMode) - store.dispatch(SearchQueryUpdated("")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) assertEquals( expectedQrButton, @@ -183,14 +194,14 @@ class BrowserToolbarSearchMiddlewareTest { val (_, store) = buildMiddlewareAndAddToStore() store.dispatch(EnterEditMode) - store.dispatch(SearchQueryUpdated("")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) assertEquals( expectedQrButton, store.state.editState.editActionsEnd.last() as ActionButtonRes, ) - store.dispatch(SearchQueryUpdated("a")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("a"))) assertEquals( expectedClearButton, @@ -201,14 +212,14 @@ class BrowserToolbarSearchMiddlewareTest { @Test fun `WHEN the toolbar enters in edit mode with non-blank query AND the clear button is clicked THEN text is cleared and telemetry is recorded`() { val (_, store) = buildMiddlewareAndAddToStore() - store.dispatch(SearchQueryUpdated("test")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) store.dispatch(EnterEditMode) val clearButton = store.state.editState.editActionsEnd.last() as ActionButtonRes assertEquals(expectedClearButton, clearButton) store.dispatch(clearButton.onClick as BrowserToolbarEvent) - assertEquals(store.state.editState.query, "") + assertEquals(store.state.editState.query.current, "") assertTelemetryRecorded(ACTION_CLEAR_CLICKED) } @@ -264,15 +275,15 @@ class BrowserToolbarSearchMiddlewareTest { val (_, store) = buildMiddlewareAndAddToStore(appStore = appStore) appStore.dispatch(SearchStarted()) store.dispatch(EnterEditMode) - store.dispatch(SearchQueryUpdated("test")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) assertTrue(store.state.isEditMode()) assertTrue(appStore.state.searchState.isSearchActive) - assertEquals("test", store.state.editState.query) + assertEquals("test", store.state.editState.query.current) store.dispatch(SearchSettingsItemClicked) assertFalse(appStore.state.searchState.isSearchActive) - assertEquals("", store.state.editState.query) + assertEquals("", store.state.editState.query.current) captorMiddleware.assertLastAction(SearchEnded::class) {} verify { browserStore.dispatch(EngagementFinished(abandoned = true)) } verify { @@ -313,87 +324,186 @@ class BrowserToolbarSearchMiddlewareTest { } @Test - fun `GIVEN default engine selected WHEN entering in edit mode THEN set autocomplete providers and page end buttons`() { + fun `GIVEN default engine selected WHEN entering in edit mode THEN set autocomplete suggestions and page end buttons`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns true every { settings.shouldShowBookmarkSuggestions } returns true every { settings.shouldShowVoiceSearch } returns true - val middleware = spyk(buildMiddleware(appStore = appStore)) - every { middleware.isSpeechRecognitionAvailable() } returns true + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) + every { middleware.isSpeechRecognitionAvailable() } returns true val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch(EnterEditMode) + shadowOf(Looper.getMainLooper()).idle() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + assertEquals(2, store.state.editState.editActionsEnd.size) + assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + assertEquals(expectedQrButton, store.state.editState.editActionsEnd.last()) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf( + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull( components.core.historyStorage, components.core.bookmarksStorage, components.core.domainsAutocompleteProvider, - ), - store.state.editState.autocompleteProviders, + ).map { it.javaClass::getSimpleName }, ) + assertEquals(store.state.editState.suggestion?.text, "history") + verify { engine.speculativeConnect("history.com") } assertEquals(2, store.state.editState.editActionsEnd.size) assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) - assertEquals(expectedQrButton, store.state.editState.editActionsEnd.last()) + assertEquals(expectedClearButton, store.state.editState.editActionsEnd.last()) } @Test - fun `GIVEN default engine selected and history suggestions disabled WHEN entering in edit mode THEN set autocomplete providers`() { + fun `GIVEN default engine selected and history suggestions disabled WHEN entering in edit mode THEN set autocomplete suggestions`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns false every { settings.shouldShowBookmarkSuggestions } returns true + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val (_, store) = buildMiddlewareAndAddToStore() + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) + val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch(EnterEditMode) - + shadowOf(Looper.getMainLooper()).idle() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf( + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull( components.core.bookmarksStorage, components.core.domainsAutocompleteProvider, - ), - store.state.editState.autocompleteProviders, + ).map { it.javaClass::getSimpleName }, ) + assertEquals(store.state.editState.suggestion?.text, "bookmarks") + verify { engine.speculativeConnect("bookmarks.com") } } @Test - fun `GIVEN default engine selected and bookmarks suggestions disabled WHEN entering in edit mode THEN set autocomplete providers`() { + fun `GIVEN default engine selected and bookmarks suggestions disabled WHEN entering in edit mode THEN set autocomplete suggestions`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns true every { settings.shouldShowBookmarkSuggestions } returns false + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val (_, store) = buildMiddlewareAndAddToStore() + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) + val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch(EnterEditMode) + shadowOf(Looper.getMainLooper()).idle() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf( + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull( components.core.historyStorage, components.core.domainsAutocompleteProvider, - ), - store.state.editState.autocompleteProviders, + ).map { it.javaClass::getSimpleName }, ) + assertEquals(store.state.editState.suggestion?.text, "history") + verify { engine.speculativeConnect("history.com") } } @Test - fun `GIVEN default engine selected and history + bookmarks suggestions disabled WHEN entering in edit mode THEN set autocomplete providers`() { + fun `GIVEN default engine selected and history + bookmarks suggestions disabled WHEN entering in edit mode THEN set autocomplete suggestions`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns false every { settings.shouldShowBookmarkSuggestions } returns false + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() - val (_, store) = buildMiddlewareAndAddToStore() + val middleware = spyk(buildMiddleware(appStore, browserStore, components, settings)) + val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch(EnterEditMode) - + shadowOf(Looper.getMainLooper()).idle() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf(components.core.domainsAutocompleteProvider), - store.state.editState.autocompleteProviders, + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull(components.core.domainsAutocompleteProvider).map { it.javaClass::getSimpleName }, ) + assertEquals(store.state.editState.suggestion?.text, "domains") + verify { engine.speculativeConnect("domains.com") } } @Test - fun `GIVEN tabs engine selected WHEN entering in edit mode THEN set autocomplete providers and page end buttons`() { + fun `GIVEN tabs engine selected WHEN entering in edit mode THEN set autocomplete suggestions and page end buttons`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns true every { settings.shouldShowBookmarkSuggestions } returns true @@ -401,28 +511,54 @@ class BrowserToolbarSearchMiddlewareTest { val appStore = AppStore() val middleware = spyk(buildMiddleware(appStore = appStore)) every { middleware.isSpeechRecognitionAvailable() } returns true + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch( SearchSelectorItemClicked( fakeSearchState().applicationSearchEngines.first { it.id == TABS_SEARCH_ENGINE_ID }, ), ).joinBlocking() + shadowOf(Looper.getMainLooper()).idle() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + assertEquals(1, store.state.editState.editActionsEnd.size) + assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf( + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull( components.core.sessionAutocompleteProvider, components.backgroundServices.syncedTabsAutocompleteProvider, - ), - store.state.editState.autocompleteProviders, + ).map { it.javaClass::getSimpleName }, ) - assertEquals(1, store.state.editState.editActionsEnd.size) + assertEquals(store.state.editState.suggestion?.text, "session") + verify { engine.speculativeConnect("session.com") } + assertEquals(2, store.state.editState.editActionsEnd.size) assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + assertEquals(expectedClearButton, store.state.editState.editActionsEnd.last()) } @Test - fun `GIVEN bookmarks engine selected WHEN entering in edit mode THEN set autocomplete providers`() { + fun `GIVEN bookmarks engine selected WHEN entering in edit mode THEN set autocomplete suggestions`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns true every { settings.shouldShowBookmarkSuggestions } returns true @@ -430,25 +566,51 @@ class BrowserToolbarSearchMiddlewareTest { val appStore = AppStore() val middleware = spyk(buildMiddleware(appStore = appStore)) every { middleware.isSpeechRecognitionAvailable() } returns true + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch( SearchSelectorItemClicked( fakeSearchState().applicationSearchEngines.first { it.id == BOOKMARKS_SEARCH_ENGINE_ID }, ), ).joinBlocking() + shadowOf(Looper.getMainLooper()).idle() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + assertEquals(1, store.state.editState.editActionsEnd.size) + assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf(components.core.bookmarksStorage), - store.state.editState.autocompleteProviders, + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull(components.core.bookmarksStorage).map { it.javaClass::getSimpleName }, ) - assertEquals(1, store.state.editState.editActionsEnd.size) + assertEquals(store.state.editState.suggestion?.text, "bookmarks") + verify { engine.speculativeConnect("bookmarks.com") } + assertEquals(2, store.state.editState.editActionsEnd.size) assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + assertEquals(expectedClearButton, store.state.editState.editActionsEnd.last()) } @Test - fun `GIVEN history engine selected WHEN entering in edit mode THEN set autocomplete providers`() { + fun `GIVEN history engine selected WHEN entering in edit mode THEN set autocomplete suggestions`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowHistorySuggestions } returns true every { settings.shouldShowBookmarkSuggestions } returns true @@ -456,8 +618,13 @@ class BrowserToolbarSearchMiddlewareTest { val appStore = AppStore() val middleware = spyk(buildMiddleware(appStore = appStore)) every { middleware.isSpeechRecognitionAvailable() } returns true + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() val store = buildStore(middleware) + val autocompleteProvidersSlot = slot<List<AutocompleteProvider>>() store.dispatch( SearchSelectorItemClicked( @@ -465,30 +632,60 @@ class BrowserToolbarSearchMiddlewareTest { ), ).joinBlocking() + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + assertEquals(1, store.state.editState.editActionsEnd.size) + assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) + shadowOf(Looper.getMainLooper()).idle() + coVerify { + middleware.fetchAutocomplete( + autocompleteProviders = capture(autocompleteProvidersSlot), + input = "test", + ) + } assertEquals( - listOf(components.core.historyStorage), - store.state.editState.autocompleteProviders, + autocompleteProvidersSlot.captured.map { it.javaClass::getSimpleName }, + listOfNotNull(components.core.historyStorage).map { it.javaClass::getSimpleName }, ) - assertEquals(1, store.state.editState.editActionsEnd.size) + assertEquals(store.state.editState.suggestion?.text, "history") + verify { engine.speculativeConnect("history.com") } + assertEquals(2, store.state.editState.editActionsEnd.size) assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) + assertEquals(expectedClearButton, store.state.editState.editActionsEnd.last()) } @Test - fun `GIVEN other search engine selected WHEN entering in edit mode THEN set autocomplete providers`() { + fun `GIVEN other search engine selected WHEN entering in edit mode THEN set autocomplete suggestions`() { every { settings.shouldAutocompleteInAwesomebar } returns true every { settings.shouldShowVoiceSearch } returns true val middleware = spyk(buildMiddleware(appStore = appStore)) every { middleware.isSpeechRecognitionAvailable() } returns true + val engine: Engine = mockk { + every { speculativeConnect(any()) } just Runs + } + every { components.core.engine } returns engine configureAutocompleteProvidersInComponents() val store = buildStore(middleware) store.dispatch(SearchSelectorItemClicked(mockk(relaxed = true))).joinBlocking() store.dispatch(EnterEditMode) + shadowOf(Looper.getMainLooper()).idle() - assertEquals( - emptyList<AutocompleteProvider>(), - store.state.editState.autocompleteProviders, - ) + coVerify(exactly = 0) { + middleware.fetchAutocomplete( + autocompleteProviders = any(), + input = "", + ) + } + assertNull(store.state.editState.suggestion) + verify(exactly = 0) { engine.speculativeConnect(any()) } assertEquals(1, store.state.editState.editActionsEnd.size) assertEquals(expectedVoiceSearchButton, store.state.editState.editActionsEnd.first()) } @@ -811,7 +1008,7 @@ class BrowserToolbarSearchMiddlewareTest { every { middleware.isSpeechRecognitionAvailable() } returns false val store = buildStore(middleware) store.dispatch(EnterEditMode) - store.dispatch(SearchQueryUpdated("")) + store.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) store.dispatch(EnterEditMode) val actions = store.state.editState.editActionsEnd @@ -840,7 +1037,7 @@ class BrowserToolbarSearchMiddlewareTest { appStore.dispatch(QrScannerInputAvailable("mozilla.test")).joinBlocking() shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing qr scan result - assertEquals("mozilla.test", store.state.editState.query) + assertEquals("mozilla.test", store.state.editState.query.current) appStoreActionsCaptor.assertLastAction(QrScannerInputConsumed::class) verify { browserUseCases.loadUrlOrSearch( @@ -874,7 +1071,7 @@ class BrowserToolbarSearchMiddlewareTest { appStore.dispatch(QrScannerInputAvailable("test.mozilla")).joinBlocking() shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing qr scan result - assertEquals("test.mozilla", store.state.editState.query) + assertEquals("test.mozilla", store.state.editState.query.current) appStoreActionsCaptor.assertLastAction(QrScannerInputConsumed::class) verify { browserUseCases.loadUrlOrSearch( @@ -913,7 +1110,7 @@ class BrowserToolbarSearchMiddlewareTest { appStore.dispatch(QrScannerInputAvailable("test.com")).joinBlocking() shadowOf(Looper.getMainLooper()).idle() // wait for observing and processing qr scan result - assertEquals("test.com", store.state.editState.query) + assertEquals("test.com", store.state.editState.query.current) appStoreActionsCaptor.assertLastAction(QrScannerInputConsumed::class) verify { browserUseCases.loadUrlOrSearch( @@ -1007,14 +1204,52 @@ class BrowserToolbarSearchMiddlewareTest { browserStore: BrowserStore = this.browserStore, components: Components = this.components, settings: Settings = this.settings, - ) = BrowserToolbarSearchMiddleware(appStore, browserStore, components, settings) + ) = BrowserToolbarSearchMiddleware(appStore, browserStore, components, settings, Dispatchers.Main) private fun configureAutocompleteProvidersInComponents() { - every { components.core.historyStorage } returns mockk() - every { components.core.bookmarksStorage } returns mockk() - every { components.core.domainsAutocompleteProvider } returns mockk() - every { components.core.sessionAutocompleteProvider } returns mockk() - every { components.backgroundServices.syncedTabsAutocompleteProvider } returns mockk() + val autocompleteSuggestion = AutocompleteResult( + text = "", + url = "", + input = "", + source = "t", + totalItems = 1, + ) + val historyStorage: PlacesHistoryStorage = mockk { + coEvery { getAutocompleteSuggestion(any()) } returns autocompleteSuggestion.copy( + text = "history", + url = "history.com", + ) + } + val bookmarksStorage: PlacesBookmarksStorage = mockk { + coEvery { getAutocompleteSuggestion(any()) } returns autocompleteSuggestion.copy( + text = "bookmarks", + url = "bookmarks.com", + ) + } + val domainsProvider: BaseDomainAutocompleteProvider = mockk { + coEvery { getAutocompleteSuggestion(any()) } returns autocompleteSuggestion.copy( + text = "domains", + url = "domains.com", + ) + } + val sessionsProvider: SessionAutocompleteProvider = mockk { + coEvery { getAutocompleteSuggestion(any()) } returns autocompleteSuggestion.copy( + text = "session", + url = "session.com", + ) + } + val syncedTabsProvider: SyncedTabsAutocompleteProvider = mockk { + coEvery { getAutocompleteSuggestion(any()) } returns autocompleteSuggestion.copy( + text = "synced tabs", + url = "synced-tabs.com", + ) + } + + every { components.core.historyStorage } returns historyStorage + every { components.core.bookmarksStorage } returns bookmarksStorage + every { components.core.domainsAutocompleteProvider } returns domainsProvider + every { components.core.sessionAutocompleteProvider } returns sessionsProvider + every { components.backgroundServices.syncedTabsAutocompleteProvider } returns syncedTabsProvider } private fun fakeSearchState() = SearchState( diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/BrowserToolbarToFenixSearchMapperMiddlewareTest.kt @@ -10,6 +10,7 @@ import io.mockk.mockk import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarAction.EnterEditMode import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.lib.state.Middleware import mozilla.components.support.test.middleware.CaptureActionsMiddleware import mozilla.components.support.test.robolectric.testContext @@ -68,16 +69,16 @@ class BrowserToolbarToFenixSearchMapperMiddlewareTest { searchStore.dispatch(SearchStarted(mockk(), false, false, searchStartedForCurrentUrl = false)) - toolbarStore.dispatch(SearchQueryUpdated("t")) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("t"))) assertEquals("t", searchStore.state.query) - toolbarStore.dispatch(SearchQueryUpdated("te")) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("te"))) assertEquals("te", searchStore.state.query) - toolbarStore.dispatch(SearchQueryUpdated("tes")) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("tes"))) assertEquals("tes", searchStore.state.query) - toolbarStore.dispatch(SearchQueryUpdated("test")) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) assertEquals("test", searchStore.state.query) } @@ -87,13 +88,15 @@ class BrowserToolbarToFenixSearchMapperMiddlewareTest { toolbarStore.dispatch(EnterEditMode) searchStore.dispatch(SearchStarted(mockk(), false, false, searchStartedForCurrentUrl = true)) - toolbarStore.dispatch(SearchQueryUpdated("https://mozilla.org", isQueryPrefilled = true)) + toolbarStore.dispatch( + SearchQueryUpdated(BrowserToolbarQuery("https://mozilla.org"), isQueryPrefilled = true), + ) assertEquals("", searchStore.state.query) - toolbarStore.dispatch(SearchQueryUpdated("t")) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("t"))) assertEquals("t", searchStore.state.query) - toolbarStore.dispatch(SearchQueryUpdated("https://mozilla.org")) + toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("https://mozilla.org"))) assertEquals("https://mozilla.org", searchStore.state.query) } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/FenixSearchMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/search/FenixSearchMiddlewareTest.kt @@ -26,6 +26,7 @@ import mozilla.components.browser.state.store.BrowserStore import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.PrivateModeUpdated import mozilla.components.compose.browser.toolbar.store.BrowserEditToolbarAction.SearchQueryUpdated import mozilla.components.compose.browser.toolbar.store.BrowserToolbarStore +import mozilla.components.compose.browser.toolbar.ui.BrowserToolbarQuery import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion import mozilla.components.concept.awesomebar.AwesomeBar.SuggestionProvider import mozilla.components.concept.engine.Engine @@ -453,7 +454,7 @@ class FenixSearchMiddlewareTest { store.dispatch(SuggestionClicked(clickedSuggestion)) assertTrue(wasSuggestionClickHandled) - verify { toolbarStore.dispatch(SearchQueryUpdated("")) } + verify { toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery(""))) } browserActionsCaptor.assertLastAction(AwesomeBarAction.SuggestionClicked::class) { assertEquals(clickedSuggestion, it.suggestion) } @@ -490,7 +491,7 @@ class FenixSearchMiddlewareTest { store.dispatch(SuggestionSelected(selectedSuggestion)) - verify { toolbarStore.dispatch(SearchQueryUpdated("test")) } + verify { toolbarStore.dispatch(SearchQueryUpdated(BrowserToolbarQuery("test"))) } } @Test diff --git a/mobile/android/fenix/benchmark/src/main/java/org/mozilla/fenix/benchmark/utils/UiDevice.kt b/mobile/android/fenix/benchmark/src/main/java/org/mozilla/fenix/benchmark/utils/UiDevice.kt @@ -198,7 +198,7 @@ private fun getUrlBarId(useNewToolbar: Boolean) = when (useNewToolbar) { } private fun getUrlBarEditField(useNewToolbar: Boolean) = when (useNewToolbar) { - true -> "$TARGET_PACKAGE:id/mozac_addressbar_search_query_input" + true -> "ADDRESSBAR_SEARCH_BOX" false -> "$TARGET_PACKAGE:id/mozac_browser_toolbar_edit_url_view" }