summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/browser/engine-gecko
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/browser/engine-gecko
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/browser/engine-gecko')
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/README.md41
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/build.gradle193
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/docs/metrics.md8
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/geckoview.fml.yaml24
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/metrics.yaml30
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/androidTest/java/mozilla/components/browser/engine/gecko/fetch/geckoview/GeckoViewFetchTestCases.kt126
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngine.kt1502
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt1880
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionState.kt73
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt249
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoResult.kt76
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorage.kt146
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/NestedGeckoView.kt258
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegate.kt45
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegate.kt35
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegate.kt43
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt110
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/content/blocking/GeckoTrackingProtectionException.kt20
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorage.kt128
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepository.kt75
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Address.kt42
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/CreditCard.kt32
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoChoice.kt31
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/GeckoContentPermissions.kt25
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/Login.kt43
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicy.kt82
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchClient.kt139
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/LocaleSettingUpdater.kt56
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/integration/SettingUpdater.kt45
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegate.kt53
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediaquery/PreferredColorScheme.kt22
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionController.kt56
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegate.kt125
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequest.kt185
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorage.kt461
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/profiler/Profiler.kt84
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegate.kt66
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegate.kt911
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegate.kt21
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegate.kt70
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegate.kt35
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegate.kt79
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslationUtils.kt63
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactory.kt154
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtension.kt449
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionException.kt72
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegate.kt39
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegate.kt73
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandler.kt31
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequest.kt23
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/experiment/NimbusExperimentDelegate.kt93
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionStateTest.kt61
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineSessionTest.kt4874
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineTest.kt3673
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt299
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoResultTest.kt86
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoTrackingProtectionExceptionStorageTest.kt274
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoWebExtensionExceptionTest.kt116
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/NestedGeckoViewTest.kt580
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoActivityDelegateTest.kt75
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoScreenOrientationDelegateTest.kt62
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/activity/GeckoViewActivityContextDelegateTest.kt28
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/GeckoCookieBannersStorageTest.kt161
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/cookiebanners/ReportSiteDomainsRepositoryTest.kt74
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/ext/TrackingProtectionPolicyKtTest.kt81
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/fetch/GeckoViewFetchUnitTestCases.kt351
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/integration/SettingUpdaterTest.kt69
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/media/GeckoMediaDelegateTest.kt116
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionControllerTest.kt49
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/mediasession/GeckoMediaSessionDelegateTest.kt219
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoPermissionRequestTest.kt242
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/permission/GeckoSitePermissionsStorageTest.kt740
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/ChoicePromptDelegateTest.kt81
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/GeckoPromptDelegateTest.kt2136
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/prompt/PromptInstanceDismissDelegateTest.kt40
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/selection/GeckoSelectionActionDelegateTest.kt92
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/serviceworker/GeckoServiceWorkerDelegateTest.kt40
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/translate/GeckoTranslateSessionDelegateTest.kt103
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/util/SpeculativeSessionFactoryTest.kt129
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/GeckoWebExtensionTest.kt644
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webextension/MockWebExtension.kt115
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/GeckoWebNotificationDelegateTest.kt117
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webnotifications/MockWebNotification.kt37
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushDelegateTest.kt154
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/webpush/GeckoWebPushHandlerTest.kt40
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/window/GeckoWindowRequestTest.kt20
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/experiment/NimbusExperimentDelegateTest.kt68
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/test/ReflectionUtils.kt26
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/browser/engine-gecko/src/test/resources/robolectric.properties1
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