diff options
Diffstat (limited to 'mobile/android/android-components/components/feature/toolbar')
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 |