diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/browser/engine-gecko | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip |
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/browser/engine-gecko')
92 files changed, 24822 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/browser/engine-gecko/README.md b/mobile/android/android-components/components/browser/engine-gecko/README.md new file mode 100644 index 0000000000..2071f57f5e --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/README.md @@ -0,0 +1,41 @@ +# [Android Components](../../../README.md) > Browser > Engine-Gecko + +[*Engine*](../../concept/engine/README.md) implementation based on [GeckoView](https://wiki.mozilla.org/Mobile/GeckoView). + +## 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:browser-engine-gecko:{latest-version}" +``` + +### Integration with the Glean SDK + +#### Before using this component +Products sending telemetry and using this component *must request* a data-review following [this process](https://wiki.mozilla.org/Firefox/Data_Collection). + +The [Glean SDK](../../../components/service/glean/README.md) can be used to collect [Gecko Telemetry](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/index.html). +Applications using both this component and the Glean SDK should setup the Gecko Telemetry delegate +as shown below: + +```Kotlin + val builder = GeckoRuntimeSettings.Builder() + val runtimeSettings = builder + .telemetryDelegate(GeckoGleanAdapter()) // Sets up the delegate! + .build() + // Create the Gecko runtime. + GeckoRuntime.create(context, runtimeSettings) +``` + +#### Adding new metrics + +New Gecko metrics can be added as described [in the Firefox Telemetry docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/start/adding-a-new-probe.html). + +## 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/browser/engine-gecko/build.gradle b/mobile/android/android-components/components/browser/engine-gecko/build.gradle new file mode 100644 index 0000000000..4e5c2fda65 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/build.gradle @@ -0,0 +1,193 @@ +/* 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/. */ + +buildscript { + repositories { + gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORIES.each { repository -> + maven { + url repository + if (gradle.mozconfig.substs.ALLOW_INSECURE_GRADLE_REPOSITORIES) { + allowInsecureProtocol = true + } + } + } + } + + dependencies { + classpath "${ApplicationServicesConfig.groupId}:tooling-nimbus-gradle:${ApplicationServicesConfig.version}" + classpath "org.mozilla.telemetry:glean-gradle-plugin:${Versions.mozilla_glean}" + } +} + +plugins { + id "com.jetbrains.python.envs" version "$python_envs_plugin" +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + resources { + excludes += ['META-INF/proguard/androidx-annotations.pro'] + } + } + + buildFeatures { + buildConfig true + } + + namespace 'mozilla.components.browser.engine.gecko' +} + +// Set configuration for the Glean parser to extract metrics.yaml +// file from AAR dependencies of this project rather than look +// for it into the project directory. +ext.allowMetricsFromAAR = true + +dependencies { + implementation project(':concept-engine') + implementation project(':concept-fetch') + implementation project(':support-ktx') + implementation project(':support-utils') + implementation(project(':service-nimbus')) { + exclude group: 'org.mozilla.telemetry', module: 'glean-native' + } + implementation ComponentsDependencies.kotlin_coroutines + + if (findProject(":geckoview") != null) { + api project(':geckoview') + } else { + api getGeckoViewDependency() + } + + implementation ComponentsDependencies.androidx_paging + implementation ComponentsDependencies.androidx_data_store_preferences + implementation ComponentsDependencies.androidx_lifecycle_livedata + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + testImplementation ComponentsDependencies.testing_mockwebserver + testImplementation project(':support-test') + testImplementation project(':tooling-fetch-tests') + + // We only compile against Glean. It's up to the app to add those dependencies + // if it wants to collect GeckoView telemetry through the Glean SDK. + compileOnly project(":service-glean") + testImplementation project(":service-glean") + testImplementation ComponentsDependencies.androidx_work_testing + + androidTestImplementation ComponentsDependencies.androidx_test_core + androidTestImplementation ComponentsDependencies.androidx_test_runner + androidTestImplementation ComponentsDependencies.androidx_test_rules + androidTestImplementation project(':tooling-fetch-tests') +} + +apply plugin: "org.mozilla.telemetry.glean-gradle-plugin" +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +apply plugin: "org.mozilla.appservices.nimbus-gradle-plugin" +nimbus { + // The path to the Nimbus feature manifest file + manifestFile = "geckoview.fml.yaml" + + channels = [ + debug: "debug", + release: "release", + ] + + // This is an optional value, and updates the plugin to use a copy of application + // services. The path should be relative to the root project directory. + // *NOTE*: This example will not work for all projects, but should work for Fenix, Focus, and Android Components + applicationServicesDir = gradle.hasProperty('localProperties.autoPublish.application-services.dir') + ? gradle.getProperty('localProperties.autoPublish.application-services.dir') : null +} +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) + +// Non-official versions are like "61.0a1", where "a1" is the milestone. +// This simply strips that off, leaving "61.0" in this example. +def getAppVersionWithoutMilestone() { + return gradle.mozconfig.substs.MOZ_APP_VERSION.replaceFirst(/a[0-9]/, "") +} + +// Mimic Python: open(os.path.join(buildconfig.topobjdir, 'buildid.h')).readline().split()[2] +def getBuildId() { + if (System.env.MOZ_BUILD_DATE) { + if (System.env.MOZ_BUILD_DATE.length() == 14) { + return System.env.MOZ_BUILD_DATE + } + logger.warn("Ignoring invalid MOZ_BUILD_DATE: ${System.env.MOZ_BUILD_DATE}") + } + return file("${gradle.mozconfig.topobjdir}/buildid.h").getText('utf-8').split()[2] +} + +def getVersionNumber() { + def appVersion = getAppVersionWithoutMilestone() + def parts = appVersion.split('\\.') + def version = parts[0] + "." + parts[1] + "." + getBuildId() + + if (!gradle.mozconfig.substs.MOZILLA_OFFICIAL && !gradle.mozconfig.substs.MOZ_ANDROID_FAT_AAR_ARCHITECTURES) { + // Use -SNAPSHOT versions locally to enable the local GeckoView substitution flow. + version += "-SNAPSHOT" + } + + return version +} + +def getArtifactSuffix() { + def suffix = "" + + // Release artifacts don't specify the channel, for the sake of simplicity. + if (gradle.mozconfig.substs.MOZ_UPDATE_CHANNEL != 'release') { + suffix += "-${gradle.mozconfig.substs.MOZ_UPDATE_CHANNEL}" + } + + return suffix +} + +def getArtifactId() { + def id = "geckoview" + getArtifactSuffix() + + if (!gradle.mozconfig.substs.MOZ_ANDROID_GECKOVIEW_LITE) { + id += "-omni" + } + + if (gradle.mozconfig.substs.MOZILLA_OFFICIAL && !gradle.mozconfig.substs.MOZ_ANDROID_FAT_AAR_ARCHITECTURES) { + // In automation, per-architecture artifacts identify + // the architecture; multi-architecture artifacts don't. + // When building locally, we produce a "skinny AAR" with + // one target architecture masquerading as a "fat AAR" + // to enable Gradle composite builds to substitute this + // project into consumers easily. + id += "-${gradle.mozconfig.substs.ANDROID_CPU_ARCH}" + } + + return id +} + +def getGeckoViewDependency() { + // on try, relax geckoview version pin to allow for --use-existing-task + if ('https://hg.mozilla.org/try' == System.env.GECKO_HEAD_REPOSITORY) { + rootProject.logger.lifecycle("Getting geckoview on try: ${getArtifactId()}:+") + return "org.mozilla.geckoview:${getArtifactId()}:+" + } + rootProject.logger.lifecycle("Getting geckoview: ${getArtifactId()}:${getVersionNumber()}") + return "org.mozilla.geckoview:${getArtifactId()}:${getVersionNumber()}" +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/docs/metrics.md b/mobile/android/android-components/components/browser/engine-gecko/docs/metrics.md new file mode 100644 index 0000000000..203d71a2b3 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/docs/metrics.md @@ -0,0 +1,8 @@ +# Metrics definitions have moved + +Metrics definitions for projects using `engine-gecko` moved to the Glean Dictionary. + +For Firefox for Android those definitions can be found at: +[https://dictionary.telemetry.mozilla.org/apps/fenix](https://dictionary.telemetry.mozilla.org/apps/fenix) + +This file is kept only for historical reference. diff --git a/mobile/android/android-components/components/browser/engine-gecko/geckoview.fml.yaml b/mobile/android/android-components/components/browser/engine-gecko/geckoview.fml.yaml new file mode 100644 index 0000000000..bf6ccecd88 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/geckoview.fml.yaml @@ -0,0 +1,24 @@ +# 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/. +about: + description: GeckoView features configurable via Nimbus + android: + package: mozilla.components.browser.engine.gecko + class: .GeckoNimbus +channels: + - debug + - release +features: + pdfjs: + description: "PDF.js features" + variables: + download-button: + description: "Download button" + type: Boolean + default: true + + open-in-app-button: + description: "Open in app button" + type: Boolean + default: true diff --git a/mobile/android/android-components/components/browser/engine-gecko/metrics.yaml b/mobile/android/android-components/components/browser/engine-gecko/metrics.yaml new file mode 100644 index 0000000000..4775ce544d --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/metrics.yaml @@ -0,0 +1,30 @@ +# 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/. + +# IMPORTANT NOTE: this file is here only as a safety measure, to make +# sure the correct code is generated even though the GeckoView AAR file +# reports an empty metrics.yaml file. The metric in this file is currently +# disabled and not supposed to collect any data. +--- + +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + +test.glean.geckoview: + streaming: + type: timing_distribution + gecko_datapoint: TELEMETRY_TEST_STREAMING + disabled: true + description: | + A test-only, disabled metric. This is required to guarantee + that a `GleanGeckoHistogramMapping` is always generated, even + though the GeckoView AAR exports no metric. Please note that + the data-review field below contains no review, since this + metric is disabled and not allowed to collect any data. + bugs: + - https://bugzilla.mozilla.org/1566374 + data_reviews: + - https://bugzilla.mozilla.org/1566374 + notification_emails: + - glean-team@mozilla.com + expires: never diff --git a/mobile/android/android-components/components/browser/engine-gecko/proguard-rules.pro b/mobile/android/android-components/components/browser/engine-gecko/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/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/browser/engine-gecko/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt b/mobile/android/android-components/components/browser/engine-gecko/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt new file mode 100644 index 0000000000..38e0a1586f --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.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.browser.engine.gecko.fetch.geckoview + +import androidx.test.annotation.UiThreadTest +import androidx.test.core.app.ApplicationProvider +import androidx.test.filters.MediumTest +import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient +import mozilla.components.concept.fetch.Client +import org.junit.Assert.assertTrue +import org.junit.Test + +@MediumTest +class GeckoViewFetchTestCases : mozilla.components.tooling.fetch.tests.FetchTestCases() { + override fun createNewClient(): Client = GeckoViewFetchClient(ApplicationProvider.getApplicationContext()) + + @Test + @UiThreadTest + fun clientInstance() { + assertTrue(createNewClient() is GeckoViewFetchClient) + } + + @Test + @UiThreadTest + override fun get200WithGzippedBody() { + super.get200WithGzippedBody() + } + + @Test + @UiThreadTest + override fun get200OverridingDefaultHeaders() { + super.get200OverridingDefaultHeaders() + } + + @Test + @UiThreadTest + override fun get200WithDuplicatedCacheControlRequestHeaders() { + super.get200WithDuplicatedCacheControlRequestHeaders() + } + + @Test + @UiThreadTest + override fun get200WithDuplicatedCacheControlResponseHeaders() { + super.get200WithDuplicatedCacheControlResponseHeaders() + } + + @Test + @UiThreadTest + override fun get200WithHeaders() { + super.get200WithHeaders() + } + + @Test + @UiThreadTest + override fun get200WithReadTimeout() { + super.get200WithReadTimeout() + } + + @Test + @UiThreadTest + override fun get200WithStringBody() { + super.get200WithStringBody() + } + + @Test + @UiThreadTest + override fun get302FollowRedirects() { + super.get302FollowRedirects() + } + + @Test + @UiThreadTest + override fun get302FollowRedirectsDisabled() { + super.get302FollowRedirectsDisabled() + } + + @Test + @UiThreadTest + override fun get404WithBody() { + super.get404WithBody() + } + + @Test + @UiThreadTest + override fun post200WithBody() { + super.post200WithBody() + } + + @Test + @UiThreadTest + override fun put201FileUpload() { + super.put201FileUpload() + } + + @Test + @UiThreadTest + override fun get200WithCookiePolicy() { + super.get200WithCookiePolicy() + } + + @Test + @UiThreadTest + override fun get200WithContentTypeCharset() { + super.get200WithContentTypeCharset() + } + + @Test + @UiThreadTest + override fun get200WithCacheControl() { + super.get200WithCacheControl() + } + + @Test + @UiThreadTest + override fun getThrowsIOExceptionWhenHostNotReachable() { + super.getThrowsIOExceptionWhenHostNotReachable() + } + + @Test + @UiThreadTest + override fun getDataUri() { + super.getDataUri() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/AndroidManifest.xml b/mobile/android/android-components/components/browser/engine-gecko/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- 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 /> diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt new file mode 100644 index 0000000000..92e6074a61 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt @@ -0,0 +1,1502 @@ +/* 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.browser.engine.gecko + +import android.content.Context +import android.os.Parcelable +import android.util.AttributeSet +import android.util.JsonReader +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.engine.gecko.activity.GeckoActivityDelegate +import mozilla.components.browser.engine.gecko.activity.GeckoScreenOrientationDelegate +import mozilla.components.browser.engine.gecko.ext.getAntiTrackingPolicy +import mozilla.components.browser.engine.gecko.ext.getEtpLevel +import mozilla.components.browser.engine.gecko.ext.getStrictSocialTrackingProtection +import mozilla.components.browser.engine.gecko.integration.LocaleSettingUpdater +import mozilla.components.browser.engine.gecko.mediaquery.from +import mozilla.components.browser.engine.gecko.mediaquery.toGeckoValue +import mozilla.components.browser.engine.gecko.profiler.Profiler +import mozilla.components.browser.engine.gecko.serviceworker.GeckoServiceWorkerDelegate +import mozilla.components.browser.engine.gecko.translate.GeckoTranslationUtils.intoTranslationError +import mozilla.components.browser.engine.gecko.util.SpeculativeSessionFactory +import mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension +import mozilla.components.browser.engine.gecko.webextension.GeckoWebExtensionException +import mozilla.components.browser.engine.gecko.webnotifications.GeckoWebNotificationDelegate +import mozilla.components.browser.engine.gecko.webpush.GeckoWebPushDelegate +import mozilla.components.browser.engine.gecko.webpush.GeckoWebPushHandler +import mozilla.components.concept.engine.CancellableOperation +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingMode +import mozilla.components.concept.engine.EngineSession.SafeBrowsingPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory +import mozilla.components.concept.engine.EngineSessionState +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.Settings +import mozilla.components.concept.engine.activity.ActivityDelegate +import mozilla.components.concept.engine.activity.OrientationDelegate +import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage +import mozilla.components.concept.engine.history.HistoryTrackingDelegate +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import mozilla.components.concept.engine.serviceworker.ServiceWorkerDelegate +import mozilla.components.concept.engine.translate.Language +import mozilla.components.concept.engine.translate.LanguageModel +import mozilla.components.concept.engine.translate.LanguageSetting +import mozilla.components.concept.engine.translate.ModelManagementOptions +import mozilla.components.concept.engine.translate.TranslationError +import mozilla.components.concept.engine.translate.TranslationSupport +import mozilla.components.concept.engine.translate.TranslationsRuntime +import mozilla.components.concept.engine.utils.EngineVersion +import mozilla.components.concept.engine.webextension.Action +import mozilla.components.concept.engine.webextension.ActionHandler +import mozilla.components.concept.engine.webextension.EnableSource +import mozilla.components.concept.engine.webextension.InstallationMethod +import mozilla.components.concept.engine.webextension.TabHandler +import mozilla.components.concept.engine.webextension.WebExtension +import mozilla.components.concept.engine.webextension.WebExtensionDelegate +import mozilla.components.concept.engine.webextension.WebExtensionInstallException +import mozilla.components.concept.engine.webextension.WebExtensionRuntime +import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate +import mozilla.components.concept.engine.webpush.WebPushDelegate +import mozilla.components.concept.engine.webpush.WebPushHandler +import mozilla.components.support.ktx.kotlin.isResourceUrl +import mozilla.components.support.utils.ThreadUtils +import org.json.JSONObject +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.ContentBlocking +import org.mozilla.geckoview.ContentBlockingController +import org.mozilla.geckoview.ContentBlockingController.Event +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoRuntimeSettings +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoWebExecutor +import org.mozilla.geckoview.TranslationsController +import org.mozilla.geckoview.WebExtensionController +import org.mozilla.geckoview.WebNotification +import java.lang.ref.WeakReference + +/** + * Gecko-based implementation of Engine interface. + */ +@Suppress("LargeClass", "TooManyFunctions") +class GeckoEngine( + context: Context, + private val defaultSettings: Settings? = null, + private val runtime: GeckoRuntime = GeckoRuntime.getDefault(context), + executorProvider: () -> GeckoWebExecutor = { GeckoWebExecutor(runtime) }, + override val trackingProtectionExceptionStore: TrackingProtectionExceptionStorage = + GeckoTrackingProtectionExceptionStorage(runtime), +) : Engine, WebExtensionRuntime, TranslationsRuntime { + private val executor by lazy { executorProvider.invoke() } + private val localeUpdater = LocaleSettingUpdater(context, runtime) + + @VisibleForTesting internal val speculativeConnectionFactory = SpeculativeSessionFactory() + private var webExtensionDelegate: WebExtensionDelegate? = null + private val webExtensionActionHandler = object : ActionHandler { + override fun onBrowserAction(extension: WebExtension, session: EngineSession?, action: Action) { + webExtensionDelegate?.onBrowserActionDefined(extension, action) + } + + override fun onPageAction(extension: WebExtension, session: EngineSession?, action: Action) { + webExtensionDelegate?.onPageActionDefined(extension, action) + } + + override fun onToggleActionPopup(extension: WebExtension, action: Action): EngineSession? { + return webExtensionDelegate?.onToggleActionPopup( + extension, + GeckoEngineSession( + runtime, + defaultSettings = defaultSettings, + ), + action, + ) + } + } + private val webExtensionTabHandler = object : TabHandler { + override fun onNewTab(webExtension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) { + webExtensionDelegate?.onNewTab(webExtension, engineSession, active, url) + } + } + + private var webPushHandler: WebPushHandler? = null + + init { + runtime.delegate = GeckoRuntime.Delegate { + // On shutdown: The runtime is shutting down (possibly because of an unrecoverable error state). We crash + // the app here for two reasons: + // - We want to know about those unsolicited shutdowns and fix those issues. + // - We can't recover easily from this situation. Just continuing will leave us with an engine that + // doesn't do anything anymore. + @Suppress("TooGenericExceptionThrown") + throw RuntimeException("GeckoRuntime is shutting down") + } + } + + /** + * Fetch a list of trackers logged for a given [session] . + * + * @param session the session where the trackers were logged. + * @param onSuccess callback invoked if the data was fetched successfully. + * @param onError (optional) callback invoked if fetching the data caused an exception. + */ + override fun getTrackersLog( + session: EngineSession, + onSuccess: (List<TrackerLog>) -> Unit, + onError: (Throwable) -> Unit, + ) { + val geckoSession = (session as GeckoEngineSession).geckoSession + runtime.contentBlockingController.getLog(geckoSession).then( + { contentLogList -> + val list = contentLogList ?: emptyList() + val logs = list.map { logEntry -> + logEntry.toTrackerLog() + }.filterNot { + !it.cookiesHasBeenBlocked && + it.blockedCategories.isEmpty() && + it.loadedCategories.isEmpty() + } + + onSuccess(logs) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * Creates a new Gecko-based EngineView. + */ + override fun createView(context: Context, attrs: AttributeSet?): EngineView { + return GeckoEngineView(context, attrs).apply { + setColorScheme(settings.preferredColorScheme) + } + } + + /** + * See [Engine.createSession]. + */ + override fun createSession(private: Boolean, contextId: String?): EngineSession { + ThreadUtils.assertOnUiThread() + val speculativeSession = speculativeConnectionFactory.get(private, contextId) + return speculativeSession ?: GeckoEngineSession(runtime, private, defaultSettings, contextId) + } + + /** + * See [Engine.createSessionState]. + */ + override fun createSessionState(json: JSONObject): EngineSessionState { + return GeckoEngineSessionState.fromJSON(json) + } + + /** + * See [Engine.createSessionStateFrom]. + */ + override fun createSessionStateFrom(reader: JsonReader): EngineSessionState { + return GeckoEngineSessionState.from(reader) + } + + /** + * See [Engine.speculativeCreateSession]. + */ + override fun speculativeCreateSession(private: Boolean, contextId: String?) { + ThreadUtils.assertOnUiThread() + speculativeConnectionFactory.create(runtime, private, contextId, defaultSettings) + } + + /** + * See [Engine.clearSpeculativeSession]. + */ + override fun clearSpeculativeSession() { + speculativeConnectionFactory.clear() + } + + /** + * Opens a speculative connection to the host of [url]. + * + * This is useful if an app thinks it may be making a request to that host in the near future. If no request + * is made, the connection will be cleaned up after an unspecified. + */ + override fun speculativeConnect(url: String) { + executor.speculativeConnect(url) + } + + /** + * See [Engine.installBuiltInWebExtension]. + */ + override fun installBuiltInWebExtension( + id: String, + url: String, + onSuccess: ((WebExtension) -> Unit), + onError: ((Throwable) -> Unit), + ): CancellableOperation { + require(url.isResourceUrl()) { "url should be a resource url" } + + val geckoResult = runtime.webExtensionController.ensureBuiltIn(url, id).apply { + then( + { + onExtensionInstalled(it!!, onSuccess) + GeckoResult<Void>() + }, + { throwable -> + onError(GeckoWebExtensionException.createWebExtensionException(throwable)) + GeckoResult<Void>() + }, + ) + } + return geckoResult.asCancellableOperation() + } + + /** + * See [Engine.installWebExtension]. + */ + override fun installWebExtension( + url: String, + installationMethod: InstallationMethod?, + onSuccess: ((WebExtension) -> Unit), + onError: ((Throwable) -> Unit), + ): CancellableOperation { + require(!url.isResourceUrl()) { "url shouldn't be a resource url" } + + val geckoResult = runtime.webExtensionController.install( + url, + installationMethod?.toGeckoInstallationMethod(), + ).apply { + then( + { + onExtensionInstalled(it!!, onSuccess) + GeckoResult<Void>() + }, + { throwable -> + onError(GeckoWebExtensionException.createWebExtensionException(throwable)) + GeckoResult<Void>() + }, + ) + } + return geckoResult.asCancellableOperation() + } + + /** + * See [Engine.uninstallWebExtension]. + */ + override fun uninstallWebExtension( + ext: WebExtension, + onSuccess: () -> Unit, + onError: (String, Throwable) -> Unit, + ) { + runtime.webExtensionController.uninstall((ext as GeckoWebExtension).nativeExtension).then( + { + onSuccess() + GeckoResult<Void>() + }, + { throwable -> + onError(ext.id, throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.updateWebExtension]. + */ + override fun updateWebExtension( + extension: WebExtension, + onSuccess: (WebExtension?) -> Unit, + onError: (String, Throwable) -> Unit, + ) { + runtime.webExtensionController.update((extension as GeckoWebExtension).nativeExtension).then( + { geckoExtension -> + val updatedExtension = if (geckoExtension != null) { + GeckoWebExtension(geckoExtension, runtime).also { + it.registerActionHandler(webExtensionActionHandler) + it.registerTabHandler(webExtensionTabHandler, defaultSettings) + } + } else { + null + } + onSuccess(updatedExtension) + GeckoResult<Void>() + }, + { throwable -> + onError(extension.id, GeckoWebExtensionException(throwable)) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.registerWebExtensionDelegate]. + */ + @Suppress("Deprecation") + override fun registerWebExtensionDelegate( + webExtensionDelegate: WebExtensionDelegate, + ) { + this.webExtensionDelegate = webExtensionDelegate + + val promptDelegate = object : WebExtensionController.PromptDelegate { + override fun onInstallPrompt(ext: org.mozilla.geckoview.WebExtension): GeckoResult<AllowOrDeny> { + val extension = GeckoWebExtension(ext, runtime) + val result = GeckoResult<AllowOrDeny>() + + webExtensionDelegate.onInstallPermissionRequest(extension) { allow -> + if (allow) result.complete(AllowOrDeny.ALLOW) else result.complete(AllowOrDeny.DENY) + } + + return result + } + + override fun onUpdatePrompt( + current: org.mozilla.geckoview.WebExtension, + updated: org.mozilla.geckoview.WebExtension, + newPermissions: Array<out String>, + newOrigins: Array<out String>, + ): GeckoResult<AllowOrDeny>? { + val result = GeckoResult<AllowOrDeny>() + webExtensionDelegate.onUpdatePermissionRequest( + GeckoWebExtension(current, runtime), + GeckoWebExtension(updated, runtime), + newPermissions.toList() + newOrigins.toList(), + ) { allow -> + if (allow) result.complete(AllowOrDeny.ALLOW) else result.complete(AllowOrDeny.DENY) + } + return result + } + + override fun onOptionalPrompt( + extension: org.mozilla.geckoview.WebExtension, + permissions: Array<out String>, + origins: Array<out String>, + ): GeckoResult<AllowOrDeny>? { + val result = GeckoResult<AllowOrDeny>() + webExtensionDelegate.onOptionalPermissionsRequest( + GeckoWebExtension(extension, runtime), + permissions.toList() + origins.toList(), + ) { allow -> + if (allow) result.complete(AllowOrDeny.ALLOW) else result.complete(AllowOrDeny.DENY) + } + return result + } + } + + val debuggerDelegate = object : WebExtensionController.DebuggerDelegate { + override fun onExtensionListUpdated() { + webExtensionDelegate.onExtensionListUpdated() + } + } + + val addonManagerDelegate = object : WebExtensionController.AddonManagerDelegate { + override fun onDisabled(extension: org.mozilla.geckoview.WebExtension) { + webExtensionDelegate.onDisabled(GeckoWebExtension(extension, runtime)) + } + + override fun onEnabled(extension: org.mozilla.geckoview.WebExtension) { + webExtensionDelegate.onEnabled(GeckoWebExtension(extension, runtime)) + } + + override fun onReady(extension: org.mozilla.geckoview.WebExtension) { + webExtensionDelegate.onReady(GeckoWebExtension(extension, runtime)) + } + + override fun onUninstalled(extension: org.mozilla.geckoview.WebExtension) { + webExtensionDelegate.onUninstalled(GeckoWebExtension(extension, runtime)) + } + + override fun onInstalled(extension: org.mozilla.geckoview.WebExtension) { + val installedExtension = GeckoWebExtension(extension, runtime) + webExtensionDelegate.onInstalled(installedExtension) + installedExtension.registerActionHandler(webExtensionActionHandler) + installedExtension.registerTabHandler(webExtensionTabHandler, defaultSettings) + } + + override fun onInstallationFailed( + extension: org.mozilla.geckoview.WebExtension?, + installException: org.mozilla.geckoview.WebExtension.InstallException, + ) { + val exception = + GeckoWebExtensionException.createWebExtensionException(installException) + webExtensionDelegate.onInstallationFailedRequest( + extension.toSafeWebExtension(), + exception as WebExtensionInstallException, + ) + } + } + + val extensionProcessDelegate = object : WebExtensionController.ExtensionProcessDelegate { + override fun onDisabledProcessSpawning() { + webExtensionDelegate.onDisabledExtensionProcessSpawning() + } + } + + runtime.webExtensionController.setPromptDelegate(promptDelegate) + runtime.webExtensionController.setDebuggerDelegate(debuggerDelegate) + runtime.webExtensionController.setAddonManagerDelegate(addonManagerDelegate) + runtime.webExtensionController.setExtensionProcessDelegate(extensionProcessDelegate) + } + + /** + * See [Engine.listInstalledWebExtensions]. + */ + override fun listInstalledWebExtensions(onSuccess: (List<WebExtension>) -> Unit, onError: (Throwable) -> Unit) { + runtime.webExtensionController.list().then( + { + val extensions = it?.map { + extension -> + GeckoWebExtension(extension, runtime) + } ?: emptyList() + + extensions.forEach { extension -> + extension.registerActionHandler(webExtensionActionHandler) + extension.registerTabHandler(webExtensionTabHandler, defaultSettings) + } + + onSuccess(extensions) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.enableWebExtension]. + */ + override fun enableWebExtension( + extension: WebExtension, + source: EnableSource, + onSuccess: (WebExtension) -> Unit, + onError: (Throwable) -> Unit, + ) { + runtime.webExtensionController.enable((extension as GeckoWebExtension).nativeExtension, source.id).then( + { + val enabledExtension = GeckoWebExtension(it!!, runtime) + onSuccess(enabledExtension) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.addOptionalPermissions]. + */ + override fun addOptionalPermissions( + extensionId: String, + permissions: List<String>, + origins: List<String>, + onSuccess: (WebExtension) -> Unit, + onError: (Throwable) -> Unit, + ) { + if (permissions.isEmpty() && origins.isEmpty()) { + onError(IllegalStateException("Either permissions or origins must not be empty")) + return + } + + runtime.webExtensionController.addOptionalPermissions( + extensionId, + permissions.toTypedArray(), + origins.toTypedArray(), + ).then( + { + val enabledExtension = GeckoWebExtension(it!!, runtime) + onSuccess(enabledExtension) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.removeOptionalPermissions]. + */ + override fun removeOptionalPermissions( + extensionId: String, + permissions: List<String>, + origins: List<String>, + onSuccess: (WebExtension) -> Unit, + onError: (Throwable) -> Unit, + ) { + if (permissions.isEmpty() && origins.isEmpty()) { + onError(IllegalStateException("Either permissions or origins must not be empty")) + return + } + + runtime.webExtensionController.removeOptionalPermissions( + extensionId, + permissions.toTypedArray(), + origins.toTypedArray(), + ).then( + { + val enabledExtension = GeckoWebExtension(it!!, runtime) + onSuccess(enabledExtension) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.disableWebExtension]. + */ + override fun disableWebExtension( + extension: WebExtension, + source: EnableSource, + onSuccess: (WebExtension) -> Unit, + onError: (Throwable) -> Unit, + ) { + runtime.webExtensionController.disable((extension as GeckoWebExtension).nativeExtension, source.id).then( + { + val disabledExtension = GeckoWebExtension(it!!, runtime) + onSuccess(disabledExtension) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.setAllowedInPrivateBrowsing]. + */ + override fun setAllowedInPrivateBrowsing( + extension: WebExtension, + allowed: Boolean, + onSuccess: (WebExtension) -> Unit, + onError: (Throwable) -> Unit, + ) { + runtime.webExtensionController.setAllowedInPrivateBrowsing( + (extension as GeckoWebExtension).nativeExtension, + allowed, + ).then( + { geckoExtension -> + if (geckoExtension == null) { + onError( + Exception( + "Gecko extension was not returned after trying to" + + " setAllowedInPrivateBrowsing with value $allowed", + ), + ) + } else { + val ext = GeckoWebExtension(geckoExtension, runtime) + webExtensionDelegate?.onAllowedInPrivateBrowsingChanged(ext) + onSuccess(ext) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.enableExtensionProcessSpawning]. + */ + override fun enableExtensionProcessSpawning() { + runtime.webExtensionController.enableExtensionProcessSpawning() + } + + /** + * See [Engine.disableExtensionProcessSpawning]. + */ + override fun disableExtensionProcessSpawning() { + runtime.webExtensionController.disableExtensionProcessSpawning() + } + + /** + * See [Engine.registerWebNotificationDelegate]. + */ + override fun registerWebNotificationDelegate( + webNotificationDelegate: WebNotificationDelegate, + ) { + runtime.webNotificationDelegate = GeckoWebNotificationDelegate(webNotificationDelegate) + } + + /** + * See [Engine.registerWebPushDelegate]. + */ + override fun registerWebPushDelegate( + webPushDelegate: WebPushDelegate, + ): WebPushHandler { + runtime.webPushController.setDelegate(GeckoWebPushDelegate(webPushDelegate)) + + if (webPushHandler == null) { + webPushHandler = GeckoWebPushHandler(runtime) + } + + return requireNotNull(webPushHandler) + } + + /** + * See [Engine.registerActivityDelegate]. + */ + override fun registerActivityDelegate( + activityDelegate: ActivityDelegate, + ) { + /** + * Having the activity delegate on the engine can cause issues with resolving multiple requests to the delegate + * from different sessions. Ideally, this should be moved to the [EngineView]. + * + * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1672195 + * + * Attaching the delegate to the Gecko [Engine] implicitly assumes we have WebAuthn support. When a feature + * implements the ActivityDelegate today, we need to make sure that it has full support for WebAuthn. This + * needs to be fixed in GeckoView. + * + * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1671988 + */ + runtime.activityDelegate = GeckoActivityDelegate(WeakReference(activityDelegate)) + } + + /** + * See [Engine.unregisterActivityDelegate]. + */ + override fun unregisterActivityDelegate() { + runtime.activityDelegate = null + } + + /** + * See [Engine.registerScreenOrientationDelegate]. + */ + override fun registerScreenOrientationDelegate( + delegate: OrientationDelegate, + ) { + runtime.orientationController.delegate = GeckoScreenOrientationDelegate(delegate) + } + + /** + * See [Engine.unregisterScreenOrientationDelegate]. + */ + override fun unregisterScreenOrientationDelegate() { + runtime.orientationController.delegate = null + } + + override fun registerServiceWorkerDelegate(serviceWorkerDelegate: ServiceWorkerDelegate) { + runtime.serviceWorkerDelegate = GeckoServiceWorkerDelegate( + delegate = serviceWorkerDelegate, + runtime = runtime, + engineSettings = defaultSettings, + ) + } + + override fun unregisterServiceWorkerDelegate() { + runtime.serviceWorkerDelegate = null + } + + override fun handleWebNotificationClick(webNotification: Parcelable) { + (webNotification as? WebNotification)?.click() + } + + /** + * See [Engine.clearData]. + */ + override fun clearData( + data: Engine.BrowsingData, + host: String?, + onSuccess: () -> Unit, + onError: (Throwable) -> Unit, + ) { + val flags = data.types.toLong() + if (host != null) { + runtime.storageController.clearDataFromBaseDomain(host, flags) + } else { + runtime.storageController.clearData(flags) + }.then( + { + onSuccess() + GeckoResult<Void>() + }, + { + throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.isTranslationsEngineSupported]. + */ + override fun isTranslationsEngineSupported( + onSuccess: (Boolean) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.isTranslationsEngineSupported().then( + { + if (it != null) { + onSuccess(it) + } else { + onError(TranslationError.UnexpectedNull()) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getTranslationsPairDownloadSize]. + */ + override fun getTranslationsPairDownloadSize( + fromLanguage: String, + toLanguage: String, + onSuccess: (Long) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.checkPairDownloadSize(fromLanguage, toLanguage).then( + { + if (it != null) { + onSuccess(it) + } else { + onError(TranslationError.UnexpectedNull()) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getTranslationsModelDownloadStates]. + */ + override fun getTranslationsModelDownloadStates( + onSuccess: (List<LanguageModel>) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.listModelDownloadStates().then( + { + if (it != null) { + var listOfModels = mutableListOf<LanguageModel>() + for (each in it) { + var language = each.language?.let { + language -> + Language(language.code, each.language?.localizedDisplayName) + } + var model = LanguageModel(language, each.isDownloaded, each.size) + listOfModels.add(model) + } + onSuccess(listOfModels) + } else { + onError(TranslationError.UnexpectedNull()) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getSupportedTranslationLanguages]. + */ + override fun getSupportedTranslationLanguages( + onSuccess: (TranslationSupport) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.listSupportedLanguages().then( + { + if (it != null) { + val listOfFromLanguages = mutableListOf<Language>() + val listOfToLanguages = mutableListOf<Language>() + + if (it.fromLanguages != null) { + for (each in it.fromLanguages!!) { + listOfFromLanguages.add(Language(each.code, each.localizedDisplayName)) + } + } + + if (it.toLanguages != null) { + for (each in it.toLanguages!!) { + listOfToLanguages.add(Language(each.code, each.localizedDisplayName)) + } + } + + onSuccess(TranslationSupport(listOfFromLanguages, listOfToLanguages)) + } else { + onError(TranslationError.UnexpectedNull()) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.manageTranslationsLanguageModel]. + */ + override fun manageTranslationsLanguageModel( + options: ModelManagementOptions, + onSuccess: () -> Unit, + onError: (Throwable) -> Unit, + ) { + val geckoOptions = + TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder() + .operation(options.operation.toString()) + .operationLevel(options.operationLevel.toString()) + + options.languageToManage?.let { geckoOptions.languageToManage(it) } + + TranslationsController.RuntimeTranslation.manageLanguageModel(geckoOptions.build()).then( + { + onSuccess() + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getUserPreferredLanguages]. + */ + override fun getUserPreferredLanguages( + onSuccess: (List<String>) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.preferredLanguages().then( + { + if (it != null) { + onSuccess(it) + } else { + onError(TranslationError.UnexpectedNull()) + } + + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getTranslationsOfferPopup]. + */ + override fun getTranslationsOfferPopup(): Boolean { + return runtime.settings.translationsOfferPopup + } + + /** + * See [Engine.setTranslationsOfferPopup]. + */ + override fun setTranslationsOfferPopup(offer: Boolean) { + runtime.settings.translationsOfferPopup = offer + } + + /** + * See [Engine.getLanguageSetting]. + */ + override fun getLanguageSetting( + languageCode: String, + onSuccess: (LanguageSetting) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.getLanguageSetting(languageCode).then( + { + if (it != null) { + try { + onSuccess(LanguageSetting.fromValue(it)) + } catch (e: IllegalArgumentException) { + onError(e.intoTranslationError()) + } + } else { + onError(TranslationError.UnexpectedNull()) + } + + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.setLanguageSetting]. + */ + override fun setLanguageSetting( + languageCode: String, + languageSetting: LanguageSetting, + onSuccess: () -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.setLanguageSettings(languageCode, languageSetting.toString()).then( + { + onSuccess() + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getLanguageSettings]. + */ + override fun getLanguageSettings( + onSuccess: (Map<String, LanguageSetting>) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.getLanguageSettings().then( + { + if (it != null) { + try { + val result = mutableMapOf<String, LanguageSetting>() + it.forEach { item -> + result[item.key] = LanguageSetting.fromValue(item.value) + } + onSuccess(result) + } catch (e: IllegalArgumentException) { + onError(e.intoTranslationError()) + } + } else { + onError(TranslationError.UnexpectedNull()) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.getNeverTranslateSiteList]. + */ + override fun getNeverTranslateSiteList( + onSuccess: (List<String>) -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.getNeverTranslateSiteList().then( + { + if (it != null) { + try { + onSuccess(it) + } catch (e: IllegalArgumentException) { + onError(e.intoTranslationError()) + } + } else { + onError(TranslationError.UnexpectedNull()) + } + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.setNeverTranslateSpecifiedSite]. + */ + override fun setNeverTranslateSpecifiedSite( + origin: String, + setting: Boolean, + onSuccess: () -> Unit, + onError: (Throwable) -> Unit, + ) { + TranslationsController.RuntimeTranslation.setNeverTranslateSpecifiedSite(setting, origin).then( + { + onSuccess() + GeckoResult<Void>() + }, + { throwable -> + onError(throwable.intoTranslationError()) + GeckoResult<Void>() + }, + ) + } + + /** + * See [Engine.profiler]. + */ + override val profiler: Profiler? = Profiler(runtime) + + override fun name(): String = "Gecko" + + override val version: EngineVersion = EngineVersion.parse( + org.mozilla.geckoview.BuildConfig.MOZILLA_VERSION, + org.mozilla.geckoview.BuildConfig.MOZ_UPDATE_CHANNEL, + ) ?: throw IllegalStateException("Could not determine engine version") + + /** + * See [Engine.settings] + */ + override val settings: Settings = object : Settings() { + override var javascriptEnabled: Boolean + get() = runtime.settings.javaScriptEnabled + set(value) { runtime.settings.javaScriptEnabled = value } + + override var webFontsEnabled: Boolean + get() = runtime.settings.webFontsEnabled + set(value) { runtime.settings.webFontsEnabled = value } + + override var automaticFontSizeAdjustment: Boolean + get() = runtime.settings.automaticFontSizeAdjustment + set(value) { runtime.settings.automaticFontSizeAdjustment = value } + + override var automaticLanguageAdjustment: Boolean + get() = localeUpdater.enabled + set(value) { + localeUpdater.enabled = value + defaultSettings?.automaticLanguageAdjustment = value + } + + override var safeBrowsingPolicy: Array<SafeBrowsingPolicy> = + arrayOf(SafeBrowsingPolicy.RECOMMENDED) + set(value) { + val policy = value.sumOf { it.id } + runtime.settings.contentBlocking.setSafeBrowsing(policy) + field = value + } + + override var trackingProtectionPolicy: TrackingProtectionPolicy? = null + set(value) { + value?.let { policy -> + with(runtime.settings.contentBlocking) { + if (enhancedTrackingProtectionLevel != value.getEtpLevel()) { + enhancedTrackingProtectionLevel = value.getEtpLevel() + } + + if (strictSocialTrackingProtection != value.getStrictSocialTrackingProtection()) { + strictSocialTrackingProtection = policy.getStrictSocialTrackingProtection() + } + + if (antiTrackingCategories != value.getAntiTrackingPolicy()) { + setAntiTracking(policy.getAntiTrackingPolicy()) + } + + if (cookieBehavior != value.cookiePolicy.id) { + cookieBehavior = value.cookiePolicy.id + } + + if (cookieBehaviorPrivateMode != value.cookiePolicyPrivateMode.id) { + cookieBehaviorPrivateMode = value.cookiePolicyPrivateMode.id + } + + if (cookiePurging != value.cookiePurging) { + setCookiePurging(value.cookiePurging) + } + } + + defaultSettings?.trackingProtectionPolicy = value + field = value + } + } + + override var cookieBannerHandlingMode: CookieBannerHandlingMode = CookieBannerHandlingMode.DISABLED + set(value) { + with(runtime.settings.contentBlocking) { + if (this.cookieBannerMode != value.mode) { + this.cookieBannerMode = value.mode + } + } + field = value + } + + override var cookieBannerHandlingModePrivateBrowsing: CookieBannerHandlingMode = + CookieBannerHandlingMode.REJECT_ALL + set(value) { + with(runtime.settings.contentBlocking) { + if (this.cookieBannerModePrivateBrowsing != value.mode) { + this.cookieBannerModePrivateBrowsing = value.mode + } + } + field = value + } + + override var emailTrackerBlockingPrivateBrowsing: Boolean = false + set(value) { + with(runtime.settings.contentBlocking) { + if (this.emailTrackerBlockingPrivateBrowsingEnabled != value) { + this.setEmailTrackerBlockingPrivateBrowsing(value) + } + } + field = value + } + + override var cookieBannerHandlingDetectOnlyMode: Boolean = false + set(value) { + with(runtime.settings.contentBlocking) { + if (this.cookieBannerDetectOnlyMode != value) { + this.cookieBannerDetectOnlyMode = value + } + } + field = value + } + + override var cookieBannerHandlingGlobalRules: Boolean = false + set(value) { + with(runtime.settings.contentBlocking) { + if (this.cookieBannerGlobalRulesEnabled != value) { + this.cookieBannerGlobalRulesEnabled = value + } + } + field = value + } + + override var cookieBannerHandlingGlobalRulesSubFrames: Boolean = false + set(value) { + with(runtime.settings.contentBlocking) { + if (this.cookieBannerGlobalRulesSubFramesEnabled != value) { + this.cookieBannerGlobalRulesSubFramesEnabled = value + } + } + field = value + } + + override var queryParameterStripping: Boolean = false + set(value) { + with(runtime.settings.contentBlocking) { + if (this.queryParameterStrippingEnabled != value) { + this.queryParameterStrippingEnabled = value + } + } + field = value + } + + override var queryParameterStrippingPrivateBrowsing: Boolean = false + set(value) { + with(runtime.settings.contentBlocking) { + if (this.queryParameterStrippingPrivateBrowsingEnabled != value) { + this.queryParameterStrippingPrivateBrowsingEnabled = value + } + } + field = value + } + + @Suppress("SpreadOperator") + override var queryParameterStrippingAllowList: String = "" + set(value) { + with(runtime.settings.contentBlocking) { + if (this.queryParameterStrippingAllowList.joinToString() != value) { + this.setQueryParameterStrippingAllowList( + *value.split(",") + .toTypedArray(), + ) + } + } + field = value + } + + @Suppress("SpreadOperator") + override var queryParameterStrippingStripList: String = "" + set(value) { + with(runtime.settings.contentBlocking) { + if (this.queryParameterStrippingStripList.joinToString() != value) { + this.setQueryParameterStrippingStripList( + *value.split(",").toTypedArray(), + ) + } + } + field = value + } + + override var remoteDebuggingEnabled: Boolean + get() = runtime.settings.remoteDebuggingEnabled + set(value) { runtime.settings.remoteDebuggingEnabled = value } + + override var historyTrackingDelegate: HistoryTrackingDelegate? + get() = defaultSettings?.historyTrackingDelegate + set(value) { defaultSettings?.historyTrackingDelegate = value } + + override var testingModeEnabled: Boolean + get() = defaultSettings?.testingModeEnabled ?: false + set(value) { defaultSettings?.testingModeEnabled = value } + + override var userAgentString: String? + get() = defaultSettings?.userAgentString ?: GeckoSession.getDefaultUserAgent() + set(value) { defaultSettings?.userAgentString = value } + + override var preferredColorScheme: PreferredColorScheme + get() = PreferredColorScheme.from(runtime.settings.preferredColorScheme) + set(value) { runtime.settings.preferredColorScheme = value.toGeckoValue() } + + override var suspendMediaWhenInactive: Boolean + get() = defaultSettings?.suspendMediaWhenInactive ?: false + set(value) { defaultSettings?.suspendMediaWhenInactive = value } + + override var clearColor: Int? + get() = defaultSettings?.clearColor + set(value) { defaultSettings?.clearColor = value } + + override var fontInflationEnabled: Boolean? + get() = runtime.settings.fontInflationEnabled + set(value) { + // automaticFontSizeAdjustment is set to true by default, which + // will cause an exception if fontInflationEnabled is set + // (to either true or false). We therefore need to be able to + // set our built-in default value to null so that the exception + // is only thrown if an app is configured incorrectly but not + // if it uses default values. + value?.let { + runtime.settings.fontInflationEnabled = it + } + } + + override var fontSizeFactor: Float? + get() = runtime.settings.fontSizeFactor + set(value) { + // automaticFontSizeAdjustment is set to true by default, which + // will cause an exception if fontSizeFactor is set as well. + // We therefore need to be able to set our built-in default value + // to null so that the exception is only thrown if an app is + // configured incorrectly but not if it uses default values. + value?.let { + runtime.settings.fontSizeFactor = it + } + } + + override var loginAutofillEnabled: Boolean + get() = runtime.settings.loginAutofillEnabled + set(value) { runtime.settings.loginAutofillEnabled = value } + + override var forceUserScalableContent: Boolean + get() = runtime.settings.forceUserScalableEnabled + set(value) { runtime.settings.forceUserScalableEnabled = value } + + override var enterpriseRootsEnabled: Boolean + get() = runtime.settings.enterpriseRootsEnabled + set(value) { runtime.settings.enterpriseRootsEnabled = value } + + override var httpsOnlyMode: Engine.HttpsOnlyMode + get() = when (runtime.settings.allowInsecureConnections) { + GeckoRuntimeSettings.ALLOW_ALL -> Engine.HttpsOnlyMode.DISABLED + GeckoRuntimeSettings.HTTPS_ONLY_PRIVATE -> Engine.HttpsOnlyMode.ENABLED_PRIVATE_ONLY + GeckoRuntimeSettings.HTTPS_ONLY -> Engine.HttpsOnlyMode.ENABLED + else -> throw java.lang.IllegalStateException("Unknown HTTPS-Only mode returned by GeckoView") + } + set(value) { + runtime.settings.allowInsecureConnections = when (value) { + Engine.HttpsOnlyMode.DISABLED -> GeckoRuntimeSettings.ALLOW_ALL + Engine.HttpsOnlyMode.ENABLED_PRIVATE_ONLY -> GeckoRuntimeSettings.HTTPS_ONLY_PRIVATE + Engine.HttpsOnlyMode.ENABLED -> GeckoRuntimeSettings.HTTPS_ONLY + } + } + override var globalPrivacyControlEnabled: Boolean + get() = runtime.settings.globalPrivacyControl + set(value) { runtime.settings.setGlobalPrivacyControl(value) } + }.apply { + defaultSettings?.let { + this.javascriptEnabled = it.javascriptEnabled + this.webFontsEnabled = it.webFontsEnabled + this.automaticFontSizeAdjustment = it.automaticFontSizeAdjustment + this.automaticLanguageAdjustment = it.automaticLanguageAdjustment + this.trackingProtectionPolicy = it.trackingProtectionPolicy + this.safeBrowsingPolicy = arrayOf(SafeBrowsingPolicy.RECOMMENDED) + this.remoteDebuggingEnabled = it.remoteDebuggingEnabled + this.testingModeEnabled = it.testingModeEnabled + this.userAgentString = it.userAgentString + this.preferredColorScheme = it.preferredColorScheme + this.fontInflationEnabled = it.fontInflationEnabled + this.fontSizeFactor = it.fontSizeFactor + this.forceUserScalableContent = it.forceUserScalableContent + this.clearColor = it.clearColor + this.loginAutofillEnabled = it.loginAutofillEnabled + this.enterpriseRootsEnabled = it.enterpriseRootsEnabled + this.httpsOnlyMode = it.httpsOnlyMode + this.cookieBannerHandlingMode = it.cookieBannerHandlingMode + this.cookieBannerHandlingModePrivateBrowsing = it.cookieBannerHandlingModePrivateBrowsing + this.cookieBannerHandlingDetectOnlyMode = it.cookieBannerHandlingDetectOnlyMode + this.cookieBannerHandlingGlobalRules = it.cookieBannerHandlingGlobalRules + this.cookieBannerHandlingGlobalRulesSubFrames = it.cookieBannerHandlingGlobalRulesSubFrames + this.globalPrivacyControlEnabled = it.globalPrivacyControlEnabled + this.emailTrackerBlockingPrivateBrowsing = it.emailTrackerBlockingPrivateBrowsing + } + } + + @Suppress("ComplexMethod") + internal fun ContentBlockingController.LogEntry.BlockingData.getLoadedCategory(): TrackingCategory { + val socialTrackingProtectionEnabled = settings.trackingProtectionPolicy?.strictSocialTrackingProtection + ?: false + + return when (category) { + Event.LOADED_FINGERPRINTING_CONTENT -> TrackingCategory.FINGERPRINTING + Event.LOADED_CRYPTOMINING_CONTENT -> TrackingCategory.CRYPTOMINING + Event.LOADED_SOCIALTRACKING_CONTENT -> { + if (socialTrackingProtectionEnabled) TrackingCategory.MOZILLA_SOCIAL else TrackingCategory.NONE + } + Event.COOKIES_LOADED_SOCIALTRACKER -> { + if (!socialTrackingProtectionEnabled) TrackingCategory.MOZILLA_SOCIAL else TrackingCategory.NONE + } + Event.LOADED_LEVEL_1_TRACKING_CONTENT -> TrackingCategory.SCRIPTS_AND_SUB_RESOURCES + Event.LOADED_LEVEL_2_TRACKING_CONTENT -> { + // We are making sure that we are only showing trackers that our settings are + // taking into consideration. + val isContentListActive = + settings.trackingProtectionPolicy?.contains(TrackingCategory.CONTENT) + ?: false + val isStrictLevelActive = + runtime.settings + .contentBlocking + .getEnhancedTrackingProtectionLevel() == ContentBlocking.EtpLevel.STRICT + + if (isStrictLevelActive && isContentListActive) { + TrackingCategory.SCRIPTS_AND_SUB_RESOURCES + } else { + TrackingCategory.NONE + } + } + else -> TrackingCategory.NONE + } + } + + private fun isCategoryActive(category: TrackingCategory) = settings.trackingProtectionPolicy?.contains(category) + ?: false + + /** + * Mimics the behavior for categorizing trackers from desktop, they should be kept in sync, + * as differences will result in improper categorization for trackers. + * https://dxr.mozilla.org/mozilla-central/source/browser/base/content/browser-siteProtections.js + */ + internal fun ContentBlockingController.LogEntry.toTrackerLog(): TrackerLog { + val cookiesHasBeenBlocked = this.blockingData.any { it.hasBlockedCookies() } + val blockedCategories = blockingData.map { it.getBlockedCategory() } + .filterNot { it == TrackingCategory.NONE } + .distinct() + val loadedCategories = blockingData.map { it.getLoadedCategory() } + .filterNot { it == TrackingCategory.NONE } + .distinct() + + /** + * When a resource is shimmed we'll received a [REPLACED_TRACKING_CONTENT] event with + * the quantity [BlockingData.count] of categories that were shimmed, but it doesn't + * specify which ones, it only tells us how many. For example: + * { + * "category": REPLACED_TRACKING_CONTENT, + * "count": 2 + * } + * + * This indicates that there are 2 categories that were shimmed, as a result + * we have to infer based on the categories that are active vs the amount of + * shimmed categories, for example: + * + * "blockData": [ + * { + * "category": LOADED_LEVEL_1_TRACKING_CONTENT, + * "count": 1 + * }, + * { + * "category": LOADED_SOCIALTRACKING_CONTENT, + * "count": 1 + * }, + * { + * "category": REPLACED_TRACKING_CONTENT, + * "count": 2 + * } + * ] + * This indicates that categories [LOADED_LEVEL_1_TRACKING_CONTENT] and + * [LOADED_SOCIALTRACKING_CONTENT] were loaded but shimmed and we should display them + * as blocked instead of loaded. + */ + val shimmedCount = blockingData.find { + it.category == Event.REPLACED_TRACKING_CONTENT + }?.count ?: 0 + + // If we find blocked categories that are loaded it means they were shimmed. + val shimmedCategories = loadedCategories.filter { isCategoryActive(it) } + .take(shimmedCount) + + // We have to remove the categories that are shimmed from the loaded list and + // put them back in the blocked list. + return TrackerLog( + url = origin, + loadedCategories = loadedCategories.filterNot { it in shimmedCategories }, + blockedCategories = (blockedCategories + shimmedCategories).distinct(), + cookiesHasBeenBlocked = cookiesHasBeenBlocked, + unBlockedBySmartBlock = this.blockingData.any { it.unBlockedBySmartBlock() }, + ) + } + + internal fun org.mozilla.geckoview.WebExtension?.toSafeWebExtension(): GeckoWebExtension? { + return if (this != null) { + GeckoWebExtension( + this, + runtime, + ) + } else { + null + } + } + + private fun onExtensionInstalled( + ext: org.mozilla.geckoview.WebExtension, + onSuccess: ((WebExtension) -> Unit), + ) { + val installedExtension = GeckoWebExtension(ext, runtime) + webExtensionDelegate?.onInstalled(installedExtension) + installedExtension.registerActionHandler(webExtensionActionHandler) + installedExtension.registerTabHandler(webExtensionTabHandler, defaultSettings) + onSuccess(installedExtension) + } +} + +internal fun ContentBlockingController.LogEntry.BlockingData.hasBlockedCookies(): Boolean { + return category == Event.COOKIES_BLOCKED_BY_PERMISSION || + category == Event.COOKIES_BLOCKED_TRACKER || + category == Event.COOKIES_BLOCKED_ALL || + category == Event.COOKIES_PARTITIONED_FOREIGN || + category == Event.COOKIES_BLOCKED_FOREIGN || + category == Event.COOKIES_BLOCKED_SOCIALTRACKER +} + +internal fun ContentBlockingController.LogEntry.BlockingData.unBlockedBySmartBlock(): Boolean { + return category == Event.ALLOWED_TRACKING_CONTENT +} + +internal fun ContentBlockingController.LogEntry.BlockingData.getBlockedCategory(): TrackingCategory { + return when (category) { + Event.BLOCKED_FINGERPRINTING_CONTENT -> TrackingCategory.FINGERPRINTING + Event.BLOCKED_CRYPTOMINING_CONTENT -> TrackingCategory.CRYPTOMINING + Event.BLOCKED_SOCIALTRACKING_CONTENT, Event.COOKIES_BLOCKED_SOCIALTRACKER -> TrackingCategory.MOZILLA_SOCIAL + Event.BLOCKED_TRACKING_CONTENT -> TrackingCategory.SCRIPTS_AND_SUB_RESOURCES + else -> TrackingCategory.NONE + } +} + +internal fun InstallationMethod.toGeckoInstallationMethod(): String? { + return when (this) { + InstallationMethod.MANAGER -> WebExtensionController.INSTALLATION_METHOD_MANAGER + InstallationMethod.FROM_FILE -> WebExtensionController.INSTALLATION_METHOD_FROM_FILE + else -> null + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt new file mode 100644 index 0000000000..e6907c6dde --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt @@ -0,0 +1,1880 @@ +/* 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.browser.engine.gecko + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.Build +import android.view.WindowManager +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import mozilla.components.browser.engine.gecko.ext.isExcludedForTrackingProtection +import mozilla.components.browser.engine.gecko.fetch.toResponse +import mozilla.components.browser.engine.gecko.media.GeckoMediaDelegate +import mozilla.components.browser.engine.gecko.mediasession.GeckoMediaSessionDelegate +import mozilla.components.browser.engine.gecko.permission.GeckoPermissionRequest +import mozilla.components.browser.engine.gecko.prompt.GeckoPromptDelegate +import mozilla.components.browser.engine.gecko.translate.GeckoTranslateSessionDelegate +import mozilla.components.browser.engine.gecko.translate.GeckoTranslationUtils.intoTranslationError +import mozilla.components.browser.engine.gecko.window.GeckoWindowRequest +import mozilla.components.browser.errorpages.ErrorType +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.ALLOW_ADDITIONAL_HEADERS +import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.ALLOW_JAVASCRIPT_URL +import mozilla.components.concept.engine.EngineSessionState +import mozilla.components.concept.engine.HitResult +import mozilla.components.concept.engine.Settings +import mozilla.components.concept.engine.content.blocking.Tracker +import mozilla.components.concept.engine.history.HistoryItem +import mozilla.components.concept.engine.history.HistoryTrackingDelegate +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.concept.engine.manifest.WebAppManifestParser +import mozilla.components.concept.engine.request.RequestInterceptor +import mozilla.components.concept.engine.request.RequestInterceptor.InterceptionResponse +import mozilla.components.concept.engine.shopping.Highlight +import mozilla.components.concept.engine.shopping.ProductAnalysis +import mozilla.components.concept.engine.shopping.ProductAnalysisStatus +import mozilla.components.concept.engine.shopping.ProductRecommendation +import mozilla.components.concept.engine.translate.TranslationError +import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.translate.TranslationOptions +import mozilla.components.concept.engine.window.WindowRequest +import mozilla.components.concept.fetch.Headers.Names.CONTENT_DISPOSITION +import mozilla.components.concept.fetch.Headers.Names.CONTENT_LENGTH +import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.storage.PageVisit +import mozilla.components.concept.storage.RedirectSource +import mozilla.components.concept.storage.VisitType +import mozilla.components.support.base.Component +import mozilla.components.support.base.facts.Action +import mozilla.components.support.base.facts.Fact +import mozilla.components.support.base.facts.collect +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.kotlin.isEmail +import mozilla.components.support.ktx.kotlin.isExtensionUrl +import mozilla.components.support.ktx.kotlin.isGeoLocation +import mozilla.components.support.ktx.kotlin.isPhone +import mozilla.components.support.ktx.kotlin.sanitizeFileName +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl +import mozilla.components.support.utils.DownloadUtils +import mozilla.components.support.utils.DownloadUtils.RESPONSE_CODE_SUCCESS +import mozilla.components.support.utils.DownloadUtils.makePdfContentDisposition +import org.json.JSONObject +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.ContentBlocking +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.Recommendation +import org.mozilla.geckoview.GeckoSessionSettings +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.WebResponse +import java.util.Locale +import kotlin.coroutines.CoroutineContext +import org.mozilla.geckoview.TranslationsController.SessionTranslation as GeckoViewTranslateSession + +/** + * Gecko-based EngineSession implementation. + */ +@Suppress("TooManyFunctions", "LargeClass") +class GeckoEngineSession( + private val runtime: GeckoRuntime, + private val privateMode: Boolean = false, + private val defaultSettings: Settings? = null, + contextId: String? = null, + private val geckoSessionProvider: () -> GeckoSession = { + val settings = GeckoSessionSettings.Builder() + .usePrivateMode(privateMode) + .contextId(contextId) + .build() + GeckoSession(settings) + }, + private val context: CoroutineContext = Dispatchers.IO, + openGeckoSession: Boolean = true, +) : CoroutineScope, EngineSession() { + + // This logger is temporary and parsed by FNPRMS for performance measurements. It can be + // removed once FNPRMS is replaced: https://github.com/mozilla-mobile/android-components/issues/8662 + // It mimics GeckoView debug log statements, hence the unintuitive tag and messages. + private val fnprmsLogger = Logger("GeckoSession") + + private val logger = Logger("GeckoEngineSession") + + internal lateinit var geckoSession: GeckoSession + internal var currentUrl: String? = null + internal var currentTitle: String? = null + internal var lastLoadRequestUri: String? = null + internal var pageLoadingUrl: String? = null + internal var appRedirectUrl: String? = null + internal var scrollY: Int = 0 + + // The Gecko site permissions for the loaded site. + internal var geckoPermissions: List<ContentPermission> = emptyList() + + internal var job: Job = Job() + private var canGoBack: Boolean = false + private var canGoForward: Boolean = false + + /** + * See [EngineSession.settings] + */ + override val settings: Settings = object : Settings() { + override var requestInterceptor: RequestInterceptor? = null + override var historyTrackingDelegate: HistoryTrackingDelegate? = null + override var userAgentString: String? + get() = geckoSession.settings.userAgentOverride + set(value) { + geckoSession.settings.userAgentOverride = value + } + override var suspendMediaWhenInactive: Boolean + get() = geckoSession.settings.suspendMediaWhenInactive + set(value) { + geckoSession.settings.suspendMediaWhenInactive = value + } + } + + internal var initialLoad = true + + override val coroutineContext: CoroutineContext + get() = context + job + + init { + createGeckoSession(shouldOpen = openGeckoSession) + } + + /** + * Represents a request to load a [url]. + * + * @param url the url to load. + * @param parent the parent (referring) [EngineSession] i.e. the session that + * triggered creating this one. + * @param flags the [LoadUrlFlags] to use when loading the provided url. + * @param additionalHeaders the extra headers to use when loading the provided url. + **/ + data class LoadRequest( + val url: String, + val parent: EngineSession?, + val flags: LoadUrlFlags, + val additionalHeaders: Map<String, String>?, + ) + + @VisibleForTesting + internal var initialLoadRequest: LoadRequest? = null + + /** + * See [EngineSession.loadUrl] + */ + override fun loadUrl( + url: String, + parent: EngineSession?, + flags: LoadUrlFlags, + additionalHeaders: Map<String, String>?, + ) { + notifyObservers { onLoadUrl() } + + val scheme = Uri.parse(url).normalizeScheme().scheme + if (BLOCKED_SCHEMES.contains(scheme) && !shouldLoadJSSchemes(scheme, flags)) { + logger.error("URL scheme not allowed. Aborting load.") + return + } + + if (initialLoad) { + initialLoadRequest = LoadRequest(url, parent, flags, additionalHeaders) + } + + val loader = GeckoSession.Loader() + .uri(url) + .flags(flags.getGeckoFlags()) + + if (additionalHeaders != null) { + val headerFilter = if (flags.contains(ALLOW_ADDITIONAL_HEADERS)) { + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE + } else { + GeckoSession.HEADER_FILTER_CORS_SAFELISTED + } + loader.additionalHeaders(additionalHeaders) + .headerFilter(headerFilter) + } + + if (parent != null) { + loader.referrer((parent as GeckoEngineSession).geckoSession) + } + + geckoSession.load(loader) + Fact( + Component.BROWSER_ENGINE_GECKO, + Action.IMPLEMENTATION_DETAIL, + "GeckoSession.load", + ).collect() + } + + private fun shouldLoadJSSchemes( + scheme: String?, + flags: LoadUrlFlags, + ) = scheme?.startsWith(JS_SCHEME) == true && flags.contains(ALLOW_JAVASCRIPT_URL) + + /** + * See [EngineSession.loadData] + */ + override fun loadData(data: String, mimeType: String, encoding: String) { + when (encoding) { + "base64" -> geckoSession.load(GeckoSession.Loader().data(data.toByteArray(), mimeType)) + else -> geckoSession.load(GeckoSession.Loader().data(data, mimeType)) + } + notifyObservers { onLoadData() } + } + + /** + * See [EngineSession.requestPdfToDownload] + */ + override fun requestPdfToDownload() { + geckoSession.saveAsPdf().then( + { inputStream -> + if (inputStream == null) { + logger.error("No input stream available for Save to PDF.") + return@then GeckoResult<Void>() + } + + val url = this.currentUrl ?: "" + val contentType = "application/pdf" + val disposition = currentTitle?.let { makePdfContentDisposition(it) } + // A successful status code suffices because the PDF is generated on device. + val responseStatus = RESPONSE_CODE_SUCCESS + // We do not know the size at this point; send 0 so consumers do not display it. + val contentLength = 0L + // NB: If the title is an empty string, there is a chance the PDF will not have a name. + // See https://github.com/mozilla-mobile/android-components/issues/12276 + val fileName = DownloadUtils.guessFileName( + disposition, + destinationDirectory = null, + url = url, + mimeType = contentType, + ) + + val response = Response( + url = url, + status = responseStatus, + headers = MutableHeaders(), + body = Response.Body(inputStream), + ) + + notifyObservers { + onExternalResource( + url = url, + contentLength = contentLength, + contentType = contentType, + fileName = fileName, + response = response, + isPrivate = privateMode, + ) + } + + notifyObservers { + onSaveToPdfComplete() + } + + GeckoResult() + }, + { throwable -> + // Log the error. There is nothing we can do otherwise. + logger.error("Save to PDF failed.", throwable) + notifyObservers { + onSaveToPdfException(throwable) + } + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.requestPrintContent] + */ + override fun requestPrintContent() { + geckoSession.didPrintPageContent().then( + { finishedPrinting -> + if (finishedPrinting == true) { + notifyObservers { + onPrintFinish() + } + } + GeckoResult<Void>() + }, + { throwable -> + logger.error("Printing failed.", throwable) + notifyObservers { + onPrintException(true, throwable) + } + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.stopLoading] + */ + override fun stopLoading() { + geckoSession.stop() + } + + /** + * See [EngineSession.reload] + */ + override fun reload(flags: LoadUrlFlags) { + initialLoadRequest?.let { + // We have a pending initial load request, which means we never + // successfully loaded a page. Calling reload now would just reload + // about:blank. To prevent that we trigger the initial load again. + loadUrl(it.url, it.parent, it.flags, it.additionalHeaders) + } ?: geckoSession.reload(flags.getGeckoFlags()) + } + + /** + * See [EngineSession.goBack] + */ + override fun goBack(userInteraction: Boolean) { + geckoSession.goBack(userInteraction) + if (canGoBack) { + notifyObservers { onNavigateBack() } + } + } + + /** + * See [EngineSession.goForward] + */ + override fun goForward(userInteraction: Boolean) { + geckoSession.goForward(userInteraction) + if (canGoForward) { + notifyObservers { onNavigateForward() } + } + } + + /** + * See [EngineSession.goToHistoryIndex] + */ + override fun goToHistoryIndex(index: Int) { + geckoSession.gotoHistoryIndex(index) + notifyObservers { onGotoHistoryIndex() } + } + + /** + * See [EngineSession.restoreState] + */ + override fun restoreState(state: EngineSessionState): Boolean { + if (state !is GeckoEngineSessionState) { + throw IllegalStateException("Can only restore from GeckoEngineSessionState") + } + // Also checking if SessionState is empty as a workaround for: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1687523 + if (state.actualState.isNullOrEmpty()) { + return false + } + + geckoSession.restoreState(state.actualState) + return true + } + + /** + * See [EngineSession.updateTrackingProtection] + */ + override fun updateTrackingProtection(policy: TrackingProtectionPolicy) { + updateContentBlocking(policy) + val enabled = policy != TrackingProtectionPolicy.none() + etpEnabled = enabled + notifyObservers { + onTrackerBlockingEnabledChange(this, enabled) + } + } + + @VisibleForTesting + internal fun updateContentBlocking(policy: TrackingProtectionPolicy) { + /** + * As described on https://bugzilla.mozilla.org/show_bug.cgi?id=1579264,useTrackingProtection + * is a misleading setting. When is set to true is blocking content (scripts/sub-resources). + * Instead of just turn on/off tracking protection. Until, this issue is fixed consumers need + * a way to indicate, if they want to block content or not, this is why we use + * [TrackingProtectionPolicy.TrackingCategory.SCRIPTS_AND_SUB_RESOURCES]. + */ + val shouldBlockContent = + policy.contains(TrackingProtectionPolicy.TrackingCategory.SCRIPTS_AND_SUB_RESOURCES) + + val enabledInBrowsingMode = if (privateMode) { + policy.useForPrivateSessions + } else { + policy.useForRegularSessions + } + geckoSession.settings.useTrackingProtection = enabledInBrowsingMode && shouldBlockContent + } + + // This is a temporary solution to address + // https://github.com/mozilla-mobile/android-components/issues/8431 + // until we eventually delete [EngineObserver] then this will not be needed. + @VisibleForTesting + internal var etpEnabled: Boolean? = null + + override fun register(observer: Observer) { + super.register(observer) + etpEnabled?.let { enabled -> + onTrackerBlockingEnabledChange(observer, enabled) + } + } + + private fun onTrackerBlockingEnabledChange(observer: Observer, enabled: Boolean) { + // We now register engine observers in a middleware using a dedicated + // store thread. Since this notification can be delayed until an observer + // is registered we switch to the main scope to make sure we're not notifying + // on the store thread. + MainScope().launch { + observer.onTrackerBlockingEnabledChange(enabled) + } + } + + /** + * Indicates if this [EngineSession] should be ignored the tracking protection policies. + * @return if this [EngineSession] is in + * the exception list, true if it is in, otherwise false. + */ + internal fun isIgnoredForTrackingProtection(): Boolean { + return geckoPermissions.any { it.isExcludedForTrackingProtection } + } + + /** + * See [EngineSession.settings] + */ + override fun toggleDesktopMode(enable: Boolean, reload: Boolean) { + val currentMode = geckoSession.settings.userAgentMode + val currentViewPortMode = geckoSession.settings.viewportMode + var overrideUrl: String? = null + + val newMode = if (enable) { + GeckoSessionSettings.USER_AGENT_MODE_DESKTOP + } else { + GeckoSessionSettings.USER_AGENT_MODE_MOBILE + } + + val newViewportMode = if (enable) { + overrideUrl = currentUrl?.let { checkForMobileSite(it) } + GeckoSessionSettings.VIEWPORT_MODE_DESKTOP + } else { + GeckoSessionSettings.VIEWPORT_MODE_MOBILE + } + + if (newMode != currentMode || newViewportMode != currentViewPortMode) { + geckoSession.settings.userAgentMode = newMode + geckoSession.settings.viewportMode = newViewportMode + notifyObservers { onDesktopModeChange(enable) } + } + + if (reload) { + if (overrideUrl == null) { + this.reload() + } else { + loadUrl(overrideUrl, flags = LoadUrlFlags.select(LoadUrlFlags.LOAD_FLAGS_REPLACE_HISTORY)) + } + } + } + + /** + * See [EngineSession.hasCookieBannerRuleForSession] + */ + override fun hasCookieBannerRuleForSession( + onResult: (Boolean) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.hasCookieBannerRuleForBrowsingContextTree().then( + { response -> + if (response == null) { + logger.error( + "Invalid value: unable to get response from hasCookieBannerRuleForBrowsingContextTree.", + ) + onException( + java.lang.IllegalStateException( + "Invalid value: unable to get response from hasCookieBannerRuleForBrowsingContextTree.", + ), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<Boolean>() + }, + { throwable -> + logger.error("Checking for cookie banner rule failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * Checks and returns a non-mobile version of the url. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun checkForMobileSite(url: String): String? { + var overrideUrl: String? = null + val mPrefix = "m." + val mobilePrefix = "mobile." + + val uri = Uri.parse(url) + val authority = uri.authority?.lowercase(Locale.ROOT) ?: return null + + val foundPrefix = when { + authority.startsWith(mPrefix) -> mPrefix + authority.startsWith(mobilePrefix) -> mobilePrefix + else -> null + } + + foundPrefix?.let { + val mobileUri = Uri.parse(url).buildUpon().authority(authority.substring(it.length)) + overrideUrl = mobileUri.toString() + } + + return overrideUrl + } + + /** + * See [EngineSession.findAll] + */ + override fun findAll(text: String) { + notifyObservers { onFind(text) } + geckoSession.finder.find(text, 0).then { result: GeckoSession.FinderResult? -> + result?.let { + val activeMatchOrdinal = if (it.current > 0) it.current - 1 else it.current + notifyObservers { onFindResult(activeMatchOrdinal, it.total, true) } + } + GeckoResult<Void>() + } + } + + /** + * See [EngineSession.findNext] + */ + @SuppressLint("WrongConstant") // FinderFindFlags annotation doesn't include a 0 value. + override fun findNext(forward: Boolean) { + val findFlags = if (forward) 0 else GeckoSession.FINDER_FIND_BACKWARDS + geckoSession.finder.find(null, findFlags).then { result: GeckoSession.FinderResult? -> + result?.let { + val activeMatchOrdinal = if (it.current > 0) it.current - 1 else it.current + notifyObservers { onFindResult(activeMatchOrdinal, it.total, true) } + } + GeckoResult<Void>() + } + } + + /** + * See [EngineSession.clearFindMatches] + */ + override fun clearFindMatches() { + geckoSession.finder.clear() + } + + /** + * See [EngineSession.exitFullScreenMode] + */ + override fun exitFullScreenMode() { + geckoSession.exitFullScreen() + } + + /** + * See [EngineSession.markActiveForWebExtensions]. + */ + override fun markActiveForWebExtensions(active: Boolean) { + runtime.webExtensionController.setTabActive(geckoSession, active) + } + + /** + * See [EngineSession.updateSessionPriority]. + */ + override fun updateSessionPriority(priority: SessionPriority) { + geckoSession.setPriorityHint(priority.id) + } + + /** + * See [EngineSession.setDisplayMode]. + */ + override fun setDisplayMode(displayMode: WebAppManifest.DisplayMode) { + geckoSession.settings.displayMode = when (displayMode) { + WebAppManifest.DisplayMode.MINIMAL_UI -> GeckoSessionSettings.DISPLAY_MODE_MINIMAL_UI + WebAppManifest.DisplayMode.FULLSCREEN -> GeckoSessionSettings.DISPLAY_MODE_FULLSCREEN + WebAppManifest.DisplayMode.STANDALONE -> GeckoSessionSettings.DISPLAY_MODE_STANDALONE + else -> GeckoSessionSettings.DISPLAY_MODE_BROWSER + } + } + + /** + * See [EngineSession.checkForFormData]. + */ + override fun checkForFormData() { + geckoSession.containsFormData().then( + { result -> + if (result == null) { + logger.error("No result from GeckoView containsFormData.") + return@then GeckoResult<Boolean>() + } + notifyObservers { onCheckForFormData(result) } + GeckoResult<Boolean>() + }, + { throwable -> + notifyObservers { + onCheckForFormDataException(throwable) + } + GeckoResult<Boolean>() + }, + ) + } + + /** + * Checks if a PDF viewer is being used on the current page or not via GeckoView session. + */ + override fun checkForPdfViewer( + onResult: (Boolean) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.isPdfJs.then( + { response -> + if (response == null) { + logger.error( + "Invalid value: No result from GeckoView if a PDF viewer is used.", + ) + onException( + IllegalStateException( + "Invalid value: No result from GeckoView if a PDF viewer is used.", + ), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<Boolean>() + }, + { throwable -> + logger.error("Checking for PDF viewer failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.requestProductRecommendations] + */ + override fun requestProductRecommendations( + url: String, + onResult: (List<ProductRecommendation>) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.requestRecommendations(url).then( + { response: List<Recommendation>? -> + if (response == null) { + logger.error("Invalid value: unable to get analysis result from Gecko Engine.") + onException( + java.lang.IllegalStateException( + "Invalid value: unable to get analysis result from Gecko Engine.", + ), + ) + return@then GeckoResult() + } + + val productRecommendations = response.map { it: Recommendation -> + ProductRecommendation( + url = it.url, + analysisUrl = it.analysisUrl, + adjustedRating = it.adjustedRating, + sponsored = it.sponsored, + imageUrl = it.imageUrl, + aid = it.aid, + name = it.name, + grade = it.grade, + price = it.price, + currency = it.currency, + ) + } + onResult(productRecommendations) + GeckoResult<ProductRecommendation>() + }, + { throwable -> + logger.error("Requesting product analysis failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.requestProductAnalysis] + */ + @Suppress("ComplexCondition") + override fun requestProductAnalysis( + url: String, + onResult: (ProductAnalysis) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.requestAnalysis(url).then( + { response -> + if (response == null) { + logger.error( + "Invalid value: unable to get analysis result from Gecko Engine.", + ) + onException( + java.lang.IllegalStateException( + "Invalid value: unable to get analysis result from Gecko Engine.", + ), + ) + return@then GeckoResult() + } + + val highlights = if ( + response.highlights?.quality == null && + response.highlights?.price == null && + response.highlights?.shipping == null && + response.highlights?.appearance == null && + response.highlights?.competitiveness == null + ) { + null + } else { + Highlight( + response.highlights?.quality?.toList(), + response.highlights?.price?.toList(), + response.highlights?.shipping?.toList(), + response.highlights?.appearance?.toList(), + response.highlights?.competitiveness?.toList(), + ) + } + + val analysisResult = ProductAnalysis( + productId = response.productId, + analysisURL = response.analysisURL, + grade = response.grade, + adjustedRating = response.adjustedRating, + needsAnalysis = response.needsAnalysis, + pageNotSupported = response.pageNotSupported, + notEnoughReviews = response.notEnoughReviews, + lastAnalysisTime = response.lastAnalysisTime, + deletedProductReported = response.deletedProductReported, + deletedProduct = response.deletedProduct, + highlights = highlights, + ) + + onResult(analysisResult) + GeckoResult<ProductAnalysis>() + }, + { throwable -> + logger.error("Requesting product analysis failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.reanalyzeProduct] + */ + override fun reanalyzeProduct( + url: String, + onResult: (String) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.requestCreateAnalysis(url).then( + { response -> + val errorMessage = "Invalid value: unable to reanalyze product from Gecko Engine." + if (response == null) { + logger.error(errorMessage) + onException( + java.lang.IllegalStateException(errorMessage), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<String>() + }, + { throwable -> + logger.error("Request to reanalyze product failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.requestAnalysisStatus] + */ + override fun requestAnalysisStatus( + url: String, + onResult: (ProductAnalysisStatus) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.requestAnalysisStatus(url).then( + { response -> + val errorMessage = "Invalid value: unable to request analysis status from Gecko Engine." + if (response == null) { + logger.error(errorMessage) + onException( + java.lang.IllegalStateException(errorMessage), + ) + return@then GeckoResult() + } + val analysisStatusResult = ProductAnalysisStatus( + status = response.status, + progress = response.progress, + ) + onResult(analysisStatusResult) + GeckoResult<ProductAnalysisStatus>() + }, + { throwable -> + logger.error("Request for product analysis status failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.sendClickAttributionEvent] + */ + override fun sendClickAttributionEvent( + aid: String, + onResult: (Boolean) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.sendClickAttributionEvent(aid).then( + { response -> + val errorMessage = "Invalid value: unable to send click attribution event through Gecko Engine." + if (response == null) { + logger.error(errorMessage) + onException( + java.lang.IllegalStateException(errorMessage), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<Boolean>() + }, + { throwable -> + logger.error("Sending click attribution event failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.sendImpressionAttributionEvent] + */ + override fun sendImpressionAttributionEvent( + aid: String, + onResult: (Boolean) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.sendImpressionAttributionEvent(aid).then( + { response -> + val errorMessage = "Invalid value: unable to send impression attribution event through Gecko Engine." + if (response == null) { + logger.error(errorMessage) + onException( + java.lang.IllegalStateException(errorMessage), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<Boolean>() + }, + { throwable -> + logger.error("Sending impression attribution event failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.sendPlacementAttributionEvent] + */ + override fun sendPlacementAttributionEvent( + aid: String, + onResult: (Boolean) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.sendPlacementAttributionEvent(aid).then( + { response -> + val errorMessage = "Invalid value: unable to send placement attribution event through Gecko Engine." + if (response == null) { + logger.error(errorMessage) + onException( + java.lang.IllegalStateException(errorMessage), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<Boolean>() + }, + { throwable -> + logger.error("Sending placement attribution event failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.reportBackInStock] + */ + override fun reportBackInStock( + url: String, + onResult: (String) -> Unit, + onException: (Throwable) -> Unit, + ) { + geckoSession.reportBackInStock(url).then( + { response -> + val errorMessage = "Invalid value: unable to report back in stock from Gecko Engine." + if (response == null) { + logger.error(errorMessage) + onException( + java.lang.IllegalStateException(errorMessage), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<String>() + }, + { throwable -> + logger.error("Request for reporting back in stock failed.", throwable) + onException(throwable) + GeckoResult() + }, + ) + } + + /** + * See [EngineSession.requestTranslate] + */ + override fun requestTranslate( + fromLanguage: String, + toLanguage: String, + options: TranslationOptions?, + ) { + if (geckoSession.sessionTranslation == null) { + notifyObservers { + onTranslateException( + TranslationOperation.TRANSLATE, + TranslationError.MissingSessionCoordinator(), + ) + } + return + } + + var geckoOptions: GeckoViewTranslateSession.TranslationOptions? = null + if (options != null) { + geckoOptions = + GeckoViewTranslateSession.TranslationOptions.Builder() + .downloadModel(options.downloadModel).build() + } + + geckoSession.sessionTranslation!!.translate(fromLanguage, toLanguage, geckoOptions).then({ + notifyObservers { + onTranslateComplete(TranslationOperation.TRANSLATE) + } + GeckoResult<Void>() + }, { + throwable -> + logger.error("Request for translation failed: ", throwable) + notifyObservers { + onTranslateException( + TranslationOperation.TRANSLATE, + throwable.intoTranslationError(), + ) + } + GeckoResult() + }) + } + + /** + * See [EngineSession.requestTranslationRestore] + */ + override fun requestTranslationRestore() { + if (geckoSession.sessionTranslation == null) { + notifyObservers { + onTranslateException( + TranslationOperation.RESTORE, + TranslationError.MissingSessionCoordinator(), + ) + } + return + } + + geckoSession.sessionTranslation!!.restoreOriginalPage().then({ + notifyObservers { + onTranslateComplete(TranslationOperation.RESTORE) + } + GeckoResult<Void>() + }, { + throwable -> + logger.error("Request for translation failed: ", throwable) + notifyObservers { + onTranslateException(TranslationOperation.RESTORE, throwable.intoTranslationError()) + } + GeckoResult() + }) + } + + /** + * See [EngineSession.getNeverTranslateSiteSetting] + */ + override fun getNeverTranslateSiteSetting( + onResult: (Boolean) -> Unit, + onException: (Throwable) -> Unit, + ) { + if (geckoSession.sessionTranslation == null) { + onException(TranslationError.MissingSessionCoordinator()) + return + } + + geckoSession.sessionTranslation!!.neverTranslateSiteSetting.then({ + response -> + if (response == null) { + logger.error("Did not receive a site setting response.") + onException( + TranslationError.UnexpectedNull(), + ) + return@then GeckoResult() + } + onResult(response) + GeckoResult<Boolean>() + }, { + throwable -> + logger.error("Request for site translation preference failed: ", throwable) + onException(throwable.intoTranslationError()) + GeckoResult() + }) + } + + /** + * See [EngineSession.setNeverTranslateSiteSetting] + */ + override fun setNeverTranslateSiteSetting( + setting: Boolean, + onResult: () -> Unit, + onException: (Throwable) -> Unit, + ) { + if (geckoSession.sessionTranslation == null) { + onException(TranslationError.MissingSessionCoordinator()) + return + } + + geckoSession.sessionTranslation!!.setNeverTranslateSiteSetting(setting).then({ + onResult() + GeckoResult<Boolean>() + }, { + throwable -> + logger.error("Request for setting site translation preference failed: ", throwable) + onException(throwable.intoTranslationError()) + GeckoResult() + }) + } + + /** + * Purges the history for the session (back and forward history). + */ + override fun purgeHistory() { + geckoSession.purgeHistory() + } + + /** + * See [EngineSession.close]. + */ + override fun close() { + super.close() + job.cancel() + geckoSession.close() + } + + override fun getBlockedSchemes(): List<String> { + return BLOCKED_SCHEMES + } + + /** + * NavigationDelegate implementation for forwarding callbacks to observers of the session. + */ + @Suppress("ComplexMethod") + private fun createNavigationDelegate() = object : GeckoSession.NavigationDelegate { + override fun onLocationChange( + session: GeckoSession, + url: String?, + geckoPermissions: List<ContentPermission>, + hasUserGesture: Boolean, + ) { + this@GeckoEngineSession.geckoPermissions = geckoPermissions + if (url == null) { + return // ¯\_(ツ)_/¯ + } + + // Ignore initial loads of about:blank, see: + // https://github.com/mozilla-mobile/android-components/issues/403 + // https://github.com/mozilla-mobile/android-components/issues/6832 + if (initialLoad && url == ABOUT_BLANK) { + return + } + + appRedirectUrl?.let { + if (url == appRedirectUrl) { + goBack(false) + return + } + } + + currentUrl = url + initialLoad = false + initialLoadRequest = null + + notifyObservers { + onExcludedOnTrackingProtectionChange(isIgnoredForTrackingProtection()) + } + // Re-set the status of cookie banner handling when the user navigates to another site. + notifyObservers { + onCookieBannerChange(CookieBannerHandlingStatus.NO_DETECTED) + } + // Reset the status of current page being product or not when user navigates away. + notifyObservers { onProductUrlChange(false) } + notifyObservers { onLocationChange(url, hasUserGesture) } + } + + override fun onLoadRequest( + session: GeckoSession, + request: NavigationDelegate.LoadRequest, + ): GeckoResult<AllowOrDeny> { + // The process switch involved when loading extension pages will + // trigger an initial load of about:blank which we want to + // avoid: + // https://github.com/mozilla-mobile/android-components/issues/6832 + // https://github.com/mozilla-mobile/android-components/issues/403 + if (currentUrl?.isExtensionUrl() != request.uri.isExtensionUrl()) { + initialLoad = true + } + + return when { + maybeInterceptRequest(request, false) != null -> + GeckoResult.fromValue(AllowOrDeny.DENY) + request.target == NavigationDelegate.TARGET_WINDOW_NEW -> + GeckoResult.fromValue(AllowOrDeny.ALLOW) + else -> { + notifyObservers { + onLoadRequest( + url = request.uri, + triggeredByRedirect = request.isRedirect, + triggeredByWebContent = request.hasUserGesture, + ) + } + + GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + } + } + + override fun onSubframeLoadRequest( + session: GeckoSession, + request: NavigationDelegate.LoadRequest, + ): GeckoResult<AllowOrDeny> { + if (request.target == NavigationDelegate.TARGET_WINDOW_NEW) { + return GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + + return if (maybeInterceptRequest(request, true) != null) { + GeckoResult.fromValue(AllowOrDeny.DENY) + } else { + // Not notifying session observer because of performance concern and currently there + // is no use case. + GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + } + + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + notifyObservers { onNavigationStateChange(canGoForward = canGoForward) } + this@GeckoEngineSession.canGoForward = canGoForward + } + + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + notifyObservers { onNavigationStateChange(canGoBack = canGoBack) } + this@GeckoEngineSession.canGoBack = canGoBack + } + + override fun onNewSession( + session: GeckoSession, + uri: String, + ): GeckoResult<GeckoSession> { + val newEngineSession = + GeckoEngineSession(runtime, privateMode, defaultSettings, openGeckoSession = false) + notifyObservers { + onWindowRequest(GeckoWindowRequest(uri, newEngineSession)) + } + return GeckoResult.fromValue(newEngineSession.geckoSession) + } + + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult<String> { + val response = settings.requestInterceptor?.onErrorRequest( + this@GeckoEngineSession, + geckoErrorToErrorType(error.code), + uri, + ) + return GeckoResult.fromValue(response?.uri) + } + + private fun maybeInterceptRequest( + request: NavigationDelegate.LoadRequest, + isSubframeRequest: Boolean, + ): InterceptionResponse? { + if (request.hasUserGesture) { + lastLoadRequestUri = "" + } + + val interceptor = settings.requestInterceptor + val interceptionResponse = if ( + interceptor != null && (!request.isDirectNavigation || interceptor.interceptsAppInitiatedRequests()) + ) { + val engineSession = this@GeckoEngineSession + val isSameDomain = + engineSession.currentUrl?.tryGetHostFromUrl() == request.uri.tryGetHostFromUrl() + interceptor.onLoadRequest( + engineSession, + request.uri, + lastLoadRequestUri, + request.hasUserGesture, + isSameDomain, + request.isRedirect, + request.isDirectNavigation, + isSubframeRequest, + )?.apply { + when (this) { + is InterceptionResponse.Content -> loadData(data, mimeType, encoding) + is InterceptionResponse.Url -> loadUrl( + url = url, + flags = flags, + additionalHeaders = additionalHeaders, + ) + is InterceptionResponse.AppIntent -> { + appRedirectUrl = lastLoadRequestUri + notifyObservers { + onLaunchIntentRequest(url = url, appIntent = appIntent) + } + } + else -> { + // no-op + } + } + } + } else { + null + } + + if (interceptionResponse !is InterceptionResponse.AppIntent) { + appRedirectUrl = "" + } + + lastLoadRequestUri = request.uri + return interceptionResponse + } + } + + /** + * ProgressDelegate implementation for forwarding callbacks to observers of the session. + */ + private fun createProgressDelegate() = object : GeckoSession.ProgressDelegate { + override fun onProgressChange(session: GeckoSession, progress: Int) { + notifyObservers { onProgress(progress) } + } + + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + // Ignore initial load of about:blank (see https://github.com/mozilla-mobile/android-components/issues/403) + if (initialLoad && securityInfo.origin?.startsWith(MOZ_NULL_PRINCIPAL) == true) { + return + } + + notifyObservers { + // TODO provide full certificate info: https://github.com/mozilla-mobile/android-components/issues/5557 + onSecurityChange( + securityInfo.isSecure, + securityInfo.host, + securityInfo.getIssuerName(), + ) + } + } + + override fun onPageStart(session: GeckoSession, url: String) { + // This log statement is temporary and parsed by FNPRMS for performance measurements. It can be + // removed once FNPRMS is replaced: https://github.com/mozilla-mobile/android-components/issues/8662 + fnprmsLogger.info("handleMessage GeckoView:PageStart uri=") // uri intentionally blank + + pageLoadingUrl = url + + // Ignore initial load of about:blank (see https://github.com/mozilla-mobile/android-components/issues/403) + if (initialLoad && url == ABOUT_BLANK) { + return + } + + notifyObservers { + onProgress(PROGRESS_START) + onLoadingStateChange(true) + } + } + + override fun onPageStop(session: GeckoSession, success: Boolean) { + // This log statement is temporary and parsed by FNPRMS for performance measurements. It can be + // removed once FNPRMS is replaced: https://github.com/mozilla-mobile/android-components/issues/8662 + fnprmsLogger.info("handleMessage GeckoView:PageStop uri=null") // uri intentionally hard-coded to null + // by the time we reach here, any new request will come from web content. + // If it comes from the chrome, loadUrl(url) or loadData(string) will set it to + // false. + + // Ignore initial load of about:blank (see https://github.com/mozilla-mobile/android-components/issues/403) + if (initialLoad && pageLoadingUrl == ABOUT_BLANK) { + return + } + + notifyObservers { + onProgress(PROGRESS_STOP) + onLoadingStateChange(false) + } + } + + override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) { + notifyObservers { + onStateUpdated(GeckoEngineSessionState(sessionState)) + } + } + } + + @Suppress("ComplexMethod") + internal fun createHistoryDelegate() = object : GeckoSession.HistoryDelegate { + @SuppressWarnings("ReturnCount") + override fun onVisited( + session: GeckoSession, + url: String, + lastVisitedURL: String?, + flags: Int, + ): GeckoResult<Boolean>? { + // Don't track: + // - private visits + // - error pages + // - non-top level visits (i.e. iframes). + if (privateMode || + (flags and GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL) == 0 || + (flags and GeckoSession.HistoryDelegate.VISIT_UNRECOVERABLE_ERROR) != 0 + ) { + return GeckoResult.fromValue(false) + } + + appRedirectUrl?.let { + if (url == appRedirectUrl) { + return GeckoResult.fromValue(false) + } + } + + val delegate = settings.historyTrackingDelegate ?: return GeckoResult.fromValue(false) + + // Check if the delegate wants this type of url. + if (!delegate.shouldStoreUri(url)) { + return GeckoResult.fromValue(false) + } + + val isReload = lastVisitedURL?.let { it == url } ?: false + + // Note the difference between `VISIT_REDIRECT_PERMANENT`, + // `VISIT_REDIRECT_TEMPORARY`, `VISIT_REDIRECT_SOURCE`, and + // `VISIT_REDIRECT_SOURCE_PERMANENT`. + // + // The former two indicate if the visited page is the *target* + // of a redirect; that is, another page redirected to it. + // + // The latter two indicate if the visited page is the *source* + // of a redirect: it's redirecting to another page, because the + // server returned an HTTP 3xy status code. + // + // So, we mark the **source** redirects as actual redirects, while treating **target** + // redirects as normal visits. + val visitType = when { + isReload -> VisitType.RELOAD + flags and GeckoSession.HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT != 0 -> + VisitType.REDIRECT_PERMANENT + flags and GeckoSession.HistoryDelegate.VISIT_REDIRECT_SOURCE != 0 -> + VisitType.REDIRECT_TEMPORARY + else -> VisitType.LINK + } + val redirectSource = when { + flags and GeckoSession.HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT != 0 -> + RedirectSource.PERMANENT + flags and GeckoSession.HistoryDelegate.VISIT_REDIRECT_SOURCE != 0 -> + RedirectSource.TEMPORARY + else -> null + } + + return launchGeckoResult { + delegate.onVisited(url, PageVisit(visitType, redirectSource)) + true + } + } + + override fun getVisited( + session: GeckoSession, + urls: Array<out String>, + ): GeckoResult<BooleanArray>? { + if (privateMode) { + return GeckoResult.fromValue(null) + } + + val delegate = settings.historyTrackingDelegate ?: return GeckoResult.fromValue(null) + + return launchGeckoResult { + val visits = delegate.getVisited(urls.toList()) + visits.toBooleanArray() + } + } + + override fun onHistoryStateChange( + session: GeckoSession, + historyList: GeckoSession.HistoryDelegate.HistoryList, + ) { + val items = historyList.map { + // title is sometimes null despite the @NotNull annotation + // https://bugzilla.mozilla.org/show_bug.cgi?id=1660286 + val title: String? = it.title + HistoryItem( + title = title ?: it.uri, + uri = it.uri, + ) + } + notifyObservers { onHistoryStateChanged(items, historyList.currentIndex) } + } + } + + @Suppress("ComplexMethod", "NestedBlockDepth") + internal fun createContentDelegate() = object : GeckoSession.ContentDelegate { + override fun onCookieBannerDetected(session: GeckoSession) { + notifyObservers { onCookieBannerChange(CookieBannerHandlingStatus.DETECTED) } + } + + override fun onCookieBannerHandled(session: GeckoSession) { + notifyObservers { onCookieBannerChange(CookieBannerHandlingStatus.HANDLED) } + } + + override fun onProductUrl(session: GeckoSession) { + notifyObservers { onProductUrlChange(true) } + } + + override fun onFirstComposite(session: GeckoSession) = Unit + + override fun onFirstContentfulPaint(session: GeckoSession) { + notifyObservers { onFirstContentfulPaint() } + } + + override fun onPaintStatusReset(session: GeckoSession) { + notifyObservers { onPaintStatusReset() } + } + + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: GeckoSession.ContentDelegate.ContextElement, + ) { + val hitResult = handleLongClick(element.srcUri, element.type, element.linkUri, element.title) + hitResult?.let { + notifyObservers { onLongPress(it) } + } + } + + override fun onCrash(session: GeckoSession) { + notifyObservers { onCrash() } + } + + override fun onKill(session: GeckoSession) { + notifyObservers { + onProcessKilled() + } + } + + override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { + notifyObservers { onFullScreenChange(fullScreen) } + } + + override fun onExternalResponse(session: GeckoSession, webResponse: WebResponse) { + with(webResponse) { + val contentType = headers[CONTENT_TYPE]?.trim() + val contentLength = headers[CONTENT_LENGTH]?.trim()?.toLongOrNull() + val contentDisposition = headers[CONTENT_DISPOSITION]?.trim() + val url = uri + val fileName = DownloadUtils.guessFileName( + contentDisposition, + destinationDirectory = null, + url = url, + mimeType = contentType, + ) + val response = webResponse.toResponse() + notifyObservers { + onExternalResource( + url = url, + contentLength = contentLength, + contentType = DownloadUtils.sanitizeMimeType(contentType), + fileName = fileName.sanitizeFileName(), + response = response, + isPrivate = privateMode, + openInApp = webResponse.requestExternalApp, + skipConfirmation = webResponse.skipConfirmation, + ) + } + } + } + + override fun onCloseRequest(session: GeckoSession) { + notifyObservers { + onWindowRequest( + GeckoWindowRequest( + engineSession = this@GeckoEngineSession, + type = WindowRequest.Type.CLOSE, + ), + ) + } + } + + override fun onTitleChange(session: GeckoSession, title: String?) { + if (appRedirectUrl.isNullOrEmpty()) { + if (!privateMode) { + currentUrl?.let { url -> + settings.historyTrackingDelegate?.let { delegate -> + if (delegate.shouldStoreUri(url)) { + // NB: There's no guarantee that the title change will be processed by the + // delegate before the session is closed (and the corresponding coroutine + // job is cancelled). Observers will always be notified of the title + // change though. + launch(coroutineContext) { + delegate.onTitleChanged(url, title ?: "") + } + } + } + } + } + this@GeckoEngineSession.currentTitle = title + notifyObservers { onTitleChange(title ?: "") } + } + } + + override fun onPreviewImage(session: GeckoSession, previewImageUrl: String) { + if (!privateMode) { + currentUrl?.let { url -> + settings.historyTrackingDelegate?.let { delegate -> + if (delegate.shouldStoreUri(url)) { + launch(coroutineContext) { + delegate.onPreviewImageChange(url, previewImageUrl) + } + } + } + } + } + notifyObservers { onPreviewImageChange(previewImageUrl) } + } + + override fun onFocusRequest(session: GeckoSession) = Unit + + override fun onWebAppManifest(session: GeckoSession, manifest: JSONObject) { + val parsed = WebAppManifestParser().parse(manifest) + if (parsed is WebAppManifestParser.Result.Success) { + notifyObservers { onWebAppManifestLoaded(parsed.manifest) } + } + } + + override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val layoutInDisplayCutoutMode = when (viewportFit) { + "cover" -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + "contain" -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER + else -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + + notifyObservers { onMetaViewportFitChanged(layoutInDisplayCutoutMode) } + } + } + + override fun onShowDynamicToolbar(geckoSession: GeckoSession) { + notifyObservers { onShowDynamicToolbar() } + } + } + + private fun createContentBlockingDelegate() = object : ContentBlocking.Delegate { + override fun onContentBlocked(session: GeckoSession, event: ContentBlocking.BlockEvent) { + notifyObservers { + onTrackerBlocked(event.toTracker()) + } + } + + override fun onContentLoaded(session: GeckoSession, event: ContentBlocking.BlockEvent) { + notifyObservers { + onTrackerLoaded(event.toTracker()) + } + } + } + + private fun ContentBlocking.BlockEvent.toTracker(): Tracker { + val blockedContentCategories = mutableListOf<TrackingProtectionPolicy.TrackingCategory>() + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.AD)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.AD) + } + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.ANALYTIC)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.ANALYTICS) + } + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.SOCIAL)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.SOCIAL) + } + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.FINGERPRINTING)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.FINGERPRINTING) + } + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.CRYPTOMINING)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING) + } + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.CONTENT)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.CONTENT) + } + + if (antiTrackingCategory.contains(ContentBlocking.AntiTracking.TEST)) { + blockedContentCategories.add(TrackingProtectionPolicy.TrackingCategory.TEST) + } + + return Tracker( + url = uri, + trackingCategories = blockedContentCategories, + cookiePolicies = getCookiePolicies(), + ) + } + + private fun ContentBlocking.BlockEvent.getCookiePolicies(): List<TrackingProtectionPolicy.CookiePolicy> { + val cookiesPolicies = mutableListOf<TrackingProtectionPolicy.CookiePolicy>() + + if (cookieBehaviorCategory == ContentBlocking.CookieBehavior.ACCEPT_ALL) { + cookiesPolicies.add(TrackingProtectionPolicy.CookiePolicy.ACCEPT_ALL) + } + + if (cookieBehaviorCategory.contains(ContentBlocking.CookieBehavior.ACCEPT_FIRST_PARTY)) { + cookiesPolicies.add(TrackingProtectionPolicy.CookiePolicy.ACCEPT_ONLY_FIRST_PARTY) + } + + if (cookieBehaviorCategory.contains(ContentBlocking.CookieBehavior.ACCEPT_NONE)) { + cookiesPolicies.add(TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE) + } + + if (cookieBehaviorCategory.contains(ContentBlocking.CookieBehavior.ACCEPT_NON_TRACKERS)) { + cookiesPolicies.add(TrackingProtectionPolicy.CookiePolicy.ACCEPT_NON_TRACKERS) + } + + if (cookieBehaviorCategory.contains(ContentBlocking.CookieBehavior.ACCEPT_VISITED)) { + cookiesPolicies.add(TrackingProtectionPolicy.CookiePolicy.ACCEPT_VISITED) + } + + return cookiesPolicies + } + + internal fun GeckoSession.ProgressDelegate.SecurityInformation.getIssuerName(): String? { + return certificate?.issuerDN?.name?.substringAfterLast("O=")?.substringBeforeLast(",C=") + } + + private operator fun Int.contains(mask: Int): Boolean { + return (this and mask) != 0 + } + + private fun createPermissionDelegate() = object : GeckoSession.PermissionDelegate { + override fun onContentPermissionRequest( + session: GeckoSession, + geckoContentPermission: ContentPermission, + ): GeckoResult<Int> { + val geckoResult = GeckoResult<Int>() + val uri = geckoContentPermission.uri + val type = geckoContentPermission.permission + val request = GeckoPermissionRequest.Content(uri, type, geckoContentPermission, geckoResult) + notifyObservers { onContentPermissionRequest(request) } + return geckoResult + } + + override fun onMediaPermissionRequest( + session: GeckoSession, + uri: String, + video: Array<out GeckoSession.PermissionDelegate.MediaSource>?, + audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?, + callback: GeckoSession.PermissionDelegate.MediaCallback, + ) { + val request = GeckoPermissionRequest.Media( + uri, + video?.toList() ?: emptyList(), + audio?.toList() ?: emptyList(), + callback, + ) + notifyObservers { onContentPermissionRequest(request) } + } + + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array<out String>?, + callback: GeckoSession.PermissionDelegate.Callback, + ) { + val request = GeckoPermissionRequest.App( + permissions?.toList() ?: emptyList(), + callback, + ) + notifyObservers { onAppPermissionRequest(request) } + } + } + + private fun createScrollDelegate() = object : GeckoSession.ScrollDelegate { + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + this@GeckoEngineSession.scrollY = scrollY + notifyObservers { onScrollChange(scrollX, scrollY) } + } + } + + @Suppress("ComplexMethod") + fun handleLongClick(elementSrc: String?, elementType: Int, uri: String? = null, title: String? = null): HitResult? { + return when (elementType) { + GeckoSession.ContentDelegate.ContextElement.TYPE_AUDIO -> + elementSrc?.let { + HitResult.AUDIO(it, title) + } + GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO -> + elementSrc?.let { + HitResult.VIDEO(it, title) + } + GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE -> { + when { + elementSrc != null && uri != null -> + HitResult.IMAGE_SRC(elementSrc, uri) + elementSrc != null -> + HitResult.IMAGE(elementSrc, title) + else -> HitResult.UNKNOWN("") + } + } + GeckoSession.ContentDelegate.ContextElement.TYPE_NONE -> { + elementSrc?.let { + when { + it.isPhone() -> HitResult.PHONE(it) + it.isEmail() -> HitResult.EMAIL(it) + it.isGeoLocation() -> HitResult.GEO(it) + else -> HitResult.UNKNOWN(it) + } + } ?: uri?.let { + HitResult.UNKNOWN(it) + } + } + else -> HitResult.UNKNOWN("") + } + } + + private fun createGeckoSession(shouldOpen: Boolean = true) { + this.geckoSession = geckoSessionProvider() + + defaultSettings?.trackingProtectionPolicy?.let { updateTrackingProtection(it) } + defaultSettings?.requestInterceptor?.let { settings.requestInterceptor = it } + defaultSettings?.historyTrackingDelegate?.let { settings.historyTrackingDelegate = it } + defaultSettings?.testingModeEnabled?.let { + geckoSession.settings.fullAccessibilityTree = it + } + defaultSettings?.userAgentString?.let { geckoSession.settings.userAgentOverride = it } + defaultSettings?.suspendMediaWhenInactive?.let { + geckoSession.settings.suspendMediaWhenInactive = it + } + defaultSettings?.clearColor?.let { geckoSession.compositorController.clearColor = it } + + if (shouldOpen) { + geckoSession.open(runtime) + } + + geckoSession.navigationDelegate = createNavigationDelegate() + geckoSession.progressDelegate = createProgressDelegate() + geckoSession.contentDelegate = createContentDelegate() + geckoSession.contentBlockingDelegate = createContentBlockingDelegate() + geckoSession.permissionDelegate = createPermissionDelegate() + geckoSession.promptDelegate = GeckoPromptDelegate(this) + geckoSession.mediaDelegate = GeckoMediaDelegate(this) + geckoSession.historyDelegate = createHistoryDelegate() + geckoSession.mediaSessionDelegate = GeckoMediaSessionDelegate(this) + geckoSession.scrollDelegate = createScrollDelegate() + geckoSession.translationsSessionDelegate = GeckoTranslateSessionDelegate(this) + } + + companion object { + internal const val PROGRESS_START = 25 + internal const val PROGRESS_STOP = 100 + internal const val MOZ_NULL_PRINCIPAL = "moz-nullprincipal:" + internal const val ABOUT_BLANK = "about:blank" + internal const val JS_SCHEME = "javascript" + internal val BLOCKED_SCHEMES = + listOf("file", "resource", JS_SCHEME) // See 1684761 and 1684947 + + /** + * Provides an ErrorType corresponding to the error code provided. + */ + @Suppress("ComplexMethod") + internal fun geckoErrorToErrorType(errorCode: Int) = + when (errorCode) { + WebRequestError.ERROR_UNKNOWN -> ErrorType.UNKNOWN + WebRequestError.ERROR_SECURITY_SSL -> ErrorType.ERROR_SECURITY_SSL + WebRequestError.ERROR_SECURITY_BAD_CERT -> ErrorType.ERROR_SECURITY_BAD_CERT + WebRequestError.ERROR_NET_INTERRUPT -> ErrorType.ERROR_NET_INTERRUPT + WebRequestError.ERROR_NET_TIMEOUT -> ErrorType.ERROR_NET_TIMEOUT + WebRequestError.ERROR_CONNECTION_REFUSED -> ErrorType.ERROR_CONNECTION_REFUSED + WebRequestError.ERROR_UNKNOWN_SOCKET_TYPE -> ErrorType.ERROR_UNKNOWN_SOCKET_TYPE + WebRequestError.ERROR_REDIRECT_LOOP -> ErrorType.ERROR_REDIRECT_LOOP + WebRequestError.ERROR_OFFLINE -> ErrorType.ERROR_OFFLINE + WebRequestError.ERROR_PORT_BLOCKED -> ErrorType.ERROR_PORT_BLOCKED + WebRequestError.ERROR_NET_RESET -> ErrorType.ERROR_NET_RESET + WebRequestError.ERROR_UNSAFE_CONTENT_TYPE -> ErrorType.ERROR_UNSAFE_CONTENT_TYPE + WebRequestError.ERROR_CORRUPTED_CONTENT -> ErrorType.ERROR_CORRUPTED_CONTENT + WebRequestError.ERROR_CONTENT_CRASHED -> ErrorType.ERROR_CONTENT_CRASHED + WebRequestError.ERROR_INVALID_CONTENT_ENCODING -> ErrorType.ERROR_INVALID_CONTENT_ENCODING + WebRequestError.ERROR_UNKNOWN_HOST -> ErrorType.ERROR_UNKNOWN_HOST + WebRequestError.ERROR_MALFORMED_URI -> ErrorType.ERROR_MALFORMED_URI + WebRequestError.ERROR_UNKNOWN_PROTOCOL -> ErrorType.ERROR_UNKNOWN_PROTOCOL + WebRequestError.ERROR_FILE_NOT_FOUND -> ErrorType.ERROR_FILE_NOT_FOUND + WebRequestError.ERROR_FILE_ACCESS_DENIED -> ErrorType.ERROR_FILE_ACCESS_DENIED + WebRequestError.ERROR_PROXY_CONNECTION_REFUSED -> ErrorType.ERROR_PROXY_CONNECTION_REFUSED + WebRequestError.ERROR_UNKNOWN_PROXY_HOST -> ErrorType.ERROR_UNKNOWN_PROXY_HOST + WebRequestError.ERROR_SAFEBROWSING_MALWARE_URI -> ErrorType.ERROR_SAFEBROWSING_MALWARE_URI + WebRequestError.ERROR_SAFEBROWSING_UNWANTED_URI -> ErrorType.ERROR_SAFEBROWSING_UNWANTED_URI + WebRequestError.ERROR_SAFEBROWSING_HARMFUL_URI -> ErrorType.ERROR_SAFEBROWSING_HARMFUL_URI + WebRequestError.ERROR_SAFEBROWSING_PHISHING_URI -> ErrorType.ERROR_SAFEBROWSING_PHISHING_URI + WebRequestError.ERROR_HTTPS_ONLY -> ErrorType.ERROR_HTTPS_ONLY + WebRequestError.ERROR_BAD_HSTS_CERT -> ErrorType.ERROR_BAD_HSTS_CERT + else -> ErrorType.UNKNOWN + } + } +} + +/** + * Provides all gecko flags ignoring flags that only exists on AC. + **/ +@VisibleForTesting +internal fun EngineSession.LoadUrlFlags.getGeckoFlags(): Int { + var newValue = value + + if (contains(ALLOW_ADDITIONAL_HEADERS)) { + newValue -= ALLOW_ADDITIONAL_HEADERS + } + + if (contains(ALLOW_JAVASCRIPT_URL)) { + newValue -= ALLOW_JAVASCRIPT_URL + } + + return newValue +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionState.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionState.kt new file mode 100644 index 0000000000..e457ab2ac3 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionState.kt @@ -0,0 +1,73 @@ +/* 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.browser.engine.gecko + +import android.util.JsonReader +import android.util.JsonWriter +import mozilla.components.concept.engine.EngineSessionState +import org.json.JSONException +import org.json.JSONObject +import org.mozilla.geckoview.GeckoSession +import java.io.IOException + +private const val GECKO_STATE_KEY = "GECKO_STATE" + +class GeckoEngineSessionState internal constructor( + internal val actualState: GeckoSession.SessionState?, +) : EngineSessionState { + override fun writeTo(writer: JsonWriter) { + with(writer) { + beginObject() + + name(GECKO_STATE_KEY) + value(actualState.toString()) + + endObject() + flush() + } + } + + companion object { + fun fromJSON(json: JSONObject): GeckoEngineSessionState = try { + val state = json.getString(GECKO_STATE_KEY) + + GeckoEngineSessionState( + GeckoSession.SessionState.fromString(state), + ) + } catch (e: JSONException) { + GeckoEngineSessionState(null) + } + + /** + * Creates a [GeckoEngineSessionState] from the given [JsonReader]. + */ + fun from(reader: JsonReader): GeckoEngineSessionState = try { + reader.beginObject() + + val rawState = if (reader.hasNext()) { + val key = reader.nextName() + if (key != GECKO_STATE_KEY) { + throw AssertionError("Unknown state key: $key") + } + + reader.nextString() + } else { + null + } + + reader.endObject() + + GeckoEngineSessionState( + rawState?.let { GeckoSession.SessionState.fromString(it) }, + ) + } catch (e: IOException) { + GeckoEngineSessionState(null) + } catch (e: JSONException) { + // Internally GeckoView uses org.json and currently may throw JSONException in certain cases + // https://github.com/mozilla-mobile/android-components/issues/9332 + GeckoEngineSessionState(null) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt new file mode 100644 index 0000000000..d5d77b3073 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt @@ -0,0 +1,249 @@ +/* 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.browser.engine.gecko + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Color +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.annotation.VisibleForTesting +import androidx.core.view.ViewCompat +import mozilla.components.browser.engine.gecko.activity.GeckoViewActivityContextDelegate +import mozilla.components.browser.engine.gecko.selection.GeckoSelectionActionDelegate +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import org.mozilla.geckoview.BasicSelectionActionDelegate +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import java.lang.ref.WeakReference + +/** + * Gecko-based EngineView implementation. + */ +class GeckoEngineView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr), EngineView { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var geckoView = object : NestedGeckoView(context) { + + override fun onAttachedToWindow() { + try { + super.onAttachedToWindow() + } catch (e: IllegalStateException) { + // This is to debug "display already acquired" crashes + val otherActivityClassName = + this.session?.accessibility?.view?.context?.javaClass?.simpleName + val otherActivityClassHashcode = + this.session?.accessibility?.view?.context?.hashCode() + val activityClassName = context.javaClass.simpleName + val activityClassHashCode = context.hashCode() + val msg = "ATTACH VIEW: Current activity: $activityClassName hashcode " + + "$activityClassHashCode Other activity: $otherActivityClassName " + + "hashcode $otherActivityClassHashcode" + throw IllegalStateException(msg, e) + } + } + + override fun onDetachedFromWindow() { + // We are releasing the session before GeckoView gets detached from the window. Otherwise + // GeckoView will close the session automatically and we do not want that. + releaseSession() + + super.onDetachedFromWindow() + } + }.apply { + // Explicitly mark this view as important for autofill. The default "auto" doesn't seem to trigger any + // autofill behavior for us here. + @Suppress("WrongConstant") + ViewCompat.setImportantForAutofill(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES) + } + + internal fun setColorScheme(preferredColorScheme: PreferredColorScheme) { + var colorScheme = preferredColorScheme + if (preferredColorScheme == PreferredColorScheme.System) { + colorScheme = + if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + == Configuration.UI_MODE_NIGHT_YES + ) { + PreferredColorScheme.Dark + } else { + PreferredColorScheme.Light + } + } + + if (colorScheme == PreferredColorScheme.Dark) { + geckoView.coverUntilFirstPaint(DARK_COVER) + } else { + geckoView.coverUntilFirstPaint(Color.WHITE) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var currentSession: GeckoEngineSession? = null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var currentSelection: BasicSelectionActionDelegate? = null + + override var selectionActionDelegate: SelectionActionDelegate? = null + + init { + addView(geckoView) + + /** + * With the current design, we have a [NestedGeckoView] inside this + * [GeckoEngineView]. In our supported embedders, we wrap this with the + * AndroidX `SwipeRefreshLayout` to enable features like Pull-To-Refresh: + * + * ``` + * SwipeRefreshLayout + * └── GeckoEngineView + * └── NestedGeckoView + * ``` + * + * `SwipeRefreshLayout` only looks at the direct child to see if it has nested scrolling + * enabled. As we embed [NestedGeckoView] inside [GeckoEngineView], we change the hierarchy + * so that [NestedGeckoView] is no longer the direct child of `SwipeRefreshLayout`. + * + * To fix this we enable nested scrolling on the GeckoEngineView to emulate this + * information. This is required information for `View.requestDisallowInterceptTouchEvent` + * to work correctly in the [NestedGeckoView]. + */ + isNestedScrollingEnabled = true + } + + /** + * Render the content of the given session. + */ + @Synchronized + override fun render(session: EngineSession) { + val internalSession = session as GeckoEngineSession + currentSession = session + + if (geckoView.session != internalSession.geckoSession) { + geckoView.session?.let { + // Release a previously assigned session. Otherwise GeckoView will close it + // automatically. + detachSelectionActionDelegate(it) + geckoView.releaseSession() + } + + try { + geckoView.setSession(internalSession.geckoSession) + attachSelectionActionDelegate(internalSession.geckoSession) + } catch (e: IllegalStateException) { + // This is to debug "display already acquired" crashes + val otherActivityClassName = + internalSession.geckoSession.accessibility.view?.context?.javaClass?.simpleName + val otherActivityClassHashcode = + internalSession.geckoSession.accessibility.view?.context?.hashCode() + val activityClassName = context.javaClass.simpleName + val activityClassHashCode = context.hashCode() + val msg = "SET SESSION: Current activity: $activityClassName hashcode " + + "$activityClassHashCode Other activity: $otherActivityClassName " + + "hashcode $otherActivityClassHashcode" + throw IllegalStateException(msg, e) + } + } + } + + private fun attachSelectionActionDelegate(session: GeckoSession) { + val delegate = GeckoSelectionActionDelegate.maybeCreate(context, selectionActionDelegate) + if (delegate != null) { + session.selectionActionDelegate = delegate + currentSelection = delegate + } + } + + private fun detachSelectionActionDelegate(session: GeckoSession?) { + if (currentSelection != null) { + session?.selectionActionDelegate = null + currentSelection = null + } + } + + @Synchronized + override fun release() { + detachSelectionActionDelegate(currentSession?.geckoSession) + + currentSession = null + + geckoView.releaseSession() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + release() + } + + override fun canClearSelection() = !currentSelection?.selection?.text.isNullOrEmpty() + + override fun canScrollVerticallyUp() = currentSession?.let { it.scrollY > 0 } != false + + override fun canScrollVerticallyDown() = + true // waiting for this issue https://bugzilla.mozilla.org/show_bug.cgi?id=1507569 + + override fun getInputResultDetail() = geckoView.inputResultDetail + + override fun setVerticalClipping(clippingHeight: Int) { + geckoView.setVerticalClipping(clippingHeight) + } + + override fun setDynamicToolbarMaxHeight(height: Int) { + geckoView.setDynamicToolbarMaxHeight(height) + } + + override fun setActivityContext(context: Context?) { + geckoView.activityContextDelegate = GeckoViewActivityContextDelegate(WeakReference(context)) + } + + @Suppress("TooGenericExceptionCaught") + override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) { + try { + val geckoResult = geckoView.capturePixels() + geckoResult.then( + { bitmap -> + onFinish(bitmap) + GeckoResult<Void>() + }, + { + onFinish(null) + GeckoResult<Void>() + }, + ) + } catch (e: Exception) { + // There's currently no reliable way for consumers of GeckoView to + // know whether or not the compositor is ready. So we have to add + // a catch-all here. In the future, GeckoView will invoke our error + // callback instead and this block can be removed: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1645114 + // https://github.com/mozilla-mobile/android-components/issues/6680 + onFinish(null) + } + } + + override fun clearSelection() { + currentSelection?.clearSelection() + } + + override fun setVisibility(visibility: Int) { + // GeckoView doesn't react to onVisibilityChanged so we need to propagate ourselves for now: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1630775 + // We do this to prevent the content from resizing when the view is not visible: + // https://github.com/mozilla-mobile/android-components/issues/6664 + geckoView.visibility = visibility + super.setVisibility(visibility) + } + + companion object { + internal const val DARK_COVER = 0xFF2A2A2E.toInt() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoResult.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoResult.kt new file mode 100644 index 0000000000..86dc42cba2 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoResult.kt @@ -0,0 +1,76 @@ +/* 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.browser.engine.gecko + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.CancellableOperation +import org.mozilla.geckoview.GeckoResult +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * Wait for a GeckoResult to be complete in a co-routine. + */ +suspend fun <T> GeckoResult<T>.await() = suspendCoroutine<T?> { continuation -> + then( + { + continuation.resume(it) + GeckoResult<Void>() + }, + { + continuation.resumeWithException(it) + GeckoResult<Void>() + }, + ) +} + +/** + * Converts a [GeckoResult] to a [CancellableOperation]. + */ +fun <T> GeckoResult<T>.asCancellableOperation(): CancellableOperation { + val geckoResult = this + return object : CancellableOperation { + override fun cancel(): Deferred<Boolean> { + val result = CompletableDeferred<Boolean>() + geckoResult.cancel().then( + { + result.complete(it ?: false) + GeckoResult<Void>() + }, + { throwable -> + result.completeExceptionally(throwable) + GeckoResult<Void>() + }, + ) + return result + } + } +} + +/** + * Create a GeckoResult from a co-routine. + */ +@Suppress("TooGenericExceptionCaught") +fun <T> CoroutineScope.launchGeckoResult( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> T, +) = GeckoResult<T>().apply { + launch(context, start) { + try { + val value = block() + complete(value) + } catch (exception: Throwable) { + completeExceptionally(exception) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorage.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorage.kt new file mode 100644 index 0000000000..62d8a42a97 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorage.kt @@ -0,0 +1,146 @@ +/* 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.browser.engine.gecko + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import mozilla.components.browser.engine.gecko.content.blocking.GeckoTrackingProtectionException +import mozilla.components.browser.engine.gecko.ext.geckoTrackingProtectionPermission +import mozilla.components.browser.engine.gecko.ext.isExcludedForTrackingProtection +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.content.blocking.TrackingProtectionException +import mozilla.components.concept.engine.content.blocking.TrackingProtectionExceptionStorage +import mozilla.components.support.ktx.kotlin.getOrigin +import mozilla.components.support.ktx.kotlin.stripDefaultPort +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY + +/** + * A [TrackingProtectionExceptionStorage] implementation to store tracking protection exceptions. + */ +internal class GeckoTrackingProtectionExceptionStorage( + private val runtime: GeckoRuntime, +) : TrackingProtectionExceptionStorage { + internal var scope = CoroutineScope(Dispatchers.IO) + + override fun contains(session: EngineSession, onResult: (Boolean) -> Unit) { + val url = (session as GeckoEngineSession).currentUrl + if (!url.isNullOrEmpty()) { + getPermissions(url) { permissions -> + val contains = permissions.isNotEmpty() + onResult(contains) + } + } else { + onResult(false) + } + } + + override fun fetchAll(onResult: (List<TrackingProtectionException>) -> Unit) { + runtime.storageController.allPermissions.accept { permissions -> + val trackingExceptions = permissions.filterTrackingProtectionExceptions() + onResult(trackingExceptions.map { exceptions -> exceptions.toTrackingProtectionException() }) + } + } + + private fun List<ContentPermission>?.filterTrackingProtectionExceptions() = + this.orEmpty().filter { it.isExcludedForTrackingProtection } + + private fun List<ContentPermission>?.filterTrackingProtectionExceptions(url: String) = + this.orEmpty() + .filter { + it.isExcludedForTrackingProtection && it.uri.getOrigin().orEmpty() + .stripDefaultPort() == url + } + + override fun add(session: EngineSession, persistInPrivateMode: Boolean) { + val geckoEngineSession = (session as GeckoEngineSession) + if (persistInPrivateMode) { + addPersistentPrivateException(geckoEngineSession) + } else { + geckoEngineSession.geckoTrackingProtectionPermission?.let { + runtime.storageController.setPermission(it, VALUE_ALLOW) + } + } + + geckoEngineSession.notifyObservers { + onExcludedOnTrackingProtectionChange(true) + } + } + + internal fun addPersistentPrivateException(geckoEngineSession: GeckoEngineSession) { + val permission = geckoEngineSession.geckoTrackingProtectionPermission + permission?.let { + runtime.storageController.setPrivateBrowsingPermanentPermission(it, VALUE_ALLOW) + } + } + + override fun remove(session: EngineSession) { + val geckoEngineSession = (session as GeckoEngineSession) + val url = geckoEngineSession.currentUrl ?: return + geckoEngineSession.notifyObservers { + onExcludedOnTrackingProtectionChange(false) + } + remove(url) + } + + override fun remove(exception: TrackingProtectionException) { + if (exception is GeckoTrackingProtectionException) { + remove(exception.contentPermission) + } else { + remove(exception.url) + } + } + + @VisibleForTesting + internal fun remove(url: String) { + val storage = runtime.storageController + getPermissions(url) { permissions -> + permissions.forEach { geckoPermissions -> + storage.setPermission(geckoPermissions, VALUE_DENY) + } + } + } + + // This is a workaround until https://bugzilla.mozilla.org/show_bug.cgi?id=1723280 gets addressed + private fun getPermissions(url: String, onFinish: (List<ContentPermission>) -> Unit) { + val localUrl = url.getOrigin().orEmpty().stripDefaultPort() + val storage = runtime.storageController + if (localUrl.isNotEmpty()) { + storage.allPermissions.accept { permissions -> + onFinish(permissions.filterTrackingProtectionExceptions(localUrl)) + } + } else { + onFinish(emptyList()) + } + } + + @VisibleForTesting + internal fun remove(contentPermission: ContentPermission) { + runtime.storageController.setPermission(contentPermission, VALUE_DENY) + } + + override fun removeAll(activeSessions: List<EngineSession>?, onRemove: () -> Unit) { + val storage = runtime.storageController + activeSessions?.forEach { engineSession -> + engineSession.notifyObservers { + onExcludedOnTrackingProtectionChange(false) + } + } + storage.allPermissions.accept { permissions -> + val trackingExceptions = permissions.filterTrackingProtectionExceptions() + trackingExceptions.forEach { + storage.setPermission(it, VALUE_DENY) + } + onRemove.invoke() + } + } +} + +private fun ContentPermission.toTrackingProtectionException(): GeckoTrackingProtectionException { + return GeckoTrackingProtectionException(uri, privateMode, this) +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/NestedGeckoView.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/NestedGeckoView.kt new file mode 100644 index 0000000000..717c3e8dca --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/NestedGeckoView.kt @@ -0,0 +1,258 @@ +/* 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.browser.engine.gecko + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import androidx.annotation.VisibleForTesting +import androidx.core.view.NestedScrollingChild +import androidx.core.view.NestedScrollingChildHelper +import androidx.core.view.ViewCompat +import mozilla.components.concept.engine.InputResultDetail +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoView +import org.mozilla.geckoview.PanZoomController + +/** + * geckoView that supports nested scrolls (for using in a CoordinatorLayout). + * + * This code is a simplified version of the NestedScrollView implementation + * which can be found in the support library: + * [android.support.v4.widget.NestedScrollView] + * + * Based on: + * https://github.com/takahirom/webview-in-coordinatorlayout + */ + +@Suppress("ClickableViewAccessibility") +open class NestedGeckoView(context: Context) : GeckoView(context), NestedScrollingChild { + @VisibleForTesting + internal var lastY: Int = 0 + + @VisibleForTesting + internal val scrollOffset = IntArray(2) + + private val scrollConsumed = IntArray(2) + + private var gestureCanReachParent = true + + private var initialDownY: Float = 0f + + @VisibleForTesting + internal var nestedOffsetY: Int = 0 + + @VisibleForTesting + internal var childHelper: NestedScrollingChildHelper = NestedScrollingChildHelper(this) + + /** + * How user's MotionEvent will be handled. + * + * @see InputResultDetail + */ + internal var inputResultDetail = InputResultDetail.newInstance(true) + + init { + isNestedScrollingEnabled = true + } + + @Suppress("ComplexMethod") + override fun onTouchEvent(ev: MotionEvent): Boolean { + val event = MotionEvent.obtain(ev) + val action = ev.actionMasked + val eventY = event.y.toInt() + + when (action) { + MotionEvent.ACTION_MOVE -> { + val allowScroll = !shouldPinOnScreen() && inputResultDetail.isTouchHandledByBrowser() + + var deltaY = lastY - eventY + + if (allowScroll && dispatchNestedPreScroll(0, deltaY, scrollConsumed, scrollOffset)) { + deltaY -= scrollConsumed[1] + event.offsetLocation(0f, (-scrollOffset[1]).toFloat()) + nestedOffsetY += scrollOffset[1] + } + + lastY = eventY - scrollOffset[1] + + if (allowScroll && dispatchNestedScroll(0, scrollOffset[1], 0, deltaY, scrollOffset)) { + lastY -= scrollOffset[1] + event.offsetLocation(0f, scrollOffset[1].toFloat()) + nestedOffsetY += scrollOffset[1] + } + + // If this event is the first touch move event, there are two possible cases + // where we still need to wait for the response for this first touch move event. + // a) we haven't yet received the response from GeckoView because of active touch + // event listeners etc. + // b) we have received the response for the touch down event that GeckoView + // consumed the event + // In the case of a) it's possible a touch move event listener does preventDefault() + // for this touch move event, then any subsequent touch events need to be directly + // routed to GeckoView rather than being intercepted. + // In the case of b) if GeckoView consumed this touch move event to scroll down the + // web content, any touch event interception should not be allowed since, for example + // SwipeRefreshLayout is supposed to trigger a refresh after the user started scroll + // down if the user restored the scroll position at the top. + val hasDragGestureStarted = event.y != initialDownY + if (gestureCanReachParent && hasDragGestureStarted) { + updateInputResult(event) + event.recycle() + return true + } + } + + MotionEvent.ACTION_DOWN -> { + // A new gesture started. Ask GV if it can handle this. + parent?.requestDisallowInterceptTouchEvent(true) + updateInputResult(event) + + nestedOffsetY = 0 + lastY = eventY + initialDownY = event.y + + // The event should be handled either by onTouchEvent, + // either by onTouchEventForResult, never by both. + // Early return if we sent it to updateInputResult(..) which calls onTouchEventForResult. + event.recycle() + return true + } + + // We don't care about other touch events + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + // inputResultDetail needs to be reset here and not in the next ACTION_DOWN, because + // its value is used by other features that poll for the value via + // `EngineView.getInputResultDetail`. Not resetting this in ACTION_CANCEL/ACTION_UP + // would then mean we send stale information to those features from a previous + // gesture's result. + inputResultDetail = InputResultDetail.newInstance(true) + stopNestedScroll() + + // Allow touch event interception here so that the next ACTION_DOWN event can be properly + // intercepted by the parent. + parent?.requestDisallowInterceptTouchEvent(false) + gestureCanReachParent = true + } + } + + // Execute event handler from parent class in all cases + val eventHandled = callSuperOnTouchEvent(event) + + // Recycle previously obtained event + event.recycle() + + return eventHandled + } + + @VisibleForTesting + internal fun callSuperOnTouchEvent(event: MotionEvent): Boolean { + return super.onTouchEvent(event) + } + + @SuppressLint("WrongThread") // Lint complains startNestedScroll() needs to be called on the main thread + @VisibleForTesting + internal fun updateInputResult(event: MotionEvent) { + val eventAction = event.actionMasked + val eventY = event.y + superOnTouchEventForDetailResult(event) + .accept { + // Since the response from APZ is async, we could theoretically have a response + // which is out of time when we get the ACTION_MOVE events, and we do not want + // to forward this to the parent pre-emptively. + if (!gestureCanReachParent) { + return@accept + } + + inputResultDetail = inputResultDetail.copy( + it?.handledResult(), + it?.scrollableDirections(), + it?.overscrollDirections(), + ) + + when (eventAction) { + MotionEvent.ACTION_DOWN -> { + // Gesture can reach the parent only if the content is already at the top + gestureCanReachParent = inputResultDetail.canOverscrollTop() + + if (gestureCanReachParent && inputResultDetail.isTouchUnhandled()) { + // If the event wasn't used in GeckoView, allow touch event interception. + parent?.requestDisallowInterceptTouchEvent(false) + } + } + + MotionEvent.ACTION_MOVE -> { + if (initialDownY < eventY) { + // In the case of scroll upwards gestures, allow touch event interception + // only if the event wasn't consumed by the web site. I.e. even if + // the event was consumed by the browser to scroll up the content. + if (!inputResultDetail.isTouchHandledByWebsite()) { + parent?.requestDisallowInterceptTouchEvent(false) + } + } else if (initialDownY > eventY) { + // Once after the content started scroll down, touch event interception + // is never allowed. + parent?.requestDisallowInterceptTouchEvent(true) + gestureCanReachParent = false + } else { + // Normally ACTION_MOVE should happen with moving the event position, + // but if it happened allow touch event interception just in case. + parent?.requestDisallowInterceptTouchEvent(false) + } + } + } + + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) + } + } + + @VisibleForTesting + internal open fun superOnTouchEventForDetailResult( + event: MotionEvent, + ): GeckoResult<PanZoomController.InputResultDetail> = + super.onTouchEventForDetailResult(event) + + override fun setNestedScrollingEnabled(enabled: Boolean) { + childHelper.isNestedScrollingEnabled = enabled + } + + override fun isNestedScrollingEnabled(): Boolean { + return childHelper.isNestedScrollingEnabled + } + + override fun startNestedScroll(axes: Int): Boolean { + return childHelper.startNestedScroll(axes) + } + + override fun stopNestedScroll() { + childHelper.stopNestedScroll() + } + + override fun hasNestedScrollingParent(): Boolean { + return childHelper.hasNestedScrollingParent() + } + + override fun dispatchNestedScroll( + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + offsetInWindow: IntArray?, + ): Boolean { + return childHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow) + } + + override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?): Boolean { + return childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow) + } + + override fun dispatchNestedFling(velocityX: Float, velocityY: Float, consumed: Boolean): Boolean { + return childHelper.dispatchNestedFling(velocityX, velocityY, consumed) + } + + override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float): Boolean { + return childHelper.dispatchNestedPreFling(velocityX, velocityY) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegate.kt new file mode 100644 index 0000000000..fa967bbc83 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegate.kt @@ -0,0 +1,45 @@ +/* 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.browser.engine.gecko.activity + +import android.app.PendingIntent +import android.content.Intent +import mozilla.components.concept.engine.activity.ActivityDelegate +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import java.lang.ref.WeakReference + +/** + * A wrapper for the [ActivityDelegate] to communicate with the Gecko-based delegate. + */ +internal class GeckoActivityDelegate( + private val delegateRef: WeakReference<ActivityDelegate>, +) : GeckoRuntime.ActivityDelegate { + + private val logger = Logger(GeckoActivityDelegate::javaClass.name) + + override fun onStartActivityForResult(intent: PendingIntent): GeckoResult<Intent> { + val result: GeckoResult<Intent> = GeckoResult() + val delegate = delegateRef.get() + + if (delegate == null) { + logger.warn("No activity delegate attached. Cannot request FIDO auth.") + + result.completeExceptionally(RuntimeException("Activity for result failed; no delegate attached.")) + + return result + } + + delegate.startIntentSenderForResult(intent.intentSender) { data -> + if (data != null) { + result.complete(data) + } else { + result.completeExceptionally(RuntimeException("Activity for result failed.")) + } + } + return result + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegate.kt new file mode 100644 index 0000000000..86d0e72f9f --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegate.kt @@ -0,0 +1,35 @@ +/* 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.browser.engine.gecko.activity + +import mozilla.components.concept.engine.activity.OrientationDelegate +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.AllowOrDeny.ALLOW +import org.mozilla.geckoview.AllowOrDeny.DENY +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.OrientationController + +/** + * Default [OrientationController.OrientationDelegate] implementation that delegates both the behavior + * and the returned value to a [OrientationDelegate]. + */ +internal class GeckoScreenOrientationDelegate( + private val delegate: OrientationDelegate, +) : OrientationController.OrientationDelegate { + override fun onOrientationLock(requestedOrientation: Int): GeckoResult<AllowOrDeny> { + val result = GeckoResult<AllowOrDeny>() + + when (delegate.onOrientationLock(requestedOrientation)) { + true -> result.complete(ALLOW) + false -> result.complete(DENY) + } + + return result + } + + override fun onOrientationUnlock() { + delegate.onOrientationUnlock() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegate.kt new file mode 100644 index 0000000000..41841687d9 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegate.kt @@ -0,0 +1,43 @@ +/* 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.browser.engine.gecko.activity + +import android.app.Activity +import android.content.Context +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.geckoview.GeckoView +import java.lang.ref.WeakReference + +/** + * GeckoViewActivityContextDelegate provides an active Activity to GeckoView or null. Not to be confused + * with the runtime delegate of [GeckoActivityDelegate], which is tightly coupled to webauthn. + * See bug 1806191 for more information on delegate differences. + * + * @param contextRef A reference to an active Activity context or null for GeckoView to use. + */ +class GeckoViewActivityContextDelegate( + private val contextRef: WeakReference<Context?>, +) : GeckoView.ActivityContextDelegate { + private val logger = Logger("GeckoViewActivityContextDelegate") + init { + if (contextRef.get() == null) { + logger.warn("Activity context is null.") + } else if (contextRef.get() !is Activity) { + logger.warn("A non-activity context was set.") + } + } + + /** + * Used by GeckoView to get an Activity context for operations such as printing. + * @return An active Activity context or null. + */ + override fun getActivityContext(): Context? { + val context = contextRef.get() + if ((context == null)) { + return null + } + return context + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt new file mode 100644 index 0000000000..cb178b6292 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt @@ -0,0 +1,110 @@ +/* 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.browser.engine.gecko.autofill + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import mozilla.components.browser.engine.gecko.ext.toAutocompleteAddress +import mozilla.components.browser.engine.gecko.ext.toCreditCardEntry +import mozilla.components.browser.engine.gecko.ext.toLoginEntry +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardsAddressesStorageDelegate +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginStorageDelegate +import org.mozilla.geckoview.Autocomplete +import org.mozilla.geckoview.GeckoResult + +/** + * Gecko credit card and login storage delegate that handles runtime storage requests. This allows + * the Gecko runtime to call the underlying storage to handle requests for fetching, saving and + * updating of autocomplete items in the storage. + * + * @param creditCardsAddressesStorageDelegate An instance of [CreditCardsAddressesStorageDelegate]. + * Provides methods for retrieving [CreditCard]s from the underlying storage. + * @param loginStorageDelegate An instance of [LoginStorageDelegate]. + * Provides read/write methods for the [Login] storage. + */ +class GeckoAutocompleteStorageDelegate( + private val creditCardsAddressesStorageDelegate: CreditCardsAddressesStorageDelegate, + private val loginStorageDelegate: LoginStorageDelegate, +) : Autocomplete.StorageDelegate { + + override fun onAddressFetch(): GeckoResult<Array<Autocomplete.Address>>? { + val result = GeckoResult<Array<Autocomplete.Address>>() + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(IO) { + val addresses = creditCardsAddressesStorageDelegate.onAddressesFetch() + .map { it.toAutocompleteAddress() } + .toTypedArray() + + result.complete(addresses) + } + + return result + } + + override fun onCreditCardFetch(): GeckoResult<Array<Autocomplete.CreditCard>> { + val result = GeckoResult<Array<Autocomplete.CreditCard>>() + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(IO) { + val key = creditCardsAddressesStorageDelegate.getOrGenerateKey() + + val creditCards = creditCardsAddressesStorageDelegate.onCreditCardsFetch() + .mapNotNull { + val plaintextCardNumber = + creditCardsAddressesStorageDelegate.decrypt(key, it.encryptedCardNumber)?.number + + if (plaintextCardNumber == null) { + null + } else { + Autocomplete.CreditCard.Builder() + .guid(it.guid) + .name(it.billingName) + .number(plaintextCardNumber) + .expirationMonth(it.expiryMonth.toString()) + .expirationYear(it.expiryYear.toString()) + .build() + } + } + .toTypedArray() + + result.complete(creditCards) + } + + return result + } + + override fun onCreditCardSave(creditCard: Autocomplete.CreditCard) { + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(IO) { + creditCardsAddressesStorageDelegate.onCreditCardSave(creditCard.toCreditCardEntry()) + } + } + + override fun onLoginSave(login: Autocomplete.LoginEntry) { + loginStorageDelegate.onLoginSave(login.toLoginEntry()) + } + + override fun onLoginFetch(domain: String): GeckoResult<Array<Autocomplete.LoginEntry>> { + val result = GeckoResult<Array<Autocomplete.LoginEntry>>() + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(IO) { + val storedLogins = loginStorageDelegate.onLoginFetch(domain) + + val logins = storedLogins.await() + .map { it.toLoginEntry() } + .toTypedArray() + + result.complete(logins) + } + + return result + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/content/blocking/GeckoTrackingProtectionException.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/content/blocking/GeckoTrackingProtectionException.kt new file mode 100644 index 0000000000..281f6e6968 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/content/blocking/GeckoTrackingProtectionException.kt @@ -0,0 +1,20 @@ +/* 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.browser.engine.gecko.content.blocking + +import mozilla.components.concept.engine.content.blocking.TrackingProtectionException +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission + +/** + * Represents a site that will be ignored by the tracking protection policies. + * @property url The url of the site to be ignored. + * @property privateMode Indicates if this exception should persisted in private mode. + * @property contentPermission The associated gecko content permission of this exception. + */ +data class GeckoTrackingProtectionException( + override val url: String, + val privateMode: Boolean = false, + val contentPermission: ContentPermission, +) : TrackingProtectionException diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorage.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorage.kt new file mode 100644 index 0000000000..18aefed775 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorage.kt @@ -0,0 +1,128 @@ +/* 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.browser.engine.gecko.cookiebanners + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.browser.engine.gecko.await +import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingMode +import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingMode.DISABLED +import mozilla.components.concept.engine.cookiehandling.CookieBannersStorage +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.StorageController + +/** + * A storage to store [CookieBannerHandlingMode] using GeckoView APIs. + */ +class GeckoCookieBannersStorage( + runtime: GeckoRuntime, + private val reportSiteDomainsRepository: ReportSiteDomainsRepository, +) : CookieBannersStorage { + + private val geckoStorage: StorageController = runtime.storageController + private val mainScope = CoroutineScope(Dispatchers.Main) + + override suspend fun addException( + uri: String, + privateBrowsing: Boolean, + ) { + setGeckoException(uri, DISABLED, privateBrowsing) + } + + override suspend fun isSiteDomainReported(siteDomain: String): Boolean { + return reportSiteDomainsRepository.isSiteDomainReported(siteDomain) + } + + override suspend fun saveSiteDomain(siteDomain: String) { + reportSiteDomainsRepository.saveSiteDomain(siteDomain) + } + + override suspend fun addPersistentExceptionInPrivateMode(uri: String) { + setPersistentPrivateGeckoException(uri, DISABLED) + } + + override suspend fun findExceptionFor( + uri: String, + privateBrowsing: Boolean, + ): CookieBannerHandlingMode? { + return queryExceptionInGecko(uri, privateBrowsing) + } + + override suspend fun hasException(uri: String, privateBrowsing: Boolean): Boolean? { + val result = findExceptionFor(uri, privateBrowsing) + return if (result != null) { + result == DISABLED + } else { + null + } + } + + override suspend fun removeException(uri: String, privateBrowsing: Boolean) { + removeGeckoException(uri, privateBrowsing) + } + + @VisibleForTesting + internal fun removeGeckoException(uri: String, privateBrowsing: Boolean) { + geckoStorage.removeCookieBannerModeForDomain(uri, privateBrowsing) + } + + @VisibleForTesting + internal fun setGeckoException( + uri: String, + mode: CookieBannerHandlingMode, + privateBrowsing: Boolean, + ) { + geckoStorage.setCookieBannerModeForDomain( + uri, + mode.mode, + privateBrowsing, + ) + } + + @VisibleForTesting + internal fun setPersistentPrivateGeckoException( + uri: String, + mode: CookieBannerHandlingMode, + ) { + geckoStorage.setCookieBannerModeAndPersistInPrivateBrowsingForDomain( + uri, + mode.mode, + ) + } + + @VisibleForTesting + @Suppress("TooGenericExceptionCaught") + internal suspend fun queryExceptionInGecko( + uri: String, + privateBrowsing: Boolean, + ): CookieBannerHandlingMode? { + return try { + withContext(mainScope.coroutineContext) { + geckoStorage.getCookieBannerModeForDomain(uri, privateBrowsing).await() + ?.toCookieBannerHandlingMode() ?: throw IllegalArgumentException( + "An error happened trying to find cookie banners mode for the " + + "uri $uri and private browsing mode $privateBrowsing", + ) + } + } catch (e: Exception) { + // This normally happen on internal sites like about:config or ip sites. + val disabledErrors = listOf("NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS", "NS_ERROR_HOST_IS_IP_ADDRESS") + if (disabledErrors.any { (e.message ?: "").contains(it) }) { + Logger("GeckoCookieBannersStorage").error("Unable to query cookie banners exception", e) + null + } else { + throw e + } + } + } +} + +@VisibleForTesting +internal fun Int.toCookieBannerHandlingMode(): CookieBannerHandlingMode { + return CookieBannerHandlingMode.values().first { it.mode == this } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepository.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepository.kt new file mode 100644 index 0000000000..9fbb9b3eda --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepository.kt @@ -0,0 +1,75 @@ +/* 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.browser.engine.gecko.cookiebanners + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import mozilla.components.browser.engine.gecko.cookiebanners.ReportSiteDomainsRepository.PreferencesKeys.REPORT_SITE_DOMAINS +import mozilla.components.support.base.log.logger.Logger +import java.io.IOException + +/** + * A repository to save reported site domains with the datastore API. + */ +class ReportSiteDomainsRepository( + private val dataStore: DataStore<Preferences>, +) { + + companion object { + const val SEPARATOR = "@<;>@" + const val REPORT_SITE_DOMAINS_REPOSITORY_NAME = "report_site_domains_preferences" + const val PREFERENCE_KEY_NAME = "report_site_domains" + } + + private object PreferencesKeys { + val REPORT_SITE_DOMAINS = stringPreferencesKey(PREFERENCE_KEY_NAME) + } + + /** + * Check if the given site's domain url is saved locally. + * @param siteDomain the [siteDomain] that will be checked. + */ + suspend fun isSiteDomainReported(siteDomain: String): Boolean { + return dataStore.data + .catch { exception -> + if (exception is IOException) { + Logger.error("Error reading preferences.", exception) + emit(emptyPreferences()) + } else { + throw exception + } + }.map { preferences -> + val reportSiteDomainsString = preferences[REPORT_SITE_DOMAINS] ?: "" + val reportSiteDomainsList = + reportSiteDomainsString.split(SEPARATOR).filter { it.isNotEmpty() } + reportSiteDomainsList.contains(siteDomain) + }.first() + } + + /** + * Save the given site's domain url in datastore to keep it persistent locally. + * This method gets called after the site domain was reported with Nimbus. + * @param siteDomain the [siteDomain] that will be saved. + */ + suspend fun saveSiteDomain(siteDomain: String) { + dataStore.edit { preferences -> + val siteDomainsPreferences = preferences[REPORT_SITE_DOMAINS] ?: "" + val siteDomainsList = siteDomainsPreferences.split(SEPARATOR).filter { it.isNotEmpty() } + if (siteDomainsList.contains(siteDomain)) { + return@edit + } + val domains = mutableListOf<String>() + domains.addAll(siteDomainsList) + domains.add(siteDomain) + preferences[REPORT_SITE_DOMAINS] = domains.joinToString(SEPARATOR) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Address.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Address.kt new file mode 100644 index 0000000000..2378326ed0 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Address.kt @@ -0,0 +1,42 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.concept.storage.Address +import org.mozilla.geckoview.Autocomplete + +/** + * Converts a GeckoView [Autocomplete.Address] to an Android Components [Address]. + */ +fun Autocomplete.Address.toAddress() = Address( + guid = guid ?: "", + name = name, + organization = organization, + streetAddress = streetAddress, + addressLevel3 = addressLevel3, + addressLevel2 = addressLevel2, + addressLevel1 = addressLevel1, + postalCode = postalCode, + country = country, + tel = tel, + email = email, +) + +/** + * Converts an Android Components [Address] to a GeckoView [Autocomplete.Address]. + */ +fun Address.toAutocompleteAddress() = Autocomplete.Address.Builder() + .guid(guid) + .name(name) + .organization(organization) + .streetAddress(streetAddress) + .addressLevel3(addressLevel3) + .addressLevel2(addressLevel2) + .addressLevel1(addressLevel1) + .postalCode(postalCode) + .country(country) + .tel(tel) + .email(email) + .build() diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/CreditCard.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/CreditCard.kt new file mode 100644 index 0000000000..79bb5fd091 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/CreditCard.kt @@ -0,0 +1,32 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.support.utils.creditCardIIN +import org.mozilla.geckoview.Autocomplete + +/** + * Converts a GeckoView [Autocomplete.CreditCard] to an Android Components [CreditCardEntry]. + */ +fun Autocomplete.CreditCard.toCreditCardEntry() = CreditCardEntry( + guid = guid, + name = name, + number = number, + expiryMonth = expirationMonth, + expiryYear = expirationYear, + cardType = number.creditCardIIN()?.creditCardIssuerNetwork?.name ?: "", +) + +/** + * Converts an Android Components [CreditCardEntry] to a GeckoView [Autocomplete.CreditCard]. + */ +fun CreditCardEntry.toAutocompleteCreditCard() = Autocomplete.CreditCard.Builder() + .guid(guid) + .name(name) + .number(number) + .expirationMonth(expiryMonth) + .expirationYear(expiryYear) + .build() diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoChoice.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoChoice.kt new file mode 100644 index 0000000000..f988d37a16 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoChoice.kt @@ -0,0 +1,31 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.browser.engine.gecko.prompt.GeckoChoice +import mozilla.components.concept.engine.prompt.Choice + +/** + * Converts a GeckoView [GeckoChoice] to an Android Components [Choice]. + */ +private fun GeckoChoice.toChoice(): Choice { + val choiceChildren = items?.map { it.toChoice() }?.toTypedArray() + // On the GeckoView docs states that label is a @NonNull, but on run-time + // we are getting null values + // https://bugzilla.mozilla.org/show_bug.cgi?id=1771149 + @Suppress("USELESS_ELVIS") + return Choice(id, !disabled, label ?: "", selected, separator, choiceChildren) +} + +/** + * Convert an array of [GeckoChoice] to Choice array. + * @return array of Choice + */ +fun convertToChoices( + geckoChoices: Array<out GeckoChoice>, +): Array<Choice> = geckoChoices.map { geckoChoice -> + val choice = geckoChoice.toChoice() + choice +}.toTypedArray() diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoContentPermissions.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoContentPermissions.kt new file mode 100644 index 0000000000..1e5ddcce35 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoContentPermissions.kt @@ -0,0 +1,25 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_TRACKING + +/** + * Indicates if this Gecko permission is a tracking protection permission and it is excluded + * from the tracking protection policies. + */ +val ContentPermission.isExcludedForTrackingProtection: Boolean + get() = this.permission == PERMISSION_TRACKING && + value == VALUE_ALLOW + +/** + * Provides the tracking protection permission for the given [GeckoEngineSession]. + * This is available after every onLocationChange call. + */ +val GeckoEngineSession.geckoTrackingProtectionPermission: ContentPermission? + get() = this.geckoPermissions.find { it.permission == PERMISSION_TRACKING } diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Login.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Login.kt new file mode 100644 index 0000000000..dbc6d1a308 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Login.kt @@ -0,0 +1,43 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import org.mozilla.geckoview.Autocomplete + +/** + * Converts a GeckoView [Autocomplete.LoginEntry] to an Android Components [LoginEntry]. + */ +fun Autocomplete.LoginEntry.toLoginEntry() = LoginEntry( + origin = origin, + formActionOrigin = formActionOrigin, + httpRealm = httpRealm, + username = username, + password = password, +) + +/** + * Converts an Android Components [Login] to a GeckoView [Autocomplete.LoginEntry]. + */ +fun Login.toLoginEntry() = Autocomplete.LoginEntry.Builder() + .guid(guid) + .origin(origin) + .formActionOrigin(formActionOrigin) + .httpRealm(httpRealm) + .username(username) + .password(password) + .build() + +/** + * Converts an Android Components [LoginEntry] to a GeckoView [Autocomplete.LoginEntry]. + */ +fun LoginEntry.toLoginEntry() = Autocomplete.LoginEntry.Builder() + .origin(origin) + .formActionOrigin(formActionOrigin) + .httpRealm(httpRealm) + .username(username) + .password(password) + .build() diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicy.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicy.kt new file mode 100644 index 0000000000..1775010197 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicy.kt @@ -0,0 +1,82 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory +import org.mozilla.geckoview.ContentBlocking +import org.mozilla.geckoview.GeckoRuntimeSettings + +/** + * Converts a [TrackingProtectionPolicy] into a GeckoView setting that can be used with [GeckoRuntimeSettings.Builder]. + * Also contains the cookie banner handling settings for regular and private browsing. + */ +@Suppress("SpreadOperator") +fun TrackingProtectionPolicy.toContentBlockingSetting( + safeBrowsingPolicy: Array<EngineSession.SafeBrowsingPolicy> = arrayOf(EngineSession.SafeBrowsingPolicy.RECOMMENDED), + cookieBannerHandlingMode: EngineSession.CookieBannerHandlingMode = EngineSession.CookieBannerHandlingMode.DISABLED, + cookieBannerHandlingModePrivateBrowsing: EngineSession.CookieBannerHandlingMode = + EngineSession.CookieBannerHandlingMode.REJECT_ALL, + cookieBannerHandlingDetectOnlyMode: Boolean = false, + cookieBannerGlobalRulesEnabled: Boolean = false, + cookieBannerGlobalRulesSubFramesEnabled: Boolean = false, + queryParameterStripping: Boolean = false, + queryParameterStrippingPrivateBrowsing: Boolean = false, + queryParameterStrippingAllowList: String = "", + queryParameterStrippingStripList: String = "", +) = ContentBlocking.Settings.Builder().apply { + enhancedTrackingProtectionLevel(getEtpLevel()) + antiTracking(getAntiTrackingPolicy()) + cookieBehavior(cookiePolicy.id) + cookieBehaviorPrivateMode(cookiePolicyPrivateMode.id) + cookiePurging(cookiePurging) + safeBrowsing(safeBrowsingPolicy.sumOf { it.id }) + strictSocialTrackingProtection(getStrictSocialTrackingProtection()) + cookieBannerHandlingMode(cookieBannerHandlingMode.mode) + cookieBannerHandlingModePrivateBrowsing(cookieBannerHandlingModePrivateBrowsing.mode) + cookieBannerHandlingDetectOnlyMode(cookieBannerHandlingDetectOnlyMode) + cookieBannerGlobalRulesEnabled(cookieBannerGlobalRulesEnabled) + cookieBannerGlobalRulesSubFramesEnabled(cookieBannerGlobalRulesSubFramesEnabled) + queryParameterStrippingEnabled(queryParameterStripping) + queryParameterStrippingPrivateBrowsingEnabled(queryParameterStrippingPrivateBrowsing) + queryParameterStrippingAllowList(*queryParameterStrippingAllowList.split(",").toTypedArray()) + queryParameterStrippingStripList(*queryParameterStrippingStripList.split(",").toTypedArray()) +}.build() + +/** + * Returns whether [TrackingCategory.STRICT] is enabled in the [TrackingProtectionPolicy]. + */ +internal fun TrackingProtectionPolicy.getStrictSocialTrackingProtection(): Boolean { + return strictSocialTrackingProtection ?: trackingCategories.contains(TrackingCategory.STRICT) +} + +/** + * Returns the [TrackingProtectionPolicy] categories as an Enhanced Tracking Protection level for GeckoView. + */ +internal fun TrackingProtectionPolicy.getEtpLevel(): Int { + return when { + trackingCategories.contains(TrackingCategory.NONE) -> ContentBlocking.EtpLevel.NONE + else -> ContentBlocking.EtpLevel.STRICT + } +} + +/** + * Returns the [TrackingProtectionPolicy] as a tracking policy for GeckoView. + */ +internal fun TrackingProtectionPolicy.getAntiTrackingPolicy(): Int { + /** + * The [TrackingProtectionPolicy.TrackingCategory.SCRIPTS_AND_SUB_RESOURCES] is an + * artificial category, created with the sole purpose of going around this bug + * https://bugzilla.mozilla.org/show_bug.cgi?id=1579264, for this reason we have to + * remove its value from the valid anti tracking categories, when is present. + */ + val total = trackingCategories.sumOf { it.id } + return if (contains(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)) { + total - TrackingCategory.SCRIPTS_AND_SUB_RESOURCES.id + } else { + total + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt new file mode 100644 index 0000000000..c28989752d --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt @@ -0,0 +1,139 @@ +/* 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.browser.engine.gecko.fetch + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.Response.Companion.SUCCESS +import mozilla.components.concept.fetch.isBlobUri +import mozilla.components.concept.fetch.isDataUri +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoWebExecutor +import org.mozilla.geckoview.WebRequest +import org.mozilla.geckoview.WebRequest.CACHE_MODE_DEFAULT +import org.mozilla.geckoview.WebRequest.CACHE_MODE_RELOAD +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.WebResponse +import java.io.IOException +import java.net.SocketTimeoutException +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * GeckoView ([GeckoWebExecutor]) based implementation of [Client]. + */ +class GeckoViewFetchClient( + context: Context, + runtime: GeckoRuntime = GeckoRuntime.getDefault(context), + private val maxReadTimeOut: Pair<Long, TimeUnit> = Pair(MAX_READ_TIMEOUT_MINUTES, TimeUnit.MINUTES), +) : Client() { + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var executor: GeckoWebExecutor = GeckoWebExecutor(runtime) + + @Throws(IOException::class) + override fun fetch(request: Request): Response { + if (request.isDataUri()) { + return fetchDataUri(request) + } + + val webRequest = request.toWebRequest() + + val readTimeOut = request.readTimeout ?: maxReadTimeOut + val readTimeOutMillis = readTimeOut.let { (timeout, unit) -> + unit.toMillis(timeout) + } + + return try { + val webResponse = executor.fetch(webRequest, request.fetchFlags).poll(readTimeOutMillis) + webResponse?.toResponse() ?: throw IOException("Fetch failed with null response") + } catch (e: TimeoutException) { + throw SocketTimeoutException() + } catch (e: WebRequestError) { + throw IOException(e) + } + } + + private val Request.fetchFlags: Int + get() { + var fetchFlags = 0 + if (cookiePolicy == Request.CookiePolicy.OMIT) { + fetchFlags += GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS + } + if (private) { + fetchFlags += GeckoWebExecutor.FETCH_FLAGS_PRIVATE + } + if (redirect == Request.Redirect.MANUAL) { + fetchFlags += GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS + } + return fetchFlags + } + + companion object { + const val MAX_READ_TIMEOUT_MINUTES = 5L + } +} + +private fun Request.toWebRequest(): WebRequest = WebRequest.Builder(url) + .method(method.name) + .addHeadersFrom(this) + .addBodyFrom(this) + .referrer(referrerUrl) + .cacheMode(if (useCaches) CACHE_MODE_DEFAULT else CACHE_MODE_RELOAD) + .beConservative(conservative) + .build() + +private fun WebRequest.Builder.addHeadersFrom(request: Request): WebRequest.Builder { + request.headers?.forEach { header -> + addHeader(header.name, header.value) + } + + return this +} + +private fun WebRequest.Builder.addBodyFrom(request: Request): WebRequest.Builder { + request.body?.let { body -> + body.useStream { inStream -> + val bytes = inStream.readBytes() + val buffer = ByteBuffer.allocateDirect(bytes.size) + buffer.put(bytes) + this.body(buffer) + } + } + + return this +} + +internal fun WebResponse.toResponse(): Response { + val isDataUri = uri.startsWith("data:") + val isBlobUri = uri.startsWith("blob:") + val headers = translateHeaders(this) + // We use the same API for blobs, data URLs and HTTP requests, but blobs won't receive a status code. + // If no exception is thrown we assume success. + val status = if (isBlobUri || isDataUri) SUCCESS else statusCode + return Response( + uri, + status, + headers, + body?.let { + Response.Body(it, headers["Content-Type"]) + } ?: Response.Body.empty(), + ) +} + +private fun translateHeaders(webResponse: WebResponse): Headers { + val headers = MutableHeaders() + webResponse.headers.forEach { (k, v) -> + v.split(",").forEach { headers.append(k, it.trim()) } + } + + return headers +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/LocaleSettingUpdater.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/LocaleSettingUpdater.kt new file mode 100644 index 0000000000..dc0d0427a9 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/LocaleSettingUpdater.kt @@ -0,0 +1,56 @@ +/* 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.browser.engine.gecko.integration + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import mozilla.components.support.utils.ext.registerReceiverCompat +import org.mozilla.geckoview.GeckoRuntime +import androidx.core.os.LocaleListCompat as LocaleList + +/** + * Class to set the locales setting for geckoview, updating from the locale of the device. + */ +class LocaleSettingUpdater( + private val context: Context, + private val runtime: GeckoRuntime, +) : SettingUpdater<Array<String>>() { + + override var value: Array<String> = findValue() + set(value) { + runtime.settings.locales = value + field = value + } + + private val localeChangedReceiver by lazy { + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + updateValue() + } + } + } + + override fun registerForUpdates() { + context.registerReceiverCompat( + localeChangedReceiver, + IntentFilter(Intent.ACTION_LOCALE_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + override fun unregisterForUpdates() { + context.unregisterReceiver(localeChangedReceiver) + } + + override fun findValue(): Array<String> { + val localeList = LocaleList.getAdjustedDefault() + return arrayOfNulls<Unit>(localeList.size()) + .mapIndexedNotNull { i, _ -> localeList.get(i)?.toLanguageTag() } + .toTypedArray() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/SettingUpdater.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/SettingUpdater.kt new file mode 100644 index 0000000000..af4d455a79 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/SettingUpdater.kt @@ -0,0 +1,45 @@ +/* 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.browser.engine.gecko.integration + +abstract class SettingUpdater<T> { + /** + * Toggle the automatic tracking of a setting derived from the device state. + */ + var enabled: Boolean = false + set(value) { + if (value) { + updateValue() + registerForUpdates() + } else { + unregisterForUpdates() + } + field = value + } + + /** + * The setter for this property should change the GeckoView setting. + */ + abstract var value: T + + internal fun updateValue() { + value = findValue() + } + + /** + * Register for updates from the device state. This is setting specific. + */ + abstract fun registerForUpdates() + + /** + * Unregister for updates from the device state. + */ + abstract fun unregisterForUpdates() + + /** + * Find the value of the setting from the device state. This is setting specific. + */ + abstract fun findValue(): T +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegate.kt new file mode 100644 index 0000000000..1d807dde02 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegate.kt @@ -0,0 +1,53 @@ +/* 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.browser.engine.gecko.media + +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.media.RecordingDevice +import org.mozilla.geckoview.GeckoSession +import java.security.InvalidParameterException +import org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice as GeckoRecordingDevice + +/** + * Gecko-based GeckoMediaDelegate implementation. + */ +internal class GeckoMediaDelegate(private val geckoEngineSession: GeckoEngineSession) : + GeckoSession.MediaDelegate { + + override fun onRecordingStatusChanged( + session: GeckoSession, + geckoDevices: Array<out GeckoRecordingDevice>, + ) { + val devices = geckoDevices.map { geckoRecording -> + val type = geckoRecording.toType() + val status = geckoRecording.toStatus() + RecordingDevice(type, status) + } + geckoEngineSession.notifyObservers { onRecordingStateChanged(devices) } + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun GeckoRecordingDevice.toType(): RecordingDevice.Type { + return when (type) { + GeckoRecordingDevice.Type.CAMERA -> RecordingDevice.Type.CAMERA + GeckoRecordingDevice.Type.MICROPHONE -> RecordingDevice.Type.MICROPHONE + else -> { + throw InvalidParameterException("Unexpected Gecko Media type $type status $status") + } + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun GeckoRecordingDevice.toStatus(): RecordingDevice.Status { + return when (status) { + GeckoRecordingDevice.Status.RECORDING -> RecordingDevice.Status.RECORDING + GeckoRecordingDevice.Status.INACTIVE -> RecordingDevice.Status.INACTIVE + else -> { + throw InvalidParameterException("Unexpected Gecko Media type $type status $status") + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediaquery/PreferredColorScheme.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediaquery/PreferredColorScheme.kt new file mode 100644 index 0000000000..bb62a71dc6 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediaquery/PreferredColorScheme.kt @@ -0,0 +1,22 @@ +/* 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.browser.engine.gecko.mediaquery + +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import org.mozilla.geckoview.GeckoRuntimeSettings + +internal fun PreferredColorScheme.Companion.from(geckoValue: Int) = + when (geckoValue) { + GeckoRuntimeSettings.COLOR_SCHEME_DARK -> PreferredColorScheme.Dark + GeckoRuntimeSettings.COLOR_SCHEME_LIGHT -> PreferredColorScheme.Light + GeckoRuntimeSettings.COLOR_SCHEME_SYSTEM -> PreferredColorScheme.System + else -> PreferredColorScheme.System + } + +internal fun PreferredColorScheme.toGeckoValue() = + when (this) { + is PreferredColorScheme.Dark -> GeckoRuntimeSettings.COLOR_SCHEME_DARK + is PreferredColorScheme.Light -> GeckoRuntimeSettings.COLOR_SCHEME_LIGHT + is PreferredColorScheme.System -> GeckoRuntimeSettings.COLOR_SCHEME_SYSTEM + } diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionController.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionController.kt new file mode 100644 index 0000000000..5c65652faf --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionController.kt @@ -0,0 +1,56 @@ +/* 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.browser.engine.gecko.mediasession + +import mozilla.components.concept.engine.mediasession.MediaSession +import org.mozilla.geckoview.MediaSession as GeckoViewMediaSession + +/** + * [MediaSession.Controller] (`concept-engine`) implementation for GeckoView. + */ +internal class GeckoMediaSessionController( + private val mediaSession: GeckoViewMediaSession, +) : MediaSession.Controller { + + override fun pause() { + mediaSession.pause() + } + + override fun stop() { + mediaSession.stop() + } + + override fun play() { + mediaSession.play() + } + + override fun seekTo(time: Double, fast: Boolean) { + mediaSession.seekTo(time, fast) + } + + override fun seekForward() { + mediaSession.seekForward() + } + + override fun seekBackward() { + mediaSession.seekBackward() + } + + override fun nextTrack() { + mediaSession.nextTrack() + } + + override fun previousTrack() { + mediaSession.previousTrack() + } + + override fun skipAd() { + mediaSession.skipAd() + } + + override fun muteAudio(mute: Boolean) { + mediaSession.muteAudio(mute) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegate.kt new file mode 100644 index 0000000000..68c0a7cb5b --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegate.kt @@ -0,0 +1,125 @@ +/* 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.browser.engine.gecko.mediasession + +import android.graphics.Bitmap +import kotlinx.coroutines.withTimeoutOrNull +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.browser.engine.gecko.await +import mozilla.components.concept.engine.mediasession.MediaSession +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.Image.ImageProcessingException +import org.mozilla.geckoview.MediaSession as GeckoViewMediaSession + +private const val ARTWORK_RETRIEVE_TIMEOUT = 1000L +private const val ARTWORK_IMAGE_SIZE = 48 + +internal class GeckoMediaSessionDelegate( + private val engineSession: GeckoEngineSession, +) : GeckoViewMediaSession.Delegate { + + override fun onActivated(geckoSession: GeckoSession, mediaSession: GeckoViewMediaSession) { + engineSession.notifyObservers { + onMediaActivated(GeckoMediaSessionController(mediaSession)) + } + } + + override fun onDeactivated(session: GeckoSession, mediaSession: GeckoViewMediaSession) { + engineSession.notifyObservers { + onMediaDeactivated() + } + } + + override fun onMetadata( + session: GeckoSession, + mediaSession: GeckoViewMediaSession, + metaData: GeckoViewMediaSession.Metadata, + ) { + val getArtwork: (suspend () -> Bitmap?)? = metaData.artwork?.let { + { + try { + withTimeoutOrNull(ARTWORK_RETRIEVE_TIMEOUT) { + it.getBitmap(ARTWORK_IMAGE_SIZE).await() + } + } catch (e: ImageProcessingException) { + null + } + } + } + + engineSession.notifyObservers { + onMediaMetadataChanged( + MediaSession.Metadata(metaData.title, metaData.artist, metaData.album, getArtwork), + ) + } + } + + override fun onFeatures( + session: GeckoSession, + mediaSession: GeckoViewMediaSession, + features: Long, + ) { + engineSession.notifyObservers { + onMediaFeatureChanged(MediaSession.Feature(features)) + } + } + + override fun onPlay(session: GeckoSession, mediaSession: GeckoViewMediaSession) { + engineSession.notifyObservers { + onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) + } + } + + override fun onPause(session: GeckoSession, mediaSession: GeckoViewMediaSession) { + engineSession.notifyObservers { + onMediaPlaybackStateChanged(MediaSession.PlaybackState.PAUSED) + } + } + + override fun onStop(session: GeckoSession, mediaSession: GeckoViewMediaSession) { + engineSession.notifyObservers { + onMediaPlaybackStateChanged(MediaSession.PlaybackState.STOPPED) + } + } + + override fun onPositionState( + session: GeckoSession, + mediaSession: GeckoViewMediaSession, + positionState: GeckoViewMediaSession.PositionState, + ) { + engineSession.notifyObservers { + onMediaPositionStateChanged( + MediaSession.PositionState( + positionState.duration, + positionState.position, + positionState.playbackRate, + ), + ) + } + } + + override fun onFullscreen( + session: GeckoSession, + mediaSession: GeckoViewMediaSession, + enabled: Boolean, + elementMetaData: GeckoViewMediaSession.ElementMetadata?, + ) { + val sessionElementMetaData = + elementMetaData?.let { + MediaSession.ElementMetadata( + elementMetaData.source, + elementMetaData.duration, + elementMetaData.width, + elementMetaData.height, + elementMetaData.audioTrackCount, + elementMetaData.videoTrackCount, + ) + } + + engineSession.notifyObservers { + onMediaFullscreenChanged(enabled, sessionElementMetaData) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequest.kt new file mode 100644 index 0000000000..96c17ae7fa --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequest.kt @@ -0,0 +1,185 @@ +/* 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.browser.engine.gecko.permission + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.Manifest.permission.CAMERA +import android.Manifest.permission.RECORD_AUDIO +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.engine.permission.Permission +import mozilla.components.concept.engine.permission.PermissionRequest +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource.SOURCE_AUDIOCAPTURE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource.SOURCE_CAMERA +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource.SOURCE_MICROPHONE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource.SOURCE_OTHER +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource.SOURCE_SCREEN +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_PERSISTENT_STORAGE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_STORAGE_ACCESS +import java.util.UUID + +/** + * Gecko-based implementation of [PermissionRequest]. + * + * @property permissions the list of requested permissions. + * @property callback the callback to grant/reject the requested permissions. + * @property id a unique identifier for the request. + */ +sealed class GeckoPermissionRequest constructor( + override val permissions: List<Permission>, + private val callback: PermissionDelegate.Callback? = null, + override val id: String = UUID.randomUUID().toString(), +) : PermissionRequest { + + /** + * Represents a gecko-based content permission request. + * + * @property uri the URI of the content requesting the permissions. + * @property type the type of the requested content permission (will be + * mapped to corresponding [Permission]). + * @property geckoPermission Indicates which gecko permissions is requested. + * @property geckoResult the gecko result that serves as a callback to grant/reject the requested permissions. + */ + data class Content( + override val uri: String, + private val type: Int, + internal val geckoPermission: PermissionDelegate.ContentPermission, + internal val geckoResult: GeckoResult<Int>, + ) : GeckoPermissionRequest( + listOf(permissionsMap.getOrElse(type) { Permission.Generic("$type", "Gecko permission type = $type") }), + ) { + companion object { + val permissionsMap = mapOf( + PERMISSION_DESKTOP_NOTIFICATION to Permission.ContentNotification(), + PERMISSION_GEOLOCATION to Permission.ContentGeoLocation(), + PERMISSION_AUTOPLAY_AUDIBLE to Permission.ContentAutoPlayAudible(), + PERMISSION_AUTOPLAY_INAUDIBLE to Permission.ContentAutoPlayInaudible(), + PERMISSION_PERSISTENT_STORAGE to Permission.ContentPersistentStorage(), + PERMISSION_MEDIA_KEY_SYSTEM_ACCESS to Permission.ContentMediaKeySystemAccess(), + PERMISSION_STORAGE_ACCESS to Permission.ContentCrossOriginStorageAccess(), + ) + } + + @VisibleForTesting + internal var isCompleted = false + + override fun grant(permissions: List<Permission>) { + if (!isCompleted) { + geckoResult.complete(VALUE_ALLOW) + } + isCompleted = true + } + + override fun reject() { + if (!isCompleted) { + geckoResult.complete(VALUE_DENY) + } + isCompleted = true + } + } + + /** + * Represents a gecko-based application permission request. + * + * @property uri the URI of the content requesting the permissions. + * @property nativePermissions the list of requested app permissions (will be + * mapped to corresponding [Permission]s). + * @property callback the callback to grant/reject the requested permissions. + */ + data class App( + private val nativePermissions: List<String>, + private val callback: PermissionDelegate.Callback, + ) : GeckoPermissionRequest( + nativePermissions.map { permissionsMap.getOrElse(it) { Permission.Generic(it) } }, + callback, + ) { + override val uri: String? = null + + companion object { + val permissionsMap = mapOf( + ACCESS_COARSE_LOCATION to Permission.AppLocationCoarse(ACCESS_COARSE_LOCATION), + ACCESS_FINE_LOCATION to Permission.AppLocationFine(ACCESS_FINE_LOCATION), + CAMERA to Permission.AppCamera(CAMERA), + RECORD_AUDIO to Permission.AppAudio(RECORD_AUDIO), + ) + } + } + + /** + * Represents a gecko-based media permission request. + * + * @property uri the URI of the content requesting the permissions. + * @property videoSources the list of requested video sources (will be + * mapped to the corresponding [Permission]). + * @property audioSources the list of requested audio sources (will be + * mapped to corresponding [Permission]). + * @property callback the callback to grant/reject the requested permissions. + */ + data class Media( + override val uri: String, + private val videoSources: List<MediaSource>, + private val audioSources: List<MediaSource>, + private val callback: PermissionDelegate.MediaCallback, + ) : GeckoPermissionRequest( + videoSources.map { mapPermission(it) } + audioSources.map { mapPermission(it) }, + ) { + override fun grant(permissions: List<Permission>) { + val videos = permissions.mapNotNull { permission -> videoSources.find { it.id == permission.id } } + val audios = permissions.mapNotNull { permission -> audioSources.find { it.id == permission.id } } + callback.grant(videos.firstOrNull(), audios.firstOrNull()) + } + + override fun containsVideoAndAudioSources(): Boolean { + return videoSources.isNotEmpty() && audioSources.isNotEmpty() + } + + override fun reject() { + callback.reject() + } + + companion object { + fun mapPermission(mediaSource: MediaSource): Permission = + if (mediaSource.type == MediaSource.TYPE_AUDIO) { + mapAudioPermission(mediaSource) + } else { + mapVideoPermission(mediaSource) + } + + @Suppress("SwitchIntDef") + private fun mapAudioPermission(mediaSource: MediaSource) = when (mediaSource.source) { + SOURCE_AUDIOCAPTURE -> Permission.ContentAudioCapture(mediaSource.id, mediaSource.name) + SOURCE_MICROPHONE -> Permission.ContentAudioMicrophone(mediaSource.id, mediaSource.name) + SOURCE_OTHER -> Permission.ContentAudioOther(mediaSource.id, mediaSource.name) + else -> Permission.Generic(mediaSource.id, mediaSource.name) + } + + @Suppress("ComplexMethod", "SwitchIntDef") + private fun mapVideoPermission(mediaSource: MediaSource) = when (mediaSource.source) { + SOURCE_CAMERA -> Permission.ContentVideoCamera(mediaSource.id, mediaSource.name) + SOURCE_SCREEN -> Permission.ContentVideoScreen(mediaSource.id, mediaSource.name) + SOURCE_OTHER -> Permission.ContentVideoOther(mediaSource.id, mediaSource.name) + else -> Permission.Generic(mediaSource.id, mediaSource.name) + } + } + } + + override fun grant(permissions: List<Permission>) { + callback?.grant() + } + + override fun reject() { + callback?.reject() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorage.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorage.kt new file mode 100644 index 0000000000..20d0c8ae8e --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorage.kt @@ -0,0 +1,461 @@ +/* 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.browser.engine.gecko.permission + +import androidx.annotation.VisibleForTesting +import androidx.paging.DataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.browser.engine.gecko.await +import mozilla.components.concept.engine.permission.PermissionRequest +import mozilla.components.concept.engine.permission.SitePermissions +import mozilla.components.concept.engine.permission.SitePermissions.AutoplayStatus +import mozilla.components.concept.engine.permission.SitePermissions.Status +import mozilla.components.concept.engine.permission.SitePermissions.Status.ALLOWED +import mozilla.components.concept.engine.permission.SitePermissions.Status.BLOCKED +import mozilla.components.concept.engine.permission.SitePermissions.Status.NO_DECISION +import mozilla.components.concept.engine.permission.SitePermissionsStorage +import mozilla.components.support.ktx.kotlin.stripDefaultPort +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_PROMPT +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_PERSISTENT_STORAGE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_STORAGE_ACCESS +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_TRACKING +import org.mozilla.geckoview.StorageController +import org.mozilla.geckoview.StorageController.ClearFlags + +/** + * A storage to save [SitePermissions] using GeckoView APIs. + */ +@Suppress("LargeClass") +class GeckoSitePermissionsStorage( + runtime: GeckoRuntime, + private val onDiskStorage: SitePermissionsStorage, +) : SitePermissionsStorage { + + private val geckoStorage: StorageController = runtime.storageController + private val mainScope = CoroutineScope(Dispatchers.Main) + + /* + * Temporary permissions are created when users doesn't + * check the 'Remember my decision checkbox'. At the moment, + * gecko view doesn't handle temporary permission, + * we have to store them in memory, and clear them manually, + * until we have an API for it see: + * https://bugzilla.mozilla.org/show_bug.cgi?id=1710447 + */ + @VisibleForTesting + internal val geckoTemporaryPermissions = mutableListOf<ContentPermission>() + + override suspend fun save(sitePermissions: SitePermissions, request: PermissionRequest?, private: Boolean) { + val geckoSavedPermissions = updateGeckoPermissionIfNeeded(sitePermissions, request, private) + onDiskStorage.save(geckoSavedPermissions, request, private) + } + + override fun saveTemporary(request: PermissionRequest?) { + if (request is GeckoPermissionRequest.Content) { + geckoTemporaryPermissions.add(request.geckoPermission) + } + } + + override fun clearTemporaryPermissions() { + geckoTemporaryPermissions.forEach { + geckoStorage.setPermission(it, VALUE_PROMPT) + } + geckoTemporaryPermissions.clear() + } + + override suspend fun update(sitePermissions: SitePermissions, private: Boolean) { + val updatedPermission = updateGeckoPermissionIfNeeded(sitePermissions, private = private) + onDiskStorage.update(updatedPermission, private) + } + + override suspend fun findSitePermissionsBy( + origin: String, + includeTemporary: Boolean, + private: Boolean, + ): SitePermissions? { + /** + * GeckoView ony persists [GeckoPermissionRequest.Content] other permissions like + * [GeckoPermissionRequest.Media], we have to store them ourselves. + * For this reason, we query both storage ([geckoStorage] and [onDiskStorage]) and + * merge both results into one [SitePermissions] object. + */ + val onDiskPermission: SitePermissions? = + onDiskStorage.findSitePermissionsBy(origin, private = private) + val geckoPermissions = findGeckoContentPermissionBy(origin, includeTemporary, private).groupByType() + + return mergePermissions(onDiskPermission, geckoPermissions) + } + + override suspend fun getSitePermissionsPaged(): DataSource.Factory<Int, SitePermissions> { + val geckoPermissionsByHost = findAllGeckoContentPermissions().groupByDomain() + + return onDiskStorage.getSitePermissionsPaged().map { onDiskPermission -> + val geckoPermissions = geckoPermissionsByHost[onDiskPermission.origin].groupByType() + mergePermissions(onDiskPermission, geckoPermissions) ?: onDiskPermission + } + } + + override suspend fun remove(sitePermissions: SitePermissions, private: Boolean) { + onDiskStorage.remove(sitePermissions, private) + removeGeckoContentPermissionBy(sitePermissions.origin, private) + } + + override suspend fun removeAll() { + onDiskStorage.removeAll() + removeGeckoAllContentPermissions() + } + + override suspend fun all(): List<SitePermissions> { + val onDiskPermissions: List<SitePermissions> = onDiskStorage.all() + val geckoPermissionsByHost = findAllGeckoContentPermissions().groupByDomain() + + return onDiskPermissions.mapNotNull { onDiskPermission -> + val map = geckoPermissionsByHost[onDiskPermission.origin].groupByType() + mergePermissions(onDiskPermission, map) + } + } + + @VisibleForTesting + internal suspend fun findAllGeckoContentPermissions(): List<ContentPermission>? { + return withContext(mainScope.coroutineContext) { + geckoStorage.allPermissions.await() + .filterNotTemporaryPermissions(geckoTemporaryPermissions) + } + } + + /** + * Updates the [geckoStorage] if the provided [userSitePermissions] + * exists on the [geckoStorage] or it's provided as a part of the [permissionRequest] + * otherwise nothing is updated. + * @param userSitePermissions the values provided by the user to be updated. + * @param permissionRequest the [PermissionRequest] from the web content. + * @return An updated [SitePermissions] with default values, if they were updated + * on the [geckoStorage] otherwise the same [SitePermissions]. + */ + @VisibleForTesting + @Suppress("LongMethod") + internal suspend fun updateGeckoPermissionIfNeeded( + userSitePermissions: SitePermissions, + permissionRequest: PermissionRequest? = null, + private: Boolean, + ): SitePermissions { + var updatedPermission = userSitePermissions + val geckoPermissionsByType = + permissionRequest.extractGeckoPermissionsOrQueryTheStore(userSitePermissions.origin, private) + + if (geckoPermissionsByType.isNotEmpty()) { + val geckoNotification = geckoPermissionsByType[PERMISSION_DESKTOP_NOTIFICATION]?.firstOrNull() + val geckoLocation = geckoPermissionsByType[PERMISSION_GEOLOCATION]?.firstOrNull() + val geckoMedia = geckoPermissionsByType[PERMISSION_MEDIA_KEY_SYSTEM_ACCESS]?.firstOrNull() + val geckoLocalStorage = geckoPermissionsByType[PERMISSION_PERSISTENT_STORAGE]?.firstOrNull() + val geckoCrossOriginStorageAccess = geckoPermissionsByType[PERMISSION_STORAGE_ACCESS]?.firstOrNull() + val geckoAudible = geckoPermissionsByType[PERMISSION_AUTOPLAY_AUDIBLE]?.firstOrNull() + val geckoInAudible = geckoPermissionsByType[PERMISSION_AUTOPLAY_INAUDIBLE]?.firstOrNull() + + /* + * To avoid GeckoView caching previous request, we need to clear, previous data + * before updating. See: https://github.com/mozilla-mobile/android-components/issues/6322 + */ + clearGeckoCacheFor(updatedPermission.origin) + + if (geckoNotification != null) { + removeTemporaryPermissionIfAny(geckoNotification) + geckoStorage.setPermission( + geckoNotification, + userSitePermissions.notification.toGeckoStatus(), + ) + updatedPermission = updatedPermission.copy(notification = NO_DECISION) + } + + if (geckoLocation != null) { + removeTemporaryPermissionIfAny(geckoLocation) + geckoStorage.setPermission( + geckoLocation, + userSitePermissions.location.toGeckoStatus(), + ) + updatedPermission = updatedPermission.copy(location = NO_DECISION) + } + + if (geckoMedia != null) { + removeTemporaryPermissionIfAny(geckoMedia) + geckoStorage.setPermission( + geckoMedia, + userSitePermissions.mediaKeySystemAccess.toGeckoStatus(), + ) + updatedPermission = updatedPermission.copy(mediaKeySystemAccess = NO_DECISION) + } + + if (geckoLocalStorage != null) { + removeTemporaryPermissionIfAny(geckoLocalStorage) + geckoStorage.setPermission( + geckoLocalStorage, + userSitePermissions.localStorage.toGeckoStatus(), + ) + updatedPermission = updatedPermission.copy(localStorage = NO_DECISION) + } + + if (geckoCrossOriginStorageAccess != null) { + removeTemporaryPermissionIfAny(geckoCrossOriginStorageAccess) + geckoStorage.setPermission( + geckoCrossOriginStorageAccess, + userSitePermissions.crossOriginStorageAccess.toGeckoStatus(), + ) + updatedPermission = updatedPermission.copy(crossOriginStorageAccess = NO_DECISION) + } + + if (geckoAudible != null) { + removeTemporaryPermissionIfAny(geckoAudible) + geckoStorage.setPermission( + geckoAudible, + userSitePermissions.autoplayAudible.toGeckoStatus(), + ) + updatedPermission = updatedPermission.copy(autoplayAudible = AutoplayStatus.BLOCKED) + } + + if (geckoInAudible != null) { + removeTemporaryPermissionIfAny(geckoInAudible) + geckoStorage.setPermission( + geckoInAudible, + userSitePermissions.autoplayInaudible.toGeckoStatus(), + ) + updatedPermission = + updatedPermission.copy(autoplayInaudible = AutoplayStatus.BLOCKED) + } + } + return updatedPermission + } + + /** + * Combines a permission that comes from our on disk storage with the gecko permissions, + * and combined both into a single a [SitePermissions]. + * @param onDiskPermissions a permission from the on disk storage. + * @param geckoPermissionByType a list of all the gecko permissions mapped by permission type. + * @return a [SitePermissions] containing the values from the on disk and gecko permission. + */ + @VisibleForTesting + @Suppress("ComplexMethod") + internal fun mergePermissions( + onDiskPermissions: SitePermissions?, + geckoPermissionByType: Map<Int, List<ContentPermission>>, + ): SitePermissions? { + var combinedPermissions = onDiskPermissions + + if (geckoPermissionByType.isNotEmpty() && onDiskPermissions != null) { + val geckoNotification = geckoPermissionByType[PERMISSION_DESKTOP_NOTIFICATION]?.firstOrNull() + val geckoLocation = geckoPermissionByType[PERMISSION_GEOLOCATION]?.firstOrNull() + val geckoMedia = geckoPermissionByType[PERMISSION_MEDIA_KEY_SYSTEM_ACCESS]?.firstOrNull() + val geckoStorage = geckoPermissionByType[PERMISSION_PERSISTENT_STORAGE]?.firstOrNull() + // Currently we'll receive the "storage_access" permission for all iframes of the same parent + // so we need to ensure we are reporting the permission for the current iframe request. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1746436 for more details. + val geckoCrossOriginStorageAccess = geckoPermissionByType[PERMISSION_STORAGE_ACCESS]?.firstOrNull { + it.thirdPartyOrigin == onDiskPermissions.origin.stripDefaultPort() + } + val geckoAudible = geckoPermissionByType[PERMISSION_AUTOPLAY_AUDIBLE]?.firstOrNull() + val geckoInAudible = geckoPermissionByType[PERMISSION_AUTOPLAY_INAUDIBLE]?.firstOrNull() + + /** + * We only consider permissions from geckoView, when the values default value + * has been changed otherwise we favor the values [onDiskPermissions]. + */ + if (geckoNotification != null && geckoNotification.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + notification = geckoNotification.value.toStatus(), + ) + } + + if (geckoLocation != null && geckoLocation.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + location = geckoLocation.value.toStatus(), + ) + } + + if (geckoMedia != null && geckoMedia.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + mediaKeySystemAccess = geckoMedia.value.toStatus(), + ) + } + + if (geckoStorage != null && geckoStorage.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + localStorage = geckoStorage.value.toStatus(), + ) + } + + if (geckoCrossOriginStorageAccess != null && geckoCrossOriginStorageAccess.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + crossOriginStorageAccess = geckoCrossOriginStorageAccess.value.toStatus(), + ) + } + + /** + * Autoplay permissions don't have initial values, so when the value is changed on + * the gecko storage we trust it. + */ + if (geckoAudible != null && geckoAudible.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + autoplayAudible = geckoAudible.value.toAutoPlayStatus(), + ) + } + + if (geckoInAudible != null && geckoInAudible.value != VALUE_PROMPT) { + combinedPermissions = combinedPermissions?.copy( + autoplayInaudible = geckoInAudible.value.toAutoPlayStatus(), + ) + } + } + return combinedPermissions + } + + @VisibleForTesting + internal suspend fun findGeckoContentPermissionBy( + origin: String, + includeTemporary: Boolean = false, + private: Boolean, + ): List<ContentPermission>? { + return withContext(mainScope.coroutineContext) { + val geckoPermissions = geckoStorage.getPermissions(origin, private).await() + if (includeTemporary) { + geckoPermissions + } else { + geckoPermissions.filterNotTemporaryPermissions(geckoTemporaryPermissions) + } + } + } + + @VisibleForTesting + internal suspend fun clearGeckoCacheFor(origin: String) { + withContext(mainScope.coroutineContext) { + geckoStorage.clearDataFromHost(origin, ClearFlags.PERMISSIONS).await() + } + } + + @VisibleForTesting + internal fun clearAllPermissionsGeckoCache() { + geckoStorage.clearData(ClearFlags.PERMISSIONS) + } + + @VisibleForTesting + internal fun removeTemporaryPermissionIfAny(permission: ContentPermission) { + if (geckoTemporaryPermissions.any { permission.areSame(it) }) { + geckoTemporaryPermissions.removeAll { permission.areSame(it) } + } + } + + @VisibleForTesting + internal suspend fun removeGeckoContentPermissionBy(origin: String, private: Boolean) { + findGeckoContentPermissionBy( + origin = origin, + private = private, + )?.forEach { geckoPermissions -> + removeGeckoContentPermission(geckoPermissions) + } + } + + @VisibleForTesting + internal fun removeGeckoContentPermission(geckoPermissions: ContentPermission) { + val value = if (geckoPermissions.permission != PERMISSION_TRACKING) { + VALUE_PROMPT + } else { + VALUE_DENY + } + geckoStorage.setPermission(geckoPermissions, value) + removeTemporaryPermissionIfAny(geckoPermissions) + } + + @VisibleForTesting + internal suspend fun removeGeckoAllContentPermissions() { + findAllGeckoContentPermissions()?.forEach { geckoPermissions -> + removeGeckoContentPermission(geckoPermissions) + } + clearAllPermissionsGeckoCache() + } + + private suspend fun PermissionRequest?.extractGeckoPermissionsOrQueryTheStore( + origin: String, + private: Boolean, + ): Map<Int, List<ContentPermission>> { + return if (this is GeckoPermissionRequest.Content) { + mapOf(geckoPermission.permission to listOf(geckoPermission)) + } else { + findGeckoContentPermissionBy(origin, includeTemporary = true, private).groupByType() + } + } +} + +@VisibleForTesting +internal fun List<ContentPermission>?.groupByDomain(): Map<String, List<ContentPermission>> { + return this?.groupBy { + it.uri.tryGetHostFromUrl() + }.orEmpty() +} + +@VisibleForTesting +internal fun List<ContentPermission>?.groupByType(): Map<Int, List<ContentPermission>> { + return this?.groupBy { it.permission }.orEmpty() +} + +@VisibleForTesting +internal fun List<ContentPermission>?.filterNotTemporaryPermissions( + temporaryPermissions: List<ContentPermission>, +): List<ContentPermission>? { + return this?.filterNot { geckoPermission -> + temporaryPermissions.any { geckoPermission.areSame(it) } + } +} + +@VisibleForTesting +internal fun ContentPermission.areSame(other: ContentPermission) = + other.uri.tryGetHostFromUrl() == this.uri.tryGetHostFromUrl() && + other.permission == this.permission && other.privateMode == this.privateMode + +@VisibleForTesting +internal fun Int.toStatus(): Status { + return when (this) { + VALUE_PROMPT -> NO_DECISION + VALUE_DENY -> BLOCKED + VALUE_ALLOW -> ALLOWED + else -> BLOCKED + } +} + +@VisibleForTesting +internal fun Int.toAutoPlayStatus(): AutoplayStatus { + return when (this) { + VALUE_PROMPT, VALUE_DENY -> AutoplayStatus.BLOCKED + VALUE_ALLOW -> AutoplayStatus.ALLOWED + else -> AutoplayStatus.BLOCKED + } +} + +@VisibleForTesting +internal fun Status.toGeckoStatus(): Int { + return when (this) { + NO_DECISION -> VALUE_PROMPT + BLOCKED -> VALUE_DENY + ALLOWED -> VALUE_ALLOW + else -> VALUE_ALLOW + } +} + +@VisibleForTesting +internal fun AutoplayStatus.toGeckoStatus(): Int { + return when (this) { + AutoplayStatus.BLOCKED -> VALUE_DENY + AutoplayStatus.ALLOWED -> VALUE_ALLOW + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/profiler/Profiler.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/profiler/Profiler.kt new file mode 100644 index 0000000000..b257565473 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/profiler/Profiler.kt @@ -0,0 +1,84 @@ +/* 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.browser.engine.gecko.profiler + +import mozilla.components.concept.base.profiler.Profiler +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime + +/** + * Gecko-based implementation of [Profiler], wrapping the + * ProfilerController object provided by GeckoView. + */ +class Profiler( + private val runtime: GeckoRuntime, +) : Profiler { + + /** + * See [Profiler.isProfilerActive]. + */ + override fun isProfilerActive(): Boolean { + return runtime.profilerController.isProfilerActive + } + + /** + * See [Profiler.getProfilerTime]. + */ + override fun getProfilerTime(): Double? { + return runtime.profilerController.profilerTime + } + + /** + * See [Profiler.addMarker]. + */ + override fun addMarker(markerName: String, startTime: Double?, endTime: Double?, text: String?) { + runtime.profilerController.addMarker(markerName, startTime, endTime, text) + } + + /** + * See [Profiler.addMarker]. + */ + override fun addMarker(markerName: String, startTime: Double?, text: String?) { + runtime.profilerController.addMarker(markerName, startTime, text) + } + + /** + * See [Profiler.addMarker]. + */ + override fun addMarker(markerName: String, startTime: Double?) { + runtime.profilerController.addMarker(markerName, startTime) + } + + /** + * See [Profiler.addMarker]. + */ + override fun addMarker(markerName: String, text: String?) { + runtime.profilerController.addMarker(markerName, text) + } + + /** + * See [Profiler.addMarker]. + */ + override fun addMarker(markerName: String) { + runtime.profilerController.addMarker(markerName) + } + + override fun startProfiler(filters: Array<String>, features: Array<String>) { + runtime.profilerController.startProfiler(filters, features) + } + + override fun stopProfiler(onSuccess: (ByteArray?) -> Unit, onError: (Throwable) -> Unit) { + runtime.profilerController.stopProfiler().then( + { profileResult -> + onSuccess(profileResult) + GeckoResult<Void>() + }, + { throwable -> + onError(throwable) + GeckoResult<Void>() + }, + ) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegate.kt new file mode 100644 index 0000000000..b8353e753a --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegate.kt @@ -0,0 +1,66 @@ +/* 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.browser.engine.gecko.prompt + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.browser.engine.gecko.ext.convertToChoices +import mozilla.components.concept.engine.prompt.PromptRequest +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ChoicePrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptInstanceDelegate + +/** + * Implementation of [PromptInstanceDelegate] used to update a + * prompt request when onPromptUpdate is invoked. + * + * @param geckoSession [GeckoEngineSession] used to notify the engine observer + * with the onPromptUpdate callback. + * @param previousPrompt [PromptRequest] to be updated. + */ +internal class ChoicePromptDelegate( + private val geckoSession: GeckoEngineSession, + private var previousPrompt: PromptRequest, +) : PromptInstanceDelegate { + + override fun onPromptDismiss(prompt: BasePrompt) { + geckoSession.notifyObservers { + onPromptDismissed(previousPrompt) + } + } + + override fun onPromptUpdate(prompt: BasePrompt) { + if (prompt is ChoicePrompt) { + val promptRequest = updatePromptChoices(prompt) + if (promptRequest != null) { + geckoSession.notifyObservers { + this.onPromptUpdate(previousPrompt.uid, promptRequest) + } + previousPrompt = promptRequest + } + } + } + + /** + * Use the received prompt to create the updated [PromptRequest] + * @param updatedPrompt The [ChoicePrompt] with the updated choices. + */ + private fun updatePromptChoices(updatedPrompt: ChoicePrompt): PromptRequest? { + return when (previousPrompt) { + is PromptRequest.MenuChoice -> { + (previousPrompt as PromptRequest.MenuChoice) + .copy(choices = convertToChoices(updatedPrompt.choices)) + } + is PromptRequest.SingleChoice -> { + (previousPrompt as PromptRequest.SingleChoice) + .copy(choices = convertToChoices(updatedPrompt.choices)) + } + is PromptRequest.MultipleChoice -> { + (previousPrompt as PromptRequest.MultipleChoice) + .copy(choices = convertToChoices(updatedPrompt.choices)) + } + else -> null + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegate.kt new file mode 100644 index 0000000000..d4276e675a --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegate.kt @@ -0,0 +1,911 @@ +/* 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.browser.engine.gecko.prompt + +import android.content.Context +import android.net.Uri +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.browser.engine.gecko.ext.convertToChoices +import mozilla.components.browser.engine.gecko.ext.toAddress +import mozilla.components.browser.engine.gecko.ext.toAutocompleteAddress +import mozilla.components.browser.engine.gecko.ext.toAutocompleteCreditCard +import mozilla.components.browser.engine.gecko.ext.toCreditCardEntry +import mozilla.components.browser.engine.gecko.ext.toLoginEntry +import mozilla.components.concept.engine.prompt.Choice +import mozilla.components.concept.engine.prompt.PromptRequest +import mozilla.components.concept.engine.prompt.PromptRequest.File.Companion.DEFAULT_UPLOADS_DIR_NAME +import mozilla.components.concept.engine.prompt.PromptRequest.MenuChoice +import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice +import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice +import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.concept.identitycredential.Account +import mozilla.components.concept.identitycredential.Provider +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.support.ktx.android.net.toFileUri +import mozilla.components.support.ktx.kotlin.toDate +import mozilla.components.support.utils.TimePicker.shouldShowMillisecondsPicker +import mozilla.components.support.utils.TimePicker.shouldShowSecondsPicker +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.Autocomplete +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PromptDelegate +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BeforeUnloadPrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.DATE +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.DATETIME_LOCAL +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.MONTH +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.TIME +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.WEEK +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse +import java.security.InvalidParameterException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +typealias GeckoAuthOptions = PromptDelegate.AuthPrompt.AuthOptions +typealias GeckoChoice = PromptDelegate.ChoicePrompt.Choice +typealias GECKO_AUTH_FLAGS = PromptDelegate.AuthPrompt.AuthOptions.Flags +typealias GECKO_AUTH_LEVEL = PromptDelegate.AuthPrompt.AuthOptions.Level +typealias GECKO_PROMPT_FILE_TYPE = PromptDelegate.FilePrompt.Type +typealias GECKO_PROMPT_PROVIDER_SELECTOR = ProviderSelectorPrompt.Provider +typealias GECKO_PROMPT_ACCOUNT_SELECTOR = AccountSelectorPrompt.Account +typealias GECKO_PROMPT_ACCOUNT_SELECTOR_PROVIDER = AccountSelectorPrompt.Provider +typealias GECKO_PROMPT_CHOICE_TYPE = PromptDelegate.ChoicePrompt.Type +typealias GECKO_PROMPT_FILE_CAPTURE = PromptDelegate.FilePrompt.Capture +typealias GECKO_PROMPT_SHARE_RESULT = PromptDelegate.SharePrompt.Result +typealias AC_AUTH_LEVEL = PromptRequest.Authentication.Level +typealias AC_AUTH_METHOD = PromptRequest.Authentication.Method +typealias AC_FILE_FACING_MODE = PromptRequest.File.FacingMode + +/** + * Gecko-based PromptDelegate implementation. + */ +@Suppress("LargeClass") +internal class GeckoPromptDelegate(private val geckoEngineSession: GeckoEngineSession) : + PromptDelegate { + override fun onSelectIdentityCredentialProvider( + session: GeckoSession, + prompt: ProviderSelectorPrompt, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: (Provider) -> Unit = { provider -> + if (!prompt.isComplete) { + geckoResult.complete( + prompt.confirm( + provider.id, + ), + ) + } + } + + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.IdentityCredential.SelectProvider( + providers = prompt.providers.map { it.toProvider() }, + onConfirm = onConfirm, + onDismiss = onDismiss, + ), + ) + } + return geckoResult + } + + override fun onSelectIdentityCredentialAccount( + session: GeckoSession, + prompt: AccountSelectorPrompt, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: (Account) -> Unit = { account -> + if (!prompt.isComplete) { + geckoResult.complete( + prompt.confirm( + account.id, + ), + ) + } + } + + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.IdentityCredential.SelectAccount( + accounts = prompt.accounts.map { it.toAccount() }, + provider = prompt.provider.let { it.toProvider() }, + onConfirm = onConfirm, + onDismiss = onDismiss, + ), + ) + } + return geckoResult + } + + override fun onShowPrivacyPolicyIdentityCredential( + session: GeckoSession, + prompt: PrivacyPolicyPrompt, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: (Boolean) -> Unit = { confirmed -> + if (!prompt.isComplete) { + geckoResult.complete( + prompt.confirm(confirmed), + ) + } + } + + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.IdentityCredential.PrivacyPolicy( + privacyPolicyUrl = prompt.privacyPolicyUrl, + termsOfServiceUrl = prompt.termsOfServiceUrl, + providerDomain = prompt.providerDomain, + host = prompt.host, + icon = prompt.icon, + onConfirm = onConfirm, + onDismiss = onDismiss, + ), + ) + } + return geckoResult + } + + override fun onCreditCardSave( + session: GeckoSession, + request: AutocompleteRequest<Autocomplete.CreditCardSaveOption>, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: (CreditCardEntry) -> Unit = { creditCard -> + if (!request.isComplete) { + geckoResult.complete( + request.confirm( + Autocomplete.CreditCardSelectOption(creditCard.toAutocompleteCreditCard()), + ), + ) + } + } + + val onDismiss: () -> Unit = { + request.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.SaveCreditCard( + creditCard = request.options[0].value.toCreditCardEntry(), + onConfirm = onConfirm, + onDismiss = onDismiss, + ).also { + request.delegate = PromptInstanceDismissDelegate( + geckoEngineSession, + it, + ) + }, + ) + } + + return geckoResult + } + + /** + * Handle a credit card selection prompt request. This is triggered by the user + * focusing on a credit card input field. + * + * @param session The [GeckoSession] that triggered the request. + * @param request The [AutocompleteRequest] containing the credit card selection request. + */ + override fun onCreditCardSelect( + session: GeckoSession, + request: AutocompleteRequest<Autocomplete.CreditCardSelectOption>, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: (CreditCardEntry) -> Unit = { creditCard -> + if (!request.isComplete) { + geckoResult.complete( + request.confirm( + Autocomplete.CreditCardSelectOption(creditCard.toAutocompleteCreditCard()), + ), + ) + } + } + + val onDismiss: () -> Unit = { + request.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.SelectCreditCard( + creditCards = request.options.map { it.value.toCreditCardEntry() }, + onDismiss = onDismiss, + onConfirm = onConfirm, + ), + ) + } + + return geckoResult + } + + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest<Autocomplete.LoginSaveOption>, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val onConfirmSave: (LoginEntry) -> Unit = { entry -> + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(Autocomplete.LoginSelectOption(entry.toLoginEntry()))) + } + } + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.SaveLoginPrompt( + hint = prompt.options[0].hint, + logins = prompt.options.map { it.value.toLoginEntry() }, + onConfirm = onConfirmSave, + onDismiss = onDismiss, + ).also { + prompt.delegate = PromptInstanceDismissDelegate( + geckoEngineSession, + it, + ) + }, + ) + } + return geckoResult + } + + override fun onLoginSelect( + session: GeckoSession, + prompt: AutocompleteRequest<Autocomplete.LoginSelectOption>, + ): GeckoResult<PromptResponse>? { + val promptOptions = prompt.options + val generatedPassword = + if (promptOptions.isNotEmpty() && promptOptions.first().hint == Autocomplete.SelectOption.Hint.GENERATED) { + promptOptions.first().value.password + } else { + null + } + val geckoResult = GeckoResult<PromptResponse>() + val onConfirmSelect: (Login) -> Unit = { login -> + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(Autocomplete.LoginSelectOption(login.toLoginEntry()))) + } + } + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + // `guid` plus exactly one of `httpRealm` and `formSubmitURL` must be present to be a valid login entry. + val loginList = promptOptions.filter { option -> + option.value.guid != null && (option.value.formActionOrigin != null || option.value.httpRealm != null) + }.map { option -> + Login( + guid = option.value.guid!!, + origin = option.value.origin, + formActionOrigin = option.value.formActionOrigin, + httpRealm = option.value.httpRealm, + username = option.value.username, + password = option.value.password, + ) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.SelectLoginPrompt( + logins = loginList, + generatedPassword = generatedPassword, + onConfirm = onConfirmSelect, + onDismiss = onDismiss, + ), + ) + } + return geckoResult + } + + override fun onChoicePrompt( + session: GeckoSession, + geckoPrompt: PromptDelegate.ChoicePrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val choices = convertToChoices(geckoPrompt.choices) + + val onDismiss: () -> Unit = { + geckoPrompt.dismissSafely(geckoResult) + } + + val onConfirmSingleChoice: (Choice) -> Unit = { selectedChoice -> + if (!geckoPrompt.isComplete) { + geckoResult.complete(geckoPrompt.confirm(selectedChoice.id)) + } + } + val onConfirmMultipleSelection: (Array<Choice>) -> Unit = { selectedChoices -> + if (!geckoPrompt.isComplete) { + val ids = selectedChoices.toIdsArray() + geckoResult.complete(geckoPrompt.confirm(ids)) + } + } + + val promptRequest = when (geckoPrompt.type) { + GECKO_PROMPT_CHOICE_TYPE.SINGLE -> SingleChoice( + choices, + onConfirmSingleChoice, + onDismiss, + ) + GECKO_PROMPT_CHOICE_TYPE.MENU -> MenuChoice( + choices, + onConfirmSingleChoice, + onDismiss, + ) + GECKO_PROMPT_CHOICE_TYPE.MULTIPLE -> MultipleChoice( + choices, + onConfirmMultipleSelection, + onDismiss, + ) + else -> throw InvalidParameterException("${geckoPrompt.type} is not a valid Gecko @Choice.ChoiceType") + } + + geckoPrompt.delegate = ChoicePromptDelegate( + geckoEngineSession, + promptRequest, + ) + + geckoEngineSession.notifyObservers { + onPromptRequest(promptRequest) + } + + return geckoResult + } + + override fun onAddressSelect( + session: GeckoSession, + request: AutocompleteRequest<Autocomplete.AddressSelectOption>, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: (Address) -> Unit = { address -> + if (!request.isComplete) { + geckoResult.complete( + request.confirm( + Autocomplete.AddressSelectOption(address.toAutocompleteAddress()), + ), + ) + } + } + + val onDismiss: () -> Unit = { + request.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.SelectAddress( + addresses = request.options.map { it.value.toAddress() }, + onConfirm = onConfirm, + onDismiss = onDismiss, + ), + ) + } + + return geckoResult + } + + override fun onAlertPrompt( + session: GeckoSession, + prompt: PromptDelegate.AlertPrompt, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + val onDismiss: () -> Unit = { prompt.dismissSafely(geckoResult) } + val onConfirm: (Boolean) -> Unit = { _ -> onDismiss() } + val title = prompt.title ?: "" + val message = prompt.message ?: "" + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Alert( + title, + message, + false, + onConfirm, + onDismiss, + ), + ) + } + return geckoResult + } + + override fun onFilePrompt( + session: GeckoSession, + prompt: PromptDelegate.FilePrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val isMultipleFilesSelection = prompt.type == GECKO_PROMPT_FILE_TYPE.MULTIPLE + + val captureMode = when (prompt.capture) { + GECKO_PROMPT_FILE_CAPTURE.ANY -> AC_FILE_FACING_MODE.ANY + GECKO_PROMPT_FILE_CAPTURE.USER -> AC_FILE_FACING_MODE.FRONT_CAMERA + GECKO_PROMPT_FILE_CAPTURE.ENVIRONMENT -> AC_FILE_FACING_MODE.BACK_CAMERA + else -> AC_FILE_FACING_MODE.NONE + } + + val onSelectMultiple: (Context, Array<Uri>) -> Unit = { context, uris -> + val filesUris = uris.map { + toFileUri(uri = it, context) + }.toTypedArray() + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(context, filesUris)) + } + } + + val onSelectSingle: (Context, Uri) -> Unit = { context, uri -> + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(context, toFileUri(uri, context))) + } + } + + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.File( + prompt.mimeTypes ?: emptyArray(), + isMultipleFilesSelection, + captureMode, + onSelectSingle, + onSelectMultiple, + onDismiss, + ), + ) + } + return geckoResult + } + + @Suppress("ComplexMethod") + override fun onDateTimePrompt( + session: GeckoSession, + prompt: PromptDelegate.DateTimePrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val onConfirm: (String) -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(it)) + } + } + + val onDismiss: () -> Unit = { + prompt.dismissSafely(geckoResult) + } + + val onClear: () -> Unit = { + onConfirm("") + } + val initialDateString = prompt.defaultValue ?: "" + val stepValue = with(prompt.stepValue) { + if (this?.toDoubleOrNull() == null) { + null + } else { + this + } + } + + val format = when (prompt.type) { + DATE -> "yyyy-MM-dd" + MONTH -> "yyyy-MM" + WEEK -> "yyyy-'W'ww" + TIME -> { + if (shouldShowMillisecondsPicker(stepValue?.toFloat())) { + "HH:mm:ss.SSS" + } else if (shouldShowSecondsPicker(stepValue?.toFloat())) { + "HH:mm:ss" + } else { + "HH:mm" + } + } + DATETIME_LOCAL -> "yyyy-MM-dd'T'HH:mm" + else -> { + throw InvalidParameterException("${prompt.type} is not a valid DatetimeType") + } + } + + notifyDatePromptRequest( + prompt.title ?: "", + initialDateString, + prompt.minValue, + prompt.maxValue, + stepValue, + onClear, + format, + onConfirm, + onDismiss, + ) + + return geckoResult + } + + override fun onAuthPrompt( + session: GeckoSession, + geckoPrompt: PromptDelegate.AuthPrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val title = geckoPrompt.title ?: "" + val message = geckoPrompt.message ?: "" + val uri = geckoPrompt.authOptions.uri + val flags = geckoPrompt.authOptions.flags + val userName = geckoPrompt.authOptions.username ?: "" + val password = geckoPrompt.authOptions.password ?: "" + val method = + if (flags in GECKO_AUTH_FLAGS.HOST) AC_AUTH_METHOD.HOST else AC_AUTH_METHOD.PROXY + val level = geckoPrompt.authOptions.toACLevel() + val onlyShowPassword = flags in GECKO_AUTH_FLAGS.ONLY_PASSWORD + val previousFailed = flags in GECKO_AUTH_FLAGS.PREVIOUS_FAILED + val isCrossOrigin = flags in GECKO_AUTH_FLAGS.CROSS_ORIGIN_SUB_RESOURCE + + val onConfirm: (String, String) -> Unit = + { user, pass -> + if (!geckoPrompt.isComplete) { + if (onlyShowPassword) { + geckoResult.complete(geckoPrompt.confirm(pass)) + } else { + geckoResult.complete(geckoPrompt.confirm(user, pass)) + } + } + } + + val onDismiss: () -> Unit = { geckoPrompt.dismissSafely(geckoResult) } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Authentication( + uri, + title, + message, + userName, + password, + method, + level, + onlyShowPassword, + previousFailed, + isCrossOrigin, + onConfirm, + onDismiss, + ), + ) + } + return geckoResult + } + + override fun onTextPrompt( + session: GeckoSession, + prompt: PromptDelegate.TextPrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val title = prompt.title ?: "" + val inputLabel = prompt.message ?: "" + val inputValue = prompt.defaultValue ?: "" + val onDismiss: () -> Unit = { prompt.dismissSafely(geckoResult) } + val onConfirm: (Boolean, String) -> Unit = { _, valueInput -> + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(valueInput)) + } + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.TextPrompt( + title, + inputLabel, + inputValue, + false, + onConfirm, + onDismiss, + ), + ) + } + + return geckoResult + } + + override fun onColorPrompt( + session: GeckoSession, + prompt: PromptDelegate.ColorPrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val onConfirm: (String) -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(it)) + } + } + val onDismiss: () -> Unit = { prompt.dismissSafely(geckoResult) } + + val defaultColor = prompt.defaultValue ?: "" + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Color(defaultColor, onConfirm, onDismiss), + ) + } + return geckoResult + } + + override fun onPopupPrompt( + session: GeckoSession, + prompt: PromptDelegate.PopupPrompt, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + val onAllow: () -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(AllowOrDeny.ALLOW)) + } + } + val onDeny: () -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(AllowOrDeny.DENY)) + } + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Popup(prompt.targetUri ?: "", onAllow, onDeny), + ) + } + return geckoResult + } + + override fun onBeforeUnloadPrompt( + session: GeckoSession, + geckoPrompt: BeforeUnloadPrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val title = geckoPrompt.title ?: "" + val onAllow: () -> Unit = { + if (!geckoPrompt.isComplete) { + geckoResult.complete(geckoPrompt.confirm(AllowOrDeny.ALLOW)) + } + } + val onDeny: () -> Unit = { + if (!geckoPrompt.isComplete) { + geckoResult.complete(geckoPrompt.confirm(AllowOrDeny.DENY)) + geckoEngineSession.notifyObservers { onBeforeUnloadPromptDenied() } + } + } + + geckoEngineSession.notifyObservers { + onPromptRequest(PromptRequest.BeforeUnload(title, onAllow, onDeny)) + } + + return geckoResult + } + + override fun onSharePrompt( + session: GeckoSession, + prompt: PromptDelegate.SharePrompt, + ): GeckoResult<PromptResponse> { + val geckoResult = GeckoResult<PromptResponse>() + val onSuccess = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(GECKO_PROMPT_SHARE_RESULT.SUCCESS)) + } + } + val onFailure = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(GECKO_PROMPT_SHARE_RESULT.FAILURE)) + } + } + val onDismiss = { prompt.dismissSafely(geckoResult) } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Share( + ShareData( + title = prompt.title, + text = prompt.text, + url = prompt.uri, + ), + onSuccess, + onFailure, + onDismiss, + ), + ) + } + return geckoResult + } + + override fun onButtonPrompt( + session: GeckoSession, + prompt: PromptDelegate.ButtonPrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + val title = prompt.title ?: "" + val message = prompt.message ?: "" + + val onConfirmPositiveButton: (Boolean) -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(PromptDelegate.ButtonPrompt.Type.POSITIVE)) + } + } + val onConfirmNegativeButton: (Boolean) -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(PromptDelegate.ButtonPrompt.Type.NEGATIVE)) + } + } + + val onDismiss: (Boolean) -> Unit = { prompt.dismissSafely(geckoResult) } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Confirm( + title, + message, + false, + "", + "", + "", + onConfirmPositiveButton, + onConfirmNegativeButton, + onDismiss, + ) { + onDismiss(false) + }, + ) + } + return geckoResult + } + + override fun onRepostConfirmPrompt( + session: GeckoSession, + prompt: PromptDelegate.RepostConfirmPrompt, + ): GeckoResult<PromptResponse>? { + val geckoResult = GeckoResult<PromptResponse>() + + val onConfirm: () -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(AllowOrDeny.ALLOW)) + } + } + val onCancel: () -> Unit = { + if (!prompt.isComplete) { + geckoResult.complete(prompt.confirm(AllowOrDeny.DENY)) + geckoEngineSession.notifyObservers { onRepostPromptCancelled() } + } + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.Repost( + onConfirm, + onCancel, + ), + ) + } + return geckoResult + } + + @Suppress("LongParameterList") + private fun notifyDatePromptRequest( + title: String, + initialDateString: String, + minDateString: String?, + maxDateString: String?, + stepValue: String?, + onClear: () -> Unit, + format: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + ) { + val initialDate = initialDateString.toDate(format) + val minDate = if (minDateString.isNullOrEmpty()) null else minDateString.toDate() + val maxDate = if (maxDateString.isNullOrEmpty()) null else maxDateString.toDate() + val onSelect: (Date) -> Unit = { + val stringDate = it.toString(format) + onConfirm(stringDate) + } + + val selectionType = when (format) { + "HH:mm", "HH:mm:ss", "HH:mm:ss.SSS" -> PromptRequest.TimeSelection.Type.TIME + "yyyy-MM" -> PromptRequest.TimeSelection.Type.MONTH + "yyyy-MM-dd'T'HH:mm" -> PromptRequest.TimeSelection.Type.DATE_AND_TIME + else -> PromptRequest.TimeSelection.Type.DATE + } + + geckoEngineSession.notifyObservers { + onPromptRequest( + PromptRequest.TimeSelection( + title, + initialDate, + minDate, + maxDate, + stepValue, + selectionType, + onSelect, + onClear, + onDismiss, + ), + ) + } + } + + private fun GeckoAuthOptions.toACLevel(): AC_AUTH_LEVEL { + return when (level) { + GECKO_AUTH_LEVEL.NONE -> AC_AUTH_LEVEL.NONE + GECKO_AUTH_LEVEL.PW_ENCRYPTED -> AC_AUTH_LEVEL.PASSWORD_ENCRYPTED + GECKO_AUTH_LEVEL.SECURE -> AC_AUTH_LEVEL.SECURED + else -> { + AC_AUTH_LEVEL.NONE + } + } + } + + private operator fun Int.contains(mask: Int): Boolean { + return (this and mask) != 0 + } + + @VisibleForTesting + internal fun toFileUri(uri: Uri, context: Context): Uri { + return uri.toFileUri(context, dirToCopy = DEFAULT_UPLOADS_DIR_NAME) + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun Array<Choice>.toIdsArray(): Array<String> { + return this.map { it.id }.toTypedArray() +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun Date.toString(format: String): String { + val formatter = SimpleDateFormat(format, Locale.ROOT) + return formatter.format(this) ?: "" +} + +/** + * Only dismiss if the prompt is not already dismissed. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun PromptDelegate.BasePrompt.dismissSafely(geckoResult: GeckoResult<PromptResponse>) { + if (!this.isComplete) { + geckoResult.complete(dismiss()) + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun GECKO_PROMPT_PROVIDER_SELECTOR.toProvider(): Provider { + return Provider(id, icon, name, domain) +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun GECKO_PROMPT_ACCOUNT_SELECTOR.toAccount(): Account { + return Account(id, email, name, icon) +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun GECKO_PROMPT_ACCOUNT_SELECTOR_PROVIDER.toProvider(): Provider { + return Provider(0, icon, name, domain) +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegate.kt new file mode 100644 index 0000000000..1448b726c4 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegate.kt @@ -0,0 +1,21 @@ +/* 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.browser.engine.gecko.prompt + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.prompt.PromptRequest +import org.mozilla.geckoview.GeckoSession + +internal class PromptInstanceDismissDelegate( + private val geckoSession: GeckoEngineSession, + private val promptRequest: PromptRequest, +) : GeckoSession.PromptDelegate.PromptInstanceDelegate { + + override fun onPromptDismiss(prompt: GeckoSession.PromptDelegate.BasePrompt) { + geckoSession.notifyObservers { + onPromptDismissed(promptRequest) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegate.kt new file mode 100644 index 0000000000..ad840f1476 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegate.kt @@ -0,0 +1,70 @@ +/* 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.browser.engine.gecko.selection + +import android.app.Activity +import android.content.Context +import android.view.MenuItem +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import org.mozilla.geckoview.BasicSelectionActionDelegate + +/** + * An adapter between the GV [BasicSelectionActionDelegate] and a generic [SelectionActionDelegate]. + * + * @param customDelegate handles as much of this logic as possible. + */ +open class GeckoSelectionActionDelegate( + activity: Activity, + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val customDelegate: SelectionActionDelegate, +) : BasicSelectionActionDelegate(activity) { + + companion object { + /** + * @returns a [GeckoSelectionActionDelegate] if [customDelegate] is non-null and [context] + * is an instance of [Activity]. Otherwise, returns null. + */ + fun maybeCreate(context: Context, customDelegate: SelectionActionDelegate?): GeckoSelectionActionDelegate? { + return if (context is Activity && customDelegate != null) { + GeckoSelectionActionDelegate(context, customDelegate) + } else { + null + } + } + } + + override fun getAllActions(): Array<String> { + return customDelegate.sortedActions(super.getAllActions() + customDelegate.getAllActions()) + } + + override fun isActionAvailable(id: String): Boolean { + val selectedText = mSelection?.text + + val customActionIsAvailable = !selectedText.isNullOrEmpty() && + customDelegate.isActionAvailable(id, selectedText) + + return customActionIsAvailable || + super.isActionAvailable(id) + } + + override fun prepareAction(id: String, item: MenuItem) { + val title = customDelegate.getActionTitle(id) + ?: return super.prepareAction(id, item) + + item.title = title + } + + override fun performAction(id: String, item: MenuItem): Boolean { + /* Temporary, removed once https://bugzilla.mozilla.org/show_bug.cgi?id=1694983 is fixed */ + try { + val selectedText = mSelection?.text ?: return super.performAction(id, item) + + return customDelegate.performAction(id, selectedText) || super.performAction(id, item) + } catch (e: SecurityException) { + return false + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegate.kt new file mode 100644 index 0000000000..3bf0f24fff --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegate.kt @@ -0,0 +1,35 @@ +/* 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.browser.engine.gecko.serviceworker + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.Settings +import mozilla.components.concept.engine.serviceworker.ServiceWorkerDelegate +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession + +/** + * Default implementation for supporting Gecko service workers. + * + * @param delegate [ServiceWorkerDelegate] handling service workers requests. + * @param runtime [GeckoRuntime] current engine's runtime. + * @param engineSettings [Settings] default settings used when new [EngineSession]s are to be created. + */ +class GeckoServiceWorkerDelegate( + internal val delegate: ServiceWorkerDelegate, + internal val runtime: GeckoRuntime, + internal val engineSettings: Settings?, +) : GeckoRuntime.ServiceWorkerDelegate { + override fun onOpenWindow(url: String): GeckoResult<GeckoSession> { + val newEngineSession = GeckoEngineSession(runtime, false, engineSettings, openGeckoSession = false) + + return when (delegate.addNewTab(newEngineSession)) { + true -> GeckoResult.fromValue(newEngineSession.geckoSession) + false -> GeckoResult.fromValue(null) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegate.kt new file mode 100644 index 0000000000..3266ba8538 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegate.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.browser.engine.gecko.translate + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.translate.DetectedLanguages +import mozilla.components.concept.engine.translate.TranslationEngineState +import mozilla.components.concept.engine.translate.TranslationPair +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.TranslationsController + +internal class GeckoTranslateSessionDelegate( + private val engineSession: GeckoEngineSession, +) : TranslationsController.SessionTranslation.Delegate { + + /** + * This delegate function is triggered when requesting a translation on the page is likely. + * + * The criteria is that the page is in a different language than the user's known languages and + * that the page is translatable (a model is available). + * + * @param session The session that this delegate event corresponds to. + */ + override fun onExpectedTranslate(session: GeckoSession) { + engineSession.notifyObservers { + onTranslateExpected() + } + } + + /** + * This delegate function is triggered when the app should offer a translation. + * + * The criteria is that the translation is likely and it is the user's first visit to the host site. + * + * @param session The session that this delegate event corresponds to. + */ + override fun onOfferTranslate(session: GeckoSession) { + engineSession.notifyObservers { + onTranslateOffer() + } + } + + /** + * This delegate function is triggered when the state of the translation or translation options + * for the page has changed. State changes usually occur on navigation or if a translation + * action was requested, such as translating or restoring to the original page. + * + * This provides the translations engine state and information for the page. + * + * @param session The session that this delegate event corresponds to. + * @param state The reported translations state. Not to be confused + * with the browser translation state. + */ + override fun onTranslationStateChange( + session: GeckoSession, + state: TranslationsController.SessionTranslation.TranslationState?, + ) { + val detectedLanguages = DetectedLanguages( + state?.detectedLanguages?.docLangTag, + state?.detectedLanguages?.isDocLangTagSupported, + state?.detectedLanguages?.userLangTag, + ) + val pair = TranslationPair( + state?.requestedTranslationPair?.fromLanguage, + state?.requestedTranslationPair?.toLanguage, + ) + val translationsState = TranslationEngineState( + detectedLanguages, + state?.error, + state?.isEngineReady, + pair, + ) + + engineSession.notifyObservers { + onTranslateStateChange(translationsState) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslationUtils.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslationUtils.kt new file mode 100644 index 0000000000..c91992f355 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslationUtils.kt @@ -0,0 +1,63 @@ +/* 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.browser.engine.gecko.translate + +import mozilla.components.concept.engine.translate.TranslationError +import org.mozilla.geckoview.TranslationsController.TranslationsException + +/** + * Utility file for translations functions related to the Gecko implementation. + */ +object GeckoTranslationUtils { + + /** + * Convenience method for mapping a [TranslationsException] to the Android Components defined + * error type of [TranslationError]. + * + * Throwable is the engine throwable that occurred during translating. Ordinarily should be + * a [TranslationsException]. + */ + fun Throwable.intoTranslationError(): TranslationError { + return if (this is TranslationsException) { + when ((this).code) { + TranslationsException.ERROR_UNKNOWN -> + TranslationError.UnknownError(this) + + TranslationsException.ERROR_ENGINE_NOT_SUPPORTED -> + TranslationError.EngineNotSupportedError(this) + + TranslationsException.ERROR_COULD_NOT_TRANSLATE -> + TranslationError.CouldNotTranslateError(this) + + TranslationsException.ERROR_COULD_NOT_RESTORE -> + TranslationError.CouldNotRestoreError(this) + + TranslationsException.ERROR_COULD_NOT_LOAD_LANGUAGES -> + TranslationError.CouldNotLoadLanguagesError(this) + + TranslationsException.ERROR_LANGUAGE_NOT_SUPPORTED -> + TranslationError.LanguageNotSupportedError(this) + + TranslationsException.ERROR_MODEL_COULD_NOT_RETRIEVE -> + TranslationError.ModelCouldNotRetrieveError(this) + + TranslationsException.ERROR_MODEL_COULD_NOT_DELETE -> + TranslationError.ModelCouldNotDeleteError(this) + + TranslationsException.ERROR_MODEL_COULD_NOT_DOWNLOAD -> + TranslationError.ModelCouldNotDownloadError(this) + + TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED -> + TranslationError.ModelLanguageRequiredError(this) + + TranslationsException.ERROR_MODEL_DOWNLOAD_REQUIRED -> + TranslationError.ModelDownloadRequiredError(this) + + else -> TranslationError.UnknownError(this) + } + } else { + TranslationError.UnknownError(this) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactory.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactory.kt new file mode 100644 index 0000000000..91f0b1ece1 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactory.kt @@ -0,0 +1,154 @@ +/* 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.browser.engine.gecko.util + +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.Settings +import org.mozilla.geckoview.GeckoRuntime + +/** + * Helper factory for creating and maintaining a speculative [EngineSession]. + */ +internal class SpeculativeSessionFactory { + @VisibleForTesting + internal var speculativeEngineSession: SpeculativeEngineSession? = null + + /** + * Creates a speculative [EngineSession] using the provided [contextId] and [defaultSettings]. + * Creates a private session if [private] is set to true. + * + * The speculative [EngineSession] is kept internally until explicitly needed and access via [get]. + */ + @Synchronized + fun create( + runtime: GeckoRuntime, + private: Boolean, + contextId: String?, + defaultSettings: Settings?, + ) { + if (speculativeEngineSession?.matches(private, contextId) == true) { + // We already have a speculative engine session for this configuration. Nothing to do here. + return + } + + // Clear any potentially non-matching engine session + clear() + + speculativeEngineSession = SpeculativeEngineSession.create( + this, + runtime, + private, + contextId, + defaultSettings, + ) + } + + /** + * Clears the internal speculative [EngineSession]. + */ + @Synchronized + fun clear() { + speculativeEngineSession?.cleanUp() + speculativeEngineSession = null + } + + /** + * Returns and consumes a previously created [private] speculative [EngineSession] if it uses + * the same [contextId]. Returns `null` if no speculative [EngineSession] for that + * configuration is available. + */ + @Synchronized + fun get( + private: Boolean, + contextId: String?, + ): GeckoEngineSession? { + val speculativeEngineSession = speculativeEngineSession ?: return null + + return if (speculativeEngineSession.matches(private, contextId)) { + this.speculativeEngineSession = null + speculativeEngineSession.unwrap() + } else { + clear() + null + } + } + + @VisibleForTesting + internal fun hasSpeculativeSession(): Boolean { + return speculativeEngineSession != null + } +} + +/** + * Internal wrapper for [GeckoEngineSession] that takes care of registering and unregistering an + * observer for handling content process crashes/kills. + */ +internal class SpeculativeEngineSession constructor( + @get:VisibleForTesting internal val engineSession: GeckoEngineSession, + @get:VisibleForTesting internal val observer: SpeculativeSessionObserver, +) { + /** + * Checks whether the [SpeculativeEngineSession] matches the given configuration. + */ + fun matches(private: Boolean, contextId: String?): Boolean { + return engineSession.geckoSession.settings.usePrivateMode == private && + engineSession.geckoSession.settings.contextId == contextId + } + + /** + * Unwraps the internal [GeckoEngineSession]. + * + * After calling [unwrap] the wrapper will no longer observe the [GeckoEngineSession] and further + * crash handling is left to the application. + */ + fun unwrap(): GeckoEngineSession { + engineSession.unregister(observer) + return engineSession + } + + /** + * Cleans up the internal state of this [SpeculativeEngineSession]. After calling this method + * his [SpeculativeEngineSession] cannot be used anymore. + */ + fun cleanUp() { + engineSession.unregister(observer) + engineSession.close() + } + + companion object { + fun create( + factory: SpeculativeSessionFactory, + runtime: GeckoRuntime, + private: Boolean, + contextId: String?, + defaultSettings: Settings?, + ): SpeculativeEngineSession { + val engineSession = GeckoEngineSession(runtime, private, defaultSettings, contextId) + val observer = SpeculativeSessionObserver(factory) + engineSession.register(observer) + + return SpeculativeEngineSession(engineSession, observer) + } + } +} + +/** + * [EngineSession.Observer] implementation that will notify the [SpeculativeSessionFactory] if an + * [GeckoEngineSession] can no longer be used after a crash. + */ +internal class SpeculativeSessionObserver( + private val factory: SpeculativeSessionFactory, + +) : EngineSession.Observer { + override fun onCrash() { + factory.clear() + } + + override fun onProcessKilled() { + factory.clear() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt new file mode 100644 index 0000000000..2c5eef52c4 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt @@ -0,0 +1,449 @@ +/* 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.browser.engine.gecko.webextension + +import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.browser.engine.gecko.await +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.Settings +import mozilla.components.concept.engine.webextension.Action +import mozilla.components.concept.engine.webextension.ActionHandler +import mozilla.components.concept.engine.webextension.DisabledFlags +import mozilla.components.concept.engine.webextension.Incognito +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Metadata +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.TabHandler +import mozilla.components.concept.engine.webextension.WebExtension +import mozilla.components.support.base.log.logger.Logger +import org.json.JSONObject +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.WebExtension as GeckoNativeWebExtension +import org.mozilla.geckoview.WebExtension.Action as GeckoNativeWebExtensionAction + +/** + * Gecko-based implementation of [WebExtension], wrapping the native web + * extension object provided by GeckoView. + */ +class GeckoWebExtension( + val nativeExtension: GeckoNativeWebExtension, + val runtime: GeckoRuntime, +) : WebExtension(nativeExtension.id, nativeExtension.location, true) { + + private val connectedPorts: MutableMap<PortId, GeckoPort> = mutableMapOf() + private val logger = Logger("GeckoWebExtension") + + /** + * Uniquely identifies a port using its name and the session it + * was opened for. Ports connected from background scripts will + * have a null session. + */ + data class PortId(val name: String, val session: EngineSession? = null) + + /** + * See [WebExtension.registerBackgroundMessageHandler]. + */ + override fun registerBackgroundMessageHandler(name: String, messageHandler: MessageHandler) { + val portDelegate = object : GeckoNativeWebExtension.PortDelegate { + + override fun onPortMessage(message: Any, port: GeckoNativeWebExtension.Port) { + messageHandler.onPortMessage(message, GeckoPort(port)) + } + + override fun onDisconnect(port: GeckoNativeWebExtension.Port) { + val connectedPort = connectedPorts[PortId(name)] + if (connectedPort != null && connectedPort.nativePort == port) { + connectedPorts.remove(PortId(name)) + messageHandler.onPortDisconnected(GeckoPort(port)) + } + } + } + + connectedPorts[PortId(name)]?.nativePort?.setDelegate(portDelegate) + + val messageDelegate = object : GeckoNativeWebExtension.MessageDelegate { + + override fun onConnect(port: GeckoNativeWebExtension.Port) { + port.setDelegate(portDelegate) + val geckoPort = GeckoPort(port) + connectedPorts[PortId(name)] = geckoPort + messageHandler.onPortConnected(geckoPort) + } + + override fun onMessage( + // We don't use the same delegate instance for multiple apps so we don't need to verify the name. + name: String, + message: Any, + sender: GeckoNativeWebExtension.MessageSender, + ): GeckoResult<Any>? { + val response = messageHandler.onMessage(message, null) + return response?.let { GeckoResult.fromValue(it) } + } + } + + nativeExtension.setMessageDelegate(messageDelegate, name) + } + + /** + * See [WebExtension.registerContentMessageHandler]. + */ + override fun registerContentMessageHandler(session: EngineSession, name: String, messageHandler: MessageHandler) { + val portDelegate = object : GeckoNativeWebExtension.PortDelegate { + + override fun onPortMessage(message: Any, port: GeckoNativeWebExtension.Port) { + messageHandler.onPortMessage(message, GeckoPort(port, session)) + } + + override fun onDisconnect(port: GeckoNativeWebExtension.Port) { + val connectedPort = connectedPorts[PortId(name, session)] + if (connectedPort != null && connectedPort.nativePort == port) { + connectedPorts.remove(PortId(name, session)) + messageHandler.onPortDisconnected(connectedPort) + } + } + } + + connectedPorts[PortId(name, session)]?.nativePort?.setDelegate(portDelegate) + + val messageDelegate = object : GeckoNativeWebExtension.MessageDelegate { + + override fun onConnect(port: GeckoNativeWebExtension.Port) { + port.setDelegate(portDelegate) + val geckoPort = GeckoPort(port, session) + connectedPorts[PortId(name, session)] = geckoPort + messageHandler.onPortConnected(geckoPort) + } + + override fun onMessage( + // We don't use the same delegate instance for multiple apps so we don't need to verify the name. + name: String, + message: Any, + sender: GeckoNativeWebExtension.MessageSender, + ): GeckoResult<Any>? { + val response = messageHandler.onMessage(message, session) + return response?.let { GeckoResult.fromValue(it) } + } + } + + val geckoSession = (session as GeckoEngineSession).geckoSession + geckoSession.webExtensionController.setMessageDelegate(nativeExtension, messageDelegate, name) + } + + /** + * See [WebExtension.hasContentMessageHandler]. + */ + override fun hasContentMessageHandler(session: EngineSession, name: String): Boolean { + val geckoSession = (session as GeckoEngineSession).geckoSession + return geckoSession.webExtensionController.getMessageDelegate(nativeExtension, name) != null + } + + /** + * See [WebExtension.getConnectedPort]. + */ + override fun getConnectedPort(name: String, session: EngineSession?): Port? { + return connectedPorts[PortId(name, session)] + } + + /** + * See [WebExtension.disconnectPort]. + */ + override fun disconnectPort(name: String, session: EngineSession?) { + val portId = PortId(name, session) + val port = connectedPorts[portId] + port?.let { + it.disconnect() + connectedPorts.remove(portId) + } + } + + /** + * See [WebExtension.registerActionHandler]. + */ + override fun registerActionHandler(actionHandler: ActionHandler) { + if (!supportActions) { + logger.error( + "Attempt to register default action handler but browser and page " + + "action support is turned off for this extension: $id", + ) + return + } + + val actionDelegate = object : GeckoNativeWebExtension.ActionDelegate { + + override fun onBrowserAction( + ext: GeckoNativeWebExtension, + // Session will always be null here for the global default delegate + session: GeckoSession?, + action: GeckoNativeWebExtensionAction, + ) { + actionHandler.onBrowserAction(this@GeckoWebExtension, null, action.convert()) + } + + override fun onPageAction( + ext: GeckoNativeWebExtension, + // Session will always be null here for the global default delegate + session: GeckoSession?, + action: GeckoNativeWebExtensionAction, + ) { + actionHandler.onPageAction(this@GeckoWebExtension, null, action.convert()) + } + + override fun onTogglePopup( + ext: GeckoNativeWebExtension, + action: GeckoNativeWebExtensionAction, + ): GeckoResult<GeckoSession>? { + val session = actionHandler.onToggleActionPopup(this@GeckoWebExtension, action.convert()) + return session?.let { GeckoResult.fromValue((session as GeckoEngineSession).geckoSession) } + } + } + + nativeExtension.setActionDelegate(actionDelegate) + } + + /** + * See [WebExtension.registerActionHandler]. + */ + override fun registerActionHandler(session: EngineSession, actionHandler: ActionHandler) { + if (!supportActions) { + logger.error( + "Attempt to register action handler on session but browser and page " + + "action support is turned off for this extension: $id", + ) + return + } + + val actionDelegate = object : GeckoNativeWebExtension.ActionDelegate { + + override fun onBrowserAction( + ext: GeckoNativeWebExtension, + geckoSession: GeckoSession?, + action: GeckoNativeWebExtensionAction, + ) { + actionHandler.onBrowserAction(this@GeckoWebExtension, session, action.convert()) + } + + override fun onPageAction( + ext: GeckoNativeWebExtension, + geckoSession: GeckoSession?, + action: GeckoNativeWebExtensionAction, + ) { + actionHandler.onPageAction(this@GeckoWebExtension, session, action.convert()) + } + } + + val geckoSession = (session as GeckoEngineSession).geckoSession + geckoSession.webExtensionController.setActionDelegate(nativeExtension, actionDelegate) + } + + /** + * See [WebExtension.hasActionHandler]. + */ + override fun hasActionHandler(session: EngineSession): Boolean { + val geckoSession = (session as GeckoEngineSession).geckoSession + return geckoSession.webExtensionController.getActionDelegate(nativeExtension) != null + } + + /** + * See [WebExtension.registerTabHandler]. + */ + override fun registerTabHandler(tabHandler: TabHandler, defaultSettings: Settings?) { + val tabDelegate = object : GeckoNativeWebExtension.TabDelegate { + + override fun onNewTab( + ext: GeckoNativeWebExtension, + tabDetails: GeckoNativeWebExtension.CreateTabDetails, + ): GeckoResult<GeckoSession>? { + val geckoEngineSession = GeckoEngineSession( + runtime, + defaultSettings = defaultSettings, + openGeckoSession = false, + ) + + tabHandler.onNewTab( + this@GeckoWebExtension, + geckoEngineSession, + tabDetails.active == true, + tabDetails.url ?: "", + ) + return GeckoResult.fromValue(geckoEngineSession.geckoSession) + } + + override fun onOpenOptionsPage(ext: GeckoNativeWebExtension) { + ext.metaData.optionsPageUrl?.let { optionsPageUrl -> + tabHandler.onNewTab( + this@GeckoWebExtension, + GeckoEngineSession( + runtime, + defaultSettings = defaultSettings, + ), + false, + optionsPageUrl, + ) + } + } + } + + nativeExtension.tabDelegate = tabDelegate + } + + /** + * See [WebExtension.registerTabHandler]. + */ + override fun registerTabHandler(session: EngineSession, tabHandler: TabHandler) { + val tabDelegate = object : GeckoNativeWebExtension.SessionTabDelegate { + + override fun onUpdateTab( + ext: GeckoNativeWebExtension, + geckoSession: GeckoSession, + tabDetails: GeckoNativeWebExtension.UpdateTabDetails, + ): GeckoResult<AllowOrDeny> { + return if (tabHandler.onUpdateTab( + this@GeckoWebExtension, + session, + tabDetails.active == true, + tabDetails.url, + ) + ) { + GeckoResult.allow() + } else { + GeckoResult.deny() + } + } + + override fun onCloseTab( + ext: GeckoNativeWebExtension?, + geckoSession: GeckoSession, + ): GeckoResult<AllowOrDeny> { + return if (ext != null) { + if (tabHandler.onCloseTab(this@GeckoWebExtension, session)) { + GeckoResult.allow() + } else { + GeckoResult.deny() + } + } else { + GeckoResult.deny() + } + } + } + + val geckoSession = (session as GeckoEngineSession).geckoSession + geckoSession.webExtensionController.setTabDelegate(nativeExtension, tabDelegate) + } + + /** + * See [WebExtension.hasTabHandler]. + */ + override fun hasTabHandler(session: EngineSession): Boolean { + val geckoSession = (session as GeckoEngineSession).geckoSession + return geckoSession.webExtensionController.getTabDelegate(nativeExtension) != null + } + + /** + * See [WebExtension.getMetadata]. + */ + override fun getMetadata(): Metadata { + return nativeExtension.metaData.let { + Metadata( + name = it.name, + fullDescription = it.fullDescription, + downloadUrl = it.downloadUrl, + updateDate = it.updateDate, + averageRating = it.averageRating.toFloat(), + reviewCount = it.reviewCount, + description = it.description, + developerName = it.creatorName, + developerUrl = it.creatorUrl, + homepageUrl = it.homepageUrl, + creatorName = it.creatorName, + creatorUrl = it.creatorUrl, + reviewUrl = it.reviewUrl, + version = it.version, + permissions = it.promptPermissions.toList(), + optionalPermissions = it.optionalPermissions.toList(), + grantedOptionalPermissions = it.grantedOptionalPermissions.toList(), + grantedOptionalOrigins = it.grantedOptionalOrigins.toList(), + optionalOrigins = it.optionalOrigins.toList(), + // Origins is marked as @NonNull but may be null: https://bugzilla.mozilla.org/show_bug.cgi?id=1629957 + hostPermissions = it.origins.orEmpty().toList(), + disabledFlags = DisabledFlags.select(it.disabledFlags), + optionsPageUrl = it.optionsPageUrl, + openOptionsPageInTab = it.openOptionsPageInTab, + baseUrl = it.baseUrl, + temporary = it.temporary, + detailUrl = it.amoListingUrl, + incognito = Incognito.fromString(it.incognito), + ) + } + } + + override fun isBuiltIn(): Boolean { + return nativeExtension.isBuiltIn + } + + override fun isEnabled(): Boolean { + return nativeExtension.metaData.enabled + } + + override fun isAllowedInPrivateBrowsing(): Boolean { + return isBuiltIn() || nativeExtension.metaData.allowedInPrivateBrowsing + } + + override suspend fun loadIcon(size: Int): Bitmap? { + return getIcon(size).await() + } + + @VisibleForTesting + internal fun getIcon(size: Int): GeckoResult<Bitmap> { + return nativeExtension.metaData.icon.getBitmap(size) + } +} + +/** + * Gecko-based implementation of [Port], wrapping the native port provided by GeckoView. + */ +class GeckoPort( + internal val nativePort: GeckoNativeWebExtension.Port, + engineSession: EngineSession? = null, +) : Port(engineSession) { + + override fun postMessage(message: JSONObject) { + nativePort.postMessage(message) + } + + override fun name(): String { + return nativePort.name + } + + override fun senderUrl(): String { + return nativePort.sender.url + } + + override fun disconnect() { + nativePort.disconnect() + } +} + +private fun GeckoNativeWebExtensionAction.convert(): Action { + val loadIcon: (suspend (Int) -> Bitmap?)? = icon?.let { + { size -> icon?.getBitmap(size)?.await() } + } + + val onClick = { click() } + + return Action( + title, + enabled, + loadIcon, + badgeText, + badgeTextColor, + badgeBackgroundColor, + onClick, + ) +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionException.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionException.kt new file mode 100644 index 0000000000..3874dd903b --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionException.kt @@ -0,0 +1,72 @@ +/* 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.browser.engine.gecko.webextension + +import mozilla.components.concept.engine.webextension.WebExtensionException +import mozilla.components.concept.engine.webextension.WebExtensionInstallException +import org.mozilla.geckoview.WebExtension.InstallException +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_BLOCKLISTED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_INCOMPATIBLE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_UNSUPPORTED_ADDON_TYPE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED + +/** + * An unexpected gecko exception that occurs when trying to perform an action on the extension like + * (but not exclusively) installing/uninstalling, removing or updating.. + */ +class GeckoWebExtensionException(throwable: Throwable) : WebExtensionException(throwable) { + override val isRecoverable: Boolean = throwable is InstallException && + throwable.code == ERROR_USER_CANCELED + + companion object { + internal fun createWebExtensionException(throwable: Throwable): WebExtensionException { + if (throwable is InstallException) { + return when (throwable.code) { + ERROR_USER_CANCELED -> WebExtensionInstallException.UserCancelled( + extensionName = throwable.extensionName, + throwable, + ) + + ERROR_BLOCKLISTED -> WebExtensionInstallException.Blocklisted( + extensionName = throwable.extensionName, + throwable, + ) + + ERROR_CORRUPT_FILE -> WebExtensionInstallException.CorruptFile( + throwable = throwable, + ) + + ERROR_NETWORK_FAILURE -> WebExtensionInstallException.NetworkFailure( + throwable = throwable, + ) + + ERROR_SIGNEDSTATE_REQUIRED -> WebExtensionInstallException.NotSigned( + throwable = throwable, + ) + + ERROR_INCOMPATIBLE -> WebExtensionInstallException.Incompatible( + extensionName = throwable.extensionName, + throwable, + ) + + ERROR_UNSUPPORTED_ADDON_TYPE -> WebExtensionInstallException.UnsupportedAddonType( + extensionName = throwable.extensionName, + throwable, + ) + + else -> WebExtensionInstallException.Unknown( + extensionName = throwable.extensionName, + throwable, + ) + } + } + + return GeckoWebExtensionException(throwable) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegate.kt new file mode 100644 index 0000000000..bf9cdb71b9 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegate.kt @@ -0,0 +1,39 @@ +/* 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.browser.engine.gecko.webnotifications + +import mozilla.components.concept.engine.webnotifications.WebNotification +import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate +import org.mozilla.geckoview.WebNotification as GeckoViewWebNotification +import org.mozilla.geckoview.WebNotificationDelegate as GeckoViewWebNotificationDelegate + +internal class GeckoWebNotificationDelegate( + private val webNotificationDelegate: WebNotificationDelegate, +) : GeckoViewWebNotificationDelegate { + override fun onShowNotification(webNotification: GeckoViewWebNotification) { + webNotificationDelegate.onShowNotification(webNotification.toWebNotification()) + } + + override fun onCloseNotification(webNotification: GeckoViewWebNotification) { + webNotificationDelegate.onCloseNotification(webNotification.toWebNotification()) + } + + private fun GeckoViewWebNotification.toWebNotification(): WebNotification { + return WebNotification( + title = title, + tag = tag, + body = text, + sourceUrl = source, + iconUrl = imageUrl, + direction = textDirection, + lang = lang, + requireInteraction = requireInteraction, + triggeredByWebExtension = source == null, + privateBrowsing = privateBrowsing, + engineNotification = this@toWebNotification, + silent = silent, + ) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegate.kt new file mode 100644 index 0000000000..528f798594 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegate.kt @@ -0,0 +1,73 @@ +/* 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.browser.engine.gecko.webpush + +import mozilla.components.concept.engine.webpush.WebPushDelegate +import mozilla.components.concept.engine.webpush.WebPushSubscription +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.WebPushDelegate as GeckoViewWebPushDelegate +import org.mozilla.geckoview.WebPushSubscription as GeckoWebPushSubscription + +/** + * A wrapper for the [WebPushDelegate] to communicate with the Gecko-based delegate. + */ +internal class GeckoWebPushDelegate(private val delegate: WebPushDelegate) : GeckoViewWebPushDelegate { + + /** + * See [GeckoViewWebPushDelegate.onGetSubscription]. + */ + override fun onGetSubscription(scope: String): GeckoResult<GeckoWebPushSubscription>? { + val result: GeckoResult<GeckoWebPushSubscription> = GeckoResult() + + delegate.onGetSubscription(scope) { subscription -> + result.complete(subscription?.toGeckoSubscription()) + } + + return result + } + + /** + * See [GeckoViewWebPushDelegate.onSubscribe]. + */ + override fun onSubscribe(scope: String, appServerKey: ByteArray?): GeckoResult<GeckoWebPushSubscription>? { + val result: GeckoResult<GeckoWebPushSubscription> = GeckoResult() + + delegate.onSubscribe(scope, appServerKey) { subscription -> + result.complete(subscription?.toGeckoSubscription()) + } + + return result + } + + /** + * See [GeckoViewWebPushDelegate.onUnsubscribe]. + */ + override fun onUnsubscribe(scope: String): GeckoResult<Void>? { + val result: GeckoResult<Void> = GeckoResult() + + delegate.onUnsubscribe(scope) { success -> + if (success) { + result.complete(null) + } else { + result.completeExceptionally(WebPushException("Un-subscribing from subscription failed.")) + } + } + + return result + } +} + +/** + * A helper extension to convert the subscription data class to the Gecko-based implementation. + */ +internal fun WebPushSubscription.toGeckoSubscription() = GeckoWebPushSubscription( + scope, + endpoint, + appServerKey, + publicKey, + authSecret, +) + +internal class WebPushException(message: String) : IllegalStateException(message) diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandler.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandler.kt new file mode 100644 index 0000000000..09982b3847 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandler.kt @@ -0,0 +1,31 @@ +/* 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.browser.engine.gecko.webpush + +import mozilla.components.concept.engine.webpush.WebPushHandler +import org.mozilla.geckoview.GeckoRuntime + +/** + * Gecko-based implementation of [WebPushHandler], wrapping the + * controller object provided by GeckoView. + */ +internal class GeckoWebPushHandler( + private val runtime: GeckoRuntime, +) : WebPushHandler { + + /** + * See [WebPushHandler]. + */ + override fun onPushMessage(scope: String, message: ByteArray?) { + runtime.webPushController.onPushEvent(scope, message) + } + + /** + * See [WebPushHandler]. + */ + override fun onSubscriptionChanged(scope: String) { + runtime.webPushController.onSubscriptionChanged(scope) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequest.kt new file mode 100644 index 0000000000..22b09817f6 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequest.kt @@ -0,0 +1,23 @@ +/* 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.browser.engine.gecko.window + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.window.WindowRequest + +/** + * Gecko-based implementation of [WindowRequest]. + */ +class GeckoWindowRequest( + override val url: String = "", + private val engineSession: GeckoEngineSession, + override val type: WindowRequest.Type = WindowRequest.Type.OPEN, +) : WindowRequest { + + override fun prepare(): EngineSession { + return this.engineSession + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/experiment/NimbusExperimentDelegate.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/experiment/NimbusExperimentDelegate.kt new file mode 100644 index 0000000000..a14031a874 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/experiment/NimbusExperimentDelegate.kt @@ -0,0 +1,93 @@ +/* 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.experiment + +import mozilla.components.browser.engine.gecko.GeckoNimbus +import mozilla.components.support.base.log.logger.Logger +import org.json.JSONObject +import org.mozilla.experiments.nimbus.internal.FeatureHolder +import org.mozilla.geckoview.ExperimentDelegate +import org.mozilla.geckoview.ExperimentDelegate.ExperimentException +import org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_FEATURE_NOT_FOUND +import org.mozilla.geckoview.GeckoResult + +/** + * Default Nimbus [ExperimentDelegate] implementation to communicate with mobile Gecko and GeckoView. + */ +class NimbusExperimentDelegate : ExperimentDelegate { + + private val logger = Logger(NimbusExperimentDelegate::javaClass.name) + + /** + * Retrieves experiment information on the feature for use in GeckoView. + * + * @param feature Nimbus feature to retrieve information about + * @return a [GeckoResult] with a JSON object containing experiment information or completes exceptionally. + */ + override fun onGetExperimentFeature(feature: String): GeckoResult<JSONObject> { + val result = GeckoResult<JSONObject>() + val nimbusFeature = GeckoNimbus.getFeature(feature) + if (nimbusFeature != null) { + result.complete(nimbusFeature.toJSONObject()) + } else { + logger.warn("Could not find Nimbus feature '$feature' to retrieve experiment information.") + result.completeExceptionally(ExperimentException(ERROR_FEATURE_NOT_FOUND)) + } + return result + } + + /** + * Records that an exposure event occurred with the feature. + * + * @param feature Nimbus feature to record information about + * @return a [GeckoResult] that completes if the feature was found and recorded or completes exceptionally. + */ + override fun onRecordExposureEvent(feature: String): GeckoResult<Void> { + return recordWithFeature(feature) { it.recordExposure() } + } + + /** + * Records that an exposure event occurred with the feature, in a given experiment. + * Note: See [onRecordExposureEvent] if no slug is known or needed + * + * @param feature Nimbus feature to record information about + * @param slug Nimbus experiment slug to record information about + * @return a [GeckoResult] that completes if the feature was found and recorded or completes exceptionally. + */ + override fun onRecordExperimentExposureEvent(feature: String, slug: String): GeckoResult<Void> { + return recordWithFeature(feature) { it.recordExperimentExposure(slug) } + } + + /** + * Records a malformed exposure event for the feature. + * + * @param feature Nimbus feature to record information about + * @param part an optional detail or part identifier for then event. May be an empty string. + * @return a [GeckoResult] that completes if the feature was found and recorded or completes exceptionally. + */ + override fun onRecordMalformedConfigurationEvent(feature: String, part: String): GeckoResult<Void> { + return recordWithFeature(feature) { it.recordMalformedConfiguration(part) } + } + + /** + * Convenience method to record experiment events and return the correct errors. + * + * @param featureId Nimbus feature to record information on + * @param closure Nimbus record function to use + * @return a [GeckoResult] that completes if successful or else with an exception + */ + private fun recordWithFeature(featureId: String, closure: (FeatureHolder<*>) -> Unit): GeckoResult<Void> { + val result = GeckoResult<Void>() + val nimbusFeature = GeckoNimbus.getFeature(featureId) + if (nimbusFeature != null) { + closure(nimbusFeature) + result.complete(null) + } else { + logger.warn("Could not find Nimbus feature '$featureId' to record an exposure event.") + result.completeExceptionally(ExperimentException(ERROR_FEATURE_NOT_FOUND)) + } + return result + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionStateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionStateTest.kt new file mode 100644 index 0000000000..3b6f5eb427 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionStateTest.kt @@ -0,0 +1,61 @@ +/* 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.browser.engine.gecko + +import android.util.JsonWriter +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mozilla.geckoview.GeckoSession +import java.io.ByteArrayOutputStream + +@RunWith(AndroidJUnit4::class) +class GeckoEngineSessionStateTest { + + @Test + fun writeTo() { + val geckoState: GeckoSession.SessionState = mock() + doReturn("<state>").`when`(geckoState).toString() + + val state = GeckoEngineSessionState(geckoState) + + val stream = ByteArrayOutputStream() + val writer = JsonWriter(stream.writer()) + state.writeTo(writer) + val json = JSONObject(stream.toString()) + + assertEquals(1, json.length()) + assertTrue(json.has("GECKO_STATE")) + assertEquals("<state>", json.getString("GECKO_STATE")) + } + + @Test + fun fromJSON() { + val json = JSONObject().apply { + put("GECKO_STATE", "{ 'foo': 'bar' }") + } + + val state = GeckoEngineSessionState.fromJSON(json) + + assertEquals("""{"foo":"bar"}""", state.actualState.toString()) + } + + @Test + fun `fromJSON with invalid JSON returns empty State`() { + val json = JSONObject().apply { + put("nothing", "helpful") + } + + val state = GeckoEngineSessionState.fromJSON(json) + + assertNull(state.actualState) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt new file mode 100644 index 0000000000..87849440b6 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt @@ -0,0 +1,4874 @@ +/* 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.browser.engine.gecko + +import android.content.Intent +import android.graphics.Color +import android.os.Handler +import android.os.Looper.getMainLooper +import android.os.Message +import android.view.WindowManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.engine.gecko.ext.geckoTrackingProtectionPermission +import mozilla.components.browser.engine.gecko.ext.isExcludedForTrackingProtection +import mozilla.components.browser.engine.gecko.permission.geckoContentPermission +import mozilla.components.browser.engine.gecko.translate.GeckoTranslationUtils.intoTranslationError +import mozilla.components.browser.errorpages.ErrorType +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingStatus +import mozilla.components.concept.engine.EngineSession.LoadUrlFlags +import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.EXTERNAL +import mozilla.components.concept.engine.EngineSession.LoadUrlFlags.Companion.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE +import mozilla.components.concept.engine.EngineSession.SafeBrowsingPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory +import mozilla.components.concept.engine.EngineSessionState +import mozilla.components.concept.engine.HitResult +import mozilla.components.concept.engine.UnsupportedSettingException +import mozilla.components.concept.engine.content.blocking.Tracker +import mozilla.components.concept.engine.history.HistoryItem +import mozilla.components.concept.engine.history.HistoryTrackingDelegate +import mozilla.components.concept.engine.manifest.WebAppManifest +import mozilla.components.concept.engine.permission.PermissionRequest +import mozilla.components.concept.engine.request.RequestInterceptor +import mozilla.components.concept.engine.translate.TranslationError +import mozilla.components.concept.engine.translate.TranslationOperation +import mozilla.components.concept.engine.window.WindowRequest +import mozilla.components.concept.fetch.Headers +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.storage.PageVisit +import mozilla.components.concept.storage.RedirectSource +import mozilla.components.concept.storage.VisitType +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.eq +import mozilla.components.support.test.expectException +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import mozilla.components.support.test.whenever +import mozilla.components.support.utils.DownloadUtils.RESPONSE_CODE_SUCCESS +import mozilla.components.support.utils.ThreadUtils +import mozilla.components.test.ReflectionUtils +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.atLeastOnce +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 +import org.mockito.Mockito.verifyNoInteractions +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.ContentBlocking +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement.TYPE_AUDIO +import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE +import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement.TYPE_NONE +import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO +import org.mozilla.geckoview.GeckoSession.GeckoPrintException +import org.mozilla.geckoview.GeckoSession.GeckoPrintException.ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_STORAGE_ACCESS +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_TRACKING +import org.mozilla.geckoview.GeckoSession.ProgressDelegate.SecurityInformation +import org.mozilla.geckoview.GeckoSessionSettings +import org.mozilla.geckoview.SessionFinder +import org.mozilla.geckoview.TranslationsController +import org.mozilla.geckoview.TranslationsController.TranslationsException +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.WebRequestError.ERROR_CATEGORY_UNKNOWN +import org.mozilla.geckoview.WebRequestError.ERROR_MALFORMED_URI +import org.mozilla.geckoview.WebRequestError.ERROR_UNKNOWN +import org.mozilla.geckoview.WebResponse +import org.robolectric.Shadows.shadowOf +import java.io.IOException +import java.security.Principal +import java.security.cert.X509Certificate + +typealias GeckoAntiTracking = ContentBlocking.AntiTracking +typealias GeckoSafeBrowsing = ContentBlocking.SafeBrowsing +typealias GeckoCookieBehavior = ContentBlocking.CookieBehavior + +private const val AID = "AID" + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class GeckoEngineSessionTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + private lateinit var runtime: GeckoRuntime + private lateinit var geckoSession: GeckoSession + private lateinit var geckoSessionProvider: () -> GeckoSession + + private lateinit var navigationDelegate: ArgumentCaptor<GeckoSession.NavigationDelegate> + private lateinit var progressDelegate: ArgumentCaptor<GeckoSession.ProgressDelegate> + private lateinit var mediaDelegate: ArgumentCaptor<GeckoSession.MediaDelegate> + private lateinit var contentDelegate: ArgumentCaptor<GeckoSession.ContentDelegate> + private lateinit var permissionDelegate: ArgumentCaptor<GeckoSession.PermissionDelegate> + private lateinit var scrollDelegate: ArgumentCaptor<GeckoSession.ScrollDelegate> + private lateinit var contentBlockingDelegate: ArgumentCaptor<ContentBlocking.Delegate> + private lateinit var historyDelegate: ArgumentCaptor<GeckoSession.HistoryDelegate> + + @Suppress("DEPRECATION") + // Deprecation will be handled in https://github.com/mozilla-mobile/android-components/issues/8514 + @Before + fun setup() { + ThreadUtils.setHandlerForTest( + object : Handler() { + override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { + val wrappedRunnable = Runnable { + try { + msg.callback?.run() + } catch (t: Throwable) { + // We ignore this in the test as the runnable could be calling + // a native method (disposeNative) which won't work in Robolectric + } + } + return super.sendMessageAtTime(Message.obtain(this, wrappedRunnable), uptimeMillis) + } + }, + ) + + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + navigationDelegate = ArgumentCaptor.forClass(GeckoSession.NavigationDelegate::class.java) + progressDelegate = ArgumentCaptor.forClass(GeckoSession.ProgressDelegate::class.java) + mediaDelegate = ArgumentCaptor.forClass(GeckoSession.MediaDelegate::class.java) + contentDelegate = ArgumentCaptor.forClass(GeckoSession.ContentDelegate::class.java) + permissionDelegate = ArgumentCaptor.forClass(GeckoSession.PermissionDelegate::class.java) + scrollDelegate = ArgumentCaptor.forClass(GeckoSession.ScrollDelegate::class.java) + contentBlockingDelegate = ArgumentCaptor.forClass(ContentBlocking.Delegate::class.java) + historyDelegate = ArgumentCaptor.forClass(GeckoSession.HistoryDelegate::class.java) + + geckoSession = mockGeckoSession() + geckoSessionProvider = { geckoSession } + } + + private fun captureDelegates() { + verify(geckoSession).navigationDelegate = navigationDelegate.capture() + verify(geckoSession).progressDelegate = progressDelegate.capture() + verify(geckoSession).contentDelegate = contentDelegate.capture() + verify(geckoSession).permissionDelegate = permissionDelegate.capture() + verify(geckoSession).scrollDelegate = scrollDelegate.capture() + verify(geckoSession).contentBlockingDelegate = contentBlockingDelegate.capture() + verify(geckoSession).historyDelegate = historyDelegate.capture() + verify(geckoSession).mediaDelegate = mediaDelegate.capture() + } + + @Test + fun engineSessionInitialization() { + GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + verify(geckoSession).open(any()) + + captureDelegates() + + assertNotNull(navigationDelegate.value) + assertNotNull(progressDelegate.value) + } + + @Test + fun isIgnoredForTrackingProtection() { + val session = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + session.geckoPermissions = + listOf(geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_ALLOW)) + + var ignored = session.isIgnoredForTrackingProtection() + + assertTrue(ignored) + + session.geckoPermissions = + listOf(geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_DENY)) + + ignored = session.isIgnoredForTrackingProtection() + + assertFalse(ignored) + } + + @Test + fun `WHEN calling isExcludedForTrackingProtection THEN indicate if it is excluded for tracking protection`() { + val excludedPermission = geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_ALLOW) + + assertTrue(excludedPermission.isExcludedForTrackingProtection) + + val noExcludedPermission = geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_DENY) + + assertFalse(noExcludedPermission.isExcludedForTrackingProtection) + + val storagePermission = geckoContentPermission(type = PERMISSION_STORAGE_ACCESS, value = VALUE_DENY) + + assertFalse(storagePermission.isExcludedForTrackingProtection) + } + + @Test + fun `WHEN calling geckoTrackingProtectionPermission on a session THEN provide the gecko tracking protection permission`() { + val session = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + val trackingProtectionPermission = geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_ALLOW) + val storagePermission = geckoContentPermission(type = PERMISSION_STORAGE_ACCESS, value = VALUE_DENY) + + session.geckoPermissions = listOf(trackingProtectionPermission, storagePermission) + + assertEquals(session.geckoTrackingProtectionPermission, trackingProtectionPermission) + + session.geckoPermissions = listOf(storagePermission) + + assertNull(session.geckoTrackingProtectionPermission) + } + + @Test + fun progressDelegateNotifiesObservers() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var observedProgress = 0 + var observedLoadingState = false + var observedSecurityChange = false + engineSession.register( + object : EngineSession.Observer { + override fun onLoadingStateChange(loading: Boolean) { observedLoadingState = loading } + override fun onProgress(progress: Int) { observedProgress = progress } + override fun onSecurityChange(secure: Boolean, host: String?, issuer: String?) { + // We cannot assert on actual parameters as SecurityInfo's fields can't be set + // from the outside and its constructor isn't accessible either. + observedSecurityChange = true + } + }, + ) + + captureDelegates() + + progressDelegate.value.onPageStart(mock(), "http://mozilla.org") + assertEquals(GeckoEngineSession.PROGRESS_START, observedProgress) + assertEquals(true, observedLoadingState) + + progressDelegate.value.onPageStop(mock(), true) + assertEquals(GeckoEngineSession.PROGRESS_STOP, observedProgress) + assertEquals(false, observedLoadingState) + + // Stop will update the loading state and progress observers even when + // we haven't completed been successful. + progressDelegate.value.onPageStart(mock(), "http://mozilla.org") + assertEquals(GeckoEngineSession.PROGRESS_START, observedProgress) + assertEquals(true, observedLoadingState) + + progressDelegate.value.onPageStop(mock(), false) + assertEquals(GeckoEngineSession.PROGRESS_STOP, observedProgress) + assertEquals(false, observedLoadingState) + + val securityInfo = mock<SecurityInformation>() + progressDelegate.value.onSecurityChange(mock(), securityInfo) + assertTrue(observedSecurityChange) + + observedSecurityChange = false + + progressDelegate.value.onSecurityChange(mock(), mock()) + assertTrue(observedSecurityChange) + } + + @Test + fun navigationDelegateNotifiesObservers() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + var observedUrl = "" + var observedUserGesture = true + var observedCanGoBack = false + var observedCanGoForward = false + var cookieBanner = CookieBannerHandlingStatus.HANDLED + var displaysProduct = false + engineSession.register( + object : EngineSession.Observer { + override fun onLocationChange(url: String, hasUserGesture: Boolean) { + observedUrl = url + observedUserGesture = hasUserGesture + } + override fun onNavigationStateChange(canGoBack: Boolean?, canGoForward: Boolean?) { + canGoBack?.let { observedCanGoBack = canGoBack } + canGoForward?.let { observedCanGoForward = canGoForward } + } + override fun onCookieBannerChange(status: CookieBannerHandlingStatus) { + cookieBanner = status + } + override fun onProductUrlChange(isProductUrl: Boolean) { + displaysProduct = isProductUrl + } + }, + ) + + captureDelegates() + + navigationDelegate.value.onLocationChange(mock(), "http://mozilla.org", emptyList(), false) + assertEquals("http://mozilla.org", observedUrl) + assertEquals(false, observedUserGesture) + assertEquals(CookieBannerHandlingStatus.NO_DETECTED, cookieBanner) + // TO DO: add a positive test case after a test endpoint is implemented in desktop (Bug 1846341) + assertEquals(false, displaysProduct) + + navigationDelegate.value.onCanGoBack(mock(), true) + assertEquals(true, observedCanGoBack) + + navigationDelegate.value.onCanGoForward(mock(), true) + assertEquals(true, observedCanGoForward) + } + + @Test + fun contentDelegateNotifiesObserverAboutDownloads() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + privateMode = true, + ) + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + val response = WebResponse.Builder("https://download.mozilla.org/image.png") + .addHeader(Headers.Names.CONTENT_TYPE, "image/png") + .addHeader(Headers.Names.CONTENT_LENGTH, "42") + .skipConfirmation(true) + .requestExternalApp(true) + .body(mock()) + .build() + + val captor = argumentCaptor<Response>() + captureDelegates() + contentDelegate.value.onExternalResponse(mock(), response) + + verify(observer).onExternalResource( + url = eq("https://download.mozilla.org/image.png"), + fileName = eq("image.png"), + contentLength = eq(42), + contentType = eq("image/png"), + cookie = eq(null), + userAgent = eq(null), + isPrivate = eq(true), + skipConfirmation = eq(true), + openInApp = eq(true), + response = captor.capture(), + ) + + assertNotNull(captor.value) + } + + @Test + fun contentDelegateNotifiesObserverAboutDownloadsWithMalformedContentLength() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + privateMode = true, + ) + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + val response = WebResponse.Builder("https://download.mozilla.org/image.png") + .addHeader(Headers.Names.CONTENT_TYPE, "image/png") + .addHeader(Headers.Names.CONTENT_LENGTH, "42,42") + .body(mock()) + .build() + + val captor = argumentCaptor<Response>() + captureDelegates() + contentDelegate.value.onExternalResponse(mock(), response) + + verify(observer).onExternalResource( + url = eq("https://download.mozilla.org/image.png"), + fileName = eq("image.png"), + contentLength = eq(null), + contentType = eq("image/png"), + cookie = eq(null), + userAgent = eq(null), + isPrivate = eq(true), + skipConfirmation = eq(false), + openInApp = eq(false), + response = captor.capture(), + ) + + assertNotNull(captor.value) + } + + @Test + fun contentDelegateNotifiesObserverAboutDownloadsWithEmptyContentLength() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + privateMode = true, + ) + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + val response = WebResponse.Builder("https://download.mozilla.org/image.png") + .addHeader(Headers.Names.CONTENT_TYPE, "image/png") + .addHeader(Headers.Names.CONTENT_LENGTH, "") + .body(mock()) + .build() + + val captor = argumentCaptor<Response>() + captureDelegates() + contentDelegate.value.onExternalResponse(mock(), response) + + verify(observer).onExternalResource( + url = eq("https://download.mozilla.org/image.png"), + fileName = eq("image.png"), + contentLength = eq(null), + contentType = eq("image/png"), + cookie = eq(null), + userAgent = eq(null), + isPrivate = eq(true), + skipConfirmation = eq(false), + openInApp = eq(false), + response = captor.capture(), + ) + + assertNotNull(captor.value) + } + + @Test + fun contentDelegateNotifiesObserverAboutWebAppManifest() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + val json = JSONObject().apply { + put("name", "Minimal") + put("start_url", "/") + } + val manifest = WebAppManifest( + name = "Minimal", + startUrl = "/", + ) + + captureDelegates() + contentDelegate.value.onWebAppManifest(mock(), json) + + verify(observer).onWebAppManifestLoaded(manifest) + } + + @Test + fun permissionDelegateNotifiesObservers() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val observedContentPermissionRequests: MutableList<PermissionRequest> = mutableListOf() + val observedAppPermissionRequests: MutableList<PermissionRequest> = mutableListOf() + engineSession.register( + object : EngineSession.Observer { + override fun onContentPermissionRequest(permissionRequest: PermissionRequest) { + observedContentPermissionRequests.add(permissionRequest) + } + + override fun onAppPermissionRequest(permissionRequest: PermissionRequest) { + observedAppPermissionRequests.add(permissionRequest) + } + }, + ) + + captureDelegates() + + permissionDelegate.value.onContentPermissionRequest( + geckoSession, + geckoContentPermission("originContent", GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION), + ) + + permissionDelegate.value.onContentPermissionRequest( + geckoSession, + geckoContentPermission("", GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION), + ) + + permissionDelegate.value.onMediaPermissionRequest( + geckoSession, + "originMedia", + emptyArray(), + emptyArray(), + mock(), + ) + + permissionDelegate.value.onMediaPermissionRequest( + geckoSession, + "about:blank", + null, + null, + mock(), + ) + + permissionDelegate.value.onAndroidPermissionsRequest( + geckoSession, + emptyArray(), + mock(), + ) + + permissionDelegate.value.onAndroidPermissionsRequest( + geckoSession, + null, + mock(), + ) + + assertEquals(4, observedContentPermissionRequests.size) + assertEquals("originContent", observedContentPermissionRequests[0].uri) + assertEquals("", observedContentPermissionRequests[1].uri) + assertEquals("originMedia", observedContentPermissionRequests[2].uri) + assertEquals("about:blank", observedContentPermissionRequests[3].uri) + assertEquals(2, observedAppPermissionRequests.size) + } + + @Test + fun scrollDelegateNotifiesObservers() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val observedScrollChanges: MutableList<Pair<Int, Int>> = mutableListOf() + engineSession.register( + object : EngineSession.Observer { + override fun onScrollChange(scrollX: Int, scrollY: Int) { + observedScrollChanges.add(Pair(scrollX, scrollY)) + } + }, + ) + + captureDelegates() + + scrollDelegate.value.onScrollChanged( + geckoSession, + 1234, + 4321, + ) + + scrollDelegate.value.onScrollChanged( + geckoSession, + 2345, + 5432, + ) + + assertEquals(2, observedScrollChanges.size) + assertEquals(Pair(1234, 4321), observedScrollChanges[0]) + assertEquals(Pair(2345, 5432), observedScrollChanges[1]) + } + + @Test + fun loadUrl() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val parentEngineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + engineSession.loadUrl("http://mozilla.org") + verify(geckoSession).load( + GeckoSession.Loader().uri("http://mozilla.org"), + ) + + engineSession.loadUrl("http://www.mozilla.org", flags = LoadUrlFlags.select(LoadUrlFlags.EXTERNAL)) + verify(geckoSession).load( + GeckoSession.Loader().uri("http://www.mozilla.org").flags(LoadUrlFlags.EXTERNAL), + ) + + engineSession.loadUrl("http://www.mozilla.org", parent = parentEngineSession) + verify(geckoSession).load( + GeckoSession.Loader().uri("http://www.mozilla.org").referrer(parentEngineSession.geckoSession), + ) + + val extraHeaders = mapOf("X-Extra-Header" to "true") + engineSession.loadUrl("http://www.mozilla.org", additionalHeaders = extraHeaders) + verify(geckoSession).load( + GeckoSession.Loader().uri("http://www.mozilla.org").additionalHeaders(extraHeaders) + .headerFilter(GeckoSession.HEADER_FILTER_CORS_SAFELISTED), + ) + + engineSession.loadUrl( + "http://www.mozilla.org", + flags = LoadUrlFlags.select(LoadUrlFlags.ALLOW_ADDITIONAL_HEADERS), + additionalHeaders = extraHeaders, + ) + verify(geckoSession).load( + GeckoSession.Loader().uri("http://www.mozilla.org").additionalHeaders(extraHeaders) + .headerFilter(GeckoSession.HEADER_FILTER_CORS_SAFELISTED), + ) + } + + @Test + fun `loadUrl doesn't load URLs with blocked schemes`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + engineSession.loadUrl("file://test.txt") + engineSession.loadUrl("FILE://test.txt") + verify(geckoSession, never()).load(GeckoSession.Loader().uri("file://test.txt")) + verify(geckoSession, never()).load(GeckoSession.Loader().uri("FILE://test.txt")) + + engineSession.loadUrl("resource://package/test.text") + engineSession.loadUrl("RESOURCE://package/test.text") + verify(geckoSession, never()).load(GeckoSession.Loader().uri("resource://package/test.text")) + verify(geckoSession, never()).load(GeckoSession.Loader().uri("RESOURCE://package/test.text")) + } + + @Test + fun loadData() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.loadData("<html><body>Hello!</body></html>") + verify(geckoSession).load( + GeckoSession.Loader().data("<html><body>Hello!</body></html>", "text/html"), + ) + + engineSession.loadData("Hello!", "text/plain", "UTF-8") + verify(geckoSession).load( + GeckoSession.Loader().data("Hello!", "text/plain"), + ) + + engineSession.loadData("ahr0cdovl21vemlsbgeub3jn==", "text/plain", "base64") + verify(geckoSession).load( + GeckoSession.Loader().data("ahr0cdovl21vemlsbgeub3jn==".toByteArray(), "text/plain"), + ) + + engineSession.loadData("ahr0cdovl21vemlsbgeub3jn==", encoding = "base64") + verify(geckoSession).load( + GeckoSession.Loader().data("ahr0cdovl21vemlsbgeub3jn==".toByteArray(), "text/html"), + ) + } + + @Test + fun `getGeckoFlags returns only gecko load flags`() { + val flags = LoadUrlFlags.select(LoadUrlFlags.all().getGeckoFlags()) + + assertFalse(flags.contains(LoadUrlFlags.NONE)) + assertTrue(flags.contains(LoadUrlFlags.BYPASS_CACHE)) + assertTrue(flags.contains(LoadUrlFlags.BYPASS_PROXY)) + assertTrue(flags.contains(LoadUrlFlags.EXTERNAL)) + assertTrue(flags.contains(LoadUrlFlags.ALLOW_POPUPS)) + assertTrue(flags.contains(LoadUrlFlags.BYPASS_CLASSIFIER)) + assertTrue(flags.contains(LoadUrlFlags.LOAD_FLAGS_FORCE_ALLOW_DATA_URI)) + assertTrue(flags.contains(LoadUrlFlags.LOAD_FLAGS_REPLACE_HISTORY)) + assertTrue(flags.contains(LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE)) + assertFalse(flags.contains(LoadUrlFlags.ALLOW_ADDITIONAL_HEADERS)) + assertFalse(flags.contains(LoadUrlFlags.ALLOW_JAVASCRIPT_URL)) + } + + @Test + fun loadDataBase64() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.loadData("Hello!", "text/plain", "UTF-8") + verify(geckoSession).load( + GeckoSession.Loader().data("Hello!", "text/plain"), + ) + + engineSession.loadData("ahr0cdovl21vemlsbgeub3jn==", "text/plain", "base64") + verify(geckoSession).load( + GeckoSession.Loader().data("ahr0cdovl21vemlsbgeub3jn==".toByteArray(), "text/plain"), + ) + + engineSession.loadData("ahr0cdovl21vemlsbgeub3jn==", encoding = "base64") + verify(geckoSession).load( + GeckoSession.Loader().data("ahr0cdovl21vemlsbgeub3jn==".toByteArray(), "text/plain"), + ) + } + + @Test + fun stopLoading() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.stopLoading() + + verify(geckoSession).stop() + } + + @Test + fun reload() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + engineSession.loadUrl("http://mozilla.org") + + // Initial load is still in progress so reload should not be called. + // Instead we should have called loadUrl again to prevent reloading + // about:blank. + engineSession.reload() + verify(geckoSession, never()).reload(GeckoSession.LOAD_FLAGS_BYPASS_CACHE) + verify(geckoSession, times(2)).load( + GeckoSession.Loader().uri("http://mozilla.org"), + ) + + // Subsequent reloads should simply call reload on the gecko session. + engineSession.initialLoadRequest = null + engineSession.reload() + verify(geckoSession).reload(GeckoSession.LOAD_FLAGS_NONE) + + engineSession.reload(flags = LoadUrlFlags.select(LoadUrlFlags.BYPASS_CACHE)) + verify(geckoSession).reload(GeckoSession.LOAD_FLAGS_BYPASS_CACHE) + } + + @Test + fun goBack() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.goBack() + + verify(geckoSession).goBack(true) + } + + @Test + fun goForward() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.goForward() + + verify(geckoSession).goForward(true) + } + + @Test + fun goToHistoryIndex() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.goToHistoryIndex(0) + + verify(geckoSession).gotoHistoryIndex(0) + } + + @Test + fun restoreState() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val actualState: GeckoSession.SessionState = mock() + val state = GeckoEngineSessionState(actualState) + + assertTrue(engineSession.restoreState(state)) + verify(geckoSession).restoreState(any()) + } + + @Test + fun `restoreState returns false for null state`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val state = GeckoEngineSessionState(null) + + assertFalse(engineSession.restoreState(state)) + verify(geckoSession, never()).restoreState(any()) + } + + @Test + fun progressDelegateIgnoresInitialLoadOfAboutBlank() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var observedSecurityChange = false + var progressObserved = false + var loadingStateChangeObserved = false + engineSession.register( + object : EngineSession.Observer { + override fun onSecurityChange(secure: Boolean, host: String?, issuer: String?) { + observedSecurityChange = true + } + + override fun onProgress(progress: Int) { + progressObserved = true + } + + override fun onLoadingStateChange(loading: Boolean) { + loadingStateChangeObserved = true + } + }, + ) + + captureDelegates() + + progressDelegate.value.onSecurityChange( + mock(), + MockSecurityInformation("moz-nullprincipal:{uuid}"), + ) + assertFalse(observedSecurityChange) + + progressDelegate.value.onSecurityChange( + mock(), + MockSecurityInformation("https://www.mozilla.org"), + ) + assertTrue(observedSecurityChange) + + progressDelegate.value.onPageStart(mock(), "about:blank") + assertFalse(progressObserved) + assertFalse(loadingStateChangeObserved) + + progressDelegate.value.onPageStop(mock(), true) + assertFalse(progressObserved) + assertFalse(loadingStateChangeObserved) + + progressDelegate.value.onPageStart(mock(), "https://www.mozilla.org") + assertTrue(progressObserved) + assertTrue(loadingStateChangeObserved) + + progressDelegate.value.onPageStop(mock(), true) + assertTrue(progressObserved) + assertTrue(loadingStateChangeObserved) + } + + @Test + fun navigationDelegateIgnoresInitialLoadOfAboutBlank() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + var observedUrl = "" + engineSession.register( + object : EngineSession.Observer { + override fun onLocationChange(url: String, hasUserGesture: Boolean) { observedUrl = url } + }, + ) + + captureDelegates() + + navigationDelegate.value.onLocationChange(mock(), "about:blank", emptyList(), false) + assertEquals("", observedUrl) + + navigationDelegate.value.onLocationChange(mock(), "about:blank", emptyList(), false) + assertEquals("", observedUrl) + + navigationDelegate.value.onLocationChange(mock(), "https://www.mozilla.org", emptyList(), false) + assertEquals("https://www.mozilla.org", observedUrl) + + navigationDelegate.value.onLocationChange(mock(), "about:blank", emptyList(), false) + assertEquals("about:blank", observedUrl) + } + + @Test + fun `onLoadRequest will reset initial load flag on process switch to ignore about blank loads`() { + val session = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + captureDelegates() + assertTrue(session.initialLoad) + + navigationDelegate.value.onLocationChange(mock(), "https://mozilla.org", emptyList(), false) + assertFalse(session.initialLoad) + + navigationDelegate.value.onLoadRequest(mock(), mockLoadRequest("moz-extension://1234-test")) + assertTrue(session.initialLoad) + + var observedUrl = "" + session.register( + object : EngineSession.Observer { + override fun onLocationChange(url: String, hasUserGesture: Boolean) { observedUrl = url } + }, + ) + navigationDelegate.value.onLocationChange(mock(), "about:blank", emptyList(), false) + assertEquals("", observedUrl) + + navigationDelegate.value.onLocationChange(mock(), "https://www.mozilla.org", emptyList(), false) + assertEquals("https://www.mozilla.org", observedUrl) + + navigationDelegate.value.onLocationChange(mock(), "about:blank", emptyList(), false) + assertEquals("about:blank", observedUrl) + } + + @Test + fun `do not keep track of current url via onPageStart events`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + assertNull(engineSession.currentUrl) + progressDelegate.value.onPageStart(geckoSession, "https://www.mozilla.org") + assertNull(engineSession.currentUrl) + + progressDelegate.value.onPageStart(geckoSession, "https://www.firefox.com") + assertNull(engineSession.currentUrl) + } + + @Test + fun `keeps track of current url via onLocationChange events`() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + val geckoResult = GeckoResult<Boolean?>() + + captureDelegates() + geckoResult.complete(true) + + assertNull(engineSession.currentUrl) + navigationDelegate.value.onLocationChange(geckoSession, "https://www.mozilla.org", emptyList(), false) + assertEquals("https://www.mozilla.org", engineSession.currentUrl) + + navigationDelegate.value.onLocationChange(geckoSession, "https://www.firefox.com", emptyList(), false) + assertEquals("https://www.firefox.com", engineSession.currentUrl) + } + + @Test + fun `WHEN onLocationChange is called THEN geckoPermissions is assigned`() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + captureDelegates() + + navigationDelegate.value.onLocationChange(geckoSession, "https://www.mozilla.org", listOf(mock()), false) + + assertTrue(engineSession.geckoPermissions.isNotEmpty()) + } + + @Test + fun `WHEN onLocationChange is called with null URL THEN geckoPermissions is assigned`() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + captureDelegates() + + navigationDelegate.value.onLocationChange(geckoSession, null, listOf(mock()), false) + + assertTrue(engineSession.geckoPermissions.isNotEmpty()) + } + + @Test + fun `notifies configured history delegate of title changes`() = runTestOnMain { + val engineSession = GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + // Nothing breaks if history delegate isn't configured. + contentDelegate.value.onTitleChange(geckoSession, "Hello World!") + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri(eq("https://www.mozilla.com"))).thenReturn(true) + + contentDelegate.value.onTitleChange(geckoSession, "Hello World!") + verify(historyTrackingDelegate, never()).onTitleChanged(anyString(), anyString()) + + // This sets the currentUrl. + navigationDelegate.value.onLocationChange(geckoSession, "https://www.mozilla.com", emptyList(), false) + + contentDelegate.value.onTitleChange(geckoSession, "Hello World!") + verify(historyTrackingDelegate).onTitleChanged(eq("https://www.mozilla.com"), eq("Hello World!")) + verify(historyTrackingDelegate).shouldStoreUri(eq("https://www.mozilla.com")) + } + + @Test + fun `does not notify configured history delegate of title changes for private sessions`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + privateMode = true, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + // Nothing breaks if history delegate isn't configured. + contentDelegate.value.onTitleChange(geckoSession, "Hello World!") + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + contentDelegate.value.onTitleChange(geckoSession, "Hello World!") + verify(historyTrackingDelegate, never()).onTitleChanged(anyString(), anyString()) + verify(observer).onTitleChange("Hello World!") + + // This sets the currentUrl. + progressDelegate.value.onPageStart(geckoSession, "https://www.mozilla.com") + + contentDelegate.value.onTitleChange(geckoSession, "Mozilla") + verify(historyTrackingDelegate, never()).onTitleChanged(anyString(), anyString()) + verify(observer).onTitleChange("Mozilla") + } + + @Test + fun `GIVEN an app initiated request WHEN the user swipe back or launches the browser THEN the tab should display the correct page`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + + captureDelegates() + + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + var observedUrl = "https://www.google.com" + var observedTitle = "Google Search" + val emptyPageUrl = "https://example.com" + + engineSession.register( + object : EngineSession.Observer { + override fun onLocationChange(url: String, hasUserGesture: Boolean) { observedUrl = url } + override fun onTitleChange(title: String) { observedTitle = title } + }, + ) + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + engineSession.appRedirectUrl = emptyPageUrl + + class MockHistoryList( + items: List<GeckoSession.HistoryDelegate.HistoryItem>, + private val currentIndex: Int, + ) : ArrayList<GeckoSession.HistoryDelegate.HistoryItem>(items), GeckoSession.HistoryDelegate.HistoryList { + override fun getCurrentIndex() = currentIndex + } + + fun mockHistoryItem(title: String?, uri: String): GeckoSession.HistoryDelegate.HistoryItem { + val item = mock<GeckoSession.HistoryDelegate.HistoryItem>() + whenever(item.title).thenReturn(title) + whenever(item.uri).thenReturn(uri) + return item + } + + historyDelegate.value.onHistoryStateChange(mock(), MockHistoryList(emptyList(), 0)) + + historyDelegate.value.onHistoryStateChange( + mock(), + MockHistoryList( + listOf( + mockHistoryItem("Google Search", observedUrl), + mockHistoryItem("Moved", emptyPageUrl), + ), + 1, + ), + ) + + navigationDelegate.value.onLocationChange(geckoSession, emptyPageUrl, emptyList(), false) + contentDelegate.value.onTitleChange(geckoSession, emptyPageUrl) + + historyDelegate.value.onVisited( + geckoSession, + emptyPageUrl, + null, + 9, + ) + + verify(historyTrackingDelegate, never()).onVisited(eq(emptyPageUrl), any()) + assertEquals("https://www.google.com", observedUrl) + assertEquals("Google Search", observedTitle) + } + + @Test + fun `notifies configured history delegate of preview image URL changes`() = runTestOnMain { + val engineSession = GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + val geckoResult = GeckoResult<Boolean?>() + + captureDelegates() + geckoResult.complete(true) + + val previewImageUrl = "https://test.com/og-image-url" + + // Nothing breaks if history delegate isn't configured. + contentDelegate.value.onPreviewImage(geckoSession, previewImageUrl) + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri(eq("https://www.mozilla.com"))).thenReturn(true) + + contentDelegate.value.onPreviewImage(geckoSession, previewImageUrl) + verify(historyTrackingDelegate, never()).onPreviewImageChange(anyString(), anyString()) + + // This sets the currentUrl. + navigationDelegate.value.onLocationChange(geckoSession, "https://www.mozilla.com", emptyList(), false) + + contentDelegate.value.onPreviewImage(geckoSession, previewImageUrl) + verify(historyTrackingDelegate).onPreviewImageChange(eq("https://www.mozilla.com"), eq(previewImageUrl)) + verify(historyTrackingDelegate).shouldStoreUri(eq("https://www.mozilla.com")) + } + + @Test + fun `does not notify configured history delegate of preview image URL changes for private sessions`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + privateMode = true, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + // Nothing breaks if history delegate isn't configured. + contentDelegate.value.onPreviewImage(geckoSession, "https://test.com/og-image-url") + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + contentDelegate.value.onPreviewImage(geckoSession, "https://test.com/og-image-url") + verify(historyTrackingDelegate, never()).onPreviewImageChange(anyString(), anyString()) + verify(observer).onPreviewImageChange("https://test.com/og-image-url") + + // This sets the currentUrl. + progressDelegate.value.onPageStart(geckoSession, "https://www.mozilla.com") + + contentDelegate.value.onPreviewImage(geckoSession, "https://test.com/og-image.jpg") + verify(historyTrackingDelegate, never()).onPreviewImageChange(anyString(), anyString()) + verify(observer).onPreviewImageChange("https://test.com/og-image.jpg") + } + + @Test + fun `does not notify configured history delegate for top-level visits to error pages`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri(any())).thenReturn(true) + + historyDelegate.value.onVisited( + geckoSession, + "about:neterror", + null, + GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL + or GeckoSession.HistoryDelegate.VISIT_UNRECOVERABLE_ERROR, + ) + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate, never()).onVisited(anyString(), any()) + } + + @Test + fun `notifies configured history delegate of visits`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri("https://www.mozilla.com")).thenReturn(true) + + historyDelegate.value.onVisited(geckoSession, "https://www.mozilla.com", null, GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL) + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com"), eq(PageVisit(VisitType.LINK))) + } + + @Test + fun `notifies configured history delegate of reloads`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri("https://www.mozilla.com")).thenReturn(true) + + historyDelegate.value.onVisited(geckoSession, "https://www.mozilla.com", "https://www.mozilla.com", GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL) + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com"), eq(PageVisit(VisitType.RELOAD))) + } + + @Test + fun `checks with the delegate before trying to record a visit`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri("https://www.mozilla.com/allowed")).thenReturn(true) + whenever(historyTrackingDelegate.shouldStoreUri("https://www.mozilla.com/not-allowed")).thenReturn(false) + + historyDelegate.value.onVisited(geckoSession, "https://www.mozilla.com/allowed", null, GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL) + + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).shouldStoreUri("https://www.mozilla.com/allowed") + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com/allowed"), eq(PageVisit(VisitType.LINK))) + + historyDelegate.value.onVisited(geckoSession, "https://www.mozilla.com/not-allowed", null, GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL) + + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).shouldStoreUri("https://www.mozilla.com/not-allowed") + verify(historyTrackingDelegate, never()).onVisited(eq("https://www.mozilla.com/not-allowed"), any()) + } + + @Test + fun `correctly processes redirect visit flags`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + whenever(historyTrackingDelegate.shouldStoreUri(any())).thenReturn(true) + + historyDelegate.value.onVisited( + geckoSession, + "https://www.mozilla.com/tempredirect", + null, + // bitwise 'or' + GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL + or GeckoSession.HistoryDelegate.VISIT_REDIRECT_SOURCE, + ) + + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com/tempredirect"), eq(PageVisit(VisitType.REDIRECT_TEMPORARY, RedirectSource.TEMPORARY))) + + historyDelegate.value.onVisited( + geckoSession, + "https://www.mozilla.com/permredirect", + null, + GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL + or GeckoSession.HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT, + ) + + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com/permredirect"), eq(PageVisit(VisitType.REDIRECT_PERMANENT, RedirectSource.PERMANENT))) + + // Visits below are targets of redirects, not redirects themselves. + // Check that they're mapped to "link". + historyDelegate.value.onVisited( + geckoSession, + "https://www.mozilla.com/targettemp", + null, + GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL + or GeckoSession.HistoryDelegate.VISIT_REDIRECT_TEMPORARY, + ) + + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com/targettemp"), eq(PageVisit(VisitType.LINK))) + + historyDelegate.value.onVisited( + geckoSession, + "https://www.mozilla.com/targetperm", + null, + GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL + or GeckoSession.HistoryDelegate.VISIT_REDIRECT_PERMANENT, + ) + + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).onVisited(eq("https://www.mozilla.com/targetperm"), eq(PageVisit(VisitType.LINK))) + } + + @Test + fun `does not notify configured history delegate of visits for private sessions`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + privateMode = true, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + + historyDelegate.value.onVisited(geckoSession, "https://www.mozilla.com", "https://www.mozilla.com", GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL) + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate, never()).onVisited(anyString(), any()) + } + + @Test + fun `requests visited URLs from configured history delegate`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + // Nothing breaks if history delegate isn't configured. + historyDelegate.value.getVisited(geckoSession, arrayOf("https://www.mozilla.com", "https://www.mozilla.org")) + engineSession.job.children.forEach { it.join() } + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + + historyDelegate.value.getVisited(geckoSession, arrayOf("https://www.mozilla.com", "https://www.mozilla.org")) + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate).getVisited(eq(listOf("https://www.mozilla.com", "https://www.mozilla.org"))) + } + + @Test + fun `does not request visited URLs from configured history delegate in private sessions`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + privateMode = true, + ) + val historyTrackingDelegate: HistoryTrackingDelegate = mock() + + captureDelegates() + + engineSession.settings.historyTrackingDelegate = historyTrackingDelegate + + historyDelegate.value.getVisited(geckoSession, arrayOf("https://www.mozilla.com", "https://www.mozilla.org")) + engineSession.job.children.forEach { it.join() } + verify(historyTrackingDelegate, never()).getVisited(anyList()) + } + + @Test + fun `notifies configured history delegate of state changes`() = runTestOnMain { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + context = coroutineContext, + ) + val observer = mock<EngineSession.Observer>() + engineSession.register(observer) + + captureDelegates() + + class MockHistoryList( + items: List<GeckoSession.HistoryDelegate.HistoryItem>, + private val currentIndex: Int, + ) : ArrayList<GeckoSession.HistoryDelegate.HistoryItem>(items), GeckoSession.HistoryDelegate.HistoryList { + override fun getCurrentIndex() = currentIndex + } + + fun mockHistoryItem(title: String?, uri: String): GeckoSession.HistoryDelegate.HistoryItem { + val item = mock<GeckoSession.HistoryDelegate.HistoryItem>() + whenever(item.title).thenReturn(title) + whenever(item.uri).thenReturn(uri) + return item + } + + historyDelegate.value.onHistoryStateChange(mock(), MockHistoryList(emptyList(), 0)) + verify(observer).onHistoryStateChanged(emptyList(), 0) + + historyDelegate.value.onHistoryStateChange( + mock(), + MockHistoryList( + listOf( + mockHistoryItem("Firefox", "https://firefox.com"), + mockHistoryItem("Mozilla", "http://mozilla.org"), + mockHistoryItem(null, "https://example.com"), + ), + 1, + ), + ) + verify(observer).onHistoryStateChanged( + listOf( + HistoryItem("Firefox", "https://firefox.com"), + HistoryItem("Mozilla", "http://mozilla.org"), + HistoryItem("https://example.com", "https://example.com"), + ), + 1, + ) + } + + @Test + fun websiteTitleUpdates() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + captureDelegates() + + contentDelegate.value.onTitleChange(geckoSession, "Hello World!") + + verify(observer).onTitleChange("Hello World!") + } + + @Test + fun `WHEN preview image URL changes THEN notify observers`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + val observer: EngineSession.Observer = mock() + engineSession.register(observer) + + captureDelegates() + + val previewImageURL = "https://test.com/og-image-url" + contentDelegate.value.onPreviewImage(geckoSession, previewImageURL) + + verify(observer).onPreviewImageChange(previewImageURL) + } + + @Test + fun trackingProtectionDelegateNotifiesObservers() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var trackerBlocked: Tracker? = null + engineSession.register( + object : EngineSession.Observer { + override fun onTrackerBlocked(tracker: Tracker) { + trackerBlocked = tracker + } + }, + ) + + captureDelegates() + var geckoCategories = 0 + geckoCategories = geckoCategories.or(GeckoAntiTracking.AD) + geckoCategories = geckoCategories.or(GeckoAntiTracking.ANALYTIC) + geckoCategories = geckoCategories.or(GeckoAntiTracking.SOCIAL) + geckoCategories = geckoCategories.or(GeckoAntiTracking.CRYPTOMINING) + geckoCategories = geckoCategories.or(GeckoAntiTracking.FINGERPRINTING) + geckoCategories = geckoCategories.or(GeckoAntiTracking.CONTENT) + geckoCategories = geckoCategories.or(GeckoAntiTracking.TEST) + + contentBlockingDelegate.value.onContentBlocked( + geckoSession, + ContentBlocking.BlockEvent("tracker1", geckoCategories, 0, 0, false), + ) + + assertEquals("tracker1", trackerBlocked!!.url) + + val expectedBlockedCategories = listOf( + TrackingCategory.AD, + TrackingCategory.ANALYTICS, + TrackingCategory.SOCIAL, + TrackingCategory.CRYPTOMINING, + TrackingCategory.FINGERPRINTING, + TrackingCategory.CONTENT, + TrackingCategory.TEST, + ) + + assertTrue(trackerBlocked!!.trackingCategories.containsAll(expectedBlockedCategories)) + + var trackerLoaded: Tracker? = null + engineSession.register( + object : EngineSession.Observer { + override fun onTrackerLoaded(tracker: Tracker) { + trackerLoaded = tracker + } + }, + ) + + var geckoCookieCategories = 0 + geckoCookieCategories = geckoCookieCategories.or(GeckoCookieBehavior.ACCEPT_ALL) + geckoCookieCategories = geckoCookieCategories.or(GeckoCookieBehavior.ACCEPT_VISITED) + geckoCookieCategories = geckoCookieCategories.or(GeckoCookieBehavior.ACCEPT_NON_TRACKERS) + geckoCookieCategories = geckoCookieCategories.or(GeckoCookieBehavior.ACCEPT_NONE) + geckoCookieCategories = geckoCookieCategories.or(GeckoCookieBehavior.ACCEPT_FIRST_PARTY) + + contentBlockingDelegate.value.onContentLoaded( + geckoSession, + ContentBlocking.BlockEvent("tracker1", 0, 0, geckoCookieCategories, false), + ) + + val expectedCookieCategories = listOf( + CookiePolicy.ACCEPT_ONLY_FIRST_PARTY, + CookiePolicy.ACCEPT_NONE, + CookiePolicy.ACCEPT_VISITED, + CookiePolicy.ACCEPT_NON_TRACKERS, + ) + + assertEquals("tracker1", trackerLoaded!!.url) + assertTrue(trackerLoaded!!.cookiePolicies.containsAll(expectedCookieCategories)) + + contentBlockingDelegate.value.onContentLoaded( + geckoSession, + ContentBlocking.BlockEvent("tracker1", 0, 0, GeckoCookieBehavior.ACCEPT_ALL, false), + ) + + assertTrue( + trackerLoaded!!.cookiePolicies.containsAll( + listOf( + CookiePolicy.ACCEPT_ALL, + ), + ), + ) + } + + @Test + fun `WHEN updateing tracking protection with a recommended policy THEN etpEnabled should be enabled`() { + whenever(runtime.settings).thenReturn(mock()) + whenever(runtime.settings.contentBlocking).thenReturn(mock()) + + val session = spy(GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider)) + var trackerBlockingObserved = false + + session.register( + object : EngineSession.Observer { + override fun onTrackerBlockingEnabledChange(enabled: Boolean) { + trackerBlockingObserved = enabled + } + }, + ) + + val policy = TrackingProtectionPolicy.recommended() + session.updateTrackingProtection(policy) + shadowOf(getMainLooper()).idle() + + verify(session).updateContentBlocking(policy) + assertTrue(session.etpEnabled!!) + assertTrue(trackerBlockingObserved) + } + + @Test + fun `WHEN calling updateTrackingProtection with a none policy THEN etpEnabled should be disabled`() { + whenever(runtime.settings).thenReturn(mock()) + whenever(runtime.settings.contentBlocking).thenReturn(mock()) + + val session = spy(GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider)) + var trackerBlockingObserved = false + + session.register( + object : EngineSession.Observer { + override fun onTrackerBlockingEnabledChange(enabled: Boolean) { + trackerBlockingObserved = enabled + } + }, + ) + + val policy = TrackingProtectionPolicy.none() + session.updateTrackingProtection(policy) + + verify(session).updateContentBlocking(policy) + assertFalse(session.etpEnabled!!) + assertFalse(trackerBlockingObserved) + } + + @Test + fun `WHEN updating the contentBlocking with a policy SCRIPTS_AND_SUB_RESOURCES useForPrivateSessions being in privateMode THEN useTrackingProtection should be true`() { + val geckoSetting = mock<GeckoSessionSettings>() + val geckoSession = mock<GeckoSession>() + + val session = spy( + GeckoEngineSession( + runtime = runtime, + geckoSessionProvider = geckoSessionProvider, + privateMode = true, + ), + ) + + whenever(geckoSession.settings).thenReturn(geckoSetting) + + session.geckoSession = geckoSession + + val policy = TrackingProtectionPolicy.select(trackingCategories = arrayOf(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)).forPrivateSessionsOnly() + + session.updateContentBlocking(policy) + + verify(geckoSetting).useTrackingProtection = true + } + + @Test + fun `WHEN calling updateContentBlocking with a policy SCRIPTS_AND_SUB_RESOURCES useForRegularSessions being in privateMode THEN useTrackingProtection should be true`() { + val geckoSetting = mock<GeckoSessionSettings>() + val geckoSession = mock<GeckoSession>() + + val session = spy( + GeckoEngineSession( + runtime = runtime, + geckoSessionProvider = geckoSessionProvider, + privateMode = false, + ), + ) + + whenever(geckoSession.settings).thenReturn(geckoSetting) + + session.geckoSession = geckoSession + + val policy = TrackingProtectionPolicy.select(trackingCategories = arrayOf(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)).forRegularSessionsOnly() + + session.updateContentBlocking(policy) + + verify(geckoSetting).useTrackingProtection = true + } + + @Test + fun `WHEN updating content blocking without a policy SCRIPTS_AND_SUB_RESOURCES for any browsing mode THEN useTrackingProtection should be false`() { + val geckoSetting = mock<GeckoSessionSettings>() + val geckoSession = mock<GeckoSession>() + + var session = spy( + GeckoEngineSession( + runtime = runtime, + geckoSessionProvider = geckoSessionProvider, + privateMode = false, + ), + ) + + whenever(geckoSession.settings).thenReturn(geckoSetting) + session.geckoSession = geckoSession + + val policy = TrackingProtectionPolicy.none() + + session.updateContentBlocking(policy) + + verify(geckoSetting).useTrackingProtection = false + + session = spy( + GeckoEngineSession( + runtime = runtime, + geckoSessionProvider = geckoSessionProvider, + privateMode = true, + ), + ) + + whenever(geckoSession.settings).thenReturn(geckoSetting) + session.geckoSession = geckoSession + + session.updateContentBlocking(policy) + + verify(geckoSetting, times(2)).useTrackingProtection = false + } + + @Test + fun `changes to updateTrackingProtection will be notified to all new observers`() { + whenever(runtime.settings).thenReturn(mock()) + whenever(runtime.settings.contentBlocking).thenReturn(mock()) + val session = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + val observers = mutableListOf<EngineSession.Observer>() + val policy = TrackingProtectionPolicy.strict() + + for (x in 1..5) { + observers.add(spy(object : EngineSession.Observer {})) + } + + session.updateTrackingProtection(policy) + + observers.forEach { session.register(it) } + shadowOf(getMainLooper()).idle() + + observers.forEach { + verify(it).onTrackerBlockingEnabledChange(true) + } + + observers.forEach { session.unregister(it) } + shadowOf(getMainLooper()).idle() + + session.updateTrackingProtection(TrackingProtectionPolicy.none()) + + observers.forEach { session.register(it) } + shadowOf(getMainLooper()).idle() + + observers.forEach { + verify(it).onTrackerBlockingEnabledChange(false) + } + } + + @Test + fun safeBrowsingCategoriesAreAligned() { + assertEquals(GeckoSafeBrowsing.NONE, SafeBrowsingPolicy.NONE.id) + assertEquals(GeckoSafeBrowsing.MALWARE, SafeBrowsingPolicy.MALWARE.id) + assertEquals(GeckoSafeBrowsing.UNWANTED, SafeBrowsingPolicy.UNWANTED.id) + assertEquals(GeckoSafeBrowsing.HARMFUL, SafeBrowsingPolicy.HARMFUL.id) + assertEquals(GeckoSafeBrowsing.PHISHING, SafeBrowsingPolicy.PHISHING.id) + assertEquals(GeckoSafeBrowsing.DEFAULT, SafeBrowsingPolicy.RECOMMENDED.id) + } + + @Test + fun trackingProtectionCategoriesAreAligned() { + assertEquals(GeckoAntiTracking.NONE, TrackingCategory.NONE.id) + assertEquals(GeckoAntiTracking.AD, TrackingCategory.AD.id) + assertEquals(GeckoAntiTracking.CONTENT, TrackingCategory.CONTENT.id) + assertEquals(GeckoAntiTracking.SOCIAL, TrackingCategory.SOCIAL.id) + assertEquals(GeckoAntiTracking.TEST, TrackingCategory.TEST.id) + assertEquals(GeckoAntiTracking.CRYPTOMINING, TrackingCategory.CRYPTOMINING.id) + assertEquals(GeckoAntiTracking.FINGERPRINTING, TrackingCategory.FINGERPRINTING.id) + assertEquals(GeckoAntiTracking.STP, TrackingCategory.MOZILLA_SOCIAL.id) + assertEquals(GeckoAntiTracking.EMAIL, TrackingCategory.EMAIL.id) + + assertEquals(GeckoCookieBehavior.ACCEPT_ALL, CookiePolicy.ACCEPT_ALL.id) + assertEquals( + GeckoCookieBehavior.ACCEPT_NON_TRACKERS, + CookiePolicy.ACCEPT_NON_TRACKERS.id, + ) + assertEquals(GeckoCookieBehavior.ACCEPT_NONE, CookiePolicy.ACCEPT_NONE.id) + assertEquals( + GeckoCookieBehavior.ACCEPT_FIRST_PARTY, + CookiePolicy.ACCEPT_ONLY_FIRST_PARTY.id, + + ) + assertEquals(GeckoCookieBehavior.ACCEPT_VISITED, CookiePolicy.ACCEPT_VISITED.id) + } + + @Test + fun settingTestingMode() { + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(), + ) + verify(geckoSession.settings).fullAccessibilityTree = false + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(testingModeEnabled = true), + ) + verify(geckoSession.settings).fullAccessibilityTree = true + } + + @Test + fun settingUserAgent() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + engineSession.settings.userAgentString + + verify(geckoSession.settings).userAgentOverride + + engineSession.settings.userAgentString = "test-ua" + + verify(geckoSession.settings).userAgentOverride = "test-ua" + } + + @Test + fun settingUserAgentDefault() { + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(userAgentString = "test-ua"), + ) + + verify(geckoSession.settings).userAgentOverride = "test-ua" + } + + @Test + fun settingSuspendMediaWhenInactive() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + verify(geckoSession.settings, never()).suspendMediaWhenInactive = anyBoolean() + + assertFalse(engineSession.settings.suspendMediaWhenInactive) + verify(geckoSession.settings).suspendMediaWhenInactive + + engineSession.settings.suspendMediaWhenInactive = true + verify(geckoSession.settings).suspendMediaWhenInactive = true + } + + @Test + fun settingSuspendMediaWhenInactiveDefault() { + GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + verify(geckoSession.settings, never()).suspendMediaWhenInactive = anyBoolean() + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(), + ) + verify(geckoSession.settings).suspendMediaWhenInactive = false + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(suspendMediaWhenInactive = true), + ) + verify(geckoSession.settings).suspendMediaWhenInactive = true + } + + @Test + fun settingClearColorDefault() { + whenever(geckoSession.compositorController).thenReturn(mock()) + + GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + verify(geckoSession.compositorController, never()).clearColor = anyInt() + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(), + ) + verify(geckoSession.compositorController, never()).clearColor = anyInt() + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + defaultSettings = DefaultSettings(clearColor = Color.BLUE), + ) + verify(geckoSession.compositorController).clearColor = Color.BLUE + } + + @Test + fun unsupportedSettings() { + val settings = GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + ).settings + + expectException(UnsupportedSettingException::class) { + settings.javascriptEnabled = true + } + + expectException(UnsupportedSettingException::class) { + settings.domStorageEnabled = false + } + + expectException(UnsupportedSettingException::class) { + settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + } + } + + @Test + fun settingInterceptorToProvideAlternativeContent() { + var interceptorCalledWithUri: String? = null + + val interceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + interceptorCalledWithUri = uri + return RequestInterceptor.InterceptionResponse.Content("<h1>Hello World</h1>") + } + } + + val defaultSettings = DefaultSettings(requestInterceptor = interceptor) + GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider, defaultSettings = defaultSettings) + captureDelegates() + + navigationDelegate.value.onLoadRequest(geckoSession, mockLoadRequest("sample:about")) + + assertEquals("sample:about", interceptorCalledWithUri) + verify(geckoSession).load( + GeckoSession.Loader().data("<h1>Hello World</h1>", "text/html"), + ) + } + + @Test + fun settingInterceptorToProvideAlternativeUrl() { + var interceptorCalledWithUri: String? = null + + val interceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + interceptorCalledWithUri = uri + return RequestInterceptor.InterceptionResponse.Url("https://mozilla.org") + } + } + + val defaultSettings = DefaultSettings(requestInterceptor = interceptor) + GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider, defaultSettings = defaultSettings) + captureDelegates() + + navigationDelegate.value.onLoadRequest(geckoSession, mockLoadRequest("sample:about", "trigger:uri")) + + assertEquals("sample:about", interceptorCalledWithUri) + verify(geckoSession).load( + GeckoSession.Loader().uri("https://mozilla.org").flags(EXTERNAL + LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE), + ) + } + + @Test + fun settingInterceptorCanIgnoreAppInitiatedRequests() { + var interceptorCalled = false + + val interceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = false + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + interceptorCalled = true + return RequestInterceptor.InterceptionResponse.Url("https://mozilla.org") + } + } + + val defaultSettings = DefaultSettings(requestInterceptor = interceptor) + GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider, defaultSettings = defaultSettings) + captureDelegates() + + navigationDelegate.value.onLoadRequest(geckoSession, mockLoadRequest("sample:about", isDirectNavigation = true)) + assertFalse(interceptorCalled) + + navigationDelegate.value.onLoadRequest(geckoSession, mockLoadRequest("sample:about", isDirectNavigation = false)) + assertTrue(interceptorCalled) + } + + @Test + fun onLoadRequestWithoutInterceptor() { + val defaultSettings = DefaultSettings() + + GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + defaultSettings = defaultSettings, + ) + + captureDelegates() + + navigationDelegate.value.onLoadRequest(geckoSession, mockLoadRequest("sample:about")) + + verify(geckoSession, never()).load(any()) + } + + @Test + fun onLoadRequestWithInterceptorThatDoesNotIntercept() { + var interceptorCalledWithUri: String? = null + + val interceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + interceptorCalledWithUri = uri + return null + } + } + + val defaultSettings = DefaultSettings(requestInterceptor = interceptor) + + GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + defaultSettings = defaultSettings, + ) + + captureDelegates() + + navigationDelegate.value.onLoadRequest(geckoSession, mockLoadRequest("sample:about")) + + assertEquals("sample:about", interceptorCalledWithUri!!) + verify(geckoSession, never()).load(any()) + } + + @Test + fun onLoadErrorCallsInterceptorWithNull() { + var interceptedUri: String? = null + val requestInterceptor: RequestInterceptor = mock() + var defaultSettings = DefaultSettings() + var engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + defaultSettings = defaultSettings, + ) + + captureDelegates() + + // Interceptor is not called when there is none attached. + var onLoadError = navigationDelegate.value.onLoadError( + geckoSession, + "", + WebRequestError( + ERROR_CATEGORY_UNKNOWN, + ERROR_UNKNOWN, + ), + ) + verify(requestInterceptor, never()).onErrorRequest(engineSession, ErrorType.UNKNOWN, "") + onLoadError!!.then { value: String? -> + interceptedUri = value + GeckoResult.fromValue(null) + } + assertNull(interceptedUri) + + // Interceptor is called correctly + defaultSettings = DefaultSettings(requestInterceptor = requestInterceptor) + geckoSession = mockGeckoSession() + engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + defaultSettings = defaultSettings, + ) + + captureDelegates() + + onLoadError = navigationDelegate.value.onLoadError( + geckoSession, + "", + WebRequestError( + ERROR_CATEGORY_UNKNOWN, + ERROR_UNKNOWN, + ), + ) + + verify(requestInterceptor).onErrorRequest(engineSession, ErrorType.UNKNOWN, "") + onLoadError!!.then { value: String? -> + interceptedUri = value + GeckoResult.fromValue(null) + } + assertNull(interceptedUri) + } + + @Test + fun onLoadErrorCallsInterceptorWithErrorPage() { + val requestInterceptor: RequestInterceptor = object : RequestInterceptor { + override fun onErrorRequest( + session: EngineSession, + errorType: ErrorType, + uri: String?, + ): RequestInterceptor.ErrorResponse? = + RequestInterceptor.ErrorResponse("nonNullData") + } + + val defaultSettings = DefaultSettings(requestInterceptor = requestInterceptor) + GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + defaultSettings = defaultSettings, + ) + + captureDelegates() + + val onLoadError = navigationDelegate.value.onLoadError( + geckoSession, + "about:failed", + WebRequestError( + ERROR_CATEGORY_UNKNOWN, + ERROR_UNKNOWN, + ), + ) + + onLoadError!!.then { value: String? -> + GeckoResult.fromValue(value) + } + } + + @Test + fun onLoadErrorCallsInterceptorWithInvalidUri() { + val requestInterceptor: RequestInterceptor = mock() + val defaultSettings = DefaultSettings(requestInterceptor = requestInterceptor) + val engineSession = GeckoEngineSession(runtime, defaultSettings = defaultSettings) + + engineSession.geckoSession.navigationDelegate!!.onLoadError( + engineSession.geckoSession, + null, + WebRequestError(ERROR_MALFORMED_URI, ERROR_CATEGORY_UNKNOWN), + ) + verify(requestInterceptor).onErrorRequest(engineSession, ErrorType.ERROR_MALFORMED_URI, null) + } + + @Test + fun geckoErrorMappingToErrorType() { + assertEquals( + ErrorType.ERROR_SECURITY_SSL, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_SECURITY_SSL), + ) + assertEquals( + ErrorType.ERROR_SECURITY_BAD_CERT, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_SECURITY_BAD_CERT), + ) + assertEquals( + ErrorType.ERROR_NET_INTERRUPT, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_NET_INTERRUPT), + ) + assertEquals( + ErrorType.ERROR_NET_TIMEOUT, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_NET_TIMEOUT), + ) + assertEquals( + ErrorType.ERROR_NET_RESET, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_NET_RESET), + ) + assertEquals( + ErrorType.ERROR_CONNECTION_REFUSED, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_CONNECTION_REFUSED), + ) + assertEquals( + ErrorType.ERROR_UNKNOWN_SOCKET_TYPE, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_UNKNOWN_SOCKET_TYPE), + ) + assertEquals( + ErrorType.ERROR_REDIRECT_LOOP, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_REDIRECT_LOOP), + ) + assertEquals( + ErrorType.ERROR_OFFLINE, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_OFFLINE), + ) + assertEquals( + ErrorType.ERROR_PORT_BLOCKED, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_PORT_BLOCKED), + ) + assertEquals( + ErrorType.ERROR_UNSAFE_CONTENT_TYPE, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_UNSAFE_CONTENT_TYPE), + ) + assertEquals( + ErrorType.ERROR_CORRUPTED_CONTENT, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_CORRUPTED_CONTENT), + ) + assertEquals( + ErrorType.ERROR_CONTENT_CRASHED, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_CONTENT_CRASHED), + ) + assertEquals( + ErrorType.ERROR_INVALID_CONTENT_ENCODING, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_INVALID_CONTENT_ENCODING), + ) + assertEquals( + ErrorType.ERROR_UNKNOWN_HOST, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_UNKNOWN_HOST), + ) + assertEquals( + ErrorType.ERROR_MALFORMED_URI, + GeckoEngineSession.geckoErrorToErrorType(ERROR_MALFORMED_URI), + ) + assertEquals( + ErrorType.ERROR_UNKNOWN_PROTOCOL, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_UNKNOWN_PROTOCOL), + ) + assertEquals( + ErrorType.ERROR_FILE_NOT_FOUND, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_FILE_NOT_FOUND), + ) + assertEquals( + ErrorType.ERROR_FILE_ACCESS_DENIED, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_FILE_ACCESS_DENIED), + ) + assertEquals( + ErrorType.ERROR_PROXY_CONNECTION_REFUSED, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_PROXY_CONNECTION_REFUSED), + ) + assertEquals( + ErrorType.ERROR_UNKNOWN_PROXY_HOST, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_UNKNOWN_PROXY_HOST), + ) + assertEquals( + ErrorType.ERROR_SAFEBROWSING_MALWARE_URI, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_SAFEBROWSING_MALWARE_URI), + ) + assertEquals( + ErrorType.ERROR_SAFEBROWSING_HARMFUL_URI, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_SAFEBROWSING_HARMFUL_URI), + ) + assertEquals( + ErrorType.ERROR_SAFEBROWSING_PHISHING_URI, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_SAFEBROWSING_PHISHING_URI), + ) + assertEquals( + ErrorType.ERROR_SAFEBROWSING_UNWANTED_URI, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_SAFEBROWSING_UNWANTED_URI), + ) + assertEquals( + ErrorType.UNKNOWN, + GeckoEngineSession.geckoErrorToErrorType(-500), + ) + assertEquals( + ErrorType.ERROR_HTTPS_ONLY, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_HTTPS_ONLY), + ) + assertEquals( + ErrorType.ERROR_BAD_HSTS_CERT, + GeckoEngineSession.geckoErrorToErrorType(WebRequestError.ERROR_BAD_HSTS_CERT), + ) + } + + @Test + fun defaultSettings() { + val runtime = mock<GeckoRuntime>() + whenever(runtime.settings).thenReturn(mock()) + + val defaultSettings = + DefaultSettings(trackingProtectionPolicy = TrackingProtectionPolicy.strict()) + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + privateMode = false, + defaultSettings = defaultSettings, + ) + + assertFalse(geckoSession.settings.usePrivateMode) + verify(geckoSession.settings).useTrackingProtection = true + } + + @Test + fun `WHEN TrackingCategory do not includes content then useTrackingProtection must be set to false`() { + val defaultSettings = + DefaultSettings(trackingProtectionPolicy = TrackingProtectionPolicy.recommended()) + + GeckoEngineSession( + runtime, + geckoSessionProvider = geckoSessionProvider, + privateMode = false, + defaultSettings = defaultSettings, + ) + + verify(geckoSession.settings).useTrackingProtection = false + } + + @Test + fun contentDelegate() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + val delegate = engineSession.createContentDelegate() + + var observedChanged = false + engineSession.register( + object : EngineSession.Observer { + override fun onLongPress(hitResult: HitResult) { + observedChanged = true + } + }, + ) + + class MockContextElement( + baseUri: String?, + linkUri: String?, + title: String?, + altText: String?, + typeStr: String, + srcUri: String?, + ) : GeckoSession.ContentDelegate.ContextElement(baseUri, linkUri, title, altText, typeStr, srcUri) + + delegate.onContextMenu( + geckoSession, + 0, + 0, + MockContextElement(null, null, "title", "alt", "HTMLAudioElement", "file.mp3"), + ) + assertTrue(observedChanged) + + observedChanged = false + delegate.onContextMenu( + geckoSession, + 0, + 0, + MockContextElement(null, null, "title", "alt", "HTMLAudioElement", null), + ) + assertFalse(observedChanged) + + observedChanged = false + delegate.onContextMenu( + geckoSession, + 0, + 0, + MockContextElement(null, null, "title", "alt", "foobar", null), + ) + assertFalse(observedChanged) + } + + @Test + fun contentDelegateCookieBanner() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + val delegate = engineSession.createContentDelegate() + + var cookieBannerStatus: CookieBannerHandlingStatus? = null + engineSession.register( + object : EngineSession.Observer { + override fun onCookieBannerChange(status: CookieBannerHandlingStatus) { + cookieBannerStatus = status + } + }, + ) + + delegate.onCookieBannerDetected(geckoSession) + + assertNotNull(cookieBannerStatus) + assertEquals(CookieBannerHandlingStatus.DETECTED, cookieBannerStatus) + + cookieBannerStatus = null + + delegate.onCookieBannerHandled(geckoSession) + + assertNotNull(cookieBannerStatus) + assertEquals(CookieBannerHandlingStatus.HANDLED, cookieBannerStatus) + } + + @Test + fun handleLongClick() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var result = engineSession.handleLongClick("file.mp3", TYPE_AUDIO) + assertNotNull(result) + assertTrue(result is HitResult.AUDIO && result.src == "file.mp3") + + result = engineSession.handleLongClick("file.mp4", TYPE_VIDEO) + assertNotNull(result) + assertTrue(result is HitResult.VIDEO && result.src == "file.mp4") + + result = engineSession.handleLongClick("file.png", TYPE_IMAGE) + assertNotNull(result) + assertTrue(result is HitResult.IMAGE && result.src == "file.png") + + result = engineSession.handleLongClick("file.png", TYPE_IMAGE, "https://mozilla.org") + assertNotNull(result) + assertTrue(result is HitResult.IMAGE_SRC && result.src == "file.png" && result.uri == "https://mozilla.org") + + result = engineSession.handleLongClick(null, TYPE_IMAGE) + assertNotNull(result) + assertTrue(result is HitResult.UNKNOWN && result.src == "") + + result = engineSession.handleLongClick("tel:+1234567890", TYPE_NONE) + assertNotNull(result) + assertTrue(result is HitResult.PHONE && result.src == "tel:+1234567890") + + result = engineSession.handleLongClick("geo:1,-1", TYPE_NONE) + assertNotNull(result) + assertTrue(result is HitResult.GEO && result.src == "geo:1,-1") + + result = engineSession.handleLongClick("mailto:asa@mozilla.com", TYPE_NONE) + assertNotNull(result) + assertTrue(result is HitResult.EMAIL && result.src == "mailto:asa@mozilla.com") + + result = engineSession.handleLongClick(null, TYPE_NONE, "https://mozilla.org") + assertNotNull(result) + assertTrue(result is HitResult.UNKNOWN && result.src == "https://mozilla.org") + + result = engineSession.handleLongClick("data://foobar", TYPE_NONE, "https://mozilla.org") + assertNotNull(result) + assertTrue(result is HitResult.UNKNOWN && result.src == "data://foobar") + + result = engineSession.handleLongClick(null, TYPE_NONE, null) + assertNull(result) + } + + @Test + fun setDesktopMode() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + var desktopModeToggled = false + engineSession.register( + object : EngineSession.Observer { + override fun onDesktopModeChange(enabled: Boolean) { + desktopModeToggled = true + } + }, + ) + engineSession.toggleDesktopMode(true) + assertTrue(desktopModeToggled) + + desktopModeToggled = false + whenever(geckoSession.settings.userAgentMode) + .thenReturn(GeckoSessionSettings.USER_AGENT_MODE_DESKTOP) + whenever(geckoSession.settings.viewportMode) + .thenReturn(GeckoSessionSettings.VIEWPORT_MODE_DESKTOP) + + engineSession.toggleDesktopMode(true) + assertFalse(desktopModeToggled) + + engineSession.toggleDesktopMode(true) + assertFalse(desktopModeToggled) + + engineSession.toggleDesktopMode(false) + assertTrue(desktopModeToggled) + } + + @Test + fun `toggleDesktopMode should reload a non-mobile url when set to desktop mode`() { + val mobileUrl = "https://m.example.com" + val nonMobileUrl = "https://example.com" + val engineSession = spy(GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider)) + engineSession.currentUrl = mobileUrl + + engineSession.toggleDesktopMode(true, reload = true) + verify(engineSession, atLeastOnce()).loadUrl(nonMobileUrl, null, LoadUrlFlags.select(LoadUrlFlags.LOAD_FLAGS_REPLACE_HISTORY), null) + + engineSession.toggleDesktopMode(false, reload = true) + verify(engineSession, atLeastOnce()).reload() + } + + @Test + fun `hasCookieBannerRuleForSession should call onSuccess callback for a valid GV response`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<Boolean>() + whenever(geckoSession.hasCookieBannerRuleForBrowsingContextTree()).thenReturn(ruleResult) + + engineSession.hasCookieBannerRuleForSession( + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.complete(true) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `hasCookieBannerRuleForSession should call onError callback in case GV returns an exception`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<Boolean>() + whenever(geckoSession.hasCookieBannerRuleForBrowsingContextTree()).thenReturn(ruleResult) + + engineSession.hasCookieBannerRuleForSession( + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.completeExceptionally(IOException()) + shadowOf(getMainLooper()).idle() + + assertTrue(onExceptionCalled) + assertFalse(onResultCalled) + } + + @Test + fun `hasCookieBannerRuleForSession should call onError callback in case GV returns a null`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<Boolean>() + whenever(geckoSession.hasCookieBannerRuleForBrowsingContextTree()).thenReturn(ruleResult) + + engineSession.hasCookieBannerRuleForSession( + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.complete(null) + shadowOf(getMainLooper()).idle() + + assertTrue(onExceptionCalled) + assertFalse(onResultCalled) + } + + @Test + fun `checkForPdfViewer should correctly process a GV response`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<Boolean>() + whenever(geckoSession.isPdfJs).thenReturn(ruleResult) + + engineSession.checkForPdfViewer( + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.complete(true) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session onProductUrlChange is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val delegate = engineSession.createContentDelegate() + var productUrlStatus = false + engineSession.register( + object : EngineSession.Observer { + override fun onProductUrlChange(isProductUrl: Boolean) { + productUrlStatus = isProductUrl + } + }, + ) + + delegate.onProductUrl(geckoSession) + + assertTrue(productUrlStatus) + assertEquals(true, productUrlStatus) + } + + @Test + fun `WHEN session requestProductAnalysis is successful with analysis object THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<GeckoSession.ReviewAnalysis>() + whenever(geckoSession.requestAnalysis("mozilla.com")).thenReturn(ruleResult) + + engineSession.requestProductAnalysis( + "mozilla.com", + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + val productId = "banana" + val grade = "A" + val adjustedRating = 4.5 + val lastAnalysisTime = 12345.toLong() + val analysisURL = "https://analysis.com" + val analysisObject = GeckoSession.ReviewAnalysis.Builder(productId) + .grade(grade) + .adjustedRating(adjustedRating) + .analysisUrl(analysisURL) + .needsAnalysis(true) + .pageNotSupported(false) + .notEnoughReviews(false) + .highlights(null) + .lastAnalysisTime(lastAnalysisTime) + .deletedProductReported(true) + .deletedProduct(true) + .build() + + ruleResult.complete(analysisObject) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN requestProductAnalysis is not successful THEN onException callback for error is called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<GeckoSession.ReviewAnalysis>() + whenever(geckoSession.requestAnalysis("mozilla.com")).thenReturn(ruleResult) + + engineSession.requestProductAnalysis( + "mozilla.com", + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.completeExceptionally(IOException()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN session requestProductRecommendations is successful with empty list THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<List<GeckoSession.Recommendation>>() + whenever(geckoSession.requestRecommendations("mozilla.com")).thenReturn(ruleResult) + + engineSession.requestProductRecommendations( + "mozilla.com", + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.complete(emptyList()) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session requestProductRecommendations is successful with Recommendation THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<List<GeckoSession.Recommendation>>() + whenever(geckoSession.requestRecommendations("mozilla.com")).thenReturn(ruleResult) + + engineSession.requestProductRecommendations( + "mozilla.com", + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + val recommendationUrl = "https://recommendation.com" + val adjustedRating = 3.5 + val imageUrl = "http://image.com" + val aid = "banana" + val name = "apple" + val grade = "C" + val price = "450" + val currency = "USD" + + val recommendationObject = GeckoSession.Recommendation.Builder(recommendationUrl) + .adjustedRating(adjustedRating) + .sponsored(true) + .imageUrl(imageUrl) + .aid(aid) + .name(name) + .grade(grade) + .price(price) + .currency(currency) + .build() + + ruleResult.complete(listOf(recommendationObject)) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN requestProductRecommendations is not successful THEN onException callback for error is called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val ruleResult = GeckoResult<List<GeckoSession.Recommendation>>() + whenever(geckoSession.requestRecommendations("mozilla.com")).thenReturn(ruleResult) + + engineSession.requestProductRecommendations( + "mozilla.com", + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + ruleResult.completeExceptionally(IOException()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN session reanalyzeProduct is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val mUrl = "https://m.example.com" + val geckoResult = GeckoResult<String?>() + geckoResult.complete("COMPLETED") + whenever(geckoSession.requestCreateAnalysis(mUrl)) + .thenReturn(geckoResult) + + engineSession.reanalyzeProduct( + mUrl, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session requestAnalysisStatus is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val mUrl = "https://m.example.com" + val geckoResult = GeckoResult<GeckoSession.AnalysisStatusResponse>() + + val status = "in_progress" + val progress = 90.9 + val analysisObject = GeckoSession.AnalysisStatusResponse.Builder(status) + .progress(progress) + .build() + + geckoResult.complete(analysisObject) + whenever(geckoSession.requestAnalysisStatus(mUrl)) + .thenReturn(geckoResult) + + engineSession.requestAnalysisStatus( + mUrl, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session sendClickAttributionEvent is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val geckoResult = GeckoResult<Boolean?>() + geckoResult.complete(true) + whenever(geckoSession.sendClickAttributionEvent(AID)) + .thenReturn(geckoResult) + + engineSession.sendClickAttributionEvent( + aid = AID, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session sendClickAttributionEvent is not successful THEN onException callback for error is called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val geckoResult = GeckoResult<Boolean?>() + whenever(geckoSession.sendClickAttributionEvent(AID)) + .thenReturn(geckoResult) + + engineSession.sendClickAttributionEvent( + aid = AID, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.completeExceptionally(IOException()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN session sendImpressionAttributionEvent is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val geckoResult = GeckoResult<Boolean?>() + geckoResult.complete(true) + whenever(geckoSession.sendImpressionAttributionEvent(AID)) + .thenReturn(geckoResult) + + engineSession.sendImpressionAttributionEvent( + aid = AID, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session sendImpressionAttributionEvent is not successful THEN onException callback for error is called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val geckoResult = GeckoResult<Boolean?>() + whenever(geckoSession.sendImpressionAttributionEvent(AID)) + .thenReturn(geckoResult) + + engineSession.sendImpressionAttributionEvent( + aid = AID, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.completeExceptionally(IOException()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN session sendPlacementAttributionEvent is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val geckoResult = GeckoResult<Boolean?>() + geckoResult.complete(true) + whenever(geckoSession.sendPlacementAttributionEvent(AID)) + .thenReturn(geckoResult) + + engineSession.sendPlacementAttributionEvent( + aid = AID, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session sendPlacementAttributionEvent is not successful THEN onException callback for error is called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + var onResultCalled = false + var onExceptionCalled = false + + val geckoResult = GeckoResult<Boolean?>() + whenever(geckoSession.sendPlacementAttributionEvent(AID)) + .thenReturn(geckoResult) + + engineSession.sendPlacementAttributionEvent( + aid = AID, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.completeExceptionally(IOException()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN session requestTranslate is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Void>() + val fromLanguage = "en" + val toLanguage = "es" + val options = null + + geckoResult.complete(null) + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.translate(fromLanguage, toLanguage, options)).thenReturn(geckoResult) + + engineSession.register(object : EngineSession.Observer { + override fun onTranslateComplete(operation: TranslationOperation) { + assert(true) { "We should notify of a successful translation." } + } + + override fun onTranslateException( + operation: TranslationOperation, + translationError: TranslationError, + ) { + assert(false) { "We should not notify of a failure." } + } + }) + + engineSession.requestTranslate( + fromLanguage = fromLanguage, + toLanguage = toLanguage, + options = options, + ) + + shadowOf(getMainLooper()).idle() + } + + @Test + fun `WHEN session requestTranslationRestore is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Void>() + geckoResult.complete(null) + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.restoreOriginalPage()).thenReturn(geckoResult) + + engineSession.register(object : EngineSession.Observer { + override fun onTranslateComplete(operation: TranslationOperation) { + assert(true) { "We should notify of a successful translation." } + } + override fun onTranslateException( + operation: TranslationOperation, + translationError: TranslationError, + ) { + assert(false) { "We should not notify of a failure." } + } + }) + + engineSession.requestTranslationRestore() + + shadowOf(getMainLooper()).idle() + } + + @Test + fun `WHEN session requestTranslate is unsuccessful THEN notify of failure`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Void>() + val fromLanguage = "en" + val toLanguage = "es" + val options = null + + geckoResult.completeExceptionally(Exception()) + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.translate(fromLanguage, toLanguage, options)).thenReturn(geckoResult) + + engineSession.register(object : EngineSession.Observer { + override fun onTranslateComplete(operation: TranslationOperation) { + assert(false) { "We should not notify of a successful translation." } + } + + override fun onTranslateException( + operation: TranslationOperation, + translationError: TranslationError, + ) { + assert(true) { "We should notify of a failure." } + } + }) + + engineSession.requestTranslate( + fromLanguage = fromLanguage, + toLanguage = toLanguage, + options = options, + ) + + shadowOf(getMainLooper()).idle() + } + + @Test + fun `WHEN session requestTranslationRestore is unsuccessful THEN notify of failure`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Void>() + geckoResult.completeExceptionally(Exception()) + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.restoreOriginalPage()).thenReturn(geckoResult) + + engineSession.register(object : EngineSession.Observer { + override fun onTranslateComplete(operation: TranslationOperation) { + assert(false) { "We should not notify of a successful translation." } + } + override fun onTranslateException( + operation: TranslationOperation, + translationError: TranslationError, + ) { + assert(true) { "We should notify of a failure." } + } + }) + + engineSession.requestTranslationRestore() + + shadowOf(getMainLooper()).idle() + } + + @Test + fun `WHEN session getNeverTranslateSiteSetting is successful THEN onResult should be called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Boolean>() + + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.neverTranslateSiteSetting).thenReturn(geckoResult) + + var onResultCalled = false + var onExceptionCalled = false + + engineSession.getNeverTranslateSiteSetting( + onResult = { + onResultCalled = true + assertTrue(it) + }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.complete(true) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session getNeverTranslateSiteSetting has an error THEN onException should be called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Boolean>() + + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.neverTranslateSiteSetting).thenReturn(geckoResult) + + var onResultCalled = false + var onExceptionCalled = false + + engineSession.getNeverTranslateSiteSetting( + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN session setNeverTranslateSiteSetting is successful THEN onResult should be called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Void>() + + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.setNeverTranslateSiteSetting(any())).thenReturn(geckoResult) + + var onResultCalled = false + var onExceptionCalled = false + + engineSession.setNeverTranslateSiteSetting( + true, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.complete(null) + shadowOf(getMainLooper()).idle() + + assertTrue(onResultCalled) + assertFalse(onExceptionCalled) + } + + @Test + fun `WHEN session setNeverTranslateSiteSetting has an error THEN onException should be called`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + val mockedGeckoController: TranslationsController.SessionTranslation = mock() + + val geckoResult = GeckoResult<Void>() + + whenever(geckoSession.sessionTranslation).thenReturn(mockedGeckoController) + whenever(geckoSession.sessionTranslation!!.setNeverTranslateSiteSetting(any())).thenReturn(geckoResult) + + var onResultCalled = false + var onExceptionCalled = false + + engineSession.setNeverTranslateSiteSetting( + true, + onResult = { onResultCalled = true }, + onException = { onExceptionCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assertFalse(onResultCalled) + assertTrue(onExceptionCalled) + } + + @Test + fun `WHEN mapping a Gecko TranslationsException THEN it maps as expected to a TranslationError`() { + // Specifically defined unknown error thrown by the translations engine + val geckoUnknownError = TranslationsException(TranslationsException.ERROR_UNKNOWN) + val unknownError = geckoUnknownError.intoTranslationError() + assertTrue( + unknownError is TranslationError.UnknownError, + ) + assertEquals( + (unknownError as TranslationError.UnknownError).cause, + geckoUnknownError, + ) + assertEquals( + (unknownError as Throwable).cause, + geckoUnknownError, + ) + assertEquals( + unknownError.errorName, + "unknown", + ) + assertEquals( + unknownError.displayError, + false, + ) + + // Something really unexpected was thrown + val unexpectedUnknownError = Exception("Something very unexpected") + val unexpectedUnknown = unexpectedUnknownError.intoTranslationError() + assertTrue( + unexpectedUnknown is + TranslationError.UnknownError, + ) + assertEquals( + (unexpectedUnknown as TranslationError.UnknownError).cause, + unexpectedUnknownError, + ) + assertEquals( + unexpectedUnknown.errorName, + "unknown", + ) + assertEquals( + unexpectedUnknown.displayError, + false, + ) + + // For manual use as a guard for when the API returns a null value and it shouldn't be + // possible + val unexpectedNullError = TranslationError.UnexpectedNull() + assertEquals( + unexpectedNullError.errorName, + "unexpected-null", + ) + assertEquals( + unexpectedNullError.displayError, + false, + ) + + // For manual use as a guard for when the engine is missing a session coordinator + val missingCoordinator = TranslationError.MissingSessionCoordinator() + assertEquals( + missingCoordinator.errorName, + "missing-session-coordinator", + ) + assertEquals( + missingCoordinator.displayError, + false, + ) + + val notSupported = + TranslationsException(TranslationsException.ERROR_ENGINE_NOT_SUPPORTED).intoTranslationError() + assertTrue( + notSupported is + TranslationError.EngineNotSupportedError, + ) + assertEquals( + notSupported.errorName, + "engine-not-supported", + ) + assertEquals( + notSupported.displayError, + false, + ) + + val couldNotTranslate = + TranslationsException(TranslationsException.ERROR_COULD_NOT_TRANSLATE).intoTranslationError() + assertTrue( + couldNotTranslate is + TranslationError.CouldNotTranslateError, + ) + assertEquals( + couldNotTranslate.errorName, + "could-not-translate", + ) + assertEquals( + couldNotTranslate.displayError, + true, + ) + + val couldNotRestore = + TranslationsException(TranslationsException.ERROR_COULD_NOT_RESTORE).intoTranslationError() + assertTrue( + couldNotRestore is + TranslationError.CouldNotRestoreError, + ) + assertEquals( + couldNotRestore.errorName, + "could-not-restore", + ) + assertEquals( + couldNotRestore.displayError, + false, + ) + + val couldNotLoadLanguages = + TranslationsException(TranslationsException.ERROR_COULD_NOT_LOAD_LANGUAGES).intoTranslationError() + assertTrue( + couldNotLoadLanguages is + TranslationError.CouldNotLoadLanguagesError, + ) + assertEquals( + couldNotLoadLanguages.errorName, + "could-not-load-languages", + ) + assertEquals( + couldNotLoadLanguages.displayError, + true, + ) + + val languageNotSupported = + TranslationsException(TranslationsException.ERROR_LANGUAGE_NOT_SUPPORTED).intoTranslationError() + assertTrue( + languageNotSupported is + TranslationError.LanguageNotSupportedError, + ) + assertEquals( + languageNotSupported.errorName, + "language-not-supported", + ) + assertEquals( + languageNotSupported.displayError, + true, + ) + + val couldNotRetrieve = + TranslationsException(TranslationsException.ERROR_MODEL_COULD_NOT_RETRIEVE).intoTranslationError() + assertTrue( + couldNotRetrieve is + TranslationError.ModelCouldNotRetrieveError, + ) + assertEquals( + couldNotRetrieve.errorName, + "model-could-not-retrieve", + ) + assertEquals( + couldNotRetrieve.displayError, + false, + ) + + val couldNotDelete = + TranslationsException(TranslationsException.ERROR_MODEL_COULD_NOT_DELETE).intoTranslationError() + assertTrue( + couldNotDelete is + TranslationError.ModelCouldNotDeleteError, + ) + assertEquals( + couldNotDelete.errorName, + "model-could-not-delete", + ) + assertEquals( + couldNotDelete.displayError, + false, + ) + + val couldNotDownload = + TranslationsException(TranslationsException.ERROR_MODEL_COULD_NOT_DOWNLOAD).intoTranslationError() + assertTrue( + couldNotDownload is + TranslationError.ModelCouldNotDownloadError, + ) + assertEquals( + couldNotDownload.errorName, + "model-could-not-download", + ) + assertEquals( + couldNotDelete.displayError, + false, + ) + + val languageRequired = + TranslationsException(TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED).intoTranslationError() + assertTrue( + languageRequired is + TranslationError.ModelLanguageRequiredError, + ) + assertEquals( + languageRequired.errorName, + "model-language-required", + ) + assertEquals( + languageRequired.displayError, + false, + ) + + val downloadRequired = + TranslationsException(TranslationsException.ERROR_MODEL_DOWNLOAD_REQUIRED).intoTranslationError() + assertTrue( + downloadRequired is + TranslationError.ModelDownloadRequiredError, + ) + assertEquals( + downloadRequired.errorName, + "model-download-required", + ) + assertEquals( + downloadRequired.displayError, + false, + ) + } + + @Test + fun containsFormData() { + val engineSession = GeckoEngineSession(runtime = mock(), geckoSessionProvider = geckoSessionProvider) + var formData = false + engineSession.register( + object : EngineSession.Observer { + override fun onCheckForFormData(containsFormData: Boolean) { + formData = true + } + }, + ) + + whenever(geckoSession.containsFormData()) + .thenReturn(GeckoResult.fromValue(null)) + .thenReturn(GeckoResult.fromException(IllegalStateException())) + engineSession.checkForFormData() + assertEquals(false, formData) + } + + @Test + fun checkForMobileSite() { + val mUrl = "https://m.example.com" + val mobileUrl = "https://mobile.example.com" + val nonAuthorityUrl = "mobile.example.com" + val unrecognizedMobilePrefixUrl = "https://phone.example.com" + val nonMobileUrl = "https://example.com" + + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + + assertNull(engineSession.checkForMobileSite(nonAuthorityUrl)) + assertNull(engineSession.checkForMobileSite(unrecognizedMobilePrefixUrl)) + assertEquals(nonMobileUrl, engineSession.checkForMobileSite(mUrl)) + assertEquals(nonMobileUrl, engineSession.checkForMobileSite(mobileUrl)) + } + + @Test + fun findAll() { + val finderResult = mock<GeckoSession.FinderResult>() + val sessionFinder = mock<SessionFinder>() + whenever(sessionFinder.find("mozilla", 0)) + .thenReturn(GeckoResult.fromValue(finderResult)) + + whenever(geckoSession.finder).thenReturn(sessionFinder) + + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var findObserved: String? = null + var findResultObserved = false + engineSession.register( + object : EngineSession.Observer { + override fun onFind(text: String) { + findObserved = text + } + + override fun onFindResult(activeMatchOrdinal: Int, numberOfMatches: Int, isDoneCounting: Boolean) { + assertEquals(0, activeMatchOrdinal) + assertEquals(0, numberOfMatches) + assertTrue(isDoneCounting) + findResultObserved = true + } + }, + ) + + engineSession.findAll("mozilla") + shadowOf(getMainLooper()).idle() + + assertEquals("mozilla", findObserved) + assertTrue(findResultObserved) + verify(sessionFinder).find("mozilla", 0) + } + + @Test + fun findNext() { + val finderResult = mock<GeckoSession.FinderResult>() + val sessionFinder = mock<SessionFinder>() + whenever(sessionFinder.find(eq(null), anyInt())) + .thenReturn(GeckoResult.fromValue(finderResult)) + + whenever(geckoSession.finder).thenReturn(sessionFinder) + + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + var findResultObserved = false + engineSession.register( + object : EngineSession.Observer { + override fun onFindResult(activeMatchOrdinal: Int, numberOfMatches: Int, isDoneCounting: Boolean) { + assertEquals(0, activeMatchOrdinal) + assertEquals(0, numberOfMatches) + assertTrue(isDoneCounting) + findResultObserved = true + } + }, + ) + + engineSession.findNext(true) + shadowOf(getMainLooper()).idle() + + assertTrue(findResultObserved) + verify(sessionFinder).find(null, 0) + + engineSession.findNext(false) + shadowOf(getMainLooper()).idle() + + assertTrue(findResultObserved) + verify(sessionFinder).find(null, GeckoSession.FINDER_FIND_BACKWARDS) + } + + @Test + fun clearFindMatches() { + val finder = mock<SessionFinder>() + whenever(geckoSession.finder).thenReturn(finder) + + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.clearFindMatches() + + verify(finder).clear() + } + + @Test + fun exitFullScreenModeTriggersExitEvent() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + val observer: EngineSession.Observer = mock() + + // Verify the event is triggered for exiting fullscreen mode and GeckoView is called. + engineSession.exitFullScreenMode() + verify(geckoSession).exitFullScreen() + + // Verify the call to the observer. + engineSession.register(observer) + + captureDelegates() + + contentDelegate.value.onFullScreen(geckoSession, true) + + verify(observer).onFullScreenChange(true) + } + + @Test + fun exitFullscreenTrueHasNoInteraction() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + engineSession.exitFullScreenMode() + verify(geckoSession).exitFullScreen() + } + + @Test + fun viewportFitChangeTranslateValuesCorrectly() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + val observer: EngineSession.Observer = mock() + + // Verify the call to the observer. + engineSession.register(observer) + captureDelegates() + + contentDelegate.value.onMetaViewportFitChange(geckoSession, "test") + verify(observer).onMetaViewportFitChanged(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT) + reset(observer) + + contentDelegate.value.onMetaViewportFitChange(geckoSession, "auto") + verify(observer).onMetaViewportFitChanged(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT) + reset(observer) + + contentDelegate.value.onMetaViewportFitChange(geckoSession, "cover") + verify(observer).onMetaViewportFitChanged(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES) + reset(observer) + + contentDelegate.value.onMetaViewportFitChange(geckoSession, "contain") + verify(observer).onMetaViewportFitChanged(WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER) + reset(observer) + } + + @Test + fun onShowDynamicToolbarTriggersTheRightEvent() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + val observer: EngineSession.Observer = mock() + + // Verify the call to the observer. + engineSession.register(observer) + captureDelegates() + + contentDelegate.value.onShowDynamicToolbar(geckoSession) + + verify(observer).onShowDynamicToolbar() + } + + @Test + fun clearData() { + val engineSession = GeckoEngineSession(runtime, geckoSessionProvider = geckoSessionProvider) + val observer: EngineSession.Observer = mock() + + engineSession.register(observer) + + engineSession.clearData() + + verifyNoInteractions(observer) + } + + @Test + fun `Closing engine session should close underlying gecko session`() { + val geckoSession = mockGeckoSession() + + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = { geckoSession }) + + engineSession.close() + + verify(geckoSession).close() + } + + @Test + fun `onLoadRequest will try to intercept new window load requests`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedUrl: String? = null + var observedIntent: Intent? = null + + var observedLoadUrl: String? = null + var observedTriggeredByRedirect: Boolean? = null + var observedTriggeredByWebContent: Boolean? = null + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + return when (uri) { + "sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result") + else -> null + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLaunchIntentRequest( + url: String, + appIntent: Intent?, + ) { + observedUrl = url + observedIntent = appIntent + } + + override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) { + observedLoadUrl = url + observedTriggeredByRedirect = triggeredByRedirect + observedTriggeredByWebContent = triggeredByWebContent + } + }, + ) + + var result = navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest( + "sample:about", + null, + GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW, + triggeredByRedirect = true, + ), + ) + + assertEquals(result!!.poll(0), AllowOrDeny.DENY) + assertNotNull(observedIntent) + assertEquals("result", observedUrl) + assertNull(observedLoadUrl) + assertNull(observedTriggeredByRedirect) + assertNull(observedTriggeredByWebContent) + + result = navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest( + "sample:about", + null, + GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW, + triggeredByRedirect = false, + ), + ) + + assertEquals(result!!.poll(0), AllowOrDeny.DENY) + assertNotNull(observedIntent) + assertEquals("result", observedUrl) + assertNull(observedLoadUrl) + assertNull(observedTriggeredByRedirect) + assertNull(observedTriggeredByWebContent) + } + + @Test + fun `onLoadRequest allows new window requests if not intercepted`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedUrl: String? = null + var observedIntent: Intent? = null + + var observedLoadUrl: String? = null + var observedTriggeredByRedirect: Boolean? = null + var observedTriggeredByWebContent: Boolean? = null + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + return when (uri) { + "sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result") + else -> null + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLaunchIntentRequest( + url: String, + appIntent: Intent?, + ) { + observedUrl = url + observedIntent = appIntent + } + + override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) { + observedLoadUrl = url + observedTriggeredByRedirect = triggeredByRedirect + observedTriggeredByWebContent = triggeredByWebContent + } + }, + ) + + var result = navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest( + "about:blank", + null, + GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW, + triggeredByRedirect = true, + ), + ) + + assertEquals(result!!.poll(0), AllowOrDeny.ALLOW) + assertNull(observedIntent) + assertNull(observedUrl) + assertNull(observedLoadUrl) + assertNull(observedTriggeredByRedirect) + assertNull(observedTriggeredByWebContent) + + result = navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest( + "https://www.example.com", + null, + GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW, + triggeredByRedirect = true, + ), + ) + + assertEquals(result!!.poll(0), AllowOrDeny.ALLOW) + assertNull(observedIntent) + assertNull(observedUrl) + assertNull(observedLoadUrl) + assertNull(observedTriggeredByRedirect) + assertNull(observedTriggeredByWebContent) + } + + @Test + fun `onLoadRequest not intercepted and not new window will notify observer`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedLoadUrl: String? = null + var observedTriggeredByRedirect: Boolean? = null + var observedTriggeredByWebContent: Boolean? = null + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + return when (uri) { + "sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result") + else -> null + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLoadRequest(url: String, triggeredByRedirect: Boolean, triggeredByWebContent: Boolean) { + observedLoadUrl = url + observedTriggeredByRedirect = triggeredByRedirect + observedTriggeredByWebContent = triggeredByWebContent + } + }, + ) + + val result = navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("https://www.example.com", null, triggeredByRedirect = true), + ) + + assertEquals(result!!.poll(0), AllowOrDeny.ALLOW) + assertEquals("https://www.example.com", observedLoadUrl) + assertEquals(true, observedTriggeredByRedirect) + assertEquals(false, observedTriggeredByWebContent) + } + + @Test + fun `State provided through delegate will be returned from saveState`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + val state: GeckoSession.SessionState = mock() + + var observedState: EngineSessionState? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onStateUpdated(state: EngineSessionState) { + observedState = state + } + }, + ) + + progressDelegate.value.onSessionStateChange(mock(), state) + + assertNotNull(observedState) + assertTrue(observedState is GeckoEngineSessionState) + + val actualState = (observedState as GeckoEngineSessionState).actualState + assertEquals(state, actualState) + } + + @Test + fun `onFirstContentfulPaint notifies observers`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observed = false + + engineSession.register( + object : EngineSession.Observer { + override fun onFirstContentfulPaint() { + observed = true + } + }, + ) + + contentDelegate.value.onFirstContentfulPaint(mock()) + assertTrue(observed) + } + + @Test + fun `onPaintStatusReset notifies observers`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observed = false + + engineSession.register( + object : EngineSession.Observer { + override fun onPaintStatusReset() { + observed = true + } + }, + ) + + contentDelegate.value.onPaintStatusReset(mock()) + assertTrue(observed) + } + + @Test + fun `onCrash notifies observers about crash`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var crashedState = false + + engineSession.register( + object : EngineSession.Observer { + override fun onCrash() { + crashedState = true + } + }, + ) + + contentDelegate.value.onCrash(mock()) + + assertEquals(true, crashedState) + } + + @Test + fun `onLoadRequest will notify onLaunchIntent observers if request was intercepted with app intent`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedUrl: String? = null + var observedIntent: Intent? = null + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + return when (uri) { + "sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result") + else -> null + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLaunchIntentRequest( + url: String, + appIntent: Intent?, + ) { + observedUrl = url + observedIntent = appIntent + } + }, + ) + + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = true), + ) + + assertNotNull(observedIntent) + assertEquals("result", observedUrl) + + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = false), + ) + + assertNotNull(observedIntent) + assertEquals("result", observedUrl) + } + + @Test + fun `onLoadRequest keep track of the last onLoadRequest uri correctly`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedUrl: String? = null + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + observedUrl = lastUri + return null + } + } + + navigationDelegate.value.onLoadRequest(mock(), mockLoadRequest("test1")) + assertEquals(null, observedUrl) + + navigationDelegate.value.onLoadRequest(mock(), mockLoadRequest("test2")) + assertEquals("test1", observedUrl) + + navigationDelegate.value.onLoadRequest(mock(), mockLoadRequest("test3")) + assertEquals("test2", observedUrl) + } + + @Test + fun `onSubframeLoadRequest will notify onLaunchIntent observers if request was intercepted with app intent`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedUrl: String? = null + var observedIntent: Intent? = null + var observedIsSubframe = false + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + observedIsSubframe = isSubframeRequest + return when (uri) { + "sample:about" -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), "result") + else -> null + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLaunchIntentRequest( + url: String, + appIntent: Intent?, + ) { + observedUrl = url + observedIntent = appIntent + } + }, + ) + + navigationDelegate.value.onSubframeLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = true), + ) + + assertNotNull(observedIntent) + assertEquals("result", observedUrl) + assertEquals(true, observedIsSubframe) + + navigationDelegate.value.onSubframeLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = false), + ) + + assertNotNull(observedIntent) + assertEquals("result", observedUrl) + assertEquals(true, observedIsSubframe) + } + + @Test + fun `onLoadRequest will notify any observers if request was intercepted as url`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedLaunchIntentUrl: String? = null + var observedLaunchIntent: Intent? = null + var observedOnLoadRequestUrl: String? = null + var observedTriggeredByRedirect: Boolean? = null + var observedTriggeredByWebContent: Boolean? = null + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun interceptsAppInitiatedRequests() = true + + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + return when (uri) { + "sample:about" -> RequestInterceptor.InterceptionResponse.Url("result") + else -> null + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLaunchIntentRequest( + url: String, + appIntent: Intent?, + ) { + observedLaunchIntentUrl = url + observedLaunchIntent = appIntent + } + + override fun onLoadRequest( + url: String, + triggeredByRedirect: Boolean, + triggeredByWebContent: Boolean, + ) { + observedOnLoadRequestUrl = url + observedTriggeredByRedirect = triggeredByRedirect + observedTriggeredByWebContent = triggeredByWebContent + } + }, + ) + + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = true), + ) + + assertNull(observedLaunchIntentUrl) + assertNull(observedLaunchIntent) + assertNull(observedTriggeredByRedirect) + assertNull(observedTriggeredByWebContent) + assertNull(observedOnLoadRequestUrl) + + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = false), + ) + + assertNull(observedLaunchIntentUrl) + assertNull(observedLaunchIntent) + assertNull(observedTriggeredByRedirect) + assertNull(observedTriggeredByWebContent) + assertNull(observedOnLoadRequestUrl) + } + + @Test + fun `onLoadRequest will notify onLoadRequest observers if request was not intercepted`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observedLaunchIntentUrl: String? = null + var observedLaunchIntent: Intent? = null + var observedOnLoadRequestUrl: String? = null + var observedTriggeredByRedirect: Boolean? = null + var observedTriggeredByWebContent: Boolean? = null + + engineSession.settings.requestInterceptor = null + engineSession.register( + object : EngineSession.Observer { + override fun onLaunchIntentRequest( + url: String, + appIntent: Intent?, + ) { + observedLaunchIntentUrl = url + observedLaunchIntent = appIntent + } + + override fun onLoadRequest( + url: String, + triggeredByRedirect: Boolean, + triggeredByWebContent: Boolean, + ) { + observedOnLoadRequestUrl = url + observedTriggeredByRedirect = triggeredByRedirect + observedTriggeredByWebContent = triggeredByWebContent + } + }, + ) + + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = true), + ) + + assertNull(observedLaunchIntentUrl) + assertNull(observedLaunchIntent) + assertNotNull(observedTriggeredByRedirect) + assertTrue(observedTriggeredByRedirect!!) + assertNotNull(observedTriggeredByWebContent) + assertFalse(observedTriggeredByWebContent!!) + assertEquals("sample:about", observedOnLoadRequestUrl) + + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = false), + ) + + assertNull(observedLaunchIntentUrl) + assertNull(observedLaunchIntent) + assertNotNull(observedTriggeredByRedirect) + assertFalse(observedTriggeredByRedirect!!) + assertNotNull(observedTriggeredByWebContent) + assertFalse(observedTriggeredByWebContent!!) + assertEquals("sample:about", observedOnLoadRequestUrl) + } + + @Test + fun `onLoadRequest will notify observers if the url is loaded from the user interacting with chrome`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + val fakeUrl = "https://example.com" + var observedUrl: String? + var observedTriggeredByWebContent: Boolean? + + engineSession.settings.requestInterceptor = object : RequestInterceptor { + override fun onLoadRequest( + engineSession: EngineSession, + uri: String, + lastUri: String?, + hasUserGesture: Boolean, + isSameDomain: Boolean, + isRedirect: Boolean, + isDirectNavigation: Boolean, + isSubframeRequest: Boolean, + ): RequestInterceptor.InterceptionResponse? { + return when (uri) { + fakeUrl -> null + else -> RequestInterceptor.InterceptionResponse.AppIntent(mock(), fakeUrl) + } + } + } + + engineSession.register( + object : EngineSession.Observer { + override fun onLoadRequest( + url: String, + triggeredByRedirect: Boolean, + triggeredByWebContent: Boolean, + ) { + observedTriggeredByWebContent = triggeredByWebContent + observedUrl = url + } + }, + ) + + fun fakePageLoad(expectedTriggeredByWebContent: Boolean) { + observedTriggeredByWebContent = null + observedUrl = null + navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest( + fakeUrl, + triggeredByRedirect = true, + hasUserGesture = expectedTriggeredByWebContent, + ), + ) + progressDelegate.value.onPageStop(mock(), true) + assertNotNull(observedTriggeredByWebContent) + assertEquals(expectedTriggeredByWebContent, observedTriggeredByWebContent!!) + assertNotNull(observedUrl) + assertEquals(fakeUrl, observedUrl) + } + + // loadUrl(url: String) + engineSession.loadUrl(fakeUrl) + verify(geckoSession).load( + GeckoSession.Loader().uri(fakeUrl), + ) + fakePageLoad(false) + + // subsequent page loads _are_ from web content + fakePageLoad(true) + + // loadData(data: String, mimeType: String, encoding: String) + val fakeData = "data://" + val fakeMimeType = "" + val fakeEncoding = "" + engineSession.loadData(data = fakeData, mimeType = fakeMimeType, encoding = fakeEncoding) + verify(geckoSession).load( + GeckoSession.Loader().data(fakeData, fakeMimeType), + ) + fakePageLoad(false) + + fakePageLoad(true) + + // reload() + engineSession.initialLoadRequest = null + engineSession.reload() + verify(geckoSession).reload(GeckoSession.LOAD_FLAGS_NONE) + fakePageLoad(false) + + fakePageLoad(true) + + // goBack() + engineSession.goBack() + verify(geckoSession).goBack(true) + fakePageLoad(false) + + fakePageLoad(true) + + // goForward() + engineSession.goForward() + verify(geckoSession).goForward(true) + fakePageLoad(false) + + fakePageLoad(true) + + // toggleDesktopMode() + engineSession.toggleDesktopMode(false, reload = true) + // This is the second time in this test, so we actually want two invocations. + verify(geckoSession, times(2)).reload(GeckoSession.LOAD_FLAGS_NONE) + fakePageLoad(false) + + fakePageLoad(true) + + // goToHistoryIndex(index: Int) + engineSession.goToHistoryIndex(0) + verify(geckoSession).gotoHistoryIndex(0) + fakePageLoad(false) + + fakePageLoad(true) + } + + @Test + fun `onLoadRequest will return correct GeckoResult if no observer is available`() { + GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + captureDelegates() + + val geckoResult = navigationDelegate.value.onLoadRequest( + mock(), + mockLoadRequest("sample:about", triggeredByRedirect = true), + ) + + assertEquals(geckoResult!!, GeckoResult.fromValue(AllowOrDeny.ALLOW)) + } + + @Test + fun loadFlagsAreAligned() { + assertEquals(LoadUrlFlags.BYPASS_CACHE, GeckoSession.LOAD_FLAGS_BYPASS_CACHE) + assertEquals(LoadUrlFlags.BYPASS_PROXY, GeckoSession.LOAD_FLAGS_BYPASS_PROXY) + assertEquals(LoadUrlFlags.EXTERNAL, GeckoSession.LOAD_FLAGS_EXTERNAL) + assertEquals(LoadUrlFlags.ALLOW_POPUPS, GeckoSession.LOAD_FLAGS_ALLOW_POPUPS) + assertEquals(LoadUrlFlags.BYPASS_CLASSIFIER, GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER) + assertEquals(LoadUrlFlags.LOAD_FLAGS_FORCE_ALLOW_DATA_URI, GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI) + assertEquals(LoadUrlFlags.LOAD_FLAGS_REPLACE_HISTORY, GeckoSession.LOAD_FLAGS_REPLACE_HISTORY) + } + + @Test + fun `onKill will notify observers`() { + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + captureDelegates() + + var observerNotified = false + + engineSession.register( + object : EngineSession.Observer { + override fun onProcessKilled() { + observerNotified = true + } + }, + ) + + val mockedState: GeckoSession.SessionState = mock() + progressDelegate.value.onSessionStateChange(geckoSession, mockedState) + + contentDelegate.value.onKill(geckoSession) + + assertTrue(observerNotified) + } + + @Test + fun `onNewSession creates window request`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + captureDelegates() + + var receivedWindowRequest: WindowRequest? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onWindowRequest(windowRequest: WindowRequest) { + receivedWindowRequest = windowRequest + } + }, + ) + + navigationDelegate.value.onNewSession(mock(), "mozilla.org") + + assertNotNull(receivedWindowRequest) + assertEquals("mozilla.org", receivedWindowRequest!!.url) + assertEquals(WindowRequest.Type.OPEN, receivedWindowRequest!!.type) + } + + @Test + fun `onCloseRequest creates window request`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + captureDelegates() + + var receivedWindowRequest: WindowRequest? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onWindowRequest(windowRequest: WindowRequest) { + receivedWindowRequest = windowRequest + } + }, + ) + + contentDelegate.value.onCloseRequest(geckoSession) + + assertNotNull(receivedWindowRequest) + assertSame(engineSession, receivedWindowRequest!!.prepare()) + assertEquals(WindowRequest.Type.CLOSE, receivedWindowRequest!!.type) + } + + class MockSecurityInformation( + origin: String? = null, + certificate: X509Certificate? = null, + ) : SecurityInformation() { + init { + origin?.let { + ReflectionUtils.setField(this, "origin", origin) + } + certificate?.let { + ReflectionUtils.setField(this, "certificate", certificate) + } + } + } + + @Test + fun `certificate issuer is parsed and provided onSecurityChange`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + var observedIssuer: String? = null + engineSession.register( + object : EngineSession.Observer { + override fun onSecurityChange(secure: Boolean, host: String?, issuer: String?) { + observedIssuer = issuer + } + }, + ) + + captureDelegates() + + val unparsedIssuerName = "Verified By: CN=Digicert SHA2 Extended Validation Server CA,OU=www.digicert.com,O=DigiCert Inc,C=US" + val parsedIssuerName = "DigiCert Inc" + val certificate: X509Certificate = mock() + val principal: Principal = mock() + whenever(principal.name).thenReturn(unparsedIssuerName) + whenever(certificate.issuerDN).thenReturn(principal) + + val securityInformation = MockSecurityInformation(certificate = certificate) + progressDelegate.value.onSecurityChange(mock(), securityInformation) + assertEquals(parsedIssuerName, observedIssuer) + } + + @Test + fun `certificate issuer is parsed and provided onSecurityChange with null arg`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + var observedIssuer: String? = null + engineSession.register( + object : EngineSession.Observer { + override fun onSecurityChange(secure: Boolean, host: String?, issuer: String?) { + observedIssuer = issuer + } + }, + ) + + captureDelegates() + + val unparsedIssuerName = null + val parsedIssuerName = null + val certificate: X509Certificate = mock() + val principal: Principal = mock() + whenever(principal.name).thenReturn(unparsedIssuerName) + whenever(certificate.issuerDN).thenReturn(principal) + + val securityInformation = MockSecurityInformation(certificate = certificate) + progressDelegate.value.onSecurityChange(mock(), securityInformation) + assertEquals(parsedIssuerName, observedIssuer) + } + + @Test + fun `pattern-breaking certificate issuer isnt parsed and returns original name `() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + var observedIssuer: String? = null + engineSession.register( + object : EngineSession.Observer { + override fun onSecurityChange(secure: Boolean, host: String?, issuer: String?) { + observedIssuer = issuer + } + }, + ) + + captureDelegates() + + val unparsedIssuerName = "pattern breaking cert" + val parsedIssuerName = "pattern breaking cert" + val certificate: X509Certificate = mock() + val principal: Principal = mock() + whenever(principal.name).thenReturn(unparsedIssuerName) + whenever(certificate.issuerDN).thenReturn(principal) + + val securityInformation = MockSecurityInformation(certificate = certificate) + progressDelegate.value.onSecurityChange(mock(), securityInformation) + assertEquals(parsedIssuerName, observedIssuer) + } + + @Test + fun `GIVEN canGoBack true WHEN goBack() is called THEN verify EngineObserver onNavigateBack() is triggered`() { + var observedOnNavigateBack = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onNavigateBack() { + observedOnNavigateBack = true + } + }, + ) + + captureDelegates() + navigationDelegate.value.onCanGoBack(mock(), true) + engineSession.goBack() + assertTrue(observedOnNavigateBack) + } + + @Test + fun `GIVEN canGoBack false WHEN goBack() is called THEN verify EngineObserver onNavigateBack() is not triggered`() { + var observedOnNavigateBack = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onNavigateBack() { + observedOnNavigateBack = true + } + }, + ) + + captureDelegates() + navigationDelegate.value.onCanGoBack(mock(), false) + engineSession.goBack() + assertFalse(observedOnNavigateBack) + } + + @Test + fun `GIVEN forward navigation is possible WHEN navigating forward THEN observers are notified`() { + var observedOnNavigateForward = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onNavigateForward() { + observedOnNavigateForward = true + } + }, + ) + + captureDelegates() + navigationDelegate.value.onCanGoForward(mock(), true) + engineSession.goForward() + assertTrue(observedOnNavigateForward) + } + + @Test + fun `GIVEN forward navigation is not possible WHEN navigating forward THEN forward navigation observers are not notified`() { + var observedOnNavigateForward = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onNavigateBack() { + observedOnNavigateForward = true + } + }, + ) + + captureDelegates() + navigationDelegate.value.onCanGoForward(mock(), false) + engineSession.goForward() + assertFalse(observedOnNavigateForward) + } + + @Test + fun `WHEN URL is loaded THEN URL load observer is notified`() { + var onLoadUrlTriggered = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onLoadUrl() { + onLoadUrlTriggered = true + } + }, + ) + + captureDelegates() + engineSession.loadUrl("http://mozilla.org") + assertTrue(onLoadUrlTriggered) + } + + @Test + fun `WHEN data is loaded THEN data load observer is notified`() { + var onLoadDataTriggered = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onLoadData() { + onLoadDataTriggered = true + } + }, + ) + + captureDelegates() + engineSession.loadData("<html><body/></html>") + assertTrue(onLoadDataTriggered) + } + + @Test + fun `WHEN navigating to history index THEN the observer is notified`() { + var onGotoHistoryIndexTriggered = false + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onGotoHistoryIndex() { + onGotoHistoryIndexTriggered = true + } + }, + ) + + captureDelegates() + engineSession.goToHistoryIndex(0) + assertTrue(onGotoHistoryIndexTriggered) + } + + @Test + fun `GIVEN a list of blocked schemes set WHEN getBlockedSchemes is called THEN it returns that list`() { + val engineSession = GeckoEngineSession(mock(), geckoSessionProvider = geckoSessionProvider) + + assertSame(GeckoEngineSession.BLOCKED_SCHEMES, engineSession.getBlockedSchemes()) + } + + @Test + fun `WHEN requestPdfToDownload THEN notify observers`() { + val engineSession = GeckoEngineSession( + runtime = mock(), + geckoSessionProvider = geckoSessionProvider, + ).apply { + currentUrl = "https://mozilla.org" + currentTitle = "Mozilla" + } + engineSession.register( + object : EngineSession.Observer { + override fun onExternalResource( + url: String, + fileName: String?, + contentLength: Long?, + contentType: String?, + cookie: String?, + userAgent: String?, + isPrivate: Boolean, + skipConfirmation: Boolean, + openInApp: Boolean, + response: Response?, + ) { + assertEquals("PDF response is always a success.", RESPONSE_CODE_SUCCESS, response!!.status) + assertEquals("Length should always be zero.", 0L, contentLength) + assertEquals("Filename is based on title, when available.", "Mozilla.pdf", fileName) + assertEquals("Content type is always static.", "application/pdf", contentType) + } + }, + ) + + whenever(geckoSession.saveAsPdf()).thenReturn(GeckoResult.fromValue(mock())) + + engineSession.requestPdfToDownload() + shadowOf(getMainLooper()).idle() + } + + @Test + fun `WHEN requestPdfToDownload cannot return a result THEN do nothing`() { + val engineSession = GeckoEngineSession( + runtime = mock(), + geckoSessionProvider = geckoSessionProvider, + ) + engineSession.register( + object : EngineSession.Observer { + override fun onExternalResource( + url: String, + fileName: String?, + contentLength: Long?, + contentType: String?, + cookie: String?, + userAgent: String?, + isPrivate: Boolean, + skipConfirmation: Boolean, + openInApp: Boolean, + response: Response?, + ) { + assert(false) { "We should not notify observers." } + } + }, + ) + + whenever(geckoSession.saveAsPdf()) + .thenReturn(GeckoResult.fromValue(null)) + .thenReturn(GeckoResult.fromException(IllegalStateException())) + + // When input stream in the GeckoResult is null. + engineSession.requestPdfToDownload() + shadowOf(getMainLooper()).idle() + + // When we receive an exception from the GeckoResult. + engineSession.requestPdfToDownload() + shadowOf(getMainLooper()).idle() + } + + @Test + fun `setDisplayMode sets same display mode value`() { + val geckoSetting = mock<GeckoSessionSettings>() + val geckoSession = mock<GeckoSession>() + + val engineSession = GeckoEngineSession( + mock(), + geckoSessionProvider = geckoSessionProvider, + ) + + whenever(geckoSession.settings).thenReturn(geckoSetting) + + engineSession.geckoSession = geckoSession + + engineSession.setDisplayMode(WebAppManifest.DisplayMode.FULLSCREEN) + verify(geckoSetting, atLeastOnce()).setDisplayMode(GeckoSessionSettings.DISPLAY_MODE_FULLSCREEN) + + engineSession.setDisplayMode(WebAppManifest.DisplayMode.STANDALONE) + verify(geckoSetting, atLeastOnce()).setDisplayMode(GeckoSessionSettings.DISPLAY_MODE_STANDALONE) + + engineSession.setDisplayMode(WebAppManifest.DisplayMode.MINIMAL_UI) + verify(geckoSetting, atLeastOnce()).setDisplayMode(GeckoSessionSettings.DISPLAY_MODE_MINIMAL_UI) + + engineSession.setDisplayMode(WebAppManifest.DisplayMode.BROWSER) + verify(geckoSetting, atLeastOnce()).setDisplayMode(GeckoSessionSettings.DISPLAY_MODE_BROWSER) + } + + fun `WHEN requestPrintContent is successful THEN notify of completion`() { + val engineSession = GeckoEngineSession( + runtime = mock(), + geckoSessionProvider = geckoSessionProvider, + ) + whenever(geckoSession.didPrintPageContent()).thenReturn(GeckoResult.fromValue(true)) + + engineSession.register(object : EngineSession.Observer { + override fun onPrintFinish() { + assert(true) { "We should notify of a successful print." } + } + + override fun onPrintException(isPrint: Boolean, throwable: Throwable) { + assert(false) { "We should not notify of an exception." } } + }) + engineSession.requestPrintContent() + shadowOf(getMainLooper()).idle() + } + + @Test + fun `WHEN requestPrintContent has an exception THEN do nothing`() { + val engineSession = GeckoEngineSession( + runtime = mock(), + geckoSessionProvider = geckoSessionProvider, + ) + class MockGeckoPrintException() : GeckoPrintException() + whenever(geckoSession.didPrintPageContent()).thenReturn(GeckoResult.fromException(MockGeckoPrintException())) + + engineSession.register(object : EngineSession.Observer { + override fun onPrintFinish() { + assert(false) { "We should not notify of a successful print." } + } + + override fun onPrintException(isPrint: Boolean, throwable: Throwable) { + assert(true) { "An exception should occur." } + assertEquals("A GeckoPrintException occurred.", ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE, (throwable as GeckoPrintException).code) + } + }) + engineSession.requestPrintContent() + shadowOf(getMainLooper()).idle() + } + + private fun mockGeckoSession(): GeckoSession { + val session = mock<GeckoSession>() + whenever(session.settings).thenReturn( + mock(), + ) + return session + } + + private fun mockLoadRequest( + uri: String, + triggerUri: String? = null, + target: Int = 0, + triggeredByRedirect: Boolean = false, + hasUserGesture: Boolean = false, + isDirectNavigation: Boolean = false, + ): GeckoSession.NavigationDelegate.LoadRequest { + var flags = 0 + if (triggeredByRedirect) { + flags = flags or 0x800000 + } + + val constructor = GeckoSession.NavigationDelegate.LoadRequest::class.java.getDeclaredConstructor( + String::class.java, + String::class.java, + Int::class.java, + Int::class.java, + Boolean::class.java, + Boolean::class.java, + ) + constructor.isAccessible = true + + return constructor.newInstance(uri, triggerUri, target, flags, hasUserGesture, isDirectNavigation) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt new file mode 100644 index 0000000000..6a8ed3c330 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt @@ -0,0 +1,3673 @@ +/* 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.browser.engine.gecko + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.os.Looper.getMainLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.ext.getAntiTrackingPolicy +import mozilla.components.browser.engine.gecko.mediaquery.toGeckoValue +import mozilla.components.browser.engine.gecko.serviceworker.GeckoServiceWorkerDelegate +import mozilla.components.browser.engine.gecko.util.SpeculativeEngineSession +import mozilla.components.browser.engine.gecko.util.SpeculativeSessionObserver +import mozilla.components.browser.engine.gecko.webextension.GeckoWebExtensionException +import mozilla.components.browser.engine.gecko.webextension.mockNativeWebExtension +import mozilla.components.browser.engine.gecko.webextension.mockNativeWebExtensionMetaData +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.SafeBrowsingPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory +import mozilla.components.concept.engine.UnsupportedSettingException +import mozilla.components.concept.engine.content.blocking.TrackerLog +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import mozilla.components.concept.engine.serviceworker.ServiceWorkerDelegate +import mozilla.components.concept.engine.translate.LanguageSetting +import mozilla.components.concept.engine.translate.ModelManagementOptions +import mozilla.components.concept.engine.translate.ModelOperation +import mozilla.components.concept.engine.translate.OperationLevel +import mozilla.components.concept.engine.webextension.Action +import mozilla.components.concept.engine.webextension.InstallationMethod +import mozilla.components.concept.engine.webextension.WebExtension +import mozilla.components.concept.engine.webextension.WebExtensionDelegate +import mozilla.components.concept.engine.webextension.WebExtensionException +import mozilla.components.concept.engine.webextension.WebExtensionInstallException +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 mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import mozilla.components.test.ReflectionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito +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 +import org.mozilla.geckoview.ContentBlocking +import org.mozilla.geckoview.ContentBlocking.CookieBehavior +import org.mozilla.geckoview.ContentBlockingController +import org.mozilla.geckoview.ContentBlockingController.Event +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoRuntimeSettings +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoWebExecutor +import org.mozilla.geckoview.OrientationController +import org.mozilla.geckoview.StorageController +import org.mozilla.geckoview.TranslationsController +import org.mozilla.geckoview.TranslationsController.Language +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.LanguageModel +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.checkPairDownloadSize +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.getLanguageSetting +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.getLanguageSettings +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.getNeverTranslateSiteList +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.isTranslationsEngineSupported +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.listModelDownloadStates +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.listSupportedLanguages +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.manageLanguageModel +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.preferredLanguages +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.setLanguageSettings +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.setNeverTranslateSpecifiedSite +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_BLOCKLISTED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_FILE_ACCESS +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_INCORRECT_HASH +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_INCORRECT_ID +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED +import org.mozilla.geckoview.WebExtensionController +import org.mozilla.geckoview.WebNotification +import org.mozilla.geckoview.WebPushController +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import java.io.IOException +import org.mozilla.geckoview.WebExtension as GeckoWebExtension + +typealias GeckoInstallException = org.mozilla.geckoview.WebExtension.InstallException + +@RunWith(AndroidJUnit4::class) +class GeckoEngineTest { + + private lateinit var runtime: GeckoRuntime + private lateinit var context: Context + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + context = mock() + } + + @Test + fun createView() { + assertTrue( + GeckoEngine(context, runtime = runtime).createView( + Robolectric.buildActivity(Activity::class.java).get(), + ) is GeckoEngineView, + ) + } + + @Test + fun createSession() { + val engine = GeckoEngine(context, runtime = runtime) + assertTrue(engine.createSession() is GeckoEngineSession) + + // Create a private speculative session and consume it + engine.speculativeCreateSession(private = true) + assertTrue(engine.speculativeConnectionFactory.hasSpeculativeSession()) + var privateSpeculativeSession = engine.speculativeConnectionFactory.speculativeEngineSession!!.engineSession + assertSame(privateSpeculativeSession, engine.createSession(private = true)) + assertFalse(engine.speculativeConnectionFactory.hasSpeculativeSession()) + + // Create a regular speculative session and make sure it is not returned + // if a private session is requested instead. + engine.speculativeCreateSession(private = false) + assertTrue(engine.speculativeConnectionFactory.hasSpeculativeSession()) + privateSpeculativeSession = engine.speculativeConnectionFactory.speculativeEngineSession!!.engineSession + assertNotSame(privateSpeculativeSession, engine.createSession(private = true)) + // Make sure previous (never used) speculative session is now closed + assertFalse(privateSpeculativeSession.geckoSession.isOpen) + assertFalse(engine.speculativeConnectionFactory.hasSpeculativeSession()) + } + + @Test + fun speculativeCreateSession() { + val engine = GeckoEngine(context, runtime = runtime) + assertNull(engine.speculativeConnectionFactory.speculativeEngineSession) + + // Create a private speculative session + engine.speculativeCreateSession(private = true) + assertNotNull(engine.speculativeConnectionFactory.speculativeEngineSession) + val privateSpeculativeSession = engine.speculativeConnectionFactory.speculativeEngineSession!! + assertTrue(privateSpeculativeSession.engineSession.geckoSession.settings.usePrivateMode) + + // Creating another private speculative session should have no effect as + // session hasn't been "consumed". + engine.speculativeCreateSession(private = true) + assertSame(privateSpeculativeSession, engine.speculativeConnectionFactory.speculativeEngineSession) + assertTrue(privateSpeculativeSession.engineSession.geckoSession.settings.usePrivateMode) + + // Creating a non-private speculative session should affect prepared session + engine.speculativeCreateSession(private = false) + assertNotSame(privateSpeculativeSession, engine.speculativeConnectionFactory.speculativeEngineSession) + // Make sure previous (never used) speculative session is now closed + assertFalse(privateSpeculativeSession.engineSession.geckoSession.isOpen) + val regularSpeculativeSession = engine.speculativeConnectionFactory.speculativeEngineSession!! + assertFalse(regularSpeculativeSession.engineSession.geckoSession.settings.usePrivateMode) + } + + @Test + fun clearSpeculativeSession() { + val engine = GeckoEngine(context, runtime = runtime) + assertNull(engine.speculativeConnectionFactory.speculativeEngineSession) + + val mockEngineSession: GeckoEngineSession = mock() + val mockEngineSessionObserver: SpeculativeSessionObserver = mock() + engine.speculativeConnectionFactory.speculativeEngineSession = + SpeculativeEngineSession(mockEngineSession, mockEngineSessionObserver) + engine.clearSpeculativeSession() + + verify(mockEngineSession).unregister(mockEngineSessionObserver) + verify(mockEngineSession).close() + assertNull(engine.speculativeConnectionFactory.speculativeEngineSession) + } + + @Test + fun `createSession with contextId`() { + val engine = GeckoEngine(context, runtime = runtime) + + // Create a speculative session with a context id and consume it + engine.speculativeCreateSession(private = false, contextId = "1") + assertNotNull(engine.speculativeConnectionFactory.speculativeEngineSession) + var newSpeculativeSession = engine.speculativeConnectionFactory.speculativeEngineSession!!.engineSession + assertSame(newSpeculativeSession, engine.createSession(private = false, contextId = "1")) + assertNull(engine.speculativeConnectionFactory.speculativeEngineSession) + + // Create a regular speculative session and make sure it is not returned + // if a session with a context id is requested instead. + engine.speculativeCreateSession(private = false) + assertNotNull(engine.speculativeConnectionFactory.speculativeEngineSession) + newSpeculativeSession = engine.speculativeConnectionFactory.speculativeEngineSession!!.engineSession + assertNotSame(newSpeculativeSession, engine.createSession(private = false, contextId = "1")) + // Make sure previous (never used) speculative session is now closed + assertFalse(newSpeculativeSession.geckoSession.isOpen) + assertNull(engine.speculativeConnectionFactory.speculativeEngineSession) + } + + @Test + fun name() { + assertEquals("Gecko", GeckoEngine(context, runtime = runtime).name()) + } + + @Test + fun settings() { + val defaultSettings = DefaultSettings() + val contentBlockingSettings = ContentBlocking.Settings.Builder().build() + val runtime = mock<GeckoRuntime>() + val runtimeSettings = mock<GeckoRuntimeSettings>() + whenever(runtimeSettings.javaScriptEnabled).thenReturn(true) + whenever(runtimeSettings.webFontsEnabled).thenReturn(true) + whenever(runtimeSettings.automaticFontSizeAdjustment).thenReturn(true) + whenever(runtimeSettings.fontInflationEnabled).thenReturn(true) + whenever(runtimeSettings.fontSizeFactor).thenReturn(1.0F) + whenever(runtimeSettings.forceUserScalableEnabled).thenReturn(false) + whenever(runtimeSettings.loginAutofillEnabled).thenReturn(false) + whenever(runtimeSettings.enterpriseRootsEnabled).thenReturn(false) + whenever(runtimeSettings.contentBlocking).thenReturn(contentBlockingSettings) + whenever(runtimeSettings.preferredColorScheme).thenReturn(GeckoRuntimeSettings.COLOR_SCHEME_SYSTEM) + whenever(runtime.settings).thenReturn(runtimeSettings) + val engine = GeckoEngine(context, runtime = runtime, defaultSettings = defaultSettings) + + assertTrue(engine.settings.javascriptEnabled) + engine.settings.javascriptEnabled = false + verify(runtimeSettings).javaScriptEnabled = false + + assertFalse(engine.settings.loginAutofillEnabled) + engine.settings.loginAutofillEnabled = true + verify(runtimeSettings).loginAutofillEnabled = true + + assertFalse(engine.settings.enterpriseRootsEnabled) + engine.settings.enterpriseRootsEnabled = true + verify(runtimeSettings).enterpriseRootsEnabled = true + + assertTrue(engine.settings.webFontsEnabled) + engine.settings.webFontsEnabled = false + verify(runtimeSettings).webFontsEnabled = false + + assertTrue(engine.settings.automaticFontSizeAdjustment) + engine.settings.automaticFontSizeAdjustment = false + verify(runtimeSettings).automaticFontSizeAdjustment = false + + assertTrue(engine.settings.fontInflationEnabled!!) + engine.settings.fontInflationEnabled = null + verify(runtimeSettings, never()).fontInflationEnabled = anyBoolean() + engine.settings.fontInflationEnabled = false + verify(runtimeSettings).fontInflationEnabled = false + + assertEquals(1.0F, engine.settings.fontSizeFactor) + engine.settings.fontSizeFactor = null + verify(runtimeSettings, never()).fontSizeFactor = anyFloat() + engine.settings.fontSizeFactor = 2.0F + verify(runtimeSettings).fontSizeFactor = 2.0F + + assertFalse(engine.settings.forceUserScalableContent) + engine.settings.forceUserScalableContent = true + verify(runtimeSettings).forceUserScalableEnabled = true + + assertFalse(engine.settings.remoteDebuggingEnabled) + engine.settings.remoteDebuggingEnabled = true + verify(runtimeSettings).remoteDebuggingEnabled = true + + assertFalse(engine.settings.testingModeEnabled) + engine.settings.testingModeEnabled = true + assertTrue(engine.settings.testingModeEnabled) + + assertEquals(PreferredColorScheme.System, engine.settings.preferredColorScheme) + engine.settings.preferredColorScheme = PreferredColorScheme.Dark + verify(runtimeSettings).preferredColorScheme = PreferredColorScheme.Dark.toGeckoValue() + + assertFalse(engine.settings.suspendMediaWhenInactive) + engine.settings.suspendMediaWhenInactive = true + assertEquals(true, engine.settings.suspendMediaWhenInactive) + + assertNull(engine.settings.clearColor) + engine.settings.clearColor = Color.BLUE + assertEquals(Color.BLUE, engine.settings.clearColor) + + // Specifying no ua-string default should result in GeckoView's default. + assertEquals(GeckoSession.getDefaultUserAgent(), engine.settings.userAgentString) + // It also should be possible to read and set a new default. + engine.settings.userAgentString = engine.settings.userAgentString + "-test" + assertEquals(GeckoSession.getDefaultUserAgent() + "-test", engine.settings.userAgentString) + + assertEquals(null, engine.settings.trackingProtectionPolicy) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + val trackingStrictCategories = TrackingProtectionPolicy.strict().trackingCategories.sumOf { it.id } + val artificialCategory = + TrackingCategory.SCRIPTS_AND_SUB_RESOURCES.id + assertEquals( + trackingStrictCategories - artificialCategory, + contentBlockingSettings.antiTrackingCategories, + ) + + assertFalse(engine.settings.emailTrackerBlockingPrivateBrowsing) + engine.settings.emailTrackerBlockingPrivateBrowsing = true + assertTrue(engine.settings.emailTrackerBlockingPrivateBrowsing) + + val safeStrictBrowsingCategories = SafeBrowsingPolicy.RECOMMENDED.id + assertEquals(safeStrictBrowsingCategories, contentBlockingSettings.safeBrowsingCategories) + + engine.settings.safeBrowsingPolicy = arrayOf(SafeBrowsingPolicy.PHISHING) + assertEquals(SafeBrowsingPolicy.PHISHING.id, contentBlockingSettings.safeBrowsingCategories) + + assertEquals(defaultSettings.trackingProtectionPolicy, TrackingProtectionPolicy.strict()) + assertEquals(contentBlockingSettings.cookieBehavior, CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS.id) + assertEquals( + contentBlockingSettings.cookieBehaviorPrivateMode, + CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS.id, + ) + + assertEquals(contentBlockingSettings.cookieBannerMode, EngineSession.CookieBannerHandlingMode.DISABLED.mode) + assertEquals(contentBlockingSettings.cookieBannerModePrivateBrowsing, EngineSession.CookieBannerHandlingMode.DISABLED.mode) + assertEquals(contentBlockingSettings.cookieBannerDetectOnlyMode, engine.settings.cookieBannerHandlingDetectOnlyMode) + assertEquals(contentBlockingSettings.cookieBannerGlobalRulesEnabled, engine.settings.cookieBannerHandlingGlobalRules) + assertEquals(contentBlockingSettings.cookieBannerGlobalRulesSubFramesEnabled, engine.settings.cookieBannerHandlingGlobalRulesSubFrames) + assertEquals(contentBlockingSettings.queryParameterStrippingEnabled, engine.settings.queryParameterStripping) + assertEquals(contentBlockingSettings.queryParameterStrippingPrivateBrowsingEnabled, engine.settings.queryParameterStrippingPrivateBrowsing) + assertEquals(contentBlockingSettings.queryParameterStrippingAllowList[0], engine.settings.queryParameterStrippingAllowList) + assertEquals(contentBlockingSettings.queryParameterStrippingStripList[0], engine.settings.queryParameterStrippingStripList) + + assertEquals(contentBlockingSettings.emailTrackerBlockingPrivateBrowsingEnabled, engine.settings.emailTrackerBlockingPrivateBrowsing) + + try { + engine.settings.domStorageEnabled + fail("Expected UnsupportedOperationException") + } catch (e: UnsupportedSettingException) { } + + try { + engine.settings.domStorageEnabled = false + fail("Expected UnsupportedOperationException") + } catch (e: UnsupportedSettingException) { } + } + + @Test + fun `the SCRIPTS_AND_SUB_RESOURCES tracking protection category must not be passed to gecko view`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + val trackingStrictCategories = TrackingProtectionPolicy.strict().trackingCategories.sumOf { it.id } + val artificialCategory = TrackingCategory.SCRIPTS_AND_SUB_RESOURCES.id + + assertEquals( + trackingStrictCategories - artificialCategory, + mockRuntime.settings.contentBlocking.antiTrackingCategories, + ) + + mockRuntime.settings.contentBlocking.setAntiTracking(0) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.select( + arrayOf(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES), + ) + + assertEquals(0, mockRuntime.settings.contentBlocking.antiTrackingCategories) + } + + @Test + fun `WHEN a strict tracking protection policy is set THEN the strict social list must be activated`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(mock()) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + verify(mockRuntime.settings.contentBlocking).setStrictSocialTrackingProtection(true) + } + + @Test + fun `WHEN a strict tracking protection policy is set THEN the setEnhancedTrackingProtectionLevel must be STRICT`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(mock()) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + verify(mockRuntime.settings.contentBlocking).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.STRICT, + ) + } + + @Test + fun `WHEN an HTTPS-Only mode is set THEN allowInsecureConnections is getting set on GeckoRuntime`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + reset(mockRuntime.settings) + engine.settings.httpsOnlyMode = Engine.HttpsOnlyMode.ENABLED_PRIVATE_ONLY + verify(mockRuntime.settings).allowInsecureConnections = GeckoRuntimeSettings.HTTPS_ONLY_PRIVATE + + reset(mockRuntime.settings) + engine.settings.httpsOnlyMode = Engine.HttpsOnlyMode.ENABLED + verify(mockRuntime.settings).allowInsecureConnections = GeckoRuntimeSettings.HTTPS_ONLY + + reset(mockRuntime.settings) + engine.settings.httpsOnlyMode = Engine.HttpsOnlyMode.DISABLED + verify(mockRuntime.settings).allowInsecureConnections = GeckoRuntimeSettings.ALLOW_ALL + } + + @Test + fun `setAntiTracking is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + val policy = TrackingProtectionPolicy.recommended() + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking).setAntiTracking( + policy.getAntiTrackingPolicy(), + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking, never()).setAntiTracking( + policy.getAntiTrackingPolicy(), + ) + } + + @Test + fun `cookiePurging is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + val policy = TrackingProtectionPolicy.recommended() + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking).setCookiePurging(policy.cookiePurging) + + reset(settings) + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking, never()).setCookiePurging(policy.cookiePurging) + } + + @Test + fun `setCookieBehavior is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + whenever(mockRuntime.settings.contentBlocking.cookieBehavior).thenReturn(CookieBehavior.ACCEPT_NONE) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + val policy = TrackingProtectionPolicy.recommended() + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking).setCookieBehavior( + policy.cookiePolicy.id, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBehavior( + policy.cookiePolicy.id, + ) + } + + @Test + fun `setCookieBehavior private mode is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + whenever(mockRuntime.settings.contentBlocking.cookieBehaviorPrivateMode).thenReturn(CookieBehavior.ACCEPT_NONE) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + val policy = TrackingProtectionPolicy.recommended() + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking).setCookieBehaviorPrivateMode( + policy.cookiePolicy.id, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = policy + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBehaviorPrivateMode( + policy.cookiePolicy.id, + ) + } + + @Test + fun `setCookieBannerMode is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + val policy = EngineSession.CookieBannerHandlingMode.REJECT_ALL + + engine.settings.cookieBannerHandlingMode = policy + + verify(mockRuntime.settings.contentBlocking).setCookieBannerMode(policy.mode) + + reset(settings) + + engine.settings.cookieBannerHandlingMode = policy + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBannerMode(policy.mode) + } + + @Test + fun `setCookieBannerModePrivateBrowsing is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + val policy = EngineSession.CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL + + engine.settings.cookieBannerHandlingModePrivateBrowsing = policy + + verify(mockRuntime.settings.contentBlocking).setCookieBannerModePrivateBrowsing(policy.mode) + + reset(settings) + + engine.settings.cookieBannerHandlingModePrivateBrowsing = policy + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBannerModePrivateBrowsing(policy.mode) + } + + @Test + fun `setCookieBannerHandlingDetectOnlyMode is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.cookieBannerHandlingDetectOnlyMode = true + + verify(mockRuntime.settings.contentBlocking).setCookieBannerDetectOnlyMode(true) + + reset(settings) + + engine.settings.cookieBannerHandlingDetectOnlyMode = true + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBannerDetectOnlyMode(true) + } + + @Test + fun `setCookieBannerHandlingGlobalRules is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.cookieBannerHandlingGlobalRules = true + + verify(mockRuntime.settings.contentBlocking).setCookieBannerGlobalRulesEnabled(true) + + reset(settings) + + engine.settings.cookieBannerHandlingGlobalRules = true + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBannerGlobalRulesEnabled(true) + } + + @Test + fun `setCookieBannerHandlingGlobalRulesSubFrames is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.cookieBannerHandlingGlobalRulesSubFrames = true + + verify(mockRuntime.settings.contentBlocking).setCookieBannerGlobalRulesSubFramesEnabled(true) + + reset(settings) + + engine.settings.cookieBannerHandlingGlobalRulesSubFrames = true + + verify(mockRuntime.settings.contentBlocking, never()).setCookieBannerGlobalRulesSubFramesEnabled(true) + } + + @Test + fun `setQueryParameterStripping is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.queryParameterStripping = true + + verify(mockRuntime.settings.contentBlocking).setQueryParameterStrippingEnabled(true) + + reset(settings) + + engine.settings.queryParameterStripping = true + + verify(mockRuntime.settings.contentBlocking, never()).setQueryParameterStrippingEnabled(true) + } + + @Test + fun `setQueryParameterStrippingPrivateBrowsingEnabled is only invoked when the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.queryParameterStrippingPrivateBrowsing = true + + verify(mockRuntime.settings.contentBlocking).setQueryParameterStrippingPrivateBrowsingEnabled(true) + + reset(settings) + + engine.settings.queryParameterStrippingPrivateBrowsing = true + + verify(mockRuntime.settings.contentBlocking, never()).setQueryParameterStrippingPrivateBrowsingEnabled(true) + } + + @Test + fun `emailTrackerBlockingPrivateBrowsing is only invoked with the value is changed`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.emailTrackerBlockingPrivateBrowsing = true + + verify(mockRuntime.settings.contentBlocking).setEmailTrackerBlockingPrivateBrowsing(true) + + reset(settings) + + engine.settings.emailTrackerBlockingPrivateBrowsing = true + + verify(mockRuntime.settings.contentBlocking, never()).setEmailTrackerBlockingPrivateBrowsing(true) + } + + @Test + fun `Cookie banner handling settings are aligned`() { + assertEquals(ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_DISABLED, EngineSession.CookieBannerHandlingMode.DISABLED.mode) + assertEquals(ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT, EngineSession.CookieBannerHandlingMode.REJECT_ALL.mode) + assertEquals(ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT, EngineSession.CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL.mode) + } + + @Test + fun `setEnhancedTrackingProtectionLevel MUST always be set to STRICT unless the tracking protection policy is none`() { + val mockRuntime = mock<GeckoRuntime>() + val settings = spy(ContentBlocking.Settings.Builder().build()) + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(settings) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.recommended() + + verify(mockRuntime.settings.contentBlocking).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.STRICT, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.recommended() + + verify(mockRuntime.settings.contentBlocking, never()).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.STRICT, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + verify(mockRuntime.settings.contentBlocking, never()).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.STRICT, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.none() + verify(mockRuntime.settings.contentBlocking).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.NONE, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.none() + verify(mockRuntime.settings.contentBlocking, never()).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.NONE, + ) + + reset(settings) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + verify(mockRuntime.settings.contentBlocking).setEnhancedTrackingProtectionLevel( + ContentBlocking.EtpLevel.STRICT, + ) + } + + @Test + fun `WHEN a non strict tracking protection policy is set THEN the strict social list must be disabled`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking.strictSocialTrackingProtection).thenReturn(true) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.recommended() + + verify(mockRuntime.settings.contentBlocking).setStrictSocialTrackingProtection(false) + } + + @Test + fun `WHEN strict social tracking protection is set to true THEN the strict social list must be activated`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(mock()) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.select( + strictSocialTrackingProtection = true, + ) + + verify(mockRuntime.settings.contentBlocking).setStrictSocialTrackingProtection(true) + } + + @Test + fun `WHEN strict social tracking protection is set to false THEN the strict social list must be disabled`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking).thenReturn(mock()) + whenever(mockRuntime.settings.contentBlocking.strictSocialTrackingProtection).thenReturn(true) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.select( + strictSocialTrackingProtection = false, + ) + + verify(mockRuntime.settings.contentBlocking).setStrictSocialTrackingProtection(false) + } + + @Test + fun defaultSettings() { + val runtime = mock<GeckoRuntime>() + val runtimeSettings = mock<GeckoRuntimeSettings>() + val contentBlockingSettings = ContentBlocking.Settings.Builder().build() + whenever(runtimeSettings.javaScriptEnabled).thenReturn(true) + whenever(runtime.settings).thenReturn(runtimeSettings) + whenever(runtimeSettings.contentBlocking).thenReturn(contentBlockingSettings) + whenever(runtimeSettings.fontInflationEnabled).thenReturn(true) + + val engine = GeckoEngine( + context, + DefaultSettings( + trackingProtectionPolicy = TrackingProtectionPolicy.strict(), + javascriptEnabled = false, + webFontsEnabled = false, + automaticFontSizeAdjustment = false, + fontInflationEnabled = false, + fontSizeFactor = 2.0F, + remoteDebuggingEnabled = true, + testingModeEnabled = true, + userAgentString = "test-ua", + preferredColorScheme = PreferredColorScheme.Light, + suspendMediaWhenInactive = true, + forceUserScalableContent = false, + ), + runtime, + ) + + verify(runtimeSettings).javaScriptEnabled = false + verify(runtimeSettings).webFontsEnabled = false + verify(runtimeSettings).automaticFontSizeAdjustment = false + verify(runtimeSettings).fontInflationEnabled = false + verify(runtimeSettings).fontSizeFactor = 2.0F + verify(runtimeSettings).remoteDebuggingEnabled = true + verify(runtimeSettings).forceUserScalableEnabled = false + + val trackingStrictCategories = TrackingProtectionPolicy.strict().trackingCategories.sumOf { it.id } + val artificialCategory = + TrackingCategory.SCRIPTS_AND_SUB_RESOURCES.id + assertEquals( + trackingStrictCategories - artificialCategory, + contentBlockingSettings.antiTrackingCategories, + ) + + assertEquals(SafeBrowsingPolicy.RECOMMENDED.id, contentBlockingSettings.safeBrowsingCategories) + + assertEquals(CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS.id, contentBlockingSettings.cookieBehavior) + assertEquals( + CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS.id, + contentBlockingSettings.cookieBehaviorPrivateMode, + ) + assertTrue(engine.settings.testingModeEnabled) + assertEquals("test-ua", engine.settings.userAgentString) + assertEquals(PreferredColorScheme.Light, engine.settings.preferredColorScheme) + assertTrue(engine.settings.suspendMediaWhenInactive) + + engine.settings.safeBrowsingPolicy = arrayOf(SafeBrowsingPolicy.PHISHING) + engine.settings.trackingProtectionPolicy = + TrackingProtectionPolicy.select( + trackingCategories = arrayOf(TrackingCategory.AD), + cookiePolicy = CookiePolicy.ACCEPT_ONLY_FIRST_PARTY, + ) + + assertEquals( + TrackingCategory.AD.id, + contentBlockingSettings.antiTrackingCategories, + ) + + assertEquals( + SafeBrowsingPolicy.PHISHING.id, + contentBlockingSettings.safeBrowsingCategories, + ) + + assertEquals( + CookiePolicy.ACCEPT_ONLY_FIRST_PARTY.id, + contentBlockingSettings.cookieBehavior, + ) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.none() + + assertEquals(CookiePolicy.ACCEPT_ALL.id, contentBlockingSettings.cookieBehavior) + + assertEquals(EngineSession.CookieBannerHandlingMode.DISABLED.mode, contentBlockingSettings.cookieBannerMode) + assertEquals(EngineSession.CookieBannerHandlingMode.DISABLED.mode, contentBlockingSettings.cookieBannerModePrivateBrowsing) + } + + @Test + fun `speculativeConnect forwards call to executor`() { + val executor: GeckoWebExecutor = mock() + + val engine = GeckoEngine(context, runtime = runtime, executorProvider = { executor }) + + engine.speculativeConnect("https://www.mozilla.org") + + verify(executor).speculativeConnect("https://www.mozilla.org") + } + + @Test + fun `install built-in web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var onSuccessCalled = false + var onErrorCalled = false + val result = GeckoResult<GeckoWebExtension>() + + whenever(extensionController.ensureBuiltIn(extUrl, extId)).thenReturn(result) + engine.installBuiltInWebExtension( + extId, + extUrl, + onSuccess = { onSuccessCalled = true }, + onError = { _ -> onErrorCalled = true }, + ) + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val extUrlCaptor = argumentCaptor<String>() + val extIdCaptor = argumentCaptor<String>() + verify(extensionController).ensureBuiltIn(extUrlCaptor.capture(), extIdCaptor.capture()) + assertEquals(extUrl, extUrlCaptor.value) + assertEquals(extId, extIdCaptor.value) + assertTrue(onSuccessCalled) + assertFalse(onErrorCalled) + } + + @Test + fun `add optional permissions to a web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + val permissions = listOf("permission1") + val origin = listOf("origin") + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var onSuccessCalled = false + var onErrorCalled = false + val result = GeckoResult<GeckoWebExtension>() + + whenever( + extensionController.addOptionalPermissions( + extId, + permissions.toTypedArray(), + origin.toTypedArray(), + ), + ).thenReturn( + result, + ) + engine.addOptionalPermissions( + extId, + permissions, + origin, + onSuccess = { onSuccessCalled = true }, + onError = { _ -> onErrorCalled = true }, + ) + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + verify(extensionController).addOptionalPermissions(anyString(), any(), any()) + assertTrue(onSuccessCalled) + assertFalse(onErrorCalled) + } + + @Test + fun `addOptionalPermissions with empty permissions and origins with `() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val engine = GeckoEngine(context, runtime = runtime) + var onErrorCalled = false + + engine.addOptionalPermissions( + extId, + emptyList(), + emptyList(), + onError = { _ -> onErrorCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + } + + @Test + fun `remove optional permissions to a web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + val permissions = listOf("permission1") + val origin = listOf("origin") + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var onSuccessCalled = false + var onErrorCalled = false + val result = GeckoResult<GeckoWebExtension>() + + whenever( + extensionController.removeOptionalPermissions( + extId, + permissions.toTypedArray(), + origin.toTypedArray(), + ), + ).thenReturn( + result, + ) + engine.removeOptionalPermissions( + extId, + permissions, + origin, + onSuccess = { onSuccessCalled = true }, + onError = { _ -> onErrorCalled = true }, + ) + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + verify(extensionController).removeOptionalPermissions(anyString(), any(), any()) + assertTrue(onSuccessCalled) + assertFalse(onErrorCalled) + } + + @Test + fun `removeOptionalPermissions with empty permissions and origins with `() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val engine = GeckoEngine(context, runtime = runtime) + var onErrorCalled = false + + engine.removeOptionalPermissions( + extId, + emptyList(), + emptyList(), + onError = { _ -> onErrorCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + } + + @Test + fun `install external web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var onSuccessCalled = false + var onErrorCalled = false + val result = GeckoResult<GeckoWebExtension>() + + whenever(extensionController.install(any(), any())).thenReturn(result) + engine.installWebExtension( + extUrl, + onSuccess = { onSuccessCalled = true }, + onError = { _ -> onErrorCalled = true }, + ) + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val extCaptor = argumentCaptor<String>() + verify(extensionController).install(extCaptor.capture(), any()) + assertEquals(extUrl, extCaptor.value) + assertTrue(onSuccessCalled) + assertFalse(onErrorCalled) + } + + @Test + fun `install built-in web extension failure`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var onErrorCalled = false + val expected = IOException() + val result = GeckoResult<GeckoWebExtension>() + + var throwable: Throwable? = null + whenever(extensionController.ensureBuiltIn(extUrl, extId)).thenReturn(result) + engine.installBuiltInWebExtension(extId, extUrl) { e -> + onErrorCalled = true + throwable = e + } + result.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + assertTrue(throwable is GeckoWebExtensionException) + } + + @Test + fun `install external web extension failure`() { + val runtime = mock<GeckoRuntime>() + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var onErrorCalled = false + val expected = IOException() + val result = GeckoResult<GeckoWebExtension>() + + var throwable: Throwable? = null + whenever(extensionController.install(any(), any())).thenReturn(result) + engine.installWebExtension(extUrl) { e -> + onErrorCalled = true + throwable = e + } + result.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + assertTrue(throwable is GeckoWebExtensionException) + } + + @Test + fun `install web extension with installation method manager`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val result = GeckoResult<GeckoWebExtension>() + + whenever(extensionController.install(any(), any())).thenReturn(result) + + engine.installWebExtension( + extUrl, + InstallationMethod.MANAGER, + ) + + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val methodCaptor = argumentCaptor<String>() + + verify(extensionController).install(any(), methodCaptor.capture()) + + assertEquals(WebExtensionController.INSTALLATION_METHOD_MANAGER, methodCaptor.value) + } + + @Test + fun `install web extension with installation method file`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val result = GeckoResult<GeckoWebExtension>() + + whenever(extensionController.install(any(), any())).thenReturn(result) + + engine.installWebExtension( + extUrl, + InstallationMethod.FROM_FILE, + ) + + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val methodCaptor = argumentCaptor<String>() + + verify(extensionController).install(any(), methodCaptor.capture()) + + assertEquals(WebExtensionController.INSTALLATION_METHOD_FROM_FILE, methodCaptor.value) + } + + @Test + fun `install web extension with null installation method`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val result = GeckoResult<GeckoWebExtension>() + + whenever(extensionController.install(any(), any())).thenReturn(result) + + engine.installWebExtension( + extUrl, + null, + ) + + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val methodCaptor = argumentCaptor<String>() + + verify(extensionController).install(any(), methodCaptor.capture()) + + assertNull(methodCaptor.value) + } + + @Test(expected = IllegalArgumentException::class) + fun `installWebExtension should throw when a resource URL is passed`() { + val engine = GeckoEngine(context, runtime = mock()) + engine.installWebExtension("resource://android/assets/extensions/test") + } + + @Test(expected = IllegalArgumentException::class) + fun `installBuiltInWebExtension should throw when a non-resource URL is passed`() { + val engine = GeckoEngine(context, runtime = mock()) + engine.installBuiltInWebExtension(id = "id", url = "https://addons.mozilla.org/1/some_web_ext.xpi") + } + + @Test + fun `uninstall web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val nativeExtension = mockNativeWebExtension("test-webext", "https://addons.mozilla.org/1/some_web_ext.xpi") + val ext = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + nativeExtension, + runtime, + ) + + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + var onSuccessCalled = false + var onErrorCalled = false + val result = GeckoResult<Void>() + + whenever(extensionController.uninstall(any())).thenReturn(result) + engine.uninstallWebExtension( + ext, + onSuccess = { onSuccessCalled = true }, + onError = { _, _ -> onErrorCalled = true }, + ) + result.complete(null) + + shadowOf(getMainLooper()).idle() + + val extCaptor = argumentCaptor<GeckoWebExtension>() + verify(extensionController).uninstall(extCaptor.capture()) + assertSame(nativeExtension, extCaptor.value) + assertTrue(onSuccessCalled) + assertFalse(onErrorCalled) + } + + @Test + fun `uninstall web extension failure`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val nativeExtension = mockNativeWebExtension( + "test-webext", + "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi", + ) + val ext = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + nativeExtension, + runtime, + ) + + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + var onErrorCalled = false + val expected = IOException() + val result = GeckoResult<Void>() + + var throwable: Throwable? = null + whenever(extensionController.uninstall(any())).thenReturn(result) + engine.uninstallWebExtension(ext) { _, e -> + onErrorCalled = true + throwable = e + } + result.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + assertEquals(expected, throwable) + } + + @Test + fun `web extension delegate handles installation of built-in extensions`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + val result = GeckoResult<GeckoWebExtension>() + whenever(webExtensionController.ensureBuiltIn(extUrl, extId)).thenReturn(result) + engine.installBuiltInWebExtension(extId, extUrl) + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val extCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onInstalled(extCaptor.capture()) + assertEquals(extId, extCaptor.value.id) + assertEquals(extUrl, extCaptor.value.url) + } + + @Test + fun `web extension delegate handles installation of external extensions`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/123/some_web_ext.xpi" + val result = GeckoResult<GeckoWebExtension>() + whenever(webExtensionController.install(any(), any())).thenReturn(result) + engine.installWebExtension(extUrl) + result.complete(mockNativeWebExtension(extId, extUrl)) + + shadowOf(getMainLooper()).idle() + + val extCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onInstalled(extCaptor.capture()) + assertEquals(extId, extCaptor.value.id) + assertEquals(extUrl, extCaptor.value.url) + } + + @Test + fun `GIVEN approved permissions prompt WHEN onInstallPermissionRequest THEN delegate is called with allow`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.PromptDelegate>() + verify(webExtensionController).promptDelegate = geckoDelegateCaptor.capture() + + val result = geckoDelegateCaptor.value.onInstallPrompt(extension) + + val extensionCaptor = argumentCaptor<WebExtension>() + val onConfirmCaptor = argumentCaptor<((Boolean) -> Unit)>() + + verify(webExtensionsDelegate).onInstallPermissionRequest(extensionCaptor.capture(), onConfirmCaptor.capture()) + + onConfirmCaptor.value(true) + + assertEquals(GeckoResult.allow(), result) + } + + @Test + fun `GIVEN denied permissions prompt WHEN onInstallPermissionRequest THEN delegate is called with deny`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.PromptDelegate>() + verify(webExtensionController).promptDelegate = geckoDelegateCaptor.capture() + + val result = geckoDelegateCaptor.value.onInstallPrompt(extension) + + val extensionCaptor = argumentCaptor<WebExtension>() + val onConfirmCaptor = argumentCaptor<((Boolean) -> Unit)>() + + verify(webExtensionsDelegate).onInstallPermissionRequest(extensionCaptor.capture(), onConfirmCaptor.capture()) + + onConfirmCaptor.value(false) + + assertEquals(GeckoResult.deny(), result) + } + + @Test + fun `web extension delegate handles update prompt`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val currentExtension = mockNativeWebExtension("test", "uri") + val updatedExtension = mockNativeWebExtension("testUpdated", "uri") + val updatedPermissions = arrayOf("p1", "p2") + val hostPermissions = arrayOf("p3", "p4") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.PromptDelegate>() + verify(webExtensionController).promptDelegate = geckoDelegateCaptor.capture() + + val result = geckoDelegateCaptor.value.onUpdatePrompt( + currentExtension, + updatedExtension, + updatedPermissions, + hostPermissions, + ) + assertNotNull(result) + + val currentExtensionCaptor = argumentCaptor<WebExtension>() + val updatedExtensionCaptor = argumentCaptor<WebExtension>() + val onPermissionsGrantedCaptor = argumentCaptor<((Boolean) -> Unit)>() + verify(webExtensionsDelegate).onUpdatePermissionRequest( + currentExtensionCaptor.capture(), + updatedExtensionCaptor.capture(), + eq(updatedPermissions.toList() + hostPermissions.toList()), + onPermissionsGrantedCaptor.capture(), + ) + val current = + currentExtensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(currentExtension, current.nativeExtension) + val updated = + updatedExtensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(updatedExtension, updated.nativeExtension) + + onPermissionsGrantedCaptor.value.invoke(true) + assertEquals(GeckoResult.allow(), result) + } + + @Test + fun `web extension delegate handles update prompt with empty host permissions`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val currentExtension = mockNativeWebExtension("test", "uri") + val updatedExtension = mockNativeWebExtension("testUpdated", "uri") + val updatedPermissions = arrayOf("p1", "p2") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.PromptDelegate>() + verify(webExtensionController).promptDelegate = geckoDelegateCaptor.capture() + + val result = geckoDelegateCaptor.value.onUpdatePrompt( + currentExtension, + updatedExtension, + updatedPermissions, + emptyArray(), + ) + assertNotNull(result) + + val currentExtensionCaptor = argumentCaptor<WebExtension>() + val updatedExtensionCaptor = argumentCaptor<WebExtension>() + val onPermissionsGrantedCaptor = argumentCaptor<((Boolean) -> Unit)>() + verify(webExtensionsDelegate).onUpdatePermissionRequest( + currentExtensionCaptor.capture(), + updatedExtensionCaptor.capture(), + eq(updatedPermissions.toList()), + onPermissionsGrantedCaptor.capture(), + ) + val current = + currentExtensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(currentExtension, current.nativeExtension) + val updated = + updatedExtensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(updatedExtension, updated.nativeExtension) + + onPermissionsGrantedCaptor.value.invoke(true) + assertEquals(GeckoResult.allow(), result) + } + + @Test + fun `web extension delegate handles optional permissions prompt - allow`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val permissions = arrayOf("p1", "p2") + val origins = arrayOf("p3", "p4") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.PromptDelegate>() + verify(webExtensionController).promptDelegate = geckoDelegateCaptor.capture() + + val result = geckoDelegateCaptor.value.onOptionalPrompt(extension, permissions, origins) + assertNotNull(result) + + val extensionCaptor = argumentCaptor<WebExtension>() + val onPermissionsGrantedCaptor = argumentCaptor<((Boolean) -> Unit)>() + verify(webExtensionsDelegate).onOptionalPermissionsRequest( + extensionCaptor.capture(), + eq(permissions.toList() + origins.toList()), + onPermissionsGrantedCaptor.capture(), + ) + val current = extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, current.nativeExtension) + + onPermissionsGrantedCaptor.value.invoke(true) + assertEquals(GeckoResult.allow(), result) + } + + @Test + fun `web extension delegate handles optional permissions prompt - deny`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val permissions = arrayOf("p1", "p2") + val origins = emptyArray<String>() + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.PromptDelegate>() + verify(webExtensionController).promptDelegate = geckoDelegateCaptor.capture() + + val result = geckoDelegateCaptor.value.onOptionalPrompt(extension, permissions, origins) + assertNotNull(result) + + val extensionCaptor = argumentCaptor<WebExtension>() + val onPermissionsGrantedCaptor = argumentCaptor<((Boolean) -> Unit)>() + verify(webExtensionsDelegate).onOptionalPermissionsRequest( + extensionCaptor.capture(), + eq(permissions.toList() + origins.toList()), + onPermissionsGrantedCaptor.capture(), + ) + val current = extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, current.nativeExtension) + + onPermissionsGrantedCaptor.value.invoke(false) + assertEquals(GeckoResult.deny(), result) + } + + @Test + fun `web extension delegate notified of browser actions from built-in extensions`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val result = GeckoResult<GeckoWebExtension>() + whenever(extensionController.ensureBuiltIn(extUrl, extId)).thenReturn(result) + engine.installBuiltInWebExtension(extId, extUrl) + val extension = mockNativeWebExtension(extId, extUrl) + result.complete(extension) + + shadowOf(getMainLooper()).idle() + + val actionDelegateCaptor = argumentCaptor<org.mozilla.geckoview.WebExtension.ActionDelegate>() + verify(extension).setActionDelegate(actionDelegateCaptor.capture()) + + val browserAction: org.mozilla.geckoview.WebExtension.Action = mock() + actionDelegateCaptor.value.onBrowserAction(extension, null, browserAction) + + val extensionCaptor = argumentCaptor<WebExtension>() + val actionCaptor = argumentCaptor<Action>() + verify(webExtensionsDelegate).onBrowserActionDefined(extensionCaptor.capture(), actionCaptor.capture()) + assertEquals(extId, extensionCaptor.value.id) + + actionCaptor.value.onClick() + verify(browserAction).click() + } + + @Test + fun `web extension delegate notified of page actions from built-in extensions`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val result = GeckoResult<GeckoWebExtension>() + whenever(extensionController.ensureBuiltIn(extUrl, extId)).thenReturn(result) + engine.installBuiltInWebExtension(extId, extUrl) + val extension = mockNativeWebExtension(extId, extUrl) + result.complete(extension) + + shadowOf(getMainLooper()).idle() + + val actionDelegateCaptor = argumentCaptor<org.mozilla.geckoview.WebExtension.ActionDelegate>() + verify(extension).setActionDelegate(actionDelegateCaptor.capture()) + + val pageAction: org.mozilla.geckoview.WebExtension.Action = mock() + actionDelegateCaptor.value.onPageAction(extension, null, pageAction) + + val extensionCaptor = argumentCaptor<WebExtension>() + val actionCaptor = argumentCaptor<Action>() + verify(webExtensionsDelegate).onPageActionDefined(extensionCaptor.capture(), actionCaptor.capture()) + assertEquals(extId, extensionCaptor.value.id) + + actionCaptor.value.onClick() + verify(pageAction).click() + } + + @Test + fun `web extension delegate notified when built-in extension wants to open tab`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "resource://android/assets/extensions/test" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val result = GeckoResult<GeckoWebExtension>() + whenever(extensionController.ensureBuiltIn(extUrl, extId)).thenReturn(result) + engine.installBuiltInWebExtension(extId, extUrl) + val extension = mockNativeWebExtension(extId, extUrl) + result.complete(extension) + + shadowOf(getMainLooper()).idle() + + val tabDelegateCaptor = argumentCaptor<org.mozilla.geckoview.WebExtension.TabDelegate>() + verify(extension).tabDelegate = tabDelegateCaptor.capture() + + val createTabDetails: org.mozilla.geckoview.WebExtension.CreateTabDetails = mock() + tabDelegateCaptor.value.onNewTab(extension, createTabDetails) + + val extensionCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq("")) + assertEquals(extId, extensionCaptor.value.id) + } + + @Test + fun `web extension delegate notified of browser actions from external extensions`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val result = GeckoResult<GeckoWebExtension>() + whenever(extensionController.install(any(), any())).thenReturn(result) + engine.installWebExtension(extUrl) + val extension = mockNativeWebExtension(extId, extUrl) + result.complete(extension) + + shadowOf(getMainLooper()).idle() + + val actionDelegateCaptor = argumentCaptor<org.mozilla.geckoview.WebExtension.ActionDelegate>() + verify(extension).setActionDelegate(actionDelegateCaptor.capture()) + + val browserAction: org.mozilla.geckoview.WebExtension.Action = mock() + actionDelegateCaptor.value.onBrowserAction(extension, null, browserAction) + + val extensionCaptor = argumentCaptor<WebExtension>() + val actionCaptor = argumentCaptor<Action>() + verify(webExtensionsDelegate).onBrowserActionDefined(extensionCaptor.capture(), actionCaptor.capture()) + assertEquals(extId, extensionCaptor.value.id) + + actionCaptor.value.onClick() + verify(browserAction).click() + } + + @Test + fun `web extension delegate notified of page actions from external extensions`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val result = GeckoResult<GeckoWebExtension>() + whenever(extensionController.install(any(), any())).thenReturn(result) + engine.installWebExtension(extUrl) + val extension = mockNativeWebExtension(extId, extUrl) + result.complete(extension) + + shadowOf(getMainLooper()).idle() + + val actionDelegateCaptor = argumentCaptor<org.mozilla.geckoview.WebExtension.ActionDelegate>() + verify(extension).setActionDelegate(actionDelegateCaptor.capture()) + + val pageAction: org.mozilla.geckoview.WebExtension.Action = mock() + actionDelegateCaptor.value.onPageAction(extension, null, pageAction) + + val extensionCaptor = argumentCaptor<WebExtension>() + val actionCaptor = argumentCaptor<Action>() + verify(webExtensionsDelegate).onPageActionDefined(extensionCaptor.capture(), actionCaptor.capture()) + assertEquals(extId, extensionCaptor.value.id) + + actionCaptor.value.onClick() + verify(pageAction).click() + } + + @Test + fun `web extension delegate notified when external extension wants to open tab`() { + val runtime = mock<GeckoRuntime>() + val extId = "test-webext" + val extUrl = "https://addons.mozilla.org/firefox/downloads/file/123/some_web_ext.xpi" + + val extensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val result = GeckoResult<GeckoWebExtension>() + whenever(extensionController.install(any(), any())).thenReturn(result) + engine.installWebExtension(extUrl) + val extension = mockNativeWebExtension(extId, extUrl) + result.complete(extension) + + shadowOf(getMainLooper()).idle() + + val tabDelegateCaptor = argumentCaptor<org.mozilla.geckoview.WebExtension.TabDelegate>() + verify(extension).tabDelegate = tabDelegateCaptor.capture() + + val createTabDetails: org.mozilla.geckoview.WebExtension.CreateTabDetails = mock() + tabDelegateCaptor.value.onNewTab(extension, createTabDetails) + + val extensionCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onNewTab(extensionCaptor.capture(), any(), eq(false), eq("")) + assertEquals(extId, extensionCaptor.value.id) + } + + @Test + fun `web extension delegate notified of extension list change`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val debuggerDelegateCaptor = argumentCaptor<WebExtensionController.DebuggerDelegate>() + verify(webExtensionController).setDebuggerDelegate(debuggerDelegateCaptor.capture()) + + debuggerDelegateCaptor.value.onExtensionListUpdated() + verify(webExtensionsDelegate).onExtensionListUpdated() + } + + @Test + fun `web extension delegate notified of extension process spawning disabled`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val webExtensionDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionDelegate) + + val extensionProcessDelegate = argumentCaptor<WebExtensionController.ExtensionProcessDelegate>() + verify(webExtensionController).setExtensionProcessDelegate(extensionProcessDelegate.capture()) + + extensionProcessDelegate.value.onDisabledProcessSpawning() + verify(webExtensionDelegate).onDisabledExtensionProcessSpawning() + } + + @Test + fun `update web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val updatedExtension = mockNativeWebExtension() + val updateExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.update(any())).thenReturn(updateExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + var onErrorCalled = false + + engine.updateWebExtension( + extension, + onSuccess = { result = it }, + onError = { _, _ -> onErrorCalled = true }, + ) + updateExtensionResult.complete(updatedExtension) + + shadowOf(getMainLooper()).idle() + + assertFalse(onErrorCalled) + assertNotNull(result) + } + + @Test + fun `try to update a web extension without a new update available`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val updateExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.update(any())).thenReturn(updateExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + var onErrorCalled = false + + engine.updateWebExtension( + extension, + onSuccess = { result = it }, + onError = { _, _ -> onErrorCalled = true }, + ) + updateExtensionResult.complete(null) + + assertFalse(onErrorCalled) + assertNull(result) + } + + @Test + fun `update web extension failure`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val updateExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.update(any())).thenReturn(updateExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + val expected = IOException() + var throwable: Throwable? = null + + engine.updateWebExtension( + extension, + onSuccess = { result = it }, + onError = { _, e -> throwable = e }, + ) + updateExtensionResult.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertSame(expected, throwable!!.cause) + assertNull(result) + } + + @Test + fun `failures when updating MUST indicate if they are recoverable`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + val engine = GeckoEngine(context, runtime = runtime) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + val performUpdate: (GeckoInstallException) -> WebExtensionException = { exception -> + val updateExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.update(any())).thenReturn(updateExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + var throwable: WebExtensionException? = null + + engine.updateWebExtension( + extension, + onError = { _, e -> + throwable = e as WebExtensionException + }, + ) + + updateExtensionResult.completeExceptionally(exception) + + shadowOf(getMainLooper()).idle() + + throwable!! + } + + val unrecoverableExceptions = listOf( + mockGeckoInstallException(ERROR_NETWORK_FAILURE), + mockGeckoInstallException(ERROR_INCORRECT_HASH), + mockGeckoInstallException(ERROR_CORRUPT_FILE), + mockGeckoInstallException(ERROR_FILE_ACCESS), + mockGeckoInstallException(ERROR_SIGNEDSTATE_REQUIRED), + mockGeckoInstallException(ERROR_UNEXPECTED_ADDON_TYPE), + mockGeckoInstallException(ERROR_INCORRECT_ID), + mockGeckoInstallException(ERROR_POSTPONED), + ) + + unrecoverableExceptions.forEach { exception -> + assertFalse(performUpdate(exception).isRecoverable) + } + + val recoverableExceptions = listOf(mockGeckoInstallException(ERROR_USER_CANCELED)) + + recoverableExceptions.forEach { exception -> + assertTrue(performUpdate(exception).isRecoverable) + } + } + + @Test + fun `list web extensions successfully`() { + val installedExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData(allowedInPrivateBrowsing = false), + ) + + val installedExtensions = listOf(installedExtension) + val installedExtensionResult = GeckoResult<List<GeckoWebExtension>>() + + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + whenever(extensionController.list()).thenReturn(installedExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(testContext, runtime = runtime) + var extensions: List<WebExtension>? = null + var onErrorCalled = false + + engine.listInstalledWebExtensions( + onSuccess = { extensions = it }, + onError = { onErrorCalled = true }, + ) + installedExtensionResult.complete(installedExtensions) + + shadowOf(getMainLooper()).idle() + + assertFalse(onErrorCalled) + assertNotNull(extensions) + } + + @Test + fun `list web extensions failure`() { + val installedExtensionResult = GeckoResult<List<GeckoWebExtension>>() + + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + whenever(extensionController.list()).thenReturn(installedExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + var extensions: List<WebExtension>? = null + val expected = IOException() + var throwable: Throwable? = null + + engine.listInstalledWebExtensions( + onSuccess = { extensions = it }, + onError = { throwable = it }, + ) + installedExtensionResult.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertSame(expected, throwable) + assertNull(extensions) + } + + @Test + fun `enable web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val enabledExtension = mockNativeWebExtension(id = "id", location = "uri") + val enableExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.enable(any(), anyInt())).thenReturn(enableExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + val engine = GeckoEngine(context, runtime = runtime) + + var result: WebExtension? = null + var onErrorCalled = false + + engine.enableWebExtension( + extension, + onSuccess = { result = it }, + onError = { onErrorCalled = true }, + ) + enableExtensionResult.complete(enabledExtension) + + shadowOf(getMainLooper()).idle() + + assertFalse(onErrorCalled) + assertNotNull(result) + } + + @Test + fun `enable web extension failure`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val enableExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.enable(any(), anyInt())).thenReturn(enableExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + val expected = IOException() + var throwable: Throwable? = null + + engine.enableWebExtension( + extension, + onSuccess = { result = it }, + onError = { throwable = it }, + ) + enableExtensionResult.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertSame(expected, throwable) + assertNull(result) + } + + @Test + fun `disable web extension successfully`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val disabledExtension = mockNativeWebExtension(id = "id", location = "uri") + val disableExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.disable(any(), anyInt())).thenReturn(disableExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + var onErrorCalled = false + + engine.disableWebExtension( + extension, + onSuccess = { result = it }, + onError = { onErrorCalled = true }, + ) + disableExtensionResult.complete(disabledExtension) + + shadowOf(getMainLooper()).idle() + + assertFalse(onErrorCalled) + assertNotNull(result) + } + + @Test + fun `disable web extension failure`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val disableExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.disable(any(), anyInt())).thenReturn(disableExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + val expected = IOException() + var throwable: Throwable? = null + + engine.disableWebExtension( + extension, + onSuccess = { result = it }, + onError = { throwable = it }, + ) + disableExtensionResult.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertSame(expected, throwable) + assertNull(result) + } + + @Test + fun `set allowedInPrivateBrowsing successfully`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val allowedInPrivateBrowsing = mockNativeWebExtension(id = "id", location = "uri") + val allowedInPrivateBrowsingExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.setAllowedInPrivateBrowsing(any(), anyBoolean())).thenReturn(allowedInPrivateBrowsingExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + var onErrorCalled = false + + engine.setAllowedInPrivateBrowsing( + extension, + true, + onSuccess = { ext -> result = ext }, + onError = { onErrorCalled = true }, + ) + allowedInPrivateBrowsingExtensionResult.complete(allowedInPrivateBrowsing) + + shadowOf(getMainLooper()).idle() + + assertFalse(onErrorCalled) + assertNotNull(result) + verify(webExtensionsDelegate).onAllowedInPrivateBrowsingChanged(result!!) + } + + @Test + fun `set allowedInPrivateBrowsing failure`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val allowedInPrivateBrowsingExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.setAllowedInPrivateBrowsing(any(), anyBoolean())).thenReturn(allowedInPrivateBrowsingExtensionResult) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + val expected = IOException() + var throwable: Throwable? = null + + engine.setAllowedInPrivateBrowsing( + extension, + true, + onSuccess = { ext -> result = ext }, + onError = { throwable = it }, + ) + allowedInPrivateBrowsingExtensionResult.completeExceptionally(expected) + + shadowOf(getMainLooper()).idle() + + assertSame(expected, throwable) + assertNull(result) + verify(webExtensionsDelegate, never()).onAllowedInPrivateBrowsingChanged(any()) + } + + @Test + fun `GIVEN null native extension WHEN calling setAllowedInPrivateBrowsing THEN call onError`() { + val runtime = mock<GeckoRuntime>() + val extensionController: WebExtensionController = mock() + + val allowedInPrivateBrowsingExtensionResult = GeckoResult<GeckoWebExtension>() + whenever(extensionController.setAllowedInPrivateBrowsing(any(), anyBoolean())).thenReturn( + allowedInPrivateBrowsingExtensionResult, + ) + whenever(runtime.webExtensionController).thenReturn(extensionController) + + val engine = GeckoEngine(context, runtime = runtime) + val webExtensionsDelegate: WebExtensionDelegate = mock() + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val extension = mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension( + mockNativeWebExtension(), + runtime, + ) + var result: WebExtension? = null + var throwable: Throwable? = null + + engine.setAllowedInPrivateBrowsing( + extension, + true, + onSuccess = { ext -> result = ext }, + onError = { throwable = it }, + ) + allowedInPrivateBrowsingExtensionResult.complete(null) + + shadowOf(getMainLooper()).idle() + + assertNotNull(throwable) + assertNull(result) + verify(webExtensionsDelegate, never()).onAllowedInPrivateBrowsingChanged(any()) + } + + @Test(expected = RuntimeException::class) + fun `WHEN GeckoRuntime is shutting down THEN GeckoEngine throws runtime exception`() { + val runtime: GeckoRuntime = mock() + + GeckoEngine(context, runtime = runtime) + + val captor = argumentCaptor<GeckoRuntime.Delegate>() + verify(runtime).delegate = captor.capture() + + assertNotNull(captor.value) + + captor.value.onShutdown() + } + + @Test + fun `clear browsing data for all hosts`() { + val runtime: GeckoRuntime = mock() + val storageController: StorageController = mock() + + var onSuccessCalled = false + + val result = GeckoResult<Void>() + whenever(runtime.storageController).thenReturn(storageController) + whenever(storageController.clearData(eq(Engine.BrowsingData.all().types.toLong()))).thenReturn(result) + result.complete(null) + + val engine = GeckoEngine(context, runtime = runtime) + engine.clearData(data = Engine.BrowsingData.all(), onSuccess = { onSuccessCalled = true }) + + shadowOf(getMainLooper()).idle() + + assertTrue(onSuccessCalled) + } + + @Test + fun `error handler invoked when clearing browsing data for all hosts fails`() { + val runtime: GeckoRuntime = mock() + val storageController: StorageController = mock() + + var throwable: Throwable? = null + var onErrorCalled = false + + val exception = IOException() + val result = GeckoResult<Void>() + whenever(runtime.storageController).thenReturn(storageController) + whenever(storageController.clearData(eq(Engine.BrowsingData.all().types.toLong()))).thenReturn(result) + result.completeExceptionally(exception) + + val engine = GeckoEngine(context, runtime = runtime) + engine.clearData( + data = Engine.BrowsingData.all(), + onError = { + onErrorCalled = true + throwable = it + }, + ) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + assertSame(exception, throwable) + } + + @Test + fun `clear browsing data for specified host`() { + val runtime: GeckoRuntime = mock() + val storageController: StorageController = mock() + + var onSuccessCalled = false + + val result = GeckoResult<Void>() + whenever(runtime.storageController).thenReturn(storageController) + whenever( + storageController.clearDataFromBaseDomain( + eq("mozilla.org"), + eq(Engine.BrowsingData.all().types.toLong()), + ), + ).thenReturn(result) + result.complete(null) + + val engine = GeckoEngine(context, runtime = runtime) + engine.clearData(data = Engine.BrowsingData.all(), host = "mozilla.org", onSuccess = { onSuccessCalled = true }) + + shadowOf(getMainLooper()).idle() + + assertTrue(onSuccessCalled) + } + + @Test + fun `error handler invoked when clearing browsing data for specified hosts fails`() { + val runtime: GeckoRuntime = mock() + val storageController: StorageController = mock() + + var throwable: Throwable? = null + var onErrorCalled = false + + val exception = IOException() + val result = GeckoResult<Void>() + whenever(runtime.storageController).thenReturn(storageController) + whenever( + storageController.clearDataFromBaseDomain( + eq("mozilla.org"), + eq(Engine.BrowsingData.all().types.toLong()), + ), + ).thenReturn(result) + result.completeExceptionally(exception) + + val engine = GeckoEngine(context, runtime = runtime) + engine.clearData( + data = Engine.BrowsingData.all(), + host = "mozilla.org", + onError = { + onErrorCalled = true + throwable = it + }, + ) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + assertSame(exception, throwable) + } + + @Test + fun `test parsing engine version`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(context, runtime = runtime) + val version = engine.version + + println(version) + + assertTrue(version.major >= 69) + assertTrue(version.isAtLeast(69, 0, 0)) + } + + @Test + fun `fetch trackers logged successfully`() { + val runtime = mock<GeckoRuntime>() + val engine = GeckoEngine(context, runtime = runtime) + var onSuccessCalled = false + var onErrorCalled = false + val mockSession = mock<GeckoEngineSession>() + val mockGeckoSetting = mock<GeckoRuntimeSettings>() + val mockGeckoContentBlockingSetting = mock<ContentBlocking.Settings>() + var trackersLog: List<TrackerLog>? = null + + val mockContentBlockingController = mock<ContentBlockingController>() + var logEntriesResult = GeckoResult<List<ContentBlockingController.LogEntry>>() + + whenever(runtime.settings).thenReturn(mockGeckoSetting) + whenever(mockGeckoSetting.contentBlocking).thenReturn(mockGeckoContentBlockingSetting) + whenever(mockGeckoContentBlockingSetting.enhancedTrackingProtectionLevel).thenReturn( + ContentBlocking.EtpLevel.STRICT, + ) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlockingController) + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + + engine.getTrackersLog( + mockSession, + onSuccess = { + trackersLog = it + onSuccessCalled = true + }, + onError = { onErrorCalled = true }, + ) + + logEntriesResult.complete(createDummyLogEntryList()) + + shadowOf(getMainLooper()).idle() + + val trackerLog = trackersLog!!.first() + assertTrue(trackerLog.cookiesHasBeenBlocked) + assertEquals("www.tracker.com", trackerLog.url) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.FINGERPRINTING)) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.CRYPTOMINING)) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + assertTrue(trackerLog.loadedCategories.contains(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)) + assertTrue(trackerLog.loadedCategories.contains(TrackingCategory.FINGERPRINTING)) + assertTrue(trackerLog.loadedCategories.contains(TrackingCategory.CRYPTOMINING)) + assertTrue(trackerLog.loadedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + assertTrue(trackerLog.unBlockedBySmartBlock) + + assertTrue(onSuccessCalled) + assertFalse(onErrorCalled) + + logEntriesResult = GeckoResult() + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + logEntriesResult.completeExceptionally(Exception()) + + engine.getTrackersLog( + mockSession, + onSuccess = { + trackersLog = it + onSuccessCalled = true + }, + onError = { onErrorCalled = true }, + ) + + shadowOf(getMainLooper()).idle() + + assertTrue(onErrorCalled) + } + + @Test + fun `shimmed content MUST be categorized as blocked`() { + val runtime = mock<GeckoRuntime>() + val engine = spy(GeckoEngine(context, runtime = runtime)) + val mockSession = mock<GeckoEngineSession>() + val mockGeckoSetting = mock<GeckoRuntimeSettings>() + val mockGeckoContentBlockingSetting = mock<ContentBlocking.Settings>() + var trackersLog: List<TrackerLog>? = null + + val mockContentBlockingController = mock<ContentBlockingController>() + val logEntriesResult = GeckoResult<List<ContentBlockingController.LogEntry>>() + + val engineSetting = DefaultSettings() + engineSetting.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + whenever(engine.settings).thenReturn(engineSetting) + whenever(runtime.settings).thenReturn(mockGeckoSetting) + whenever(mockGeckoSetting.contentBlocking).thenReturn(mockGeckoContentBlockingSetting) + + whenever(runtime.contentBlockingController).thenReturn(mockContentBlockingController) + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + + engine.getTrackersLog(mockSession, onSuccess = { trackersLog = it }) + + logEntriesResult.complete(createShimmedEntryList()) + + shadowOf(getMainLooper()).idle() + + val trackerLog = trackersLog!!.first() + assertEquals("www.tracker.com", trackerLog.url) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + assertTrue(trackerLog.loadedCategories.isEmpty()) + } + + @Test + fun `fetch site with social trackers`() { + val runtime = mock<GeckoRuntime>() + val engine = GeckoEngine(context, runtime = runtime) + val mockSession = mock<GeckoEngineSession>() + val mockGeckoSetting = mock<GeckoRuntimeSettings>() + val mockGeckoContentBlockingSetting = mock<ContentBlocking.Settings>() + var trackersLog: List<TrackerLog>? = null + + val mockContentBlockingController = mock<ContentBlockingController>() + var logEntriesResult = GeckoResult<List<ContentBlockingController.LogEntry>>() + + whenever(runtime.settings).thenReturn(mockGeckoSetting) + whenever(mockGeckoSetting.contentBlocking).thenReturn(mockGeckoContentBlockingSetting) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlockingController) + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.recommended() + + engine.getTrackersLog(mockSession, onSuccess = { trackersLog = it }) + logEntriesResult.complete(createSocialTrackersLogEntryList()) + + shadowOf(getMainLooper()).idle() + + var trackerLog = trackersLog!!.first() + assertTrue(trackerLog.cookiesHasBeenBlocked) + assertEquals("www.tracker.com", trackerLog.url) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + + var trackerLog2 = trackersLog!![1] + assertFalse(trackerLog2.cookiesHasBeenBlocked) + assertEquals("www.tracker2.com", trackerLog2.url) + assertTrue(trackerLog2.loadedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() + + logEntriesResult = GeckoResult() + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + + engine.getTrackersLog(mockSession, onSuccess = { trackersLog = it }) + logEntriesResult.complete(createSocialTrackersLogEntryList()) + + trackerLog = trackersLog!!.first() + assertTrue(trackerLog.cookiesHasBeenBlocked) + assertEquals("www.tracker.com", trackerLog.url) + assertTrue(trackerLog.blockedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + + trackerLog2 = trackersLog!![1] + assertFalse(trackerLog2.cookiesHasBeenBlocked) + assertEquals("www.tracker2.com", trackerLog2.url) + assertTrue(trackerLog2.loadedCategories.contains(TrackingCategory.MOZILLA_SOCIAL)) + } + + @Test + fun `fetch trackers logged of the level 2 list`() { + val runtime = mock<GeckoRuntime>() + val engine = GeckoEngine(context, runtime = runtime) + val mockSession = mock<GeckoEngineSession>() + val mockGeckoSetting = mock<GeckoRuntimeSettings>() + val mockGeckoContentBlockingSetting = mock<ContentBlocking.Settings>() + var trackersLog: List<TrackerLog>? = null + + val mockContentBlockingController = mock<ContentBlockingController>() + var logEntriesResult = GeckoResult<List<ContentBlockingController.LogEntry>>() + + whenever(runtime.settings).thenReturn(mockGeckoSetting) + whenever(mockGeckoSetting.contentBlocking).thenReturn(mockGeckoContentBlockingSetting) + whenever(mockGeckoContentBlockingSetting.enhancedTrackingProtectionLevel).thenReturn( + ContentBlocking.EtpLevel.STRICT, + ) + whenever(runtime.contentBlockingController).thenReturn(mockContentBlockingController) + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + + engine.settings.trackingProtectionPolicy = TrackingProtectionPolicy.select( + arrayOf( + TrackingCategory.STRICT, + TrackingCategory.CONTENT, + ), + ) + + logEntriesResult = GeckoResult() + whenever(runtime.contentBlockingController).thenReturn(mockContentBlockingController) + whenever(mockContentBlockingController.getLog(any())).thenReturn(logEntriesResult) + + engine.getTrackersLog( + mockSession, + onSuccess = { + trackersLog = it + }, + onError = { }, + ) + logEntriesResult.complete(createDummyLogEntryList()) + + shadowOf(getMainLooper()).idle() + + val trackerLog = trackersLog!![1] + assertTrue(trackerLog.loadedCategories.contains(TrackingCategory.SCRIPTS_AND_SUB_RESOURCES)) + } + + @Test + fun `registerWebNotificationDelegate sets delegate`() { + val runtime = mock<GeckoRuntime>() + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerWebNotificationDelegate(mock()) + + verify(runtime).webNotificationDelegate = any() + } + + @Test + fun `registerWebPushDelegate sets delegate and returns same handler`() { + val runtime = mock<GeckoRuntime>() + val controller: WebPushController = mock() + val engine = GeckoEngine(context, runtime = runtime) + + whenever(runtime.webPushController).thenReturn(controller) + + val handler1 = engine.registerWebPushDelegate(mock()) + val handler2 = engine.registerWebPushDelegate(mock()) + + verify(controller, times(2)).setDelegate(any()) + + assert(handler1 == handler2) + } + + @Test + fun `registerActivityDelegate sets delegate`() { + val runtime = mock<GeckoRuntime>() + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerActivityDelegate(mock()) + + verify(runtime).activityDelegate = any() + } + + @Test + fun `unregisterActivityDelegate sets delegate to null`() { + val runtime = mock<GeckoRuntime>() + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerActivityDelegate(mock()) + + verify(runtime).activityDelegate = any() + + engine.unregisterActivityDelegate() + + verify(runtime).activityDelegate = null + } + + @Test + fun `registerScreenOrientationDelegate sets delegate`() { + val orientationController = mock<OrientationController>() + val runtime = mock<GeckoRuntime>() + doReturn(orientationController).`when`(runtime).orientationController + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerScreenOrientationDelegate(mock()) + + verify(orientationController).delegate = any() + } + + @Test + fun `unregisterScreenOrientationDelegate sets delegate to null`() { + val orientationController = mock<OrientationController>() + val runtime = mock<GeckoRuntime>() + doReturn(orientationController).`when`(runtime).orientationController + val engine = GeckoEngine(context, runtime = runtime) + + engine.registerScreenOrientationDelegate(mock()) + verify(orientationController).delegate = any() + + engine.unregisterScreenOrientationDelegate() + verify(orientationController).delegate = null + } + + @Test + fun `registerServiceWorkerDelegate sets delegate`() { + val delegate = mock<ServiceWorkerDelegate>() + val runtime = GeckoRuntime.getDefault(testContext) + val settings = DefaultSettings() + val engine = GeckoEngine(context, runtime = runtime, defaultSettings = settings) + + engine.registerServiceWorkerDelegate(delegate) + val result = runtime.serviceWorkerDelegate as GeckoServiceWorkerDelegate + + assertEquals(delegate, result.delegate) + assertEquals(runtime, result.runtime) + assertEquals(settings, result.engineSettings) + } + + @Test + fun `unregisterServiceWorkerDelegate sets delegate to null`() { + val runtime = GeckoRuntime.getDefault(testContext) + val settings = DefaultSettings() + val engine = GeckoEngine(context, runtime = runtime, defaultSettings = settings) + + engine.registerServiceWorkerDelegate(mock()) + assertNotNull(runtime.serviceWorkerDelegate) + + engine.unregisterServiceWorkerDelegate() + assertNull(runtime.serviceWorkerDelegate) + } + + @Test + fun `handleWebNotificationClick calls click on the WebNotification`() { + val runtime = GeckoRuntime.getDefault(testContext) + val settings = DefaultSettings() + val engine = GeckoEngine(context, runtime = runtime, defaultSettings = settings) + + // Check that having another argument doesn't cause any issues + engine.handleWebNotificationClick(runtime) + + val notification: WebNotification = mock() + engine.handleWebNotificationClick(notification) + verify(notification).click() + } + + @Test + fun `web extension delegate handles add-on onEnabled event`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.AddonManagerDelegate>() + verify(webExtensionController).setAddonManagerDelegate(geckoDelegateCaptor.capture()) + + assertEquals(Unit, geckoDelegateCaptor.value.onEnabled(extension)) + val extensionCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onEnabled(extensionCaptor.capture()) + val capturedExtension = + extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, capturedExtension.nativeExtension) + } + + @Test + fun `web extension delegate handles add-on onInstallationFailed event`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + val exception = mockGeckoInstallException(ERROR_BLOCKLISTED) + + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.AddonManagerDelegate>() + verify(webExtensionController).setAddonManagerDelegate(geckoDelegateCaptor.capture()) + + assertEquals(Unit, geckoDelegateCaptor.value.onInstallationFailed(extension, exception)) + + val extensionCaptor = argumentCaptor<WebExtension>() + val exceptionCaptor = argumentCaptor<WebExtensionInstallException>() + + verify(webExtensionsDelegate).onInstallationFailedRequest( + extensionCaptor.capture(), + exceptionCaptor.capture(), + ) + val capturedExtension = + extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, capturedExtension.nativeExtension) + + assertTrue(exceptionCaptor.value is WebExtensionInstallException.Blocklisted) + } + + @Test + fun `web extension delegate handles add-on onDisabled event`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.AddonManagerDelegate>() + verify(webExtensionController).setAddonManagerDelegate(geckoDelegateCaptor.capture()) + + assertEquals(Unit, geckoDelegateCaptor.value.onDisabled(extension)) + val extensionCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onDisabled(extensionCaptor.capture()) + val capturedExtension = + extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, capturedExtension.nativeExtension) + } + + @Test + fun `web extension delegate handles add-on onUninstalled event`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.AddonManagerDelegate>() + verify(webExtensionController).setAddonManagerDelegate(geckoDelegateCaptor.capture()) + + assertEquals(Unit, geckoDelegateCaptor.value.onUninstalled(extension)) + val extensionCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onUninstalled(extensionCaptor.capture()) + val capturedExtension = + extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, capturedExtension.nativeExtension) + } + + @Test + fun `web extension delegate handles add-on onInstalled event`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val extension = mockNativeWebExtension("test", "uri") + val webExtensionsDelegate: WebExtensionDelegate = mock() + val engine = GeckoEngine(context, runtime = runtime) + engine.registerWebExtensionDelegate(webExtensionsDelegate) + + val geckoDelegateCaptor = argumentCaptor<WebExtensionController.AddonManagerDelegate>() + verify(webExtensionController).setAddonManagerDelegate(geckoDelegateCaptor.capture()) + + assertEquals(Unit, geckoDelegateCaptor.value.onInstalled(extension)) + val extensionCaptor = argumentCaptor<WebExtension>() + verify(webExtensionsDelegate).onInstalled(extensionCaptor.capture()) + val capturedExtension = + extensionCaptor.value as mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension + assertEquals(extension, capturedExtension.nativeExtension) + + // Make sure we called `registerActionHandler()` on the installed extension. + verify(extension).setActionDelegate(any()) + // Make sure we called `registerTabHandler()` on the installed extension. + verify(extension).tabDelegate = any() + } + + @Test + fun `WHEN isTranslationsEngineSupported is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Boolean>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Boolean>> { isTranslationsEngineSupported() } + .thenReturn(geckoResult) + + engine.isTranslationsEngineSupported( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(true) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully determine translation engine status." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN isTranslationsEngineSupported is called AND excepts THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Boolean>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Boolean>> { isTranslationsEngineSupported() } + .thenReturn(geckoResult) + + engine.isTranslationsEngineSupported( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not have successfully determine translation engine status." } + assert(onErrorCalled) { "Should have had an exception." } + } + } + + @Test + fun `WHEN getTranslationsPairDownloadSize is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Long>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Long>> { checkPairDownloadSize(any(), any()) } + .thenReturn(geckoResult) + + engine.getTranslationsPairDownloadSize( + fromLanguage = "es", + toLanguage = "en", + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(12345) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully determine pair size." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getTranslationsPairDownloadSize is called AND excepts THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Long>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Long>> { checkPairDownloadSize(any(), any()) } + .thenReturn(geckoResult) + + engine.getTranslationsPairDownloadSize( + fromLanguage = "es", + toLanguage = "en", + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not have successfully determine pair size." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN getTranslationsModelDownloadStates is called successfully THEN onSuccess is called AND the LanguageModel maps as expected`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + var code = "es" + var localizedDisplayName = "Spanish" + var isDownloaded = true + var size: Long = 1234 + var geckoLanguage = TranslationsController.Language(code, localizedDisplayName) + var geckoLanguageModel = LanguageModel(geckoLanguage, isDownloaded, size) + var geckoResultValue: List<LanguageModel> = mutableListOf(geckoLanguageModel) + val geckoResult = GeckoResult<List<LanguageModel>>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<LanguageModel>>> { listModelDownloadStates() } + .thenReturn(geckoResult) + + engine.getTranslationsModelDownloadStates( + onSuccess = { + onSuccessCalled = true + assertTrue(it[0].language!!.code == code) + assertTrue(it[0].language!!.localizedDisplayName == localizedDisplayName) + assertTrue(it[0].isDownloaded == isDownloaded) + assertTrue(it[0].size == size) + }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should have successfully listed model download state." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getTranslationsModelDownloadStates is called AND excepts THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<List<LanguageModel>>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<LanguageModel>>> { listModelDownloadStates() } + .thenReturn(geckoResult) + + engine.getTranslationsModelDownloadStates( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not have successfully listed model download state." } + assert(onErrorCalled) { "An error should have have occurred." } + } + } + + @Test + fun `WHEN getSupportedTranslationLanguages is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<TranslationsController.RuntimeTranslation.TranslationSupport>() + val toLanguage = Language("de", "German") + val fromLanguage = Language("es", "Spanish") + val geckoResultValue = TranslationsController.RuntimeTranslation.TranslationSupport(listOf<Language>(fromLanguage), listOf<Language>(toLanguage)) + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<TranslationsController.RuntimeTranslation.TranslationSupport>> { listSupportedLanguages() } + .thenReturn(geckoResult) + + engine.getSupportedTranslationLanguages( + onSuccess = { + onSuccessCalled = true + assertTrue(it.fromLanguages!![0].code == fromLanguage.code) + assertTrue(it.toLanguages!![0].code == toLanguage.code) + }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Successfully retrieved list of supported languages." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getSupportedTranslationLanguages is called AND excepts THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<TranslationsController.RuntimeTranslation.TranslationSupport>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<TranslationsController.RuntimeTranslation.TranslationSupport>> { listSupportedLanguages() } + .thenReturn(geckoResult) + + engine.getSupportedTranslationLanguages( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not have retrieved list of supported languages." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN manageTranslationsLanguageModel is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + var options = ModelManagementOptions(null, ModelOperation.DOWNLOAD, OperationLevel.ALL) + val geckoResult = GeckoResult<Void>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Void>> { manageLanguageModel(any()) } + .thenReturn(geckoResult) + + engine.manageTranslationsLanguageModel( + options = options, + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(null) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully manage language models." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN manageTranslationsLanguageModel is called AND excepts THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + var options = ModelManagementOptions(null, ModelOperation.DOWNLOAD, OperationLevel.ALL) + val geckoResult = GeckoResult<Void>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Void>> { manageLanguageModel(any()) } + .thenReturn(geckoResult) + + engine.manageTranslationsLanguageModel( + options = options, + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully manage language models." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN getUserPreferredLanguages is called successfully THEN onSuccess is called `() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<List<String>>() + val geckoResultValue = listOf<String>("en", "es", "de") + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<String>>> { preferredLanguages() } + .thenReturn(geckoResult) + + engine.getUserPreferredLanguages( + onSuccess = { + onSuccessCalled = true + assertTrue(it[0] == "en") + }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully list user languages." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getUserPreferredLanguages is called AND excepts THEN onError is called `() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<List<String>>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<String>>> { preferredLanguages() } + .thenReturn(geckoResult) + + engine.getUserPreferredLanguages( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully list user languages." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN getTranslationsOfferPopup is called successfully THEN a result is retrieved `() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + val runtimeSettings = mock<GeckoRuntimeSettings>() + + whenever(runtime.settings).thenReturn(runtimeSettings) + whenever(runtime.settings.translationsOfferPopup).thenReturn(true) + + val result = engine.getTranslationsOfferPopup() + assert(result) { "Should successfully get a language setting." } + } + + @Test + fun `WHEN getLanguageSetting is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<String>() + val geckoResultValue = "always" + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<String>> { getLanguageSetting(any()) } + .thenReturn(geckoResult) + + engine.getLanguageSetting( + "es", + onSuccess = { + onSuccessCalled = true + assertTrue(it == LanguageSetting.ALWAYS) + }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully get a language setting." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getLanguageSetting is unsuccessful THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<String>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<String>> { getLanguageSetting(any()) } + .thenReturn(geckoResult) + + engine.getLanguageSetting( + "es", + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully get a language setting." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN setLanguageSetting is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Void>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Void>> { setLanguageSettings(any(), any()) } + .thenReturn(geckoResult) + + engine.setLanguageSetting( + "es", + LanguageSetting.ALWAYS, + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(null) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully set a language setting." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN setLanguageSetting is unsuccessful THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Void>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Void>> { setLanguageSettings(any(), any()) } + .thenReturn(geckoResult) + + engine.setLanguageSetting( + "es", + LanguageSetting.ALWAYS, + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully set a language setting." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN getLanguageSetting is unrecognized THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<String>() + val geckoResultValue = "NotAnExpectedResponse" + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<String>> { getLanguageSetting(any()) } + .thenReturn(geckoResult) + + engine.getLanguageSetting( + "es", + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully get a language setting." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN getLanguageSettings is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Map<String, String>>() + val geckoResultValue = mapOf("es" to "offer", "de" to "always", "fr" to "never") + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Map<String, String>>> { getLanguageSettings() } + .thenReturn(geckoResult) + + engine.getLanguageSettings( + onSuccess = { + onSuccessCalled = true + assertTrue(it["es"] == LanguageSetting.OFFER) + assertTrue(it["de"] == LanguageSetting.ALWAYS) + assertTrue(it["fr"] == LanguageSetting.NEVER) + }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully list language settings." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getLanguageSettings is unsuccessful THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Map<String, String>>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Map<String, String>>> { getLanguageSettings() } + .thenReturn(geckoResult) + + engine.getLanguageSettings( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully list language settings." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN getNeverTranslateSiteList is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<List<String>>() + val geckoResultValue = listOf("www.mozilla.org") + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<String>>> { getNeverTranslateSiteList() } + .thenReturn(geckoResult) + + engine.getNeverTranslateSiteList( + onSuccess = { + onSuccessCalled = true + assertTrue(it[0] == "www.mozilla.org") + }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(geckoResultValue) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully list of never translate websites." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN getNeverTranslateSiteList is unsuccessful THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<List<String>>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<String>>> { getNeverTranslateSiteList() } + .thenReturn(geckoResult) + + engine.getNeverTranslateSiteList( + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully list never translate sites." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN setNeverTranslateSpecifiedSite is called successfully THEN onSuccess is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<Void>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<Void>> { setNeverTranslateSpecifiedSite(any(), any()) } + .thenReturn(geckoResult) + + engine.setNeverTranslateSpecifiedSite( + "www.mozilla.org", + true, + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.complete(null) + shadowOf(getMainLooper()).idle() + + assert(onSuccessCalled) { "Should successfully complete when setting the never translate site." } + assert(!onErrorCalled) { "An error should not have occurred." } + } + } + + @Test + fun `WHEN setNeverTranslateSpecifiedSite is unsuccessful THEN onError is called`() { + val runtime: GeckoRuntime = mock() + val engine = GeckoEngine(testContext, runtime = runtime) + + var onSuccessCalled = false + var onErrorCalled = false + + val geckoResult = GeckoResult<List<String>>() + + Mockito.mockStatic(TranslationsController.RuntimeTranslation::class.java, Mockito.CALLS_REAL_METHODS).use { + mocked -> + mocked.`when`<GeckoResult<List<String>>> { setNeverTranslateSpecifiedSite(any(), any()) } + .thenReturn(geckoResult) + + engine.setNeverTranslateSpecifiedSite( + "www.mozilla.org", + true, + onSuccess = { onSuccessCalled = true }, + onError = { onErrorCalled = true }, + ) + + geckoResult.completeExceptionally(Exception()) + shadowOf(getMainLooper()).idle() + + assert(!onSuccessCalled) { "Should not successfully complete when setting the never translate site." } + assert(onErrorCalled) { "An error should have occurred." } + } + } + + @Test + fun `WHEN Global Privacy Control value is set THEN setGlobalPrivacyControl is getting called on GeckoRuntime`() { + val mockRuntime = mock<GeckoRuntime>() + whenever(mockRuntime.settings).thenReturn(mock()) + + val engine = GeckoEngine(testContext, runtime = mockRuntime) + + reset(mockRuntime.settings) + engine.settings.globalPrivacyControlEnabled = true + verify(mockRuntime.settings).setGlobalPrivacyControl(true) + + reset(mockRuntime.settings) + engine.settings.globalPrivacyControlEnabled = false + verify(mockRuntime.settings).setGlobalPrivacyControl(false) + } + + private fun createSocialTrackersLogEntryList(): List<ContentBlockingController.LogEntry> { + val blockedLogEntry = object : ContentBlockingController.LogEntry() {} + + ReflectionUtils.setField(blockedLogEntry, "origin", "www.tracker.com") + val blockedCookieSocialTracker = createBlockingData(Event.COOKIES_BLOCKED_SOCIALTRACKER) + val blockedSocialContent = createBlockingData(Event.BLOCKED_SOCIALTRACKING_CONTENT) + + ReflectionUtils.setField(blockedLogEntry, "blockingData", listOf(blockedSocialContent, blockedCookieSocialTracker)) + + val loadedLogEntry = object : ContentBlockingController.LogEntry() {} + ReflectionUtils.setField(loadedLogEntry, "origin", "www.tracker2.com") + + val loadedCookieSocialTracker = createBlockingData(Event.COOKIES_LOADED_SOCIALTRACKER) + val loadedSocialContent = createBlockingData(Event.LOADED_SOCIALTRACKING_CONTENT) + + ReflectionUtils.setField(loadedLogEntry, "blockingData", listOf(loadedCookieSocialTracker, loadedSocialContent)) + + return listOf(blockedLogEntry, loadedLogEntry) + } + + private fun createDummyLogEntryList(): List<ContentBlockingController.LogEntry> { + val addLogEntry = object : ContentBlockingController.LogEntry() {} + + ReflectionUtils.setField(addLogEntry, "origin", "www.tracker.com") + val blockedCookiePermission = createBlockingData(Event.COOKIES_BLOCKED_BY_PERMISSION) + val loadedCookieSocialTracker = createBlockingData(Event.COOKIES_LOADED_SOCIALTRACKER) + val blockedCookieSocialTracker = createBlockingData(Event.COOKIES_BLOCKED_SOCIALTRACKER) + + val blockedTrackingContent = createBlockingData(Event.BLOCKED_TRACKING_CONTENT) + val blockedFingerprintingContent = createBlockingData(Event.BLOCKED_FINGERPRINTING_CONTENT) + val blockedCyptominingContent = createBlockingData(Event.BLOCKED_CRYPTOMINING_CONTENT) + val blockedSocialContent = createBlockingData(Event.BLOCKED_SOCIALTRACKING_CONTENT) + + val loadedTrackingLevel1Content = createBlockingData(Event.LOADED_LEVEL_1_TRACKING_CONTENT) + val loadedTrackingLevel2Content = createBlockingData(Event.LOADED_LEVEL_2_TRACKING_CONTENT) + val loadedFingerprintingContent = createBlockingData(Event.LOADED_FINGERPRINTING_CONTENT) + val loadedCyptominingContent = createBlockingData(Event.LOADED_CRYPTOMINING_CONTENT) + val loadedSocialContent = createBlockingData(Event.LOADED_SOCIALTRACKING_CONTENT) + val unBlockedBySmartBlock = createBlockingData(Event.ALLOWED_TRACKING_CONTENT) + + val contentBlockingList = listOf( + blockedTrackingContent, + loadedTrackingLevel1Content, + loadedTrackingLevel2Content, + blockedFingerprintingContent, + loadedFingerprintingContent, + blockedCyptominingContent, + loadedCyptominingContent, + blockedCookiePermission, + blockedSocialContent, + loadedSocialContent, + loadedCookieSocialTracker, + blockedCookieSocialTracker, + unBlockedBySmartBlock, + ) + + val addLogSecondEntry = object : ContentBlockingController.LogEntry() {} + ReflectionUtils.setField(addLogSecondEntry, "origin", "www.tracker2.com") + val contentBlockingSecondEntryList = listOf(loadedTrackingLevel2Content) + + ReflectionUtils.setField(addLogEntry, "blockingData", contentBlockingList) + ReflectionUtils.setField(addLogSecondEntry, "blockingData", contentBlockingSecondEntryList) + + return listOf(addLogEntry, addLogSecondEntry) + } + + private fun createShimmedEntryList(): List<ContentBlockingController.LogEntry> { + val addLogEntry = object : ContentBlockingController.LogEntry() {} + + ReflectionUtils.setField(addLogEntry, "origin", "www.tracker.com") + val shimmedContent = createBlockingData(Event.REPLACED_TRACKING_CONTENT, 2) + val loadedTrackingLevel1Content = createBlockingData(Event.LOADED_LEVEL_1_TRACKING_CONTENT) + val loadedSocialContent = createBlockingData(Event.LOADED_SOCIALTRACKING_CONTENT) + + val contentBlockingList = listOf( + loadedTrackingLevel1Content, + loadedSocialContent, + shimmedContent, + ) + + ReflectionUtils.setField(addLogEntry, "blockingData", contentBlockingList) + + return listOf(addLogEntry) + } + + private fun createBlockingData(category: Int, count: Int = 0): ContentBlockingController.LogEntry.BlockingData { + val blockingData = object : ContentBlockingController.LogEntry.BlockingData() {} + ReflectionUtils.setField(blockingData, "category", category) + ReflectionUtils.setField(blockingData, "count", count) + return blockingData + } + + private fun mockGeckoInstallException(errorCode: Int): GeckoInstallException { + val exception = object : GeckoInstallException() {} + ReflectionUtils.setField(exception, "code", errorCode) + return exception + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt new file mode 100644 index 0000000000..7056187e09 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt @@ -0,0 +1,299 @@ +/* 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.browser.engine.gecko + +import android.app.Activity +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Looper.getMainLooper +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.GeckoEngineView.Companion.DARK_COVER +import mozilla.components.browser.engine.gecko.selection.GeckoSelectionActionDelegate +import mozilla.components.concept.engine.mediaquery.PreferredColorScheme +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import mozilla.components.test.ReflectionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class GeckoEngineViewTest { + + private val context: Context + get() = buildActivity(Activity::class.java).get() + + @Test + fun render() { + val engineView = GeckoEngineView(context) + val engineSession = mock<GeckoEngineSession>() + val geckoSession = mock<GeckoSession>() + val geckoView = mock<NestedGeckoView>() + + whenever(engineSession.geckoSession).thenReturn(geckoSession) + engineView.geckoView = geckoView + + engineView.render(engineSession) + verify(geckoView, times(1)).setSession(geckoSession) + + whenever(geckoView.session).thenReturn(geckoSession) + engineView.render(engineSession) + verify(geckoView, times(1)).setSession(geckoSession) + } + + @Test + fun captureThumbnail() { + val engineView = GeckoEngineView(context) + val mockGeckoView = mock<NestedGeckoView>() + var thumbnail: Bitmap? = null + + var geckoResult = GeckoResult<Bitmap>() + whenever(mockGeckoView.capturePixels()).thenReturn(geckoResult) + engineView.geckoView = mockGeckoView + + // Test GeckoResult resolves successfuly + engineView.captureThumbnail { + thumbnail = it + } + verify(mockGeckoView).capturePixels() + geckoResult.complete(mock()) + shadowOf(getMainLooper()).idle() + + assertNotNull(thumbnail) + + geckoResult = GeckoResult() + whenever(mockGeckoView.capturePixels()).thenReturn(geckoResult) + + // Test GeckoResult resolves in error + engineView.captureThumbnail { + thumbnail = it + } + geckoResult.completeExceptionally(mock()) + shadowOf(getMainLooper()).idle() + + assertNull(thumbnail) + + // Test GeckoView throwing an exception + whenever(mockGeckoView.capturePixels()).thenThrow(IllegalStateException("Compositor not ready")) + + thumbnail = mock() + engineView.captureThumbnail { + thumbnail = it + } + assertNull(thumbnail) + } + + @Test + fun `clearSelection is forwarded to BasicSelectionAction instance`() { + val engineView = GeckoEngineView(context) + engineView.geckoView = mock() + engineView.currentSelection = mock() + + engineView.clearSelection() + + verify(engineView.currentSelection)?.clearSelection() + } + + @Test + fun `setColorScheme uses preferred color scheme to set correct cover color`() { + val engineView = GeckoEngineView(context) + + engineView.geckoView = mock() + + var preferredColorScheme: PreferredColorScheme = PreferredColorScheme.Light + + engineView.setColorScheme(preferredColorScheme) + + verify(engineView.geckoView)?.coverUntilFirstPaint(Color.WHITE) + + preferredColorScheme = PreferredColorScheme.Dark + engineView.setColorScheme(preferredColorScheme) + verify(engineView.geckoView)?.coverUntilFirstPaint(DARK_COVER) + } + + @Test + fun `setVerticalClipping is forwarded to GeckoView instance`() { + val engineView = GeckoEngineView(context) + engineView.geckoView = mock() + + engineView.setVerticalClipping(-42) + + verify(engineView.geckoView).setVerticalClipping(-42) + } + + @Test + fun `setDynamicToolbarMaxHeight is forwarded to GeckoView instance`() { + val engineView = GeckoEngineView(context) + engineView.geckoView = mock() + + engineView.setDynamicToolbarMaxHeight(42) + + verify(engineView.geckoView).setDynamicToolbarMaxHeight(42) + } + + @Test + fun `release method releases session from GeckoView`() { + val engineView = GeckoEngineView(context) + val engineSession = mock<GeckoEngineSession>() + val geckoSession = mock<GeckoSession>() + val geckoView = mock<NestedGeckoView>() + + whenever(engineSession.geckoSession).thenReturn(geckoSession) + engineView.geckoView = geckoView + + engineView.render(engineSession) + + verify(geckoView, never()).releaseSession() + + engineView.release() + + verify(geckoView).releaseSession() + } + + @Test + fun `after rendering currentSelection should be a GeckoSelectionActionDelegate`() { + val engineView = GeckoEngineView(context).apply { + selectionActionDelegate = mock() + } + val engineSession = mock<GeckoEngineSession>() + val geckoSession = mock<GeckoSession>() + val geckoView = mock<NestedGeckoView>() + + whenever(engineSession.geckoSession).thenReturn(geckoSession) + engineView.geckoView = geckoView + + engineView.render(engineSession) + + assertTrue(engineView.currentSelection is GeckoSelectionActionDelegate) + } + + @Test + fun `will attach and detach selection action delegate when rendering and releasing`() { + val delegate: SelectionActionDelegate = mock() + + val engineView = GeckoEngineView(context).apply { + selectionActionDelegate = delegate + } + val engineSession = mock<GeckoEngineSession>() + val geckoSession = mock<GeckoSession>() + val geckoView = mock<NestedGeckoView>() + + whenever(engineSession.geckoSession).thenReturn(geckoSession) + engineView.geckoView = geckoView + + engineView.render(engineSession) + + val captor = argumentCaptor<GeckoSession.SelectionActionDelegate>() + verify(geckoSession).selectionActionDelegate = captor.capture() + + assertTrue(captor.value is GeckoSelectionActionDelegate) + val capturedDelegate = captor.value as GeckoSelectionActionDelegate + + assertEquals(delegate, capturedDelegate.customDelegate) + + verify(geckoSession, never()).selectionActionDelegate = null + + engineView.release() + + verify(geckoSession).selectionActionDelegate = null + } + + @Test + fun `will attach and detach selection action delegate when rendering new session`() { + val delegate: SelectionActionDelegate = mock() + + val engineView = GeckoEngineView(context).apply { + selectionActionDelegate = delegate + } + val engineSession = mock<GeckoEngineSession>() + val geckoSession = mock<GeckoSession>() + val geckoView = mock<NestedGeckoView>() + + whenever(engineSession.geckoSession).thenReturn(geckoSession) + engineView.geckoView = geckoView + + engineView.render(engineSession) + + val captor = argumentCaptor<GeckoSession.SelectionActionDelegate>() + verify(geckoSession).selectionActionDelegate = captor.capture() + + assertTrue(captor.value is GeckoSelectionActionDelegate) + val capturedDelegate = captor.value as GeckoSelectionActionDelegate + + assertEquals(delegate, capturedDelegate.customDelegate) + + verify(geckoSession, never()).selectionActionDelegate = null + + whenever(geckoView.session).thenReturn(geckoSession) + + engineView.render( + mock<GeckoEngineSession>().apply { + whenever(this.geckoSession).thenReturn(mock()) + }, + ) + + verify(geckoSession).selectionActionDelegate = null + } + + @Test + fun `setVisibility is propagated to gecko view`() { + val engineView = GeckoEngineView(context) + engineView.geckoView = mock() + + engineView.visibility = View.GONE + verify(engineView.geckoView)?.visibility = View.GONE + } + + @Test + fun `canClearSelection should return false for null selection, null and empty selection text`() { + val engineView = GeckoEngineView(context) + engineView.geckoView = mock() + engineView.currentSelection = mock() + + // null selection returns false + whenever(engineView.currentSelection?.selection).thenReturn(null) + assertFalse(engineView.canClearSelection()) + + // selection with null text returns false + val selectionWthNullText: GeckoSession.SelectionActionDelegate.Selection = mock() + whenever(engineView.currentSelection?.selection).thenReturn(selectionWthNullText) + assertFalse(engineView.canClearSelection()) + + // selection with empty text returns false + val selectionWthEmptyText: GeckoSession.SelectionActionDelegate.Selection = mockSelection("") + whenever(engineView.currentSelection?.selection).thenReturn(selectionWthEmptyText) + assertFalse(engineView.canClearSelection()) + } + + @Test + fun `GIVEN a GeckoView WHEN EngineView returns the InputResultDetail THEN the value from the GeckoView is used`() { + val engineView = GeckoEngineView(context) + val geckoview = engineView.geckoView + + assertSame(geckoview.inputResultDetail, engineView.getInputResultDetail()) + } + + private fun mockSelection(text: String): GeckoSession.SelectionActionDelegate.Selection { + val selection: GeckoSession.SelectionActionDelegate.Selection = mock() + ReflectionUtils.setField(selection, "text", text) + return selection + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoResultTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoResultTest.kt new file mode 100644 index 0000000000..b8c0220046 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoResultTest.kt @@ -0,0 +1,86 @@ +/* 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.browser.engine.gecko + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.robolectric.annotation.LooperMode + +@Suppress("DEPRECATION") // Suppress deprecation for LooperMode.Mode.LEGACY +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.LEGACY) +class GeckoResultTest { + + @Test + fun awaitWithResult() = runTest { + val result = GeckoResult.fromValue(42).await() + assertEquals(42, result) + } + + @Test(expected = IllegalStateException::class) + fun awaitWithException() = runTest { + GeckoResult.fromException<Unit>(IllegalStateException()).await() + } + + @Test + fun fromResult() = runTest { + val result = launchGeckoResult { 42 } + + result.then<Int> { + assertEquals(42, it) + GeckoResult.fromValue(null) + }.await() + } + + @Test + fun fromException() = runTest { + val result = launchGeckoResult { throw IllegalStateException() } + + result.then<Unit>( + { + assertTrue("Invalid branch", false) + GeckoResult.fromValue(null) + }, + { + assertTrue(it is IllegalStateException) + GeckoResult.fromValue(null) + }, + ).await() + } + + @Test + fun asCancellableOperation() = runTest { + val geckoResult: GeckoResult<Int> = mock() + val op = geckoResult.asCancellableOperation() + + whenever(geckoResult.cancel()).thenReturn(GeckoResult.fromValue(false)) + assertFalse(op.cancel().await()) + + whenever(geckoResult.cancel()).thenReturn(GeckoResult.fromValue(null)) + assertFalse(op.cancel().await()) + + whenever(geckoResult.cancel()).thenReturn(GeckoResult.fromValue(true)) + assertTrue(op.cancel().await()) + + whenever(geckoResult.cancel()).thenReturn(GeckoResult.fromException(IllegalStateException())) + try { + op.cancel().await() + fail("Expected IllegalStateException") + } catch (e: IllegalStateException) { + // expected + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorageTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorageTest.kt new file mode 100644 index 0000000000..146f9f137f --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorageTest.kt @@ -0,0 +1,274 @@ +/* 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.browser.engine.gecko + +import android.os.Looper.getMainLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import mozilla.components.browser.engine.gecko.content.blocking.GeckoTrackingProtectionException +import mozilla.components.browser.engine.gecko.permission.geckoContentPermission +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.content.blocking.TrackingProtectionException +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyString +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_TRACKING +import org.mozilla.geckoview.StorageController +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class GeckoTrackingProtectionExceptionStorageTest { + + private lateinit var runtime: GeckoRuntime + + private lateinit var storage: GeckoTrackingProtectionExceptionStorage + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + storage = spy(GeckoTrackingProtectionExceptionStorage(runtime)) + storage.scope = CoroutineScope(Dispatchers.Main) + } + + @Test + fun `GIVEN a new exception WHEN adding THEN the exception is stored on the gecko storage`() { + val storageController = mock<StorageController>() + val mockGeckoSession = mock<GeckoSession>() + val session = spy(GeckoEngineSession(runtime, geckoSessionProvider = { mockGeckoSession })) + + val geckoPermission = geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_ALLOW) + session.geckoPermissions = listOf(geckoPermission) + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.storageController).thenReturn(storageController) + + var excludedOnTrackingProtection = false + + session.register( + object : EngineSession.Observer { + override fun onExcludedOnTrackingProtectionChange(excluded: Boolean) { + excludedOnTrackingProtection = excluded + } + }, + ) + + storage.add(session) + + verify(storageController).setPermission(geckoPermission, VALUE_ALLOW) + assertTrue(excludedOnTrackingProtection) + } + + @Test + fun `GIVEN a persistInPrivateMode new exception WHEN adding THEN the exception is stored on the gecko storage`() { + val storageController = mock<StorageController>() + val mockGeckoSession = mock<GeckoSession>() + val session = spy(GeckoEngineSession(runtime, geckoSessionProvider = { mockGeckoSession })) + + val geckoPermission = geckoContentPermission(type = PERMISSION_TRACKING, value = VALUE_ALLOW) + session.geckoPermissions = listOf(geckoPermission) + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(runtime.storageController).thenReturn(storageController) + + var excludedOnTrackingProtection = false + + session.register( + object : EngineSession.Observer { + override fun onExcludedOnTrackingProtectionChange(excluded: Boolean) { + excludedOnTrackingProtection = excluded + } + }, + ) + + storage.add(session, persistInPrivateMode = true) + + verify(storageController).setPrivateBrowsingPermanentPermission(geckoPermission, VALUE_ALLOW) + assertTrue(excludedOnTrackingProtection) + } + + @Test + fun `WHEN removing an exception by session THEN the session is removed of the exception list`() { + val mockGeckoSession = mock<GeckoSession>() + val session = spy(GeckoEngineSession(runtime, geckoSessionProvider = { mockGeckoSession })) + + whenever(session.geckoSession).thenReturn(mockGeckoSession) + whenever(session.currentUrl).thenReturn("https://example.com/") + doNothing().`when`(storage).remove(anyString()) + + var excludedOnTrackingProtection = true + + session.register( + object : EngineSession.Observer { + override fun onExcludedOnTrackingProtectionChange(excluded: Boolean) { + excludedOnTrackingProtection = excluded + } + }, + ) + + storage.remove(session) + + verify(storage).remove(anyString()) + assertFalse(excludedOnTrackingProtection) + } + + @Test + fun `GIVEN TrackingProtectionException WHEN removing THEN remove the exception using with its contentPermission`() { + val geckoException = mock<GeckoTrackingProtectionException>() + val contentPermission = mock<ContentPermission>() + + whenever(geckoException.contentPermission).thenReturn(contentPermission) + doNothing().`when`(storage).remove(contentPermission) + + storage.remove(geckoException) + verify(storage).remove(geckoException.contentPermission) + } + + @Test + fun `GIVEN URL WHEN removing THEN remove the exception using with its URL`() { + val exception = mock<TrackingProtectionException>() + + whenever(exception.url).thenReturn("https://example.com/") + doNothing().`when`(storage).remove(anyString()) + + storage.remove(exception) + verify(storage).remove(anyString()) + } + + @Test + fun `WHEN removing an exception by contentPermission THEN remove it from the gecko storage`() { + val contentPermission = mock<ContentPermission>() + val storageController = mock<StorageController>() + + whenever(runtime.storageController).thenReturn(storageController) + + storage.remove(contentPermission) + + verify(storageController).setPermission(contentPermission, VALUE_DENY) + } + + @Test + fun `WHEN removing an exception by URL THEN try to find it in the gecko store and remove it`() { + val contentPermission = + geckoContentPermission("https://example.com/", PERMISSION_TRACKING, VALUE_ALLOW) + val storageController = mock<StorageController>() + val geckoResult = GeckoResult<List<ContentPermission>>() + + whenever(runtime.storageController).thenReturn(storageController) + whenever(runtime.storageController.allPermissions).thenReturn(geckoResult) + + storage.remove("https://example.com/") + + geckoResult.complete(listOf(contentPermission)) + shadowOf(getMainLooper()).idle() + + verify(storageController).setPermission(contentPermission, VALUE_DENY) + } + + @Test + fun `WHEN removing all exceptions THEN remove all the exceptions in the gecko store`() { + val mockGeckoSession = mock<GeckoSession>() + val session = GeckoEngineSession(runtime, geckoSessionProvider = { mockGeckoSession }) + + val contentPermission = + geckoContentPermission("https://example.com/", PERMISSION_TRACKING, VALUE_ALLOW) + val storageController = mock<StorageController>() + val geckoResult = GeckoResult<List<ContentPermission>>() + var excludedOnTrackingProtection = true + + session.register( + object : EngineSession.Observer { + override fun onExcludedOnTrackingProtectionChange(excluded: Boolean) { + excludedOnTrackingProtection = excluded + } + }, + ) + + whenever(runtime.storageController).thenReturn(storageController) + whenever(runtime.storageController.allPermissions).thenReturn(geckoResult) + + storage.removeAll(listOf(session)) + + geckoResult.complete(listOf(contentPermission)) + shadowOf(getMainLooper()).idle() + + verify(storageController).setPermission(contentPermission, VALUE_DENY) + assertFalse(excludedOnTrackingProtection) + } + + @Test + fun `WHEN querying all exceptions THEN all the exceptions in the gecko store should be fetched`() { + val contentPermission = + geckoContentPermission("https://example.com/", PERMISSION_TRACKING, VALUE_ALLOW) + val storageController = mock<StorageController>() + val geckoResult = GeckoResult<List<ContentPermission>>() + var exceptionList: List<TrackingProtectionException>? = null + + whenever(runtime.storageController).thenReturn(storageController) + whenever(runtime.storageController.allPermissions).thenReturn(geckoResult) + + storage.fetchAll { exceptions -> + exceptionList = exceptions + } + + geckoResult.complete(listOf(contentPermission)) + shadowOf(getMainLooper()).idle() + + assertTrue(exceptionList!!.isNotEmpty()) + val exception = exceptionList!!.first() as GeckoTrackingProtectionException + + assertEquals("https://example.com/", exception.url) + assertEquals(contentPermission, exception.contentPermission) + } + + @Test + fun `WHEN checking if exception is on the exception list THEN the exception is found in the storage`() { + val session = mock<GeckoEngineSession>() + val mockGeckoSession = mock<GeckoSession>() + var containsException = false + val contentPermission = + geckoContentPermission("https://example.com/", PERMISSION_TRACKING, VALUE_ALLOW) + val storageController = mock<StorageController>() + val geckoResult = GeckoResult<List<ContentPermission>>() + + whenever(runtime.storageController).thenReturn(storageController) + whenever(runtime.storageController.allPermissions).thenReturn(geckoResult) + + whenever(session.currentUrl).thenReturn("https://example.com/") + whenever(session.geckoSession).thenReturn(mockGeckoSession) + + storage.contains(session) { contains -> + containsException = contains + } + + geckoResult.complete(listOf(contentPermission)) + shadowOf(getMainLooper()).idle() + + assertTrue(containsException) + + whenever(session.currentUrl).thenReturn("") + + storage.contains(session) { contains -> + containsException = contains + } + + assertFalse(containsException) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoWebExtensionExceptionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoWebExtensionExceptionTest.kt new file mode 100644 index 0000000000..0af4abd95e --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoWebExtensionExceptionTest.kt @@ -0,0 +1,116 @@ +/* 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.browser.engine.gecko + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.webextension.GeckoWebExtensionException +import mozilla.components.concept.engine.webextension.WebExtensionInstallException +import mozilla.components.support.test.mock +import mozilla.components.test.ReflectionUtils +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.WebExtension +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_BLOCKLISTED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_INCOMPATIBLE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_UNSUPPORTED_ADDON_TYPE +import org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED + +@RunWith(AndroidJUnit4::class) +class GeckoWebExtensionExceptionTest { + + @Test + fun `Handles an user cancelled exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField(geckoException, "code", ERROR_USER_CANCELED) + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.UserCancelled) + } + + @Test + fun `Handles a generic exception`() { + val geckoException = Exception() + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is GeckoWebExtensionException) + } + + @Test + fun `Handles a blocklisted exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField(geckoException, "code", ERROR_BLOCKLISTED) + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.Blocklisted) + } + + @Test + fun `Handles a CorruptFile exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField(geckoException, "code", ERROR_CORRUPT_FILE) + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.CorruptFile) + } + + @Test + fun `Handles a NetworkFailure exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField(geckoException, "code", ERROR_NETWORK_FAILURE) + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.NetworkFailure) + } + + @Test + fun `Handles an NotSigned exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField( + geckoException, + "code", + ERROR_SIGNEDSTATE_REQUIRED, + ) + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.NotSigned) + } + + @Test + fun `Handles an Incompatible exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField( + geckoException, + "code", + ERROR_INCOMPATIBLE, + ) + val webExtensionException = + GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.Incompatible) + } + + @Test + fun `Handles an UnsupportedAddonType exception`() { + val geckoException = mock<WebExtension.InstallException>() + ReflectionUtils.setField( + geckoException, + "code", + ERROR_UNSUPPORTED_ADDON_TYPE, + ) + val webExtensionException = GeckoWebExtensionException.createWebExtensionException(geckoException) + + assertTrue(webExtensionException is WebExtensionInstallException.UnsupportedAddonType) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/NestedGeckoViewTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/NestedGeckoViewTest.kt new file mode 100644 index 0000000000..9e956c4566 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/NestedGeckoViewTest.kt @@ -0,0 +1,580 @@ +/* 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.browser.engine.gecko + +import android.app.Activity +import android.content.Context +import android.os.Looper.getMainLooper +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL +import android.view.MotionEvent.ACTION_DOWN +import android.view.MotionEvent.ACTION_MOVE +import android.view.MotionEvent.ACTION_UP +import android.widget.FrameLayout +import androidx.core.view.NestedScrollingChildHelper +import androidx.core.view.ViewCompat.SCROLL_AXIS_VERTICAL +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.engine.INPUT_HANDLING_UNKNOWN +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.mockMotionEvent +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.PanZoomController.INPUT_RESULT_HANDLED +import org.mozilla.geckoview.PanZoomController.INPUT_RESULT_HANDLED_CONTENT +import org.mozilla.geckoview.PanZoomController.InputResultDetail +import org.mozilla.geckoview.PanZoomController.OVERSCROLL_FLAG_HORIZONTAL +import org.mozilla.geckoview.PanZoomController.OVERSCROLL_FLAG_NONE +import org.mozilla.geckoview.PanZoomController.OVERSCROLL_FLAG_VERTICAL +import org.mozilla.geckoview.PanZoomController.SCROLLABLE_FLAG_BOTTOM +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class NestedGeckoViewTest { + + private val context: Context + get() = buildActivity(Activity::class.java).get() + + @Test + fun `NestedGeckoView must delegate NestedScrollingChild implementation to childHelper`() { + val nestedWebView = NestedGeckoView(context) + val mockChildHelper: NestedScrollingChildHelper = mock() + nestedWebView.childHelper = mockChildHelper + + doReturn(true).`when`(mockChildHelper).isNestedScrollingEnabled + doReturn(true).`when`(mockChildHelper).hasNestedScrollingParent() + + nestedWebView.isNestedScrollingEnabled = true + verify(mockChildHelper).isNestedScrollingEnabled = true + + assertTrue(nestedWebView.isNestedScrollingEnabled) + verify(mockChildHelper).isNestedScrollingEnabled + + nestedWebView.startNestedScroll(1) + verify(mockChildHelper).startNestedScroll(1) + + nestedWebView.stopNestedScroll() + verify(mockChildHelper).stopNestedScroll() + + assertTrue(nestedWebView.hasNestedScrollingParent()) + verify(mockChildHelper).hasNestedScrollingParent() + + nestedWebView.dispatchNestedScroll(0, 0, 0, 0, null) + verify(mockChildHelper).dispatchNestedScroll(0, 0, 0, 0, null) + + nestedWebView.dispatchNestedPreScroll(0, 0, null, null) + verify(mockChildHelper).dispatchNestedPreScroll(0, 0, null, null) + + nestedWebView.dispatchNestedFling(0f, 0f, true) + verify(mockChildHelper).dispatchNestedFling(0f, 0f, true) + + nestedWebView.dispatchNestedPreFling(0f, 0f) + verify(mockChildHelper).dispatchNestedPreFling(0f, 0f) + } + + @Test + fun `verify onTouchEvent when ACTION_DOWN`() { + val nestedWebView = spy(NestedGeckoView(context)) + val mockChildHelper: NestedScrollingChildHelper = mock() + val downEvent = mockMotionEvent(ACTION_DOWN) + val eventCaptor = argumentCaptor<MotionEvent>() + nestedWebView.childHelper = mockChildHelper + + nestedWebView.onTouchEvent(downEvent) + shadowOf(getMainLooper()).idle() + + // We pass a deep copy to `updateInputResult`. + // Can't easily check for equality, `eventTime` should be good enough. + verify(nestedWebView).updateInputResult(eventCaptor.capture()) + assertEquals(downEvent.eventTime, eventCaptor.value.eventTime) + verify(mockChildHelper).startNestedScroll(SCROLL_AXIS_VERTICAL) + verify(nestedWebView, times(0)).callSuperOnTouchEvent(any()) + } + + @Test + fun `verify onTouchEvent when ACTION_MOVE`() { + val nestedWebView = spy(NestedGeckoView(context)) + val mockChildHelper: NestedScrollingChildHelper = mock() + nestedWebView.childHelper = mockChildHelper + nestedWebView.inputResultDetail = nestedWebView.inputResultDetail.copy(INPUT_RESULT_HANDLED) + doReturn(true).`when`(nestedWebView).callSuperOnTouchEvent(any()) + + doReturn(true).`when`(mockChildHelper).dispatchNestedPreScroll( + anyInt(), + anyInt(), + any(), + any(), + ) + + nestedWebView.scrollOffset[0] = 1 + nestedWebView.scrollOffset[1] = 2 + + nestedWebView.onTouchEvent(mockMotionEvent(ACTION_DOWN, y = 0f)) + nestedWebView.onTouchEvent(mockMotionEvent(ACTION_MOVE, y = 5f)) + assertEquals(2, nestedWebView.nestedOffsetY) + assertEquals(3, nestedWebView.lastY) + + doReturn(true).`when`(mockChildHelper).dispatchNestedScroll( + anyInt(), + anyInt(), + anyInt(), + anyInt(), + any(), + ) + + nestedWebView.onTouchEvent(mockMotionEvent(ACTION_MOVE, y = 10f)) + assertEquals(6, nestedWebView.nestedOffsetY) + assertEquals(6, nestedWebView.lastY) + + // onTouchEventForResult should be also called for ACTION_MOVE + verify(nestedWebView, times(3)).updateInputResult(any()) + } + + @Test + fun `verify onTouchEvent when ACTION_UP or ACTION_CANCEL`() { + val nestedWebView = spy(NestedGeckoView(context)) + val initialInputResultDetail = nestedWebView.inputResultDetail.copy(INPUT_RESULT_HANDLED) + nestedWebView.inputResultDetail = initialInputResultDetail + val mockChildHelper: NestedScrollingChildHelper = mock() + nestedWebView.childHelper = mockChildHelper + doReturn(true).`when`(nestedWebView).callSuperOnTouchEvent(any()) + + assertEquals(INPUT_RESULT_HANDLED, nestedWebView.inputResultDetail.inputResult) + nestedWebView.onTouchEvent(mockMotionEvent(ACTION_UP)) + verify(mockChildHelper).stopNestedScroll() + // ACTION_UP should reset nestedWebView.inputResultDetail. + assertNotEquals(initialInputResultDetail, nestedWebView.inputResultDetail) + assertEquals(INPUT_HANDLING_UNKNOWN, nestedWebView.inputResultDetail.inputResult) + + nestedWebView.inputResultDetail = initialInputResultDetail + assertEquals(INPUT_RESULT_HANDLED, nestedWebView.inputResultDetail.inputResult) + nestedWebView.onTouchEvent(mockMotionEvent(ACTION_CANCEL)) + verify(mockChildHelper, times(2)).stopNestedScroll() + // ACTION_CANCEL should reset nestedWebView.inputResultDetail. + assertNotEquals(initialInputResultDetail, nestedWebView.inputResultDetail) + assertEquals(INPUT_HANDLING_UNKNOWN, nestedWebView.inputResultDetail.inputResult) + + // onTouchEventForResult should never be called for ACTION_UP or ACTION_CANCEL + verify(nestedWebView, times(0)).updateInputResult(any()) + } + + @Test + fun `requestDisallowInterceptTouchEvent doesn't pass touch events to parents until engineView responds`() { + var viewParentInterceptCounter = 0 + val result: GeckoResult<InputResultDetail> = GeckoResult() + val nestedWebView = object : NestedGeckoView(context) { + init { + // We need to make the view a non-zero size so that the touch events hit it. + left = 0 + top = 0 + right = 5 + bottom = 5 + } + + override fun superOnTouchEventForDetailResult(event: MotionEvent) = result + } + val viewParent = object : FrameLayout(context) { + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + viewParentInterceptCounter++ + return super.onInterceptTouchEvent(ev) + } + }.apply { + addView(nestedWebView) + } + + // Down action enables requestDisallowInterceptTouchEvent (and starts a gesture). + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_DOWN, y = 0f)) + + // `onInterceptTouchEvent` will be triggered the first time because it's the first pass. + assertEquals(1, viewParentInterceptCounter) + + // Move action assert that onInterceptTouchEvent calls continue to be ignored. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 1f)) + + assertEquals(1, viewParentInterceptCounter) + + // Simulate a `handled` response from the APZ GeckoEngineView API. + val inputResultMock = mock<InputResultDetail>().apply { + whenever(handledResult()).thenReturn(INPUT_RESULT_HANDLED) + whenever(scrollableDirections()).thenReturn(SCROLLABLE_FLAG_BOTTOM) + whenever(overscrollDirections()).thenReturn(OVERSCROLL_FLAG_VERTICAL) + } + result.complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + // Move action no longer ignores onInterceptTouchEvent calls. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 2f)) + + assertEquals(2, viewParentInterceptCounter) + + // Complete the gesture by finishing with an up action. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_UP)) + + assertEquals(3, viewParentInterceptCounter) + } + + @Test + fun `touch events are never intercepted once after scrolled down`() { + var viewParentInterceptCounter = 0 + val result: GeckoResult<InputResultDetail> = GeckoResult() + val nestedWebView = object : NestedGeckoView(context) { + init { + // We need to make the view a non-zero size so that the touch events hit it. + left = 0 + top = 0 + right = 5 + bottom = 5 + } + + override fun superOnTouchEventForDetailResult(event: MotionEvent): GeckoResult<InputResultDetail> = result + } + + val viewParent = object : FrameLayout(context) { + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + viewParentInterceptCounter++ + return super.onInterceptTouchEvent(ev) + } + }.apply { + addView(nestedWebView) + } + + // Down action enables requestDisallowInterceptTouchEvent (and starts a gesture). + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_DOWN, y = 4f)) + + // `onInterceptTouchEvent` will be triggered the first time because it's the first pass. + assertEquals(1, viewParentInterceptCounter) + + // Move action to scroll down assert that onInterceptTouchEvent calls continue to be ignored. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 3f)) + + assertEquals(1, viewParentInterceptCounter) + + // Simulate a `handled` response from the APZ GeckoEngineView API. + val inputResultMock = mock<InputResultDetail>().apply { + whenever(handledResult()).thenReturn(INPUT_RESULT_HANDLED) + whenever(scrollableDirections()).thenReturn(SCROLLABLE_FLAG_BOTTOM) + whenever(overscrollDirections()).thenReturn(OVERSCROLL_FLAG_VERTICAL or OVERSCROLL_FLAG_HORIZONTAL) + } + result.complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + // Move action to scroll down further that onInterceptTouchEvent calls continue to be ignored. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 2f)) + + assertEquals(1, viewParentInterceptCounter) + + // Complete the gesture by finishing with an up action. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_UP)) + + assertEquals(1, viewParentInterceptCounter) + } + + @Suppress("UNUSED_CHANGED_VALUE") + @Test + fun `GIVEN page is not at its top touch events WHEN user pulls page up THEN parent doesn't intercept the gesture`() { + var viewParentInterceptCounter = 0 + val geckoResults = mutableListOf<GeckoResult<InputResultDetail>>() + var resultCurrentIndex = 0 + val nestedWebView = object : NestedGeckoView(context) { + init { + // We need to make the view a non-zero size so that the touch events hit it. + left = 0 + top = 0 + right = 5 + bottom = 5 + } + + override fun superOnTouchEventForDetailResult(event: MotionEvent): GeckoResult<InputResultDetail> { + return GeckoResult<InputResultDetail>().also(geckoResults::add) + } + } + + val viewParent = object : FrameLayout(context) { + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + viewParentInterceptCounter++ + return super.onInterceptTouchEvent(ev) + } + }.apply { + addView(nestedWebView) + } + + // Down action enables requestDisallowInterceptTouchEvent (and starts a gesture). + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_DOWN, y = 1f)) + + // `onInterceptTouchEvent` will be triggered the first time because it's the first pass. + assertEquals(1, viewParentInterceptCounter) + + // Move action to scroll down assert that onInterceptTouchEvent calls continue to be ignored. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 2f)) + + // Simulate a `handled` response from the APZ GeckoEngineView API. + val inputResultMock = mock<InputResultDetail>().apply { + whenever(handledResult()).thenReturn(INPUT_RESULT_HANDLED) + whenever(scrollableDirections()).thenReturn(SCROLLABLE_FLAG_BOTTOM) + whenever(overscrollDirections()).thenReturn(OVERSCROLL_FLAG_NONE) + } + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + assertEquals(1, viewParentInterceptCounter) + + // Move action to scroll down further that onInterceptTouchEvent calls continue to be ignored. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 3f)) + + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + assertEquals(1, viewParentInterceptCounter) + + // Complete the gesture by finishing with an up action. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_UP)) + + assertEquals(1, viewParentInterceptCounter) + } + + @Suppress("UNUSED_CHANGED_VALUE") + @Test + fun `verify parent don't intercept touch when gesture started with an downward scroll on a page`() { + var viewParentInterceptCounter = 0 + val geckoResults = mutableListOf<GeckoResult<InputResultDetail>>() + var resultCurrentIndex = 0 + var disallowInterceptTouchEventValue = false + val nestedWebView = object : NestedGeckoView(context) { + init { + // We need to make the view a non-zero size so that the touch events hit it. + left = 0 + top = 0 + right = 5 + bottom = 5 + } + + override fun superOnTouchEventForDetailResult(event: MotionEvent): GeckoResult<InputResultDetail> { + return GeckoResult<InputResultDetail>().also(geckoResults::add) + } + } + + val viewParent = object : FrameLayout(context) { + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + viewParentInterceptCounter++ + return super.onInterceptTouchEvent(ev) + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + disallowInterceptTouchEventValue = disallowIntercept + super.requestDisallowInterceptTouchEvent(disallowIntercept) + } + }.apply { + addView(nestedWebView) + } + + // Simulate a `handled` response from the APZ GeckoEngineView API. + val inputResultMock = generateOverscrollInputResultMock(INPUT_RESULT_HANDLED) + + // Down action enables requestDisallowInterceptTouchEvent (and starts a gesture). + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_DOWN, y = 2f)) + + inputResultMock.hashCode() + geckoResults[resultCurrentIndex++].complete(inputResultMock) + + // `onInterceptTouchEvent` will be triggered the first time because it's the first pass. + assertEquals(1, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 2f)) + + // Make sure the size of results hasn't increased, meaning we don't pass the event to GeckoView to process + assertEquals(1, geckoResults.size) + + // Make sure the parent couldn't intercept the touch event + assertEquals(1, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 3f)) + + // Make sure the size of results hasn't increased, meaning we don't pass the event to GeckoView to process + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + // Parent should now be allowed to intercept the next event, this one was not intercepted + assertEquals(1, viewParentInterceptCounter) + assertFalse(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are now reaching the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 4f)) + + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + assertEquals(2, viewParentInterceptCounter) + assertFalse(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls still reaching the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 4f)) + + geckoResults[resultCurrentIndex++].complete(generateOverscrollInputResultMock(INPUT_RESULT_HANDLED_CONTENT)) + shadowOf(getMainLooper()).idle() + + assertEquals(3, viewParentInterceptCounter) + assertFalse(disallowInterceptTouchEventValue) + + // Move action to scroll downwards, assert that onInterceptTouchEvent calls don't reach the parent any more. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 1f)) + + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + assertEquals(4, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 4f)) + + assertEquals(5, resultCurrentIndex) + assertEquals(4, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 5f)) + + assertEquals(5, resultCurrentIndex) + assertEquals(4, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Complete the gesture by finishing with an up action. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_UP)) + assertEquals(4, viewParentInterceptCounter) + } + + @Suppress("UNUSED_CHANGED_VALUE") + @Test + fun `verify parent don't intercept touch when gesture started with an downward scroll on a page2`() { + var viewParentInterceptCounter = 0 + val geckoResults = mutableListOf<GeckoResult<InputResultDetail>>() + var resultCurrentIndex = 0 + var disallowInterceptTouchEventValue = false + val nestedWebView = object : NestedGeckoView(context) { + init { + // We need to make the view a non-zero size so that the touch events hit it. + left = 0 + top = 0 + right = 5 + bottom = 5 + } + + override fun superOnTouchEventForDetailResult(event: MotionEvent): GeckoResult<InputResultDetail> { + return GeckoResult<InputResultDetail>().also(geckoResults::add) + } + } + + val viewParent = object : FrameLayout(context) { + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + viewParentInterceptCounter++ + return super.onInterceptTouchEvent(ev) + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + disallowInterceptTouchEventValue = disallowIntercept + super.requestDisallowInterceptTouchEvent(disallowIntercept) + } + }.apply { + addView(nestedWebView) + } + + // Simulate a `handled` response from the APZ GeckoEngineView API. + val inputResultMock = mock<InputResultDetail>().apply { + whenever(handledResult()).thenReturn(INPUT_RESULT_HANDLED) + whenever(scrollableDirections()).thenReturn(SCROLLABLE_FLAG_BOTTOM) + whenever(overscrollDirections()).thenReturn(OVERSCROLL_FLAG_VERTICAL or OVERSCROLL_FLAG_HORIZONTAL) + } + + // Down action enables requestDisallowInterceptTouchEvent (and starts a gesture). + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_DOWN, y = 2f)) + + inputResultMock.hashCode() + geckoResults[resultCurrentIndex++].complete(inputResultMock) + + // `onInterceptTouchEvent` will be triggered the first time because it's the first pass. + assertEquals(1, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 2f)) + + // Make sure the size of results hasn't increased, meaning we don't pass the event to GeckoView to process + assertEquals(1, geckoResults.size) + + // Make sure the parent couldn't intercept the touch event + assertEquals(1, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 3f)) + + // Make sure the size of results hasn't increased, meaning we don't pass the event to GeckoView to process + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + // Parent should now be allowed to intercept the next event, this one was not intercepted + assertEquals(1, viewParentInterceptCounter) + assertFalse(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are now reaching the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 4f)) + + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + assertEquals(2, viewParentInterceptCounter) + assertFalse(disallowInterceptTouchEventValue) + + // Move action to scroll downwards, assert that onInterceptTouchEvent calls don't reach the parent any more. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 1f)) + + geckoResults[resultCurrentIndex++].complete(inputResultMock) + shadowOf(getMainLooper()).idle() + + assertEquals(3, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 4f)) + + assertEquals(4, resultCurrentIndex) + assertEquals(3, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Move action to scroll upwards, assert that onInterceptTouchEvent calls are still ignored by the parent. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_MOVE, y = 5f)) + + assertEquals(4, resultCurrentIndex) + assertEquals(3, viewParentInterceptCounter) + assertTrue(disallowInterceptTouchEventValue) + + // Complete the gesture by finishing with an up action. + viewParent.dispatchTouchEvent(mockMotionEvent(ACTION_UP)) + assertEquals(3, viewParentInterceptCounter) + } + + private fun generateOverscrollInputResultMock(inputResult: Int) = mock<InputResultDetail>().apply { + whenever(handledResult()).thenReturn(inputResult) + whenever(scrollableDirections()).thenReturn(SCROLLABLE_FLAG_BOTTOM) + whenever(overscrollDirections()).thenReturn(OVERSCROLL_FLAG_VERTICAL or OVERSCROLL_FLAG_HORIZONTAL) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegateTest.kt new file mode 100644 index 0000000000..461b0f4df5 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegateTest.kt @@ -0,0 +1,75 @@ +/* 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.browser.engine.gecko.activity + +import android.app.PendingIntent +import android.content.Intent +import android.content.IntentSender +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.engine.activity.ActivityDelegate +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mozilla.geckoview.GeckoResult +import java.lang.ref.WeakReference + +@RunWith(AndroidJUnit4::class) +class GeckoActivityDelegateTest { + lateinit var pendingIntent: PendingIntent + + @Before + fun setup() { + pendingIntent = mock() + `when`(pendingIntent.intentSender).thenReturn(mock()) + } + + @Test + fun `onStartActivityForResult is completed successfully`() { + val delegate: ActivityDelegate = object : ActivityDelegate { + override fun startIntentSenderForResult(intent: IntentSender, onResult: (Intent?) -> Unit) { + onResult(mock()) + } + } + + val geckoActivityDelegate = GeckoActivityDelegate(WeakReference(delegate)) + val result = geckoActivityDelegate.onStartActivityForResult(pendingIntent) + + result.accept { + assertNotNull(it) + } + } + + @Test + fun `onStartActivityForResult completes exceptionally on null response`() { + val delegate: ActivityDelegate = object : ActivityDelegate { + override fun startIntentSenderForResult(intent: IntentSender, onResult: (Intent?) -> Unit) { + onResult(null) + } + } + + val geckoActivityDelegate = GeckoActivityDelegate(WeakReference(delegate)) + val result = geckoActivityDelegate.onStartActivityForResult(pendingIntent) + + result.exceptionally { throwable -> + assertEquals("Activity for result failed.", throwable.localizedMessage) + GeckoResult.fromValue(null) + } + } + + @Test + fun `onStartActivityForResult completes exceptionally when there is no object attached to the weak reference`() { + val geckoActivityDelegate = GeckoActivityDelegate(WeakReference(null)) + val result = geckoActivityDelegate.onStartActivityForResult(pendingIntent) + + result.exceptionally { throwable -> + assertEquals("Activity for result failed; no delegate attached.", throwable.localizedMessage) + GeckoResult.fromValue(null) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegateTest.kt new file mode 100644 index 0000000000..c519110d64 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegateTest.kt @@ -0,0 +1,62 @@ +/* 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.browser.engine.gecko.activity + +import android.content.pm.ActivityInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.engine.activity.OrientationDelegate +import mozilla.components.support.test.mock +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.geckoview.AllowOrDeny + +@RunWith(AndroidJUnit4::class) +class GeckoScreenOrientationDelegateTest { + @Test + fun `GIVEN a delegate is set WHEN the orientation should be locked THEN call this on the delegate`() { + val activityDelegate = mock<OrientationDelegate>() + val geckoDelegate = GeckoScreenOrientationDelegate(activityDelegate) + + geckoDelegate.onOrientationLock(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + + verify(activityDelegate).onOrientationLock(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + } + + @Test + fun `GIVEN a delegate is set WHEN the orientation should be locked THEN return ALLOW depending on the delegate response`() { + val activityDelegate = object : OrientationDelegate { + override fun onOrientationLock(requestedOrientation: Int) = true + } + val geckoDelegate = GeckoScreenOrientationDelegate(activityDelegate) + + val result = geckoDelegate.onOrientationLock(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) + + assertTrue(result.poll(1) == AllowOrDeny.ALLOW) + } + + @Test + fun `GIVEN a delegate is set WHEN the orientation should be locked THEN return DENY depending on the delegate response`() { + val activityDelegate = object : OrientationDelegate { + override fun onOrientationLock(requestedOrientation: Int) = false + } + val geckoDelegate = GeckoScreenOrientationDelegate(activityDelegate) + + val result = geckoDelegate.onOrientationLock(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT) + + assertTrue(result.poll(1) == AllowOrDeny.DENY) + } + + @Test + fun `GIVEN a delegate is set WHEN the orientation should be unlocked THEN call this on the delegate`() { + val activityDelegate = mock<OrientationDelegate>() + val geckoDelegate = GeckoScreenOrientationDelegate(activityDelegate) + + geckoDelegate.onOrientationUnlock() + + verify(activityDelegate).onOrientationUnlock() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegateTest.kt new file mode 100644 index 0000000000..4eeeea4460 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegateTest.kt @@ -0,0 +1,28 @@ +/* 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.browser.engine.gecko.activity + +import android.app.Activity +import mozilla.components.support.test.mock +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.ref.WeakReference + +class GeckoViewActivityContextDelegateTest { + + @Test + fun `getActivityContext returns the same activity as set on the delegate`() { + val mockActivity = mock<Activity>() + val activityContextDelegate = GeckoViewActivityContextDelegate(WeakReference(mockActivity)) + assertTrue(mockActivity == activityContextDelegate.activityContext) + } + + @Test + fun `getActivityContext returns null when the activity reference is null`() { + val activityContextDelegate = GeckoViewActivityContextDelegate(WeakReference(null)) + assertNull(activityContextDelegate.activityContext) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorageTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorageTest.kt new file mode 100644 index 0000000000..958553706b --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorageTest.kt @@ -0,0 +1,161 @@ +/* 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.browser.engine.gecko.cookiebanners + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingMode.DISABLED +import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.StorageController + +@ExperimentalCoroutinesApi +class GeckoCookieBannersStorageTest { + private lateinit var runtime: GeckoRuntime + private lateinit var geckoStorage: GeckoCookieBannersStorage + private lateinit var storageController: StorageController + private lateinit var reportSiteDomainsRepository: ReportSiteDomainsRepository + + @Before + fun setup() { + storageController = mock() + runtime = mock() + reportSiteDomainsRepository = mock() + + whenever(runtime.storageController).thenReturn(storageController) + + geckoStorage = spy(GeckoCookieBannersStorage(runtime, reportSiteDomainsRepository)) + } + + @Test + fun `GIVEN a cookie banner mode WHEN adding an exception THEN add an exception for the given uri and browsing mode`() = + runTest { + val uri = "https://www.mozilla.org" + + doNothing().`when`(geckoStorage) + .setGeckoException(uri = uri, mode = DISABLED, privateBrowsing = false) + + geckoStorage.addException(uri = uri, privateBrowsing = false) + + verify(geckoStorage).setGeckoException(uri, DISABLED, false) + } + + @Test + fun `GIVEN uri and browsing mode WHEN removing an exception THEN remove the exception`() = + runTest { + val uri = "https://www.mozilla.org" + + doNothing().`when`(geckoStorage).removeGeckoException(uri, false) + + geckoStorage.removeException(uri = uri, privateBrowsing = false) + + verify(geckoStorage).removeGeckoException(uri, false) + } + + @Test + fun `GIVEN uri and browsing mode WHEN querying an exception THEN return the matching exception`() = + runTest { + val uri = "https://www.mozilla.org" + + doReturn(REJECT_OR_ACCEPT_ALL).`when`(geckoStorage) + .queryExceptionInGecko(uri = uri, privateBrowsing = false) + + val result = geckoStorage.findExceptionFor(uri = uri, privateBrowsing = false) + assertEquals(REJECT_OR_ACCEPT_ALL, result) + } + + @Test + fun `GIVEN error WHEN querying an exception THEN return null`() = + runTest { + val uri = "https://www.mozilla.org" + + doReturn(null).`when`(geckoStorage) + .queryExceptionInGecko(uri = uri, privateBrowsing = false) + + val result = geckoStorage.findExceptionFor(uri = uri, privateBrowsing = false) + assertNull(result) + } + + @Test + fun `GIVEN uri and browsing mode WHEN checking for an exception THEN indicate if it has exceptions`() = + runTest { + val uri = "https://www.mozilla.org" + + doReturn(REJECT_OR_ACCEPT_ALL).`when`(geckoStorage) + .queryExceptionInGecko(uri = uri, privateBrowsing = false) + + var result = geckoStorage.hasException(uri = uri, privateBrowsing = false) + + assertFalse(result!!) + + Mockito.reset(geckoStorage) + + doReturn(DISABLED).`when`(geckoStorage) + .queryExceptionInGecko(uri = uri, privateBrowsing = false) + + result = geckoStorage.hasException(uri = uri, privateBrowsing = false) + + assertTrue(result!!) + } + + @Test + fun `GIVEN an error WHEN checking for an exception THEN indicate if that an error happened`() = + runTest { + val uri = "https://www.mozilla.org" + + doReturn(null).`when`(geckoStorage) + .queryExceptionInGecko(uri = uri, privateBrowsing = false) + + val result = geckoStorage.hasException(uri = uri, privateBrowsing = false) + + assertNull(result) + } + + @Test + fun `GIVEN a cookie banner mode WHEN adding a persistent exception in private mode THEN add a persistent exception for the given uri in private browsing mode`() = + runTest { + val uri = "https://www.mozilla.org" + + doNothing().`when`(geckoStorage) + .setPersistentPrivateGeckoException(uri = uri, mode = DISABLED) + + geckoStorage.addPersistentExceptionInPrivateMode(uri = uri) + + verify(geckoStorage).setPersistentPrivateGeckoException(uri, DISABLED) + } + + @Test + fun `GIVEN site domain url WHEN checking if site domain is reported THEN the report site domain repository gets called`() = + runTest { + val reportSiteDomainUrl = "mozilla.org" + + geckoStorage.isSiteDomainReported(reportSiteDomainUrl) + + verify(reportSiteDomainsRepository).isSiteDomainReported(reportSiteDomainUrl) + } + + @Test + fun `GIVEN site domain url WHEN saving a site domain THEN the save method from repository should get called`() = + runTest { + val reportSiteDomainUrl = "mozilla.org" + + geckoStorage.saveSiteDomain(reportSiteDomainUrl) + + verify(reportSiteDomainsRepository).saveSiteDomain(reportSiteDomainUrl) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepositoryTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepositoryTest.kt new file mode 100644 index 0000000000..dbc809ef2c --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepositoryTest.kt @@ -0,0 +1,74 @@ +/* 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.browser.engine.gecko.cookiebanners + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.robolectric.testContext +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ReportSiteDomainsRepositoryTest { + + companion object { + const val TEST_DATASTORE_NAME = "test_data_store" + } + + private lateinit var testDataStore: DataStore<Preferences> + + private lateinit var reportSiteDomainsRepository: ReportSiteDomainsRepository + + @Before + fun setUp() { + testDataStore = PreferenceDataStoreFactory.create( + produceFile = { testContext.preferencesDataStoreFile(TEST_DATASTORE_NAME) }, + ) + reportSiteDomainsRepository = ReportSiteDomainsRepository(testDataStore) + } + + @After + fun cleanUp() = runTest { testDataStore.edit { it.clear() } } + + @Test + fun `GIVEN site domain url WHEN site domain url is not saved THEN is side domain reported return false`() = + runTest { + assertFalse(reportSiteDomainsRepository.isSiteDomainReported("mozilla.org")) + } + + @Test + fun `GIVEN site domain url WHEN site domain url is saved THEN is side domain reported return true`() = + runTest { + val siteDomainReported = "mozilla.org" + + reportSiteDomainsRepository.saveSiteDomain(siteDomainReported) + + assertTrue(reportSiteDomainsRepository.isSiteDomainReported(siteDomainReported)) + } + + @Test + fun `GIVEN site domain urls WHEN site domain urls are saved THEN is side domain reported return true for each one`() = + runTest { + val mozillaSiteDomainReported = "mozilla.org" + val youtubeSiteDomainReported = "youtube.com" + + reportSiteDomainsRepository.saveSiteDomain(mozillaSiteDomainReported) + reportSiteDomainsRepository.saveSiteDomain(youtubeSiteDomainReported) + + assertTrue(reportSiteDomainsRepository.isSiteDomainReported(mozillaSiteDomainReported)) + assertTrue(reportSiteDomainsRepository.isSiteDomainReported(youtubeSiteDomainReported)) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicyKtTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicyKtTest.kt new file mode 100644 index 0000000000..e29cfcb61d --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicyKtTest.kt @@ -0,0 +1,81 @@ +/* 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.browser.engine.gecko.ext + +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.geckoview.ContentBlocking.EtpLevel + +class TrackingProtectionPolicyKtTest { + + private val defaultSafeBrowsing = arrayOf(EngineSession.SafeBrowsingPolicy.RECOMMENDED) + + @Test + fun `transform the policy to a GeckoView ContentBlockingSetting`() { + val policy = TrackingProtectionPolicy.recommended() + val setting = policy.toContentBlockingSetting() + val cookieBannerSetting = EngineSession.CookieBannerHandlingMode.REJECT_OR_ACCEPT_ALL + val cookieBannerSettingPrivateBrowsing = EngineSession.CookieBannerHandlingMode.DISABLED + + assertEquals(policy.getEtpLevel(), setting.enhancedTrackingProtectionLevel) + assertEquals(policy.getAntiTrackingPolicy(), setting.antiTrackingCategories) + assertEquals(policy.cookiePolicy.id, setting.cookieBehavior) + assertEquals(policy.cookiePolicyPrivateMode.id, setting.cookieBehavior) + assertEquals(defaultSafeBrowsing.sumOf { it.id }, setting.safeBrowsingCategories) + assertEquals(setting.strictSocialTrackingProtection, policy.strictSocialTrackingProtection) + assertEquals(setting.cookiePurging, policy.cookiePurging) + assertEquals(EngineSession.CookieBannerHandlingMode.DISABLED.mode, setting.cookieBannerMode) + assertEquals(EngineSession.CookieBannerHandlingMode.REJECT_ALL.mode, setting.cookieBannerModePrivateBrowsing) + assertFalse(setting.cookieBannerDetectOnlyMode) + assertFalse(setting.queryParameterStrippingEnabled) + assertFalse(setting.queryParameterStrippingPrivateBrowsingEnabled) + assertEquals("", setting.queryParameterStrippingAllowList[0]) + assertEquals("", setting.queryParameterStrippingStripList[0]) + + val policyWithSafeBrowsing = + TrackingProtectionPolicy.recommended().toContentBlockingSetting( + safeBrowsingPolicy = emptyArray(), + cookieBannerHandlingMode = cookieBannerSetting, + cookieBannerHandlingModePrivateBrowsing = cookieBannerSettingPrivateBrowsing, + cookieBannerHandlingDetectOnlyMode = true, + cookieBannerGlobalRulesEnabled = true, + cookieBannerGlobalRulesSubFramesEnabled = true, + queryParameterStripping = true, + queryParameterStrippingPrivateBrowsing = true, + queryParameterStrippingAllowList = "AllowList", + queryParameterStrippingStripList = "StripList", + ) + assertEquals(0, policyWithSafeBrowsing.safeBrowsingCategories) + assertEquals(cookieBannerSetting.mode, policyWithSafeBrowsing.cookieBannerMode) + assertEquals(cookieBannerSettingPrivateBrowsing.mode, policyWithSafeBrowsing.cookieBannerModePrivateBrowsing) + assertTrue(policyWithSafeBrowsing.cookieBannerDetectOnlyMode) + assertTrue(policyWithSafeBrowsing.cookieBannerGlobalRulesEnabled) + assertTrue(policyWithSafeBrowsing.cookieBannerGlobalRulesSubFramesEnabled) + assertTrue(policyWithSafeBrowsing.queryParameterStrippingEnabled) + assertTrue(policyWithSafeBrowsing.queryParameterStrippingPrivateBrowsingEnabled) + assertEquals("AllowList", policyWithSafeBrowsing.queryParameterStrippingAllowList[0]) + assertEquals("StripList", policyWithSafeBrowsing.queryParameterStrippingStripList[0]) + } + + @Test + fun `getEtpLevel is always Strict unless None`() { + assertEquals(EtpLevel.STRICT, TrackingProtectionPolicy.recommended().getEtpLevel()) + assertEquals(EtpLevel.STRICT, TrackingProtectionPolicy.strict().getEtpLevel()) + assertEquals(EtpLevel.NONE, TrackingProtectionPolicy.none().getEtpLevel()) + } + + @Test + fun `getStrictSocialTrackingProtection is true if category is STRICT`() { + val recommendedPolicy = TrackingProtectionPolicy.recommended() + val strictPolicy = TrackingProtectionPolicy.strict() + + assertFalse(recommendedPolicy.toContentBlockingSetting().strictSocialTrackingProtection) + assertTrue(strictPolicy.toContentBlockingSetting().strictSocialTrackingProtection) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchUnitTestCases.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchUnitTestCases.kt new file mode 100644 index 0000000000..3a889550ed --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchUnitTestCases.kt @@ -0,0 +1,351 @@ +/* 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.browser.engine.gecko.fetch + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import mozilla.components.tooling.fetch.tests.FetchTestCases +import okhttp3.Headers.Companion.toHeaders +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoWebExecutor +import org.mozilla.geckoview.WebRequest +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.WebResponse +import java.io.IOException +import java.nio.charset.Charset +import java.util.concurrent.TimeoutException + +/** + * We can't run standard JVM unit tests for GWE. Therefore, we provide both + * instrumented tests as well as these unit tests which mock both requests + * and responses. While these tests guard our logic to map responses to our + * concept-fetch abstractions, they are not sufficient to guard the full + * functionality of [GeckoViewFetchClient]. That's why end-to-end tests are + * provided in instrumented tests. + */ +@RunWith(AndroidJUnit4::class) +class GeckoViewFetchUnitTestCases : FetchTestCases() { + + override fun createNewClient(): Client { + val client = GeckoViewFetchClient(testContext, mock()) + geckoWebExecutor?.let { client.executor = it } + return client + } + + override fun createWebServer(): MockWebServer { + return mockWebServer ?: super.createWebServer() + } + + private var geckoWebExecutor: GeckoWebExecutor? = null + private var mockWebServer: MockWebServer? = null + + @Before + fun setup() { + geckoWebExecutor = null + } + + @Test + fun clientInstance() { + assertTrue(createNewClient() is GeckoViewFetchClient) + } + + @Test + override fun get200WithDuplicatedCacheControlRequestHeaders() { + val headerMap = mapOf("Cache-Control" to "no-cache, no-store") + mockRequest(headerMap) + mockResponse(200) + + super.get200WithDuplicatedCacheControlRequestHeaders() + } + + @Test + override fun get200WithDuplicatedCacheControlResponseHeaders() { + val responseHeaderMap = mapOf( + "Cache-Control" to "no-cache, no-store", + "Content-Length" to "16", + ) + mockResponse(200, responseHeaderMap) + + super.get200WithDuplicatedCacheControlResponseHeaders() + } + + @Test + override fun get200OverridingDefaultHeaders() { + val headerMap = mapOf( + "Accept" to "text/html", + "Accept-Encoding" to "deflate", + "User-Agent" to "SuperBrowser/1.0", + "Connection" to "close", + ) + mockRequest(headerMap) + mockResponse(200) + + super.get200OverridingDefaultHeaders() + } + + @Test + override fun get200WithGzippedBody() { + val responseHeaderMap = mapOf("Content-Encoding" to "gzip") + mockRequest() + mockResponse(200, responseHeaderMap, "This is compressed") + + super.get200WithGzippedBody() + } + + @Test + override fun get200WithHeaders() { + val requestHeaders = mapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Encoding" to "gzip, deflate", + "Accept-Language" to "en-US,en;q=0.5", + "Connection" to "keep-alive", + "User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0", + ) + mockRequest(requestHeaders) + mockResponse(200) + + super.get200WithHeaders() + } + + @Test + override fun get200WithReadTimeout() { + mockRequest() + mockResponse(200) + + val geckoResult = mock<GeckoResult<*>>() + whenever(geckoResult.poll(anyLong())).thenThrow(TimeoutException::class.java) + @Suppress("UNCHECKED_CAST") + whenever(geckoWebExecutor!!.fetch(any(), anyInt())).thenReturn(geckoResult as GeckoResult<WebResponse>) + + super.get200WithReadTimeout() + } + + @Test + override fun get200WithStringBody() { + mockRequest() + mockResponse(200, body = "Hello World") + + super.get200WithStringBody() + } + + @Test + override fun get302FollowRedirects() { + mockResponse(200) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + whenever(request.redirect).thenReturn(Request.Redirect.FOLLOW) + createNewClient().fetch(request) + + verify(geckoWebExecutor)!!.fetch(any(), eq(GeckoWebExecutor.FETCH_FLAGS_NONE)) + } + + @Test + override fun get302FollowRedirectsDisabled() { + mockResponse(200) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + whenever(request.redirect).thenReturn(Request.Redirect.MANUAL) + createNewClient().fetch(request) + + verify(geckoWebExecutor)!!.fetch(any(), eq(GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS)) + } + + @Test + override fun get404WithBody() { + mockRequest() + mockResponse(404, body = "Error") + super.get404WithBody() + } + + @Test + override fun post200WithBody() { + mockRequest(method = "POST", body = "Hello World") + mockResponse(200) + super.post200WithBody() + } + + @Test + override fun put201FileUpload() { + mockRequest(method = "PUT", headerMap = mapOf("Content-Type" to "image/png"), body = "I am an image file!") + mockResponse(201, headerMap = mapOf("Location" to "/your-image.png"), body = "Thank you!") + super.put201FileUpload() + } + + @Test(expected = IOException::class) + fun pollReturningNull() { + mockResponse(200) + + val geckoResult = mock<GeckoResult<*>>() + whenever(geckoResult.poll(anyLong())).thenReturn(null) + @Suppress("UNCHECKED_CAST") + whenever(geckoWebExecutor!!.fetch(any(), anyInt())).thenReturn(geckoResult as GeckoResult<WebResponse>) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + createNewClient().fetch(request) + } + + @Test + override fun get200WithCookiePolicy() { + mockResponse(200) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + whenever(request.cookiePolicy).thenReturn(Request.CookiePolicy.OMIT) + createNewClient().fetch(request) + + verify(geckoWebExecutor)!!.fetch(any(), eq(GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS)) + } + + @Test + fun performPrivateRequest() { + mockResponse(200) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + whenever(request.private).thenReturn(true) + createNewClient().fetch(request) + + verify(geckoWebExecutor)!!.fetch(any(), eq(GeckoWebExecutor.FETCH_FLAGS_PRIVATE)) + } + + @Test + override fun get200WithContentTypeCharset() { + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + + mockResponse( + 200, + headerMap = mapOf("Content-Type" to "text/html; charset=ISO-8859-1"), + body = "ÄäÖöÜü", + charset = Charsets.ISO_8859_1, + ) + + val response = createNewClient().fetch(request) + assertEquals("ÄäÖöÜü", response.body.string()) + } + + @Test + override fun get200WithCacheControl() { + mockResponse(200) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + whenever(request.useCaches).thenReturn(false) + createNewClient().fetch(request) + + val captor = ArgumentCaptor.forClass(WebRequest::class.java) + + verify(geckoWebExecutor)!!.fetch(captor.capture(), eq(GeckoWebExecutor.FETCH_FLAGS_NONE)) + assertEquals(WebRequest.CACHE_MODE_RELOAD, captor.value.cacheMode) + } + + @Test(expected = IOException::class) + override fun getThrowsIOExceptionWhenHostNotReachable() { + val executor = mock<GeckoWebExecutor>() + whenever(executor.fetch(any(), anyInt())).thenAnswer { throw WebRequestError(0, 0) } + geckoWebExecutor = executor + + createNewClient().fetch(Request("")) + } + + @Test + fun toResponseMustReturn200ForBlobUrls() { + val builder = WebResponse.Builder("blob:https://mdn.mozillademos.org/d518464c-5075-9046-aef2-9c313214ed53").statusCode(0).build() + assertEquals(Response.SUCCESS, builder.toResponse().status) + } + + @Test + fun get200WithReferrerUrl() { + mockResponse(200) + + val request = mock<Request>() + whenever(request.url).thenReturn("https://mozilla.org") + whenever(request.method).thenReturn(Request.Method.GET) + whenever(request.referrerUrl).thenReturn("https://mozilla.org") + createNewClient().fetch(request) + + val captor = ArgumentCaptor.forClass(WebRequest::class.java) + + verify(geckoWebExecutor)!!.fetch(captor.capture(), eq(GeckoWebExecutor.FETCH_FLAGS_NONE)) + assertEquals("https://mozilla.org", captor.value.referrer) + } + + @Test + fun toResponseMustReturn200ForDataUrls() { + val builder = WebResponse.Builder("data:,Hello%2C%20World!").statusCode(0).build() + assertEquals(Response.SUCCESS, builder.toResponse().status) + } + + private fun mockRequest(headerMap: Map<String, String>? = null, body: String? = null, method: String = "GET") { + val server = mock<MockWebServer>() + whenever(server.url(any())).thenReturn(mock()) + val request = mock<RecordedRequest>() + whenever(request.method).thenReturn(method) + + headerMap?.let { + whenever(request.headers).thenReturn(headerMap.toHeaders()) + whenever(request.getHeader(any())).thenAnswer { inv -> it[inv.getArgument(0)] } + } + + body?.let { + val buffer = okio.Buffer() + buffer.write(body.toByteArray()) + whenever(request.body).thenReturn(buffer) + } + + whenever(server.takeRequest()).thenReturn(request) + mockWebServer = server + } + + private fun mockResponse( + statusCode: Int, + headerMap: Map<String, String>? = null, + body: String? = null, + charset: Charset = Charsets.UTF_8, + ) { + val executor = mock<GeckoWebExecutor>() + val builder = WebResponse.Builder("").statusCode(statusCode) + headerMap?.let { + headerMap.forEach { (k, v) -> builder.addHeader(k, v) } + } + + body?.let { + builder.body(it.byteInputStream(charset)) + } + + val response = builder.build() + + whenever(executor.fetch(any(), anyInt())).thenReturn(GeckoResult.fromValue(response)) + geckoWebExecutor = executor + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/integration/SettingUpdaterTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/integration/SettingUpdaterTest.kt new file mode 100644 index 0000000000..c07729894e --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/integration/SettingUpdaterTest.kt @@ -0,0 +1,69 @@ +/* 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.browser.engine.gecko.integration + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettingUpdaterTest { + + @Test + fun `test updateValue`() { + val subject = DummySettingUpdater("current", "new") + assertEquals("current", subject.value) + + subject.updateValue() + assertEquals("new", subject.value) + } + + @Test + fun `test enabled updates value`() { + val subject = DummySettingUpdater("current", "new") + assertEquals("current", subject.value) + + subject.enabled = true + assertEquals("new", subject.value) + + // disabling doesn't update the value. + subject.nextValue = "disabled" + subject.enabled = false + assertEquals("new", subject.value) + } + + @Test + fun `test registering and deregistering for updates`() { + val subject = DummySettingUpdater("current", "new") + assertFalse("Initialized not registering for updates", subject.registered) + + subject.updateValue() + assertFalse("updateValue not registering for updates", subject.registered) + + subject.enabled = true + assertTrue("enabled = true registering for updates", subject.registered) + + subject.enabled = false + assertFalse("enabled = false deregistering for updates", subject.registered) + } +} + +class DummySettingUpdater( + override var value: String = "", + var nextValue: String, +) : SettingUpdater<String>() { + + var registered = false + + override fun registerForUpdates() { + registered = true + } + + override fun unregisterForUpdates() { + registered = false + } + + override fun findValue() = nextValue +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegateTest.kt new file mode 100644 index 0000000000..71b9303ac9 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegateTest.kt @@ -0,0 +1,116 @@ +/* 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.browser.engine.gecko.media + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import junit.framework.TestCase.fail +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.media.RecordingDevice +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import mozilla.components.test.ReflectionUtils +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoRuntime +import java.security.InvalidParameterException +import org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice as GeckoRecordingDevice + +@RunWith(AndroidJUnit4::class) +class GeckoMediaDelegateTest { + private lateinit var runtime: GeckoRuntime + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + } + + @Test + fun `WHEN onRecordingStatusChanged is called THEN notify onRecordingStateChanged`() { + val mockSession = GeckoEngineSession(runtime) + var onRecordingWasCalled = false + val geckoRecordingDevice = createGeckoRecordingDevice( + status = GeckoRecordingDevice.Status.RECORDING, + type = GeckoRecordingDevice.Type.CAMERA, + ) + val gecko = GeckoMediaDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onRecordingStateChanged(devices: List<RecordingDevice>) { + onRecordingWasCalled = true + } + }, + ) + + gecko.onRecordingStatusChanged(mock(), arrayOf(geckoRecordingDevice)) + + assertTrue(onRecordingWasCalled) + } + + @Test + fun `GIVEN a GeckoRecordingDevice status WHEN calling toStatus THEN covert to the RecordingDevice status`() { + val geckoRecordingDevice = createGeckoRecordingDevice( + status = GeckoRecordingDevice.Status.RECORDING, + ) + val geckoInactiveDevice = createGeckoRecordingDevice( + status = GeckoRecordingDevice.Status.INACTIVE, + ) + + assertEquals(RecordingDevice.Status.RECORDING, geckoRecordingDevice.toStatus()) + assertEquals(RecordingDevice.Status.INACTIVE, geckoInactiveDevice.toStatus()) + } + + @Test + fun `GIVEN an invalid GeckoRecordingDevice status WHEN calling toStatus THEN throw an exception`() { + val geckoInvalidDevice = createGeckoRecordingDevice( + status = 12, + ) + try { + geckoInvalidDevice.toStatus() + fail() + } catch (_: InvalidParameterException) { + } + } + + @Test + fun `GIVEN a GeckoRecordingDevice type WHEN calling toType THEN covert to the RecordingDevice type`() { + val geckoCameraDevice = createGeckoRecordingDevice( + type = GeckoRecordingDevice.Type.CAMERA, + ) + val geckoMicDevice = createGeckoRecordingDevice( + type = GeckoRecordingDevice.Type.MICROPHONE, + ) + + assertEquals(RecordingDevice.Type.CAMERA, geckoCameraDevice.toType()) + assertEquals(RecordingDevice.Type.MICROPHONE, geckoMicDevice.toType()) + } + + @Test + fun `GIVEN an invalid GeckoRecordingDevice type WHEN calling toType THEN throw an exception`() { + val geckoInvalidDevice = createGeckoRecordingDevice( + type = 12, + ) + try { + geckoInvalidDevice.toType() + fail() + } catch (_: InvalidParameterException) { + } + } + + private fun createGeckoRecordingDevice( + status: Long = GeckoRecordingDevice.Status.RECORDING, + type: Long = GeckoRecordingDevice.Type.CAMERA, + ): GeckoRecordingDevice { + val device: GeckoRecordingDevice = mock() + ReflectionUtils.setField(device, "status", status) + ReflectionUtils.setField(device, "type", type) + return device + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionControllerTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionControllerTest.kt new file mode 100644 index 0000000000..433e3e3af7 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionControllerTest.kt @@ -0,0 +1,49 @@ +/* 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.browser.engine.gecko.mediasession + +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.geckoview.MediaSession as GeckoViewMediaSession + +class GeckoMediaSessionControllerTest { + @Test + fun `GeckoMediaSessionController works correctly with GeckoView MediaSession`() { + val geckoViewMediaSession: GeckoViewMediaSession = mock() + val controller = GeckoMediaSessionController(geckoViewMediaSession) + + controller.pause() + verify(geckoViewMediaSession, times(1)).pause() + + controller.stop() + verify(geckoViewMediaSession, times(1)).stop() + + controller.play() + verify(geckoViewMediaSession, times(1)).play() + + controller.seekTo(123.0, true) + verify(geckoViewMediaSession, times(1)).seekTo(123.0, true) + + controller.seekForward() + verify(geckoViewMediaSession, times(1)).seekForward() + + controller.seekBackward() + verify(geckoViewMediaSession, times(1)).seekBackward() + + controller.nextTrack() + verify(geckoViewMediaSession, times(1)).nextTrack() + + controller.previousTrack() + verify(geckoViewMediaSession, times(1)).previousTrack() + + controller.skipAd() + verify(geckoViewMediaSession, times(1)).skipAd() + + controller.muteAudio(true) + verify(geckoViewMediaSession, times(1)).muteAudio(true) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegateTest.kt new file mode 100644 index 0000000000..e68bfa9e9f --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegateTest.kt @@ -0,0 +1,219 @@ +/* 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.browser.engine.gecko.mediasession + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.mediasession.MediaSession +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.MediaSession as GeckoViewMediaSession + +@RunWith(AndroidJUnit4::class) +class GeckoMediaSessionDelegateTest { + private lateinit var runtime: GeckoRuntime + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + } + + @Test + fun `media session activated is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedController: MediaSession.Controller? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaActivated(mediaSessionController: MediaSession.Controller) { + observedController = mediaSessionController + } + }, + ) + + engineSession.geckoSession.mediaSessionDelegate!!.onActivated(mock(), geckoViewMediaSession) + + assertNotNull(observedController) + observedController!!.play() + verify(geckoViewMediaSession).play() + } + + @Test + fun `media session deactivated is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedActivated = true + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaDeactivated() { + observedActivated = false + } + }, + ) + + engineSession.geckoSession.mediaSessionDelegate!!.onDeactivated(mock(), geckoViewMediaSession) + + assertFalse(observedActivated) + } + + @Test + fun `media session metadata is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedMetadata: MediaSession.Metadata? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaMetadataChanged( + metadata: MediaSession.Metadata, + ) { + observedMetadata = metadata + } + }, + ) + + val metadata: GeckoViewMediaSession.Metadata = mock() + engineSession.geckoSession.mediaSessionDelegate!!.onMetadata(mock(), geckoViewMediaSession, metadata) + + assertNotNull(observedMetadata) + assertEquals(observedMetadata?.title, metadata.title) + assertEquals(observedMetadata?.artist, metadata.artist) + assertEquals(observedMetadata?.album, metadata.album) + assertEquals(observedMetadata?.getArtwork, metadata.artwork) + } + + @Test + fun `media session feature is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedFeature: MediaSession.Feature? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaFeatureChanged( + features: MediaSession.Feature, + ) { + observedFeature = features + } + }, + ) + + engineSession.geckoSession.mediaSessionDelegate!!.onFeatures(mock(), geckoViewMediaSession, 123) + + assertNotNull(observedFeature) + assertEquals(observedFeature, MediaSession.Feature(123)) + } + + @Test + fun `media session play state is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedPlaybackState: MediaSession.PlaybackState? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaPlaybackStateChanged( + playbackState: MediaSession.PlaybackState, + ) { + observedPlaybackState = playbackState + } + }, + ) + + engineSession.geckoSession.mediaSessionDelegate!!.onPlay(mock(), geckoViewMediaSession) + + assertNotNull(observedPlaybackState) + assertEquals(observedPlaybackState, MediaSession.PlaybackState.PLAYING) + + observedPlaybackState = null + engineSession.geckoSession.mediaSessionDelegate!!.onPause(mock(), geckoViewMediaSession) + + assertNotNull(observedPlaybackState) + assertEquals(observedPlaybackState, MediaSession.PlaybackState.PAUSED) + + observedPlaybackState = null + engineSession.geckoSession.mediaSessionDelegate!!.onStop(mock(), geckoViewMediaSession) + + assertNotNull(observedPlaybackState) + assertEquals(observedPlaybackState, MediaSession.PlaybackState.STOPPED) + } + + @Test + fun `media session position state is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedPositionState: MediaSession.PositionState? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaPositionStateChanged( + positionState: MediaSession.PositionState, + ) { + observedPositionState = positionState + } + }, + ) + + val positionState: GeckoViewMediaSession.PositionState = mock() + engineSession.geckoSession.mediaSessionDelegate!!.onPositionState(mock(), geckoViewMediaSession, positionState) + + assertNotNull(observedPositionState) + assertEquals(observedPositionState?.duration, positionState.duration) + assertEquals(observedPositionState?.position, positionState.position) + assertEquals(observedPositionState?.playbackRate, positionState.playbackRate) + } + + @Test + fun `media session fullscreen state is forwarded to observer`() { + val engineSession = GeckoEngineSession(runtime) + val geckoViewMediaSession: GeckoViewMediaSession = mock() + + var observedFullscreen: Boolean? = null + var observedElementMetadata: MediaSession.ElementMetadata? = null + + engineSession.register( + object : EngineSession.Observer { + override fun onMediaFullscreenChanged( + fullscreen: Boolean, + elementMetadata: MediaSession.ElementMetadata?, + ) { + observedFullscreen = fullscreen + observedElementMetadata = elementMetadata + } + }, + ) + + val elementMetadata: GeckoViewMediaSession.ElementMetadata = mock() + engineSession.geckoSession.mediaSessionDelegate!!.onFullscreen(mock(), geckoViewMediaSession, true, elementMetadata) + + assertNotNull(observedFullscreen) + assertNotNull(observedElementMetadata) + assertEquals(observedFullscreen, true) + assertEquals(observedElementMetadata?.source, elementMetadata.source) + assertEquals(observedElementMetadata?.duration, elementMetadata.duration) + assertEquals(observedElementMetadata?.width, elementMetadata.width) + assertEquals(observedElementMetadata?.height, elementMetadata.height) + assertEquals(observedElementMetadata?.audioTrackCount, elementMetadata.audioTrackCount) + assertEquals(observedElementMetadata?.videoTrackCount, elementMetadata.videoTrackCount) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequestTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequestTest.kt new file mode 100644 index 0000000000..2a458c9042 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequestTest.kt @@ -0,0 +1,242 @@ +/* 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.browser.engine.gecko.permission + +import android.Manifest +import mozilla.components.concept.engine.permission.Permission +import mozilla.components.support.test.mock +import mozilla.components.test.ReflectionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION + +class GeckoPermissionRequestTest { + + @Test + fun `create content permission request`() { + val uri = "https://mozilla.org" + + var request = GeckoPermissionRequest.Content(uri, PERMISSION_DESKTOP_NOTIFICATION, mock(), mock()) + assertEquals(uri, request.uri) + assertEquals(listOf(Permission.ContentNotification()), request.permissions) + + request = GeckoPermissionRequest.Content(uri, PERMISSION_GEOLOCATION, mock(), mock()) + assertEquals(uri, request.uri) + assertEquals(listOf(Permission.ContentGeoLocation()), request.permissions) + + request = GeckoPermissionRequest.Content(uri, GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE, mock(), mock()) + assertEquals(uri, request.uri) + assertEquals(listOf(Permission.ContentAutoPlayAudible()), request.permissions) + + request = GeckoPermissionRequest.Content(uri, GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE, mock(), mock()) + assertEquals(uri, request.uri) + assertEquals(listOf(Permission.ContentAutoPlayInaudible()), request.permissions) + + request = GeckoPermissionRequest.Content(uri, 1234, mock(), mock()) + assertEquals(uri, request.uri) + assertEquals(listOf(Permission.Generic("1234", "Gecko permission type = 1234")), request.permissions) + } + + @Test + fun `grant content permission request`() { + val uri = "https://mozilla.org" + val geckoResult = mock<GeckoResult<Int>>() + + val request = GeckoPermissionRequest.Content(uri, PERMISSION_GEOLOCATION, mock(), geckoResult) + + assertFalse(request.isCompleted) + + request.grant() + + verify(geckoResult).complete(VALUE_ALLOW) + assertTrue(request.isCompleted) + } + + @Test + fun `reject content permission request`() { + val uri = "https://mozilla.org" + val geckoResult = mock<GeckoResult<Int>>() + + val request = GeckoPermissionRequest.Content(uri, PERMISSION_GEOLOCATION, mock(), geckoResult) + + assertFalse(request.isCompleted) + + request.reject() + verify(geckoResult).complete(VALUE_DENY) + assertTrue(request.isCompleted) + } + + @Test + fun `create app permission request`() { + val callback: GeckoSession.PermissionDelegate.Callback = mock() + val permissions = listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + "unknown app permission", + ) + + val mappedPermissions = listOf( + Permission.AppLocationCoarse(Manifest.permission.ACCESS_COARSE_LOCATION), + Permission.AppLocationFine(Manifest.permission.ACCESS_FINE_LOCATION), + Permission.AppCamera(Manifest.permission.CAMERA), + Permission.AppAudio(Manifest.permission.RECORD_AUDIO), + Permission.Generic("unknown app permission"), + ) + + val request = GeckoPermissionRequest.App(permissions, callback) + assertEquals(mappedPermissions, request.permissions) + } + + @Test + fun `grant app permission request`() { + val callback: GeckoSession.PermissionDelegate.Callback = mock() + + val request = GeckoPermissionRequest.App(listOf(Manifest.permission.CAMERA), callback) + request.grant() + verify(callback).grant() + } + + @Test + fun `reject app permission request`() { + val callback: GeckoSession.PermissionDelegate.Callback = mock() + + val request = GeckoPermissionRequest.App(listOf(Manifest.permission.CAMERA), callback) + request.reject() + verify(callback).reject() + } + + @Test + fun `create media permission request`() { + val callback: GeckoSession.PermissionDelegate.MediaCallback = mock() + val uri = "https://mozilla.org" + + val audioMicrophone = MockMediaSource( + "audioMicrophone", + "audioMicrophone", + MediaSource.SOURCE_MICROPHONE, + MediaSource.TYPE_AUDIO, + ) + val audioCapture = MockMediaSource( + "audioCapture", + "audioCapture", + MediaSource.SOURCE_AUDIOCAPTURE, + MediaSource.TYPE_AUDIO, + ) + val audioOther = MockMediaSource( + "audioOther", + "audioOther", + MediaSource.SOURCE_OTHER, + MediaSource.TYPE_AUDIO, + ) + + val videoCamera = MockMediaSource( + "videoCamera", + "videoCamera", + MediaSource.SOURCE_CAMERA, + MediaSource.TYPE_VIDEO, + ) + val videoScreen = MockMediaSource( + "videoScreen", + "videoScreen", + MediaSource.SOURCE_SCREEN, + MediaSource.TYPE_VIDEO, + ) + val videoOther = MockMediaSource( + "videoOther", + "videoOther", + MediaSource.SOURCE_OTHER, + MediaSource.TYPE_VIDEO, + ) + + val audioSources = listOf(audioCapture, audioMicrophone, audioOther) + val videoSources = listOf(videoCamera, videoOther, videoScreen) + + val mappedPermissions = listOf( + Permission.ContentVideoCamera("videoCamera", "videoCamera"), + Permission.ContentVideoScreen("videoScreen", "videoScreen"), + Permission.ContentVideoOther("videoOther", "videoOther"), + Permission.ContentAudioMicrophone("audioMicrophone", "audioMicrophone"), + Permission.ContentAudioCapture("audioCapture", "audioCapture"), + Permission.ContentAudioOther("audioOther", "audioOther"), + ) + + val request = GeckoPermissionRequest.Media(uri, videoSources, audioSources, callback) + assertEquals(uri, request.uri) + assertEquals(mappedPermissions.size, request.permissions.size) + assertTrue(request.permissions.containsAll(mappedPermissions)) + } + + @Test + fun `grant media permission request`() { + val callback: GeckoSession.PermissionDelegate.MediaCallback = mock() + val uri = "https://mozilla.org" + + val audioMicrophone = MockMediaSource( + "audioMicrophone", + "audioMicrophone", + MediaSource.SOURCE_MICROPHONE, + MediaSource.TYPE_AUDIO, + ) + val videoCamera = MockMediaSource( + "videoCamera", + "videoCamera", + MediaSource.SOURCE_CAMERA, + MediaSource.TYPE_VIDEO, + ) + + val audioSources = listOf(audioMicrophone) + val videoSources = listOf(videoCamera) + + val request = GeckoPermissionRequest.Media(uri, videoSources, audioSources, callback) + request.grant(request.permissions) + verify(callback).grant(videoCamera, audioMicrophone) + } + + @Test + fun `reject media permission request`() { + val callback: GeckoSession.PermissionDelegate.MediaCallback = mock() + val uri = "https://mozilla.org" + + val audioMicrophone = MockMediaSource( + "audioMicrophone", + "audioMicrophone", + MediaSource.SOURCE_MICROPHONE, + MediaSource.TYPE_AUDIO, + ) + val videoCamera = MockMediaSource( + "videoCamera", + "videoCamera", + MediaSource.SOURCE_CAMERA, + MediaSource.TYPE_VIDEO, + ) + + val audioSources = listOf(audioMicrophone) + val videoSources = listOf(videoCamera) + + val request = GeckoPermissionRequest.Media(uri, videoSources, audioSources, callback) + request.reject() + verify(callback).reject() + } + + class MockMediaSource(id: String, name: String, source: Int, type: Int) : MediaSource() { + init { + ReflectionUtils.setField(this, "id", id) + ReflectionUtils.setField(this, "name", name) + ReflectionUtils.setField(this, "source", source) + ReflectionUtils.setField(this, "type", type) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorageTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorageTest.kt new file mode 100644 index 0000000000..cd996b96d7 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorageTest.kt @@ -0,0 +1,740 @@ +/* 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.browser.engine.gecko.permission + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.engine.permission.SitePermissions +import mozilla.components.concept.engine.permission.SitePermissions.AutoplayStatus +import mozilla.components.concept.engine.permission.SitePermissions.Status.ALLOWED +import mozilla.components.concept.engine.permission.SitePermissions.Status.BLOCKED +import mozilla.components.concept.engine.permission.SitePermissions.Status.NO_DECISION +import mozilla.components.concept.engine.permission.SitePermissionsStorage +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import mozilla.components.test.ReflectionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.anyString +import org.mockito.Mockito.doNothing +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission.VALUE_PROMPT +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_GEOLOCATION +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_PERSISTENT_STORAGE +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_STORAGE_ACCESS +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.PERMISSION_TRACKING +import org.mozilla.geckoview.StorageController +import org.mozilla.geckoview.StorageController.ClearFlags + +@ExperimentalCoroutinesApi +class GeckoSitePermissionsStorageTest { + private lateinit var runtime: GeckoRuntime + private lateinit var geckoStorage: GeckoSitePermissionsStorage + private lateinit var onDiskStorage: SitePermissionsStorage + private lateinit var storageController: StorageController + + @Before + fun setup() { + storageController = mock() + runtime = mock() + onDiskStorage = mock() + + whenever(runtime.storageController).thenReturn(storageController) + + geckoStorage = spy(GeckoSitePermissionsStorage(runtime, onDiskStorage)) + } + + @Test + fun `GIVEN a location permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(location = ALLOWED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_GEOLOCATION) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_GEOLOCATION, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(NO_DECISION, permissionsCaptor.value.location) + verify(storageController).setPermission(geckoPermissions, VALUE_ALLOW) + } + + @Test + fun `GIVEN a notification permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(notification = BLOCKED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_DESKTOP_NOTIFICATION) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_DESKTOP_NOTIFICATION, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(NO_DECISION, permissionsCaptor.value.notification) + verify(storageController).setPermission(geckoPermissions, VALUE_DENY) + } + + @Test + fun `GIVEN a localStorage permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(localStorage = BLOCKED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_PERSISTENT_STORAGE) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_PERSISTENT_STORAGE, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(NO_DECISION, permissionsCaptor.value.localStorage) + verify(storageController).setPermission(geckoPermissions, VALUE_DENY) + } + + @Test + fun `GIVEN a crossOriginStorageAccess permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(crossOriginStorageAccess = BLOCKED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_STORAGE_ACCESS) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_STORAGE_ACCESS, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(NO_DECISION, permissionsCaptor.value.crossOriginStorageAccess) + verify(storageController).setPermission(geckoPermissions, VALUE_DENY) + } + + @Test + fun `GIVEN a mediaKeySystemAccess permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(mediaKeySystemAccess = ALLOWED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_MEDIA_KEY_SYSTEM_ACCESS) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_MEDIA_KEY_SYSTEM_ACCESS, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(NO_DECISION, permissionsCaptor.value.mediaKeySystemAccess) + verify(storageController).setPermission(geckoPermissions, VALUE_ALLOW) + } + + @Test + fun `GIVEN a autoplayInaudible permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(autoplayInaudible = AutoplayStatus.ALLOWED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_AUTOPLAY_INAUDIBLE) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_AUTOPLAY_INAUDIBLE, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(AutoplayStatus.BLOCKED, permissionsCaptor.value.autoplayInaudible) + verify(storageController).setPermission(geckoPermissions, VALUE_ALLOW) + } + + @Test + fun `GIVEN a autoplayAudible permission WHEN saving THEN the permission is saved in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(autoplayAudible = AutoplayStatus.ALLOWED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE, geckoPermissions, mock()) + val permissionsCaptor = argumentCaptor<SitePermissions>() + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save(permissionsCaptor.capture(), any(), anyBoolean()) + + assertEquals(AutoplayStatus.BLOCKED, permissionsCaptor.value.autoplayAudible) + verify(storageController).setPermission(geckoPermissions, VALUE_ALLOW) + } + + @Test + fun `WHEN saving a site permission THEN the permission is saved in the gecko storage and in disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(autoplayAudible = AutoplayStatus.ALLOWED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE, geckoPermissions, mock()) + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + geckoStorage.save(sitePermissions, geckoRequest, false) + + verify(onDiskStorage).save( + sitePermissions.copy(autoplayAudible = AutoplayStatus.BLOCKED), + geckoRequest, + false, + ) + verify(storageController).setPermission(geckoPermissions, VALUE_ALLOW) + } + + @Test + fun `GIVEN a temporary permission WHEN saving THEN the permission is saved in memory`() = runTest { + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE, geckoPermissions, mock()) + + geckoStorage.saveTemporary(geckoRequest) + + assertTrue(geckoStorage.geckoTemporaryPermissions.contains(geckoPermissions)) + } + + @Test + fun `GIVEN media type temporary permission WHEN saving THEN the permission is NOT saved in memory`() = runTest { + val geckoRequest = GeckoPermissionRequest.Media("mozilla.org", emptyList(), emptyList(), mock()) + + assertTrue(geckoStorage.geckoTemporaryPermissions.isEmpty()) + + geckoStorage.saveTemporary(geckoRequest) + + assertTrue(geckoStorage.geckoTemporaryPermissions.isEmpty()) + } + + @Test + fun `GIVEN multiple saved temporary permissions WHEN clearing all temporary permission THEN all permissions are cleared`() = runTest { + val geckoAutoPlayPermissions = geckoContentPermission("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE) + val geckoPersistentStoragePermissions = geckoContentPermission("mozilla.org", PERMISSION_PERSISTENT_STORAGE) + val geckoStorageAccessPermissions = geckoContentPermission("mozilla.org", PERMISSION_STORAGE_ACCESS) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE, geckoAutoPlayPermissions, mock()) + + assertTrue(geckoStorage.geckoTemporaryPermissions.isEmpty()) + + geckoStorage.saveTemporary(geckoRequest) + + assertEquals(1, geckoStorage.geckoTemporaryPermissions.size) + + geckoStorage.saveTemporary(geckoRequest.copy(geckoPermission = geckoPersistentStoragePermissions)) + + assertEquals(2, geckoStorage.geckoTemporaryPermissions.size) + + geckoStorage.saveTemporary(geckoRequest.copy(geckoPermission = geckoStorageAccessPermissions)) + + assertEquals(3, geckoStorage.geckoTemporaryPermissions.size) + + geckoStorage.clearTemporaryPermissions() + + verify(storageController).setPermission(geckoAutoPlayPermissions, VALUE_PROMPT) + verify(storageController).setPermission(geckoPersistentStoragePermissions, VALUE_PROMPT) + verify(storageController).setPermission(geckoStorageAccessPermissions, VALUE_PROMPT) + + assertTrue(geckoStorage.geckoTemporaryPermissions.isEmpty()) + } + + @Test + fun `GIVEN a localStorage permission WHEN updating THEN the permission is updated in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(location = ALLOWED) + val geckoPermissions = geckoContentPermission("mozilla.org", PERMISSION_GEOLOCATION) + val geckoRequest = GeckoPermissionRequest.Content("mozilla.org", PERMISSION_AUTOPLAY_AUDIBLE, geckoPermissions, mock()) + + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + val permission = geckoStorage.updateGeckoPermissionIfNeeded( + sitePermissions, + geckoRequest, + private = false, + ) + + assertEquals(NO_DECISION, permission.location) + verify(storageController).setPermission(geckoPermissions, VALUE_ALLOW) + } + + @Test + fun `WHEN updating a permission THEN the permission is updated in the gecko storage and on the disk storage`() = runTest { + val sitePermissions = createNewSitePermission().copy(location = ALLOWED) + + doReturn(sitePermissions).`when`(geckoStorage) + .updateGeckoPermissionIfNeeded(sitePermissions, private = true) + + geckoStorage.update(sitePermissions, true) + + verify(geckoStorage).updateGeckoPermissionIfNeeded(sitePermissions, private = true) + verify(onDiskStorage).update(sitePermissions, private = true) + } + + @Test + fun `WHEN updating THEN the permission is updated in the gecko storage and set to the default value on the disk storage`() = runTest { + val sitePermissions = SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = ALLOWED, + location = ALLOWED, + notification = ALLOWED, + microphone = ALLOWED, + camera = ALLOWED, + bluetooth = ALLOWED, + mediaKeySystemAccess = ALLOWED, + autoplayAudible = AutoplayStatus.ALLOWED, + autoplayInaudible = AutoplayStatus.ALLOWED, + savedAt = 0, + ) + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS), + ) + + doReturn(geckoPermissions).`when`(geckoStorage) + .findGeckoContentPermissionBy(anyString(), anyBoolean(), anyBoolean()) + doReturn(Unit).`when`(geckoStorage).clearGeckoCacheFor(sitePermissions.origin) + + val permission = geckoStorage.updateGeckoPermissionIfNeeded(sitePermissions, null, false) + + geckoPermissions.forEach { + verify(geckoStorage).removeTemporaryPermissionIfAny(it) + verify(storageController).setPermission(it, VALUE_ALLOW) + } + + assertEquals(NO_DECISION, permission.location) + assertEquals(NO_DECISION, permission.notification) + assertEquals(NO_DECISION, permission.localStorage) + assertEquals(NO_DECISION, permission.crossOriginStorageAccess) + assertEquals(NO_DECISION, permission.mediaKeySystemAccess) + assertEquals(ALLOWED, permission.camera) + assertEquals(ALLOWED, permission.microphone) + assertEquals(AutoplayStatus.BLOCKED, permission.autoplayAudible) + assertEquals(AutoplayStatus.BLOCKED, permission.autoplayInaudible) + } + + @Test + fun `WHEN querying the store by origin THEN the gecko and the on disk storage are queried and results are combined`() = runTest { + val sitePermissions = SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = ALLOWED, + location = ALLOWED, + notification = ALLOWED, + microphone = ALLOWED, + camera = ALLOWED, + bluetooth = ALLOWED, + mediaKeySystemAccess = ALLOWED, + autoplayAudible = AutoplayStatus.ALLOWED, + autoplayInaudible = AutoplayStatus.ALLOWED, + savedAt = 0, + ) + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION, value = VALUE_ALLOW), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION, value = VALUE_ALLOW), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS, value = VALUE_ALLOW), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE, value = VALUE_ALLOW), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS, value = VALUE_ALLOW), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE, value = VALUE_ALLOW), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE, value = VALUE_ALLOW), + ) + + doReturn(sitePermissions).`when`(onDiskStorage) + .findSitePermissionsBy( + origin = "mozilla.dev", + includeTemporary = false, + private = false, + ) + doReturn(geckoPermissions).`when`(geckoStorage) + .findGeckoContentPermissionBy( + origin = "mozilla.dev", + includeTemporary = false, + private = false, + ) + + val foundPermissions = geckoStorage.findSitePermissionsBy( + origin = "mozilla.dev", + includeTemporary = false, + private = false, + )!! + + assertEquals(ALLOWED, foundPermissions.location) + assertEquals(ALLOWED, foundPermissions.notification) + assertEquals(ALLOWED, foundPermissions.localStorage) + assertEquals(ALLOWED, foundPermissions.crossOriginStorageAccess) + assertEquals(ALLOWED, foundPermissions.mediaKeySystemAccess) + assertEquals(ALLOWED, foundPermissions.camera) + assertEquals(ALLOWED, foundPermissions.microphone) + assertEquals(AutoplayStatus.ALLOWED, foundPermissions.autoplayAudible) + assertEquals(AutoplayStatus.ALLOWED, foundPermissions.autoplayInaudible) + } + + @Test + fun `GIVEN a gecko and on disk permissions WHEN merging values THEN both should be combined into one`() = runTest { + val onDiskPermissions = SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = ALLOWED, + location = ALLOWED, + notification = ALLOWED, + microphone = ALLOWED, + camera = ALLOWED, + bluetooth = ALLOWED, + mediaKeySystemAccess = ALLOWED, + autoplayAudible = AutoplayStatus.ALLOWED, + autoplayInaudible = AutoplayStatus.ALLOWED, + savedAt = 0, + ) + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE, value = VALUE_DENY), + ).groupByType() + + val mergedPermissions = geckoStorage.mergePermissions(onDiskPermissions, geckoPermissions)!! + + assertEquals(BLOCKED, mergedPermissions.location) + assertEquals(BLOCKED, mergedPermissions.notification) + assertEquals(BLOCKED, mergedPermissions.localStorage) + assertEquals(BLOCKED, mergedPermissions.crossOriginStorageAccess) + assertEquals(BLOCKED, mergedPermissions.mediaKeySystemAccess) + assertEquals(ALLOWED, mergedPermissions.camera) + assertEquals(ALLOWED, mergedPermissions.microphone) + assertEquals(AutoplayStatus.BLOCKED, mergedPermissions.autoplayAudible) + assertEquals(AutoplayStatus.BLOCKED, mergedPermissions.autoplayInaudible) + } + + @Test + fun `GIVEN permissions that are not present on the gecko storage WHEN merging THEN favor the values on disk permissions`() = runTest { + val onDiskPermissions = SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = ALLOWED, + location = ALLOWED, + notification = ALLOWED, + microphone = ALLOWED, + camera = ALLOWED, + bluetooth = ALLOWED, + mediaKeySystemAccess = ALLOWED, + autoplayAudible = AutoplayStatus.ALLOWED, + autoplayInaudible = AutoplayStatus.ALLOWED, + savedAt = 0, + ) + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION, value = VALUE_DENY), + ).groupByType() + + val mergedPermissions = geckoStorage.mergePermissions(onDiskPermissions, geckoPermissions)!! + + assertEquals(BLOCKED, mergedPermissions.location) + assertEquals(ALLOWED, mergedPermissions.notification) + assertEquals(ALLOWED, mergedPermissions.localStorage) + assertEquals(ALLOWED, mergedPermissions.crossOriginStorageAccess) + assertEquals(ALLOWED, mergedPermissions.mediaKeySystemAccess) + assertEquals(ALLOWED, mergedPermissions.camera) + assertEquals(ALLOWED, mergedPermissions.microphone) + assertEquals(AutoplayStatus.ALLOWED, mergedPermissions.autoplayAudible) + assertEquals(AutoplayStatus.ALLOWED, mergedPermissions.autoplayInaudible) + } + + @Test + fun `GIVEN different cross_origin_storage_access permissions WHEN mergePermissions is called THEN they are filtered by origin url`() { + val onDiskPermissions = SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = NO_DECISION, + location = ALLOWED, + notification = ALLOWED, + microphone = ALLOWED, + camera = ALLOWED, + bluetooth = ALLOWED, + mediaKeySystemAccess = ALLOWED, + autoplayAudible = AutoplayStatus.ALLOWED, + autoplayInaudible = AutoplayStatus.ALLOWED, + savedAt = 0, + ) + val geckoPermission1 = geckoContentPermission( + type = PERMISSION_STORAGE_ACCESS, + value = VALUE_DENY, + thirdPartyOrigin = "mozilla.com", + ) + val geckoPermission2 = geckoContentPermission( + type = PERMISSION_STORAGE_ACCESS, + value = VALUE_ALLOW, + thirdPartyOrigin = "mozilla.dev", + ) + val geckoPermission3 = geckoContentPermission( + type = PERMISSION_STORAGE_ACCESS, + value = VALUE_PROMPT, + thirdPartyOrigin = "mozilla.org", + ) + + val mergedPermissions = geckoStorage.mergePermissions( + onDiskPermissions, + mapOf(PERMISSION_STORAGE_ACCESS to listOf(geckoPermission1, geckoPermission2, geckoPermission3)), + ) + + assertEquals(onDiskPermissions.copy(crossOriginStorageAccess = ALLOWED), mergedPermissions!!) + } + + @Test + fun `WHEN removing a site permissions THEN permissions should be removed from the on disk and gecko storage`() = runTest { + val onDiskPermissions = createNewSitePermission() + + doReturn(Unit).`when`(geckoStorage).removeGeckoContentPermissionBy( + origin = onDiskPermissions.origin, + private = false, + ) + + geckoStorage.remove(sitePermissions = onDiskPermissions, private = false) + + verify(onDiskStorage).remove(sitePermissions = onDiskPermissions, private = false) + verify(geckoStorage).removeGeckoContentPermissionBy( + origin = onDiskPermissions.origin, + private = false, + ) + } + + @Test + fun `WHEN removing gecko permissions THEN permissions should be set to the default values in the gecko storage`() = runTest { + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE), + geckoContentPermission(type = PERMISSION_TRACKING), + ) + + doReturn(geckoPermissions).`when`(geckoStorage) + .findGeckoContentPermissionBy(anyString(), anyBoolean(), anyBoolean()) + + geckoStorage.removeGeckoContentPermissionBy(origin = "mozilla.dev", private = false) + + geckoPermissions.forEach { + val value = if (it.permission != PERMISSION_TRACKING) { + VALUE_PROMPT + } else { + VALUE_DENY + } + verify(geckoStorage).removeTemporaryPermissionIfAny(it) + verify(storageController).setPermission(it, value) + } + } + + @Test + fun `WHEN removing a temporary permissions THEN the permissions should be remove from memory`() = runTest { + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE), + geckoContentPermission(type = PERMISSION_TRACKING), + ) + + assertTrue(geckoStorage.geckoTemporaryPermissions.isEmpty()) + + geckoStorage.geckoTemporaryPermissions.addAll(geckoPermissions) + + assertEquals(10, geckoStorage.geckoTemporaryPermissions.size) + + geckoPermissions.forEach { + geckoStorage.removeTemporaryPermissionIfAny(it) + } + + assertTrue(geckoStorage.geckoTemporaryPermissions.isEmpty()) + } + + @Test + fun `WHEN removing all THEN all permissions should be removed from the on disk and gecko storage`() = runTest { + doReturn(Unit).`when`(geckoStorage).removeGeckoAllContentPermissions() + + geckoStorage.removeAll() + + verify(onDiskStorage).removeAll() + verify(geckoStorage).removeGeckoAllContentPermissions() + } + + @Test + fun `WHEN removing all gecko permissions THEN remove all permissions on gecko and clear the site permissions info`() = runTest { + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE), + geckoContentPermission(type = PERMISSION_TRACKING), + ) + + doReturn(geckoPermissions).`when`(geckoStorage).findAllGeckoContentPermissions() + doNothing().`when`(geckoStorage).removeGeckoContentPermission(any()) + + geckoStorage.removeGeckoAllContentPermissions() + + geckoPermissions.forEach { + verify(geckoStorage).removeGeckoContentPermission(it) + } + verify(storageController).clearData(ClearFlags.PERMISSIONS) + } + + @Test + fun `WHEN querying all permission THEN the gecko and the on disk storage are queried and results are combined`() = runTest { + val onDiskPermissions = SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = ALLOWED, + location = ALLOWED, + notification = ALLOWED, + microphone = ALLOWED, + camera = ALLOWED, + bluetooth = ALLOWED, + mediaKeySystemAccess = ALLOWED, + autoplayAudible = AutoplayStatus.ALLOWED, + autoplayInaudible = AutoplayStatus.ALLOWED, + savedAt = 0, + ) + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE, value = VALUE_DENY), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE, value = VALUE_DENY), + ) + + doReturn(listOf(onDiskPermissions)).`when`(onDiskStorage).all() + doReturn(geckoPermissions).`when`(geckoStorage).findAllGeckoContentPermissions() + + val foundPermissions = geckoStorage.all().first() + + assertEquals(BLOCKED, foundPermissions.location) + assertEquals(BLOCKED, foundPermissions.notification) + assertEquals(BLOCKED, foundPermissions.localStorage) + assertEquals(BLOCKED, foundPermissions.crossOriginStorageAccess) + assertEquals(BLOCKED, foundPermissions.mediaKeySystemAccess) + assertEquals(ALLOWED, foundPermissions.camera) + assertEquals(ALLOWED, foundPermissions.microphone) + assertEquals(AutoplayStatus.BLOCKED, foundPermissions.autoplayAudible) + assertEquals(AutoplayStatus.BLOCKED, foundPermissions.autoplayInaudible) + } + + @Test + fun `WHEN filtering temporary permissions THEN all temporary permissions should be removed`() = runTest { + val temporary = listOf(geckoContentPermission(type = PERMISSION_GEOLOCATION)) + + val geckoPermissions = listOf( + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_GEOLOCATION), + geckoContentPermission(type = PERMISSION_DESKTOP_NOTIFICATION), + geckoContentPermission(type = PERMISSION_MEDIA_KEY_SYSTEM_ACCESS), + geckoContentPermission(type = PERMISSION_PERSISTENT_STORAGE), + geckoContentPermission(type = PERMISSION_STORAGE_ACCESS), + geckoContentPermission(type = PERMISSION_AUTOPLAY_AUDIBLE), + geckoContentPermission(type = PERMISSION_AUTOPLAY_INAUDIBLE), + ) + + val filteredPermissions = geckoPermissions.filterNotTemporaryPermissions(temporary)!! + + assertEquals(6, filteredPermissions.size) + assertFalse(filteredPermissions.any { it.permission == PERMISSION_GEOLOCATION }) + } + + @Test + fun `WHEN compering two gecko ContentPermissions THEN they are the same when host, mode and permissions are the same`() = runTest { + val location1 = geckoContentPermission(uri = "mozilla.dev", type = PERMISSION_GEOLOCATION) + val location2 = geckoContentPermission(uri = "mozilla.dev", type = PERMISSION_GEOLOCATION) + val notification = geckoContentPermission(uri = "mozilla.dev", type = PERMISSION_DESKTOP_NOTIFICATION) + val privateNotification = geckoContentPermission(uri = "mozilla.dev", type = PERMISSION_DESKTOP_NOTIFICATION, privateMode = true) + + assertTrue(location1.areSame(location2)) + assertFalse(notification.areSame(location1)) + assertFalse(notification.areSame(privateNotification)) + } + + @Test + fun `WHEN converting from gecko status to sitePermissions status THEN they get converted to the equivalent one`() = runTest { + assertEquals(NO_DECISION, VALUE_PROMPT.toStatus()) + assertEquals(BLOCKED, VALUE_DENY.toStatus()) + assertEquals(ALLOWED, VALUE_ALLOW.toStatus()) + } + + @Test + fun `WHEN converting from gecko status to autoplay sitePermissions status THEN they get converted to the equivalent one`() = runTest { + assertEquals(AutoplayStatus.BLOCKED, VALUE_PROMPT.toAutoPlayStatus()) + assertEquals(AutoplayStatus.BLOCKED, VALUE_DENY.toAutoPlayStatus()) + assertEquals(AutoplayStatus.ALLOWED, VALUE_ALLOW.toAutoPlayStatus()) + } + + @Test + fun `WHEN converting a sitePermissions status to gecko status THEN they get converted to the equivalent one`() = runTest { + assertEquals(VALUE_PROMPT, NO_DECISION.toGeckoStatus()) + assertEquals(VALUE_DENY, BLOCKED.toGeckoStatus()) + assertEquals(VALUE_ALLOW, ALLOWED.toGeckoStatus()) + } + + @Test + fun `WHEN converting from autoplay sitePermissions to gecko status THEN they get converted to the equivalent one`() = runTest { + assertEquals(VALUE_DENY, AutoplayStatus.BLOCKED.toGeckoStatus()) + assertEquals(VALUE_ALLOW, AutoplayStatus.ALLOWED.toGeckoStatus()) + } + + private fun createNewSitePermission(): SitePermissions { + return SitePermissions( + origin = "mozilla.dev", + localStorage = ALLOWED, + crossOriginStorageAccess = BLOCKED, + location = BLOCKED, + notification = NO_DECISION, + microphone = NO_DECISION, + camera = NO_DECISION, + bluetooth = ALLOWED, + savedAt = 0, + ) + } +} + +internal fun geckoContentPermission( + uri: String = "mozilla.dev", + type: Int, + value: Int = VALUE_PROMPT, + thirdPartyOrigin: String = "mozilla.dev", + privateMode: Boolean = false, +): ContentPermission { + val permission: ContentPermission = mock() + ReflectionUtils.setField(permission, "uri", uri) + ReflectionUtils.setField(permission, "thirdPartyOrigin", thirdPartyOrigin) + ReflectionUtils.setField(permission, "permission", type) + ReflectionUtils.setField(permission, "value", value) + ReflectionUtils.setField(permission, "privateMode", privateMode) + return permission +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegateTest.kt new file mode 100644 index 0000000000..3f00852a1a --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegateTest.kt @@ -0,0 +1,81 @@ +/* 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.browser.engine.gecko.prompt + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.prompt.PromptRequest +import mozilla.components.support.test.mock +import mozilla.components.test.ReflectionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSession + +@RunWith(AndroidJUnit4::class) +class ChoicePromptDelegateTest { + + @Test + fun `WHEN onPromptUpdate is called from GeckoView THEN notifyObservers is invoked with onPromptUpdate`() { + val mockSession = GeckoEngineSession(mock()) + var isOnPromptUpdateCalled = false + var isOnConfirmCalled = false + var isOnDismissCalled = false + var observedPrompt: PromptRequest? = null + var observedUID: String? = null + mockSession.register( + object : EngineSession.Observer { + override fun onPromptUpdate( + previousPromptRequestUid: String, + promptRequest: PromptRequest, + ) { + observedPrompt = promptRequest + observedUID = previousPromptRequestUid + isOnPromptUpdateCalled = true + } + }, + ) + val prompt = PromptRequest.SingleChoice( + arrayOf(), + { isOnConfirmCalled = true }, + { isOnDismissCalled = true }, + ) + val delegate = ChoicePromptDelegate(mockSession, prompt) + val updatedPrompt = mock<GeckoSession.PromptDelegate.ChoicePrompt>() + ReflectionUtils.setField(updatedPrompt, "choices", arrayOf<GeckoChoice>()) + + delegate.onPromptUpdate(updatedPrompt) + + assertTrue(isOnPromptUpdateCalled) + assertEquals(prompt.uid, observedUID) + // Verify if the onConfirm and onDismiss callbacks were changed + (observedPrompt as PromptRequest.SingleChoice).onConfirm(mock()) + (observedPrompt as PromptRequest.SingleChoice).onDismiss() + assertTrue(isOnDismissCalled) + assertTrue(isOnConfirmCalled) + } + + @Test + fun `WHEN onPromptDismiss is called from GeckoView THEN notifyObservers is invoked with onPromptDismissed`() { + val mockSession = GeckoEngineSession(mock()) + var isOnDismissCalled = false + mockSession.register( + object : EngineSession.Observer { + override fun onPromptDismissed(promptRequest: PromptRequest) { + super.onPromptDismissed(promptRequest) + isOnDismissCalled = true + } + }, + ) + val basePrompt: GeckoSession.PromptDelegate.ChoicePrompt = mock() + val prompt: PromptRequest.SingleChoice = mock() + val delegate = ChoicePromptDelegate(mockSession, prompt) + + delegate.onPromptDismiss(basePrompt) + + assertTrue(isOnDismissCalled) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegateTest.kt new file mode 100644 index 0000000000..8c5c058055 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegateTest.kt @@ -0,0 +1,2136 @@ +/* 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.browser.engine.gecko.prompt + +import android.net.Uri +import android.os.Looper.getMainLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.browser.engine.gecko.ext.toAutocompleteAddress +import mozilla.components.browser.engine.gecko.ext.toAutocompleteCreditCard +import mozilla.components.browser.engine.gecko.ext.toLoginEntry +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.prompt.Choice +import mozilla.components.concept.engine.prompt.PromptRequest +import mozilla.components.concept.engine.prompt.PromptRequest.IdentityCredential +import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice +import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.support.ktx.kotlin.toDate +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 mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import mozilla.components.test.ReflectionUtils +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.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.Autocomplete +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.DATE +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.DATETIME_LOCAL +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.MONTH +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.TIME +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt.Type.WEEK +import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.ANY +import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.NONE +import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.USER +import org.robolectric.Shadows.shadowOf +import java.security.InvalidParameterException +import java.util.Calendar +import java.util.Calendar.YEAR +import java.util.Date + +typealias GeckoChoice = GeckoSession.PromptDelegate.ChoicePrompt.Choice +typealias GECKO_AUTH_LEVEL = GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.Level +typealias GECKO_PROMPT_CHOICE_TYPE = GeckoSession.PromptDelegate.ChoicePrompt.Type +typealias GECKO_AUTH_FLAGS = GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.Flags +typealias GECKO_PROMPT_FILE_TYPE = GeckoSession.PromptDelegate.FilePrompt.Type +typealias AC_AUTH_METHOD = PromptRequest.Authentication.Method +typealias AC_AUTH_LEVEL = PromptRequest.Authentication.Level + +@RunWith(AndroidJUnit4::class) +class GeckoPromptDelegateTest { + + private lateinit var runtime: GeckoRuntime + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + } + + @Test + fun `onChoicePrompt called with CHOICE_TYPE_SINGLE must provide a SingleChoice PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var promptRequestSingleChoice: PromptRequest = MultipleChoice(arrayOf(), {}, {}) + var confirmWasCalled = false + val gecko = GeckoPromptDelegate(mockSession) + val geckoChoice = object : GeckoChoice() {} + val geckoPrompt = geckoChoicePrompt( + "title", + "message", + GECKO_PROMPT_CHOICE_TYPE.SINGLE, + arrayOf(geckoChoice), + ) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + promptRequestSingleChoice = promptRequest + } + }, + ) + + val geckoResult = gecko.onChoicePrompt(mock(), geckoPrompt) + + geckoResult!!.accept { + confirmWasCalled = true + } + + assertTrue(promptRequestSingleChoice is SingleChoice) + val request = promptRequestSingleChoice as SingleChoice + + request.onConfirm(request.choices.first()) + shadowOf(getMainLooper()).idle() + assertTrue(confirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + confirmWasCalled = false + request.onConfirm(request.choices.first()) + shadowOf(getMainLooper()).idle() + assertFalse(confirmWasCalled) + } + + @Test + fun `onChoicePrompt called with CHOICE_TYPE_MULTIPLE must provide a MultipleChoice PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var promptRequestSingleChoice: PromptRequest = SingleChoice(arrayOf(), {}, {}) + var confirmWasCalled = false + val gecko = GeckoPromptDelegate(mockSession) + val mockGeckoChoice = object : GeckoChoice() {} + val geckoPrompt = geckoChoicePrompt( + "title", + "message", + GECKO_PROMPT_CHOICE_TYPE.MULTIPLE, + arrayOf(mockGeckoChoice), + ) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + promptRequestSingleChoice = promptRequest + } + }, + ) + + val geckoResult = gecko.onChoicePrompt(mock(), geckoPrompt) + + geckoResult!!.accept { + confirmWasCalled = true + } + + assertTrue(promptRequestSingleChoice is MultipleChoice) + + (promptRequestSingleChoice as MultipleChoice).onConfirm(arrayOf()) + shadowOf(getMainLooper()).idle() + assertTrue(confirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + confirmWasCalled = false + (promptRequestSingleChoice as MultipleChoice).onConfirm(arrayOf()) + shadowOf(getMainLooper()).idle() + assertFalse(confirmWasCalled) + } + + @Test + fun `onChoicePrompt called with CHOICE_TYPE_MENU must provide a MenuChoice PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var promptRequestSingleChoice: PromptRequest = PromptRequest.MenuChoice(arrayOf(), {}, {}) + var confirmWasCalled = false + val gecko = GeckoPromptDelegate(mockSession) + val geckoChoice = object : GeckoChoice() {} + val geckoPrompt = geckoChoicePrompt( + "title", + "message", + GECKO_PROMPT_CHOICE_TYPE.MENU, + arrayOf(geckoChoice), + ) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + promptRequestSingleChoice = promptRequest + } + }, + ) + + val geckoResult = gecko.onChoicePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + confirmWasCalled = true + } + + assertTrue(promptRequestSingleChoice is PromptRequest.MenuChoice) + val request = promptRequestSingleChoice as PromptRequest.MenuChoice + + request.onConfirm(request.choices.first()) + shadowOf(getMainLooper()).idle() + assertTrue(confirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + confirmWasCalled = false + request.onConfirm(request.choices.first()) + shadowOf(getMainLooper()).idle() + assertFalse(confirmWasCalled) + } + + @Test(expected = InvalidParameterException::class) + fun `calling onChoicePrompt with not valid Gecko ChoiceType will throw an exception`() { + val promptDelegate = GeckoPromptDelegate(mock()) + val geckoPrompt = geckoChoicePrompt( + "title", + "message", + -1, + arrayOf(), + ) + promptDelegate.onChoicePrompt(mock(), geckoPrompt) + } + + @Test + fun `onAlertPrompt must provide an alert PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var alertRequest: PromptRequest? = null + var dismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + alertRequest = promptRequest + } + }, + ) + + val geckoResult = promptDelegate.onAlertPrompt(mock(), geckoAlertPrompt()) + geckoResult.accept { + dismissWasCalled = true + } + assertTrue(alertRequest is PromptRequest.Alert) + + (alertRequest as PromptRequest.Alert).onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(dismissWasCalled) + + assertEquals((alertRequest as PromptRequest.Alert).title, "title") + assertEquals((alertRequest as PromptRequest.Alert).message, "message") + } + + @Test + fun `toIdsArray must convert an list of choices to array of id strings`() { + val choices = arrayOf(Choice(id = "0", label = ""), Choice(id = "1", label = "")) + val ids = choices.toIdsArray() + ids.forEachIndexed { index, item -> + assertEquals("$index", item) + } + } + + @Test + fun `onDateTimePrompt called with DATETIME_TYPE_DATE must provide a date PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var dateRequest: PromptRequest? = null + var geckoPrompt = geckoDateTimePrompt("title", DATE, "", "", "") + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + dateRequest = promptRequest + } + }, + ) + + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertTrue(dateRequest is PromptRequest.TimeSelection) + val date = Date() + (dateRequest as PromptRequest.TimeSelection).onConfirm(date) + verify(geckoPrompt, times(1)).confirm(eq(date.toString("yyyy-MM-dd"))) + assertEquals((dateRequest as PromptRequest.TimeSelection).title, "title") + + geckoPrompt = geckoDateTimePrompt("title", DATE, "", "", "") + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + (dateRequest as PromptRequest.TimeSelection).onClear() + verify(geckoPrompt, times(1)).confirm(eq("")) + } + + @Test + fun `onDateTimePrompt DATETIME_TYPE_DATE with date parameters must format dates correctly`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val confirmCaptor = argumentCaptor<String>() + + val geckoPrompt = + geckoDateTimePrompt( + title = "title", + type = DATE, + defaultValue = "2019-11-29", + minValue = "2019-11-28", + maxValue = "2019-11-30", + ) + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertNotNull(timeSelectionRequest) + with(timeSelectionRequest!!) { + assertEquals(initialDate, "2019-11-29".toDate("yyyy-MM-dd")) + assertEquals(minimumDate, "2019-11-28".toDate("yyyy-MM-dd")) + assertEquals(maximumDate, "2019-11-30".toDate("yyyy-MM-dd")) + } + val selectedDate = "2019-11-28".toDate("yyyy-MM-dd") + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedDate) + verify(geckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(confirmCaptor.value.toDate("yyyy-MM-dd"), selectedDate) + assertEquals((timeSelectionRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt called with DATETIME_TYPE_MONTH must provide a date PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var dateRequest: PromptRequest? = null + var confirmCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + dateRequest = promptRequest + } + }, + ) + val geckoPrompt = geckoDateTimePrompt(type = MONTH) + + val geckoResult = promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + confirmCalled = true + } + + shadowOf(getMainLooper()).idle() + + assertTrue(dateRequest is PromptRequest.TimeSelection) + (dateRequest as PromptRequest.TimeSelection).onConfirm(Date()) + shadowOf(getMainLooper()).idle() + + assertTrue(confirmCalled) + assertEquals((dateRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt DATETIME_TYPE_MONTH with date parameters must format dates correctly`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val confirmCaptor = argumentCaptor<String>() + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + val geckoPrompt = geckoDateTimePrompt( + title = "title", + type = MONTH, + defaultValue = "2019-11", + minValue = "2019-11", + maxValue = "2019-11", + ) + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertNotNull(timeSelectionRequest) + with(timeSelectionRequest!!) { + assertEquals(initialDate, "2019-11".toDate("yyyy-MM")) + assertEquals(minimumDate, "2019-11".toDate("yyyy-MM")) + assertEquals(maximumDate, "2019-11".toDate("yyyy-MM")) + } + val selectedDate = "2019-11".toDate("yyyy-MM") + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedDate) + verify(geckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(confirmCaptor.value.toDate("yyyy-MM"), selectedDate) + assertEquals((timeSelectionRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt called with DATETIME_TYPE_WEEK must provide a date PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var dateRequest: PromptRequest? = null + var confirmCalled = false + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + dateRequest = promptRequest + } + }, + ) + val geckoPrompt = geckoDateTimePrompt(type = WEEK) + + val geckoResult = promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + confirmCalled = true + } + + shadowOf(getMainLooper()).idle() + + assertTrue(dateRequest is PromptRequest.TimeSelection) + (dateRequest as PromptRequest.TimeSelection).onConfirm(Date()) + shadowOf(getMainLooper()).idle() + assertTrue(confirmCalled) + assertEquals((dateRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt DATETIME_TYPE_WEEK with date parameters must format dates correctly`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val confirmCaptor = argumentCaptor<String>() + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + + val geckoPrompt = geckoDateTimePrompt( + title = "title", + type = WEEK, + defaultValue = "2018-W18", + minValue = "2018-W18", + maxValue = "2018-W26", + ) + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertNotNull(timeSelectionRequest) + with(timeSelectionRequest!!) { + assertEquals(initialDate, "2018-W18".toDate("yyyy-'W'ww")) + assertEquals(minimumDate, "2018-W18".toDate("yyyy-'W'ww")) + assertEquals(maximumDate, "2018-W26".toDate("yyyy-'W'ww")) + } + val selectedDate = "2018-W26".toDate("yyyy-'W'ww") + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedDate) + verify(geckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(confirmCaptor.value.toDate("yyyy-'W'ww"), selectedDate) + assertEquals((timeSelectionRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt called with DATETIME_TYPE_TIME must provide a TimeSelection PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var dateRequest: PromptRequest? = null + var confirmCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + dateRequest = promptRequest + } + }, + ) + val geckoPrompt = geckoDateTimePrompt(type = TIME) + + val geckoResult = promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + confirmCalled = true + } + + assertTrue(dateRequest is PromptRequest.TimeSelection) + (dateRequest as PromptRequest.TimeSelection).onConfirm(Date()) + shadowOf(getMainLooper()).idle() + assertTrue(confirmCalled) + assertEquals((dateRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt DATETIME_TYPE_TIME with time parameters must format time correctly`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val confirmCaptor = argumentCaptor<String>() + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + + val geckoPrompt = geckoDateTimePrompt( + title = "title", + type = TIME, + defaultValue = "17:00", + minValue = "9:00", + maxValue = "18:00", + ) + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertNotNull(timeSelectionRequest) + with(timeSelectionRequest!!) { + assertEquals(initialDate, "17:00".toDate("HH:mm")) + assertEquals(minimumDate, "9:00".toDate("HH:mm")) + assertEquals(maximumDate, "18:00".toDate("HH:mm")) + } + val selectedDate = "17:00".toDate("HH:mm") + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedDate) + verify(geckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(confirmCaptor.value.toDate("HH:mm"), selectedDate) + assertEquals((timeSelectionRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt DATETIME_TYPE_TIME with stepValue time parameter must format time correctly`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val confirmCaptor = argumentCaptor<String>() + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + val minutesGeckoPrompt = geckoDateTimePrompt( + type = TIME, + defaultValue = "17:00", + stepValue = "", + ) + val secondsGeckoPrompt = geckoDateTimePrompt( + type = TIME, + defaultValue = "17:00:00", + stepValue = "1", + ) + val millisecondsGeckoPrompt = geckoDateTimePrompt( + type = TIME, + defaultValue = "17:00:00.000", + stepValue = "0.1", + ) + + promptDelegate.onDateTimePrompt(mock(), minutesGeckoPrompt) + + var selectedTime = "17:00" + assertNotNull(timeSelectionRequest) + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedTime.toDate("HH:mm")) + verify(minutesGeckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(selectedTime, confirmCaptor.value) + + promptDelegate.onDateTimePrompt(mock(), secondsGeckoPrompt) + + selectedTime = "17:00:25" + assertNotNull(timeSelectionRequest) + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedTime.toDate("HH:mm:ss")) + verify(secondsGeckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(selectedTime, confirmCaptor.value) + + promptDelegate.onDateTimePrompt(mock(), millisecondsGeckoPrompt) + + selectedTime = "17:00:20.100" + assertNotNull(timeSelectionRequest) + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedTime.toDate("HH:mm:ss.SSS")) + verify(millisecondsGeckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(selectedTime, confirmCaptor.value) + } + + @Test + fun `WHEN DateTimePrompt request with invalid stepValue parameter is triggered THEN stepValue is passed as null`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + val geckoPrompt = geckoDateTimePrompt( + type = TIME, + defaultValue = "17:00", + stepValue = "Time", + ) + + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertNotNull(timeSelectionRequest) + assertEquals(PromptRequest.TimeSelection.Type.TIME, timeSelectionRequest?.type) + assertNull(timeSelectionRequest?.stepValue) + } + + @Test + fun `onDateTimePrompt called with DATETIME_TYPE_DATETIME_LOCAL must provide a TimeSelection PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var dateRequest: PromptRequest? = null + var confirmCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + dateRequest = promptRequest + } + }, + ) + val geckoResult = + promptDelegate.onDateTimePrompt(mock(), geckoDateTimePrompt(type = DATETIME_LOCAL)) + geckoResult!!.accept { + confirmCalled = true + } + + assertTrue(dateRequest is PromptRequest.TimeSelection) + (dateRequest as PromptRequest.TimeSelection).onConfirm(Date()) + shadowOf(getMainLooper()).idle() + + assertTrue(confirmCalled) + assertEquals((dateRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test + fun `onDateTimePrompt DATETIME_TYPE_DATETIME_LOCAL with date parameters must format time correctly`() { + val mockSession = GeckoEngineSession(runtime) + var timeSelectionRequest: PromptRequest.TimeSelection? = null + val confirmCaptor = argumentCaptor<String>() + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + timeSelectionRequest = promptRequest as PromptRequest.TimeSelection + } + }, + ) + val geckoPrompt = geckoDateTimePrompt( + title = "title", + type = DATETIME_LOCAL, + defaultValue = "2018-06-12T19:30", + minValue = "2018-06-07T00:00", + maxValue = "2018-06-14T00:00", + ) + promptDelegate.onDateTimePrompt(mock(), geckoPrompt) + + assertNotNull(timeSelectionRequest) + with(timeSelectionRequest!!) { + assertEquals(initialDate, "2018-06-12T19:30".toDate("yyyy-MM-dd'T'HH:mm")) + assertEquals(minimumDate, "2018-06-07T00:00".toDate("yyyy-MM-dd'T'HH:mm")) + assertEquals(maximumDate, "2018-06-14T00:00".toDate("yyyy-MM-dd'T'HH:mm")) + } + val selectedDate = "2018-06-12T19:30".toDate("yyyy-MM-dd'T'HH:mm") + (timeSelectionRequest as PromptRequest.TimeSelection).onConfirm(selectedDate) + verify(geckoPrompt).confirm(confirmCaptor.capture()) + assertEquals(confirmCaptor.value.toDate("yyyy-MM-dd'T'HH:mm"), selectedDate) + assertEquals((timeSelectionRequest as PromptRequest.TimeSelection).title, "title") + } + + @Test(expected = InvalidParameterException::class) + fun `Calling onDateTimePrompt with invalid DatetimeType will throw an exception`() { + val promptDelegate = GeckoPromptDelegate(mock()) + promptDelegate.onDateTimePrompt( + mock(), + geckoDateTimePrompt( + type = 13223, + defaultValue = "17:00", + minValue = "9:00", + maxValue = "18:00", + ), + ) + } + + @Test + fun `date to string`() { + val date = Date() + + var dateString = date.toString() + assertNotNull(dateString.isEmpty()) + + dateString = date.toString("yyyy") + val calendar = Calendar.getInstance() + calendar.time = date + val year = calendar[YEAR].toString() + assertEquals(dateString, year) + } + + @Test + fun `Calling onFilePrompt must provide a FilePicker PromptRequest`() { + val context = spy(testContext) + val contentResolver = spy(context.contentResolver) + val mockSession = GeckoEngineSession(runtime) + var onSingleFileSelectedWasCalled = false + var onMultipleFilesSelectedWasCalled = false + var onDismissWasCalled = false + val mockUri: Uri = mock() + + doReturn(contentResolver).`when`(context).contentResolver + + var filePickerRequest: PromptRequest.File = mock() + + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + // Prevent the file from being copied + doReturn(mockUri).`when`(promptDelegate).toFileUri(any(), any()) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + filePickerRequest = promptRequest as PromptRequest.File + } + }, + ) + var geckoPrompt = geckoFilePrompt(type = GECKO_PROMPT_FILE_TYPE.SINGLE, capture = NONE) + + var geckoResult = promptDelegate.onFilePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onSingleFileSelectedWasCalled = true + } + + filePickerRequest.onSingleFileSelected(context, mockUri) + shadowOf(getMainLooper()).idle() + + assertTrue(onSingleFileSelectedWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onSingleFileSelectedWasCalled = false + filePickerRequest.onSingleFileSelected(context, mockUri) + shadowOf(getMainLooper()).idle() + + assertFalse(onSingleFileSelectedWasCalled) + + geckoPrompt = geckoFilePrompt(type = GECKO_PROMPT_FILE_TYPE.MULTIPLE, capture = ANY) + geckoResult = promptDelegate.onFilePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onMultipleFilesSelectedWasCalled = true + } + + filePickerRequest.onMultipleFilesSelected(context, arrayOf(mockUri)) + shadowOf(getMainLooper()).idle() + + assertTrue(onMultipleFilesSelectedWasCalled) + + geckoPrompt = geckoFilePrompt(type = GECKO_PROMPT_FILE_TYPE.SINGLE, capture = NONE) + geckoResult = promptDelegate.onFilePrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onDismissWasCalled = true + } + + filePickerRequest.onDismiss() + shadowOf(getMainLooper()).idle() + + assertTrue(onDismissWasCalled) + + assertTrue(filePickerRequest.mimeTypes.isEmpty()) + assertFalse(filePickerRequest.isMultipleFilesSelection) + assertEquals(PromptRequest.File.FacingMode.NONE, filePickerRequest.captureMode) + + promptDelegate.onFilePrompt( + mock(), + geckoFilePrompt(type = GECKO_PROMPT_FILE_TYPE.MULTIPLE, capture = USER), + ) + + assertTrue(filePickerRequest.isMultipleFilesSelection) + assertEquals( + PromptRequest.File.FacingMode.FRONT_CAMERA, + filePickerRequest.captureMode, + ) + } + + @Test + fun `Calling onLoginSave must provide an SaveLoginPrompt PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var onLoginSaved = false + var onDismissWasCalled = false + + var loginSaveRequest: PromptRequest.SaveLoginPrompt = mock() + + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + loginSaveRequest = promptRequest as PromptRequest.SaveLoginPrompt + } + }, + ) + + val entry = createLoginEntry() + val saveOption = Autocomplete.LoginSaveOption(entry.toLoginEntry()) + + var geckoResult = + promptDelegate.onLoginSave(mock(), geckoLoginSavePrompt(arrayOf(saveOption))) + + geckoResult!!.accept { + onDismissWasCalled = true + } + + loginSaveRequest.onDismiss() + shadowOf(getMainLooper()).idle() + + assertTrue(onDismissWasCalled) + + val geckoPrompt = geckoLoginSavePrompt(arrayOf(saveOption)) + geckoResult = promptDelegate.onLoginSave(mock(), geckoPrompt) + + geckoResult!!.accept { + onLoginSaved = true + } + + loginSaveRequest.onConfirm(entry) + shadowOf(getMainLooper()).idle() + + assertTrue(onLoginSaved) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onLoginSaved = false + + loginSaveRequest.onConfirm(entry) + shadowOf(getMainLooper()).idle() + + assertFalse(onLoginSaved) + } + + @Test + fun `Calling onLoginSave must set a PromptInstanceDismissDelegate`() { + val mockSession = GeckoEngineSession(runtime) + var loginSaveRequest: PromptRequest.SaveLoginPrompt = mock() + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + loginSaveRequest = promptRequest as PromptRequest.SaveLoginPrompt + } + }, + ) + val login = createLogin() + val saveOption = Autocomplete.LoginSaveOption(login.toLoginEntry()) + val saveLoginPrompt = geckoLoginSavePrompt(arrayOf(saveOption)) + + promptDelegate.onLoginSave(mock(), saveLoginPrompt) + + assertNotNull(loginSaveRequest) + assertNotNull(saveLoginPrompt.delegate) + } + + @Test + fun `Calling onLoginSelect must provide an SelectLoginPrompt PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var onLoginSelected = false + var onDismissWasCalled = false + + var loginSelectRequest: PromptRequest.SelectLoginPrompt = mock() + + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + loginSelectRequest = promptRequest as PromptRequest.SelectLoginPrompt + } + }, + ) + + val login = createLogin() + val loginSelectOption = Autocomplete.LoginSelectOption(login.toLoginEntry()) + + val secondLogin = createLogin(username = "username2") + val secondLoginSelectOption = Autocomplete.LoginSelectOption(secondLogin.toLoginEntry()) + + var geckoResult = + promptDelegate.onLoginSelect( + mock(), + geckoLoginSelectPrompt(arrayOf(loginSelectOption, secondLoginSelectOption)), + ) + + geckoResult!!.accept { + onDismissWasCalled = true + } + + loginSelectRequest.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + + val geckoPrompt = geckoLoginSelectPrompt(arrayOf(loginSelectOption, secondLoginSelectOption)) + geckoResult = promptDelegate.onLoginSelect( + mock(), + geckoPrompt, + ) + + geckoResult!!.accept { + onLoginSelected = true + } + + loginSelectRequest.onConfirm(login) + shadowOf(getMainLooper()).idle() + + assertTrue(onLoginSelected) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onLoginSelected = false + loginSelectRequest.onConfirm(login) + shadowOf(getMainLooper()).idle() + + assertFalse(onLoginSelected) + } + + fun createLogin( + guid: String = "id", + password: String = "password", + username: String = "username", + origin: String = "https://www.origin.com", + httpRealm: String = "httpRealm", + formActionOrigin: String = "https://www.origin.com", + usernameField: String = "usernameField", + passwordField: String = "passwordField", + ) = Login( + guid = guid, + origin = origin, + password = password, + username = username, + httpRealm = httpRealm, + formActionOrigin = formActionOrigin, + usernameField = usernameField, + passwordField = passwordField, + ) + + fun createLoginEntry( + password: String = "password", + username: String = "username", + origin: String = "https://www.origin.com", + httpRealm: String = "httpRealm", + formActionOrigin: String = "https://www.origin.com", + usernameField: String = "usernameField", + passwordField: String = "passwordField", + ) = LoginEntry( + origin = origin, + password = password, + username = username, + httpRealm = httpRealm, + formActionOrigin = formActionOrigin, + usernameField = usernameField, + passwordField = passwordField, + ) + + @Test + fun `Calling onCreditCardSave must provide an SaveCreditCard PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var onCreditCardSaved = false + var onDismissWasCalled = false + + var saveCreditCardPrompt: PromptRequest.SaveCreditCard = mock() + + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + saveCreditCardPrompt = promptRequest as PromptRequest.SaveCreditCard + } + }, + ) + + val creditCard = CreditCardEntry( + guid = "1", + name = "Banana Apple", + number = "4111111111111110", + expiryMonth = "5", + expiryYear = "2030", + cardType = "amex", + ) + val creditCardSaveOption = + Autocomplete.CreditCardSaveOption(creditCard.toAutocompleteCreditCard()) + + var geckoResult = promptDelegate.onCreditCardSave( + mock(), + geckoCreditCardSavePrompt(arrayOf(creditCardSaveOption)), + ) + + geckoResult.accept { + onDismissWasCalled = true + } + + saveCreditCardPrompt.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + + val geckoPrompt = geckoCreditCardSavePrompt(arrayOf(creditCardSaveOption)) + geckoResult = promptDelegate.onCreditCardSave(mock(), geckoPrompt) + + geckoResult.accept { + onCreditCardSaved = true + } + + saveCreditCardPrompt.onConfirm(creditCard) + shadowOf(getMainLooper()).idle() + + assertTrue(onCreditCardSaved) + + whenever(geckoPrompt.isComplete).thenReturn(true) + onCreditCardSaved = false + saveCreditCardPrompt.onConfirm(creditCard) + + assertFalse(onCreditCardSaved) + } + + @Test + fun `Calling onCreditSave must set a PromptInstanceDismissDelegate`() { + val mockSession = GeckoEngineSession(runtime) + var saveCreditCardPrompt: PromptRequest.SaveCreditCard = mock() + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + saveCreditCardPrompt = promptRequest as PromptRequest.SaveCreditCard + } + }, + ) + + val creditCard = CreditCardEntry( + guid = "1", + name = "Banana Apple", + number = "4111111111111110", + expiryMonth = "5", + expiryYear = "2030", + cardType = "amex", + ) + val creditCardSaveOption = + Autocomplete.CreditCardSaveOption(creditCard.toAutocompleteCreditCard()) + val geckoPrompt = geckoCreditCardSavePrompt(arrayOf(creditCardSaveOption)) + + promptDelegate.onCreditCardSave(mock(), geckoPrompt) + + assertNotNull(saveCreditCardPrompt) + assertNotNull(geckoPrompt.delegate) + } + + @Test + fun `Calling onCreditCardSelect must provide as CreditCardSelectOption PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var onConfirmWasCalled = false + var onDismissWasCalled = false + + var selectCreditCardPrompt: PromptRequest.SelectCreditCard = mock() + + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + selectCreditCardPrompt = promptRequest as PromptRequest.SelectCreditCard + } + }, + ) + + val creditCard1 = CreditCardEntry( + guid = "1", + name = "Banana Apple", + number = "4111111111111110", + expiryMonth = "5", + expiryYear = "2030", + cardType = "amex", + ) + val creditCardSelectOption1 = + Autocomplete.CreditCardSelectOption(creditCard1.toAutocompleteCreditCard()) + + val creditCard2 = CreditCardEntry( + guid = "2", + name = "Orange Pineapple", + number = "4111111111115555", + expiryMonth = "1", + expiryYear = "2040", + cardType = "amex", + ) + val creditCardSelectOption2 = + Autocomplete.CreditCardSelectOption(creditCard2.toAutocompleteCreditCard()) + + var geckoResult = promptDelegate.onCreditCardSelect( + mock(), + geckoSelectCreditCardPrompt(arrayOf(creditCardSelectOption1, creditCardSelectOption2)), + ) + + geckoResult!!.accept { + onDismissWasCalled = true + } + + selectCreditCardPrompt.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + + val geckoPrompt = + geckoSelectCreditCardPrompt(arrayOf(creditCardSelectOption1, creditCardSelectOption2)) + geckoResult = promptDelegate.onCreditCardSelect(mock(), geckoPrompt) + + geckoResult!!.accept { + onConfirmWasCalled = true + } + + selectCreditCardPrompt.onConfirm(creditCard1) + shadowOf(getMainLooper()).idle() + + assertTrue(onConfirmWasCalled) + + whenever(geckoPrompt.isComplete).thenReturn(true) + onConfirmWasCalled = false + selectCreditCardPrompt.onConfirm(creditCard1) + + assertFalse(onConfirmWasCalled) + } + + @Test + fun `Calling onAuthPrompt must provide an Authentication PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var authRequest: PromptRequest.Authentication = mock() + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + authRequest = promptRequest as PromptRequest.Authentication + } + }, + ) + + var geckoPrompt = geckoAuthPrompt(authOptions = mock()) + promptDelegate.onAuthPrompt(mock(), geckoPrompt) + + authRequest.onConfirm("", "") + verify(geckoPrompt, times(1)).confirm(eq(""), eq("")) + + geckoPrompt = geckoAuthPrompt(authOptions = mock()) + promptDelegate.onAuthPrompt(mock(), geckoPrompt) + authRequest.onDismiss() + verify(geckoPrompt, times(1)).dismiss() + + val authOptions = geckoAuthOptions() + ReflectionUtils.setField(authOptions, "level", GECKO_AUTH_LEVEL.SECURE) + + var flags = 0 + flags = flags.or(GECKO_AUTH_FLAGS.ONLY_PASSWORD) + flags = flags.or(GECKO_AUTH_FLAGS.PREVIOUS_FAILED) + flags = flags.or(GECKO_AUTH_FLAGS.CROSS_ORIGIN_SUB_RESOURCE) + flags = flags.or(GECKO_AUTH_FLAGS.HOST) + ReflectionUtils.setField(authOptions, "flags", flags) + + geckoPrompt = geckoAuthPrompt(authOptions = authOptions) + promptDelegate.onAuthPrompt(mock(), geckoPrompt) + + authRequest.onConfirm("", "") + + with(authRequest) { + assertTrue(onlyShowPassword) + assertTrue(previousFailed) + assertTrue(isCrossOrigin) + + assertEquals(method, AC_AUTH_METHOD.HOST) + assertEquals(level, AC_AUTH_LEVEL.SECURED) + + verify(geckoPrompt, never()).confirm(eq(""), eq("")) + verify(geckoPrompt, times(1)).confirm(eq("")) + } + + ReflectionUtils.setField(authOptions, "level", GECKO_AUTH_LEVEL.PW_ENCRYPTED) + + promptDelegate.onAuthPrompt(mock(), geckoAuthPrompt(authOptions = authOptions)) + + assertEquals(authRequest.level, AC_AUTH_LEVEL.PASSWORD_ENCRYPTED) + + ReflectionUtils.setField(authOptions, "level", -2423) + + promptDelegate.onAuthPrompt(mock(), geckoAuthPrompt(authOptions = authOptions)) + + assertEquals(authRequest.level, AC_AUTH_LEVEL.NONE) + } + + @Test + fun `WHEN onSelectIdentityCredentialProvider is called THEN SelectProvider prompt request must be provided with the correct callbacks`() { + val mockSession = GeckoEngineSession(runtime) + var selectProviderRequest: IdentityCredential.SelectProvider = mock() + var onConfirmWasCalled = false + var onDismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + selectProviderRequest = promptRequest as IdentityCredential.SelectProvider + } + }, + ) + + val geckoProvider = GECKO_PROMPT_PROVIDER_SELECTOR(0, "name", "icon", "domain") + val acProvider = geckoProvider.toProvider() + val geckoPrompt = geckoProviderSelectorPrompt(listOf(geckoProvider)) + var geckoResult = promptDelegate.onSelectIdentityCredentialProvider(mock(), geckoPrompt) + + geckoResult.accept { + onConfirmWasCalled = true + } + + with(selectProviderRequest) { + // Verifying we are parsing the providers correctly. + assertEquals(acProvider, this.providers.first()) + + onConfirm(acProvider) + + shadowOf(getMainLooper()).idle() + assertTrue(onConfirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + // Just making sure we are not completing the geckoResult twice. + onConfirmWasCalled = false + onConfirm(acProvider) + shadowOf(getMainLooper()).idle() + assertFalse(onConfirmWasCalled) + } + + // Verifying we are handling the dismiss correctly. + geckoResult = promptDelegate.onSelectIdentityCredentialProvider(mock(), geckoProviderSelectorPrompt(listOf(geckoProvider))) + geckoResult.accept { + onDismissWasCalled = true + } + + selectProviderRequest.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + } + + @Test + fun `WHEN onSelectIdentityCredentialAccount is called THEN SelectAccount prompt request must be provided with the correct callbacks`() { + val mockSession = GeckoEngineSession(runtime) + var selectAccountRequest: IdentityCredential.SelectAccount = mock() + var onConfirmWasCalled = false + var onDismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + selectAccountRequest = promptRequest as IdentityCredential.SelectAccount + } + }, + ) + + val geckoAccount = GECKO_PROMPT_ACCOUNT_SELECTOR(0, "foo@mozilla.org", "foo", "icon") + val provider = GECKO_PROMPT_ACCOUNT_SELECTOR_PROVIDER("name", "domain", "favicon") + val acAccount = geckoAccount.toAccount() + val geckoPrompt = geckoAccountSelectorPrompt(listOf(geckoAccount), provider) + var geckoResult = promptDelegate.onSelectIdentityCredentialAccount(mock(), geckoPrompt) + + geckoResult.accept { + onConfirmWasCalled = true + } + + with(selectAccountRequest) { + // Verifying we are parsing the providers correctly. + assertEquals(acAccount, this.accounts.first()) + + onConfirm(acAccount) + + shadowOf(getMainLooper()).idle() + assertTrue(onConfirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + // Just making sure we are not completing the geckoResult twice. + onConfirmWasCalled = false + onConfirm(acAccount) + shadowOf(getMainLooper()).idle() + assertFalse(onConfirmWasCalled) + } + + // Verifying we are handling the dismiss correctly. + geckoResult = promptDelegate.onSelectIdentityCredentialAccount(mock(), geckoAccountSelectorPrompt(listOf(geckoAccount), provider)) + geckoResult.accept { + onDismissWasCalled = true + } + + selectAccountRequest.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + } + + @Test + fun `WHEN onShowPrivacyPolicyIdentityCredential is called THEN the PrivacyPolicy prompt request must be provided with the correct callbacks`() { + val mockSession = GeckoEngineSession(runtime) + var privacyPolicyRequest: IdentityCredential.PrivacyPolicy = mock() + var onConfirmWasCalled = false + var onDismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + privacyPolicyRequest = promptRequest as IdentityCredential.PrivacyPolicy + } + }, + ) + + val geckoPrompt = geckoPrivacyPolicyPrompt() + var geckoResult = promptDelegate.onShowPrivacyPolicyIdentityCredential(mock(), geckoPrompt) + + geckoResult.accept { + onConfirmWasCalled = true + } + + with(privacyPolicyRequest) { + // Verifying we are parsing the providers correctly. + assertEquals(privacyPolicyUrl, "privacyPolicyUrl") + assertEquals(termsOfServiceUrl, "termsOfServiceUrl") + assertEquals(providerDomain, "providerDomain") + assertEquals(host, "host") + assertEquals(icon, "icon") + + onConfirm(true) + + shadowOf(getMainLooper()).idle() + assertTrue(onConfirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + // Just making sure we are not completing the geckoResult twice. + onConfirmWasCalled = false + onConfirm(true) + shadowOf(getMainLooper()).idle() + assertFalse(onConfirmWasCalled) + } + + // Verifying we are handling the dismiss correctly. + geckoResult = promptDelegate.onShowPrivacyPolicyIdentityCredential(mock(), geckoPrivacyPolicyPrompt()) + geckoResult.accept { + onDismissWasCalled = true + } + + privacyPolicyRequest.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + } + + @Test + fun `Calling onColorPrompt must provide a Color PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var colorRequest: PromptRequest.Color = mock() + var onConfirmWasCalled = false + var onDismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + colorRequest = promptRequest as PromptRequest.Color + } + }, + ) + + val geckoPrompt = geckoColorPrompt(defaultValue = "#e66465") + var geckoResult = promptDelegate.onColorPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onConfirmWasCalled = true + } + + with(colorRequest) { + assertEquals(defaultColor, "#e66465") + onConfirm("#f6b73c") + shadowOf(getMainLooper()).idle() + assertTrue(onConfirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onConfirmWasCalled = false + onConfirm("#f6b73c") + shadowOf(getMainLooper()).idle() + assertFalse(onConfirmWasCalled) + } + + geckoResult = promptDelegate.onColorPrompt(mock(), geckoColorPrompt()) + geckoResult!!.accept { + onDismissWasCalled = true + } + + colorRequest.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + + with(colorRequest) { + assertEquals(defaultColor, "defaultValue") + } + } + + @Test + fun `onTextPrompt must provide an TextPrompt PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.TextPrompt = mock() + var dismissWasCalled = false + var confirmWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.TextPrompt + } + }, + ) + + var geckoResult = promptDelegate.onTextPrompt(mock(), geckoTextPrompt()) + geckoResult!!.accept { + dismissWasCalled = true + } + + with(request) { + assertEquals(title, "title") + assertEquals(inputLabel, "message") + assertEquals(inputValue, "defaultValue") + + onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(dismissWasCalled) + } + + val geckoPrompt = geckoTextPrompt() + geckoResult = promptDelegate.onTextPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + confirmWasCalled = true + } + + request.onConfirm(true, "newInput") + shadowOf(getMainLooper()).idle() + assertTrue(confirmWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + confirmWasCalled = false + request.onConfirm(true, "newInput") + shadowOf(getMainLooper()).idle() + assertFalse(confirmWasCalled) + } + + @Test + fun `onPopupRequest must provide a Popup PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.Popup? = null + + val promptDelegate = GeckoPromptDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.Popup + } + }, + ) + + var geckoPrompt = geckoPopupPrompt(targetUri = "www.popuptest.com/") + promptDelegate.onPopupPrompt(mock(), geckoPrompt) + + with(request!!) { + assertEquals(targetUri, "www.popuptest.com/") + + onAllow() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.ALLOW)) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onAllow() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.ALLOW)) + } + + geckoPrompt = geckoPopupPrompt() + promptDelegate.onPopupPrompt(mock(), geckoPrompt) + + request!!.onDeny() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.DENY)) + whenever(geckoPrompt.isComplete).thenReturn(true) + + request!!.onDeny() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.DENY)) + } + + @Test + fun `onBeforeUnloadPrompt must provide a BeforeUnload PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.BeforeUnload? = null + val promptDelegate = GeckoPromptDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.BeforeUnload + } + }, + ) + + var geckoPrompt = geckoBeforeUnloadPrompt() + promptDelegate.onBeforeUnloadPrompt(mock(), geckoPrompt) + assertEquals(request!!.title, "") + + request!!.onLeave() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.ALLOW)) + whenever(geckoPrompt.isComplete).thenReturn(true) + + request!!.onLeave() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.ALLOW)) + + geckoPrompt = geckoBeforeUnloadPrompt() + promptDelegate.onBeforeUnloadPrompt(mock(), geckoPrompt) + + request!!.onStay() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.DENY)) + whenever(geckoPrompt.isComplete).thenReturn(true) + + request!!.onStay() + verify(geckoPrompt, times(1)).confirm(eq(AllowOrDeny.DENY)) + } + + @Test + fun `onBeforeUnloadPrompt will inform listeners when if navigation is cancelled`() { + val mockSession = GeckoEngineSession(runtime) + var onBeforeUnloadPromptCancelledCalled = false + var request: PromptRequest.BeforeUnload = mock() + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.BeforeUnload + } + + override fun onBeforeUnloadPromptDenied() { + onBeforeUnloadPromptCancelledCalled = true + } + }, + ) + val prompt = geckoBeforeUnloadPrompt() + doReturn(false).`when`(prompt).isComplete + + GeckoPromptDelegate(mockSession).onBeforeUnloadPrompt(mock(), prompt) + request.onStay() + + assertTrue(onBeforeUnloadPromptCancelledCalled) + } + + @Test + fun `onSharePrompt must provide a Share PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.Share? = null + var onSuccessWasCalled = false + var onFailureWasCalled = false + var onDismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.Share + } + }, + ) + + var geckoPrompt = geckoSharePrompt() + var geckoResult = promptDelegate.onSharePrompt(mock(), geckoPrompt) + geckoResult.accept { + onSuccessWasCalled = true + } + + with(request!!) { + assertEquals(data.title, "title") + assertEquals(data.text, "text") + assertEquals(data.url, "https://example.com") + + onSuccess() + shadowOf(getMainLooper()).idle() + assertTrue(onSuccessWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onSuccessWasCalled = false + onSuccess() + shadowOf(getMainLooper()).idle() + assertFalse(onSuccessWasCalled) + } + + geckoPrompt = geckoSharePrompt() + geckoResult = promptDelegate.onSharePrompt(mock(), geckoPrompt) + geckoResult.accept { + onFailureWasCalled = true + } + + request!!.onFailure() + shadowOf(getMainLooper()).idle() + assertTrue(onFailureWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onFailureWasCalled = false + request!!.onFailure() + shadowOf(getMainLooper()).idle() + + assertFalse(onFailureWasCalled) + + geckoPrompt = geckoSharePrompt() + geckoResult = promptDelegate.onSharePrompt(mock(), geckoPrompt) + geckoResult.accept { + onDismissWasCalled = true + } + + request!!.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onDismissWasCalled) + } + + @Test + fun `onButtonPrompt must provide a Confirm PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.Confirm = mock() + var onPositiveButtonWasCalled = false + var onNegativeButtonWasCalled = false + var onNeutralButtonWasCalled = false + var dismissWasCalled = false + + val promptDelegate = GeckoPromptDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.Confirm + } + }, + ) + + var geckoPrompt = geckoButtonPrompt() + var geckoResult = promptDelegate.onButtonPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onPositiveButtonWasCalled = true + } + + with(request) { + assertNotNull(request) + assertEquals(title, "title") + assertEquals(message, "message") + + onConfirmPositiveButton(false) + shadowOf(getMainLooper()).idle() + assertTrue(onPositiveButtonWasCalled) + + whenever(geckoPrompt.isComplete).thenReturn(true) + onPositiveButtonWasCalled = false + onConfirmPositiveButton(false) + shadowOf(getMainLooper()).idle() + + assertFalse(onPositiveButtonWasCalled) + } + + geckoPrompt = geckoButtonPrompt() + geckoResult = promptDelegate.onButtonPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onNeutralButtonWasCalled = true + } + + request.onConfirmNeutralButton(false) + shadowOf(getMainLooper()).idle() + assertTrue(onNeutralButtonWasCalled) + + geckoPrompt = geckoButtonPrompt() + geckoResult = promptDelegate.onButtonPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onNegativeButtonWasCalled = true + } + + request.onConfirmNegativeButton(false) + shadowOf(getMainLooper()).idle() + assertTrue(onNegativeButtonWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onNegativeButtonWasCalled = false + request.onConfirmNegativeButton(false) + shadowOf(getMainLooper()).idle() + + assertFalse(onNegativeButtonWasCalled) + + geckoResult = promptDelegate.onButtonPrompt(mock(), geckoButtonPrompt()) + geckoResult!!.accept { + dismissWasCalled = true + } + + request.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(dismissWasCalled) + } + + @Test + fun `onRepostConfirmPrompt must provide a Repost PromptRequest`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.Repost = mock() + var onPositiveButtonWasCalled = false + var onNegativeButtonWasCalled = false + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.Repost + } + }, + ) + + val promptDelegate = GeckoPromptDelegate(mockSession) + + var geckoPrompt = geckoRepostPrompt() + var geckoResult = promptDelegate.onRepostConfirmPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onPositiveButtonWasCalled = true + } + request.onConfirm() + shadowOf(getMainLooper()).idle() + assertTrue(onPositiveButtonWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onPositiveButtonWasCalled = false + request.onConfirm() + shadowOf(getMainLooper()).idle() + + assertFalse(onPositiveButtonWasCalled) + + geckoPrompt = geckoRepostPrompt() + geckoResult = promptDelegate.onRepostConfirmPrompt(mock(), geckoPrompt) + geckoResult!!.accept { + onNegativeButtonWasCalled = true + } + request.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(onNegativeButtonWasCalled) + whenever(geckoPrompt.isComplete).thenReturn(true) + + onNegativeButtonWasCalled = false + request.onDismiss() + shadowOf(getMainLooper()).idle() + + assertFalse(onNegativeButtonWasCalled) + } + + @Test + fun `onRepostConfirmPrompt will not be able to complete multiple times`() { + val mockSession = GeckoEngineSession(runtime) + var request: PromptRequest.Repost = mock() + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.Repost + } + }, + ) + + val promptDelegate = GeckoPromptDelegate(mockSession) + + var prompt = geckoRepostPrompt() + promptDelegate.onRepostConfirmPrompt(mock(), prompt) + doReturn(false).`when`(prompt).isComplete + request.onConfirm() + verify(prompt).confirm(any()) + + prompt = mock() + promptDelegate.onRepostConfirmPrompt(mock(), prompt) + doReturn(true).`when`(prompt).isComplete + request.onConfirm() + verify(prompt, never()).confirm(any()) + + prompt = mock() + promptDelegate.onRepostConfirmPrompt(mock(), prompt) + doReturn(false).`when`(prompt).isComplete + request.onDismiss() + verify(prompt).confirm(any()) + + prompt = mock() + promptDelegate.onRepostConfirmPrompt(mock(), prompt) + doReturn(true).`when`(prompt).isComplete + request.onDismiss() + verify(prompt, never()).confirm(any()) + } + + @Test + fun `onRepostConfirmPrompt will inform listeners when it is being dismissed`() { + val mockSession = GeckoEngineSession(runtime) + var onRepostPromptCancelledCalled = false + var request: PromptRequest.Repost = mock() + + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + request = promptRequest as PromptRequest.Repost + } + + override fun onRepostPromptCancelled() { + onRepostPromptCancelledCalled = true + } + }, + ) + val prompt = geckoRepostPrompt() + doReturn(false).`when`(prompt).isComplete + + GeckoPromptDelegate(mockSession).onRepostConfirmPrompt(mock(), prompt) + request.onDismiss() + + assertTrue(onRepostPromptCancelledCalled) + } + + @Test + fun `dismissSafely only dismiss if the prompt is NOT already dismissed`() { + val prompt = geckoAlertPrompt() + val geckoResult = mock<GeckoResult<GeckoSession.PromptDelegate.PromptResponse>>() + + doReturn(false).`when`(prompt).isComplete + + prompt.dismissSafely(geckoResult) + + verify(geckoResult).complete(any()) + } + + @Test + fun `dismissSafely do nothing if the prompt is already dismissed`() { + val prompt = geckoAlertPrompt() + val geckoResult = mock<GeckoResult<GeckoSession.PromptDelegate.PromptResponse>>() + + doReturn(true).`when`(prompt).isComplete + + prompt.dismissSafely(geckoResult) + + verify(geckoResult, never()).complete(any()) + } + + @Test + fun `WHEN onAddressSelect is called THEN SelectAddress prompt request must be provided with the correct callbacks`() { + val mockSession = GeckoEngineSession(runtime) + + var isOnConfirmCalled = false + var isOnDismissCalled = false + + var selectAddressPrompt: PromptRequest.SelectAddress = mock() + + val promptDelegate = spy(GeckoPromptDelegate(mockSession)) + + // Capture the SelectAddress prompt request + mockSession.register( + object : EngineSession.Observer { + override fun onPromptRequest(promptRequest: PromptRequest) { + selectAddressPrompt = promptRequest as PromptRequest.SelectAddress + } + }, + ) + + val address = Address( + guid = "1", + name = "Firefox", + organization = "-", + streetAddress = "street", + addressLevel3 = "address3", + addressLevel2 = "address2", + addressLevel1 = "address1", + postalCode = "1", + country = "Country", + tel = "1", + email = "@", + ) + val addressSelectOption = + Autocomplete.AddressSelectOption(address.toAutocompleteAddress()) + + var geckoPrompt = + geckoSelectAddressPrompt(arrayOf(addressSelectOption)) + + var geckoResult = promptDelegate.onAddressSelect( + mock(), + geckoPrompt, + ) + + // Verify that the onDismiss callback was called + geckoResult.accept { + isOnDismissCalled = true + } + + selectAddressPrompt.onDismiss() + shadowOf(getMainLooper()).idle() + assertTrue(isOnDismissCalled) + + // Verify that the onConfirm callback was called + geckoPrompt = + geckoSelectAddressPrompt(arrayOf(addressSelectOption)) + + geckoResult = promptDelegate.onAddressSelect( + mock(), + geckoPrompt, + ) + + geckoResult.accept { + isOnConfirmCalled = true + } + + selectAddressPrompt.onConfirm(selectAddressPrompt.addresses.first()) + shadowOf(getMainLooper()).idle() + assertTrue(isOnConfirmCalled) + + // Verify that when the prompt request is already completed and onConfirm callback is called, + // then onConfirm callback is not executed + isOnConfirmCalled = false + geckoPrompt = + geckoSelectAddressPrompt(arrayOf(addressSelectOption), true) + + geckoResult = promptDelegate.onAddressSelect( + mock(), + geckoPrompt, + ) + + geckoResult.accept { + isOnConfirmCalled = true + } + + selectAddressPrompt.onConfirm(selectAddressPrompt.addresses.first()) + shadowOf(getMainLooper()).idle() + assertFalse(isOnConfirmCalled) + } + + private fun geckoChoicePrompt( + title: String, + message: String, + type: Int, + choices: Array<out GeckoChoice>, + ): GeckoSession.PromptDelegate.ChoicePrompt { + val prompt: GeckoSession.PromptDelegate.ChoicePrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "type", type) + ReflectionUtils.setField(prompt, "message", message) + ReflectionUtils.setField(prompt, "choices", choices) + return prompt + } + + private fun geckoAlertPrompt( + title: String = "title", + message: String = "message", + ): GeckoSession.PromptDelegate.AlertPrompt { + val prompt: GeckoSession.PromptDelegate.AlertPrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "message", message) + return prompt + } + + private fun geckoDateTimePrompt( + title: String = "title", + type: Int, + defaultValue: String = "", + minValue: String = "", + maxValue: String = "", + stepValue: String = "", + ): GeckoSession.PromptDelegate.DateTimePrompt { + val prompt: GeckoSession.PromptDelegate.DateTimePrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "type", type) + ReflectionUtils.setField(prompt, "defaultValue", defaultValue) + ReflectionUtils.setField(prompt, "minValue", minValue) + ReflectionUtils.setField(prompt, "maxValue", maxValue) + ReflectionUtils.setField(prompt, "stepValue", stepValue) + return prompt + } + + private fun geckoFilePrompt( + title: String = "title", + type: Int, + capture: Int = 0, + mimeTypes: Array<out String> = emptyArray(), + ): GeckoSession.PromptDelegate.FilePrompt { + val prompt: GeckoSession.PromptDelegate.FilePrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "type", type) + ReflectionUtils.setField(prompt, "capture", capture) + ReflectionUtils.setField(prompt, "mimeTypes", mimeTypes) + return prompt + } + + private fun geckoAuthPrompt( + title: String = "title", + message: String = "message", + authOptions: GeckoSession.PromptDelegate.AuthPrompt.AuthOptions, + ): GeckoSession.PromptDelegate.AuthPrompt { + val prompt: GeckoSession.PromptDelegate.AuthPrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "message", message) + ReflectionUtils.setField(prompt, "authOptions", authOptions) + return prompt + } + + private fun geckoColorPrompt( + title: String = "title", + defaultValue: String = "defaultValue", + ): GeckoSession.PromptDelegate.ColorPrompt { + val prompt: GeckoSession.PromptDelegate.ColorPrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "defaultValue", defaultValue) + return prompt + } + + private fun geckoProviderSelectorPrompt( + providers: List<GECKO_PROMPT_PROVIDER_SELECTOR> = emptyList(), + ): GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt { + val prompt: GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt = mock() + ReflectionUtils.setField(prompt, "providers", providers.toTypedArray()) + return prompt + } + + private fun geckoAccountSelectorPrompt( + accounts: List<GECKO_PROMPT_ACCOUNT_SELECTOR> = emptyList(), + provider: GECKO_PROMPT_ACCOUNT_SELECTOR_PROVIDER, + ): GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt { + val prompt: GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt = mock() + ReflectionUtils.setField(prompt, "accounts", accounts.toTypedArray()) + ReflectionUtils.setField(prompt, "provider", provider) + return prompt + } + + private fun geckoPrivacyPolicyPrompt(): GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt { + val prompt: GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt = mock() + ReflectionUtils.setField(prompt, "privacyPolicyUrl", "privacyPolicyUrl") + ReflectionUtils.setField(prompt, "termsOfServiceUrl", "termsOfServiceUrl") + ReflectionUtils.setField(prompt, "providerDomain", "providerDomain") + ReflectionUtils.setField(prompt, "host", "host") + ReflectionUtils.setField(prompt, "icon", "icon") + return prompt + } + private fun geckoTextPrompt( + title: String = "title", + message: String = "message", + defaultValue: String = "defaultValue", + ): GeckoSession.PromptDelegate.TextPrompt { + val prompt: GeckoSession.PromptDelegate.TextPrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "message", message) + ReflectionUtils.setField(prompt, "defaultValue", defaultValue) + return prompt + } + + private fun geckoPopupPrompt( + targetUri: String = "targetUri", + ): GeckoSession.PromptDelegate.PopupPrompt { + val prompt: GeckoSession.PromptDelegate.PopupPrompt = mock() + ReflectionUtils.setField(prompt, "targetUri", targetUri) + return prompt + } + + private fun geckoBeforeUnloadPrompt(): GeckoSession.PromptDelegate.BeforeUnloadPrompt { + return mock() + } + + private fun geckoSharePrompt( + title: String? = "title", + text: String? = "text", + url: String? = "https://example.com", + ): GeckoSession.PromptDelegate.SharePrompt { + val prompt: GeckoSession.PromptDelegate.SharePrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "text", text) + ReflectionUtils.setField(prompt, "uri", url) + return prompt + } + + private fun geckoButtonPrompt( + title: String = "title", + message: String = "message", + ): GeckoSession.PromptDelegate.ButtonPrompt { + val prompt: GeckoSession.PromptDelegate.ButtonPrompt = mock() + ReflectionUtils.setField(prompt, "title", title) + ReflectionUtils.setField(prompt, "message", message) + return prompt + } + + private fun geckoLoginSelectPrompt( + loginArray: Array<Autocomplete.LoginSelectOption>, + ): GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption> { + val prompt: GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption> = mock() + ReflectionUtils.setField(prompt, "options", loginArray) + return prompt + } + + @Suppress("UNCHECKED_CAST") + private fun geckoLoginSavePrompt( + login: Array<Autocomplete.LoginSaveOption>, + ): GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption> { + val prompt = Mockito.mock( + GeckoSession.PromptDelegate.AutocompleteRequest::class.java, + Mockito.RETURNS_DEEP_STUBS, // for testing prompt.delegate + ) as GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption> + + ReflectionUtils.setField(prompt, "options", login) + return prompt + } + + private fun geckoAuthOptions(): GeckoSession.PromptDelegate.AuthPrompt.AuthOptions { + return mock() + } + + private fun geckoRepostPrompt(): GeckoSession.PromptDelegate.RepostConfirmPrompt { + return mock() + } + + private fun geckoSelectCreditCardPrompt( + creditCards: Array<Autocomplete.CreditCardSelectOption>, + ): GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.CreditCardSelectOption> { + val prompt: GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.CreditCardSelectOption> = + mock() + ReflectionUtils.setField(prompt, "options", creditCards) + return prompt + } + + private fun geckoSelectAddressPrompt( + addresses: Array<Autocomplete.AddressSelectOption>, + isComplete: Boolean = false, + ): GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.AddressSelectOption> { + val prompt: GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.AddressSelectOption> = + mock() + whenever(prompt.isComplete).thenReturn(isComplete) + ReflectionUtils.setField(prompt, "options", addresses) + return prompt + } + + @Suppress("UNCHECKED_CAST") + private fun geckoCreditCardSavePrompt( + creditCard: Array<Autocomplete.CreditCardSaveOption>, + ): GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.CreditCardSaveOption> { + val prompt = Mockito.mock( + GeckoSession.PromptDelegate.AutocompleteRequest::class.java, + Mockito.RETURNS_DEEP_STUBS, // for testing prompt.delegate + ) as GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.CreditCardSaveOption> + + ReflectionUtils.setField(prompt, "options", creditCard) + return prompt + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegateTest.kt new file mode 100644 index 0000000000..6ae5f87862 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegateTest.kt @@ -0,0 +1,40 @@ +/* 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.browser.engine.gecko.prompt + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.prompt.PromptRequest +import mozilla.components.support.test.mock +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.Autocomplete +import org.mozilla.geckoview.GeckoSession + +@RunWith(AndroidJUnit4::class) +class PromptInstanceDismissDelegateTest { + + @Test + fun `GIVEN delegate with promptRequest WHEN onPromptDismiss called from geckoview THEN notifyObservers the prompt is dismissed`() { + val mockSession = GeckoEngineSession(mock()) + var onDismissWasCalled = false + mockSession.register( + object : EngineSession.Observer { + override fun onPromptDismissed(promptRequest: PromptRequest) { + super.onPromptDismissed(promptRequest) + onDismissWasCalled = true + } + }, + ) + val basePrompt: GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption> = mock() + val prompt: PromptRequest.SingleChoice = mock() + val delegate = PromptInstanceDismissDelegate(mockSession, prompt) + + delegate.onPromptDismiss(basePrompt) + + assertTrue(onDismissWasCalled) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegateTest.kt new file mode 100644 index 0000000000..8e784cacc1 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegateTest.kt @@ -0,0 +1,92 @@ +/* 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.browser.engine.gecko.selection + +import android.app.Activity +import android.app.Application +import android.app.Service +import android.view.MenuItem +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.support.test.mock +import org.junit.Assert +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class GeckoSelectionActionDelegateTest { + + @Test + fun `maybe create with non-activity context should return null`() { + val customDelegate = mock<SelectionActionDelegate>() + + assertNull(GeckoSelectionActionDelegate.maybeCreate(mock<Application>(), customDelegate)) + assertNull(GeckoSelectionActionDelegate.maybeCreate(mock<Service>(), customDelegate)) + } + + @Test + fun `maybe create with null delegate context should return null`() { + assertNull(GeckoSelectionActionDelegate.maybeCreate(mock<Activity>(), null)) + } + + @Test + fun `maybe create with expected inputs should return non-null`() { + assertNotNull(GeckoSelectionActionDelegate.maybeCreate(mock<Activity>(), mock())) + } + + @Test + fun `getAllActions should contain all actions from the custom delegate`() { + val customActions = arrayOf("1", "2", "3") + val customDelegate = object : SelectionActionDelegate { + override fun getAllActions(): Array<String> = customActions + override fun isActionAvailable(id: String, selectedText: String): Boolean = false + override fun getActionTitle(id: String): CharSequence? = "" + override fun performAction(id: String, selectedText: String): Boolean = false + override fun sortedActions(actions: Array<String>): Array<String> { + return actions + } + } + + val geckoDelegate = TestGeckoSelectionActionDelegate(mock(), customDelegate) + + val actualActions = geckoDelegate.allActions + + customActions.forEach { + Assert.assertTrue(actualActions.contains(it)) + } + } + + @Test + fun `WHEN perform action triggers a security exception THEN false is returned`() { + val customActions = arrayOf("1", "2", "3") + val customDelegate = object : SelectionActionDelegate { + override fun getAllActions(): Array<String> = customActions + override fun isActionAvailable(id: String, selectedText: String): Boolean = false + override fun getActionTitle(id: String): CharSequence? = "" + override fun performAction(id: String, selectedText: String): Boolean { + throw SecurityException("test") + } + override fun sortedActions(actions: Array<String>): Array<String> { + return actions + } + } + + val geckoDelegate = TestGeckoSelectionActionDelegate(mock(), customDelegate) + assertFalse(geckoDelegate.performAction("test", mock())) + } +} + +/** + * Test object that overrides visibility for [getAllActions] + */ +class TestGeckoSelectionActionDelegate( + activity: Activity, + customDelegate: SelectionActionDelegate, +) : GeckoSelectionActionDelegate(activity, customDelegate) { + public override fun getAllActions() = super.getAllActions() + public override fun performAction(id: String, item: MenuItem): Boolean { + return super.performAction(id, item) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegateTest.kt new file mode 100644 index 0000000000..8e4cbf2983 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegateTest.kt @@ -0,0 +1,40 @@ +/* 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.browser.engine.gecko.serviceworker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.engine.serviceworker.ServiceWorkerDelegate +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn + +@RunWith(AndroidJUnit4::class) +class GeckoServiceWorkerDelegateTest() { + @Test + fun `GIVEN a delegate to add tabs WHEN it added a new tab for the request to open a new window THEN return a the new closed session`() { + val delegate = mock<ServiceWorkerDelegate>() + doReturn(true).`when`(delegate).addNewTab(any()) + val geckoDelegate = GeckoServiceWorkerDelegate(delegate, mock(), mock()) + + val result = geckoDelegate.onOpenWindow("").poll(1) + + assertFalse(result!!.isOpen) + } + + @Test + fun `GIVEN a delegate to add tabs WHEN it disn't add a new tab for the request to open a new window THEN return null`() { + val delegate = mock<ServiceWorkerDelegate>() + doReturn(false).`when`(delegate).addNewTab(any()) + val geckoDelegate = GeckoServiceWorkerDelegate(delegate, mock(), mock()) + + val result = geckoDelegate.onOpenWindow("").poll(1) + + assertNull(result) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegateTest.kt new file mode 100644 index 0000000000..65a1c7d8f9 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegateTest.kt @@ -0,0 +1,103 @@ +/* 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.browser.engine.gecko.translate +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertTrue +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.translate.TranslationEngineState +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.TranslationsController + +@RunWith(AndroidJUnit4::class) +class GeckoTranslateSessionDelegateTest { + private lateinit var runtime: GeckoRuntime + private lateinit var mockSession: GeckoEngineSession + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + mockSession = GeckoEngineSession(runtime) + } + + @Test + fun `WHEN onExpectedTranslate is called THEN notify onTranslateExpected`() { + var onTranslateExpectedWasCalled = false + val gecko = GeckoTranslateSessionDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onTranslateExpected() { + onTranslateExpectedWasCalled = true + } + }, + ) + + gecko.onExpectedTranslate(mock()) + + assertTrue(onTranslateExpectedWasCalled) + } + + @Test + fun `WHEN onOfferTranslate is called THEN notify onTranslateOffer`() { + var onTranslateOfferWasCalled = false + val gecko = GeckoTranslateSessionDelegate(mockSession) + + mockSession.register( + object : EngineSession.Observer { + override fun onTranslateOffer() { + onTranslateOfferWasCalled = true + } + }, + ) + + gecko.onOfferTranslate(mock()) + + assertTrue(onTranslateOfferWasCalled) + } + + @Test + fun `WHEN onTranslationStateChange is called THEN notify onTranslateStateChange AND ensure mapped values are correct`() { + var onTranslateStateChangeWasCalled = false + val gecko = GeckoTranslateSessionDelegate(mockSession) + + // Mock state parameters to check Gecko to AC mapping is correctly occurring + var userLangTag = "en" + var isDocLangTagSupported = true + var docLangTag = "es" + var fromLanguage = "de" + var toLanguage = "bg" + var error = "Error!" + var isEngineReady = false + + mockSession.register( + object : EngineSession.Observer { + override fun onTranslateStateChange(state: TranslationEngineState) { + onTranslateStateChangeWasCalled = true + assertTrue(state.detectedLanguages?.userPreferredLangTag == userLangTag) + assertTrue(state.detectedLanguages?.supportedDocumentLang == isDocLangTagSupported) + assertTrue(state.detectedLanguages?.documentLangTag == docLangTag) + assertTrue(state.requestedTranslationPair?.fromLanguage == fromLanguage) + assertTrue(state.requestedTranslationPair?.toLanguage == toLanguage) + assertTrue(state.error == error) + assertTrue(state.isEngineReady == isEngineReady) + } + }, + ) + + // Mock states + var mockDetectedLanguages = TranslationsController.SessionTranslation.DetectedLanguages(userLangTag, isDocLangTagSupported, docLangTag) + var mockTranslationsPair = TranslationsController.SessionTranslation.TranslationPair(fromLanguage, toLanguage) + var mockGeckoState = TranslationsController.SessionTranslation.TranslationState(mockTranslationsPair, error, mockDetectedLanguages, isEngineReady) + gecko.onTranslationStateChange(mock(), mockGeckoState) + + assertTrue(onTranslateStateChangeWasCalled) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactoryTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactoryTest.kt new file mode 100644 index 0000000000..e660a3761f --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactoryTest.kt @@ -0,0 +1,129 @@ +/* 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.browser.engine.gecko.util + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoRuntime + +@RunWith(AndroidJUnit4::class) +class SpeculativeSessionFactoryTest { + + private lateinit var runtime: GeckoRuntime + + @Before + fun setup() { + runtime = mock() + whenever(runtime.settings).thenReturn(mock()) + } + + @Test + fun `create does nothing if matching speculative session already exists`() { + val factory = SpeculativeSessionFactory() + assertNull(factory.speculativeEngineSession) + + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + val speculativeSession = factory.speculativeEngineSession + assertNotNull(speculativeSession) + + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + assertSame(speculativeSession, factory.speculativeEngineSession) + } + + @Test + fun `create clears previous non-matching speculative session`() { + val factory = SpeculativeSessionFactory() + assertNull(factory.speculativeEngineSession) + + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + val speculativeSession = factory.speculativeEngineSession + assertNotNull(speculativeSession) + + factory.create(runtime = runtime, private = false, contextId = null, defaultSettings = mock()) + assertNotSame(speculativeSession, factory.speculativeEngineSession) + assertFalse(speculativeSession!!.engineSession.geckoSession.isOpen) + assertFalse(speculativeSession.engineSession.isObserved()) + } + + @Test + fun `get consumes matching speculative session`() { + val factory = SpeculativeSessionFactory() + assertFalse(factory.hasSpeculativeSession()) + + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + assertTrue(factory.hasSpeculativeSession()) + + val speculativeSession = factory.get(private = true, contextId = null) + assertNotNull(speculativeSession) + assertFalse(speculativeSession!!.isObserved()) + + assertFalse(factory.hasSpeculativeSession()) + assertNull(factory.speculativeEngineSession) + } + + @Test + fun `get clears previous non-matching speculative session`() { + val factory = SpeculativeSessionFactory() + assertNull(factory.speculativeEngineSession) + + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + val speculativeSession = factory.speculativeEngineSession + assertNotNull(speculativeSession) + + assertNull(factory.get(private = true, contextId = "test")) + assertFalse(speculativeSession!!.engineSession.geckoSession.isOpen) + assertFalse(speculativeSession.engineSession.isObserved()) + } + + @Test + fun `clears speculative session on crash`() { + val factory = SpeculativeSessionFactory() + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + assertTrue(factory.hasSpeculativeSession()) + val speculativeSession = factory.speculativeEngineSession + + factory.speculativeEngineSession?.engineSession?.notifyObservers { onCrash() } + assertFalse(factory.hasSpeculativeSession()) + assertFalse(speculativeSession!!.engineSession.geckoSession.isOpen) + assertFalse(speculativeSession.engineSession.isObserved()) + } + + @Test + fun `clears speculative session when process is killed`() { + val factory = SpeculativeSessionFactory() + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + assertTrue(factory.hasSpeculativeSession()) + val speculativeSession = factory.speculativeEngineSession + + factory.speculativeEngineSession?.engineSession?.notifyObservers { onProcessKilled() } + assertFalse(factory.hasSpeculativeSession()) + assertFalse(speculativeSession!!.engineSession.geckoSession.isOpen) + assertFalse(speculativeSession.engineSession.isObserved()) + } + + @Test + fun `clear unregisters observer and closes session`() { + val factory = SpeculativeSessionFactory() + factory.create(runtime = runtime, private = true, contextId = null, defaultSettings = mock()) + assertTrue(factory.hasSpeculativeSession()) + val speculativeSession = factory.speculativeEngineSession + assertTrue(speculativeSession!!.engineSession.isObserved()) + + factory.clear() + assertFalse(factory.hasSpeculativeSession()) + assertFalse(speculativeSession.engineSession.geckoSession.isOpen) + assertFalse(speculativeSession.engineSession.isObserved()) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt new file mode 100644 index 0000000000..dd53da92bb --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt @@ -0,0 +1,644 @@ +/* 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.browser.engine.gecko.webextension + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.concept.engine.DefaultSettings +import mozilla.components.concept.engine.webextension.Action +import mozilla.components.concept.engine.webextension.ActionHandler +import mozilla.components.concept.engine.webextension.DisabledFlags +import mozilla.components.concept.engine.webextension.Incognito +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.TabHandler +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 mozilla.components.support.test.whenever +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.Image +import org.mozilla.geckoview.WebExtension +import org.mozilla.geckoview.WebExtensionController + +@RunWith(AndroidJUnit4::class) +class GeckoWebExtensionTest { + + @Test + fun `register background message handler`() { + val runtime: GeckoRuntime = mock() + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + val messageHandler: MessageHandler = mock() + val updatedMessageHandler: MessageHandler = mock() + val messageDelegateCaptor = argumentCaptor<WebExtension.MessageDelegate>() + val portCaptor = argumentCaptor<Port>() + val portDelegateCaptor = argumentCaptor<WebExtension.PortDelegate>() + + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + + extension.registerBackgroundMessageHandler("mozacTest", messageHandler) + verify(nativeGeckoWebExt).setMessageDelegate(messageDelegateCaptor.capture(), eq("mozacTest")) + + // Verify messages are forwarded to message handler + val message: Any = mock() + val sender: WebExtension.MessageSender = mock() + whenever(messageHandler.onMessage(eq(message), eq(null))).thenReturn("result") + assertNotNull(messageDelegateCaptor.value.onMessage("mozacTest", message, sender)) + verify(messageHandler).onMessage(eq(message), eq(null)) + + whenever(messageHandler.onMessage(eq(message), eq(null))).thenReturn(null) + assertNull(messageDelegateCaptor.value.onMessage("mozacTest", message, sender)) + verify(messageHandler, times(2)).onMessage(eq(message), eq(null)) + + // Verify port is connected and forwarded to message handler + val port: WebExtension.Port = mock() + messageDelegateCaptor.value.onConnect(port) + verify(messageHandler).onPortConnected(portCaptor.capture()) + assertSame(port, (portCaptor.value as GeckoPort).nativePort) + assertNotNull(extension.getConnectedPort("mozacTest")) + assertSame(port, (extension.getConnectedPort("mozacTest") as GeckoPort).nativePort) + + // Verify port messages are forwarded to message handler + verify(port).setDelegate(portDelegateCaptor.capture()) + val portDelegate = portDelegateCaptor.value + val portMessage: JSONObject = mock() + portDelegate.onPortMessage(portMessage, port) + verify(messageHandler).onPortMessage(eq(portMessage), portCaptor.capture()) + assertSame(port, (portCaptor.value as GeckoPort).nativePort) + + // Verify content message handler can be updated and receive messages + extension.registerBackgroundMessageHandler("mozacTest", updatedMessageHandler) + verify(port, times(2)).setDelegate(portDelegateCaptor.capture()) + portDelegateCaptor.value.onPortMessage(portMessage, port) + verify(updatedMessageHandler).onPortMessage(eq(portMessage), portCaptor.capture()) + + // Verify disconnected port is forwarded to message handler if connected + portDelegate.onDisconnect(mock()) + verify(messageHandler, never()).onPortDisconnected(portCaptor.capture()) + + portDelegate.onDisconnect(port) + verify(messageHandler).onPortDisconnected(portCaptor.capture()) + assertSame(port, (portCaptor.value as GeckoPort).nativePort) + assertNull(extension.getConnectedPort("mozacTest")) + } + + @Test + fun `register content message handler`() { + val runtime: GeckoRuntime = mock() + val webExtensionSessionController: WebExtension.SessionController = mock() + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + val messageHandler: MessageHandler = mock() + val updatedMessageHandler: MessageHandler = mock() + val session: GeckoEngineSession = mock() + val geckoSession: GeckoSession = mock() + val messageDelegateCaptor = argumentCaptor<WebExtension.MessageDelegate>() + val portCaptor = argumentCaptor<Port>() + val portDelegateCaptor = argumentCaptor<WebExtension.PortDelegate>() + + whenever(geckoSession.webExtensionController).thenReturn(webExtensionSessionController) + whenever(session.geckoSession).thenReturn(geckoSession) + + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + assertFalse(extension.hasContentMessageHandler(session, "mozacTest")) + extension.registerContentMessageHandler(session, "mozacTest", messageHandler) + verify(webExtensionSessionController).setMessageDelegate(eq(nativeGeckoWebExt), messageDelegateCaptor.capture(), eq("mozacTest")) + + // Verify messages are forwarded to message handler and return value passed on + val message: Any = mock() + val sender: WebExtension.MessageSender = mock() + whenever(messageHandler.onMessage(eq(message), eq(session))).thenReturn("result") + assertNotNull(messageDelegateCaptor.value.onMessage("mozacTest", message, sender)) + verify(messageHandler).onMessage(eq(message), eq(session)) + + whenever(messageHandler.onMessage(eq(message), eq(session))).thenReturn(null) + assertNull(messageDelegateCaptor.value.onMessage("mozacTest", message, sender)) + verify(messageHandler, times(2)).onMessage(eq(message), eq(session)) + + // Verify port is connected and forwarded to message handler + val port: WebExtension.Port = mock() + messageDelegateCaptor.value.onConnect(port) + verify(messageHandler).onPortConnected(portCaptor.capture()) + assertSame(port, (portCaptor.value as GeckoPort).nativePort) + assertSame(session, (portCaptor.value as GeckoPort).engineSession) + assertNotNull(extension.getConnectedPort("mozacTest", session)) + assertSame(port, (extension.getConnectedPort("mozacTest", session) as GeckoPort).nativePort) + + // Verify port messages are forwarded to message handler + verify(port).setDelegate(portDelegateCaptor.capture()) + val portDelegate = portDelegateCaptor.value + val portMessage: JSONObject = mock() + portDelegate.onPortMessage(portMessage, port) + verify(messageHandler).onPortMessage(eq(portMessage), portCaptor.capture()) + assertSame(port, (portCaptor.value as GeckoPort).nativePort) + assertSame(session, (portCaptor.value as GeckoPort).engineSession) + + // Verify content message handler can be updated and receive messages + extension.registerContentMessageHandler(session, "mozacTest", updatedMessageHandler) + verify(port, times(2)).setDelegate(portDelegateCaptor.capture()) + portDelegateCaptor.value.onPortMessage(portMessage, port) + verify(updatedMessageHandler).onPortMessage(eq(portMessage), portCaptor.capture()) + + // Verify disconnected port is forwarded to message handler if connected + portDelegate.onDisconnect(mock()) + verify(messageHandler, never()).onPortDisconnected(portCaptor.capture()) + + portDelegate.onDisconnect(port) + verify(messageHandler).onPortDisconnected(portCaptor.capture()) + assertSame(port, (portCaptor.value as GeckoPort).nativePort) + assertSame(session, (portCaptor.value as GeckoPort).engineSession) + assertNull(extension.getConnectedPort("mozacTest", session)) + } + + @Test + fun `disconnect port from content script`() { + val runtime: GeckoRuntime = mock() + val webExtensionSessionController: WebExtension.SessionController = mock() + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + val messageHandler: MessageHandler = mock() + val session: GeckoEngineSession = mock() + val geckoSession: GeckoSession = mock() + val messageDelegateCaptor = argumentCaptor<WebExtension.MessageDelegate>() + + whenever(geckoSession.webExtensionController).thenReturn(webExtensionSessionController) + whenever(session.geckoSession).thenReturn(geckoSession) + + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + extension.registerContentMessageHandler(session, "mozacTest", messageHandler) + verify(webExtensionSessionController).setMessageDelegate(eq(nativeGeckoWebExt), messageDelegateCaptor.capture(), eq("mozacTest")) + + // Connect port + val port: WebExtension.Port = mock() + messageDelegateCaptor.value.onConnect(port) + assertNotNull(extension.getConnectedPort("mozacTest", session)) + + // Disconnect port + extension.disconnectPort("mozacTest", session) + verify(port).disconnect() + assertNull(extension.getConnectedPort("mozacTest", session)) + } + + @Test + fun `disconnect port from background script`() { + val runtime: GeckoRuntime = mock() + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + val messageHandler: MessageHandler = mock() + val messageDelegateCaptor = argumentCaptor<WebExtension.MessageDelegate>() + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + extension.registerBackgroundMessageHandler("mozacTest", messageHandler) + + verify(nativeGeckoWebExt).setMessageDelegate(messageDelegateCaptor.capture(), eq("mozacTest")) + + // Connect port + val port: WebExtension.Port = mock() + messageDelegateCaptor.value.onConnect(port) + assertNotNull(extension.getConnectedPort("mozacTest")) + + // Disconnect port + extension.disconnectPort("mozacTest") + verify(port).disconnect() + assertNull(extension.getConnectedPort("mozacTest")) + } + + @Test + fun `register global default action handler`() { + val runtime: GeckoRuntime = mock() + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + val actionHandler: ActionHandler = mock() + val actionDelegateCaptor = argumentCaptor<WebExtension.ActionDelegate>() + val browserActionCaptor = argumentCaptor<Action>() + val pageActionCaptor = argumentCaptor<Action>() + val nativeBrowserAction: WebExtension.Action = mock() + val nativePageAction: WebExtension.Action = mock() + + // Create extension and register global default action handler + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + extension.registerActionHandler(actionHandler) + verify(nativeGeckoWebExt).setActionDelegate(actionDelegateCaptor.capture()) + + // Verify that browser actions are forwarded to the handler + actionDelegateCaptor.value.onBrowserAction(nativeGeckoWebExt, null, nativeBrowserAction) + verify(actionHandler).onBrowserAction(eq(extension), eq(null), browserActionCaptor.capture()) + + // Verify that page actions are forwarded to the handler + actionDelegateCaptor.value.onPageAction(nativeGeckoWebExt, null, nativePageAction) + verify(actionHandler).onPageAction(eq(extension), eq(null), pageActionCaptor.capture()) + + // Verify that toggle popup is forwarded to the handler + actionDelegateCaptor.value.onTogglePopup(nativeGeckoWebExt, nativeBrowserAction) + verify(actionHandler).onToggleActionPopup(eq(extension), any()) + + // We don't have access to the native WebExtension.Action fields and + // can't mock them either, but we can verify that we've linked + // the actions by simulating a click. + browserActionCaptor.value.onClick() + verify(nativeBrowserAction).click() + pageActionCaptor.value.onClick() + verify(nativePageAction).click() + } + + @Test + fun `register session-specific action handler`() { + val runtime: GeckoRuntime = mock() + val webExtensionSessionController: WebExtension.SessionController = mock() + val session: GeckoEngineSession = mock() + val geckoSession: GeckoSession = mock() + whenever(geckoSession.webExtensionController).thenReturn(webExtensionSessionController) + whenever(session.geckoSession).thenReturn(geckoSession) + + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + val actionHandler: ActionHandler = mock() + val actionDelegateCaptor = argumentCaptor<WebExtension.ActionDelegate>() + val browserActionCaptor = argumentCaptor<Action>() + val pageActionCaptor = argumentCaptor<Action>() + val nativeBrowserAction: WebExtension.Action = mock() + val nativePageAction: WebExtension.Action = mock() + + // Create extension and register action handler for session + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + extension.registerActionHandler(session, actionHandler) + verify(webExtensionSessionController).setActionDelegate(eq(nativeGeckoWebExt), actionDelegateCaptor.capture()) + + whenever(webExtensionSessionController.getActionDelegate(nativeGeckoWebExt)).thenReturn(actionDelegateCaptor.value) + assertTrue(extension.hasActionHandler(session)) + + // Verify that browser actions are forwarded to the handler + actionDelegateCaptor.value.onBrowserAction(nativeGeckoWebExt, null, nativeBrowserAction) + verify(actionHandler).onBrowserAction(eq(extension), eq(session), browserActionCaptor.capture()) + + // Verify that page actions are forwarded to the handler + actionDelegateCaptor.value.onPageAction(nativeGeckoWebExt, null, nativePageAction) + verify(actionHandler).onPageAction(eq(extension), eq(session), pageActionCaptor.capture()) + + // We don't have access to the native WebExtension.Action fields and + // can't mock them either, but we can verify that we've linked + // the actions by simulating a click. + browserActionCaptor.value.onClick() + verify(nativeBrowserAction).click() + pageActionCaptor.value.onClick() + verify(nativePageAction).click() + } + + @Test + fun `register global tab handler`() { + val runtime: GeckoRuntime = mock() + whenever(runtime.settings).thenReturn(mock()) + whenever(runtime.webExtensionController).thenReturn(mock()) + val tabHandler: TabHandler = mock() + val tabDelegateCaptor = argumentCaptor<WebExtension.TabDelegate>() + val engineSessionCaptor = argumentCaptor<GeckoEngineSession>() + + val nativeGeckoWebExt: WebExtension = + mockNativeWebExtension(id = "id", location = "uri", metaData = mockNativeWebExtensionMetaData()) + + // Create extension and register global tab handler + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + val defaultSettings: DefaultSettings = mock() + + extension.registerTabHandler(tabHandler, defaultSettings) + verify(nativeGeckoWebExt).tabDelegate = tabDelegateCaptor.capture() + + // Verify that tab methods are forwarded to the handler + val tabDetails = mockCreateTabDetails(active = true, url = "url") + tabDelegateCaptor.value.onNewTab(nativeGeckoWebExt, tabDetails) + verify(tabHandler).onNewTab(eq(extension), engineSessionCaptor.capture(), eq(true), eq("url")) + assertNotNull(engineSessionCaptor.value) + + tabDelegateCaptor.value.onOpenOptionsPage(nativeGeckoWebExt) + verify(tabHandler, never()).onNewTab(eq(extension), any(), eq(false), eq("http://options-page.moz")) + + val nativeGeckoWebExtWithMetadata = + mockNativeWebExtension(id = "id", location = "uri", metaData = mockNativeWebExtensionMetaData()) + tabDelegateCaptor.value.onOpenOptionsPage(nativeGeckoWebExtWithMetadata) + verify(tabHandler, never()).onNewTab(eq(extension), any(), eq(false), eq("http://options-page.moz")) + + val nativeGeckoWebExtWithOptionsPageUrl = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData(optionsPageUrl = "http://options-page.moz"), + ) + tabDelegateCaptor.value.onOpenOptionsPage(nativeGeckoWebExtWithOptionsPageUrl) + verify(tabHandler).onNewTab(eq(extension), any(), eq(false), eq("http://options-page.moz")) + } + + @Test + fun `register session-specific tab handler`() { + val runtime: GeckoRuntime = mock() + whenever(runtime.webExtensionController).thenReturn(mock()) + val webExtensionSessionController: WebExtension.SessionController = mock() + val session: GeckoEngineSession = mock() + val geckoSession: GeckoSession = mock() + whenever(geckoSession.webExtensionController).thenReturn(webExtensionSessionController) + whenever(session.geckoSession).thenReturn(geckoSession) + + val tabHandler: TabHandler = mock() + val tabDelegateCaptor = argumentCaptor<WebExtension.SessionTabDelegate>() + + val nativeGeckoWebExt: WebExtension = mockNativeWebExtension() + // Create extension and register tab handler for session + val extension = GeckoWebExtension( + runtime = runtime, + nativeExtension = nativeGeckoWebExt, + ) + extension.registerTabHandler(session, tabHandler) + verify(webExtensionSessionController).setTabDelegate(eq(nativeGeckoWebExt), tabDelegateCaptor.capture()) + + assertFalse(extension.hasTabHandler(session)) + whenever(webExtensionSessionController.getTabDelegate(nativeGeckoWebExt)).thenReturn(tabDelegateCaptor.value) + assertTrue(extension.hasTabHandler(session)) + + // Verify that tab methods are forwarded to the handler + val tabDetails = mockUpdateTabDetails(active = true) + tabDelegateCaptor.value.onUpdateTab(nativeGeckoWebExt, mock(), tabDetails) + verify(tabHandler).onUpdateTab(eq(extension), eq(session), eq(true), eq(null)) + + tabDelegateCaptor.value.onCloseTab(nativeGeckoWebExt, mock()) + verify(tabHandler).onCloseTab(eq(extension), eq(session)) + } + + @Test + fun `all metadata fields are mapped correctly`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val nativeWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData( + origins = arrayOf("o1", "o2"), + description = "desc", + version = "1.0", + creatorName = "developer1", + creatorUrl = "https://developer1.dev", + homepageUrl = "https://mozilla.org", + name = "myextension", + optionsPageUrl = "http://options-page.moz", + baseUrl = "moz-extension://123c5c5b-cd03-4bea-b23f-ac0b9ab40257/", + openOptionsPageInTab = false, + disabledFlags = DisabledFlags.USER, + temporary = true, + permissions = arrayOf("p1", "p2"), + optionalPermissions = arrayOf("clipboardRead"), + grantedOptionalPermissions = arrayOf("clipboardRead"), + optionalOrigins = arrayOf("*://*.example.com/*", "*://opt-host-perm.example.com/*"), + grantedOptionalOrigins = arrayOf("*://*.example.com/*"), + fullDescription = "fullDescription", + downloadUrl = "downloadUrl", + reviewUrl = "reviewUrl", + updateDate = "updateDate", + reviewCount = 2, + averageRating = 2.2, + incognito = "split", + ), + ) + val extensionWithMetadata = GeckoWebExtension(nativeWebExtension, runtime) + val metadata = extensionWithMetadata.getMetadata() + assertNotNull(metadata) + + assertEquals("1.0", metadata.version) + assertEquals(listOf("clipboardRead"), metadata.optionalPermissions) + assertEquals(listOf("clipboardRead"), metadata.grantedOptionalPermissions) + assertEquals(listOf("*://*.example.com/*", "*://opt-host-perm.example.com/*"), metadata.optionalOrigins) + assertEquals(listOf("*://*.example.com/*"), metadata.grantedOptionalOrigins) + assertEquals(listOf("p1", "p2"), metadata.permissions) + assertEquals(listOf("o1", "o2"), metadata.hostPermissions) + assertEquals("desc", metadata.description) + assertEquals("developer1", metadata.developerName) + assertEquals("https://developer1.dev", metadata.developerUrl) + assertEquals("https://mozilla.org", metadata.homepageUrl) + assertEquals("myextension", metadata.name) + assertEquals("http://options-page.moz", metadata.optionsPageUrl) + assertEquals("moz-extension://123c5c5b-cd03-4bea-b23f-ac0b9ab40257/", metadata.baseUrl) + assertEquals("fullDescription", metadata.fullDescription) + assertEquals("downloadUrl", metadata.downloadUrl) + assertEquals("reviewUrl", metadata.reviewUrl) + assertEquals("updateDate", metadata.updateDate) + assertEquals(2, metadata.reviewCount) + assertEquals(2.2f, metadata.averageRating) + assertFalse(metadata.openOptionsPageInTab) + assertTrue(metadata.temporary) + assertTrue(metadata.disabledFlags.contains(DisabledFlags.USER)) + assertFalse(metadata.disabledFlags.contains(DisabledFlags.BLOCKLIST)) + assertFalse(metadata.disabledFlags.contains(DisabledFlags.APP_SUPPORT)) + assertEquals(Incognito.SPLIT, metadata.incognito) + } + + @Test + fun `nullable metadata fields`() { + val runtime: GeckoRuntime = mock() + val webExtensionController: WebExtensionController = mock() + whenever(runtime.webExtensionController).thenReturn(webExtensionController) + + val nativeWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData( + version = "1.0", + baseUrl = "moz-extension://123c5c5b-cd03-4bea-b23f-ac0b9ab40257/", + disabledFlags = DisabledFlags.USER, + permissions = arrayOf("p1", "p2"), + incognito = null, + ), + ) + val extensionWithMetadata = GeckoWebExtension(nativeWebExtension, runtime) + val metadata = extensionWithMetadata.getMetadata() + assertNotNull(metadata) + assertEquals("1.0", metadata.version) + assertEquals(0.0f, metadata.averageRating) + assertEquals(0, metadata.reviewCount) + assertEquals(listOf("p1", "p2"), metadata.permissions) + assertEquals(emptyList<String>(), metadata.hostPermissions) + assertEquals("moz-extension://123c5c5b-cd03-4bea-b23f-ac0b9ab40257/", metadata.baseUrl) + assertNull(metadata.description) + assertNull(metadata.developerName) + assertNull(metadata.developerUrl) + assertNull(metadata.homepageUrl) + assertNull(metadata.name) + assertNull(metadata.optionsPageUrl) + assertNull(metadata.fullDescription) + assertNull(metadata.reviewUrl) + assertNull(metadata.updateDate) + assertNull(metadata.downloadUrl) + assertEquals(Incognito.SPANNING, metadata.incognito) + } + + @Test + fun `isBuiltIn depends on native state`() { + val runtime: GeckoRuntime = mock() + + val builtInExtension = GeckoWebExtension( + mockNativeWebExtension(id = "id", location = "uri", isBuiltIn = true), + runtime, + ) + assertTrue(builtInExtension.isBuiltIn()) + + val externalExtension = GeckoWebExtension( + mockNativeWebExtension(id = "id", location = "uri", isBuiltIn = false), + runtime, + ) + assertFalse(externalExtension.isBuiltIn()) + } + + @Test + fun `isEnabled depends on native state and defaults to true if state unknown`() { + val runtime: GeckoRuntime = mock() + whenever(runtime.webExtensionController).thenReturn(mock()) + + val nativeEnabledWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData( + enabled = true, + ), + ) + val enabledWebExtension = GeckoWebExtension(nativeEnabledWebExtension, runtime) + assertTrue(enabledWebExtension.isEnabled()) + + val nativeDisabledWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData( + enabled = false, + ), + ) + val disabledWebExtension = GeckoWebExtension(nativeDisabledWebExtension, runtime) + assertFalse(disabledWebExtension.isEnabled()) + } + + @Test + fun `isAllowedInPrivateBrowsing depends on native state and defaults to false if state unknown`() { + val runtime: GeckoRuntime = mock() + whenever(runtime.webExtensionController).thenReturn(mock()) + + val nativeBuiltInExtension = mockNativeWebExtension( + id = "id", + location = "uri", + isBuiltIn = true, + metaData = mockNativeWebExtensionMetaData( + allowedInPrivateBrowsing = false, + ), + ) + val builtInExtension = GeckoWebExtension(nativeBuiltInExtension, runtime) + assertTrue(builtInExtension.isAllowedInPrivateBrowsing()) + + val nativeWebExtensionWithPrivateBrowsing = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData( + allowedInPrivateBrowsing = true, + ), + ) + val webExtensionWithPrivateBrowsing = GeckoWebExtension(nativeWebExtensionWithPrivateBrowsing, runtime) + assertTrue(webExtensionWithPrivateBrowsing.isAllowedInPrivateBrowsing()) + + val nativeWebExtensionWithoutPrivateBrowsing = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData( + allowedInPrivateBrowsing = false, + ), + ) + val webExtensionWithoutPrivateBrowsing = GeckoWebExtension(nativeWebExtensionWithoutPrivateBrowsing, runtime) + assertFalse(webExtensionWithoutPrivateBrowsing.isAllowedInPrivateBrowsing()) + } + + @Test + fun `loadIcon tries to load icon from metadata`() { + val runtime: GeckoRuntime = mock() + whenever(runtime.webExtensionController).thenReturn(mock()) + + val iconMock: Image = mock() + whenever(iconMock.getBitmap(48)).thenReturn(mock()) + val nativeWebExtensionWithIcon = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData(icon = iconMock), + ) + + val webExtensionWithIcon = GeckoWebExtension(nativeWebExtensionWithIcon, runtime) + webExtensionWithIcon.getIcon(48) + verify(iconMock).getBitmap(48) + } + + @Test + fun `incognito set to spanning`() { + val runtime: GeckoRuntime = mock() + val nativeWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData(version = "1", incognito = "spanning"), + ) + val extensionWithMetadata = GeckoWebExtension(nativeWebExtension, runtime) + + val metadata = extensionWithMetadata.getMetadata() + assertNotNull(metadata) + assertEquals(Incognito.SPANNING, metadata.incognito) + } + + @Test + fun `incognito set to not_allowed`() { + val runtime: GeckoRuntime = mock() + val nativeWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData(version = "1", incognito = "not_allowed"), + ) + val extensionWithMetadata = GeckoWebExtension(nativeWebExtension, runtime) + + val metadata = extensionWithMetadata.getMetadata() + assertNotNull(metadata) + assertEquals(Incognito.NOT_ALLOWED, metadata.incognito) + } + + @Test + fun `incognito set to split`() { + val runtime: GeckoRuntime = mock() + val nativeWebExtension = mockNativeWebExtension( + id = "id", + location = "uri", + metaData = mockNativeWebExtensionMetaData(version = "1", incognito = "split"), + ) + val extensionWithMetadata = GeckoWebExtension(nativeWebExtension, runtime) + + val metadata = extensionWithMetadata.getMetadata() + assertNotNull(metadata) + assertEquals(Incognito.SPLIT, metadata.incognito) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/MockWebExtension.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/MockWebExtension.kt new file mode 100644 index 0000000000..c90be13bc3 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/MockWebExtension.kt @@ -0,0 +1,115 @@ +/* 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.browser.engine.gecko.webextension + +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.test.ReflectionUtils +import org.mockito.Mockito.doNothing +import org.mozilla.geckoview.Image +import org.mozilla.geckoview.WebExtension + +fun mockNativeWebExtension( + id: String = "id", + location: String = "uri", + flags: Int = 0, + isBuiltIn: Boolean = false, + metaData: WebExtension.MetaData? = null, +): WebExtension { + val extension: WebExtension = mock() + ReflectionUtils.setField(extension, "id", id) + ReflectionUtils.setField(extension, "location", location) + ReflectionUtils.setField(extension, "flags", flags) + ReflectionUtils.setField(extension, "isBuiltIn", isBuiltIn) + ReflectionUtils.setField(extension, "metaData", metaData) + + doNothing().`when`(extension).setActionDelegate(any()) + return extension +} + +fun mockNativeWebExtensionMetaData( + icon: Image = mock(), + permissions: Array<String> = emptyArray(), + optionalPermissions: Array<String> = emptyArray(), + grantedOptionalPermissions: Array<String> = emptyArray(), + grantedOptionalOrigins: Array<String> = emptyArray(), + optionalOrigins: Array<String> = emptyArray(), + origins: Array<String> = emptyArray(), + name: String? = null, + description: String? = null, + version: String? = null, + creatorName: String? = null, + creatorUrl: String? = null, + homepageUrl: String? = null, + optionsPageUrl: String? = null, + openOptionsPageInTab: Boolean = false, + isRecommended: Boolean = false, + blocklistState: Int = 0, + signedState: Int = 0, + disabledFlags: Int = 0, + baseUrl: String = "", + allowedInPrivateBrowsing: Boolean = false, + enabled: Boolean = false, + temporary: Boolean = false, + fullDescription: String? = null, + downloadUrl: String? = null, + reviewUrl: String? = null, + updateDate: String? = null, + reviewCount: Int = 0, + averageRating: Double = 0.0, + incognito: String? = "spanning", +): WebExtension.MetaData { + val metadata: WebExtension.MetaData = mock() + ReflectionUtils.setField(metadata, "icon", icon) + ReflectionUtils.setField(metadata, "promptPermissions", permissions) + ReflectionUtils.setField(metadata, "optionalPermissions", optionalPermissions) + ReflectionUtils.setField(metadata, "grantedOptionalPermissions", grantedOptionalPermissions) + ReflectionUtils.setField(metadata, "optionalOrigins", optionalOrigins) + ReflectionUtils.setField(metadata, "grantedOptionalOrigins", grantedOptionalOrigins) + ReflectionUtils.setField(metadata, "origins", origins) + ReflectionUtils.setField(metadata, "name", name) + ReflectionUtils.setField(metadata, "description", description) + ReflectionUtils.setField(metadata, "version", version) + ReflectionUtils.setField(metadata, "creatorName", creatorName) + ReflectionUtils.setField(metadata, "creatorUrl", creatorUrl) + ReflectionUtils.setField(metadata, "homepageUrl", homepageUrl) + ReflectionUtils.setField(metadata, "optionsPageUrl", optionsPageUrl) + ReflectionUtils.setField(metadata, "openOptionsPageInTab", openOptionsPageInTab) + ReflectionUtils.setField(metadata, "isRecommended", isRecommended) + ReflectionUtils.setField(metadata, "blocklistState", blocklistState) + ReflectionUtils.setField(metadata, "signedState", signedState) + ReflectionUtils.setField(metadata, "disabledFlags", disabledFlags) + ReflectionUtils.setField(metadata, "baseUrl", baseUrl) + ReflectionUtils.setField(metadata, "allowedInPrivateBrowsing", allowedInPrivateBrowsing) + ReflectionUtils.setField(metadata, "enabled", enabled) + ReflectionUtils.setField(metadata, "temporary", temporary) + ReflectionUtils.setField(metadata, "fullDescription", fullDescription) + ReflectionUtils.setField(metadata, "downloadUrl", downloadUrl) + ReflectionUtils.setField(metadata, "reviewUrl", reviewUrl) + ReflectionUtils.setField(metadata, "updateDate", updateDate) + ReflectionUtils.setField(metadata, "reviewCount", reviewCount) + ReflectionUtils.setField(metadata, "averageRating", averageRating) + ReflectionUtils.setField(metadata, "averageRating", averageRating) + ReflectionUtils.setField(metadata, "incognito", incognito) + return metadata +} + +fun mockCreateTabDetails( + active: Boolean, + url: String, +): WebExtension.CreateTabDetails { + val createTabDetails: WebExtension.CreateTabDetails = mock() + ReflectionUtils.setField(createTabDetails, "active", active) + ReflectionUtils.setField(createTabDetails, "url", url) + return createTabDetails +} + +fun mockUpdateTabDetails( + active: Boolean, +): WebExtension.UpdateTabDetails { + val updateTabDetails: WebExtension.UpdateTabDetails = mock() + ReflectionUtils.setField(updateTabDetails, "active", active) + return updateTabDetails +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegateTest.kt new file mode 100644 index 0000000000..6984a4cf03 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegateTest.kt @@ -0,0 +1,117 @@ +/* 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.browser.engine.gecko.webnotifications + +import mozilla.components.concept.engine.webnotifications.WebNotification +import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.verify +import org.mozilla.geckoview.WebNotification as GeckoViewWebNotification + +class GeckoWebNotificationDelegateTest { + + @Test + fun `onShowNotification is forwarded to delegate`() { + val webNotificationDelegate: WebNotificationDelegate = mock() + val geckoViewWebNotification: GeckoViewWebNotification = mockWebNotification( + title = "title", + tag = "tag", + text = "text", + imageUrl = "imageUrl", + textDirection = "textDirection", + lang = "lang", + requireInteraction = true, + source = "source", + privateBrowsing = true, + ) + val geckoWebNotificationDelegate = GeckoWebNotificationDelegate(webNotificationDelegate) + + val notificationCaptor = argumentCaptor<WebNotification>() + geckoWebNotificationDelegate.onShowNotification(geckoViewWebNotification) + verify(webNotificationDelegate).onShowNotification(notificationCaptor.capture()) + + val notification = notificationCaptor.value + assertEquals(notification.title, geckoViewWebNotification.title) + assertEquals(notification.tag, geckoViewWebNotification.tag) + assertEquals(notification.body, geckoViewWebNotification.text) + assertEquals(notification.sourceUrl, geckoViewWebNotification.source) + assertEquals(notification.iconUrl, geckoViewWebNotification.imageUrl) + assertEquals(notification.direction, geckoViewWebNotification.textDirection) + assertEquals(notification.lang, geckoViewWebNotification.lang) + assertEquals(notification.requireInteraction, geckoViewWebNotification.requireInteraction) + assertFalse(notification.triggeredByWebExtension) + assertTrue(notification.privateBrowsing) + } + + @Test + fun `onCloseNotification is forwarded to delegate`() { + val webNotificationDelegate: WebNotificationDelegate = mock() + val geckoViewWebNotification: GeckoViewWebNotification = mockWebNotification( + title = "title", + tag = "tag", + text = "text", + imageUrl = "imageUrl", + textDirection = "textDirection", + lang = "lang", + requireInteraction = true, + source = "source", + privateBrowsing = false, + ) + val geckoWebNotificationDelegate = GeckoWebNotificationDelegate(webNotificationDelegate) + + val notificationCaptor = argumentCaptor<WebNotification>() + geckoWebNotificationDelegate.onCloseNotification(geckoViewWebNotification) + verify(webNotificationDelegate).onCloseNotification(notificationCaptor.capture()) + + val notification = notificationCaptor.value + assertEquals(notification.title, geckoViewWebNotification.title) + assertEquals(notification.tag, geckoViewWebNotification.tag) + assertEquals(notification.body, geckoViewWebNotification.text) + assertEquals(notification.sourceUrl, geckoViewWebNotification.source) + assertEquals(notification.iconUrl, geckoViewWebNotification.imageUrl) + assertEquals(notification.direction, geckoViewWebNotification.textDirection) + assertEquals(notification.lang, geckoViewWebNotification.lang) + assertEquals(notification.requireInteraction, geckoViewWebNotification.requireInteraction) + assertEquals(notification.privateBrowsing, geckoViewWebNotification.privateBrowsing) + } + + @Test + fun `notification without a source are from web extensions`() { + val webNotificationDelegate: WebNotificationDelegate = mock() + val geckoViewWebNotification: GeckoViewWebNotification = mockWebNotification( + title = "title", + tag = "tag", + text = "text", + imageUrl = "imageUrl", + textDirection = "textDirection", + lang = "lang", + requireInteraction = true, + source = null, + privateBrowsing = true, + ) + val geckoWebNotificationDelegate = GeckoWebNotificationDelegate(webNotificationDelegate) + + val notificationCaptor = argumentCaptor<WebNotification>() + geckoWebNotificationDelegate.onShowNotification(geckoViewWebNotification) + verify(webNotificationDelegate).onShowNotification(notificationCaptor.capture()) + + val notification = notificationCaptor.value + assertEquals(notification.title, geckoViewWebNotification.title) + assertEquals(notification.tag, geckoViewWebNotification.tag) + assertEquals(notification.body, geckoViewWebNotification.text) + assertEquals(notification.sourceUrl, geckoViewWebNotification.source) + assertEquals(notification.iconUrl, geckoViewWebNotification.imageUrl) + assertEquals(notification.direction, geckoViewWebNotification.textDirection) + assertEquals(notification.lang, geckoViewWebNotification.lang) + assertEquals(notification.requireInteraction, geckoViewWebNotification.requireInteraction) + assertTrue(notification.triggeredByWebExtension) + assertTrue(notification.privateBrowsing) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/MockWebNotification.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/MockWebNotification.kt new file mode 100644 index 0000000000..247bf220b2 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/MockWebNotification.kt @@ -0,0 +1,37 @@ +/* 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.browser.engine.gecko.webnotifications + +import mozilla.components.support.test.mock +import mozilla.components.test.ReflectionUtils +import org.mozilla.geckoview.WebNotification + +fun mockWebNotification( + tag: String, + requireInteraction: Boolean, + vibrate: IntArray = IntArray(0), + title: String? = null, + text: String? = null, + imageUrl: String? = null, + textDirection: String? = null, + lang: String? = null, + source: String? = null, + silent: Boolean = false, + privateBrowsing: Boolean = false, +): WebNotification { + val webNotification: WebNotification = mock() + ReflectionUtils.setField(webNotification, "title", title) + ReflectionUtils.setField(webNotification, "tag", tag) + ReflectionUtils.setField(webNotification, "text", text) + ReflectionUtils.setField(webNotification, "imageUrl", imageUrl) + ReflectionUtils.setField(webNotification, "textDirection", textDirection) + ReflectionUtils.setField(webNotification, "lang", lang) + ReflectionUtils.setField(webNotification, "requireInteraction", requireInteraction) + ReflectionUtils.setField(webNotification, "source", source) + ReflectionUtils.setField(webNotification, "silent", silent) + ReflectionUtils.setField(webNotification, "vibrate", vibrate) + ReflectionUtils.setField(webNotification, "privateBrowsing", privateBrowsing) + return webNotification +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegateTest.kt new file mode 100644 index 0000000000..db50ceb559 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegateTest.kt @@ -0,0 +1,154 @@ +/* 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.browser.engine.gecko.webpush + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.engine.webpush.WebPushDelegate +import mozilla.components.concept.engine.webpush.WebPushSubscription +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.isNull +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoResult + +@RunWith(AndroidJUnit4::class) +class GeckoWebPushDelegateTest { + + @Test + fun `delegate is always invoked`() { + val delegate: WebPushDelegate = mock() + val geckoDelegate = GeckoWebPushDelegate(delegate) + + geckoDelegate.onGetSubscription("test") + + verify(delegate).onGetSubscription(eq("test"), any()) + + geckoDelegate.onSubscribe("test", null) + + verify(delegate).onSubscribe(eq("test"), isNull(), any()) + + geckoDelegate.onSubscribe("test", "key".toByteArray()) + + verify(delegate).onSubscribe(eq("test"), eq("key".toByteArray()), any()) + + geckoDelegate.onUnsubscribe("test") + + verify(delegate).onUnsubscribe(eq("test"), any()) + } + + @Test + fun `onGetSubscription result is completed`() { + var subscription: WebPushSubscription? = WebPushSubscription( + "test", + "https://example.com", + null, + ByteArray(65), + ByteArray(16), + ) + val delegate: WebPushDelegate = object : WebPushDelegate { + override fun onGetSubscription( + scope: String, + onSubscription: (WebPushSubscription?) -> Unit, + ) { + onSubscription(subscription) + } + } + + val geckoDelegate = GeckoWebPushDelegate(delegate) + val result = geckoDelegate.onGetSubscription("test") + + result?.accept { sub -> + assert(sub!!.scope == subscription!!.scope) + } + + subscription = null + + val nullResult = geckoDelegate.onGetSubscription("test") + + nullResult?.accept { sub -> + assertNull(sub) + } + } + + @Test + fun `onSubscribe result is completed`() { + var subscription: WebPushSubscription? = WebPushSubscription( + "test", + "https://example.com", + null, + ByteArray(65), + ByteArray(16), + ) + val delegate: WebPushDelegate = object : WebPushDelegate { + override fun onSubscribe( + scope: String, + serverKey: ByteArray?, + onSubscribe: (WebPushSubscription?) -> Unit, + ) { + onSubscribe(subscription) + } + } + + val geckoDelegate = GeckoWebPushDelegate(delegate) + val result = geckoDelegate.onSubscribe("test", null) + + result?.accept { sub -> + assert(sub!!.scope == subscription!!.scope) + assertNull(sub.appServerKey) + } + + subscription = null + + val nullResult = geckoDelegate.onSubscribe("test", null) + nullResult?.accept { sub -> + assertNull(sub) + } + } + + @Test + fun `onUnsubscribe result is completed successfully`() { + val delegate: WebPushDelegate = object : WebPushDelegate { + override fun onUnsubscribe( + scope: String, + onUnsubscribe: (Boolean) -> Unit, + ) { + onUnsubscribe(true) + } + } + + val geckoDelegate = GeckoWebPushDelegate(delegate) + val result = geckoDelegate.onUnsubscribe("test") + + result?.accept { sub -> + assertNull(sub) + } + } + + @Test + fun `onUnsubscribe result receives throwable when unsuccessful`() { + val delegate: WebPushDelegate = object : WebPushDelegate { + override fun onUnsubscribe( + scope: String, + onUnsubscribe: (Boolean) -> Unit, + ) { + onUnsubscribe(false) + } + } + + val geckoDelegate = GeckoWebPushDelegate(delegate) + + val result = geckoDelegate.onUnsubscribe("test") + + result?.exceptionally<Void> { throwable -> + assertTrue(throwable.localizedMessage == "Un-subscribing from subscription failed.") + GeckoResult.fromValue(null) + } + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandlerTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandlerTest.kt new file mode 100644 index 0000000000..a59ee61c1e --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandlerTest.kt @@ -0,0 +1,40 @@ +/* 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.browser.engine.gecko.webpush + +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.isNull +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.WebPushController + +class GeckoWebPushHandlerTest { + + lateinit var runtime: GeckoRuntime + lateinit var controller: WebPushController + + @Before + fun setup() { + controller = mock() + runtime = mock() + `when`(runtime.webPushController).thenReturn(controller) + } + + @Test + fun `runtime controller is invoked`() { + val handler = GeckoWebPushHandler(runtime) + + handler.onPushMessage("", null) + verify(controller).onPushEvent(any(), isNull()) + + handler.onSubscriptionChanged("test") + verify(controller).onSubscriptionChanged(eq("test")) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequestTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequestTest.kt new file mode 100644 index 0000000000..ec4009a1f1 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequestTest.kt @@ -0,0 +1,20 @@ +/* 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.browser.engine.gecko.window + +import mozilla.components.browser.engine.gecko.GeckoEngineSession +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Test + +class GeckoWindowRequestTest { + + @Test + fun testPrepare() { + val engineSession: GeckoEngineSession = mock() + val windowRequest = GeckoWindowRequest("mozilla.org", engineSession) + assertEquals(engineSession, windowRequest.prepare()) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/experiment/NimbusExperimentDelegateTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/experiment/NimbusExperimentDelegateTest.kt new file mode 100644 index 0000000000..13a2236243 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/experiment/NimbusExperimentDelegateTest.kt @@ -0,0 +1,68 @@ +/* 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.browser.experiment + +import android.os.Looper.getMainLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.experiment.NimbusExperimentDelegate +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.ExperimentDelegate +import org.mozilla.geckoview.ExperimentDelegate.ExperimentException +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoRuntimeSettings +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class NimbusExperimentDelegateTest { + + @Test + fun `WHEN an experiment delegate can be set on the runtime THEN the same delegate can be returned`() { + val runtime = mock<GeckoRuntime>() + val mockExperimentDelegate = mock<ExperimentDelegate>() + val mockGeckoSetting = mock<GeckoRuntimeSettings>() + whenever(runtime.settings).thenReturn(mockGeckoSetting) + whenever(mockGeckoSetting.experimentDelegate).thenReturn(mockExperimentDelegate) + assertThat("Can set and retrieve experiment delegate.", runtime.settings.experimentDelegate, equalTo(mockExperimentDelegate)) + } + + @Test + fun `WHEN the Nimbus experiment delegate is used AND the feature does not exist THEN the delegate responds with exceptions`() { + val nimbusExperimentDelegate = NimbusExperimentDelegate() + nimbusExperimentDelegate.onGetExperimentFeature("test-no-op") + .accept { assertTrue("Should not have completed.", false) } + .exceptionally { e -> + assertTrue("Should have completed exceptionally.", (e as ExperimentException).code == ExperimentException.ERROR_FEATURE_NOT_FOUND) + GeckoResult.fromValue(null) + } + + nimbusExperimentDelegate.onRecordExposureEvent("test-no-op") + .accept { assertTrue("Should not have completed.", false) } + .exceptionally { e -> + assertTrue("Should have completed exceptionally.", (e as ExperimentException).code == ExperimentException.ERROR_FEATURE_NOT_FOUND) + GeckoResult.fromValue(null) + } + nimbusExperimentDelegate.onRecordExperimentExposureEvent("test-no-op", "test-no-op") + .accept { assertTrue("Should not have completed.", false) } + .exceptionally { e -> + assertTrue("Should have completed exceptionally.", (e as ExperimentException).code == ExperimentException.ERROR_FEATURE_NOT_FOUND) + GeckoResult.fromValue(null) + } + nimbusExperimentDelegate.onRecordMalformedConfigurationEvent("test-no-op", "test") + .accept { assertTrue("Should not have completed.", false) } + .exceptionally { e -> + assertTrue("Should have completed exceptionally.", (e as ExperimentException).code == ExperimentException.ERROR_FEATURE_NOT_FOUND) + GeckoResult.fromValue(null) + } + + shadowOf(getMainLooper()).idle() + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/test/ReflectionUtils.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/test/ReflectionUtils.kt new file mode 100644 index 0000000000..f49849fbd4 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/test/ReflectionUtils.kt @@ -0,0 +1,26 @@ +/* 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.test + +import java.security.AccessController +import java.security.PrivilegedExceptionAction + +object ReflectionUtils { + fun <T : Any> setField(instance: T, fieldName: String, value: Any?) { + val mapField = AccessController.doPrivileged( + PrivilegedExceptionAction { + try { + val field = instance::class.java.getField(fieldName) + field.isAccessible = true + return@PrivilegedExceptionAction field + } catch (e: ReflectiveOperationException) { + throw Error(e) + } + }, + ) + + mapField.set(instance, value) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/browser/engine-gecko/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/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/browser/engine-gecko/src/test/resources/robolectric.properties b/mobile/android/android-components/components/browser/engine-gecko/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/browser/engine-gecko/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |