summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/toolbar
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/feature/toolbar
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/toolbar')
-rw-r--r--mobile/android/android-components/components/feature/toolbar/README.md19
-rw-r--r--mobile/android/android-components/components/feature/toolbar/build.gradle57
-rw-r--r--mobile/android/android-components/components/feature/toolbar/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/AndroidManifest.xml7
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarAction.kt89
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarFeature.kt77
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeature.kt109
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarBehaviorController.kt79
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarFeature.kt102
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarInteractor.kt36
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarPresenter.kt114
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarAction.kt98
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeature.kt170
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/internal/URLRenderer.kt126
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_container_action_layout.xml20
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_web_extension_action_layout.xml29
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/main/res/values/colors.xml15
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarActionTest.kt67
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarFeatureTest.kt97
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeatureTest.kt596
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarBehaviorControllerTest.kt200
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarFeatureTest.kt100
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarInteractorTest.kt164
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarPresenterTest.kt548
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeatureTest.kt432
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarTest.kt179
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/internal/URLRendererTest.kt106
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/feature/toolbar/src/test/resources/robolectric.properties1
29 files changed, 3660 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/toolbar/README.md b/mobile/android/android-components/components/feature/toolbar/README.md
new file mode 100644
index 0000000000..bc9834da10
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Feature > Toolbar
+
+A component that connects a (concept) toolbar implementation with the browser session module.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:feature-toolbar:{latest-version}"
+```
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/feature/toolbar/build.gradle b/mobile/android/android-components/components/feature/toolbar/build.gradle
new file mode 100644
index 0000000000..e950512518
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/build.gradle
@@ -0,0 +1,57 @@
+/* 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/. */
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.feature.toolbar'
+}
+
+tasks.withType(KotlinCompile).configureEach {
+ kotlinOptions.freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
+}
+
+dependencies {
+ api project(':concept-toolbar')
+ implementation project(':feature-session')
+ implementation project(':browser-state')
+ implementation project(':browser-domains')
+ implementation project(':concept-engine')
+ implementation project(':concept-storage')
+ implementation project(':lib-publicsuffixlist')
+ implementation project(':support-utils')
+ implementation project(':support-ktx')
+ implementation project(':ui-icons')
+
+ implementation ComponentsDependencies.androidx_core_ktx
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ testImplementation project(':support-test')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_coroutines
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/feature/toolbar/proguard-rules.pro b/mobile/android/android-components/components/feature/toolbar/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/toolbar/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..1eccdee26a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <application android:supportsRtl="true" />
+</manifest>
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarAction.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarAction.kt
new file mode 100644
index 0000000000..33d166a9c9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarAction.kt
@@ -0,0 +1,89 @@
+/* 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.feature.toolbar
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat.getColor
+import mozilla.components.browser.state.state.ContainerState
+import mozilla.components.browser.state.state.ContainerState.Color
+import mozilla.components.browser.state.state.ContainerState.Icon
+import mozilla.components.concept.toolbar.Toolbar.Action
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.ktx.android.view.setPadding
+import mozilla.components.support.utils.DrawableUtils.loadAndTintDrawable
+import mozilla.components.ui.icons.R as iconsR
+
+/**
+ * An action button that represents a container to be added to the toolbar.
+ *
+ * @param container Associated [ContainerState]'s icon and color to render in the toolbar.
+ * @param padding A optional custom padding.
+ * @param listener A optional callback that will be invoked whenever the button is pressed.
+ */
+class ContainerToolbarAction(
+ internal val container: ContainerState,
+ internal val padding: Padding? = null,
+ private var listener: (() -> Unit)? = null,
+) : Action {
+ override fun createView(parent: ViewGroup): View {
+ val rootView = LayoutInflater.from(parent.context)
+ .inflate(R.layout.mozac_feature_toolbar_container_action_layout, parent, false)
+
+ listener?.let { clickListener ->
+ rootView.setOnClickListener { clickListener.invoke() }
+ }
+
+ padding?.let { rootView.setPadding(it) }
+
+ return rootView
+ }
+
+ override fun bind(view: View) {
+ val imageView = view.findViewById<ImageView>(R.id.container_action_image)
+ imageView.contentDescription = container.name
+ imageView.setImageDrawable(getIcon(view.context, container))
+ }
+
+ @Suppress("ComplexMethod")
+ internal fun getIcon(context: Context, container: ContainerState): Drawable {
+ @ColorInt val tint = getTint(context, container.color)
+
+ return when (container.icon) {
+ Icon.FINGERPRINT -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_fingerprinter_24, tint)
+ Icon.BRIEFCASE -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_briefcase, tint)
+ Icon.DOLLAR -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_dollar, tint)
+ Icon.CART -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_cart, tint)
+ Icon.CIRCLE -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_circle, tint)
+ Icon.GIFT -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_gift, tint)
+ Icon.VACATION -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_vacation, tint)
+ Icon.FOOD -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_food, tint)
+ Icon.FRUIT -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_fruit, tint)
+ Icon.PET -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_pet, tint)
+ Icon.TREE -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_tree, tint)
+ Icon.CHILL -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_chill, tint)
+ Icon.FENCE -> loadAndTintDrawable(context, iconsR.drawable.mozac_ic_fence, tint)
+ }
+ }
+
+ private fun getTint(context: Context, color: Color): Int {
+ return when (color) {
+ Color.BLUE -> getColor(context, R.color.mozac_feature_toolbar_container_blue)
+ Color.TURQUOISE -> getColor(context, R.color.mozac_feature_toolbar_container_turquoise)
+ Color.GREEN -> getColor(context, R.color.mozac_feature_toolbar_container_green)
+ Color.YELLOW -> getColor(context, R.color.mozac_feature_toolbar_container_yellow)
+ Color.ORANGE -> getColor(context, R.color.mozac_feature_toolbar_container_orange)
+ Color.RED -> getColor(context, R.color.mozac_feature_toolbar_container_red)
+ Color.PINK -> getColor(context, R.color.mozac_feature_toolbar_container_pink)
+ Color.PURPLE -> getColor(context, R.color.mozac_feature_toolbar_container_purple)
+ Color.TOOLBAR -> getColor(context, R.color.mozac_feature_toolbar_container_toolbar)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarFeature.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarFeature.kt
new file mode 100644
index 0000000000..a83eac6e6b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ContainerToolbarFeature.kt
@@ -0,0 +1,77 @@
+/* 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.feature.toolbar
+
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * Container toolbar implementation that updates the toolbar with the container page action
+ * whenever the selected tab changes.
+ */
+class ContainerToolbarFeature(
+ private val toolbar: Toolbar,
+ private var store: BrowserStore,
+) : LifecycleAwareFeature {
+ private var containerPageAction: ContainerToolbarAction? = null
+ private var scope: CoroutineScope? = null
+
+ init {
+ renderContainerAction(store.state)
+ }
+
+ override fun start() {
+ scope = store.flowScoped { flow ->
+ flow.distinctUntilChangedBy { it.selectedTab }
+ .collect { state ->
+ renderContainerAction(state, state.selectedTab)
+ }
+ }
+ }
+
+ override fun stop() {
+ scope?.cancel()
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun renderContainerAction(state: BrowserState, tab: SessionState? = null) {
+ val containerState = state.containers[tab?.contextId]
+
+ if (containerState == null) {
+ // Entered a normal tab from a container tab. Remove the old container
+ // page action.
+ containerPageAction?.let {
+ toolbar.removePageAction(it)
+ toolbar.invalidateActions()
+ containerPageAction = null
+ }
+ return
+ } else if (containerState == containerPageAction?.container) {
+ // Do nothing since we're still in a tab with same container.
+ return
+ }
+
+ // Remove the old container page action and create a new action with the new
+ // container state.
+ containerPageAction?.let {
+ toolbar.removePageAction(it)
+ containerPageAction = null
+ }
+
+ containerPageAction = ContainerToolbarAction(containerState).also { action ->
+ toolbar.addPageAction(action)
+ toolbar.invalidateActions()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeature.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeature.kt
new file mode 100644
index 0000000000..f1082f67c2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeature.kt
@@ -0,0 +1,109 @@
+/* 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.feature.toolbar
+
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CoroutineScope
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.toolbar.AutocompleteProvider
+import mozilla.components.concept.toolbar.Toolbar
+import java.util.SortedSet
+
+/**
+ * Feature implementation for connecting a toolbar with a list of autocomplete providers.
+ *
+ * @param toolbar the [Toolbar] to connect to autocomplete providers.
+ * @param engine (optional) instance of a browser [Engine] to issue
+ * [Engine.speculativeConnect] calls on successful URL autocompletion.
+ * @param shouldAutocomplete (optional) lambda expression that returns true if
+ * autocomplete is shown. Otherwise, autocomplete is not shown.
+ * @param scope (optional) [CoroutineScope] in which to query autocompletion providers.
+ */
+class ToolbarAutocompleteFeature(
+ val toolbar: Toolbar,
+ val engine: Engine? = null,
+ val shouldAutocomplete: () -> Boolean = { true },
+) {
+ @VisibleForTesting
+ internal var autocompleteProviders: SortedSet<AutocompleteProvider> = sortedSetOf()
+
+ init {
+ toolbar.setAutocompleteListener { query, delegate ->
+ if (!shouldAutocomplete() || autocompleteProviders.isEmpty() || query.isBlank()) {
+ delegate.noAutocompleteResult(query)
+ } else {
+ val result = autocompleteProviders
+ .firstNotNullOfOrNull { it.getAutocompleteSuggestion(query) }
+
+ if (result != null) {
+ delegate.applyAutocompleteResult(result) {
+ engine?.speculativeConnect(result.url)
+ }
+ } else {
+ delegate.noAutocompleteResult(query)
+ }
+ }
+ }
+ }
+
+ /**
+ * Update the list of providers used for autocompletion results.
+ * Changes will take effect the next time user changes their input.
+ *
+ * @param providers New list of autocomplete providers.
+ * The list can be empty in which case autocompletion will be disabled until there is at least one provider.
+ * @param refreshAutocomplete Whether to immediately update the autocompletion suggestion
+ * based on the new providers.
+ */
+ @Synchronized
+ fun updateAutocompleteProviders(
+ providers: List<AutocompleteProvider>,
+ refreshAutocomplete: Boolean = true,
+ ) {
+ autocompleteProviders.clear()
+ autocompleteProviders.addAll(providers)
+ if (refreshAutocomplete) toolbar.refreshAutocomplete()
+ }
+
+ /**
+ * Adds the specified provider to the current list of providers.
+ *
+ * @param provider New [AutocompleteProvider] to add to the current list.
+ * If this exact instance already exists it will not be added again.
+ * @param refreshAutocomplete Whether to immediately update the autocompletion suggestion
+ * based on the new providers.
+ *
+ * @return `true` if the provider has been added, `false` if the provider already exists.
+ */
+ @Synchronized
+ fun addAutocompleteProvider(
+ provider: AutocompleteProvider,
+ refreshAutocomplete: Boolean = true,
+ ): Boolean {
+ return autocompleteProviders.add(provider).also {
+ if (refreshAutocomplete) toolbar.refreshAutocomplete()
+ }
+ }
+
+ /**
+ * Remove an autocomplete provider from the current providers list.
+ *
+ * @param provider [AutocompleteProvider] instance to remove from the current list.
+ * If it isn't set already calling this method will have no effect.
+ * @param refreshAutocomplete Whether to immediately update the autocompletion suggestion
+ * based on the new providers.
+ *
+ * @return `true` if the provider has been removed, `false` if the provider could not be found.
+ */
+ @Synchronized
+ fun removeAutocompleteProvider(
+ provider: AutocompleteProvider,
+ refreshAutocomplete: Boolean = true,
+ ): Boolean {
+ return autocompleteProviders.remove(provider).also {
+ if (refreshAutocomplete) toolbar.refreshAutocomplete()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarBehaviorController.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarBehaviorController.kt
new file mode 100644
index 0000000000..7928f1ac64
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarBehaviorController.kt
@@ -0,0 +1,79 @@
+/* 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.feature.toolbar
+
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.mapNotNull
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.ScrollableToolbar
+import mozilla.components.lib.state.ext.flowScoped
+
+/**
+ * Controls how a dynamic toolbar should behave based on the current tab state.
+ *
+ * Responsible to enforce the following:
+ * - toolbar should not be scrollable if the page has not finished loading
+ */
+class ToolbarBehaviorController(
+ private val toolbar: ScrollableToolbar,
+ private val store: BrowserStore,
+ private val customTabId: String? = null,
+) {
+ @VisibleForTesting
+ internal var updatesScope: CoroutineScope? = null
+
+ /**
+ * Starts listening for changes in the current tab and updates how the toolbar should behave.
+ */
+ fun start() {
+ updatesScope = store.flowScoped { flow ->
+ flow.mapNotNull { state ->
+ state.findCustomTabOrSelectedTab(customTabId)
+ }.distinctUntilChangedBy {
+ arrayOf(it.content.loading, it.content.showToolbarAsExpanded)
+ }.collect { state ->
+ if (state.content.showToolbarAsExpanded) {
+ expandToolbar()
+ store.dispatch(ContentAction.UpdateExpandedToolbarStateAction(state.id, false))
+ return@collect
+ }
+
+ if (state.content.loading) {
+ expandToolbar()
+ disableScrolling()
+ } else if (!state.content.loading) {
+ enableScrolling()
+ }
+ }
+ }
+ }
+
+ /**
+ * Stop listening for changes in the current tab.
+ */
+ fun stop() {
+ updatesScope?.cancel()
+ }
+
+ @VisibleForTesting
+ internal fun expandToolbar() {
+ toolbar.expand()
+ }
+
+ @VisibleForTesting
+ internal fun disableScrolling() {
+ toolbar.disableScrolling()
+ }
+
+ @VisibleForTesting
+ internal fun enableScrolling() {
+ toolbar.enableScrolling()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarFeature.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarFeature.kt
new file mode 100644
index 0000000000..e8ba395dd4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarFeature.kt
@@ -0,0 +1,102 @@
+/* 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.feature.toolbar
+
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.base.feature.UserInteractionHandler
+
+/**
+ * A function representing the search use case, accepting
+ * the search terms as string.
+ */
+typealias SearchUseCase = (String) -> Unit
+
+/**
+ * Feature implementation for connecting a toolbar implementation with the session module.
+ */
+class ToolbarFeature(
+ private val toolbar: Toolbar,
+ store: BrowserStore,
+ loadUrlUseCase: SessionUseCases.LoadUrlUseCase,
+ searchUseCase: SearchUseCase? = null,
+ customTabId: String? = null,
+ shouldDisplaySearchTerms: Boolean = false,
+ urlRenderConfiguration: UrlRenderConfiguration? = null,
+) : LifecycleAwareFeature, UserInteractionHandler {
+ @VisibleForTesting
+ internal var presenter = ToolbarPresenter(
+ toolbar,
+ store,
+ customTabId,
+ shouldDisplaySearchTerms,
+ urlRenderConfiguration,
+ )
+
+ @VisibleForTesting
+ internal var interactor = ToolbarInteractor(toolbar, loadUrlUseCase, searchUseCase)
+
+ @VisibleForTesting
+ internal var controller = ToolbarBehaviorController(toolbar, store, customTabId)
+
+ /**
+ * Start feature: App is in the foreground.
+ */
+ override fun start() {
+ interactor.start()
+ presenter.start()
+ controller.start()
+ }
+
+ /**
+ * Handler for back pressed events in activities that use this feature.
+ *
+ * @return true if the event was handled, otherwise false.
+ */
+ override fun onBackPressed(): Boolean = toolbar.onBackPressed()
+
+ /**
+ * Stop feature: App is in the background.
+ */
+ override fun stop() {
+ presenter.stop()
+ controller.stop()
+ toolbar.onStop()
+ }
+
+ /**
+ * Configuration that controls how URLs are rendered.
+ *
+ * @property publicSuffixList A shared/global [PublicSuffixList] object required to extract certain domain parts.
+ * @property registrableDomainColor Text color that should be used for the registrable domain of the URL (see
+ * [PublicSuffixList.getPublicSuffixPlusOne] for an explanation of "registrable domain".
+ * @property urlColor Optional text color used for the URL.
+ * @property renderStyle Sealed class that controls the style of the url to be displayed
+ */
+ data class UrlRenderConfiguration(
+ internal val publicSuffixList: PublicSuffixList,
+ @ColorInt internal val registrableDomainColor: Int,
+ @ColorInt internal val urlColor: Int? = null,
+ internal val renderStyle: RenderStyle = RenderStyle.ColoredUrl,
+ )
+
+ /**
+ * Controls how the url should be styled
+ *
+ * RegistrableDomain: displays only the url, uncolored
+ * ColoredUrl: displays the registrableDomain with color and url with another color
+ * UncoloredUrl: displays the full url, uncolored
+ */
+ sealed class RenderStyle {
+ object RegistrableDomain : RenderStyle()
+ object ColoredUrl : RenderStyle()
+ object UncoloredUrl : RenderStyle()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarInteractor.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarInteractor.kt
new file mode 100644
index 0000000000..0026af04d8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarInteractor.kt
@@ -0,0 +1,36 @@
+/* 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.feature.toolbar
+
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.support.ktx.kotlin.isUrl
+import mozilla.components.support.ktx.kotlin.toNormalizedUrl
+
+/**
+ * Connects a toolbar instance to the browser engine via use cases
+ */
+class ToolbarInteractor(
+ private val toolbar: Toolbar,
+ private val loadUrlUseCase: SessionUseCases.LoadUrlUseCase,
+ private val searchUseCase: SearchUseCase? = null,
+) {
+
+ /**
+ * Starts this interactor. Makes sure this interactor is listening
+ * to relevant UI changes and triggers the corresponding use-cases
+ * in response.
+ */
+ fun start() {
+ toolbar.setOnUrlCommitListener { text ->
+ when {
+ text.isUrl() -> loadUrlUseCase.invoke(text.toNormalizedUrl())
+ searchUseCase != null -> searchUseCase.invoke(text)
+ else -> loadUrlUseCase.invoke(text)
+ }
+ true
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarPresenter.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarPresenter.kt
new file mode 100644
index 0000000000..d8951a1417
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/ToolbarPresenter.kt
@@ -0,0 +1,114 @@
+/* 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.feature.toolbar
+
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.Companion.PRIVATE
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.concept.toolbar.Toolbar.Highlight
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection
+import mozilla.components.feature.toolbar.internal.URLRenderer
+import mozilla.components.lib.state.ext.flowScoped
+
+/**
+ * Presenter implementation for a toolbar implementation in order to update the toolbar whenever
+ * the state of the selected session.
+ */
+class ToolbarPresenter(
+ private val toolbar: Toolbar,
+ private val store: BrowserStore,
+ private val customTabId: String? = null,
+ private val shouldDisplaySearchTerms: Boolean = false,
+ urlRenderConfiguration: ToolbarFeature.UrlRenderConfiguration? = null,
+) {
+ @VisibleForTesting
+ internal var renderer = URLRenderer(toolbar, urlRenderConfiguration)
+
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Start presenter: Display data in toolbar.
+ */
+ fun start() {
+ renderer.start()
+
+ scope = store.flowScoped { flow ->
+ flow.distinctUntilChangedBy { it.findCustomTabOrSelectedTab(customTabId) }
+ .collect { state ->
+ render(state)
+ }
+ }
+ }
+
+ fun stop() {
+ scope?.cancel()
+ renderer.stop()
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun render(state: BrowserState) {
+ val tab = state.findCustomTabOrSelectedTab(customTabId)
+
+ if (tab != null) {
+ if (shouldDisplaySearchTerms && tab.content.searchTerms.isNotBlank()) {
+ toolbar.url = tab.content.searchTerms
+ } else {
+ renderer.post(tab.content.url)
+ }
+
+ toolbar.setSearchTerms(tab.content.searchTerms)
+ toolbar.displayProgress(tab.content.progress)
+
+ toolbar.siteSecure = if (tab.content.securityInfo.secure) {
+ Toolbar.SiteSecurity.SECURE
+ } else {
+ Toolbar.SiteSecurity.INSECURE
+ }
+
+ toolbar.siteTrackingProtection = when {
+ tab.trackingProtection.ignoredOnTrackingProtection -> SiteTrackingProtection.OFF_FOR_A_SITE
+ tab.trackingProtection.enabled && tab.trackingProtection.blockedTrackers.isNotEmpty() ->
+ SiteTrackingProtection.ON_TRACKERS_BLOCKED
+
+ tab.trackingProtection.enabled -> SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED
+
+ else -> SiteTrackingProtection.OFF_GLOBALLY
+ }
+
+ updateHighlight(tab)
+ } else {
+ clear()
+ }
+ }
+
+ private fun updateHighlight(tab: SessionState) {
+ toolbar.highlight = when {
+ tab.content.permissionHighlights.permissionsChanged ||
+ tab.trackingProtection.ignoredOnTrackingProtection
+ -> Highlight.PERMISSIONS_CHANGED
+ else -> Highlight.NONE
+ }
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun clear() {
+ renderer.post("")
+
+ toolbar.setSearchTerms("")
+ toolbar.displayProgress(0)
+
+ toolbar.siteSecure = Toolbar.SiteSecurity.INSECURE
+
+ toolbar.siteTrackingProtection = SiteTrackingProtection.OFF_GLOBALLY
+ toolbar.highlight = Highlight.NONE
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarAction.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarAction.kt
new file mode 100644
index 0000000000..653f6e7148
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarAction.kt
@@ -0,0 +1,98 @@
+/* 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.feature.toolbar
+
+import android.graphics.drawable.BitmapDrawable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import mozilla.components.concept.engine.webextension.WebExtensionBrowserAction
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.base.log.Log
+import mozilla.components.support.ktx.android.content.res.resolveAttribute
+import mozilla.components.support.ktx.android.view.setPadding
+import mozilla.components.ui.icons.R as iconsR
+
+/**
+ * An action button that represents an web extension item to be added to the toolbar.
+ *
+ * @param action Associated [WebExtensionBrowserAction]
+ * @param listener Callback that will be invoked whenever the button is pressed
+ */
+open class WebExtensionToolbarAction(
+ internal var action: WebExtensionBrowserAction,
+ internal val padding: Padding? = null,
+ internal val iconJobDispatcher: CoroutineDispatcher,
+ internal val listener: () -> Unit,
+) : Toolbar.Action {
+ internal var iconJob: Job? = null
+
+ override fun createView(parent: ViewGroup): View {
+ val rootView = LayoutInflater.from(parent.context)
+ .inflate(R.layout.mozac_feature_toolbar_web_extension_action_layout, parent, false)
+
+ rootView.isEnabled = action.enabled ?: true
+ rootView.setOnClickListener { listener.invoke() }
+
+ val backgroundResource =
+ parent.context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless)
+
+ rootView.setBackgroundResource(backgroundResource)
+ padding?.let { rootView.setPadding(it) }
+
+ parent.addOnAttachStateChangeListener(
+ object : View.OnAttachStateChangeListener {
+ override fun onViewDetachedFromWindow(view: View) {
+ iconJob?.cancel()
+ }
+
+ override fun onViewAttachedToWindow(view: View) = Unit
+ },
+ )
+ return rootView
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ override fun bind(view: View) {
+ val imageView = view.findViewById<ImageView>(R.id.action_image)
+ val textView = view.findViewById<TextView>(R.id.badge_text)
+
+ iconJob = CoroutineScope(iconJobDispatcher).launch {
+ try {
+ val icon = action.loadIcon?.invoke(imageView.measuredHeight)
+ icon?.let {
+ MainScope().launch {
+ imageView.setImageDrawable(BitmapDrawable(view.context.resources, it))
+ }
+ }
+ } catch (throwable: Throwable) {
+ MainScope().launch {
+ imageView.setImageResource(
+ iconsR.drawable.mozac_ic_web_extension_default_icon,
+ )
+ }
+ Log.log(
+ Log.Priority.ERROR,
+ "mozac-webextensions",
+ throwable,
+ "Failed to load browser action icon, falling back to default.",
+ )
+ }
+ }
+
+ action.title?.let { imageView.contentDescription = it }
+ action.badgeText?.let { textView.text = it }
+ action.badgeTextColor?.let { textView.setTextColor(it) }
+ action.badgeBackgroundColor?.let { textView.setBackgroundColor(it) }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeature.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeature.kt
new file mode 100644
index 0000000000..b00228dd15
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeature.kt
@@ -0,0 +1,170 @@
+/* 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.feature.toolbar
+
+import android.os.Handler
+import android.os.HandlerThread
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.android.asCoroutineDispatcher
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collect
+import mozilla.components.browser.state.selector.selectedTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
+
+/**
+ * Web extension toolbar implementation that updates the toolbar whenever the state of web
+ * extensions changes.
+ */
+class WebExtensionToolbarFeature(
+ private val toolbar: Toolbar,
+ private var store: BrowserStore,
+) : LifecycleAwareFeature {
+ // This maps web extension ids to [WebExtensionToolbarAction]s for efficient
+ // updates of global and tab-specific browser/page actions within the same
+ // lifecycle.
+ @VisibleForTesting
+ internal val webExtensionBrowserActions = HashMap<String, WebExtensionToolbarAction>()
+ internal val webExtensionPageActions = HashMap<String, WebExtensionToolbarAction>()
+
+ private var scope: CoroutineScope? = null
+
+ internal val iconThread = HandlerThread("IconThread")
+ internal val iconHandler by lazy {
+ iconThread.start()
+ Handler(iconThread.looper)
+ }
+
+ internal var iconJobDispatcher: CoroutineDispatcher = Dispatchers.Main
+
+ init {
+ renderWebExtensionActions(store.state)
+ }
+
+ /**
+ * Starts observing for the state of web extensions changes
+ */
+ override fun start() {
+ // The feature could start with an existing view and toolbar so
+ // we have to check if any stale actions (from uninstalled or
+ // disabled extensions) are being displayed and remove them.
+ webExtensionBrowserActions
+ .filterKeys { !store.state.extensions.containsKey(it) || store.state.extensions[it]?.enabled == false }
+ .forEach { (extensionId, action) ->
+ toolbar.removeBrowserAction(action)
+ toolbar.invalidateActions()
+ webExtensionBrowserActions.remove(extensionId)
+ }
+
+ webExtensionPageActions
+ .filterKeys { !store.state.extensions.containsKey(it) || store.state.extensions[it]?.enabled == false }
+ .forEach { (extensionId, action) ->
+ toolbar.removePageAction(action)
+ toolbar.invalidateActions()
+ webExtensionPageActions.remove(extensionId)
+ }
+
+ iconJobDispatcher = iconHandler.asCoroutineDispatcher("WebExtensionIconDispatcher")
+ scope = store.flowScoped { flow ->
+ flow.ifAnyChanged { arrayOf(it.selectedTab, it.extensions) }
+ .collect { state ->
+ renderWebExtensionActions(state, state.selectedTab)
+ }
+ }
+ }
+
+ override fun stop() {
+ iconJobDispatcher.cancel()
+ scope?.cancel()
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun renderWebExtensionActions(state: BrowserState, tab: SessionState? = null) {
+ val extensions = state.extensions.values.toList()
+ extensions.filter { it.enabled }.sortedBy { it.name }.forEach { extension ->
+ if (extensionNotAllowedInTab(extension, tab)) {
+ webExtensionPageActions[extension.id]?.let {
+ toolbar.removePageAction(it)
+ toolbar.invalidateActions()
+ webExtensionPageActions.remove(extension.id)
+ }
+ webExtensionBrowserActions[extension.id]?.let {
+ toolbar.removeBrowserAction(it)
+ toolbar.invalidateActions()
+ webExtensionBrowserActions.remove(extension.id)
+ }
+ return@forEach
+ }
+
+ extension.browserAction?.let { browserAction ->
+ addOrUpdateAction(
+ extension = extension,
+ globalAction = browserAction,
+ tabAction = tab?.extensionState?.get(extension.id)?.browserAction,
+ )
+ }
+
+ extension.pageAction?.let { pageAction ->
+ val tabPageAction = tab?.extensionState?.get(extension.id)?.pageAction
+
+ // Unlike browser actions, page actions are not displayed by default (only if enabled):
+ // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action
+ if (pageAction.copyWithOverride(tabPageAction).enabled == true) {
+ addOrUpdateAction(
+ extension = extension,
+ globalAction = pageAction,
+ tabAction = tabPageAction,
+ isPageAction = true,
+ )
+ }
+ }
+ }
+ }
+
+ private fun extensionNotAllowedInTab(
+ extension: WebExtensionState?,
+ tab: SessionState?,
+ ): Boolean = extension?.allowedInPrivateBrowsing == false && tab?.content?.private == true
+
+ private fun addOrUpdateAction(
+ extension: WebExtensionState,
+ globalAction: Action,
+ tabAction: Action?,
+ isPageAction: Boolean = false,
+ ) {
+ val actionMap = if (isPageAction) webExtensionPageActions else webExtensionBrowserActions
+ // Add the global page/browser action if it doesn't exist
+ val toolbarAction = actionMap.getOrPut(extension.id) {
+ val toolbarAction = WebExtensionToolbarAction(
+ action = globalAction,
+ listener = globalAction.onClick,
+ iconJobDispatcher = iconJobDispatcher,
+ )
+ if (isPageAction) {
+ toolbar.addPageAction(toolbarAction)
+ } else {
+ toolbar.addBrowserAction(toolbarAction)
+ }
+ toolbar.invalidateActions()
+ toolbarAction
+ }
+
+ // Apply tab-specific override of page/browser action
+ tabAction?.let {
+ toolbarAction.action = globalAction.copyWithOverride(it)
+ toolbar.invalidateActions()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/internal/URLRenderer.kt b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/internal/URLRenderer.kt
new file mode 100644
index 0000000000..52adb9e999
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/java/mozilla/components/feature/toolbar/internal/URLRenderer.kt
@@ -0,0 +1,126 @@
+/* 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.feature.toolbar.internal
+
+import android.text.SpannableStringBuilder
+import android.text.SpannableStringBuilder.SPAN_INCLUSIVE_INCLUSIVE
+import android.text.style.ForegroundColorSpan
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import androidx.core.net.toUri
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.launch
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.toolbar.ToolbarFeature
+
+/**
+ * Asynchronous URL renderer.
+ *
+ * This "renderer" will create a (potentially) colored URL (using spans) in a coroutine and set it on the [Toolbar].
+ */
+internal class URLRenderer(
+ private val toolbar: Toolbar,
+ private val configuration: ToolbarFeature.UrlRenderConfiguration?,
+) {
+ private val scope = CoroutineScope(Dispatchers.Main)
+
+ @VisibleForTesting internal var job: Job? = null
+
+ @VisibleForTesting internal val channel = Channel<String>(capacity = Channel.CONFLATED)
+
+ /**
+ * Starts this renderer which will listen for incoming URLs to render.
+ */
+ fun start() {
+ job = scope.launch {
+ for (url in channel) {
+ updateUrl(url)
+ }
+ }
+ }
+
+ /**
+ * Stops this renderer.
+ */
+ fun stop() {
+ job?.cancel()
+ }
+
+ /**
+ * Posts this [url] to the renderer.
+ */
+ fun post(url: String) {
+ try {
+ channel.trySendBlocking(url)
+ } catch (e: InterruptedException) {
+ // Ignore
+ }
+ }
+
+ @VisibleForTesting
+ internal suspend fun updateUrl(url: String) {
+ if (url.isEmpty() || configuration == null) {
+ toolbar.url = url
+ return
+ }
+
+ toolbar.url = when (configuration.renderStyle) {
+ // Display only the URL, uncolored
+ ToolbarFeature.RenderStyle.RegistrableDomain -> {
+ val host = url.toUri().host?.ifEmpty { null }
+ host?.let { getRegistrableDomain(host, configuration) } ?: url
+ }
+ // Display the registrableDomain with color and URL with another color
+ ToolbarFeature.RenderStyle.ColoredUrl -> SpannableStringBuilder(url).apply {
+ color(configuration.urlColor)
+ colorRegistrableDomain(configuration)
+ }
+ // Display the full URL, uncolored
+ ToolbarFeature.RenderStyle.UncoloredUrl -> url
+ }
+ }
+}
+
+private suspend fun getRegistrableDomain(host: String, configuration: ToolbarFeature.UrlRenderConfiguration) =
+ configuration.publicSuffixList.getPublicSuffixPlusOne(host).await()
+
+private suspend fun SpannableStringBuilder.colorRegistrableDomain(
+ configuration: ToolbarFeature.UrlRenderConfiguration,
+) {
+ val url = toString()
+ val host = url.toUri().host ?: return
+
+ val registrableDomain = configuration
+ .publicSuffixList
+ .getPublicSuffixPlusOne(host)
+ .await() ?: return
+
+ val index = url.indexOf(registrableDomain)
+ if (index == -1) {
+ return
+ }
+
+ setSpan(
+ ForegroundColorSpan(configuration.registrableDomainColor),
+ index,
+ index + registrableDomain.length,
+ SPAN_INCLUSIVE_INCLUSIVE,
+ )
+}
+
+private fun SpannableStringBuilder.color(@ColorInt urlColor: Int?) {
+ urlColor ?: return
+
+ setSpan(
+ ForegroundColorSpan(urlColor),
+ 0,
+ length,
+ SPAN_INCLUSIVE_INCLUSIVE,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_container_action_layout.xml b/mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_container_action_layout.xml
new file mode 100644
index 0000000000..803aa1b068
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_container_action_layout.xml
@@ -0,0 +1,20 @@
+<?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/. -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:background="?android:selectableItemBackgroundBorderless"
+ tools:ignore="Overdraw">
+
+ <ImageView
+ android:id="@+id/container_action_image"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center"
+ android:importantForAccessibility="no" />
+
+</FrameLayout>
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_web_extension_action_layout.xml b/mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_web_extension_action_layout.xml
new file mode 100644
index 0000000000..8e6a440829
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/res/layout/mozac_feature_toolbar_web_extension_action_layout.xml
@@ -0,0 +1,29 @@
+<?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/. -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/counter_root"
+ android:layout_width="32dp"
+ android:layout_height="32dp">
+
+ <ImageView
+ android:id="@+id/action_image"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center"
+ android:importantForAccessibility="no" />
+
+ <TextView
+ android:id="@+id/badge_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top|end"
+ android:background="#3B3B3C"
+ android:textColor="#FFFFFF"
+ android:textSize="12sp"
+ tools:text="18" />
+
+</FrameLayout>
diff --git a/mobile/android/android-components/components/feature/toolbar/src/main/res/values/colors.xml b/mobile/android/android-components/components/feature/toolbar/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..4d019dc690
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/main/res/values/colors.xml
@@ -0,0 +1,15 @@
+<?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>
+ <color name="mozac_feature_toolbar_container_blue">#37adff</color>
+ <color name="mozac_feature_toolbar_container_turquoise">#00c79a</color>
+ <color name="mozac_feature_toolbar_container_green">#51cd00</color>
+ <color name="mozac_feature_toolbar_container_yellow">#ffcb00</color>
+ <color name="mozac_feature_toolbar_container_orange">#ff9f00</color>
+ <color name="mozac_feature_toolbar_container_red">#ff613d</color>
+ <color name="mozac_feature_toolbar_container_pink">#ff4bda</color>
+ <color name="mozac_feature_toolbar_container_purple">#af51f5</color>
+ <color name="mozac_feature_toolbar_container_toolbar">#7c7c7d</color>
+</resources>
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarActionTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarActionTest.kt
new file mode 100644
index 0000000000..44bed56d21
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarActionTest.kt
@@ -0,0 +1,67 @@
+/* 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.feature.toolbar
+
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.state.ContainerState
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class ContainerToolbarActionTest {
+
+ // Test container
+ private val container = ContainerState(
+ contextId = "contextId",
+ name = "Personal",
+ color = ContainerState.Color.GREEN,
+ icon = ContainerState.Icon.CART,
+ )
+
+ @Test
+ fun bind() {
+ val imageView: ImageView = spy(ImageView(testContext))
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.container_action_image)).thenReturn(imageView)
+ whenever(view.context).thenReturn(testContext)
+
+ val action = spy(ContainerToolbarAction(container))
+ action.bind(view)
+
+ verify(imageView).contentDescription = container.name
+ verify(imageView).setImageDrawable(any())
+ }
+
+ @Test
+ fun createView() {
+ var listenerWasClicked = false
+
+ val action = ContainerToolbarAction(container, padding = Padding(1, 2, 3, 4)) {
+ listenerWasClicked = true
+ }
+
+ val rootView = action.createView(LinearLayout(testContext))
+ rootView.performClick()
+
+ assertTrue(listenerWasClicked)
+ assertEquals(rootView.paddingLeft, 1)
+ assertEquals(rootView.paddingTop, 2)
+ assertEquals(rootView.paddingRight, 3)
+ assertEquals(rootView.paddingBottom, 4)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarFeatureTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarFeatureTest.kt
new file mode 100644
index 0000000000..d1d920b16e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ContainerToolbarFeatureTest.kt
@@ -0,0 +1,97 @@
+/* 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.feature.toolbar
+
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContainerState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class ContainerToolbarFeatureTest {
+ // Test container
+ private val container = ContainerState(
+ contextId = "1",
+ name = "Personal",
+ color = ContainerState.Color.GREEN,
+ icon = ContainerState.Icon.FINGERPRINT,
+ )
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `render a container action from browser state`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.example.org", id = "tab1", contextId = "1"),
+ ),
+ selectedTabId = "tab1",
+ containers = mapOf(
+ container.contextId to container,
+ ),
+ ),
+ ),
+ )
+ val containerToolbarFeature = getContainerToolbarFeature(toolbar, store)
+
+ verify(store).observeManually(any())
+ verify(containerToolbarFeature).renderContainerAction(any(), any())
+
+ val pageActionCaptor = argumentCaptor<ContainerToolbarAction>()
+ verify(toolbar).addPageAction(pageActionCaptor.capture())
+ assertEquals(container, pageActionCaptor.value.container)
+ }
+
+ @Test
+ fun `remove container page action when selecting a normal tab`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://www.example.org", id = "tab1", contextId = "1"),
+ createTab("https://www.mozilla.org", id = "tab2"),
+ ),
+ selectedTabId = "tab1",
+ containers = mapOf(
+ container.contextId to container,
+ ),
+ ),
+ ),
+ )
+ val containerToolbarFeature = getContainerToolbarFeature(toolbar, store)
+ store.dispatch(TabListAction.SelectTabAction("tab2")).joinBlocking()
+ coroutinesTestRule.testDispatcher.scheduler.advanceUntilIdle()
+
+ verify(store).observeManually(any())
+ verify(containerToolbarFeature, times(2)).renderContainerAction(any(), any())
+ verify(toolbar).removePageAction(any())
+ }
+
+ private fun getContainerToolbarFeature(
+ toolbar: Toolbar = mock(),
+ store: BrowserStore = BrowserStore(),
+ ): ContainerToolbarFeature {
+ val containerToolbarFeature = spy(ContainerToolbarFeature(toolbar, store))
+ containerToolbarFeature.start()
+ return containerToolbarFeature
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeatureTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeatureTest.kt
new file mode 100644
index 0000000000..3a5dbc1cfe
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarAutocompleteFeatureTest.kt
@@ -0,0 +1,596 @@
+/* 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.feature.toolbar
+
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.domains.Domain
+import mozilla.components.browser.domains.autocomplete.BaseDomainAutocompleteProvider
+import mozilla.components.browser.domains.autocomplete.DomainList
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.AutocompleteProvider
+import mozilla.components.concept.toolbar.AutocompleteResult
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class ToolbarAutocompleteFeatureTest {
+ class TestToolbar : Toolbar {
+ override var highlight: Toolbar.Highlight = Toolbar.Highlight.NONE
+ override var siteTrackingProtection: Toolbar.SiteTrackingProtection =
+ Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+ override var title: String = ""
+ override var url: CharSequence = ""
+ override var siteSecure: Toolbar.SiteSecurity = Toolbar.SiteSecurity.INSECURE
+ override var private: Boolean = false
+
+ var autocompleteFilter: (suspend (String, AutocompleteDelegate) -> Unit)? = null
+
+ override fun setSearchTerms(searchTerms: String) {
+ fail()
+ }
+
+ override fun displayProgress(progress: Int) {
+ fail()
+ }
+
+ override fun onBackPressed(): Boolean {
+ fail()
+ return false
+ }
+
+ override fun onStop() {
+ fail()
+ }
+
+ override fun setOnUrlCommitListener(listener: (String) -> Boolean) {
+ fail()
+ }
+
+ override fun setAutocompleteListener(filter: suspend (String, AutocompleteDelegate) -> Unit) {
+ autocompleteFilter = filter
+ }
+
+ override fun addBrowserAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removeBrowserAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removePageAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun addPageAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun addNavigationAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removeNavigationAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun setOnEditListener(listener: Toolbar.OnEditListener) {
+ fail()
+ }
+
+ override fun displayMode() {
+ fail()
+ }
+
+ override fun editMode(cursorPlacement: Toolbar.CursorPlacement) {
+ fail()
+ }
+
+ override fun addEditActionStart(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun addEditActionEnd(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removeEditActionEnd(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun hideMenuButton() {
+ fail()
+ }
+
+ override fun showMenuButton() {
+ fail()
+ }
+
+ override fun setDisplayHorizontalPadding(horizontalPadding: Int) {
+ fail()
+ }
+
+ override fun hidePageActionSeparator() {
+ fail()
+ }
+
+ override fun showPageActionSeparator() {
+ fail()
+ }
+
+ override fun invalidateActions() {
+ fail()
+ }
+
+ override fun dismissMenu() {
+ fail()
+ }
+
+ override fun enableScrolling() {
+ fail()
+ }
+
+ override fun disableScrolling() {
+ fail()
+ }
+
+ override fun collapse() {
+ fail()
+ }
+
+ override fun expand() {
+ fail()
+ }
+ }
+
+ @Test
+ fun `feature can be used without providers`() = runTest {
+ val toolbar = TestToolbar()
+
+ ToolbarAutocompleteFeature(toolbar)
+
+ assertNotNull(toolbar.autocompleteFilter)
+
+ val autocompleteDelegate: AutocompleteDelegate = mock()
+ toolbar.autocompleteFilter!!("moz", autocompleteDelegate)
+
+ verify(autocompleteDelegate, never()).applyAutocompleteResult(any(), any())
+ verify(autocompleteDelegate, times(1)).noAutocompleteResult("moz")
+ }
+
+ @Test
+ fun `feature can be configured with providers`() = runTest {
+ val toolbar = TestToolbar()
+ var feature = ToolbarAutocompleteFeature(toolbar)
+ val autocompleteDelegate: AutocompleteDelegate = mock()
+
+ var history: AutocompleteProvider = mock()
+ val domains = object : BaseDomainAutocompleteProvider(DomainList.CUSTOM, { emptyList() }, 22) {
+ fun testDomains(list: List<Domain>) {
+ domains = list
+ }
+ }
+
+ // Can autocomplete with just an empty history provider.
+ feature.addAutocompleteProvider(history)
+ verifyNoAutocompleteResult(toolbar, autocompleteDelegate, "hi")
+
+ // Can autocomplete with a non-empty history provider.
+ doReturn(
+ AutocompleteResult(
+ input = "mo",
+ text = "mozilla.org",
+ url = "https://www.mozilla.org",
+ source = "memoryHistory",
+ totalItems = 1,
+ ),
+ ).`when`(history).getAutocompleteSuggestion("mo")
+
+ verifyNoAutocompleteResult(toolbar, autocompleteDelegate, "hi")
+ verifyAutocompleteResult(
+ toolbar,
+ autocompleteDelegate,
+ "mo",
+ AutocompleteResult(
+ input = "mo",
+ text = "mozilla.org",
+ url = "https://www.mozilla.org",
+ source = "memoryHistory",
+ totalItems = 1,
+ ),
+ )
+
+ // Can autocomplete with just an empty domain provider.
+ feature = ToolbarAutocompleteFeature(toolbar)
+ feature.addAutocompleteProvider(domains)
+
+ verifyNoAutocompleteResult(toolbar, autocompleteDelegate, "hi")
+
+ // Can autocomplete with a non-empty domain provider.
+ domains.testDomains(
+ listOf(
+ Domain.create("https://www.mozilla.org"),
+ ),
+ )
+
+ verifyNoAutocompleteResult(toolbar, autocompleteDelegate, "hi")
+ verifyAutocompleteResult(
+ toolbar,
+ autocompleteDelegate,
+ "mo",
+ AutocompleteResult(
+ input = "mo",
+ text = "mozilla.org",
+ url = "https://www.mozilla.org",
+ source = "custom",
+ totalItems = 1,
+ ),
+ )
+
+ // Can autocomplete with empty history and domain providers.
+ history = spy(AutocompleteProviderFake()) // use a real object so that the comparator will work
+ domains.testDomains(listOf())
+ feature.addAutocompleteProvider(history)
+
+ verifyNoAutocompleteResult(toolbar, autocompleteDelegate, "hi")
+
+ // Can autocomplete with both domains providing data; test that history is prioritized,
+ // falling back to domains.
+ domains.testDomains(
+ listOf(
+ Domain.create("https://www.mozilla.org"),
+ Domain.create("https://moscow.ru"),
+ ),
+ )
+
+ verifyAutocompleteResult(
+ toolbar,
+ autocompleteDelegate,
+ "mo",
+ AutocompleteResult(
+ input = "mo",
+ text = "mozilla.org",
+ url = "https://www.mozilla.org",
+ source = "custom",
+ totalItems = 2,
+ ),
+ )
+
+ doReturn(
+ AutocompleteResult(
+ input = "mo",
+ text = "mozilla.org",
+ url = "https://www.mozilla.org",
+ source = "memoryHistory",
+ totalItems = 1,
+ ),
+ ).`when`(history).getAutocompleteSuggestion("mo")
+
+ verifyAutocompleteResult(
+ toolbar,
+ autocompleteDelegate,
+ "mo",
+ AutocompleteResult(
+ input = "mo",
+ text = "mozilla.org",
+ url = "https://www.mozilla.org",
+ source = "memoryHistory",
+ totalItems = 1,
+ ),
+ )
+
+ verifyAutocompleteResult(
+ toolbar,
+ autocompleteDelegate,
+ "mos",
+ AutocompleteResult(
+ input = "mos",
+ text = "moscow.ru",
+ url = "https://moscow.ru",
+ source = "custom",
+ totalItems = 2,
+ ),
+ )
+ }
+
+ @Test
+ fun `feature triggers speculative connect for results if engine provided`() = runTest {
+ val toolbar = TestToolbar()
+ val engine: Engine = mock()
+ val feature = ToolbarAutocompleteFeature(toolbar, engine) { true }
+ val autocompleteDelegate: AutocompleteDelegate = mock()
+
+ val domains = object : BaseDomainAutocompleteProvider(DomainList.CUSTOM, { emptyList() }) {
+ fun testDomains(list: List<Domain>) {
+ domains = list
+ }
+ }
+ domains.testDomains(listOf(Domain.create("https://www.mozilla.org")))
+ feature.addAutocompleteProvider(domains)
+
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+
+ val callbackCaptor = argumentCaptor<() -> Unit>()
+ verify(autocompleteDelegate, times(1)).applyAutocompleteResult(any(), callbackCaptor.capture())
+ verify(engine, never()).speculativeConnect("https://www.mozilla.org")
+ callbackCaptor.value.invoke()
+ verify(engine).speculativeConnect("https://www.mozilla.org")
+ }
+
+ @Test
+ fun `WHEN should autocomplete returns false THEN return no results`() = runTest {
+ val toolbar = TestToolbar()
+ val engine: Engine = mock()
+ var shouldAutoComplete = false
+ val feature = ToolbarAutocompleteFeature(toolbar, engine) { shouldAutoComplete }
+ val autocompleteDelegate: AutocompleteDelegate = mock()
+
+ val domains = object : BaseDomainAutocompleteProvider(DomainList.CUSTOM, { emptyList() }) {
+ fun testDomains(list: List<Domain>) {
+ domains = list
+ }
+ }
+ domains.testDomains(listOf(Domain.create("https://www.mozilla.org")))
+ feature.addAutocompleteProvider(domains)
+
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+
+ verify(autocompleteDelegate, times(1)).noAutocompleteResult(any())
+ verify(engine, never()).speculativeConnect("https://www.mozilla.org")
+
+ shouldAutoComplete = true
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+
+ val callbackCaptor = argumentCaptor<() -> Unit>()
+ verify(autocompleteDelegate, times(1)).applyAutocompleteResult(any(), callbackCaptor.capture())
+ verify(engine, never()).speculativeConnect("https://www.mozilla.org")
+ callbackCaptor.value.invoke()
+ verify(engine).speculativeConnect("https://www.mozilla.org")
+ }
+
+ @Test
+ fun `GIVEN no autocomplete providers WHEN checking for autocomplete results THEN silently fail with no results`() = runTest {
+ val toolbar = TestToolbar()
+ val engine: Engine = mock()
+ val feature = ToolbarAutocompleteFeature(toolbar, engine) { true }
+ val autocompleteDelegate: AutocompleteDelegate = mock()
+
+ val domains = object : BaseDomainAutocompleteProvider(DomainList.CUSTOM, { emptyList() }) {
+ fun testDomains(list: List<Domain>) {
+ domains = list
+ }
+ }
+ domains.testDomains(listOf(Domain.create("https://www.mozilla.org")))
+
+ feature.addAutocompleteProvider(domains)
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+ val callbackCaptor = argumentCaptor<() -> Unit>()
+ verify(autocompleteDelegate, times(1)).applyAutocompleteResult(any(), callbackCaptor.capture())
+ verify(engine, never()).speculativeConnect("https://www.mozilla.org")
+ callbackCaptor.value.invoke()
+ verify(engine).speculativeConnect("https://www.mozilla.org")
+
+ // After checking the results for when a provider exists test what happens when no providers exist.
+ feature.removeAutocompleteProvider(domains)
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+ verify(autocompleteDelegate, times(1)).noAutocompleteResult(any())
+ verify(engine, times(1)).speculativeConnect("https://www.mozilla.org")
+ }
+
+ @Test
+ fun `GIVEN no initial autocomplete providers and one is added WHEN checking for autocomplete results THEN return autocomplete suggestions`() = runTest {
+ val toolbar = TestToolbar()
+ val engine: Engine = mock()
+ val feature = ToolbarAutocompleteFeature(toolbar, engine) { true }
+ val autocompleteDelegate: AutocompleteDelegate = mock()
+
+ val domains = object : BaseDomainAutocompleteProvider(DomainList.CUSTOM, { emptyList() }) {
+ fun testDomains(list: List<Domain>) {
+ domains = list
+ }
+ }
+ domains.testDomains(listOf(Domain.create("https://www.mozilla.org")))
+
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+ verify(autocompleteDelegate, times(1)).noAutocompleteResult(any())
+ verify(engine, never()).speculativeConnect("https://www.mozilla.org")
+
+ // After checking no results for when no providers exist test what happens when a new one is added.
+ feature.addAutocompleteProvider(domains)
+ toolbar.autocompleteFilter!!.invoke("mo", autocompleteDelegate)
+ val callbackCaptor = argumentCaptor<() -> Unit>()
+ verify(autocompleteDelegate, times(1)).applyAutocompleteResult(any(), callbackCaptor.capture())
+ verify(engine, never()).speculativeConnect("https://www.mozilla.org")
+ callbackCaptor.value.invoke()
+ verify(engine).speculativeConnect("https://www.mozilla.org")
+ }
+
+ @Test
+ fun `GIVEN providers exist WHEN a new one is added THEN they are sorted by their priority`() {
+ val feature = ToolbarAutocompleteFeature(mock())
+
+ val provider1 = AutocompleteProviderFake(autocompletePriority = 11).also {
+ assertTrue(feature.addAutocompleteProvider(it))
+ }
+ val provider2 = AutocompleteProviderFake(autocompletePriority = 22).also {
+ assertTrue(feature.addAutocompleteProvider(it))
+ }
+ val provider3 = AutocompleteProviderFake(autocompletePriority = 3).also {
+ assertTrue(feature.addAutocompleteProvider(it))
+ }
+
+ assertEquals(
+ listOf(provider3, provider1, provider2),
+ feature.autocompleteProviders.toList(),
+ )
+ }
+
+ @Test
+ fun `GIVEN providers exist WHEN trying to add an existing one THEN avoid adding`() {
+ val feature = ToolbarAutocompleteFeature(mock())
+ val provider1 = AutocompleteProviderFake(autocompletePriority = 11).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider2 = AutocompleteProviderFake(autocompletePriority = 22).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider3 = AutocompleteProviderFake(autocompletePriority = 3).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider4 = AutocompleteProviderFake(autocompletePriority = 15).also {
+ feature.addAutocompleteProvider(it)
+ }
+
+ assertTrue(feature.removeAutocompleteProvider(provider4))
+
+ assertEquals(
+ listOf(provider3, provider1, provider2),
+ feature.autocompleteProviders.toList(),
+ )
+ }
+
+ @Test
+ fun `GIVEN providers don't exist WHEN trying to remove one THEN avoid fail gracefully`() {
+ val feature = ToolbarAutocompleteFeature(mock())
+ val provider1 = AutocompleteProviderFake(autocompletePriority = 11).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider2 = AutocompleteProviderFake(autocompletePriority = 22).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider3 = AutocompleteProviderFake(autocompletePriority = 3)
+
+ assertFalse(feature.removeAutocompleteProvider(provider3))
+
+ assertEquals(
+ listOf(provider1, provider2),
+ feature.autocompleteProviders.toList(),
+ )
+ }
+
+ @Test
+ fun `GIVEN providers exist WHEN removing one THEN keep the other sorted`() {
+ val feature = ToolbarAutocompleteFeature(mock())
+ val provider1 = AutocompleteProviderFake(autocompletePriority = 11).also {
+ assertTrue(feature.addAutocompleteProvider(it))
+ }
+ val provider2 = AutocompleteProviderFake(autocompletePriority = 22).also {
+ assertTrue(feature.addAutocompleteProvider(it))
+ }
+
+ assertFalse(feature.addAutocompleteProvider(provider1))
+
+ assertEquals(
+ listOf(provider1, provider2),
+ feature.autocompleteProviders.toList(),
+ )
+ }
+
+ @Test
+ fun `GIVEN providers exist WHEN when they are updated THEN the old ones are replaced by new ones sorted by priority`() {
+ val feature = ToolbarAutocompleteFeature(mock())
+ feature.autocompleteProviders = sortedSetOf(
+ AutocompleteProviderFake(autocompletePriority = 11),
+ AutocompleteProviderFake(autocompletePriority = 22),
+ )
+ val provider1 = AutocompleteProviderFake(autocompletePriority = 11).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider2 = AutocompleteProviderFake(autocompletePriority = 2).also {
+ feature.addAutocompleteProvider(it)
+ }
+ val provider3 = AutocompleteProviderFake().also {
+ feature.addAutocompleteProvider(it)
+ }
+
+ feature.updateAutocompleteProviders(listOf(provider1, provider2, provider3))
+
+ assertEquals(
+ listOf(provider3, provider2, provider1),
+ feature.autocompleteProviders.toList(),
+ )
+ }
+
+ @Test
+ fun `GIVEN a request to refresh autocomplete WHEN the providers are updated THEN also refresh autocomplete`() {
+ val toolbar: Toolbar = mock()
+ val feature = ToolbarAutocompleteFeature(toolbar)
+
+ feature.updateAutocompleteProviders(emptyList(), false)
+ verify(toolbar, never()).refreshAutocomplete()
+
+ feature.updateAutocompleteProviders(emptyList(), true)
+ verify(toolbar).refreshAutocomplete()
+ }
+
+ @Test
+ fun `GIVEN a request to refresh autocomplete WHEN a provider is added THEN also refresh autocomplete`() {
+ val toolbar: Toolbar = mock()
+ val feature = ToolbarAutocompleteFeature(toolbar)
+
+ feature.addAutocompleteProvider(mock(), false)
+ verify(toolbar, never()).refreshAutocomplete()
+
+ feature.addAutocompleteProvider(mock(), true)
+ verify(toolbar).refreshAutocomplete()
+ }
+
+ @Test
+ fun `GIVEN a request to refresh autocomplete WHEN a provider is removed THEN also refresh autocomplete`() {
+ val toolbar: Toolbar = mock()
+ val feature = ToolbarAutocompleteFeature(toolbar)
+
+ feature.removeAutocompleteProvider(mock(), false)
+ verify(toolbar, never()).refreshAutocomplete()
+
+ feature.removeAutocompleteProvider(mock(), true)
+ verify(toolbar).refreshAutocomplete()
+ }
+
+ @Suppress("SameParameterValue")
+ private fun verifyNoAutocompleteResult(toolbar: TestToolbar, autocompleteDelegate: AutocompleteDelegate, query: String) = runTest {
+ toolbar.autocompleteFilter!!(query, autocompleteDelegate)
+
+ verify(autocompleteDelegate, never()).applyAutocompleteResult(any(), any())
+ verify(autocompleteDelegate, times(1)).noAutocompleteResult(query)
+ reset(autocompleteDelegate)
+ }
+
+ private fun verifyAutocompleteResult(toolbar: TestToolbar, autocompleteDelegate: AutocompleteDelegate, query: String, result: AutocompleteResult) = runTest {
+ toolbar.autocompleteFilter!!.invoke(query, autocompleteDelegate)
+
+ verify(autocompleteDelegate, times(1)).applyAutocompleteResult(eq(result), any())
+ verify(autocompleteDelegate, never()).noAutocompleteResult(query)
+ reset(autocompleteDelegate)
+ }
+}
+
+/**
+ * Empty implementation of [AutocompleteProvider].
+ * [getAutocompleteSuggestion] will return `null` by default.
+ *
+ * @param resultToReturn Optional nullable [AutocompleteResult] to return for all queries.
+ */
+private class AutocompleteProviderFake(
+ val resultToReturn: AutocompleteResult? = null,
+ override val autocompletePriority: Int = 0,
+) : AutocompleteProvider {
+ override suspend fun getAutocompleteSuggestion(query: String) = resultToReturn
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarBehaviorControllerTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarBehaviorControllerTest.kt
new file mode 100644
index 0000000000..48d583c530
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarBehaviorControllerTest.kt
@@ -0,0 +1,200 @@
+/* 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.feature.toolbar
+
+import android.os.Looper.getMainLooper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.isActive
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class ToolbarBehaviorControllerTest {
+
+ @Test
+ fun `Controller should check the status of the provided custom tab id`() {
+ val customTabContent: ContentState = mock()
+ val normalTabContent: ContentState = mock()
+ val state = spy(
+ BrowserState(
+ tabs = listOf(TabSessionState("123", normalTabContent)),
+ customTabs = listOf(CustomTabSessionState("ct", customTabContent, config = mock())),
+ selectedTabId = "123",
+ ),
+ )
+ val store = BrowserStore(state)
+ val controller = ToolbarBehaviorController(mock(), store, "ct")
+
+ assertNull(controller.updatesScope)
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ assertNotNull(controller.updatesScope)
+ verify(customTabContent, times(3)).loading
+ verify(normalTabContent, never()).loading
+ }
+
+ @Test
+ fun `Controller should check the status of the currently selected tab if not initialized with a custom tab id`() {
+ val customTabContent: ContentState = mock()
+ val normalTabContent: ContentState = mock()
+ val state = spy(
+ BrowserState(
+ tabs = listOf(TabSessionState("123", normalTabContent)),
+ customTabs = listOf(CustomTabSessionState("ct", customTabContent, config = mock())),
+ selectedTabId = "123",
+ ),
+ )
+ val store = BrowserStore(state)
+ val controller = ToolbarBehaviorController(mock(), store)
+
+ assertNull(controller.updatesScope)
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ assertNotNull(controller.updatesScope)
+ verify(customTabContent, never()).loading
+ verify(normalTabContent, times(3)).loading
+ }
+
+ @Test
+ fun `Controller should disableScrolling if the current tab is loading`() {
+ val normalTabContent = ContentState("url", loading = true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(TabSessionState("123", normalTabContent)),
+ selectedTabId = "123",
+ ),
+ )
+ val controller = spy(ToolbarBehaviorController(mock(), store))
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(controller).disableScrolling()
+ }
+
+ @Test
+ fun `Controller should enableScrolling if the current tab is not loading`() {
+ val normalTabContent = ContentState("url", loading = false)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(TabSessionState("123", normalTabContent)),
+ selectedTabId = "123",
+ ),
+ )
+ val controller = spy(ToolbarBehaviorController(mock(), store))
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(controller).enableScrolling()
+ }
+
+ @Test
+ fun `Controller should listening for tab updates if stop is called`() {
+ val controller = spy(ToolbarBehaviorController(mock(), BrowserStore(BrowserState())))
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+ assertTrue(controller.updatesScope!!.isActive)
+
+ controller.stop()
+ assertFalse(controller.updatesScope!!.isActive)
+ }
+
+ @Test
+ fun `Controller should disable toolbar scrolling when disableScrolling is called`() {
+ val toolbar: Toolbar = mock()
+ val controller = spy(ToolbarBehaviorController(toolbar, mock()))
+
+ controller.disableScrolling()
+
+ verify(toolbar).disableScrolling()
+ }
+
+ @Test
+ fun `Controller should enable toolbar scrolling when enableScrolling is called`() {
+ val toolbar: Toolbar = mock()
+ val controller = spy(ToolbarBehaviorController(toolbar, mock()))
+
+ controller.enableScrolling()
+
+ verify(toolbar).enableScrolling()
+ }
+
+ @Test
+ fun `Controller should expand the toolbar and set showToolbarAsExpanded to false when showToolbarAsExpanded is true`() {
+ val normalTabContent = ContentState("url", showToolbarAsExpanded = true)
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(TabSessionState("123", normalTabContent)),
+ selectedTabId = "123",
+ ),
+ ),
+ )
+ val controller = spy(ToolbarBehaviorController(mock(), store))
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(controller).expandToolbar()
+ verify(store).dispatch(ContentAction.UpdateExpandedToolbarStateAction("123", false))
+ }
+
+ @Test
+ fun `Controller should not expand the toolbar and not update the current state if showToolbarAsExpanded is false`() {
+ val normalTabContent = ContentState("url", showToolbarAsExpanded = false)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(TabSessionState("123", normalTabContent)),
+ selectedTabId = "123",
+ ),
+ )
+ val controller = spy(ToolbarBehaviorController(mock(), store))
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(controller, never()).expandToolbar()
+ }
+
+ @Test
+ fun `GIVEN the current tab is loading an url WHEN the page is scrolled THEN expand toolbar`() {
+ val tabContent = ContentState("loading", loading = true)
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(TabSessionState("tab_1", tabContent)),
+ selectedTabId = "tab_1",
+ ),
+ )
+ val controller = spy(ToolbarBehaviorController(mock(), store))
+
+ controller.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(controller).expandToolbar()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarFeatureTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarFeatureTest.kt
new file mode 100644
index 0000000000..4c460ff6aa
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarFeatureTest.kt
@@ -0,0 +1,100 @@
+/* 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.feature.toolbar
+
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.verify
+
+class ToolbarFeatureTest {
+
+ @Test
+ fun `when app is backgrounded, toolbar onStop method is called`() {
+ val toolbar: Toolbar = mock()
+ val toolbarFeature = ToolbarFeature(toolbar, store = mock(), loadUrlUseCase = mock())
+
+ toolbarFeature.stop()
+
+ verify(toolbar).onStop()
+ }
+
+ @Test
+ fun `GIVEN ToolbarFeature, WHEN start() is called THEN it should call controller#start()`() {
+ val mockedController: ToolbarBehaviorController = mock()
+ val feature = ToolbarFeature(mock(), mock(), mock()).apply {
+ controller = mockedController
+ // mock other dependencies to limit real code running and error-ing.
+ presenter = mock()
+ interactor = mock()
+ }
+
+ feature.start()
+
+ verify(mockedController).start()
+ }
+
+ @Test
+ fun `GIVEN ToolbarFeature, WHEN start() is called THEN it should call presenter#start()`() {
+ val mockedPresenter: ToolbarPresenter = mock()
+ val feature = ToolbarFeature(mock(), mock(), mock()).apply {
+ controller = mock()
+ presenter = mockedPresenter
+ interactor = mock()
+ }
+
+ feature.start()
+
+ verify(mockedPresenter).start()
+ }
+
+ @Test
+ fun `GIVEN ToolbarFeature, WHEN start() is called THEN it should call interactor#start()`() {
+ val mockedInteractor: ToolbarInteractor = mock()
+ val feature = ToolbarFeature(mock(), mock(), mock()).apply {
+ controller = mock()
+ presenter = mock()
+ interactor = mockedInteractor
+ }
+
+ feature.start()
+
+ verify(mockedInteractor).start()
+ }
+
+ @Test
+ fun `GIVEN ToolbarFeature, WHEN stop() is called THEN it should call controller#stop()`() {
+ val mockedController: ToolbarBehaviorController = mock()
+ val feature = ToolbarFeature(mock(), mock(), mock()).apply {
+ controller = mockedController
+ }
+
+ feature.stop()
+
+ verify(mockedController).stop()
+ }
+
+ @Test
+ fun `GIVEN ToolbarFeature, WHEN stop() is called THEN it should call presenter#stop()`() {
+ val mockedPresenter: ToolbarPresenter = mock()
+ val feature = ToolbarFeature(mock(), mock(), mock()).apply {
+ presenter = mockedPresenter
+ }
+
+ feature.stop()
+
+ verify(mockedPresenter).stop()
+ }
+
+ @Test
+ fun `GIVEN ToolbarFeature, WHEN onBackPressed() is called THEN it should call toolbar#onBackPressed()`() {
+ val toolbar: Toolbar = mock()
+ val feature = ToolbarFeature(toolbar, store = mock(), loadUrlUseCase = mock())
+
+ feature.onBackPressed()
+
+ verify(toolbar).onBackPressed()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarInteractorTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarInteractorTest.kt
new file mode 100644
index 0000000000..3dcee39e7b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarInteractorTest.kt
@@ -0,0 +1,164 @@
+/* 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.feature.toolbar
+
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.session.SessionUseCases
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mockito.Mockito.spy
+
+class ToolbarInteractorTest {
+
+ class TestToolbar : Toolbar {
+ override var highlight: Toolbar.Highlight = Toolbar.Highlight.NONE
+ override var url: CharSequence = ""
+ override var siteSecure: Toolbar.SiteSecurity = Toolbar.SiteSecurity.INSECURE
+ override var private: Boolean = false
+ override var title: String = ""
+
+ override var siteTrackingProtection: Toolbar.SiteTrackingProtection =
+ Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+
+ override fun setSearchTerms(searchTerms: String) {
+ fail()
+ }
+
+ override fun displayProgress(progress: Int) {
+ fail()
+ }
+
+ override fun onBackPressed(): Boolean {
+ fail()
+ return false
+ }
+
+ override fun onStop() {
+ fail()
+ }
+
+ override fun setOnUrlCommitListener(listener: (String) -> Boolean) {
+ listener("https://mozilla.org")
+ }
+
+ override fun setAutocompleteListener(filter: suspend (String, AutocompleteDelegate) -> Unit) {
+ fail()
+ }
+
+ override fun addBrowserAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removeBrowserAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removePageAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun addPageAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun addNavigationAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removeNavigationAction(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun setOnEditListener(listener: Toolbar.OnEditListener) {
+ fail()
+ }
+
+ override fun displayMode() {
+ fail()
+ }
+
+ override fun editMode(cursorPlacement: Toolbar.CursorPlacement) {
+ fail()
+ }
+
+ override fun addEditActionStart(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun addEditActionEnd(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun removeEditActionEnd(action: Toolbar.Action) {
+ fail()
+ }
+
+ override fun hideMenuButton() {
+ fail()
+ }
+
+ override fun showMenuButton() {
+ fail()
+ }
+
+ override fun setDisplayHorizontalPadding(horizontalPadding: Int) {
+ fail()
+ }
+
+ override fun hidePageActionSeparator() {
+ fail()
+ }
+
+ override fun showPageActionSeparator() {
+ fail()
+ }
+
+ override fun invalidateActions() {
+ fail()
+ }
+
+ override fun dismissMenu() {
+ fail()
+ }
+
+ override fun enableScrolling() {
+ fail()
+ }
+
+ override fun disableScrolling() {
+ fail()
+ }
+
+ override fun collapse() {
+ fail()
+ }
+
+ override fun expand() {
+ fail()
+ }
+ }
+
+ @Test
+ fun `provide custom use case for loading url`() {
+ var useCaseInvokedWithUrl = ""
+ val loadUrlUseCase = object : SessionUseCases.LoadUrlUseCase {
+ override fun invoke(
+ url: String,
+ flags: EngineSession.LoadUrlFlags,
+ additionalHeaders: Map<String, String>?,
+ ) {
+ useCaseInvokedWithUrl = url
+ }
+ }
+
+ val toolbarInteractor = spy(ToolbarInteractor(TestToolbar(), loadUrlUseCase))
+ toolbarInteractor.start()
+
+ assertEquals("https://mozilla.org", useCaseInvokedWithUrl)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarPresenterTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarPresenterTest.kt
new file mode 100644
index 0000000000..00987ee1b9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/ToolbarPresenterTest.kt
@@ -0,0 +1,548 @@
+/* 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.feature.toolbar
+
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.ContentAction.UpdatePermissionHighlightsStateAction
+import mozilla.components.browser.state.action.ContentAction.UpdatePermissionHighlightsStateAction.NotificationChangedAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.TrackingProtectionAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ContentState
+import mozilla.components.browser.state.state.SecurityInfoState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.TrackingProtectionState
+import mozilla.components.browser.state.state.content.PermissionHighlightsState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.toolbar.internal.URLRenderer
+import mozilla.components.support.test.any
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+class ToolbarPresenterTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun `start with no custom tab id registers on store and renders selected tab`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "tab1")),
+ customTabs = listOf(createCustomTab("https://www.example.org", id = "ct")),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(store).observeManually(any())
+
+ verify(toolbarPresenter).render(any())
+
+ verify(toolbarPresenter.renderer).post("https://www.mozilla.org")
+ verify(toolbar).setSearchTerms("")
+ verify(toolbar).displayProgress(0)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.INSECURE
+ }
+
+ @Test
+ fun `start with custom tab id registers on store and renders custom tab`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "tab1")),
+ customTabs = listOf(createCustomTab("https://www.example.org", id = "ct")),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store, customTabId = "ct"))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(store).observeManually(any())
+ verify(toolbarPresenter).render(any())
+
+ verify(toolbarPresenter.renderer).post("https://www.example.org")
+ verify(toolbar).setSearchTerms("")
+ verify(toolbar).displayProgress(0)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.INSECURE
+ }
+
+ @Test
+ fun `SecurityInfoState change updates toolbar`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "tab1")),
+ customTabs = listOf(createCustomTab("https://www.example.org", id = "ct")),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar, never()).siteSecure = Toolbar.SiteSecurity.SECURE
+
+ store.dispatch(
+ ContentAction.UpdateSecurityInfoAction(
+ "tab1",
+ SecurityInfoState(
+ secure = true,
+ host = "mozilla.org",
+ issuer = "Mozilla",
+ ),
+ ),
+ ).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.SECURE
+ }
+
+ @Test
+ fun `Toolbar gets cleared when all tabs are removed`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "tab1",
+ content = ContentState(
+ url = "https://www.mozilla.org",
+ securityInfo = SecurityInfoState(true, "mozilla.org", "Mozilla"),
+ searchTerms = "Hello World",
+ progress = 60,
+ ),
+ ),
+ ),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbarPresenter.renderer).start()
+ verify(toolbarPresenter.renderer).post("https://www.mozilla.org")
+ verify(toolbar).setSearchTerms("Hello World")
+ verify(toolbar).displayProgress(60)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.SECURE
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+ verify(toolbar).highlight = Toolbar.Highlight.NONE
+ verifyNoMoreInteractions(toolbarPresenter.renderer)
+ verifyNoMoreInteractions(toolbar)
+
+ store.dispatch(TabListAction.RemoveTabAction("tab1")).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbarPresenter.renderer).post("")
+ verify(toolbar).setSearchTerms("")
+ verify(toolbar).displayProgress(0)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.INSECURE
+ }
+
+ @Test
+ fun `Search terms changes updates toolbar`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "tab1")),
+ customTabs = listOf(createCustomTab("https://www.example.org", id = "ct")),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar, never()).setSearchTerms("Hello World")
+
+ store.dispatch(
+ ContentAction.UpdateSearchTermsAction(
+ sessionId = "tab1",
+ searchTerms = "Hello World",
+ ),
+ ).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).setSearchTerms("Hello World")
+ }
+
+ @Test
+ fun `Progress changes updates toolbar`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "tab1")),
+ customTabs = listOf(createCustomTab("https://www.example.org", id = "ct")),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar, never()).displayProgress(75)
+
+ store.dispatch(
+ ContentAction.UpdateProgressAction("tab1", 75),
+ ).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).displayProgress(75)
+
+ verify(toolbar, never()).displayProgress(90)
+
+ store.dispatch(
+ ContentAction.UpdateProgressAction("tab1", 90),
+ ).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).displayProgress(90)
+ }
+
+ @Test
+ fun `Toolbar does not get cleared if a background tab gets removed`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "tab1",
+ content = ContentState(
+ url = "https://www.mozilla.org",
+ securityInfo = SecurityInfoState(true, "mozilla.org", "Mozilla"),
+ searchTerms = "Hello World",
+ progress = 60,
+ ),
+ ),
+ createTab(id = "tab2", url = "https://www.example.org"),
+ ),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ store.dispatch(TabListAction.RemoveTabAction("tab2")).joinBlocking()
+
+ verify(toolbarPresenter.renderer).start()
+ verify(toolbarPresenter.renderer).post("https://www.mozilla.org")
+ verify(toolbar).setSearchTerms("Hello World")
+ verify(toolbar).displayProgress(60)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.SECURE
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+ verify(toolbar).highlight = Toolbar.Highlight.NONE
+ verifyNoMoreInteractions(toolbarPresenter.renderer)
+ verifyNoMoreInteractions(toolbar)
+ }
+
+ @Test
+ fun `Toolbar is updated when selected tab changes`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "tab1",
+ content = ContentState(
+ url = "https://www.mozilla.org",
+ securityInfo = SecurityInfoState(true, "mozilla.org", "Mozilla"),
+ searchTerms = "Hello World",
+ progress = 60,
+ ),
+ ),
+ TabSessionState(
+ id = "tab2",
+ content = ContentState(
+ url = "https://www.example.org",
+ securityInfo = SecurityInfoState(false, "example.org", "Example"),
+ searchTerms = "Example",
+ permissionHighlights = PermissionHighlightsState(true),
+ progress = 90,
+ ),
+ trackingProtection = TrackingProtectionState(enabled = true),
+ ),
+ ),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbarPresenter.renderer).start()
+ verify(toolbarPresenter.renderer).post("https://www.mozilla.org")
+ verify(toolbar).setSearchTerms("Hello World")
+ verify(toolbar).displayProgress(60)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.SECURE
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+ verify(toolbar).highlight = Toolbar.Highlight.NONE
+ verifyNoMoreInteractions(toolbarPresenter.renderer)
+ verifyNoMoreInteractions(toolbar)
+
+ store.dispatch(TabListAction.SelectTabAction("tab2")).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbarPresenter.renderer).post("https://www.example.org")
+ verify(toolbar).setSearchTerms("Example")
+ verify(toolbar).displayProgress(90)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.INSECURE
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED
+ verify(toolbar).highlight = Toolbar.Highlight.PERMISSIONS_CHANGED
+ verifyNoMoreInteractions(toolbarPresenter.renderer)
+ verifyNoMoreInteractions(toolbar)
+ }
+
+ @Test
+ fun `displaying different tracking protection states`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "tab",
+ content = ContentState(
+ url = "https://www.mozilla.org",
+ securityInfo = SecurityInfoState(true, "mozilla.org", "Mozilla"),
+ searchTerms = "Hello World",
+ progress = 60,
+ ),
+ ),
+ ),
+ selectedTabId = "tab",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+
+ store.dispatch(TrackingProtectionAction.ToggleAction("tab", true))
+ .joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED
+
+ store.dispatch(TrackingProtectionAction.TrackerBlockedAction("tab", mock()))
+ .joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.ON_TRACKERS_BLOCKED
+
+ store.dispatch(TrackingProtectionAction.ToggleExclusionListAction("tab", true))
+ .joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.OFF_FOR_A_SITE
+ }
+
+ @Test
+ fun `displaying different dot notification states`() {
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ TabSessionState(
+ id = "tab",
+ content = ContentState(
+ url = "https://www.mozilla.org",
+ securityInfo = SecurityInfoState(true, "mozilla.org", "Mozilla"),
+ searchTerms = "Hello World",
+ progress = 60,
+ ),
+ ),
+ ),
+ selectedTabId = "tab",
+ ),
+ ),
+ )
+
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).highlight = Toolbar.Highlight.NONE
+
+ store.dispatch(NotificationChangedAction("tab", true)).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).highlight = Toolbar.Highlight.PERMISSIONS_CHANGED
+
+ store.dispatch(TrackingProtectionAction.ToggleExclusionListAction("tab", true))
+ .joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar, times(2)).highlight = Toolbar.Highlight.PERMISSIONS_CHANGED
+
+ store.dispatch(UpdatePermissionHighlightsStateAction.Reset("tab")).joinBlocking()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).highlight = Toolbar.Highlight.NONE
+ }
+
+ @Test
+ fun `Stopping presenter stops renderer`() {
+ val store = BrowserStore()
+ val presenter = ToolbarPresenter(mock(), store)
+
+ val renderer: URLRenderer = mock()
+ presenter.renderer = renderer
+
+ presenter.start()
+
+ verify(renderer, never()).stop()
+
+ presenter.stop()
+
+ verify(renderer).stop()
+ }
+
+ @Test
+ fun `Toolbar displays empty state without tabs`() {
+ val store = BrowserStore()
+ val toolbar: Toolbar = mock()
+ val presenter = ToolbarPresenter(toolbar, store)
+ presenter.renderer = mock()
+
+ presenter.start()
+
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(presenter.renderer).post("")
+ verify(toolbar).setSearchTerms("")
+ verify(toolbar).displayProgress(0)
+ verify(toolbar).siteSecure = Toolbar.SiteSecurity.INSECURE
+ verify(toolbar).siteTrackingProtection = Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+ verify(toolbar).highlight = Toolbar.Highlight.NONE
+ }
+
+ @Test
+ fun `GIVEN search terms should not be shown in display mode WHEN rendering a state with search terms set THEN toolbar url is the tab url`() {
+ val url = "https://www.mozilla.org"
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab(url, id = "tab1", searchTerms = "search terms")),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store, shouldDisplaySearchTerms = false))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbarPresenter.renderer).post(url)
+ }
+
+ @Test
+ fun `GIVEN search terms should be shown in display mode WHEN rendering a state with search terms set THEN toolbar url is set to the search terms`() {
+ val searchTerm = "mozilla firefox"
+ val toolbar: Toolbar = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab("https://www.mozilla.org", id = "tab1", searchTerms = searchTerm)),
+ selectedTabId = "tab1",
+ ),
+ ),
+ )
+ val toolbarPresenter = spy(ToolbarPresenter(toolbar, store, shouldDisplaySearchTerms = true))
+ toolbarPresenter.renderer = mock()
+
+ toolbarPresenter.start()
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(toolbar).url = searchTerm
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeatureTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeatureTest.kt
new file mode 100644
index 0000000000..a5e74a0284
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarFeatureTest.kt
@@ -0,0 +1,432 @@
+/* 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.feature.toolbar
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Looper
+import mozilla.components.browser.state.action.WebExtensionAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.engine.webextension.WebExtensionBrowserAction
+import mozilla.components.concept.engine.webextension.WebExtensionPageAction
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class WebExtensionToolbarFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val dispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun `render web extension actions from browser state`() {
+ val defaultPageAction =
+ WebExtensionPageAction("default_page_action_title", true, mock(), "", 0, 0) {}
+ val overriddenPageAction =
+ WebExtensionPageAction("overridden_page_action_title", true, mock(), "", 0, 0) {}
+ val defaultBrowserAction =
+ WebExtensionBrowserAction("default_browser_action_title", true, mock(), "", 0, 0) {}
+ val overriddenBrowserAction =
+ WebExtensionBrowserAction("overridden_browser_action_title", true, mock(), "", 0, 0) {}
+ val toolbar: Toolbar = mock()
+ val extensions: Map<String, WebExtensionState> = mapOf(
+ "id" to WebExtensionState("id", "url", "name", true, browserAction = defaultBrowserAction, pageAction = defaultPageAction),
+ )
+ val overriddenExtensions: Map<String, WebExtensionState> = mapOf(
+ "id" to WebExtensionState("id", "url", "name", true, browserAction = overriddenBrowserAction, pageAction = overriddenPageAction),
+ )
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(
+ "https://www.example.org",
+ id = "tab1",
+ extensions = overriddenExtensions,
+ ),
+ ),
+ selectedTabId = "tab1",
+ extensions = extensions,
+ ),
+ ),
+ )
+ val webExtToolbarFeature = getWebExtensionToolbarFeature(toolbar, store)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(store).observeManually(any())
+ verify(webExtToolbarFeature).renderWebExtensionActions(any(), any())
+
+ val browserActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ verify(toolbar).addBrowserAction(browserActionCaptor.capture())
+ assertEquals("overridden_browser_action_title", browserActionCaptor.value.action.title)
+
+ val pageActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ verify(toolbar).addPageAction(pageActionCaptor.capture())
+ assertEquals("overridden_page_action_title", pageActionCaptor.value.action.title)
+ }
+
+ @Test
+ fun `does not render actions from disabled extensions`() {
+ val enablePageAction =
+ WebExtensionPageAction("enable_page_action", true, mock(), "", 0, 0) {}
+ val disablePageAction =
+ WebExtensionPageAction("disable_page_action", true, mock(), "", 0, 0) {}
+ val enabledAction = WebExtensionBrowserAction("enable_browser_action", true, mock(), "", 0, 0) {}
+ val disabledAction = WebExtensionBrowserAction("disable_browser_action", true, mock(), "", 0, 0) {}
+ val toolbar: Toolbar = mock()
+ val extensions = mapOf(
+ "enabled" to WebExtensionState(
+ "enabled",
+ "url",
+ "name",
+ true,
+ browserAction = enabledAction,
+ pageAction = enablePageAction,
+ ),
+ "disabled" to WebExtensionState(
+ "disabled",
+ "url",
+ "name",
+ false,
+ browserAction = disabledAction,
+ pageAction = disablePageAction,
+ ),
+ )
+
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ ),
+ )
+ val webExtToolbarFeature = getWebExtensionToolbarFeature(toolbar, store)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(store).observeManually(any())
+ verify(webExtToolbarFeature, times(1)).renderWebExtensionActions(any(), any())
+ val browserActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ val pageActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ verify(toolbar, times(1)).addBrowserAction(browserActionCaptor.capture())
+
+ verify(toolbar, times(1)).addPageAction(pageActionCaptor.capture())
+ assertEquals("enable_browser_action", browserActionCaptor.value.action.title)
+ assertEquals("enable_page_action", pageActionCaptor.value.action.title)
+ }
+
+ @Test
+ fun `actions can be overridden per tab`() {
+ val webExtToolbarFeature = getWebExtensionToolbarFeature()
+
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+
+ val pageAction = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val pageActionOverride = Action(
+ title = "updatedTitle",
+ loadIcon = null,
+ enabled = true,
+ badgeText = "updatedText",
+ badgeTextColor = Color.RED,
+ badgeBackgroundColor = Color.GREEN,
+ ) {}
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val browserActionOverride = Action(
+ title = "updatedTitle",
+ loadIcon = null,
+ enabled = false,
+ badgeText = "updatedText",
+ badgeTextColor = Color.RED,
+ badgeBackgroundColor = Color.GREEN,
+ ) {}
+
+ // Verify rendering global default browser action
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ browserExtensions["1"] = WebExtensionState(id = "1", browserAction = browserAction, pageAction = pageAction)
+
+ val browserState = BrowserState(extensions = browserExtensions)
+ webExtToolbarFeature.renderWebExtensionActions(browserState, mock())
+
+ // verifying global browser action
+ assertEquals(1, webExtToolbarFeature.webExtensionBrowserActions.size)
+ var ext1 = webExtToolbarFeature.webExtensionBrowserActions["1"]
+ assertTrue(ext1?.action?.enabled!!)
+ assertEquals("badgeText", ext1.action.badgeText!!)
+ assertEquals("title", ext1.action.title!!)
+ assertEquals(loadIcon, ext1.action.loadIcon!!)
+ assertEquals(Color.WHITE, ext1.action.badgeTextColor!!)
+ assertEquals(Color.BLUE, ext1.action.badgeBackgroundColor!!)
+
+ // verifying global page action
+ assertEquals(1, webExtToolbarFeature.webExtensionPageActions.size)
+ ext1 = webExtToolbarFeature.webExtensionPageActions["1"]!!
+ assertTrue(ext1.action.enabled!!)
+ assertEquals("badgeText", ext1.action.badgeText!!)
+ assertEquals("title", ext1.action.title!!)
+ assertEquals(loadIcon, ext1.action.loadIcon!!)
+ assertEquals(Color.WHITE, ext1.action.badgeTextColor!!)
+ assertEquals(Color.BLUE, ext1.action.badgeBackgroundColor!!)
+
+ // Verify rendering session-specific actions override
+ val tabExtensions = HashMap<String, WebExtensionState>()
+ tabExtensions["1"] = WebExtensionState(id = "1", browserAction = browserActionOverride, pageAction = pageActionOverride)
+
+ val tabSessionState = TabSessionState(
+ content = mock(),
+ extensionState = tabExtensions,
+ )
+ webExtToolbarFeature.renderWebExtensionActions(browserState, tabSessionState)
+
+ // verifying session-specific browser action
+ assertEquals(1, webExtToolbarFeature.webExtensionBrowserActions.size)
+ var updatedExt1 = webExtToolbarFeature.webExtensionBrowserActions["1"]
+ assertFalse(updatedExt1?.action?.enabled!!)
+ assertEquals("updatedText", updatedExt1.action.badgeText!!)
+ assertEquals("updatedTitle", updatedExt1.action.title!!)
+ assertEquals(loadIcon, updatedExt1.action.loadIcon!!)
+ assertEquals(Color.RED, updatedExt1.action.badgeTextColor!!)
+ assertEquals(Color.GREEN, updatedExt1.action.badgeBackgroundColor!!)
+
+ // verifying session-specific page action
+ assertEquals(1, webExtToolbarFeature.webExtensionPageActions.size)
+ updatedExt1 = webExtToolbarFeature.webExtensionPageActions["1"]!!
+ assertTrue(updatedExt1.action.enabled!!)
+ assertEquals("updatedText", updatedExt1.action.badgeText!!)
+ assertEquals("updatedTitle", updatedExt1.action.title!!)
+ assertEquals(loadIcon, updatedExt1.action.loadIcon!!)
+ assertEquals(Color.RED, updatedExt1.action.badgeTextColor!!)
+ assertEquals(Color.GREEN, updatedExt1.action.badgeBackgroundColor!!)
+ }
+
+ @Test
+ fun `stale actions (from uninstalled or disabled extensions) are removed when feature is restarted`() {
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+ val browserAction = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val pageAction = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ browserExtensions["1"] =
+ WebExtensionState(id = "1", browserAction = browserAction, pageAction = pageAction)
+
+ browserExtensions["2"] =
+ WebExtensionState(id = "2", browserAction = browserAction, pageAction = pageAction)
+
+ val browserState = BrowserState(extensions = browserExtensions)
+ val store = BrowserStore(browserState)
+ val toolbar: Toolbar = mock()
+ val webExtToolbarFeature = getWebExtensionToolbarFeature(toolbar, store)
+
+ webExtToolbarFeature.renderWebExtensionActions(browserState, mock())
+ assertEquals(2, webExtToolbarFeature.webExtensionBrowserActions.size)
+ assertEquals(2, webExtToolbarFeature.webExtensionPageActions.size)
+
+ store.dispatch(WebExtensionAction.UninstallWebExtensionAction("1")).joinBlocking()
+ store.dispatch(WebExtensionAction.UpdateWebExtensionEnabledAction("2", false)).joinBlocking()
+
+ webExtToolbarFeature.start()
+ assertEquals(0, webExtToolbarFeature.webExtensionBrowserActions.size)
+ assertEquals(0, webExtToolbarFeature.webExtensionPageActions.size)
+
+ val browserActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ val pageActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ verify(toolbar, times(2)).removeBrowserAction(browserActionCaptor.capture())
+ verify(toolbar, times(2)).removePageAction(pageActionCaptor.capture())
+ assertEquals(browserAction, browserActionCaptor.value.action)
+ assertEquals(pageAction, pageActionCaptor.value.action)
+ }
+
+ @Test
+ fun `actions can are sorted per extension name`() {
+ val toolbar: Toolbar = mock()
+ val webExtToolbarFeature = getWebExtensionToolbarFeature(toolbar)
+
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+
+ val actionExt1 = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val actionExt2 = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ browserExtensions["1"] = WebExtensionState(id = "1", name = "extensionA", browserAction = actionExt1)
+ browserExtensions["2"] = WebExtensionState(id = "2", name = "extensionB", pageAction = actionExt2)
+
+ val browserState = BrowserState(extensions = browserExtensions)
+ webExtToolbarFeature.renderWebExtensionActions(browserState, mock())
+
+ val inOrder = inOrder(toolbar)
+ val browserActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ inOrder.verify(toolbar).addBrowserAction(browserActionCaptor.capture())
+ assertEquals(actionExt1, browserActionCaptor.value.action)
+
+ val pageActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ inOrder.verify(toolbar).addPageAction(pageActionCaptor.capture())
+ assertEquals(actionExt2, pageActionCaptor.value.action)
+ }
+
+ @Test
+ fun `renderWebExtensionActions depends on allowedInPrivateBrowsing and whether the current tab is private`() {
+ val toolbar: Toolbar = mock()
+ val webExtToolbarFeature = getWebExtensionToolbarFeature(toolbar)
+ val loadIcon: (suspend (Int) -> Bitmap?)? = { mock() }
+
+ val actionExt1 = Action(
+ title = "title",
+ loadIcon = loadIcon,
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val tabSessionState = TabSessionState(
+ content = mock(),
+ extensionState = emptyMap(),
+ )
+
+ whenever(tabSessionState.content.private).thenReturn(true)
+ val browserActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+
+ val browserExtensions = HashMap<String, WebExtensionState>()
+ browserExtensions["1"] =
+ WebExtensionState(id = "1", name = "extensionA", browserAction = actionExt1)
+ val browserState = BrowserState(extensions = browserExtensions)
+ webExtToolbarFeature.renderWebExtensionActions(browserState, tabSessionState)
+ verify(toolbar, never()).addBrowserAction(browserActionCaptor.capture())
+
+ val browserExtensionsAllowedInPrivateBrowsing = HashMap<String, WebExtensionState>()
+ browserExtensionsAllowedInPrivateBrowsing["1"] =
+ WebExtensionState(id = "1", allowedInPrivateBrowsing = true, name = "extensionA", browserAction = actionExt1)
+ val browserStateAllowedInPrivateBrowsing = BrowserState(extensions = browserExtensionsAllowedInPrivateBrowsing)
+ webExtToolbarFeature.renderWebExtensionActions(browserStateAllowedInPrivateBrowsing, tabSessionState)
+ verify(toolbar, times(1)).addBrowserAction(browserActionCaptor.capture())
+ assertEquals(actionExt1, browserActionCaptor.value.action)
+ }
+
+ @Test
+ fun `disabled page actions are not rendered`() {
+ val enablePageAction =
+ WebExtensionPageAction("enable_page_action", true, mock(), "", 0, 0) {}
+ val disablePageAction =
+ WebExtensionPageAction("disable_page_action", false, mock(), "", 0, 0) {}
+ val toolbar: Toolbar = mock()
+ val extensions = mapOf(
+ "ext1" to WebExtensionState(
+ "ext1",
+ "url",
+ "name",
+ true,
+ pageAction = enablePageAction,
+ ),
+ "ext2" to WebExtensionState(
+ "ext2",
+ "url",
+ "name",
+ true,
+ pageAction = disablePageAction,
+ ),
+ )
+
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ extensions = extensions,
+ ),
+ ),
+ )
+ val webExtToolbarFeature = getWebExtensionToolbarFeature(toolbar, store)
+ dispatcher.scheduler.advanceUntilIdle()
+
+ verify(store).observeManually(any())
+ verify(webExtToolbarFeature).renderWebExtensionActions(any(), any())
+
+ val pageActionCaptor = argumentCaptor<WebExtensionToolbarAction>()
+ verify(toolbar, times(1)).addPageAction(pageActionCaptor.capture())
+ assertEquals("enable_page_action", pageActionCaptor.value.action.title)
+ }
+
+ private fun getWebExtensionToolbarFeature(
+ toolbar: Toolbar = mock(),
+ store: BrowserStore = BrowserStore(),
+ ): WebExtensionToolbarFeature {
+ val webExtToolbarFeature = spy(WebExtensionToolbarFeature(toolbar, store))
+ val handler: Handler = mock()
+ val looper: Looper = mock()
+ val iconThread: HandlerThread = mock()
+
+ doReturn(looper).`when`(iconThread).looper
+ doReturn(iconThread).`when`(webExtToolbarFeature).iconThread
+ doReturn(handler).`when`(webExtToolbarFeature).iconHandler
+ webExtToolbarFeature.start()
+ return webExtToolbarFeature
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarTest.kt
new file mode 100644
index 0000000000..c467e0b9bb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/WebExtensionToolbarTest.kt
@@ -0,0 +1,179 @@
+/* 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.feature.toolbar
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.BitmapDrawable
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.delay
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import mozilla.components.ui.icons.R as iconsR
+
+@RunWith(AndroidJUnit4::class)
+class WebExtensionToolbarTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+ private val testDispatcher = coroutinesTestRule.testDispatcher
+
+ @Test
+ fun bind() {
+ val icon: Bitmap = mock()
+ val imageView: ImageView = mock()
+ val textView: TextView = mock()
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(textView)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { icon },
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionToolbarAction(browserAction, iconJobDispatcher = testDispatcher) {}
+ action.bind(view)
+ action.iconJob?.joinBlocking()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ val iconCaptor = argumentCaptor<BitmapDrawable>()
+ verify(imageView).setImageDrawable(iconCaptor.capture())
+ assertEquals(icon, iconCaptor.value.bitmap)
+
+ verify(imageView).contentDescription = "title"
+ verify(textView).setText("badgeText")
+ verify(textView).setTextColor(Color.WHITE)
+ verify(textView).setBackgroundColor(Color.BLUE)
+ }
+
+ @Test
+ fun fallbackToDefaultIcon() {
+ val imageView: ImageView = mock()
+ val textView: TextView = mock()
+ val view: View = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(textView)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { throw IllegalArgumentException() },
+ enabled = true,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionToolbarAction(browserAction, iconJobDispatcher = testDispatcher) {}
+ action.bind(view)
+ action.iconJob?.joinBlocking()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ verify(imageView).setImageResource(
+ iconsR.drawable.mozac_ic_web_extension_default_icon,
+ )
+ }
+
+ @Test
+ fun createView() {
+ var listenerWasClicked = false
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = { mock() },
+ enabled = false,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionToolbarAction(
+ browserAction,
+ padding = Padding(1, 2, 3, 4),
+ iconJobDispatcher = testDispatcher,
+ ) {
+ listenerWasClicked = true
+ }
+
+ val rootView = action.createView(LinearLayout(testContext))
+ rootView.performClick()
+
+ assertFalse(rootView.isEnabled)
+ assertTrue(listenerWasClicked)
+ assertEquals(rootView.paddingLeft, 1)
+ assertEquals(rootView.paddingTop, 2)
+ assertEquals(rootView.paddingRight, 3)
+ assertEquals(rootView.paddingBottom, 4)
+ }
+
+ @Test
+ fun cancelLoadIconWhenViewIsDetached() {
+ val view: View = mock()
+ val imageView: ImageView = mock()
+ val textView: TextView = mock()
+
+ whenever(view.findViewById<ImageView>(R.id.action_image)).thenReturn(imageView)
+ whenever(view.findViewById<TextView>(R.id.badge_text)).thenReturn(textView)
+ whenever(view.context).thenReturn(mock())
+
+ val browserAction = Action(
+ title = "title",
+ loadIcon = @Suppress("UNREACHABLE_CODE") {
+ while (true) { delay(10) }
+ mock()
+ },
+ enabled = false,
+ badgeText = "badgeText",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ ) {}
+
+ val action = WebExtensionToolbarAction(
+ browserAction,
+ padding = Padding(1, 2, 3, 4),
+ iconJobDispatcher = testDispatcher,
+ ) {}
+
+ val attachListenerCaptor = argumentCaptor<View.OnAttachStateChangeListener>()
+ val parent = spy(LinearLayout(testContext))
+ action.createView(parent)
+ verify(parent).addOnAttachStateChangeListener(attachListenerCaptor.capture())
+
+ action.bind(view)
+ assertNotNull(action.iconJob)
+ assertFalse(action.iconJob?.isCancelled!!)
+
+ attachListenerCaptor.value.onViewDetachedFromWindow(parent)
+ testDispatcher.scheduler.advanceUntilIdle()
+ assertTrue(action.iconJob?.isCancelled!!)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/internal/URLRendererTest.kt b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/internal/URLRendererTest.kt
new file mode 100644
index 0000000000..0983d38ee7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/java/mozilla/components/feature/toolbar/internal/URLRendererTest.kt
@@ -0,0 +1,106 @@
+/* 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.feature.toolbar.internal
+
+import android.graphics.Color
+import android.text.SpannableStringBuilder
+import android.text.style.ForegroundColorSpan
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.Dispatchers
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.toolbar.ToolbarFeature
+import mozilla.components.lib.publicsuffixlist.PublicSuffixList
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.rule.runTestOnMain
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class URLRendererTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `Lifecycle methods start and stop job`() {
+ val renderer = URLRenderer(mock(), mock())
+
+ assertNull(renderer.job)
+
+ renderer.start()
+
+ assertNotNull(renderer.job)
+ assertTrue(renderer.job!!.isActive)
+
+ renderer.stop()
+
+ assertNotNull(renderer.job)
+ assertFalse(renderer.job!!.isActive)
+ }
+
+ @Test
+ fun `Render with configuration`() {
+ runTestOnMain {
+ val configuration = ToolbarFeature.UrlRenderConfiguration(
+ publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
+ registrableDomainColor = Color.RED,
+ urlColor = Color.GREEN,
+ )
+
+ val toolbar: Toolbar = mock()
+
+ val renderer = URLRenderer(toolbar, configuration)
+
+ renderer.updateUrl("https://www.mozilla.org/")
+
+ val captor = argumentCaptor<CharSequence>()
+ verify(toolbar).url = captor.capture()
+
+ assertNotNull(captor.value)
+ assertTrue(captor.value is SpannableStringBuilder)
+ val url = captor.value as SpannableStringBuilder
+
+ assertEquals("https://www.mozilla.org/", url.toString())
+
+ val spans = url.getSpans(0, url.length, ForegroundColorSpan::class.java)
+
+ assertEquals(2, spans.size)
+ assertEquals(Color.GREEN, spans[0].foregroundColor)
+ assertEquals(Color.RED, spans[1].foregroundColor)
+
+ val domain = url.subSequence(12, 23)
+ assertEquals("mozilla.org", domain.toString())
+
+ val domainSpans = url.getSpans(13, 23, ForegroundColorSpan::class.java)
+ assertEquals(2, domainSpans.size)
+ assertEquals(Color.GREEN, domainSpans[0].foregroundColor)
+ assertEquals(Color.RED, domainSpans[1].foregroundColor)
+
+ val prefix = url.subSequence(0, 12)
+ assertEquals("https://www.", prefix.toString())
+
+ val prefixSpans = url.getSpans(0, 12, ForegroundColorSpan::class.java)
+ assertEquals(1, prefixSpans.size)
+ assertEquals(Color.GREEN, prefixSpans[0].foregroundColor)
+
+ val suffix = url.subSequence(23, url.length)
+ assertEquals("/", suffix.toString())
+
+ val suffixSpans = url.getSpans(23, url.length, ForegroundColorSpan::class.java)
+ assertEquals(1, suffixSpans.size)
+ assertEquals(Color.GREEN, suffixSpans[0].foregroundColor)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/toolbar/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,2 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)
diff --git a/mobile/android/android-components/components/feature/toolbar/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/toolbar/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/toolbar/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28