summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/concept
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/concept')
-rw-r--r--mobile/android/android-components/components/concept/awesomebar/README.md47
-rw-r--r--mobile/android/android-components/components/concept/awesomebar/build.gradle33
-rw-r--r--mobile/android/android-components/components/concept/awesomebar/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/awesomebar/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/awesomebar/src/main/java/mozilla/components/concept/awesomebar/AwesomeBar.kt222
-rw-r--r--mobile/android/android-components/components/concept/base/README.md21
-rw-r--r--mobile/android/android-components/components/concept/base/build.gradle46
-rw-r--r--mobile/android/android-components/components/concept/base/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/Breadcrumb.kt134
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/CrashReporting.kt23
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/RustCrashReport.kt19
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageLoader.kt32
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageRequest.kt28
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/memory/MemoryConsumer.kt26
-rw-r--r--mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/profiler/Profiler.kt155
-rw-r--r--mobile/android/android-components/components/concept/base/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/concept/base/src/test/resources/robolectric.properties1
-rw-r--r--mobile/android/android-components/components/concept/engine/README.md45
-rw-r--r--mobile/android/android-components/components/concept/engine/build.gradle52
-rw-r--r--mobile/android/android-components/components/concept/engine/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/CancellableOperation.kt31
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/DataCleanable.kt26
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt282
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSession.kt1103
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSessionState.kt51
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt201
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/HitResult.kt52
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt378
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Settings.kt325
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/ActivityDelegate.kt22
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/OrientationDelegate.kt28
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/Tracker.kt20
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackerLog.kt23
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionException.kt16
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt57
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/cookiehandling/CookieBannersStorage.kt68
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryItem.kt15
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryTrackingDelegate.kt47
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/Size.kt59
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifest.kt253
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt238
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/ShareTargetParser.kt129
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/WebAppManifestIconParser.kt86
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/media/RecordingDevice.kt32
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediaquery/PreferredColorScheme.kt17
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediasession/MediaSession.kt193
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/PermissionRequest.kt158
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissions.kt96
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissionsStorage.kt82
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/Choice.kt63
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/PromptRequest.kt445
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/ShareData.kt24
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/request/RequestInterceptor.kt107
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/search/SearchRequest.kt10
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/selection/SelectionActionDelegate.kt47
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/serviceworker/ServiceWorkerDelegate.kt25
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysis.kt51
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysisStatus.kt16
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductRecommendation.kt32
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/DetectedLanguages.kt20
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/Language.kt16
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageModel.kt22
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageSetting.kt125
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelManagementOptions.kt19
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelOperation.kt20
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/OperationLevel.kt26
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationDownloadSize.kt27
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt46
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationError.kt175
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt48
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOptions.kt15
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.kt32
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt26
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPair.kt16
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationSupport.kt60
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationsRuntime.kt215
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/utils/EngineVersion.kt115
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/Action.kt54
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/InstallationMethod.kt20
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt677
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt177
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionRuntime.kt220
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotification.kt44
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotificationDelegate.kt25
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPush.kt80
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPushDelegate.kt28
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/window/WindowRequest.kt44
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Account.kt25
-rw-r--r--mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Provider.kt24
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineSessionTest.kt1099
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineTest.kt140
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.kt84
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/HitResultTest.kt25
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt474
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/SettingsTest.kt227
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/SizeTest.kt45
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt603
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/mediasession/MediaSessionTest.kt230
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionRequestTest.kt89
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionTest.kt32
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ChoiceTest.kt37
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/PromptRequestTest.kt366
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ShareDataTest.kt40
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/request/RequestInterceptorTest.kt37
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/utils/EngineVersionTest.kt186
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/ActionTest.kt58
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt105
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webpush/WebPushSubscriptionTest.kt112
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_google.json21
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json37
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_json.json3
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_missing_name.json3
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal.json4
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_share_target.json13
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_short_name.json4
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/purpose_array.json23
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json51
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/squoosh.json32
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json1
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/manifests/unusual.json35
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/concept/engine/src/test/resources/robolectric.properties1
-rw-r--r--mobile/android/android-components/components/concept/fetch/README.md166
-rw-r--r--mobile/android/android-components/components/concept/fetch/build.gradle44
-rw-r--r--mobile/android/android-components/components/concept/fetch/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt98
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt168
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt190
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt160
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt93
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt36
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt240
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt191
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt258
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt132
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties1
-rw-r--r--mobile/android/android-components/components/concept/menu/build.gradle38
-rw-r--r--mobile/android/android-components/components/concept/menu/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuButton.kt47
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuController.kt53
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuStyle.kt44
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Orientation.kt39
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Side.kt20
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/ContainerStyle.kt16
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuCandidate.kt123
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuEffect.kt46
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt97
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/SmallMenuCandidate.kt22
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/TextStyle.kt46
-rw-r--r--mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/ext/MenuCandidate.kt63
-rw-r--r--mobile/android/android-components/components/concept/menu/src/test/java/mozilla/components/concept/menu/ext/MenuCandidateTest.kt179
-rw-r--r--mobile/android/android-components/components/concept/push/README.md23
-rw-r--r--mobile/android/android-components/components/concept/push/build.gradle38
-rw-r--r--mobile/android/android-components/components/concept/push/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt87
-rw-r--r--mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushService.kt41
-rw-r--r--mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/exceptions/SubscriptionException.kt17
-rw-r--r--mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushErrorTest.kt32
-rw-r--r--mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushProcessorTest.kt55
-rw-r--r--mobile/android/android-components/components/concept/push/src/test/resources/robolectric.properties1
-rw-r--r--mobile/android/android-components/components/concept/storage/README.md28
-rw-r--r--mobile/android/android-components/components/concept/storage/build.gradle41
-rw-r--r--mobile/android/android-components/components/concept/storage/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/BookmarksStorage.kt182
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Cancellable.kt42
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt503
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryMetadataStorage.kt193
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryStorage.kt237
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyManager.kt108
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyProvider.kt58
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/LoginsStorage.kt277
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Storage.kt20
-rw-r--r--mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/StorageMaintenanceRegistry.kt27
-rw-r--r--mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/AddressTest.kt92
-rw-r--r--mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/BookmarkNodeTest.kt119
-rw-r--r--mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt94
-rw-r--r--mobile/android/android-components/components/concept/sync/README.md26
-rw-r--r--mobile/android/android-components/components/concept/sync/build.gradle37
-rw-r--r--mobile/android/android-components/components/concept/sync/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/sync/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt65
-rw-r--r--mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt175
-rw-r--r--mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt358
-rw-r--r--mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Sync.kt53
-rw-r--r--mobile/android/android-components/components/concept/tabstray/README.md19
-rw-r--r--mobile/android/android-components/components/concept/tabstray/build.gradle33
-rw-r--r--mobile/android/android-components/components/concept/tabstray/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/tabstray/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tab.kt42
-rw-r--r--mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tabs.kt21
-rw-r--r--mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/TabsTray.kt44
-rw-r--r--mobile/android/android-components/components/concept/toolbar/README.md19
-rw-r--r--mobile/android/android-components/components/concept/toolbar/build.gradle39
-rw-r--r--mobile/android/android-components/components/concept/toolbar/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteDelegate.kt24
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteProvider.kt36
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteResult.kt22
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/ScrollableToolbar.kt34
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/Toolbar.kt563
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionButtonTest.kt76
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionImageTest.kt84
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionSpaceTest.kt47
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionToggleButtonTest.kt213
-rw-r--r--mobile/android/android-components/components/concept/toolbar/src/test/resources/robolectric.properties1
211 files changed, 19641 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/concept/awesomebar/README.md b/mobile/android/android-components/components/concept/awesomebar/README.md
new file mode 100644
index 0000000000..73b39c7b51
--- /dev/null
+++ b/mobile/android/android-components/components/concept/awesomebar/README.md
@@ -0,0 +1,47 @@
+# [Android Components](../../../README.md) > Concept > Awesomebar
+
+An abstract definition of an awesome bar component.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md)):
+
+```Groovy
+implementation "org.mozilla.components:concept-awesomebar:{latest-version}"
+```
+
+### Implementing an Awesome Bar
+
+An Awesome Bar can be any [Android View](https://developer.android.com/reference/android/view/View.html) that implements the `AwesomeBar` interface.
+
+An `AwesomeBar` implementation needs to react to the following events:
+
+* `onInputStarted()`: The user starts interacting with the awesome bar by entering text in the [toolbar](../toolbar/README.md). This callback is a good place to initialize code that will be required once the user starts typing.
+* `onInputChanged(text: String)`: The user changed the text in the [toolbar](../toolbar/README.md). The awesome bar implementation should update its suggestions based on the text entered now.
+* `onInputCancelled()`: The user has cancelled their interaction with the awesome bar. This callback is a good place to free resources that are no longer needed.
+
+The suggestions an awesome bar displays are provided by an `SuggestionProvider`. Those providers are passed by the app (or another component) to the awesome bar by calling `addProviders()`. Once the text changes the awesome bar queries the `SuggestionProvider` instances and receives a list of `Suggestion` objects.
+
+Once the user selects a suggestion and the awesome bar wants to stop the interaction it can invoke the callback provided via the `setOnStopListener()` method. This is required as the awesome bar implementation is unaware of how it gets displayed and how interaction with it should be stopped (e.g. leaving the [toolbar's](../toolbar/README.md) editing mode).
+
+### Suggestions
+
+A `Suggestion` object contains the data required to be displayed and callbacks for when a suggestion was selected by the user.
+
+It is up to the suggestion or its provider to define the behavior that should happen in that situation (e.g. loading a URL, performing a search, switching tabs..).
+
+All data in the `Suggestion` object is optional. It is up to the awesome bar implementation to handle missing data (e.g. show the `description` instead of a missing `title`).
+
+Every `Suggestion` has an `id`. By default the `Suggestion` will generate a random ID. This ID can be used by the awesome bar to determine whether two suggestions are the same even though they are containing different/updated data. For example a `Suggestion` showing search suggestions from a search engine might use a constant ID when it is showing new search suggestions - to avoid the awesome bar implementation animating the previous suggestion leaving and a new suggestion appearing.
+
+### Implementing a Suggestion Provider
+
+For implementing a Suggestion Provider the `SuggestionProvider` interface needs to be implemented. The awesome bar forwards the events it receives to every provider: `onInputStarted()`, `onInputCancelled()`, `onInputChanged(text: String)`. A provider is required to return a list of `Suggestion` objects from `onInputChanged()`. This implementation can be synchronous. The awesome bar implementation takes care of performing the requests from worker threads.
+
+## 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/concept/awesomebar/build.gradle b/mobile/android/android-components/components/concept/awesomebar/build.gradle
new file mode 100644
index 0000000000..b3fec1fd8e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/awesomebar/build.gradle
@@ -0,0 +1,33 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.awesomebar'
+}
+
+dependencies {
+ implementation project(':support-base')
+
+ implementation ComponentsDependencies.kotlin_coroutines
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/awesomebar/proguard-rules.pro b/mobile/android/android-components/components/concept/awesomebar/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/awesomebar/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/concept/awesomebar/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/awesomebar/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/awesomebar/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/concept/awesomebar/src/main/java/mozilla/components/concept/awesomebar/AwesomeBar.kt b/mobile/android/android-components/components/concept/awesomebar/src/main/java/mozilla/components/concept/awesomebar/AwesomeBar.kt
new file mode 100644
index 0000000000..73ce2ad57b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/awesomebar/src/main/java/mozilla/components/concept/awesomebar/AwesomeBar.kt
@@ -0,0 +1,222 @@
+/* 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.concept.awesomebar
+
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.view.View
+import java.util.UUID
+
+/**
+ * Interface to be implemented by awesome bar implementations.
+ *
+ * An awesome bar has multiple duties:
+ * - Display [Suggestion] instances and invoking its callbacks once selected
+ * - React to outside events: [onInputStarted], [onInputChanged], [onInputCancelled].
+ * - Query [SuggestionProvider] instances for new suggestions when the text changes.
+ */
+interface AwesomeBar {
+
+ /**
+ * Adds the following [SuggestionProvider] instances to be queried for [Suggestion]s whenever the text changes.
+ */
+ fun addProviders(vararg providers: SuggestionProvider)
+
+ /**
+ * Removes the following [SuggestionProvider]
+ */
+ fun removeProviders(vararg providers: SuggestionProvider)
+
+ /**
+ * Removes all [SuggestionProvider]s
+ */
+ fun removeAllProviders()
+
+ /**
+ * Returns whether or not this awesome bar contains the following [SuggestionProvider]
+ */
+ fun containsProvider(provider: SuggestionProvider): Boolean
+
+ /**
+ * Fired when the user starts interacting with the awesome bar by entering text in the toolbar.
+ */
+ fun onInputStarted() = Unit
+
+ /**
+ * Fired whenever the user changes their input, after they have started interacting with the awesome bar.
+ *
+ * @param text The current user input in the toolbar.
+ */
+ fun onInputChanged(text: String)
+
+ /**
+ * Fired when the user has cancelled their interaction with the awesome bar.
+ */
+ fun onInputCancelled() = Unit
+
+ /**
+ * Casts this awesome bar to an Android View object.
+ */
+ fun asView(): View = this as View
+
+ /**
+ * Adds a lambda to be invoked when the user has finished interacting with the awesome bar (e.g. selected a
+ * suggestion).
+ */
+ fun setOnStopListener(listener: () -> Unit)
+
+ /**
+ * Adds a lambda to be invoked when the user selected a suggestion to be edited further.
+ */
+ fun setOnEditSuggestionListener(listener: (String) -> Unit)
+
+ /**
+ * Information about the [Suggestion]s that are currently displayed by the [AwesomeBar].
+ */
+ data class VisibilityState(
+ /**
+ * An ordered map of the currently visible [SuggestionProviderGroup]s, and the visible [Suggestion]s in each
+ * group. The groups and their suggestions are ordered top to bottom.
+ */
+ val visibleProviderGroups: Map<SuggestionProviderGroup, List<Suggestion>> = emptyMap(),
+ )
+
+ /**
+ * A [Suggestion] to be displayed by an [AwesomeBar] implementation.
+ *
+ * @property provider The provider this suggestion came from.
+ * @property id A unique ID (provider scope) identifying this [Suggestion]. A stable ID but different data indicates
+ * to the [AwesomeBar] that this is the same [Suggestion] with new data. This will affect how the [AwesomeBar]
+ * animates showing the new suggestion.
+ * @property title A user-readable title for the [Suggestion].
+ * @property description A user-readable description for the [Suggestion].
+ * @property editSuggestion The string that will be set to the url bar when using the edit suggestion arrow.
+ * @property icon A lambda that can be invoked by the [AwesomeBar] implementation to receive an icon [Bitmap] for
+ * this [Suggestion]. The [AwesomeBar] will pass in its desired width and height for the Bitmap.
+ * @property indicatorIcon A drawable for indicating different types of [Suggestion].
+ * @property chips A list of [Chip] instances to be displayed.
+ * @property flags A set of [Flag] values for this [Suggestion].
+ * @property onSuggestionClicked A callback to be executed when the [Suggestion] was clicked by the user.
+ * @property onChipClicked A callback to be executed when a [Chip] was clicked by the user.
+ * @property score A score used to rank suggestions of this provider against each other. A suggestion with a higher
+ * score will be shown on top of suggestions with a lower score.
+ * @property metadata Opaque metadata associated with this [Suggestion]. A [SuggestionProvider] can use this field
+ * to pass additional information about this suggestion.
+ */
+ data class Suggestion(
+ val provider: SuggestionProvider,
+ val id: String = UUID.randomUUID().toString(),
+ val title: String? = null,
+ val description: String? = null,
+ val editSuggestion: String? = null,
+ val icon: Bitmap? = null,
+ val indicatorIcon: Drawable? = null,
+ val chips: List<Chip> = emptyList(),
+ val flags: Set<Flag> = emptySet(),
+ val onSuggestionClicked: (() -> Unit)? = null,
+ val onChipClicked: ((Chip) -> Unit)? = null,
+ val score: Int = 0,
+ val metadata: Map<String, Any>? = null,
+ ) {
+ /**
+ * Chips are compact actions that are shown as part of a suggestion. For example a [Suggestion] from a search
+ * engine may offer multiple search suggestion chips for different search terms.
+ */
+ data class Chip(
+ val title: String,
+ )
+
+ /**
+ * Flags can be added by a [SuggestionProvider] to help the [AwesomeBar] implementation decide how to display
+ * a specific [Suggestion]. For example an [AwesomeBar] could display a bookmark star icon next to [Suggestion]s
+ * that contain the [BOOKMARK] flag.
+ */
+ enum class Flag {
+ BOOKMARK,
+ HISTORY,
+ OPEN_TAB,
+ CLIPBOARD,
+ SYNC_TAB,
+ }
+
+ /**
+ * Returns true if the content of the two suggestions is the same.
+ *
+ * This is used by [AwesomeBar] implementations to decide whether an updated suggestion (same id) needs its
+ * view to be updated in order to display new data.
+ */
+ fun areContentsTheSame(other: Suggestion): Boolean {
+ return title == other.title &&
+ description == other.description &&
+ chips == other.chips &&
+ flags == other.flags
+ }
+ }
+
+ /**
+ * A [SuggestionProvider] is queried by an [AwesomeBar] whenever the text in the address bar is changed by the user.
+ * It returns a list of [Suggestion]s to be displayed by the [AwesomeBar].
+ */
+ interface SuggestionProvider {
+ /**
+ * A unique ID used for identifying this provider.
+ *
+ * The recommended approach for a [SuggestionProvider] implementation is to generate a UUID.
+ */
+ val id: String
+
+ /**
+ * A header title for grouping the suggestions.
+ **/
+ fun groupTitle(): String? = null
+
+ /**
+ * Fired when the user starts interacting with the awesome bar by entering text in the toolbar.
+ *
+ * The provider has the option to return an initial list of suggestions that will be displayed before the
+ * user has entered/modified any of the text.
+ */
+ fun onInputStarted(): List<Suggestion> = emptyList()
+
+ /**
+ * Fired whenever the user changes their input, after they have started interacting with the awesome bar.
+ *
+ * This is a suspending function. An [AwesomeBar] implementation is expected to invoke this method from a
+ * [Coroutine](https://kotlinlang.org/docs/reference/coroutines-overview.html). This allows the [AwesomeBar]
+ * implementation to group and cancel calls to multiple providers.
+ *
+ * Coroutine cancellation is cooperative. A coroutine code has to cooperate to be cancellable:
+ * https://github.com/Kotlin/kotlinx.coroutines/blob/master/docs/cancellation-and-timeouts.md
+ *
+ * @param text The current user input in the toolbar.
+ * @return A list of suggestions to be displayed by the [AwesomeBar].
+ */
+ suspend fun onInputChanged(text: String): List<Suggestion>
+
+ /**
+ * Fired when the user has cancelled their interaction with the awesome bar.
+ */
+ fun onInputCancelled() = Unit
+ }
+
+ /**
+ * A group of [SuggestionProvider]s.
+ *
+ * @property providers The list of [SuggestionProvider]s in this group.
+ * @property priority An optional priority for this group. Decides the order of this group
+ * in the AwesomeBar suggestions. Group having the highest integer value will have the highest priority.
+ * @property title An optional title for this group. The title may be rendered by an AwesomeBar
+ * implementation.
+ * @property limit The maximum number of suggestions that will be shown in this group.
+ * @property id A unique ID for this group (uses a generated UUID by default)
+ */
+ data class SuggestionProviderGroup(
+ val providers: List<SuggestionProvider>,
+ var priority: Int = 0,
+ val title: String? = null,
+ val limit: Int = Integer.MAX_VALUE,
+ val id: String = UUID.randomUUID().toString(),
+ )
+}
diff --git a/mobile/android/android-components/components/concept/base/README.md b/mobile/android/android-components/components/concept/base/README.md
new file mode 100644
index 0000000000..b98fc60e53
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/README.md
@@ -0,0 +1,21 @@
+# [Android Components](../../../README.md) > Concept > Base
+
+A component for basic interfaces needed by multiple components and that do not warrant a standalone component.
+
+## Usage
+
+Usually this component is not used by apps directly. Instead it will be referenced by other components as a transitive dependency.
+
+### 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:concept-base:{latest-version}"
+```
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/concept/base/build.gradle b/mobile/android/android-components/components/concept/base/build.gradle
new file mode 100644
index 0000000000..75de219239
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/build.gradle
@@ -0,0 +1,46 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-parcelize'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+
+ buildConfigField("String", "LIBRARY_VERSION", "\"" + config.componentsVersion + "\"")
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ buildFeatures {
+ buildConfig true
+ }
+
+ namespace 'mozilla.components.concept.base'
+}
+
+dependencies {
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ implementation ComponentsDependencies.androidx_annotation
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_mockwebserver
+
+ testImplementation project(':support-test')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/base/proguard-rules.pro b/mobile/android/android-components/components/concept/base/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/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/concept/base/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/base/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/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/concept/base/src/main/java/mozilla/components/concept/base/crash/Breadcrumb.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/Breadcrumb.kt
new file mode 100644
index 0000000000..061420ee47
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/Breadcrumb.kt
@@ -0,0 +1,134 @@
+/* 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.concept.base.crash
+
+import android.annotation.SuppressLint
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import org.json.JSONObject
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Represents a single crash breadcrumb.
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class Breadcrumb(
+ /**
+ * Message of the crash breadcrumb.
+ */
+ val message: String = "",
+
+ /**
+ * Data related to the crash breadcrumb.
+ */
+ val data: Map<String, String> = emptyMap(),
+
+ /**
+ * Category of the crash breadcrumb.
+ */
+ val category: String = "",
+
+ /**
+ * Level of the crash breadcrumb.
+ */
+ val level: Level = Level.DEBUG,
+
+ /**
+ * Type of the crash breadcrumb.
+ */
+ val type: Type = Type.DEFAULT,
+
+ /**
+ * Date of the crash breadcrumb.
+ */
+ val date: Date = Date(),
+) : Parcelable, Comparable<Breadcrumb> {
+ /**
+ * Crash breadcrumb priority level.
+ */
+ enum class Level(val value: String) {
+ /**
+ * DEBUG level.
+ */
+ DEBUG("Debug"),
+
+ /**
+ * INFO level.
+ */
+ INFO("Info"),
+
+ /**
+ * WARNING level.
+ */
+ WARNING("Warning"),
+
+ /**
+ * ERROR level.
+ */
+ ERROR("Error"),
+
+ /**
+ * CRITICAL level.
+ */
+ CRITICAL("Critical"),
+ }
+
+ /**
+ * Crash breadcrumb type.
+ */
+ enum class Type(val value: String) {
+ /**
+ * DEFAULT type.
+ */
+ DEFAULT("Default"),
+
+ /**
+ * HTTP type.
+ */
+ HTTP("Http"),
+
+ /**
+ * NAVIGATION type.
+ */
+ NAVIGATION("Navigation"),
+
+ /**
+ * USER type.
+ */
+ USER("User"),
+ }
+
+ override fun compareTo(other: Breadcrumb): Int {
+ return this.date.compareTo(other.date)
+ }
+
+ /**
+ * Converts Breadcrumb into a JSON object
+ *
+ * @return A [JSONObject] that contains the information within the [Breadcrumb]
+ */
+ fun toJson(): JSONObject {
+ val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
+ simpleDateFormat.timeZone = TimeZone.getTimeZone("GMT")
+ val jsonObject = JSONObject()
+ jsonObject.put("timestamp", simpleDateFormat.format(this.date))
+ jsonObject.put("message", this.message)
+ jsonObject.put("category", this.category)
+ jsonObject.put("level", this.level.value)
+ jsonObject.put("type", this.type.value)
+
+ val dataJsonObject = JSONObject()
+ for ((k, v) in this.data) {
+ dataJsonObject.put(k, v)
+ }
+
+ jsonObject.put("data", dataJsonObject)
+ return jsonObject
+ }
+}
diff --git a/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/CrashReporting.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/CrashReporting.kt
new file mode 100644
index 0000000000..6f6dc01907
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/CrashReporting.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.concept.base.crash
+
+import kotlinx.coroutines.Job
+
+/**
+ * A crash reporter interface that can report caught exception to multiple services.
+ */
+interface CrashReporting {
+
+ /**
+ * Submit a caught exception report to all registered services.
+ */
+ fun submitCaughtException(throwable: Throwable): Job
+
+ /**
+ * Add a crash breadcrumb to all registered services with breadcrumb support.
+ */
+ fun recordCrashBreadcrumb(breadcrumb: Breadcrumb)
+}
diff --git a/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/RustCrashReport.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/RustCrashReport.kt
new file mode 100644
index 0000000000..636e3bcb8b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/crash/RustCrashReport.kt
@@ -0,0 +1,19 @@
+/* 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.concept.base.crash
+
+/**
+ * Crash report for rust errors
+ *
+ * We implement this on exception classes that correspond to Rust errors to
+ * customize how the crash reports look.
+ *
+ * CrashReporting implementors should test if exceptions implement this
+ * interface. If so, they should try to customize their crash reports to match.
+ */
+interface RustCrashReport {
+ val typeName: String
+ val message: String
+}
diff --git a/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageLoader.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageLoader.kt
new file mode 100644
index 0000000000..15f7d45af3
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageLoader.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.concept.base.images
+
+import android.graphics.drawable.Drawable
+import android.widget.ImageView
+import androidx.annotation.MainThread
+
+/**
+ * A loader that can load an image from an ID directly into an [ImageView].
+ */
+interface ImageLoader {
+
+ /**
+ * Loads an image asynchronously and then displays it in the [ImageView].
+ * If the view is detached from the window before loading is completed, then loading is cancelled.
+ *
+ * @param view [ImageView] to load the image into.
+ * @param request [ImageLoadRequest] Load image for this given request.
+ * @param placeholder [Drawable] to display while image is loading.
+ * @param error [Drawable] to display if loading fails.
+ */
+ @MainThread
+ fun loadIntoView(
+ view: ImageView,
+ request: ImageLoadRequest,
+ placeholder: Drawable? = null,
+ error: Drawable? = null,
+ )
+}
diff --git a/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageRequest.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageRequest.kt
new file mode 100644
index 0000000000..f72d08a89b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/images/ImageRequest.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.concept.base.images
+
+import androidx.annotation.Px
+
+/**
+ * A request to save an image. This is an alias for the id of the image.
+ *
+ * @property id The id of the image to save
+ * @property isPrivate Whether the image is related to a private tab.
+ */
+data class ImageSaveRequest(val id: String, val isPrivate: Boolean)
+
+/**
+ * A request to load an image.
+ *
+ * @property id The id of the image to retrieve.
+ * @property size The preferred size of the image that should be loaded in pixels.
+ * @property isPrivate Whether the image is related to a private tab.
+ */
+data class ImageLoadRequest(
+ val id: String,
+ @Px val size: Int,
+ val isPrivate: Boolean,
+)
diff --git a/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/memory/MemoryConsumer.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/memory/MemoryConsumer.kt
new file mode 100644
index 0000000000..0713d5b0ce
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/memory/MemoryConsumer.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.concept.base.memory
+
+import android.content.ComponentCallbacks2
+
+/**
+ * Interface for components that can seize large amounts of memory and support trimming in low
+ * memory situations.
+ *
+ * Also see [ComponentCallbacks2].
+ */
+interface MemoryConsumer {
+ /**
+ * Notifies this component that it should try to release memory.
+ *
+ * Should be called from a [ComponentCallbacks2] providing the level passed to
+ * [ComponentCallbacks2.onTrimMemory].
+ *
+ * @param level The context of the trim, giving a hint of the amount of
+ * trimming the application may like to perform. See constants in [ComponentCallbacks2].
+ */
+ fun onTrimMemory(level: Int)
+}
diff --git a/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/profiler/Profiler.kt b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/profiler/Profiler.kt
new file mode 100644
index 0000000000..93a9f2c647
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/main/java/mozilla/components/concept/base/profiler/Profiler.kt
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.base.profiler
+
+/**
+ * [Profiler] is being used to manage Firefox Profiler related features.
+ *
+ * If you want to add a profiler marker to mark a point in time (without a duration)
+ * you can directly use `engine.profiler?.addMarker("marker name")`.
+ * Or if you want to provide more information, you can use
+ * `engine.profiler?.addMarker("marker name", "extra information")`.
+ *
+ * If you want to add a profiler marker with a duration (with start and end time)
+ * you can use it like this, it will automatically get the end time inside the addMarker:
+ * ```
+ * val startTime = engine.profiler?.getProfilerTime()
+ * ...some code you want to measure...
+ * engine.profiler?.addMarker("name", startTime)
+ * ```
+ *
+ * Or you can capture start and end time in somewhere, then add the marker in somewhere else:
+ * ```
+ * val startTime = engine.profiler?.getProfilerTime()
+ * ...some code you want to measure (or end time can be collected in a callback)...
+ * val endTime = engine.profiler?.getProfilerTime()
+ *
+ * ...somewhere else in the codebase...
+ * engine.profiler?.addMarker("name", startTime, endTime)
+ * ```
+ *
+ * Here's an [Profiler.addMarker] example with all the possible parameters:
+ * ```
+ * val startTime = engine.profiler?.getProfilerTime()
+ * ...some code you want to measure...
+ * val endTime = engine.profiler?.getProfilerTime()
+ *
+ * ...somewhere else in the codebase...
+ * engine.profiler?.addMarker("name", startTime, endTime, "extra information")
+ * ```
+ *
+ * [Profiler.isProfilerActive] method is handy when you want to get more information to
+ * add inside the marker, but you think it's going to be computationally heavy (and useless)
+ * when profiler is not running:
+ * ```
+ * val startTime = engine.profiler?.getProfilerTime()
+ * ...some code you want to measure...
+ * if (engine.profiler?.isProfilerActive()) {
+ * val info = aFunctionYouDoNotWantToCallWhenProfilerIsNotActive()
+ * engine.profiler?.addMarker("name", startTime, info)
+ * }
+ * ```
+ */
+interface Profiler {
+ /**
+ * Returns true if profiler is active and it's allowed the add markers.
+ * It's useful when it's computationally heavy to get startTime or the
+ * additional text for the marker. That code can be wrapped with
+ * isProfilerActive if check to reduce the overhead of it.
+ *
+ * @return true if profiler is active and safe to add a new marker.
+ */
+ fun isProfilerActive(): Boolean
+
+ /**
+ * Get the profiler time to be able to mark the start of the marker events.
+ * can be used like this:
+ *
+ * <code>
+ * val startTime = engine.profiler?.getProfilerTime()
+ * ...some code you want to measure...
+ * engine.profiler?.addMarker("name", startTime)
+ * </code>
+ *
+ * @return profiler time as Double or null if the profiler is not active.
+ */
+ fun getProfilerTime(): Double?
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments.
+ * It can be used for either adding a point-in-time marker or a duration marker.
+ * No-op if profiler is not active.
+ *
+ * @param markerName Name of the event as a string.
+ * @param startTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param endTime End time as Double. If it's null, this function implicitly gets the end time.
+ * @param text An optional string field for more information about the marker.
+ */
+ fun addMarker(markerName: String, startTime: Double?, endTime: Double?, text: String?)
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments.
+ * End time will be added automatically with the current profiler time when the function is called.
+ * No-op if profiler is not active.
+ * This is an overload of [Profiler.addMarker] for convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ fun addMarker(markerName: String, startTime: Double?, text: String?)
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments.
+ * End time will be added automatically with the current profiler time when the function is called.
+ * No-op if profiler is not active.
+ * This is an overload of [Profiler.addMarker] for convenience.
+ *
+ * @param markerName Name of the event as a string.
+ * @param startTime Start time as Double. It can be null if you want to mark a point of time.
+ */
+ fun addMarker(markerName: String, startTime: Double?)
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments.
+ * Time will be added automatically with the current profiler time when the function is called.
+ * No-op if profiler is not active.
+ * This is an overload of [Profiler.addMarker] for convenience.
+ *
+ * @param markerName Name of the event as a string.
+ * @param text An optional string field for more information about the marker.
+ */
+ fun addMarker(markerName: String, text: String?)
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments.
+ * Time will be added automatically with the current profiler time when the function is called.
+ * No-op if profiler is not active.
+ * This is an overload of [Profiler.addMarker] for convenience.
+ *
+ * @param markerName Name of the event as a string.
+ */
+ fun addMarker(markerName: String)
+
+ /**
+ * Start the Gecko profiler with the given settings. This is used by embedders which want to
+ * control the profiler from the embedding app. This allows them to provide an easier access point
+ * to profiling, as an alternative to the traditional way of using a desktop Firefox instance
+ * connected via USB + adb.
+ *
+ * @param aFilters The list of threads to profile, as an array of string of thread names filters.
+ * Each filter is used as a case-insensitive substring match against the actual thread names.
+ * @param aFeaturesArr The list of profiler features to enable for profiling, as a string array.
+ */
+ fun startProfiler(filters: Array<String>, features: Array<String>)
+
+ /**
+ * Stop the profiler and capture the recorded profile. This method is asynchronous.
+ *
+ * @return GeckoResult for the captured profile. The profile is returned as a byte[] buffer
+ * containing a gzip-compressed payload (with gzip header) of the profile JSON.
+ */
+ fun stopProfiler(onSuccess: (ByteArray?) -> Unit, onError: (Throwable) -> Unit)
+}
diff --git a/mobile/android/android-components/components/concept/base/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/concept/base/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/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/concept/base/src/test/resources/robolectric.properties b/mobile/android/android-components/components/concept/base/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/base/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28
diff --git a/mobile/android/android-components/components/concept/engine/README.md b/mobile/android/android-components/components/concept/engine/README.md
new file mode 100644
index 0000000000..6a0b66bf84
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/README.md
@@ -0,0 +1,45 @@
+# [Android Components](../../../README.md) > Concept > Engine
+
+The `concept-engine` component contains interfaces and abstract classes that hide the actual browser engine implementation from other components needing access to the browser engine.
+
+There are implementations for [WebView](https://developer.android.com/reference/android/webkit/WebView) and multiple release channels of [GeckoView](https://wiki.mozilla.org/Mobile/GeckoView) available.
+
+Other components and apps only referencing `concept-engine` makes it possible to:
+
+* Build components that work independently of the engine being used.
+* Build apps that can work with multiple engines (Compile-time or Run-time).
+* Build apps that can be build against different GeckoView release channels (Nightly/Beta/Release).
+
+## 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:concept-engine:{latest-version}"
+```
+
+### Integration
+
+Usually it is not needed to interact with the `Engine` component directly. The [browser-session](../../browser/session/README.md) component will take care of making the state accessible and link a `Session` to an `EngineSession` internally. The [feature-session](../../feature/session/README.md) component will provide "use cases" to perform actions like loading URLs and takes care of rendering the selected `Session` on an `EngineView`.
+``
+### Observing changes
+
+Every `EngineSession` can be observed for changes by registering an `EngineSession.Observer` instance.
+
+```Kotlin
+engineSession.register(object : EngineSession.Observer {
+ onLocationChange(url: String) {
+ // This session is pointing to a different URL now.
+ }
+})
+```
+
+`EngineSession.Observer` provides empty default implementation of every method so that only the needed ones need to be overridden. See the API reference of the current version to see all available methods.
+
+## 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/concept/engine/build.gradle b/mobile/android/android-components/components/concept/engine/build.gradle
new file mode 100644
index 0000000000..a84492bacb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/build.gradle
@@ -0,0 +1,52 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-parcelize'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.engine'
+}
+
+dependencies {
+ implementation project(':support-ktx')
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ implementation ComponentsDependencies.androidx_annotation
+ implementation ComponentsDependencies.androidx_paging
+
+ // We expose this as API because we are using Observable in our public API and do not want every
+ // consumer to have to manually import "base".
+ api project(':support-base')
+ api project(':browser-errorpages')
+ api project(':concept-storage')
+ api project(':concept-fetch')
+
+ testImplementation project(':support-utils')
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.kotlin_reflect
+
+ testImplementation project(':support-test')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/engine/proguard-rules.pro b/mobile/android/android-components/components/concept/engine/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/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/concept/engine/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/engine/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/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/concept/engine/src/main/java/mozilla/components/concept/engine/CancellableOperation.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/CancellableOperation.kt
new file mode 100644
index 0000000000..ce819011ab
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/CancellableOperation.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.concept.engine
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+
+/**
+ * Represents an async operation that can be cancelled.
+ */
+interface CancellableOperation {
+
+ /**
+ * Implementation of [CancellableOperation] that does nothing (for
+ * testing purposes or implementing default methods.)
+ */
+ class Noop : CancellableOperation {
+ override fun cancel(): Deferred<Boolean> {
+ return CompletableDeferred(true)
+ }
+ }
+
+ /**
+ * Cancels this operation.
+ *
+ * @return a deferred value indicating whether or not cancellation was successful.
+ */
+ fun cancel(): Deferred<Boolean>
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/DataCleanable.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/DataCleanable.kt
new file mode 100644
index 0000000000..bd69001857
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/DataCleanable.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.concept.engine
+
+/**
+ * Contract to indicate how objects with the ability to clear data should behave.
+ */
+interface DataCleanable {
+ /**
+ * Clears browsing data stored.
+ *
+ * @param data the type of data that should be cleared, defaults to all.
+ * @param host (optional) name of the host for which data should be cleared. If
+ * omitted data will be cleared for all hosts.
+ * @param onSuccess (optional) callback invoked if the data was cleared successfully.
+ * @param onError (optional) callback invoked if clearing the data caused an exception.
+ */
+ fun clearData(
+ data: Engine.BrowsingData = Engine.BrowsingData.all(),
+ host: String? = null,
+ onSuccess: (() -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = onError(UnsupportedOperationException("Clearing browsing data is not supported."))
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt
new file mode 100644
index 0000000000..9c37662514
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Engine.kt
@@ -0,0 +1,282 @@
+/* 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.concept.engine
+
+import android.content.Context
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.util.JsonReader
+import androidx.annotation.MainThread
+import mozilla.components.concept.base.profiler.Profiler
+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.serviceworker.ServiceWorkerDelegate
+import mozilla.components.concept.engine.translate.TranslationsRuntime
+import mozilla.components.concept.engine.utils.EngineVersion
+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 org.json.JSONObject
+
+/**
+ * Entry point for interacting with the engine implementation.
+ */
+interface Engine : WebExtensionRuntime, TranslationsRuntime, DataCleanable {
+
+ /**
+ * Describes a combination of browsing data types stored by the engine.
+ */
+ class BrowsingData internal constructor(val types: Int) {
+ companion object {
+ const val COOKIES: Int = 1 shl 0
+ const val NETWORK_CACHE: Int = 1 shl 1
+ const val IMAGE_CACHE: Int = 1 shl 2
+ const val DOM_STORAGES: Int = 1 shl 4
+ const val AUTH_SESSIONS: Int = 1 shl 5
+ const val PERMISSIONS: Int = 1 shl 6
+ const val ALL_CACHES: Int = NETWORK_CACHE + IMAGE_CACHE
+ const val ALL_SITE_SETTINGS: Int = (1 shl 7) + PERMISSIONS
+ const val ALL_SITE_DATA: Int = (1 shl 8) + COOKIES + DOM_STORAGES + ALL_CACHES + ALL_SITE_SETTINGS
+ const val ALL: Int = 1 shl 9
+
+ fun allCaches() = BrowsingData(ALL_CACHES)
+ fun allSiteSettings() = BrowsingData(ALL_SITE_SETTINGS)
+ fun allSiteData() = BrowsingData(ALL_SITE_DATA)
+ fun all() = BrowsingData(ALL)
+ fun select(vararg types: Int) = BrowsingData(types.sum())
+ }
+
+ fun contains(type: Int) = (types and type) != 0 || types == ALL
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is BrowsingData) return false
+ if (types != other.types) return false
+ return true
+ }
+
+ override fun hashCode() = types
+ }
+
+ /**
+ * HTTPS-Only mode: Connections will be upgraded to HTTPS.
+ */
+ enum class HttpsOnlyMode {
+ /**
+ * HTTPS-Only Mode disabled: Allow all insecure connections.
+ */
+ DISABLED,
+
+ /**
+ * HTTPS-Only Mode enabled only in private tabs: Allow insecure connections in normal
+ * browsing, but only HTTPS in private browsing.
+ */
+ ENABLED_PRIVATE_ONLY,
+
+ /**
+ * HTTPS-Only Mode enabled: Only allow HTTPS connections.
+ */
+ ENABLED,
+ }
+
+ /**
+ * Makes sure all required engine initialization logic is executed. The
+ * details are specific to individual implementations, but the following must be true:
+ *
+ * - The engine must be operational after this method was called successfully
+ * - Calling this method on an engine that is already initialized has no effect
+ */
+ @MainThread
+ fun warmUp() = Unit
+
+ /**
+ * Creates a new view for rendering web content.
+ *
+ * @param context an application context
+ * @param attrs optional set of attributes
+ *
+ * @return new newly created [EngineView].
+ */
+ fun createView(context: Context, attrs: AttributeSet? = null): EngineView
+
+ /**
+ * Creates a new engine session. If [speculativeCreateSession] is supported this
+ * method returns the prepared [EngineSession] if it is still applicable i.e.
+ * the parameter(s) ([private]) are equal.
+ *
+ * @param private whether or not this session should use private mode.
+ * @param contextId the session context ID for this session.
+ *
+ * @return the newly created [EngineSession].
+ */
+ @MainThread
+ fun createSession(private: Boolean = false, contextId: String? = null): EngineSession
+
+ /**
+ * Create a new [EngineSessionState] instance from the serialized JSON representation.
+ */
+ fun createSessionState(json: JSONObject): EngineSessionState
+
+ /**
+ * Creates a new [EngineSessionState] instances from the serialized JSON representation.
+ */
+ fun createSessionStateFrom(reader: JsonReader): EngineSessionState
+
+ /**
+ * Returns the name of this engine. The returned string might be used
+ * in filenames and must therefore only contain valid filename
+ * characters.
+ *
+ * @return the engine name as specified by concrete implementations.
+ */
+ fun name(): String
+
+ /**
+ * 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.
+ *
+ * Not all [Engine] implementations may actually implement this.
+ */
+ fun speculativeConnect(url: String)
+
+ /**
+ * Informs the engine that an [EngineSession] is likely to be requested soon
+ * via [createSession]. This is useful in case creating an engine session is
+ * costly and an application wants to decide when the session should be created
+ * without having to manage the session itself i.e. when it may or may not
+ * need it.
+ *
+ * @param private whether or not the session should use private mode.
+ * @param contextId the session context ID for the session.
+ */
+ @MainThread
+ fun speculativeCreateSession(private: Boolean = false, contextId: String? = null) = Unit
+
+ /**
+ * Removes and closes a speculative session created by [speculativeCreateSession]. This is
+ * useful in case the session should no longer be used e.g. because engine settings have
+ * changed.
+ */
+ @MainThread
+ fun clearSpeculativeSession() = Unit
+
+ /**
+ * Registers a [WebNotificationDelegate] to be notified of engine events
+ * related to web notifications
+ *
+ * @param webNotificationDelegate callback to be invoked for web notification events.
+ */
+ fun registerWebNotificationDelegate(
+ webNotificationDelegate: WebNotificationDelegate,
+ ): Unit = throw UnsupportedOperationException("Web notification support is not available in this engine")
+
+ /**
+ * Registers a [WebPushDelegate] to be notified of engine events related to web extensions.
+ *
+ * @return A [WebPushHandler] to notify the engine with messages and subscriptions when are delivered.
+ */
+ fun registerWebPushDelegate(
+ webPushDelegate: WebPushDelegate,
+ ): WebPushHandler = throw UnsupportedOperationException("Web Push support is not available in this engine")
+
+ /**
+ * Registers an [ActivityDelegate] to be notified on activity events that are needed by the engine.
+ */
+ fun registerActivityDelegate(
+ activityDelegate: ActivityDelegate,
+ ): Unit = throw UnsupportedOperationException("This engine does not have support for an Activity delegate.")
+
+ /**
+ * Un-registers the attached [ActivityDelegate] if one was added with [registerActivityDelegate].
+ */
+ fun unregisterActivityDelegate(): Unit =
+ throw UnsupportedOperationException("This engine does not have support for an Activity delegate.")
+
+ /**
+ * Registers an [OrientationDelegate] to be notified when a website asked the engine
+ * to lock the the app on a certain screen orientation.
+ */
+ fun registerScreenOrientationDelegate(
+ delegate: OrientationDelegate,
+ ): Unit = throw UnsupportedOperationException("This engine does not have support for an Activity delegate.")
+
+ /**
+ * Un-registers the attached [OrientationDelegate] if one was added with
+ * [registerScreenOrientationDelegate].
+ */
+ fun unregisterScreenOrientationDelegate(): Unit =
+ throw UnsupportedOperationException("This engine does not have support for an Activity delegate.")
+
+ /**
+ * Registers a [ServiceWorkerDelegate] to be notified of service workers events and requests.
+ *
+ * @param serviceWorkerDelegate [ServiceWorkerDelegate] responding to all service workers events and requests.
+ */
+ fun registerServiceWorkerDelegate(
+ serviceWorkerDelegate: ServiceWorkerDelegate,
+ ): Unit = throw UnsupportedOperationException("Service workers support not available in this engine")
+
+ /**
+ * Un-registers the attached [ServiceWorkerDelegate] if one was added with
+ * [registerServiceWorkerDelegate].
+ */
+ fun unregisterServiceWorkerDelegate(): Unit =
+ throw UnsupportedOperationException("Service workers support not available in this engine")
+
+ /**
+ * Handles user interacting with a web notification.
+ *
+ * @param webNotification [Parcelable] representing a web notification.
+ * If the `Parcelable` is not a web notification this method will be no-op.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">MDN Notification docs</a>
+ */
+ fun handleWebNotificationClick(webNotification: Parcelable): Unit =
+ throw UnsupportedOperationException("Web notification clicks not yet supported in this engine")
+
+ /**
+ * 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.
+ */
+ fun getTrackersLog(
+ session: EngineSession,
+ onSuccess: (List<TrackerLog>) -> Unit,
+ onError: (Throwable) -> Unit = { },
+ ): Unit = onError(
+ UnsupportedOperationException(
+ "getTrackersLog is not supported by this engine.",
+ ),
+ )
+
+ /**
+ * Provides access to the tracking protection exception list for this engine.
+ */
+ val trackingProtectionExceptionStore: TrackingProtectionExceptionStorage
+ get() = throw UnsupportedOperationException("TrackingProtectionExceptionStorage not supported by this engine.")
+
+ /**
+ * Provides access to Firefox Profiler features.
+ * See [Profiler] for more information.
+ */
+ val profiler: Profiler?
+
+ /**
+ * Provides access to the settings of this engine.
+ */
+ val settings: Settings
+
+ /**
+ * Returns the version of the engine as [EngineVersion] object.
+ */
+ val version: EngineVersion
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSession.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSession.kt
new file mode 100644
index 0000000000..1250f0f35c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSession.kt
@@ -0,0 +1,1103 @@
+/* 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.concept.engine
+
+import android.content.Intent
+import androidx.annotation.CallSuper
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_ALL
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS
+import mozilla.components.concept.engine.content.blocking.Tracker
+import mozilla.components.concept.engine.history.HistoryItem
+import mozilla.components.concept.engine.manifest.WebAppManifest
+import mozilla.components.concept.engine.media.RecordingDevice
+import mozilla.components.concept.engine.mediasession.MediaSession
+import mozilla.components.concept.engine.permission.PermissionRequest
+import mozilla.components.concept.engine.prompt.PromptRequest
+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.TranslationEngineState
+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.Response
+import mozilla.components.support.base.observer.Observable
+import mozilla.components.support.base.observer.ObserverRegistry
+
+/**
+ * Class representing a single engine session.
+ *
+ * In browsers usually a session corresponds to a tab.
+ */
+@Suppress("TooManyFunctions")
+abstract class EngineSession(
+ private val delegate: Observable<Observer> = ObserverRegistry(),
+) : Observable<EngineSession.Observer> by delegate, DataCleanable {
+ /**
+ * Interface to be implemented by classes that want to observe this engine session.
+ */
+ interface Observer {
+ /**
+ * Event to indicate the scroll position of the content has changed.
+ *
+ * @param scrollX The new horizontal scroll position in pixels.
+ * @param scrollY The new vertical scroll position in pixels.
+ */
+ fun onScrollChange(scrollX: Int, scrollY: Int) = Unit
+
+ fun onLocationChange(url: String, hasUserGesture: Boolean) = Unit
+ fun onTitleChange(title: String) = Unit
+
+ /**
+ * Event to indicate a preview image URL was discovered in the content after the content loaded.
+ *
+ * @param previewImageUrl The preview image URL sent from the content.
+ */
+ fun onPreviewImageChange(previewImageUrl: String) = Unit
+
+ fun onProgress(progress: Int) = Unit
+ fun onLoadingStateChange(loading: Boolean) = Unit
+ fun onNavigationStateChange(canGoBack: Boolean? = null, canGoForward: Boolean? = null) = Unit
+ fun onSecurityChange(secure: Boolean, host: String? = null, issuer: String? = null) = Unit
+ fun onTrackerBlockingEnabledChange(enabled: Boolean) = Unit
+
+ /**
+ * Event to indicate a new [CookieBannerHandlingStatus] is available.
+ */
+ fun onCookieBannerChange(status: CookieBannerHandlingStatus) = Unit
+ fun onTrackerBlocked(tracker: Tracker) = Unit
+ fun onTrackerLoaded(tracker: Tracker) = Unit
+ fun onNavigateBack() = Unit
+
+ /**
+ * Event to indicate a product URL is currently open.
+ */
+ fun onProductUrlChange(isProductUrl: Boolean) = Unit
+
+ /**
+ * Event to indicate that a url was loaded to this session.
+ */
+ fun onLoadUrl() = Unit
+
+ /**
+ * Event to indicate that the session was requested to navigate to a specified index.
+ */
+ fun onGotoHistoryIndex() = Unit
+
+ /**
+ * Event to indicate that the session was requested to render data.
+ */
+ fun onLoadData() = Unit
+
+ /**
+ * Event to indicate that the session was requested to navigate forward in history
+ */
+ fun onNavigateForward() = Unit
+
+ /**
+ * Event to indicate whether or not this [EngineSession] should be [excluded] from tracking protection.
+ */
+ fun onExcludedOnTrackingProtectionChange(excluded: Boolean) = Unit
+
+ /**
+ * Event to indicate that this session has had it's first engine contentful paint of page content.
+ */
+ fun onFirstContentfulPaint() = Unit
+
+ /**
+ * Event to indicate that this session has had it's paint status reset.
+ */
+ fun onPaintStatusReset() = Unit
+ fun onLongPress(hitResult: HitResult) = Unit
+ fun onDesktopModeChange(enabled: Boolean) = Unit
+ fun onFind(text: String) = Unit
+ fun onFindResult(activeMatchOrdinal: Int, numberOfMatches: Int, isDoneCounting: Boolean) = Unit
+ fun onFullScreenChange(enabled: Boolean) = Unit
+
+ /**
+ * @param layoutInDisplayCutoutMode value of defined in https://developer.android.com/reference/android/view/WindowManager.LayoutParams#layoutInDisplayCutoutMode
+ */
+ fun onMetaViewportFitChanged(layoutInDisplayCutoutMode: Int) = Unit
+ fun onAppPermissionRequest(permissionRequest: PermissionRequest) = permissionRequest.reject()
+ fun onContentPermissionRequest(permissionRequest: PermissionRequest) = permissionRequest.reject()
+ fun onCancelContentPermissionRequest(permissionRequest: PermissionRequest) = Unit
+ fun onPromptRequest(promptRequest: PromptRequest) = Unit
+
+ /**
+ * The engine has requested a prompt be dismissed.
+ */
+ fun onPromptDismissed(promptRequest: PromptRequest) = Unit
+
+ /**
+ * The engine has requested a prompt update.
+ */
+ fun onPromptUpdate(previousPromptRequestUid: String, promptRequest: PromptRequest) = Unit
+
+ /**
+ * User cancelled a repost prompt. Page will not be reloaded.
+ */
+ fun onRepostPromptCancelled() = Unit
+
+ /**
+ * User cancelled a beforeunload prompt. Navigating to another page is cancelled.
+ */
+ fun onBeforeUnloadPromptDenied() = Unit
+
+ /**
+ * The engine received a request to open or close a window.
+ *
+ * @param windowRequest the request to describing the required window action.
+ */
+ fun onWindowRequest(windowRequest: WindowRequest) = Unit
+
+ /**
+ * Based on the webpage current state the toolbar should be expanded to it's full height
+ * previously specified in [EngineView.setDynamicToolbarMaxHeight].
+ */
+ fun onShowDynamicToolbar() = Unit
+
+ /**
+ * Notify that the given media session has become active.
+ *
+ * @param mediaSessionController The associated [MediaSession.Controller].
+ */
+ fun onMediaActivated(mediaSessionController: MediaSession.Controller) = Unit
+
+ /**
+ * Notify that the given media session has become inactive.
+ * Inactive media sessions can not be controlled.
+ */
+ fun onMediaDeactivated() = Unit
+
+ /**
+ * Notify on updated metadata.
+ *
+ * @param metadata The updated [MediaSession.Metadata].
+ */
+ fun onMediaMetadataChanged(metadata: MediaSession.Metadata) = Unit
+
+ /**
+ * Notify on updated supported features.
+ *
+ * @param features A combination of [MediaSession.Feature].
+ */
+ fun onMediaFeatureChanged(features: MediaSession.Feature) = Unit
+
+ /**
+ * Notify that playback has changed for the given media session.
+ *
+ * @param playbackState The updated [MediaSession.PlaybackState].
+ */
+ fun onMediaPlaybackStateChanged(playbackState: MediaSession.PlaybackState) = Unit
+
+ /**
+ * Notify on updated position state.
+ *
+ * @param positionState The updated [MediaSession.PositionState].
+ */
+ fun onMediaPositionStateChanged(positionState: MediaSession.PositionState) = Unit
+
+ /**
+ * Notify changed audio mute state.
+ *
+ * @param muted True if audio of this media session is muted.
+ */
+ fun onMediaMuteChanged(muted: Boolean) = Unit
+
+ /**
+ * Notify on changed fullscreen state.
+ *
+ * @param fullscreen True when this media session in in fullscreen mode.
+ * @param elementMetadata An instance of [MediaSession.ElementMetadata], if enabled.
+ */
+ fun onMediaFullscreenChanged(
+ fullscreen: Boolean,
+ elementMetadata: MediaSession.ElementMetadata?,
+ ) = Unit
+
+ fun onWebAppManifestLoaded(manifest: WebAppManifest) = Unit
+ fun onCrash() = Unit
+ fun onProcessKilled() = Unit
+ fun onRecordingStateChanged(devices: List<RecordingDevice>) = Unit
+
+ /**
+ * Event to indicate that a new saved [EngineSessionState] is available.
+ */
+ fun onStateUpdated(state: EngineSessionState) = Unit
+
+ /**
+ * The engine received a request to load a request.
+ *
+ * @param url The string url that was requested.
+ * @param triggeredByRedirect True if and only if the request was triggered by an HTTP redirect.
+ * @param triggeredByWebContent True if and only if the request was triggered from within
+ * web content (as opposed to via the browser chrome).
+ *
+ * Unlike the name LoadRequest.isRedirect may imply this flag is not about http redirects.
+ * The flag is "True if and only if the request was triggered by an HTTP redirect."
+ * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1545170
+ */
+ fun onLoadRequest(
+ url: String,
+ triggeredByRedirect: Boolean,
+ triggeredByWebContent: Boolean,
+ ) = Unit
+
+ /**
+ * The engine received a request to launch a app intent.
+ *
+ * @param url The string url that was requested.
+ * @param appIntent The Android Intent that was requested.
+ * web content (as opposed to via the browser chrome).
+ */
+ fun onLaunchIntentRequest(
+ url: String,
+ appIntent: Intent?,
+ ) = Unit
+
+ /**
+ * The engine received a request to download a file.
+ *
+ * @param url The string url that was requested.
+ * @param fileName The file name.
+ * @param contentLength The size of the file to be downloaded.
+ * @param contentType The type of content to be downloaded.
+ * @param cookie The cookie related to request.
+ * @param userAgent The user agent of the engine.
+ * @param skipConfirmation Whether or not the confirmation dialog should be shown before the download begins.
+ * @param openInApp Whether or not the associated resource should be opened in a third party
+ * app after processed successfully.
+ * @param isPrivate Indicates if the download was requested from a private session.
+ * @param response A response object associated with this request, when provided can be
+ * used instead of performing a manual a download.
+ */
+ fun onExternalResource(
+ url: String,
+ fileName: String? = null,
+ contentLength: Long? = null,
+ contentType: String? = null,
+ cookie: String? = null,
+ userAgent: String? = null,
+ isPrivate: Boolean = false,
+ skipConfirmation: Boolean = false,
+ openInApp: Boolean = false,
+ response: Response? = null,
+ ) = Unit
+
+ /**
+ * Event to indicate that this session has changed its history state.
+ *
+ * @param historyList The list of items in the session history.
+ * @param currentIndex Index of the current page in the history list.
+ */
+ fun onHistoryStateChanged(historyList: List<HistoryItem>, currentIndex: Int) = Unit
+
+ /**
+ * Event to indicate that an exception was thrown while generating a PDF.
+ *
+ * @param throwable The throwable from the exception.
+ */
+ fun onSaveToPdfException(throwable: Throwable) = Unit
+
+ /**
+ * Event to indicate that printing finished.
+ */
+ fun onPrintFinish() = Unit
+
+ /**
+ * Event to indicate that an exception was thrown while preparing to print or save as pdf.
+ *
+ * @param isPrint true for a true print error or false for a Save as PDF error.
+ * @param throwable The exception throwable. Usually a GeckoPrintException.
+ */
+ fun onPrintException(isPrint: Boolean, throwable: Throwable) = Unit
+
+ /**
+ * Event to indicate that the PDF was successfully generated.
+ */
+ fun onSaveToPdfComplete() = Unit
+
+ /**
+ * Event to indicate that this session needs to be checked for form data.
+ *
+ * @param containsFormData Indicates if the session has form data.
+ */
+ fun onCheckForFormData(containsFormData: Boolean) = Unit
+
+ /**
+ * Event to indicate that an exception was thrown while checking for form data.
+ *
+ * @param throwable The throwable from the exception.
+ */
+ fun onCheckForFormDataException(throwable: Throwable) = Unit
+
+ /**
+ * Event to indicate that the translations engine expects that the user will likely
+ * request page translation.
+ *
+ * The usual use case is to show a prominent translations UI entrypoint on the toolbar.
+ */
+ fun onTranslateExpected() = Unit
+
+ /**
+ * Event to indicate that the translations engine suggests notifying the user that
+ * translations are available or else offering to translate.
+ *
+ * The usual use case is to show a popup or UI notification that translations are available.
+ */
+ fun onTranslateOffer() = Unit
+
+ /**
+ * Event to indicate the translations state. Translations state change
+ * occurs generally during navigation and after translation operations are requested.
+ *
+ * @param state The translations state.
+ */
+ fun onTranslateStateChange(state: TranslationEngineState) = Unit
+
+ /**
+ * Event to indicate that the translation operation completed successfully.
+ *
+ * @param operation The operation that the translation engine completed.
+ */
+ fun onTranslateComplete(operation: TranslationOperation) = Unit
+
+ /**
+ * Event to indicate that the translation operation was unsuccessful.
+ *
+ * @param operation The operation that the translation engine attempted.
+ * @param translationError The exception that occurred during the operation.
+ */
+ fun onTranslateException(
+ operation: TranslationOperation,
+ translationError: TranslationError,
+ ) = Unit
+ }
+
+ /**
+ * Provides access to the settings of this engine session.
+ */
+ abstract val settings: Settings
+
+ /**
+ * Represents a safe browsing policy, which is indicates with type of site should be alerted
+ * to user as possible harmful.
+ */
+ @Suppress("MagicNumber")
+ enum class SafeBrowsingPolicy(val id: Int) {
+ NONE(0),
+
+ /**
+ * Blocks malware sites.
+ */
+ MALWARE(1 shl 10),
+
+ /**
+ * Blocks unwanted sites.
+ */
+ UNWANTED(1 shl 11),
+
+ /**
+ * Blocks harmful sites.
+ */
+ HARMFUL(1 shl 12),
+
+ /**
+ * Blocks phishing sites.
+ */
+ PHISHING(1 shl 13),
+
+ /**
+ * Blocks all unsafe sites.
+ */
+ RECOMMENDED(MALWARE.id + UNWANTED.id + HARMFUL.id + PHISHING.id),
+ }
+
+ /**
+ * Represents a tracking protection policy, which is a combination of
+ * tracker categories that should be blocked. Unless otherwise specified,
+ * a [TrackingProtectionPolicy] is applicable to all session types (see
+ * [TrackingProtectionPolicyForSessionTypes]).
+ */
+ open class TrackingProtectionPolicy internal constructor(
+ val trackingCategories: Array<TrackingCategory> = arrayOf(TrackingCategory.RECOMMENDED),
+ val useForPrivateSessions: Boolean = true,
+ val useForRegularSessions: Boolean = true,
+ val cookiePolicy: CookiePolicy = ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ val cookiePolicyPrivateMode: CookiePolicy = cookiePolicy,
+ val strictSocialTrackingProtection: Boolean? = null,
+ val cookiePurging: Boolean = false,
+ ) {
+
+ /**
+ * Indicates how cookies should behave for a given [TrackingProtectionPolicy].
+ * The ids of each cookiePolicy is aligned with the GeckoView @CookieBehavior constants.
+ */
+ @Suppress("MagicNumber")
+ enum class CookiePolicy(val id: Int) {
+ /**
+ * Accept first-party and third-party cookies and site data.
+ */
+ ACCEPT_ALL(0),
+
+ /**
+ * Accept only first-party cookies and site data to block cookies which are
+ * not associated with the domain of the visited site.
+ */
+ ACCEPT_ONLY_FIRST_PARTY(1),
+
+ /**
+ * Do not store any cookies and site data.
+ */
+ ACCEPT_NONE(2),
+
+ /**
+ * Accept first-party and third-party cookies and site data only from
+ * sites previously visited in a first-party context.
+ */
+ ACCEPT_VISITED(3),
+
+ /**
+ * Accept only first-party and non-tracking third-party cookies and site data
+ * to block cookies which are not associated with the domain of the visited
+ * site set by known trackers.
+ */
+ ACCEPT_NON_TRACKERS(4),
+
+ /**
+ * Enable dynamic first party isolation (dFPI); this will block third-party tracking
+ * cookies in accordance with the ETP level and isolate non-tracking third-party
+ * cookies.
+ */
+ ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS(5),
+ }
+
+ @Suppress("MagicNumber")
+ enum class TrackingCategory(val id: Int) {
+
+ NONE(0),
+
+ /**
+ * Blocks advertisement trackers from the ads-track-digest256 list.
+ */
+ AD(1 shl 1),
+
+ /**
+ * Blocks analytics trackers from the analytics-track-digest256 list.
+ */
+ ANALYTICS(1 shl 2),
+
+ /**
+ * Blocks social trackers from the social-track-digest256 list.
+ */
+ SOCIAL(1 shl 3),
+
+ /**
+ * Blocks content trackers from the content-track-digest256 list.
+ * May cause issues with some web sites.
+ */
+ CONTENT(1 shl 4),
+
+ // This policy is just to align categories with GeckoView
+ TEST(1 shl 5),
+
+ /**
+ * Blocks cryptocurrency miners.
+ */
+ CRYPTOMINING(1 shl 6),
+
+ /**
+ * Blocks fingerprinting trackers.
+ */
+ FINGERPRINTING(1 shl 7),
+
+ /**
+ * Blocks social trackers from the social-tracking-protection-digest256 list.
+ */
+ MOZILLA_SOCIAL(1 shl 8),
+
+ /**
+ * Blocks email trackers.
+ */
+ EMAIL(1 shl 9),
+
+ /**
+ * Blocks content like scripts and sub-resources.
+ */
+ SCRIPTS_AND_SUB_RESOURCES(1 shl 31),
+
+ RECOMMENDED(
+ AD.id + ANALYTICS.id + SOCIAL.id + TEST.id + MOZILLA_SOCIAL.id +
+ CRYPTOMINING.id + FINGERPRINTING.id,
+ ),
+
+ /**
+ * Combining the [RECOMMENDED] categories plus [SCRIPTS_AND_SUB_RESOURCES] & getAntiTracking[EMAIL].
+ */
+ STRICT(RECOMMENDED.id + SCRIPTS_AND_SUB_RESOURCES.id + EMAIL.id),
+ }
+
+ companion object {
+ fun none() = TrackingProtectionPolicy(
+ trackingCategories = arrayOf(TrackingCategory.NONE),
+ cookiePolicy = ACCEPT_ALL,
+ )
+
+ /**
+ * Strict policy.
+ * Combining the [TrackingCategory.STRICT] plus a cookiePolicy of [ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS].
+ * This is the strictest setting and may cause issues on some web sites.
+ */
+ fun strict() = TrackingProtectionPolicyForSessionTypes(
+ trackingCategory = arrayOf(TrackingCategory.STRICT),
+ cookiePolicy = ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ strictSocialTrackingProtection = true,
+ cookiePurging = true,
+ )
+
+ /**
+ * Recommended policy.
+ * Combining the [TrackingCategory.RECOMMENDED] plus a [CookiePolicy]
+ * of [ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS].
+ * This is the recommended setting.
+ */
+ fun recommended() = TrackingProtectionPolicyForSessionTypes(
+ trackingCategory = arrayOf(TrackingCategory.RECOMMENDED),
+ cookiePolicy = ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ strictSocialTrackingProtection = false,
+ cookiePurging = true,
+ )
+
+ /**
+ * Creates a custom [TrackingProtectionPolicyForSessionTypes] using the provide values .
+ * @param trackingCategories a list of tracking categories to apply.
+ * @param cookiePolicy indicates how cookies should behave for this policy.
+ * @param cookiePolicyPrivateMode indicates how cookies should behave in private mode for this policy,
+ * default to [cookiePolicy] if not set.
+ * @param strictSocialTrackingProtection indicate if content should be blocked from the
+ * social-tracking-protection-digest256 list, when given a null value,
+ * it is only applied when the [EngineSession.TrackingProtectionPolicy.TrackingCategory.STRICT]
+ * is set.
+ * @param cookiePurging Whether or not to automatically purge tracking cookies. This will
+ * purge cookies from tracking sites that do not have recent user interaction provided.
+ */
+ fun select(
+ trackingCategories: Array<TrackingCategory> = arrayOf(TrackingCategory.RECOMMENDED),
+ cookiePolicy: CookiePolicy = ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ cookiePolicyPrivateMode: CookiePolicy = cookiePolicy,
+ strictSocialTrackingProtection: Boolean? = null,
+ cookiePurging: Boolean = false,
+ ) = TrackingProtectionPolicyForSessionTypes(
+ trackingCategory = trackingCategories,
+ cookiePolicy = cookiePolicy,
+ cookiePolicyPrivateMode = cookiePolicyPrivateMode,
+ strictSocialTrackingProtection = strictSocialTrackingProtection,
+ cookiePurging = cookiePurging,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is TrackingProtectionPolicy) return false
+ if (hashCode() != other.hashCode()) return false
+ if (useForPrivateSessions != other.useForPrivateSessions) return false
+ if (useForRegularSessions != other.useForRegularSessions) return false
+ if (cookiePurging != other.cookiePurging) return false
+ if (cookiePolicyPrivateMode != other.cookiePolicyPrivateMode) return false
+ if (strictSocialTrackingProtection != other.strictSocialTrackingProtection) return false
+ return true
+ }
+
+ override fun hashCode() = trackingCategories.sumOf { it.id } + cookiePolicy.id
+
+ fun contains(category: TrackingCategory) =
+ (trackingCategories.sumOf { it.id } and category.id) != 0
+ }
+
+ /**
+ * Represents settings options for cookie banner handling.
+ */
+ @Suppress("MagicNumber")
+ enum class CookieBannerHandlingMode(val mode: Int) {
+ /**
+ * The feature is turned off and cookie banners are not handled
+ */
+ DISABLED(0),
+
+ /**
+ * Reject cookies if possible
+ */
+ REJECT_ALL(1),
+
+ /**
+ * Reject cookies if possible. If rejecting is not possible, accept cookies
+ */
+ REJECT_OR_ACCEPT_ALL(2),
+ }
+
+ /**
+ * Represents a status for cookie banner handling.
+ */
+ enum class CookieBannerHandlingStatus {
+ /**
+ * Indicates a cookie banner was detected.
+ */
+ DETECTED,
+
+ /**
+ * Indicates a cookie banner was handled.
+ */
+ HANDLED,
+
+ /**
+ * Indicates a cookie banner has not been detected yet.
+ */
+ NO_DETECTED,
+ }
+
+ /**
+ * Subtype of [TrackingProtectionPolicy] to control the type of session this policy
+ * should be applied to. By default, a policy will be applied to all sessions.
+ * @param trackingCategory a list of tracking categories to apply.
+ * @param cookiePolicy indicates how cookies should behave for this policy.
+ * @param cookiePolicyPrivateMode indicates how cookies should behave in private mode for this policy,
+ * default to [cookiePolicy] if not set.
+ * @param strictSocialTrackingProtection indicate if content should be blocked from the
+ * social-tracking-protection-digest256 list, when given a null value,
+ * it is only applied when the [EngineSession.TrackingProtectionPolicy.TrackingCategory.STRICT]
+ * is set.
+ * @param cookiePurging Whether or not to automatically purge tracking cookies. This will
+ * purge cookies from tracking sites that do not have recent user interaction provided.
+ */
+ class TrackingProtectionPolicyForSessionTypes internal constructor(
+ trackingCategory: Array<TrackingCategory> = arrayOf(TrackingCategory.RECOMMENDED),
+ cookiePolicy: CookiePolicy = ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS,
+ cookiePolicyPrivateMode: CookiePolicy = cookiePolicy,
+ strictSocialTrackingProtection: Boolean? = null,
+ cookiePurging: Boolean = false,
+ ) : TrackingProtectionPolicy(
+ trackingCategories = trackingCategory,
+ cookiePolicy = cookiePolicy,
+ cookiePolicyPrivateMode = cookiePolicyPrivateMode,
+ strictSocialTrackingProtection = strictSocialTrackingProtection,
+ cookiePurging = cookiePurging,
+ ) {
+ /**
+ * Marks this policy to be used for private sessions only.
+ */
+ fun forPrivateSessionsOnly() = TrackingProtectionPolicy(
+ trackingCategories = trackingCategories,
+ useForPrivateSessions = true,
+ useForRegularSessions = false,
+ cookiePolicy = cookiePolicy,
+ cookiePolicyPrivateMode = cookiePolicyPrivateMode,
+ strictSocialTrackingProtection = false,
+ cookiePurging = cookiePurging,
+ )
+
+ /**
+ * Marks this policy to be used for regular (non-private) sessions only.
+ */
+ fun forRegularSessionsOnly() = TrackingProtectionPolicy(
+ trackingCategories = trackingCategories,
+ useForPrivateSessions = false,
+ useForRegularSessions = true,
+ cookiePolicy = cookiePolicy,
+ cookiePolicyPrivateMode = cookiePolicyPrivateMode,
+ strictSocialTrackingProtection = strictSocialTrackingProtection,
+ cookiePurging = cookiePurging,
+ )
+ }
+
+ /**
+ * Describes a combination of flags provided to the engine when loading a URL.
+ */
+ class LoadUrlFlags internal constructor(val value: Int) {
+ companion object {
+ const val NONE: Int = 0
+ const val BYPASS_CACHE: Int = 1 shl 0
+ const val BYPASS_PROXY: Int = 1 shl 1
+ const val EXTERNAL: Int = 1 shl 2
+ const val ALLOW_POPUPS: Int = 1 shl 3
+ const val BYPASS_CLASSIFIER: Int = 1 shl 4
+ const val LOAD_FLAGS_FORCE_ALLOW_DATA_URI: Int = 1 shl 5
+ const val LOAD_FLAGS_REPLACE_HISTORY: Int = 1 shl 6
+ const val LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE: Int = 1 shl 7
+ const val ALLOW_ADDITIONAL_HEADERS: Int = 1 shl 15
+ const val ALLOW_JAVASCRIPT_URL: Int = 1 shl 16
+ internal const val ALL = BYPASS_CACHE + BYPASS_PROXY + EXTERNAL + ALLOW_POPUPS +
+ BYPASS_CLASSIFIER + LOAD_FLAGS_FORCE_ALLOW_DATA_URI + LOAD_FLAGS_REPLACE_HISTORY +
+ LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE + ALLOW_ADDITIONAL_HEADERS + ALLOW_JAVASCRIPT_URL
+
+ fun all() = LoadUrlFlags(ALL)
+ fun none() = LoadUrlFlags(NONE)
+ fun external() = LoadUrlFlags(EXTERNAL)
+ fun select(vararg types: Int) = LoadUrlFlags(types.sum())
+ }
+
+ fun contains(flag: Int) = (value and flag) != 0
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is LoadUrlFlags) return false
+ if (value != other.value) return false
+ return true
+ }
+
+ override fun hashCode() = value
+ }
+
+ /**
+ * Represents a session priority, which signals to the engine that it should give
+ * a different prioritization to a given session.
+ */
+ @Suppress("MagicNumber")
+ enum class SessionPriority(val id: Int) {
+ /**
+ * Signals to the engine that this session has a default priority.
+ */
+ DEFAULT(0),
+
+ /**
+ * Signals to the engine that this session is important, and the Engine should keep
+ * the session alive for as long as possible.
+ */
+ HIGH(1),
+ }
+
+ /**
+ * Loads the given 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.
+ */
+ abstract fun loadUrl(
+ url: String,
+ parent: EngineSession? = null,
+ flags: LoadUrlFlags = LoadUrlFlags.none(),
+ additionalHeaders: Map<String, String>? = null,
+ )
+
+ /**
+ * Loads the data with the given mimeType.
+ * Example:
+ * ```
+ * engineSession.loadData("<html><body>Example HTML content here</body></html>", "text/html")
+ * ```
+ *
+ * If the data is base64 encoded, you can override the default encoding (UTF-8) with 'base64'.
+ * Example:
+ * ```
+ * engineSession.loadData("ahr0cdovl21vemlsbgeub3jn==", "text/plain", "base64")
+ * ```
+ *
+ * @param data The data that should be rendering.
+ * @param mimeType the data type needed by the engine to know how to render it.
+ * @param encoding specifies whether the data is base64 encoded; use 'base64' else defaults to "UTF-8".
+ */
+ abstract fun loadData(data: String, mimeType: String = "text/html", encoding: String = "UTF-8")
+
+ /**
+ * Requests the [EngineSession] to download the current session's contents as a PDF.
+ *
+ * A typical implementation would have the same flow that feeds into [EngineSession.Observer.onExternalResource].
+ */
+ abstract fun requestPdfToDownload()
+
+ /**
+ * Requests the [EngineSession] to print the current session's contents.
+ *
+ * This will open the Android Print Spooler.
+ */
+ abstract fun requestPrintContent()
+
+ /**
+ * Stops loading the current session.
+ */
+ abstract fun stopLoading()
+
+ /**
+ * Reloads the current URL.
+ *
+ * @param flags the [LoadUrlFlags] to use when reloading the current url.
+ */
+ abstract fun reload(flags: LoadUrlFlags = LoadUrlFlags.none())
+
+ /**
+ * Navigates back in the history of this session.
+ *
+ * @param userInteraction informs the engine whether the action was user invoked.
+ */
+ abstract fun goBack(userInteraction: Boolean = true)
+
+ /**
+ * Navigates forward in the history of this session.
+ *
+ * @param userInteraction informs the engine whether the action was user invoked.
+ */
+ abstract fun goForward(userInteraction: Boolean = true)
+
+ /**
+ * Navigates to the specified index in the [HistoryState] of this session. The current index of
+ * this session's [HistoryState] will be updated but the items within it will be unchanged.
+ * Invalid index values are ignored.
+ *
+ * @param index the index of the session's [HistoryState] to navigate to
+ */
+ abstract fun goToHistoryIndex(index: Int)
+
+ /**
+ * Restore a saved state; only data that is saved (history, scroll position, zoom, and form data)
+ * will be restored.
+ *
+ * @param state A saved session state.
+ * @return true if the engine session has successfully been restored with the provided state,
+ * false otherwise.
+ */
+ abstract fun restoreState(state: EngineSessionState): Boolean
+
+ /**
+ * Updates the tracking protection [policy] for this engine session.
+ * If you want to disable tracking protection use [TrackingProtectionPolicy.none].
+ *
+ * @param policy the tracking protection policy to use, defaults to blocking all trackers.
+ */
+ abstract fun updateTrackingProtection(policy: TrackingProtectionPolicy = TrackingProtectionPolicy.strict())
+
+ /**
+ * Enables/disables Desktop Mode with an optional ability to reload the session right after.
+ */
+ abstract fun toggleDesktopMode(enable: Boolean, reload: Boolean = false)
+
+ /**
+ * Checks if there is a rule for handling a cookie banner for the current website in the session.
+ *
+ * @param onSuccess callback invoked if the engine API returned a valid response. Please note
+ * that the response can be null - which can indicate a bug, a miscommunication
+ * or other unexpected failure.
+ * @param onError callback invoked if there was an error getting the response.
+ */
+ abstract fun hasCookieBannerRuleForSession(onResult: (Boolean) -> Unit, onException: (Throwable) -> Unit)
+
+ /**
+ * Checks if the current session is using a PDF viewer.
+ *
+ * @param onSuccess callback invoked if the engine API returned a valid response. Please note
+ * that the response can be null - which can indicate a bug, a miscommunication
+ * or other unexpected failure.
+ * @param onError callback invoked if there was an error getting the response.
+ */
+ abstract fun checkForPdfViewer(onResult: (Boolean) -> Unit, onException: (Throwable) -> Unit)
+
+ /**
+ * Requests product recommendations given a specific product url.
+ *
+ * @param onResult callback invoked if the engine API returned a valid response. Please note
+ * that the response can be null - which can indicate a bug, a miscommunication
+ * or other unexpected failure.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun requestProductRecommendations(
+ url: String,
+ onResult: (List<ProductRecommendation>) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Requests the analysis results for a given product page URL.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun requestProductAnalysis(
+ url: String,
+ onResult: (ProductAnalysis) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Requests the reanalysis of a product for a given product page URL.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun reanalyzeProduct(
+ url: String,
+ onResult: (String) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Requests the status of a product analysis for a given product page URL.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun requestAnalysisStatus(
+ url: String,
+ onResult: (ProductAnalysisStatus) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Sends a click attribution event for a given product aid.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun sendClickAttributionEvent(
+ aid: String,
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Sends an impression attribution event for a given product aid.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun sendImpressionAttributionEvent(
+ aid: String,
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Sends a placement attribution event for a given product aid.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun sendPlacementAttributionEvent(
+ aid: String,
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Reports when a product is back in stock.
+ *
+ * @param onResult callback invoked if the engine API returns a valid response.
+ * @param onException callback invoked if there was an error getting the response.
+ */
+ abstract fun reportBackInStock(
+ url: String,
+ onResult: (String) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Requests the [EngineSession] to translate the current session's contents.
+ *
+ * @param fromLanguage The BCP 47 language tag that the page should be translated from.
+ * @param toLanguage The BCP 47 language tag that the page should be translated to.
+ * @param options Options for how the translation should be processed.
+ */
+ abstract fun requestTranslate(
+ fromLanguage: String,
+ toLanguage: String,
+ options: TranslationOptions?,
+ )
+
+ /**
+ * Requests the [EngineSession] to restore the current session's contents.
+ * Will be a no-op on the Gecko side if the page is not translated.
+ */
+ abstract fun requestTranslationRestore()
+
+ /**
+ * Requests the [EngineSession] retrieve the current site's never translate preference.
+ */
+ abstract fun getNeverTranslateSiteSetting(
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Requests the [EngineSession] to set the current site's never translate preference.
+ *
+ * @param setting True if the site should never be translated. False if the site should be
+ * translated.
+ */
+ abstract fun setNeverTranslateSiteSetting(
+ setting: Boolean,
+ onResult: () -> Unit,
+ onException: (Throwable) -> Unit,
+ )
+
+ /**
+ * Finds and highlights all occurrences of the provided String and highlights them asynchronously.
+ *
+ * @param text the String to search for
+ */
+ abstract fun findAll(text: String)
+
+ /**
+ * Finds and highlights the next or previous match found by [findAll].
+ *
+ * @param forward true if the next match should be highlighted, false for
+ * the previous match.
+ */
+ abstract fun findNext(forward: Boolean)
+
+ /**
+ * Clears the highlighted results of previous calls to [findAll] / [findNext].
+ */
+ abstract fun clearFindMatches()
+
+ /**
+ * Exits fullscreen mode if currently in it that state.
+ */
+ abstract fun exitFullScreenMode()
+
+ /**
+ * Marks this session active/inactive for web extensions to support
+ * tabs.query({active: true}).
+ *
+ * @param active whether this session should be marked as active or inactive.
+ */
+ open fun markActiveForWebExtensions(active: Boolean) = Unit
+
+ /**
+ * Updates the priority for this session.
+ *
+ * @param priority the new priority for this session.
+ */
+ open fun updateSessionPriority(priority: SessionPriority) = Unit
+
+ /**
+ * Checks this session for existing user form data.
+ */
+ open fun checkForFormData() = Unit
+
+ /**
+ * Purges the history for the session (back and forward history).
+ */
+ abstract fun purgeHistory()
+
+ /**
+ * Close the session. This may free underlying objects. Call this when you are finished using
+ * this session.
+ */
+ @CallSuper
+ open fun close() = delegate.unregisterObservers()
+
+ /**
+ * Returns the list of URL schemes that are blocked from loading.
+ */
+ open fun getBlockedSchemes(): List<String> = emptyList()
+
+ /**
+ * Set the display member in Web App Manifest for this session.
+ *
+ * @param displayMode the display mode value for this session.
+ */
+ open fun setDisplayMode(displayMode: WebAppManifest.DisplayMode) = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSessionState.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSessionState.kt
new file mode 100644
index 0000000000..39cf4fca63
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineSessionState.kt
@@ -0,0 +1,51 @@
+/* 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.concept.engine
+
+import android.util.JsonWriter
+
+/**
+ * The state of an [EngineSession]. An instance can be obtained from [EngineSession.saveState]. Creating a new
+ * [EngineSession] and calling [EngineSession.restoreState] with the same state instance should restore the previous
+ * session.
+ */
+interface EngineSessionState {
+ /**
+ * Writes this state as JSON to the given [JsonWriter].
+ *
+ * When reading JSON from disk [Engine.createSessionState] can be used to turn it back into an [EngineSessionState]
+ * instance.
+ */
+ fun writeTo(writer: JsonWriter)
+}
+
+/**
+ * An interface describing a storage layer for an [EngineSessionState].
+ */
+interface EngineSessionStateStorage {
+ /**
+ * Writes a [state] with a provided [uuid] as its identifier.
+ *
+ * @return A boolean flag indicating if the write was a success.
+ */
+ suspend fun write(uuid: String, state: EngineSessionState): Boolean
+
+ /**
+ * Reads an [EngineSessionState] given a provided [uuid] as its identifier.
+ *
+ * @return A [EngineSessionState] if one is present for the given [uuid], `null` otherwise.
+ */
+ suspend fun read(uuid: String): EngineSessionState?
+
+ /**
+ * Deletes persisted [EngineSessionState] for a given [uuid].
+ */
+ suspend fun delete(uuid: String)
+
+ /**
+ * Deletes all persisted [EngineSessionState] instances.
+ */
+ suspend fun deleteAll()
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt
new file mode 100644
index 0000000000..e217f511d8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt
@@ -0,0 +1,201 @@
+/* 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.concept.engine
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.view.View
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import mozilla.components.concept.engine.selection.SelectionActionDelegate
+
+/**
+ * View component that renders web content.
+ */
+interface EngineView {
+
+ /**
+ * Convenience method to cast the implementation of this interface to an Android View object.
+ */
+ fun asView(): View = this as View
+
+ /**
+ * Render the content of the given session.
+ */
+ fun render(session: EngineSession)
+
+ /**
+ * Releases an [EngineSession] that is currently rendered by this view (after calling [render]).
+ *
+ * Usually an app does not need to call this itself since [EngineView] will take care of that if it gets detached.
+ * However there are situations where an app wants to hand-off rendering of an [EngineSession] to a different
+ * [EngineView] without the current [EngineView] getting detached immediately.
+ */
+ fun release()
+
+ /**
+ * To be called in response to [Lifecycle.Event.ON_RESUME]. See [EngineView]
+ * implementations for details.
+ */
+ fun onResume() = Unit
+
+ /**
+ * To be called in response to [Lifecycle.Event.ON_PAUSE]. See [EngineView]
+ * implementations for details.
+ */
+ fun onPause() = Unit
+
+ /**
+ * To be called in response to [Lifecycle.Event.ON_START]. See [EngineView]
+ * implementations for details.
+ */
+ fun onStart() = Unit
+
+ /**
+ * To be called in response to [Lifecycle.Event.ON_STOP]. See [EngineView]
+ * implementations for details.
+ */
+ fun onStop() = Unit
+
+ /**
+ * To be called in response to [Lifecycle.Event.ON_CREATE]. See [EngineView]
+ * implementations for details.
+ */
+ fun onCreate() = Unit
+
+ /**
+ * To be called in response to [Lifecycle.Event.ON_DESTROY]. See [EngineView]
+ * implementations for details.
+ */
+ fun onDestroy() = Unit
+
+ /**
+ * Check if [EngineView] can clear the selection.
+ * true if can and false otherwise.
+ */
+ fun canClearSelection(): Boolean = false
+
+ /**
+ * Check if [EngineView] can be scrolled vertically up.
+ * true if can and false otherwise.
+ */
+ fun canScrollVerticallyUp(): Boolean = true
+
+ /**
+ * Check if [EngineView] can be scrolled vertically down.
+ * true if can and false otherwise.
+ */
+ fun canScrollVerticallyDown(): Boolean = true
+
+ /**
+ * @return [InputResult] indicating how user's last [android.view.MotionEvent] was handled.
+ */
+ @Deprecated("Not enough data about how the touch was handled", ReplaceWith("getInputResultDetail()"))
+ @Suppress("DEPRECATION")
+ fun getInputResult(): InputResult = InputResult.INPUT_RESULT_UNHANDLED
+
+ /**
+ * @return [InputResultDetail] indicating how user's last [android.view.MotionEvent] was handled.
+ */
+ fun getInputResultDetail(): InputResultDetail = InputResultDetail.newInstance()
+
+ /**
+ * Request a screenshot of the visible portion of the web page currently being rendered.
+ * @param onFinish A callback to inform that process of capturing a
+ * thumbnail has finished. Important for engine-gecko: Make sure not to reference the
+ * context or view in this callback to prevent memory leaks:
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1678364
+ */
+ fun captureThumbnail(onFinish: (Bitmap?) -> Unit)
+
+ /**
+ * Clears the current selection if possible.
+ */
+ fun clearSelection() = Unit
+
+ /**
+ * Updates the amount of vertical space that is clipped or visibly obscured in the bottom portion of the view.
+ * Tells the [EngineView] where to put bottom fixed elements so they are fully visible.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ fun setVerticalClipping(clippingHeight: Int)
+
+ /**
+ * Sets the maximum height of the dynamic toolbar(s).
+ *
+ * @param height The maximum possible height of the toolbar.
+ */
+ fun setDynamicToolbarMaxHeight(height: Int)
+
+ /**
+ * Sets the Activity context for GeckoView.
+ *
+ * @param context The Activity context.
+ */
+ fun setActivityContext(context: Context?)
+
+ /**
+ * A delegate that will handle interactions with text selection context menus.
+ */
+ var selectionActionDelegate: SelectionActionDelegate?
+
+ /**
+ * Enumeration of all possible ways user's [android.view.MotionEvent] was handled.
+ *
+ * @see [INPUT_RESULT_UNHANDLED]
+ * @see [INPUT_RESULT_HANDLED]
+ * @see [INPUT_RESULT_HANDLED_CONTENT]
+ */
+ @Deprecated("Not enough data about how the touch was handled", ReplaceWith("InputResultDetail"))
+ @Suppress("DEPRECATION")
+ enum class InputResult(val value: Int) {
+ /**
+ * Last [android.view.MotionEvent] was not handled by neither us nor the webpage.
+ */
+ INPUT_RESULT_UNHANDLED(0),
+
+ /**
+ * We handled the last [android.view.MotionEvent].
+ */
+ INPUT_RESULT_HANDLED(1),
+
+ /**
+ * Webpage handled the last [android.view.MotionEvent].
+ * (through it's own touch event listeners)
+ */
+ INPUT_RESULT_HANDLED_CONTENT(2),
+ }
+}
+
+/**
+ * [LifecycleObserver] which dispatches lifecycle events to an [EngineView].
+ */
+class LifecycleObserver(val engineView: EngineView) : DefaultLifecycleObserver {
+
+ override fun onPause(owner: LifecycleOwner) {
+ engineView.onPause()
+ }
+ override fun onResume(owner: LifecycleOwner) {
+ engineView.onResume()
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ engineView.onStart()
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ engineView.onStop()
+ }
+
+ override fun onCreate(owner: LifecycleOwner) {
+ engineView.onCreate()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ engineView.onDestroy()
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/HitResult.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/HitResult.kt
new file mode 100644
index 0000000000..ff9637e0f3
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/HitResult.kt
@@ -0,0 +1,52 @@
+/* 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.concept.engine
+
+/**
+ * Represents all the different supported types of data that can be found from long clicking
+ * an element.
+ */
+@Suppress("ClassNaming", "ClassName")
+sealed class HitResult(open val src: String) {
+ /**
+ * Default type if we're unable to match the type to anything. It may or may not have a src.
+ */
+ data class UNKNOWN(override val src: String) : HitResult(src)
+
+ /**
+ * If the HTML element was of type 'HTMLImageElement'.
+ */
+ data class IMAGE(override val src: String, val title: String? = null) : HitResult(src)
+
+ /**
+ * If the HTML element was of type 'HTMLVideoElement'.
+ */
+ data class VIDEO(override val src: String, val title: String? = null) : HitResult(src)
+
+ /**
+ * If the HTML element was of type 'HTMLAudioElement'.
+ */
+ data class AUDIO(override val src: String, val title: String? = null) : HitResult(src)
+
+ /**
+ * If the HTML element was of type 'HTMLImageElement' and contained a URI.
+ */
+ data class IMAGE_SRC(override val src: String, val uri: String) : HitResult(src)
+
+ /**
+ * The type used if the URI is prepended with 'tel:'.
+ */
+ data class PHONE(override val src: String) : HitResult(src)
+
+ /**
+ * The type used if the URI is prepended with 'mailto:'.
+ */
+ data class EMAIL(override val src: String) : HitResult(src)
+
+ /**
+ * The type used if the URI is prepended with 'geo:'.
+ */
+ data class GEO(override val src: String) : HitResult(src)
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt
new file mode 100644
index 0000000000..e56d59bee8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt
@@ -0,0 +1,378 @@
+/* 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.concept.engine
+
+import android.view.MotionEvent
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Don't yet have a response from the browser about how the touch was handled.
+ */
+const val INPUT_HANDLING_UNKNOWN = -1
+
+// The below top-level values are following the same from [org.mozilla.geckoview.PanZoomController]
+/**
+ * The content has no scrollable element.
+ *
+ * @see [InputResultDetail.isTouchUnhandled]
+ */
+const val INPUT_UNHANDLED = 0
+
+/**
+ * The touch event is consumed by the [EngineView]
+ *
+ * @see [InputResultDetail.isTouchHandledByBrowser]
+ */
+const val INPUT_HANDLED = 1
+
+/**
+ * The touch event is consumed by the website through it's own touch listeners.
+ *
+ * @see [InputResultDetail.isTouchHandledByWebsite]
+ */
+const val INPUT_HANDLED_CONTENT = 2
+
+/**
+ * The website content is not scrollable.
+ */
+@VisibleForTesting
+internal const val SCROLL_DIRECTIONS_NONE = 0
+
+/**
+ * The website content can be scrolled to the top.
+ *
+ * @see [InputResultDetail.canScrollToTop]
+ */
+@VisibleForTesting
+internal const val SCROLL_DIRECTIONS_TOP = 1 shl 0
+
+/**
+ * The website content can be scrolled to the right.
+ *
+ * @see [InputResultDetail.canScrollToRight]
+ */
+@VisibleForTesting
+internal const val SCROLL_DIRECTIONS_RIGHT = 1 shl 1
+
+/**
+ * The website content can be scrolled to the bottom.
+ *
+ * @see [InputResultDetail.canScrollToBottom]
+ */
+@VisibleForTesting
+internal const val SCROLL_DIRECTIONS_BOTTOM = 1 shl 2
+
+/**
+ * The website content can be scrolled to the left.
+ *
+ * @see [InputResultDetail.canScrollToLeft]
+ */
+@VisibleForTesting
+internal const val SCROLL_DIRECTIONS_LEFT = 1 shl 3
+
+/**
+ * The website content cannot be overscrolled.
+ */
+@VisibleForTesting
+internal const val OVERSCROLL_DIRECTIONS_NONE = 0
+
+/**
+ * The website content can be overscrolled horizontally.
+ *
+ * @see [InputResultDetail.canOverscrollRight]
+ * @see [InputResultDetail.canOverscrollLeft]
+ */
+@VisibleForTesting
+internal const val OVERSCROLL_DIRECTIONS_HORIZONTAL = 1 shl 0
+
+/**
+ * The website content can be overscrolled vertically.
+ *
+ * @see [InputResultDetail.canOverscrollTop]
+ * @see [InputResultDetail.canOverscrollBottom]
+ */
+@VisibleForTesting
+internal const val OVERSCROLL_DIRECTIONS_VERTICAL = 1 shl 1
+
+/**
+ * All data about how a touch will be handled by the browser.
+ * - whether the event is used for panning/zooming by the browser / by the website or will be ignored.
+ * - whether the event can scroll the page and in what direction.
+ * - whether the event can overscroll the page and in what direction.
+ *
+ * @param inputResult Indicates who will use the current [MotionEvent].
+ * Possible values: [[INPUT_HANDLING_UNKNOWN], [INPUT_UNHANDLED], [INPUT_HANDLED], [INPUT_HANDLED_CONTENT]].
+ *
+ * @param scrollDirections Bitwise ORed value of the directions the page can be scrolled to.
+ * This is the same as GeckoView's [org.mozilla.geckoview.PanZoomController.ScrollableDirections].
+ *
+ * @param overscrollDirections Bitwise ORed value of the directions the page can be overscrolled to.
+ * This is the same as GeckoView's [org.mozilla.geckoview.PanZoomController.OverscrollDirections].
+ */
+@Suppress("TooManyFunctions")
+class InputResultDetail private constructor(
+ val inputResult: Int = INPUT_HANDLING_UNKNOWN,
+ val scrollDirections: Int = SCROLL_DIRECTIONS_NONE,
+ val overscrollDirections: Int = OVERSCROLL_DIRECTIONS_NONE,
+) {
+
+ override fun equals(other: Any?): Boolean {
+ return if (this !== other) {
+ if (other is InputResultDetail) {
+ return inputResult == other.inputResult &&
+ scrollDirections == other.scrollDirections &&
+ overscrollDirections == other.overscrollDirections
+ } else {
+ false
+ }
+ } else {
+ true
+ }
+ }
+
+ @Suppress("MagicNumber")
+ override fun hashCode(): Int {
+ var hash = inputResult.hashCode()
+ hash += (scrollDirections.hashCode()) * 10
+ hash += (overscrollDirections.hashCode()) * 100
+
+ return hash
+ }
+
+ override fun toString(): String {
+ return StringBuilder("InputResultDetail \$${hashCode()} (")
+ .append("Input ${getInputResultHandledDescription()}. ")
+ .append("Content ${getScrollDirectionsDescription()} and ${getOverscrollDirectionsDescription()}")
+ .append(')')
+ .toString()
+ }
+
+ /**
+ * Create a new instance of [InputResultDetail] with the option of keep some of the current values.
+ *
+ * The provided new values will be filtered out if not recognized and could corrupt the current state.
+ */
+ fun copy(
+ inputResult: Int? = this.inputResult,
+ scrollDirections: Int? = this.scrollDirections,
+ overscrollDirections: Int? = this.overscrollDirections,
+ ): InputResultDetail {
+ // Ensure this data will not get corrupted by users sending unknown arguments
+
+ val newValidInputResult = if (inputResult in INPUT_UNHANDLED..INPUT_HANDLED_CONTENT) {
+ inputResult
+ } else {
+ this.inputResult
+ }
+ val newValidScrollDirections = if (scrollDirections in
+ SCROLL_DIRECTIONS_NONE..(SCROLL_DIRECTIONS_LEFT or (SCROLL_DIRECTIONS_LEFT - 1))
+ ) {
+ scrollDirections
+ } else {
+ this.scrollDirections
+ }
+ val newValidOverscrollDirections = if (overscrollDirections in
+ OVERSCROLL_DIRECTIONS_NONE..(OVERSCROLL_DIRECTIONS_VERTICAL or (OVERSCROLL_DIRECTIONS_VERTICAL - 1))
+ ) {
+ overscrollDirections
+ } else {
+ this.overscrollDirections
+ }
+
+ // The range check automatically checks for null but doesn't yet have a contract to say so.
+ // As such it it safe to use the not-null assertion operator.
+ return InputResultDetail(newValidInputResult!!, newValidScrollDirections!!, newValidOverscrollDirections!!)
+ }
+
+ /**
+ * The [EngineView] has not yet responded on how it handled the [MotionEvent].
+ */
+ fun isTouchHandlingUnknown() = inputResult == INPUT_HANDLING_UNKNOWN
+
+ /**
+ * The [EngineView] handled the last [MotionEvent] to pan or zoom the content.
+ */
+ fun isTouchHandledByBrowser() = inputResult == INPUT_HANDLED
+
+ /**
+ * The website handled the last [MotionEvent] through it's own touch listeners
+ * and consumed it without the [EngineView] panning or zooming the website
+ */
+ fun isTouchHandledByWebsite() = inputResult == INPUT_HANDLED_CONTENT
+
+ /**
+ * Neither the [EngineView], nor the website will handle this [MotionEvent].
+ *
+ * This might happen on a website without touch listeners that is not bigger than the screen
+ * or when the content has no scrollable element.
+ */
+ fun isTouchUnhandled() = inputResult == INPUT_UNHANDLED
+
+ /**
+ * Whether the width of the webpage exceeds the display and the webpage can be scrolled to left.
+ */
+ fun canScrollToLeft(): Boolean =
+ inputResult == INPUT_HANDLED &&
+ scrollDirections and SCROLL_DIRECTIONS_LEFT != 0
+
+ /**
+ * Whether the height of the webpage exceeds the display and the webpage can be scrolled to top.
+ */
+ fun canScrollToTop(): Boolean =
+ inputResult == INPUT_HANDLED &&
+ scrollDirections and SCROLL_DIRECTIONS_TOP != 0
+
+ /**
+ * Whether the width of the webpage exceeds the display and the webpage can be scrolled to right.
+ */
+ fun canScrollToRight(): Boolean =
+ inputResult == INPUT_HANDLED &&
+ scrollDirections and SCROLL_DIRECTIONS_RIGHT != 0
+
+ /**
+ * Whether the height of the webpage exceeds the display and the webpage can be scrolled to bottom.
+ */
+ fun canScrollToBottom(): Boolean =
+ inputResult == INPUT_HANDLED &&
+ scrollDirections and SCROLL_DIRECTIONS_BOTTOM != 0
+
+ /**
+ * Whether the webpage can be overscrolled to the left.
+ *
+ * @return `true` if the page is already scrolled to the left most part
+ * and the touch event is not handled by the webpage.
+ */
+ fun canOverscrollLeft(): Boolean =
+ inputResult != INPUT_HANDLED_CONTENT &&
+ (scrollDirections and SCROLL_DIRECTIONS_LEFT == 0) &&
+ (overscrollDirections and OVERSCROLL_DIRECTIONS_HORIZONTAL != 0)
+
+ /**
+ * Whether the webpage can be overscrolled to the top.
+ *
+ * @return `true` if the page is already scrolled to the top most part
+ * and the touch event is not handled by the webpage.
+ */
+ fun canOverscrollTop(): Boolean =
+ inputResult != INPUT_HANDLED_CONTENT &&
+ (scrollDirections and SCROLL_DIRECTIONS_TOP == 0) &&
+ (overscrollDirections and OVERSCROLL_DIRECTIONS_VERTICAL != 0)
+
+ /**
+ * Whether the webpage can be overscrolled to the right.
+ *
+ * @return `true` if the page is already scrolled to the right most part
+ * and the touch event is not handled by the webpage.
+ */
+ fun canOverscrollRight(): Boolean =
+ inputResult != INPUT_HANDLED_CONTENT &&
+ (scrollDirections and SCROLL_DIRECTIONS_RIGHT == 0) &&
+ (overscrollDirections and OVERSCROLL_DIRECTIONS_HORIZONTAL != 0)
+
+ /**
+ * Whether the webpage can be overscrolled to the bottom.
+ *
+ * @return `true` if the page is already scrolled to the bottom most part
+ * and the touch event is not handled by the webpage.
+ */
+ fun canOverscrollBottom(): Boolean =
+ inputResult != INPUT_HANDLED_CONTENT &&
+ (scrollDirections and SCROLL_DIRECTIONS_BOTTOM == 0) &&
+ (overscrollDirections and OVERSCROLL_DIRECTIONS_VERTICAL != 0)
+
+ @VisibleForTesting
+ internal fun getInputResultHandledDescription() = when (inputResult) {
+ INPUT_HANDLING_UNKNOWN -> INPUT_UNKNOWN_HANDLING_DESCRIPTION
+ INPUT_HANDLED -> INPUT_HANDLED_TOSTRING_DESCRIPTION
+ INPUT_HANDLED_CONTENT -> INPUT_HANDLED_CONTENT_TOSTRING_DESCRIPTION
+ else -> INPUT_UNHANDLED_TOSTRING_DESCRIPTION
+ }
+
+ @VisibleForTesting
+ internal fun getScrollDirectionsDescription(): String {
+ if (scrollDirections == SCROLL_DIRECTIONS_NONE) {
+ return SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION
+ }
+
+ val scrollDirections = StringBuilder()
+ .append(if (canScrollToLeft()) "$SCROLL_LEFT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" else "")
+ .append(if (canScrollToTop()) "$SCROLL_TOP_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" else "")
+ .append(if (canScrollToRight()) "$SCROLL_RIGHT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" else "")
+ .append(if (canScrollToBottom()) SCROLL_BOTTOM_TOSTRING_DESCRIPTION else "")
+ .removeSuffix(TOSTRING_SEPARATOR)
+ .toString()
+
+ return if (scrollDirections.trim().isEmpty()) {
+ SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION
+ } else {
+ SCROLL_TOSTRING_DESCRIPTION + scrollDirections
+ }
+ }
+
+ @VisibleForTesting
+ internal fun getOverscrollDirectionsDescription(): String {
+ if (overscrollDirections == OVERSCROLL_DIRECTIONS_NONE) {
+ return OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION
+ }
+
+ val overscrollDirections = StringBuilder()
+ .append(if (canOverscrollLeft()) "$SCROLL_LEFT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" else "")
+ .append(if (canOverscrollTop()) "$SCROLL_TOP_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" else "")
+ .append(if (canOverscrollRight()) "$SCROLL_RIGHT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" else "")
+ .append(if (canOverscrollBottom()) SCROLL_BOTTOM_TOSTRING_DESCRIPTION else "")
+ .removeSuffix(TOSTRING_SEPARATOR)
+ .toString()
+
+ return if (overscrollDirections.trim().isEmpty()) {
+ OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION
+ } else {
+ OVERSCROLL_TOSTRING_DESCRIPTION + overscrollDirections
+ }
+ }
+
+ companion object {
+ /**
+ * Create a new instance of [InputResultDetail].
+ *
+ * @param verticalOverscrollInitiallyEnabled optional parameter for enabling pull to refresh
+ * in the cases in which this class can be used before valid values being set and it helps more to have
+ * overscroll vertically allowed and then stop depending on the values with which this class is updated
+ * rather than start with a disabled overscroll functionality for the current gesture.
+ */
+ fun newInstance(verticalOverscrollInitiallyEnabled: Boolean = false) = InputResultDetail(
+ overscrollDirections = if (verticalOverscrollInitiallyEnabled) {
+ OVERSCROLL_DIRECTIONS_VERTICAL
+ } else {
+ OVERSCROLL_DIRECTIONS_NONE
+ },
+ )
+
+ @VisibleForTesting internal const val TOSTRING_SEPARATOR = ", "
+
+ @VisibleForTesting internal const val INPUT_UNKNOWN_HANDLING_DESCRIPTION = "with unknown handling"
+
+ @VisibleForTesting internal const val INPUT_HANDLED_TOSTRING_DESCRIPTION = "handled by the browser"
+
+ @VisibleForTesting internal const val INPUT_HANDLED_CONTENT_TOSTRING_DESCRIPTION = "handled by the website"
+
+ @VisibleForTesting internal const val INPUT_UNHANDLED_TOSTRING_DESCRIPTION = "unhandled"
+
+ @VisibleForTesting internal const val SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION = "cannot be scrolled"
+
+ @VisibleForTesting internal const val OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION = "cannot be overscrolled"
+
+ @VisibleForTesting internal const val SCROLL_TOSTRING_DESCRIPTION = "can be scrolled to "
+
+ @VisibleForTesting internal const val OVERSCROLL_TOSTRING_DESCRIPTION = "can be overscrolled to "
+
+ @VisibleForTesting internal const val SCROLL_LEFT_TOSTRING_DESCRIPTION = "left"
+
+ @VisibleForTesting internal const val SCROLL_TOP_TOSTRING_DESCRIPTION = "top"
+
+ @VisibleForTesting internal const val SCROLL_RIGHT_TOSTRING_DESCRIPTION = "right"
+
+ @VisibleForTesting internal const val SCROLL_BOTTOM_TOSTRING_DESCRIPTION = "bottom"
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Settings.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Settings.kt
new file mode 100644
index 0000000000..b76377cfee
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/Settings.kt
@@ -0,0 +1,325 @@
+/* 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.concept.engine
+
+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.history.HistoryTrackingDelegate
+import mozilla.components.concept.engine.mediaquery.PreferredColorScheme
+import mozilla.components.concept.engine.request.RequestInterceptor
+import kotlin.reflect.KProperty
+
+/**
+ * Holds settings of an engine or session. Concrete engine
+ * implementations define how these settings are applied i.e.
+ * whether a setting is applied on an engine or session instance.
+ */
+@Suppress("UnnecessaryAbstractClass")
+abstract class Settings {
+ /**
+ * Setting to control whether or not JavaScript is enabled.
+ */
+ open var javascriptEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not DOM Storage is enabled.
+ */
+ open var domStorageEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not Web fonts are enabled.
+ */
+ open var webFontsEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether the fonts adjust size with the system accessibility settings.
+ */
+ open var automaticFontSizeAdjustment: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether the [Accept-Language] headers are altered with system locale
+ * settings.
+ */
+ open var automaticLanguageAdjustment: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control tracking protection.
+ */
+ open var trackingProtectionPolicy: TrackingProtectionPolicy? by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner handling feature.
+ */
+ open var cookieBannerHandlingMode: CookieBannerHandlingMode by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner handling feature in the private browsing mode.
+ */
+ open var cookieBannerHandlingModePrivateBrowsing: CookieBannerHandlingMode by UnsupportedSetting()
+
+ /**
+ * Setting to control tracking protection.
+ */
+ open var safeBrowsingPolicy: Array<SafeBrowsingPolicy> by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner handling feature detect only mode.
+ */
+ open var cookieBannerHandlingDetectOnlyMode: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner handling global rules feature.
+ */
+ open var cookieBannerHandlingGlobalRules: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner handling global rules subFrames feature.
+ */
+ open var cookieBannerHandlingGlobalRulesSubFrames: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner enables / disables the URL query string
+ * stripping in normal browsing mode which strips query parameters from loading
+ * URIs to prevent bounce (redirect) tracking.
+ */
+ open var queryParameterStripping: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the cookie banner enables / disables the URL query string
+ * stripping in private browsing mode which strips query parameters from loading
+ * URIs to prevent bounce (redirect) tracking.
+ */
+ open var queryParameterStrippingPrivateBrowsing: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the list that contains sites where should
+ * exempt from query stripping.
+ */
+ open var queryParameterStrippingAllowList: String by UnsupportedSetting()
+
+ /**
+ * Setting to control the list which contains query parameters that are needed to be stripped
+ * from URIs. The query parameters are separated by a space.
+ */
+ open var queryParameterStrippingStripList: String by UnsupportedSetting()
+
+ /**
+ * Setting to intercept and override requests.
+ */
+ open var requestInterceptor: RequestInterceptor? by UnsupportedSetting()
+
+ /**
+ * Setting to provide a history delegate to the engine.
+ */
+ open var historyTrackingDelegate: HistoryTrackingDelegate? by UnsupportedSetting()
+
+ /**
+ * Setting to control the user agent string.
+ */
+ open var userAgentString: String? by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not a user gesture is required to play media.
+ */
+ open var mediaPlaybackRequiresUserGesture: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not window.open can be called from JavaScript.
+ */
+ open var javaScriptCanOpenWindowsAutomatically: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not zoom controls should be displayed.
+ */
+ open var displayZoomControls: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not the engine zooms out the content to fit on screen by width.
+ */
+ open var loadWithOverviewMode: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether to support the viewport HTML meta tag or if a wide viewport
+ * should be used. If not null, this value overrides useWideViePort webSettings in
+ * [EngineSession.toggleDesktopMode].
+ */
+ open var useWideViewPort: Boolean? by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not file access is allowed.
+ */
+ open var allowFileAccess: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not JavaScript running in the context of a file scheme URL
+ * should be allowed to access content from other file scheme URLs.
+ */
+ open var allowFileAccessFromFileURLs: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not JavaScript running in the context of a file scheme URL
+ * should be allowed to access content from any origin.
+ */
+ open var allowUniversalAccessFromFileURLs: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not the engine is allowed to load content from a content
+ * provider installed in the system.
+ */
+ open var allowContentAccess: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not vertical scrolling is enabled.
+ */
+ open var verticalScrollBarEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not horizontal scrolling is enabled.
+ */
+ open var horizontalScrollBarEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not remote debugging is enabled.
+ */
+ open var remoteDebuggingEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not multiple windows are supported.
+ */
+ open var supportMultipleWindows: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether or not testing mode is enabled.
+ */
+ open var testingModeEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to alert the content that the user prefers a particular theme. This affects the
+ * [@media(prefers-color-scheme)] query.
+ */
+ open var preferredColorScheme: PreferredColorScheme by UnsupportedSetting()
+
+ /**
+ * Setting to control whether media should be suspended when the session is inactive.
+ */
+ open var suspendMediaWhenInactive: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control whether font inflation is enabled.
+ */
+ open var fontInflationEnabled: Boolean? by UnsupportedSetting()
+
+ /**
+ * Setting to control the font size factor. All font sizes will be multiplied by this factor.
+ */
+ open var fontSizeFactor: Float? by UnsupportedSetting()
+
+ /**
+ * Setting to control login autofill.
+ */
+ open var loginAutofillEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to force the ability to scale the content
+ */
+ open var forceUserScalableContent: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the clear color while drawing.
+ */
+ open var clearColor: Int? by UnsupportedSetting()
+
+ /**
+ * Setting to control whether enterprise root certs are enabled.
+ */
+ open var enterpriseRootsEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting the HTTPS-Only mode for upgrading connections to HTTPS.
+ */
+ open var httpsOnlyMode: Engine.HttpsOnlyMode by UnsupportedSetting()
+
+ /**
+ * Setting to control whether Global Privacy Control isenabled.
+ */
+ open var globalPrivacyControlEnabled: Boolean by UnsupportedSetting()
+
+ /**
+ * Setting to control the email tracker blocking feature in the private browsing mode.
+ */
+ open var emailTrackerBlockingPrivateBrowsing: Boolean by UnsupportedSetting()
+}
+
+/**
+ * [Settings] implementation used to set defaults for [Engine] and [EngineSession].
+ */
+data class DefaultSettings(
+ override var javascriptEnabled: Boolean = true,
+ override var domStorageEnabled: Boolean = true,
+ override var webFontsEnabled: Boolean = true,
+ override var automaticFontSizeAdjustment: Boolean = true,
+ override var automaticLanguageAdjustment: Boolean = true,
+ override var mediaPlaybackRequiresUserGesture: Boolean = true,
+ override var trackingProtectionPolicy: TrackingProtectionPolicy? = null,
+ override var requestInterceptor: RequestInterceptor? = null,
+ override var historyTrackingDelegate: HistoryTrackingDelegate? = null,
+ override var userAgentString: String? = null,
+ override var javaScriptCanOpenWindowsAutomatically: Boolean = false,
+ override var displayZoomControls: Boolean = true,
+ override var loadWithOverviewMode: Boolean = false,
+ override var useWideViewPort: Boolean? = null,
+ override var allowFileAccess: Boolean = true,
+ override var allowFileAccessFromFileURLs: Boolean = false,
+ override var allowUniversalAccessFromFileURLs: Boolean = false,
+ override var allowContentAccess: Boolean = true,
+ override var verticalScrollBarEnabled: Boolean = true,
+ override var horizontalScrollBarEnabled: Boolean = true,
+ override var remoteDebuggingEnabled: Boolean = false,
+ override var supportMultipleWindows: Boolean = false,
+ override var preferredColorScheme: PreferredColorScheme = PreferredColorScheme.System,
+ override var testingModeEnabled: Boolean = false,
+ override var suspendMediaWhenInactive: Boolean = false,
+ override var fontInflationEnabled: Boolean? = null,
+ override var fontSizeFactor: Float? = null,
+ override var forceUserScalableContent: Boolean = false,
+ override var loginAutofillEnabled: Boolean = false,
+ override var clearColor: Int? = null,
+ override var enterpriseRootsEnabled: Boolean = false,
+ override var httpsOnlyMode: Engine.HttpsOnlyMode = Engine.HttpsOnlyMode.DISABLED,
+ override var globalPrivacyControlEnabled: Boolean = false,
+ override var cookieBannerHandlingMode: CookieBannerHandlingMode = CookieBannerHandlingMode.DISABLED,
+ override var cookieBannerHandlingModePrivateBrowsing: CookieBannerHandlingMode =
+ CookieBannerHandlingMode.DISABLED,
+ override var cookieBannerHandlingDetectOnlyMode: Boolean = false,
+ override var cookieBannerHandlingGlobalRules: Boolean = false,
+ override var cookieBannerHandlingGlobalRulesSubFrames: Boolean = false,
+ override var queryParameterStripping: Boolean = false,
+ override var queryParameterStrippingPrivateBrowsing: Boolean = false,
+ override var queryParameterStrippingAllowList: String = "",
+ override var queryParameterStrippingStripList: String = "",
+ override var emailTrackerBlockingPrivateBrowsing: Boolean = false,
+) : Settings()
+
+class UnsupportedSetting<T> {
+ operator fun getValue(thisRef: Any?, prop: KProperty<*>): T {
+ throw UnsupportedSettingException(
+ "The setting ${prop.name} is not supported by this engine or session. " +
+ "Check both the engine and engine session implementation.",
+ )
+ }
+
+ operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) {
+ throw UnsupportedSettingException(
+ "The setting ${prop.name} is not supported by this engine or session. " +
+ "Check both the engine and engine session implementation.",
+ )
+ }
+}
+
+/**
+ * Exception thrown by default if a setting is not supported by an engine or session.
+ */
+class UnsupportedSettingException(message: String = "Setting not supported by this engine") : RuntimeException(message)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/ActivityDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/ActivityDelegate.kt
new file mode 100644
index 0000000000..aa270d9bf6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/ActivityDelegate.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.concept.engine.activity
+
+import android.content.Intent
+import android.content.IntentSender
+
+/**
+ * Notifies applications or other components of engine events that require interaction with an Android Activity.
+ */
+interface ActivityDelegate {
+
+ /**
+ * Requests an [IntentSender] is started on behalf of the engine.
+ *
+ * @param intent The [IntentSender] to be started through an Android Activity.
+ * @param onResult The callback to be invoked when we receive the result with the intent data.
+ */
+ fun startIntentSenderForResult(intent: IntentSender, onResult: (Intent?) -> Unit) = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/OrientationDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/OrientationDelegate.kt
new file mode 100644
index 0000000000..a44c91e759
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/activity/OrientationDelegate.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.concept.engine.activity
+
+import android.content.pm.ActivityInfo
+
+/**
+ * Notifies applications or other components of engine orientation lock events.
+ */
+interface OrientationDelegate {
+ /**
+ * Request to force a certain screen orientation on the current activity.
+ *
+ * @param requestedOrientation The screen orientation which should be set.
+ * Values can be any of screen orientation values defined in [ActivityInfo].
+ *
+ * @return Whether the request to set a screen orientation is promised to be fulfilled or denied.
+ */
+ fun onOrientationLock(requestedOrientation: Int): Boolean = true
+
+ /**
+ * Request to restore the natural device orientation, what it was before [onOrientationLock].
+ * Implementers should usually set [ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED].
+ */
+ fun onOrientationUnlock() = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/Tracker.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/Tracker.kt
new file mode 100644
index 0000000000..d5996c8896
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/Tracker.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.concept.engine.content.blocking
+
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.CookiePolicy
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory
+
+/**
+ * Represents a blocked content tracker.
+ * @property url The URL of the tracker.
+ * @property trackingCategories The anti-tracking category types of the blocked resource.
+ * @property cookiePolicies The cookie types of the blocked resource.
+ */
+class Tracker(
+ val url: String,
+ val trackingCategories: List<TrackingCategory> = emptyList(),
+ val cookiePolicies: List<CookiePolicy> = emptyList(),
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackerLog.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackerLog.kt
new file mode 100644
index 0000000000..6939e1d16b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackerLog.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.concept.engine.content.blocking
+
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy.TrackingCategory
+
+/**
+ * Represents a blocked content tracker.
+ * @property url The URL of the tracker.
+ * @property loadedCategories A list of tracking categories loaded for this tracker.
+ * @property blockedCategories A list of tracking categories blocked for this tracker.
+ * @property unBlockedBySmartBlock Indicates if the content of the [blockedCategories]
+ * has been partially unblocked by the SmartBlock feature.
+ */
+data class TrackerLog(
+ val url: String,
+ val loadedCategories: List<TrackingCategory> = emptyList(),
+ val blockedCategories: List<TrackingCategory> = emptyList(),
+ val cookiesHasBeenBlocked: Boolean = false,
+ val unBlockedBySmartBlock: Boolean = false,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionException.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionException.kt
new file mode 100644
index 0000000000..5715efc817
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionException.kt
@@ -0,0 +1,16 @@
+/* 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.concept.engine.content.blocking
+
+/**
+ * Represents a site that will be ignored by the tracking protection policies.
+ */
+interface TrackingProtectionException {
+
+ /**
+ * The url of the site to be ignored.
+ */
+ val url: String
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt
new file mode 100644
index 0000000000..e838c7676c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/content/blocking/TrackingProtectionExceptionStorage.kt
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.engine.content.blocking
+
+import mozilla.components.concept.engine.EngineSession
+
+/**
+ * A contract that define how a tracking protection storage must behave.
+ */
+interface TrackingProtectionExceptionStorage {
+
+ /**
+ * Fetch all domains that will be ignored for tracking protection.
+ * @param onResult A callback to inform that the domains in the exception list has been fetched,
+ * it provides a list of all the domains that are on the exception list, if there are none
+ * domains in the exception list, an empty list will be provided.
+ */
+ fun fetchAll(onResult: (List<TrackingProtectionException>) -> Unit)
+
+ /**
+ * Adds a new [session] to the exception list.
+ * @param session The [session] that will be added to the exception list.
+ * @param persistInPrivateMode Indicates if the exception should be persistent in private mode
+ * defaults to false.
+ */
+ fun add(session: EngineSession, persistInPrivateMode: Boolean = false)
+
+ /**
+ * Removes a [session] from the exception list.
+ * @param session The [session] that will be removed from the exception list.
+ */
+ fun remove(session: EngineSession)
+
+ /**
+ * Removes a [exception] from the exception list.
+ * @param exception The [TrackingProtectionException] that will be removed from the exception list.
+ */
+ fun remove(exception: TrackingProtectionException)
+
+ /**
+ * Indicates if a given [session] is in the exception list.
+ * @param session The [session] to be verified.
+ * @param onResult A callback to inform if the given [session] is in
+ * the exception list, true if it is in, otherwise false.
+ */
+ fun contains(session: EngineSession, onResult: (Boolean) -> Unit)
+
+ /**
+ * Removes all domains from the exception list.
+ * @param activeSessions A list of all active sessions (including CustomTab
+ * sessions) to be notified.
+ * @param onRemove A callback to inform that the list of active sessions has been removed
+ */
+ fun removeAll(activeSessions: List<EngineSession>? = null, onRemove: () -> Unit = {})
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/cookiehandling/CookieBannersStorage.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/cookiehandling/CookieBannersStorage.kt
new file mode 100644
index 0000000000..8ccbdc0f65
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/cookiehandling/CookieBannersStorage.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.concept.engine.cookiehandling
+
+import mozilla.components.concept.engine.EngineSession.CookieBannerHandlingMode
+
+/**
+ * Represents a storage to manage [CookieBannerHandlingMode] exceptions.
+ */
+interface CookieBannersStorage {
+ /**
+ * Set the [CookieBannerHandlingMode.DISABLED] mode for the given [uri] and [privateBrowsing].
+ * @param uri the [uri] for the site to be updated.
+ * @param privateBrowsing Indicates if given [uri] should be in private browsing or not.
+ */
+ suspend fun addException(
+ uri: String,
+ privateBrowsing: Boolean,
+ )
+
+ /**
+ * 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
+
+ /**
+ * 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)
+
+ /**
+ * Set persistently the [CookieBannerHandlingMode.DISABLED] mode for the given [uri] in
+ * private browsing.
+ * @param uri the [uri] for the site to be updated.
+ */
+ suspend fun addPersistentExceptionInPrivateMode(uri: String)
+
+ /**
+ * Find a [CookieBannerHandlingMode] that matches the given [uri] and browsing mode.
+ * @param uri the [uri] to be used as filter in the search.
+ * @param privateBrowsing Indicates if given [uri] should be in private browsing or not.
+ * @return the [CookieBannerHandlingMode] for the provided [uri] and browsing mode,
+ * if an error occurs null will be returned.
+ */
+ suspend fun findExceptionFor(uri: String, privateBrowsing: Boolean): CookieBannerHandlingMode?
+
+ /**
+ * Indicates if the given [uri] and browsing mode has the [CookieBannerHandlingMode.DISABLED] mode.
+ * @param uri the [uri] to be used as filter in the search.
+ * @param privateBrowsing Indicates if given [uri] should be in private browsing or not.
+ * @return A [Boolean] indicating if the [CookieBannerHandlingMode] has been updated, from the
+ * default value, if an error occurs null will be returned.
+ */
+ suspend fun hasException(uri: String, privateBrowsing: Boolean): Boolean?
+
+ /**
+ * Remove any [CookieBannerHandlingMode] exception that has been applied to the given [uri] and
+ * browsing mode.
+ * @param uri the [uri] to be used as filter in the search.
+ * @param privateBrowsing Indicates if given [uri] should be in private browsing or not.
+ */
+ suspend fun removeException(uri: String, privateBrowsing: Boolean)
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryItem.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryItem.kt
new file mode 100644
index 0000000000..f4a07d0d99
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryItem.kt
@@ -0,0 +1,15 @@
+/* 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.concept.engine.history
+
+/**
+ * A representation of an entry in browser history.
+ * @property title The title of this history element.
+ * @property uri The URI of this history element.
+ */
+data class HistoryItem(
+ val title: String,
+ val uri: String,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryTrackingDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryTrackingDelegate.kt
new file mode 100644
index 0000000000..2793f2377c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/history/HistoryTrackingDelegate.kt
@@ -0,0 +1,47 @@
+/* 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.concept.engine.history
+
+import mozilla.components.concept.storage.PageVisit
+
+/**
+ * An interface used for providing history information to an engine (e.g. for link highlighting),
+ * and receiving history updates from the engine (visits to URLs, title changes).
+ *
+ * Even though this interface is defined at the "concept" layer, its get* methods are tailored to
+ * two types of engines which we support (system's WebView and GeckoView).
+ */
+interface HistoryTrackingDelegate {
+ /**
+ * A URI visit happened that an engine considers worthy of being recorded in browser's history.
+ */
+ suspend fun onVisited(uri: String, visit: PageVisit)
+
+ /**
+ * Title changed for a given URI.
+ */
+ suspend fun onTitleChanged(uri: String, title: String)
+
+ /**
+ * Preview image changed for a given URI.
+ */
+ suspend fun onPreviewImageChange(uri: String, previewImageUrl: String)
+
+ /**
+ * An engine needs to know "visited" (true/false) status for provided URIs.
+ */
+ suspend fun getVisited(uris: List<String>): List<Boolean>
+
+ /**
+ * An engine needs to know a list of all visited URIs.
+ */
+ suspend fun getVisited(): List<String>
+
+ /**
+ * Allows an engine to check if this URI is going to be accepted by the delegate.
+ * This helps avoid unnecessary coroutine overhead for URIs which won't be accepted.
+ */
+ fun shouldStoreUri(uri: String): Boolean
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/Size.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/Size.kt
new file mode 100644
index 0000000000..ba2ae8affa
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/Size.kt
@@ -0,0 +1,59 @@
+/* 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.concept.engine.manifest
+
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Represents dimensions for an image.
+ * Corresponds to values of the "sizes" HTML attribute.
+ *
+ * @property width Width of the image.
+ * @property height Height of the image.
+ */
+data class Size(
+ val width: Int,
+ val height: Int,
+) {
+
+ /**
+ * Gets the longest length between width and height.
+ */
+ val maxLength get() = max(width, height)
+
+ /**
+ * Gets the shortest length between width and height.
+ */
+ val minLength get() = min(width, height)
+
+ override fun toString() = if (this == ANY) "any" else "${width}x$height"
+
+ companion object {
+ /**
+ * Represents the "any" size.
+ */
+ val ANY = Size(Int.MAX_VALUE, Int.MAX_VALUE)
+
+ /**
+ * Parses a value from an HTML sizes attribute (512x512, 16x16, etc).
+ * Returns null if the value was invalid.
+ */
+ fun parse(raw: String): Size? {
+ if (raw == "any") return ANY
+
+ val size = raw.split("x")
+ if (size.size != 2) return null
+
+ return try {
+ val width = size[0].toInt()
+ val height = size[1].toInt()
+ Size(width, height)
+ } catch (e: NumberFormatException) {
+ null
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifest.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifest.kt
new file mode 100644
index 0000000000..b193beaccc
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifest.kt
@@ -0,0 +1,253 @@
+/* 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.concept.engine.manifest
+
+import androidx.annotation.ColorInt
+import mozilla.components.concept.engine.manifest.WebAppManifest.ExternalApplicationResource.Fingerprint
+
+/**
+ * The web app manifest provides information about an application (such as its name, author, icon, and description).
+ *
+ * Web app manifests are part of a collection of web technologies called progressive web apps, which are websites
+ * that can be installed to a device’s homescreen without an app store, along with other capabilities like working
+ * offline and receiving push notifications.
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/Manifest
+ * https://www.w3.org/TR/appmanifest/
+ * https://developers.google.com/web/fundamentals/web-app-manifest/
+ *
+ * @property name Provides a human-readable name for the site when displayed to the user. For example, among a list of
+ * other applications or as a label for an icon.
+ * @property shortName Provides a short human-readable name for the application. This is intended for when there is
+ * insufficient space to display the full name of the web application, like device homescreens.
+ * @property startUrl The URL that loads when a user launches the application (e.g. when added to home screen),
+ * typically the index. Note that this has to be a relative URL, relative to the manifest url.
+ * @property display Defines the developers’ preferred display mode for the website.
+ * @property backgroundColor Defines the expected “background color” for the website. This value repeats what is
+ * already available in the site’s CSS, but can be used by browsers to draw the background color of a shortcut when
+ * the manifest is available before the stylesheet has loaded. This creates a smooth transition between launching the
+ * web application and loading the site's content.
+ * @property description Provides a general description of what the pinned website does.
+ * @property icons Specifies a list of image files that can serve as application icons, depending on context. For
+ * example, they can be used to represent the web application amongst a list of other applications, or to integrate the
+ * web application with an OS's task switcher and/or system preferences.
+ * @property dir Specifies the primary text direction for the name, short_name, and description members. Together with
+ * the lang member, it helps the correct display of right-to-left languages.
+ * @property lang Specifies the primary language for the values in the name and short_name members. This value is a
+ * string containing a single language tag (e.g. en-US).
+ * @property orientation Defines the default orientation for all the website's top level browsing contexts.
+ * @property scope Defines the navigation scope of this website's context. This restricts what web pages can be viewed
+ * while the manifest is applied. If the user navigates outside the scope, it returns to a normal web page inside a
+ * browser tab/window.
+ * @property themeColor Defines the default theme color for an application. This sometimes affects how the OS displays
+ * the site (e.g., on Android's task switcher, the theme color surrounds the site).
+ * @property relatedApplications List of native applications related to the web app.
+ * @property preferRelatedApplications If true, related applications should be preferred over the web app.
+ */
+data class WebAppManifest(
+ val name: String,
+ val startUrl: String,
+ val shortName: String? = null,
+ val display: DisplayMode = DisplayMode.BROWSER,
+ @ColorInt val backgroundColor: Int? = null,
+ val description: String? = null,
+ val icons: List<Icon> = emptyList(),
+ val dir: TextDirection = TextDirection.AUTO,
+ val lang: String? = null,
+ val orientation: Orientation = Orientation.ANY,
+ val scope: String? = null,
+ @ColorInt val themeColor: Int? = null,
+ val relatedApplications: List<ExternalApplicationResource> = emptyList(),
+ val preferRelatedApplications: Boolean = false,
+ val shareTarget: ShareTarget? = null,
+) {
+ /**
+ * Defines the developers’ preferred display mode for the website.
+ */
+ enum class DisplayMode {
+ /**
+ * All of the available display area is used and no user agent chrome is shown.
+ */
+ FULLSCREEN,
+
+ /**
+ * The application will look and feel like a standalone application. This can include the application having a
+ * different window, its own icon in the application launcher, etc. In this mode, the user agent will exclude
+ * UI elements for controlling navigation, but can include other UI elements such as a status bar.
+ */
+ STANDALONE,
+
+ /**
+ * The application will look and feel like a standalone application, but will have a minimal set of UI elements
+ * for controlling navigation. The elements will vary by browser.
+ */
+ MINIMAL_UI,
+
+ /**
+ * The application opens in a conventional browser tab or new window, depending on the browser and platform.
+ * This is the default.
+ */
+ BROWSER,
+ }
+
+ /**
+ * An image file that can serve as application icon.
+ *
+ * @property src The path to the image file. If src is a relative URL, the base URL will be the URL of the manifest.
+ * @property sizes A list of image dimensions.
+ * @property type A hint as to the media type of the image. The purpose of this member is to allow a user agent to
+ * quickly ignore images of media types it does not support.
+ * @property purpose Defines the purposes of the image, for example that the image is intended to serve some special
+ * purpose in the context of the host OS (i.e., for better integration).
+ */
+ data class Icon(
+ val src: String,
+ val sizes: List<Size> = emptyList(),
+ val type: String? = null,
+ val purpose: Set<Purpose> = setOf(Purpose.ANY),
+ ) {
+ enum class Purpose {
+ /**
+ * A user agent can present this icon where space constraints and/or color requirements differ from those
+ * of the application icon.
+ */
+ MONOCHROME,
+
+ /**
+ * The image is designed with icon masks and safe zone in mind, such that any part of the image that is
+ * outside the safe zone can safely be ignored and masked away by the user agent.
+ *
+ * https://w3c.github.io/manifest/#icon-masks
+ */
+ MASKABLE,
+
+ /**
+ * The user agent is free to display the icon in any context (this is the default value).
+ */
+ ANY,
+ }
+ }
+
+ /**
+ * Defines the default orientation for all the website's top level browsing contexts.
+ */
+ enum class Orientation {
+ ANY,
+ NATURAL,
+ LANDSCAPE,
+ LANDSCAPE_PRIMARY,
+ LANDSCAPE_SECONDARY,
+ PORTRAIT,
+ PORTRAIT_PRIMARY,
+ PORTRAIT_SECONDARY,
+ }
+
+ /**
+ * Specifies the primary text direction for the name, short_name, and description members. Together with the lang
+ * member, it helps the correct display of right-to-left languages.
+ */
+ enum class TextDirection {
+ /**
+ * Left-to-right (LTR).
+ */
+ LTR,
+
+ /**
+ * Right-to-left (RTL).
+ */
+ RTL,
+
+ /**
+ * If the value is set to auto, the browser will use the Unicode bidirectional algorithm to make a best guess
+ * about the text's direction.
+ */
+ AUTO,
+ }
+
+ /**
+ * An external native application that is related to the web app.
+ *
+ * @property platform The platform the native app is associated with.
+ * @property url The URL where it can be found.
+ * @property id Information additional to or instead of the URL, depending on the platform.
+ * @property minVersion The minimum version of an application related to this web app.
+ * @property fingerprints [Fingerprint] objects used for verifying the application.
+ */
+ data class ExternalApplicationResource(
+ val platform: String,
+ val url: String? = null,
+ val id: String? = null,
+ val minVersion: String? = null,
+ val fingerprints: List<Fingerprint> = emptyList(),
+ ) {
+
+ /**
+ * Represents a set of cryptographic fingerprints used for verifying the application.
+ * The syntax and semantics of [type] and [value] are platform-defined.
+ */
+ data class Fingerprint(
+ val type: String,
+ val value: String,
+ )
+ }
+
+ /**
+ * Used to define how the web app receives share data.
+ * If present, a share target should be created so that other Android apps can share to this web app.
+ *
+ * @property action URL to open on share
+ * @property method Method to use with [action]. Either "GET" or "POST".
+ * @property encType MIME type to specify how the params are encoded.
+ * @property params Specifies what query parameters correspond to share data.
+ */
+ data class ShareTarget(
+ val action: String,
+ val method: RequestMethod = RequestMethod.GET,
+ val encType: EncodingType = EncodingType.URL_ENCODED,
+ val params: Params = Params(),
+ ) {
+
+ /**
+ * Specifies what query parameters correspond to share data.
+ *
+ * @property title Name of the query parameter used for the title of the data being shared.
+ * @property text Name of the query parameter used for the body of the data being shared.
+ * @property url Name of the query parameter used for a URL referring to a shared resource.
+ * @property files Form fields used to share files.
+ */
+ data class Params(
+ val title: String? = null,
+ val text: String? = null,
+ val url: String? = null,
+ val files: List<Files> = emptyList(),
+ )
+
+ /**
+ * Specifies a form field member used to share files.
+ *
+ * @property name Name of the form field.
+ * @property accept Accepted MIME types or file extensions.
+ */
+ data class Files(
+ val name: String,
+ val accept: List<String>,
+ )
+
+ /**
+ * Valid HTTP methods for [ShareTarget.method].
+ */
+ enum class RequestMethod {
+ GET, POST
+ }
+
+ /**
+ * Valid encoding MIME types for [ShareTarget.encType].
+ */
+ enum class EncodingType(val type: String) {
+ URL_ENCODED("application/x-www-form-urlencoded"),
+ MULTIPART("multipart/form-data"),
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt
new file mode 100644
index 0000000000..b5c0cd5812
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/WebAppManifestParser.kt
@@ -0,0 +1,238 @@
+/* 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.concept.engine.manifest
+
+import android.graphics.Color
+import androidx.annotation.ColorInt
+import mozilla.components.concept.engine.manifest.parser.ShareTargetParser
+import mozilla.components.concept.engine.manifest.parser.parseIcons
+import mozilla.components.concept.engine.manifest.parser.serializeEnumName
+import mozilla.components.concept.engine.manifest.parser.serializeIcons
+import mozilla.components.support.ktx.android.org.json.asSequence
+import mozilla.components.support.ktx.android.org.json.tryGetString
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+
+/**
+ * Parser for constructing a [WebAppManifest] from JSON.
+ */
+class WebAppManifestParser {
+ /**
+ * A parsing result.
+ */
+ sealed class Result {
+ /**
+ * The JSON was parsed successful.
+ *
+ * @property manifest The parsed [WebAppManifest] object.
+ */
+ data class Success(val manifest: WebAppManifest) : Result()
+
+ /**
+ * Parsing the JSON failed.
+ *
+ * @property exception The exception that was thrown while parsing the manifest.
+ */
+ data class Failure(val exception: JSONException) : Result()
+ }
+
+ /**
+ * Parses the provided JSON and returns a [WebAppManifest] (wrapped in [Result.Success] if parsing was successful.
+ * Otherwise [Result.Failure].
+ *
+ * Gecko performs some initial parsing on the Web App Manifest, so the [JSONObject] we work with
+ * does not match what was originally provided by the website. Gecko:
+ * - Changes relative URLs to be absolute
+ * - Changes some space-separated strings into arrays (purpose, sizes)
+ * - Changes colors to follow Android format (#AARRGGBB)
+ * - Removes invalid enum values (ie display: halfscreen)
+ * - Ensures display, dir, start_url, and scope always have a value
+ * - Trims most strings (name, short_name, ...)
+ * See https://searchfox.org/mozilla-central/source/dom/manifest/ManifestProcessor.jsm
+ */
+ fun parse(json: JSONObject): Result {
+ return try {
+ val shortName = json.tryGetString("short_name")
+ val name = json.tryGetString("name") ?: shortName
+ ?: return Result.Failure(JSONException("Missing manifest name"))
+
+ Result.Success(
+ WebAppManifest(
+ name = name,
+ shortName = shortName,
+ startUrl = json.getString("start_url"),
+ display = parseDisplayMode(json),
+ backgroundColor = parseColor(json.tryGetString("background_color")),
+ description = json.tryGetString("description"),
+ icons = parseIcons(json),
+ scope = json.tryGetString("scope"),
+ themeColor = parseColor(json.tryGetString("theme_color")),
+ dir = parseTextDirection(json),
+ lang = json.tryGetString("lang"),
+ orientation = parseOrientation(json),
+ relatedApplications = parseRelatedApplications(json),
+ preferRelatedApplications = json.optBoolean("prefer_related_applications", false),
+ shareTarget = ShareTargetParser.parse(json.optJSONObject("share_target")),
+ ),
+ )
+ } catch (e: JSONException) {
+ Result.Failure(e)
+ }
+ }
+
+ /**
+ * Parses the provided JSON and returns a [WebAppManifest] (wrapped in [Result.Success] if parsing was successful.
+ * Otherwise [Result.Failure].
+ */
+ fun parse(json: String) = try {
+ parse(JSONObject(json))
+ } catch (e: JSONException) {
+ Result.Failure(e)
+ }
+
+ fun serialize(manifest: WebAppManifest) = JSONObject().apply {
+ put("name", manifest.name)
+ putOpt("short_name", manifest.shortName)
+ put("start_url", manifest.startUrl)
+ putOpt("display", serializeEnumName(manifest.display.name))
+ putOpt("background_color", serializeColor(manifest.backgroundColor))
+ putOpt("description", manifest.description)
+ putOpt("icons", serializeIcons(manifest.icons))
+ putOpt("scope", manifest.scope)
+ putOpt("theme_color", serializeColor(manifest.themeColor))
+ putOpt("dir", serializeEnumName(manifest.dir.name))
+ putOpt("lang", manifest.lang)
+ putOpt("orientation", serializeEnumName(manifest.orientation.name))
+ putOpt("orientation", serializeEnumName(manifest.orientation.name))
+ put("related_applications", serializeRelatedApplications(manifest.relatedApplications))
+ put("prefer_related_applications", manifest.preferRelatedApplications)
+ putOpt("share_target", ShareTargetParser.serialize(manifest.shareTarget))
+ }
+}
+
+/**
+ * Returns the encapsulated value if this instance represents success or `null` if it is failure.
+ */
+fun WebAppManifestParser.Result.getOrNull(): WebAppManifest? = when (this) {
+ is WebAppManifestParser.Result.Success -> manifest
+ is WebAppManifestParser.Result.Failure -> null
+}
+
+private fun parseDisplayMode(json: JSONObject): WebAppManifest.DisplayMode {
+ return when (json.optString("display")) {
+ "standalone" -> WebAppManifest.DisplayMode.STANDALONE
+ "fullscreen" -> WebAppManifest.DisplayMode.FULLSCREEN
+ "minimal-ui" -> WebAppManifest.DisplayMode.MINIMAL_UI
+ "browser" -> WebAppManifest.DisplayMode.BROWSER
+ else -> WebAppManifest.DisplayMode.BROWSER
+ }
+}
+
+@ColorInt
+private fun parseColor(color: String?): Int? {
+ if (color == null || !color.startsWith("#")) {
+ return null
+ }
+
+ return try {
+ Color.parseColor(color)
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+}
+
+private fun parseTextDirection(json: JSONObject): WebAppManifest.TextDirection {
+ return when (json.optString("dir")) {
+ "ltr" -> WebAppManifest.TextDirection.LTR
+ "rtl" -> WebAppManifest.TextDirection.RTL
+ "auto" -> WebAppManifest.TextDirection.AUTO
+ else -> WebAppManifest.TextDirection.AUTO
+ }
+}
+
+private fun parseOrientation(json: JSONObject) = when (json.optString("orientation")) {
+ "any" -> WebAppManifest.Orientation.ANY
+ "natural" -> WebAppManifest.Orientation.NATURAL
+ "landscape" -> WebAppManifest.Orientation.LANDSCAPE
+ "portrait" -> WebAppManifest.Orientation.PORTRAIT
+ "portrait-primary" -> WebAppManifest.Orientation.PORTRAIT_PRIMARY
+ "portrait-secondary" -> WebAppManifest.Orientation.PORTRAIT_SECONDARY
+ "landscape-primary" -> WebAppManifest.Orientation.LANDSCAPE_PRIMARY
+ "landscape-secondary" -> WebAppManifest.Orientation.LANDSCAPE_SECONDARY
+ else -> WebAppManifest.Orientation.ANY
+}
+
+private fun parseRelatedApplications(json: JSONObject): List<WebAppManifest.ExternalApplicationResource> {
+ val array = json.optJSONArray("related_applications") ?: return emptyList()
+
+ return array
+ .asSequence { i -> getJSONObject(i) }
+ .mapNotNull { app -> parseRelatedApplication(app) }
+ .toList()
+}
+
+private fun parseRelatedApplication(app: JSONObject): WebAppManifest.ExternalApplicationResource? {
+ val platform = app.tryGetString("platform")
+ val url = app.tryGetString("url")
+ val id = app.tryGetString("id")
+ return if (platform != null && (url != null || id != null)) {
+ WebAppManifest.ExternalApplicationResource(
+ platform = platform,
+ url = url,
+ id = id,
+ minVersion = app.tryGetString("min_version"),
+ fingerprints = parseFingerprints(app),
+ )
+ } else {
+ null
+ }
+}
+
+private fun parseFingerprints(app: JSONObject): List<WebAppManifest.ExternalApplicationResource.Fingerprint> {
+ val array = app.optJSONArray("fingerprints") ?: return emptyList()
+
+ return array
+ .asSequence { i -> getJSONObject(i) }
+ .map {
+ WebAppManifest.ExternalApplicationResource.Fingerprint(
+ type = it.getString("type"),
+ value = it.getString("value"),
+ )
+ }
+ .toList()
+}
+
+@Suppress("MagicNumber")
+private fun serializeColor(color: Int?): String? = color?.let {
+ String.format("#%06X", 0xFFFFFF and it)
+}
+
+private fun serializeRelatedApplications(
+ relatedApplications: List<WebAppManifest.ExternalApplicationResource>,
+): JSONArray {
+ val list = relatedApplications.map { app ->
+ JSONObject().apply {
+ put("platform", app.platform)
+ putOpt("url", app.url)
+ putOpt("id", app.id)
+ putOpt("min_version", app.minVersion)
+ put("fingerprints", serializeFingerprints(app.fingerprints))
+ }
+ }
+ return JSONArray(list)
+}
+
+private fun serializeFingerprints(
+ fingerprints: List<WebAppManifest.ExternalApplicationResource.Fingerprint>,
+): JSONArray {
+ val list = fingerprints.map {
+ JSONObject().apply {
+ put("type", it.type)
+ put("value", it.value)
+ }
+ }
+ return JSONArray(list)
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/ShareTargetParser.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/ShareTargetParser.kt
new file mode 100644
index 0000000000..13b4af45be
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/ShareTargetParser.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.concept.engine.manifest.parser
+
+import mozilla.components.concept.engine.manifest.WebAppManifest.ShareTarget
+import mozilla.components.support.ktx.android.org.json.asSequence
+import mozilla.components.support.ktx.android.org.json.toJSONArray
+import mozilla.components.support.ktx.android.org.json.tryGetString
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.Locale
+
+internal object ShareTargetParser {
+
+ /**
+ * Parses a share target inside a web app manifest.
+ */
+ fun parse(json: JSONObject?): ShareTarget? {
+ val action = json?.tryGetString("action") ?: return null
+ val method = parseMethod(json.tryGetString("method"))
+ val encType = parseEncType(json.tryGetString("enctype"))
+ val params = json.optJSONObject("params")
+
+ return if (method != null && encType != null && validMethodAndEncType(method, encType)) {
+ return ShareTarget(
+ action = action,
+ method = method,
+ encType = encType,
+ params = ShareTarget.Params(
+ title = params?.tryGetString("title"),
+ text = params?.tryGetString("text"),
+ url = params?.tryGetString("url"),
+ files = parseFiles(params),
+ ),
+ )
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Serializes a share target to JSON for a web app manifest.
+ */
+ fun serialize(shareTarget: ShareTarget?): JSONObject? {
+ shareTarget ?: return null
+ return JSONObject().apply {
+ put("action", shareTarget.action)
+ put("method", shareTarget.method.name)
+ put("enctype", shareTarget.encType.type)
+
+ val params = JSONObject().apply {
+ put("title", shareTarget.params.title)
+ put("text", shareTarget.params.text)
+ put("url", shareTarget.params.url)
+ put(
+ "files",
+ shareTarget.params.files.asSequence()
+ .map { file ->
+ JSONObject().apply {
+ put("name", file.name)
+ putOpt("accept", file.accept.toJSONArray())
+ }
+ }
+ .asIterable()
+ .toJSONArray(),
+ )
+ }
+ put("params", params)
+ }
+ }
+
+ /**
+ * Convert string to [ShareTarget.RequestMethod]. Returns null if the string is invalid.
+ */
+ private fun parseMethod(method: String?): ShareTarget.RequestMethod? {
+ method ?: return ShareTarget.RequestMethod.GET
+ return try {
+ ShareTarget.RequestMethod.valueOf(method.uppercase(Locale.ROOT))
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+ }
+
+ /**
+ * Convert string to [ShareTarget.EncodingType]. Returns null if the string is invalid.
+ */
+ private fun parseEncType(encType: String?): ShareTarget.EncodingType? {
+ val typeString = encType?.lowercase(Locale.ROOT) ?: return ShareTarget.EncodingType.URL_ENCODED
+ return ShareTarget.EncodingType.values().find { it.type == typeString }
+ }
+
+ /**
+ * Checks that [encType] is URL_ENCODED (if [method] is GET or POST) or MULTIPART (only if POST)
+ */
+ private fun validMethodAndEncType(
+ method: ShareTarget.RequestMethod,
+ encType: ShareTarget.EncodingType,
+ ) = when (encType) {
+ ShareTarget.EncodingType.URL_ENCODED -> true
+ ShareTarget.EncodingType.MULTIPART -> method == ShareTarget.RequestMethod.POST
+ }
+
+ private fun parseFiles(params: JSONObject?) =
+ when (val files = params?.opt("files")) {
+ is JSONObject -> listOfNotNull(parseFile(files))
+ is JSONArray -> files.asSequence { i -> getJSONObject(i) }
+ .mapNotNull(::parseFile)
+ .toList()
+ else -> emptyList()
+ }
+
+ private fun parseFile(file: JSONObject): ShareTarget.Files? {
+ val name = file.tryGetString("name")
+ val accept = file.opt("accept")
+
+ if (name.isNullOrEmpty()) return null
+
+ return ShareTarget.Files(
+ name = name,
+ accept = when (accept) {
+ is String -> listOf(accept)
+ is JSONArray -> accept.asSequence { i -> getString(i) }.toList()
+ else -> emptyList()
+ },
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/WebAppManifestIconParser.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/WebAppManifestIconParser.kt
new file mode 100644
index 0000000000..6880f66652
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/manifest/parser/WebAppManifestIconParser.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.concept.engine.manifest.parser
+
+import mozilla.components.concept.engine.manifest.Size
+import mozilla.components.concept.engine.manifest.WebAppManifest
+import mozilla.components.support.ktx.android.org.json.asSequence
+import mozilla.components.support.ktx.android.org.json.tryGet
+import mozilla.components.support.ktx.android.org.json.tryGetString
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.Locale
+
+private val whitespace = "\\s+".toRegex()
+
+/**
+ * Parses the icons array from a web app manifest.
+ */
+internal fun parseIcons(json: JSONObject): List<WebAppManifest.Icon> {
+ val array = json.optJSONArray("icons") ?: return emptyList()
+
+ return array
+ .asSequence { i -> getJSONObject(i) }
+ .mapNotNull { obj ->
+ val purpose = parsePurposes(obj).ifEmpty {
+ return@mapNotNull null
+ }
+ WebAppManifest.Icon(
+ src = obj.getString("src"),
+ sizes = parseIconSizes(obj),
+ type = obj.tryGetString("type"),
+ purpose = purpose,
+ )
+ }
+ .toList()
+}
+
+/**
+ * Parses a string set, which is expressed as either a space-delimited string or JSONArray of strings.
+ *
+ * Gecko returns a JSONArray to represent the intermediate infra type for some properties.
+ */
+private fun parseStringSet(set: Any?): Sequence<String>? = when (set) {
+ is String -> set.split(whitespace).asSequence()
+ is JSONArray -> set.asSequence { i -> getString(i) }
+ else -> null
+}
+
+private fun parseIconSizes(json: JSONObject): List<Size> {
+ val sizes = parseStringSet(json.tryGet("sizes"))
+ ?: return emptyList()
+
+ return sizes.mapNotNull { Size.parse(it) }.toList()
+}
+
+private fun parsePurposes(json: JSONObject): Set<WebAppManifest.Icon.Purpose> {
+ val purpose = parseStringSet(json.tryGet("purpose"))
+ ?: return setOf(WebAppManifest.Icon.Purpose.ANY)
+
+ return purpose
+ .mapNotNull {
+ when (it.lowercase(Locale.ROOT)) {
+ "monochrome" -> WebAppManifest.Icon.Purpose.MONOCHROME
+ "maskable" -> WebAppManifest.Icon.Purpose.MASKABLE
+ "any" -> WebAppManifest.Icon.Purpose.ANY
+ else -> null
+ }
+ }
+ .toSet()
+}
+
+internal fun serializeEnumName(name: String) = name.lowercase(Locale.ROOT).replace('_', '-')
+
+internal fun serializeIcons(icons: List<WebAppManifest.Icon>): JSONArray {
+ val list = icons.map { icon ->
+ JSONObject().apply {
+ put("src", icon.src)
+ put("sizes", icon.sizes.joinToString(" ") { it.toString() })
+ putOpt("type", icon.type)
+ put("purpose", icon.purpose.joinToString(" ") { serializeEnumName(it.name) })
+ }
+ }
+ return JSONArray(list)
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/media/RecordingDevice.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/media/RecordingDevice.kt
new file mode 100644
index 0000000000..9b15fa6205
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/media/RecordingDevice.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.concept.engine.media
+
+/**
+ * A recording device that can be used by web content.
+ *
+ * @property type The type of recording device (e.g. camera or microphone)
+ * @property status The status of the recording device (e.g. whether this device is recording)
+ */
+data class RecordingDevice(
+ val type: Type,
+ val status: Status,
+) {
+ /**
+ * Types of recording devices.
+ */
+ enum class Type {
+ CAMERA,
+ MICROPHONE,
+ }
+
+ /**
+ * States a recording device can be in.
+ */
+ enum class Status {
+ INACTIVE,
+ RECORDING,
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediaquery/PreferredColorScheme.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediaquery/PreferredColorScheme.kt
new file mode 100644
index 0000000000..1f694a53af
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediaquery/PreferredColorScheme.kt
@@ -0,0 +1,17 @@
+/* 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.concept.engine.mediaquery
+
+/**
+ * A simple data class used to suggest to page content that the user prefers a particular color
+ * scheme.
+ */
+sealed class PreferredColorScheme {
+ companion object
+
+ object Light : PreferredColorScheme()
+ object Dark : PreferredColorScheme()
+ object System : PreferredColorScheme()
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediasession/MediaSession.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediasession/MediaSession.kt
new file mode 100644
index 0000000000..08051f923b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/mediasession/MediaSession.kt
@@ -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/. */
+
+package mozilla.components.concept.engine.mediasession
+
+import android.graphics.Bitmap
+
+/**
+ * Value type that represents a media session that is present on the currently displayed page in a session.
+ */
+class MediaSession {
+
+ /**
+ * The representation of a media element's metadata.
+ *
+ * @property source The media URI.
+ * @property duration The media duration in seconds.
+ * @property width The video width in device pixels.
+ * @property height The video height in device pixels.
+ * @property audioTrackCount The audio track count.
+ * @property videoTrackCount The video track count.
+ */
+ data class ElementMetadata(
+ val source: String? = null,
+ val duration: Double = -1.0,
+ val width: Long = 0L,
+ val height: Long = 0L,
+ val audioTrackCount: Int = 0,
+ val videoTrackCount: Int = 0,
+ ) {
+ val portrait: Boolean
+ get() = height > width
+ }
+
+ /**
+ * The representation of a media session's metadata.
+ *
+ * @property title The media title string.
+ * @property artist The media artist string.
+ * @property album The media album string.
+ * @property getArtwork Get the media artwork.
+ */
+ data class Metadata(
+ val title: String? = null,
+ val artist: String? = null,
+ val album: String? = null,
+ val getArtwork: (suspend () -> Bitmap?)?,
+ )
+
+ /**
+ * Holds the details of the media session's playback state.
+ *
+ * @property duration The media duration in seconds.
+ * @property position The current media playback position in seconds.
+ * @property playbackRate The playback rate coefficient.
+ */
+ data class PositionState(
+ val duration: Double = -1.0,
+ val position: Double = 0.0,
+ val playbackRate: Double = 0.0,
+ )
+
+ /**
+ * Flags for supported media session features.
+ *
+ * Implementation note: This is a 1:1 mapping of the features that GeckoView notifies us about.
+ * https://github.com/mozilla/gecko-dev/blob/master/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
+ */
+ data class Feature(val flags: Long = 0) {
+ companion object {
+ const val NONE: Long = 0
+ const val PLAY: Long = 1L shl 0
+ const val PAUSE: Long = 1L shl 1
+ const val STOP: Long = 1L shl 2
+ const val SEEK_TO: Long = 1L shl 3
+ const val SEEK_FORWARD: Long = 1L shl 4
+ const val SEEK_BACKWARD: Long = 1L shl 5
+ const val SKIP_AD: Long = 1L shl 6
+ const val NEXT_TRACK: Long = 1L shl 7
+ const val PREVIOUS_TRACK: Long = 1L shl 8
+ const val FOCUS: Long = 1L shl 9
+ }
+
+ /**
+ * Returns `true` if this [Feature] contains the [type].
+ */
+ fun contains(flag: Long): Boolean = (flags and flag) != 0L
+
+ /**
+ * Returns `true` if this is [Feature] equal to the [other] [Feature].
+ */
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Feature) return false
+ if (flags != other.flags) return false
+ return true
+ }
+
+ override fun hashCode() = flags.hashCode()
+ }
+
+ /**
+ * A simplified media session playback state.
+ */
+ enum class PlaybackState {
+ /**
+ * Unknown. No state has been received from the engine yet.
+ */
+ UNKNOWN,
+
+ /**
+ * Playback of this [MediaSession] has stopped (either completed or aborted).
+ */
+ STOPPED,
+
+ /**
+ * This [MediaSession] is paused.
+ */
+ PAUSED,
+
+ /**
+ * This [MediaSession] is currently playing.
+ */
+ PLAYING,
+ }
+
+ /**
+ * Controller for controlling playback of a media element.
+ */
+ interface Controller {
+ /**
+ * Pauses the media.
+ */
+ fun pause()
+
+ /**
+ * Stop playback for the media session.
+ */
+ fun stop()
+
+ /**
+ * Plays the media.
+ */
+ fun play()
+
+ /**
+ * Seek to a specific time.
+ * Prefer using fast seeking when calling this in a sequence.
+ * Don't use fast seeking for the last or only call in a sequence.
+ *
+ * @param time The time in seconds to move the playback time to.
+ * @param fast Whether fast seeking should be used.
+ */
+ fun seekTo(time: Double, fast: Boolean)
+
+ /**
+ * Seek forward by a sensible number of seconds.
+ */
+ fun seekForward()
+
+ /**
+ * Seek backward by a sensible number of seconds.
+ */
+ fun seekBackward()
+
+ /**
+ * Select and play the next track.
+ * Move playback to the next item in the playlist when supported.
+ */
+ fun nextTrack()
+
+ /**
+ * Select and play the previous track.
+ * Move playback to the previous item in the playlist when supported.
+ */
+ fun previousTrack()
+
+ /**
+ * Skip the advertisement that is currently playing.
+ */
+ fun skipAd()
+
+ /**
+ * Set whether audio should be muted.
+ * Muting audio is supported by default and does not require the media
+ * session to be active.
+ *
+ * @param mute True if audio for this media session should be muted.
+ */
+ fun muteAudio(mute: Boolean)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/PermissionRequest.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/PermissionRequest.kt
new file mode 100644
index 0000000000..4c00505398
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/PermissionRequest.kt
@@ -0,0 +1,158 @@
+/* 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.concept.engine.permission
+
+/**
+ * Represents a permission request, used when engines need access to protected
+ * resources. Every request must be handled by either calling [grant] or [reject].
+ */
+interface PermissionRequest {
+ /**
+ * The origin URI which caused the permissions to be requested.
+ */
+ val uri: String?
+
+ /**
+ * A unique identifier for the request.
+ */
+ val id: String
+
+ /**
+ * List of requested permissions.
+ */
+ val permissions: List<Permission>
+
+ /**
+ * Grants the provided permissions, or all requested permissions, if none
+ * are provided.
+ *
+ * @param permissions the permissions to grant.
+ */
+ fun grant(permissions: List<Permission> = this.permissions)
+
+ /**
+ * Grants this permission request if the provided predicate is true
+ * for any of the requested permissions.
+ *
+ * @param predicate predicate to test for.
+ * @return true if the permission request was granted, otherwise false.
+ */
+ fun grantIf(predicate: (Permission) -> Boolean): Boolean {
+ return if (permissions.any(predicate)) {
+ this.grant()
+ true
+ } else {
+ false
+ }
+ }
+
+ /**
+ * Rejects the requested permissions.
+ */
+ fun reject()
+
+ fun containsVideoAndAudioSources() = false
+}
+
+/**
+ * Represents all the different supported permission types.
+ *
+ * @property id an optional native engine-specific ID of this permission.
+ * @property desc an optional description of what this permission type is for.
+ * @property name permission name allowing to easily identify and differentiate one from the other.
+ */
+@Suppress("UndocumentedPublicClass")
+sealed class Permission {
+ abstract val id: String?
+ abstract val desc: String?
+ val name: String = with(this::class.java) {
+ // Using the canonicalName is safer - see https://github.com/mozilla-mobile/android-components/pull/10810
+ // simpleName is used as a backup to the avoid not null assertion (!!) operator.
+ canonicalName?.substringAfterLast('.') ?: simpleName
+ }
+
+ data class ContentAudioCapture(
+ override val id: String? = "ContentAudioCapture",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentAudioMicrophone(
+ override val id: String? = "ContentAudioMicrophone",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentAudioOther(
+ override val id: String? = "ContentAudioOther",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentGeoLocation(
+ override val id: String? = "ContentGeoLocation",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentNotification(
+ override val id: String? = "ContentNotification",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentProtectedMediaId(
+ override val id: String? = "ContentProtectedMediaId",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentVideoCamera(
+ override val id: String? = "ContentVideoCamera",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentVideoCapture(
+ override val id: String? = "ContentVideoCapture",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentVideoScreen(
+ override val id: String? = "ContentVideoScreen",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentVideoOther(
+ override val id: String? = "ContentVideoOther",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentAutoPlayAudible(
+ override val id: String? = "ContentAutoPlayAudible",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentAutoPlayInaudible(
+ override val id: String? = "ContentAutoPlayInaudible",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentPersistentStorage(
+ override val id: String? = "ContentPersistentStorage",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentMediaKeySystemAccess(
+ override val id: String? = "ContentMediaKeySystemAccess",
+ override val desc: String? = "",
+ ) : Permission()
+ data class ContentCrossOriginStorageAccess(
+ override val id: String? = "ContentCrossOriginStorageAccess",
+ override val desc: String? = "",
+ ) : Permission()
+
+ data class AppCamera(
+ override val id: String? = "AppCamera",
+ override val desc: String? = "",
+ ) : Permission()
+ data class AppAudio(
+ override val id: String? = "AppAudio",
+ override val desc: String? = "",
+ ) : Permission()
+ data class AppLocationCoarse(
+ override val id: String? = "AppLocationCoarse",
+ override val desc: String? = "",
+ ) : Permission()
+ data class AppLocationFine(
+ override val id: String? = "AppLocationFine",
+ override val desc: String? = "",
+ ) : Permission()
+
+ data class Generic(
+ override val id: String? = "Generic",
+ override val desc: String? = "",
+ ) : Permission()
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissions.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissions.kt
new file mode 100644
index 0000000000..146f796b95
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissions.kt
@@ -0,0 +1,96 @@
+/* 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.concept.engine.permission
+
+import android.annotation.SuppressLint
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import mozilla.components.concept.engine.permission.SitePermissions.Status.NO_DECISION
+import mozilla.components.concept.engine.permission.SitePermissionsStorage.Permission
+
+/**
+ * A site permissions and its state.
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class SitePermissions(
+ val origin: String,
+ val location: Status = NO_DECISION,
+ val notification: Status = NO_DECISION,
+ val microphone: Status = NO_DECISION,
+ val camera: Status = NO_DECISION,
+ val bluetooth: Status = NO_DECISION,
+ val localStorage: Status = NO_DECISION,
+ val autoplayAudible: AutoplayStatus = AutoplayStatus.BLOCKED,
+ val autoplayInaudible: AutoplayStatus = AutoplayStatus.ALLOWED,
+ val mediaKeySystemAccess: Status = NO_DECISION,
+ val crossOriginStorageAccess: Status = NO_DECISION,
+ val savedAt: Long,
+) : Parcelable {
+ enum class Status(
+ val id: Int,
+ ) {
+ BLOCKED(-1), NO_DECISION(0), ALLOWED(1);
+
+ fun isAllowed() = this == ALLOWED
+
+ fun doNotAskAgain() = this == ALLOWED || this == BLOCKED
+
+ fun toggle(): Status = when (this) {
+ BLOCKED, NO_DECISION -> ALLOWED
+ ALLOWED -> BLOCKED
+ }
+
+ /**
+ * Converts from [SitePermissions.Status] to [AutoplayStatus].
+ */
+ fun toAutoplayStatus(): AutoplayStatus {
+ return when (this) {
+ NO_DECISION, BLOCKED -> AutoplayStatus.BLOCKED
+ ALLOWED -> AutoplayStatus.ALLOWED
+ }
+ }
+ }
+
+ /**
+ * An enum that represents the status that autoplay can have.
+ */
+ enum class AutoplayStatus(val id: Int) {
+ BLOCKED(Status.BLOCKED.id), ALLOWED(Status.ALLOWED.id);
+
+ /**
+ * Indicates if the status is allowed.
+ */
+ fun isAllowed() = this == ALLOWED
+
+ /**
+ * Convert from a AutoplayStatus to Status.
+ */
+ fun toStatus(): Status {
+ return when (this) {
+ BLOCKED -> Status.BLOCKED
+ ALLOWED -> Status.ALLOWED
+ }
+ }
+ }
+
+ /**
+ * Gets the current status for a [Permission] type
+ */
+ operator fun get(permissionType: Permission): Status {
+ return when (permissionType) {
+ Permission.MICROPHONE -> microphone
+ Permission.BLUETOOTH -> bluetooth
+ Permission.CAMERA -> camera
+ Permission.LOCAL_STORAGE -> localStorage
+ Permission.NOTIFICATION -> notification
+ Permission.LOCATION -> location
+ Permission.AUTOPLAY_AUDIBLE -> autoplayAudible.toStatus()
+ Permission.AUTOPLAY_INAUDIBLE -> autoplayInaudible.toStatus()
+ Permission.MEDIA_KEY_SYSTEM_ACCESS -> mediaKeySystemAccess
+ Permission.STORAGE_ACCESS -> crossOriginStorageAccess
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissionsStorage.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissionsStorage.kt
new file mode 100644
index 0000000000..79f4dc6ac9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/permission/SitePermissionsStorage.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.concept.engine.permission
+
+import androidx.paging.DataSource
+
+/**
+ * Represents a storage to store [SitePermissions].
+ */
+interface SitePermissionsStorage {
+ /**
+ * Persists the [sitePermissions] provided as a parameter.
+ * @param sitePermissions the [sitePermissions] to be stored.
+ * @param request the [PermissionRequest] to be stored, default to null.
+ * @param private indicates if the [SitePermissions] belongs to a private session.
+ */
+ suspend fun save(sitePermissions: SitePermissions, request: PermissionRequest? = null, private: Boolean)
+
+ /**
+ * Saves the permission temporarily until the user navigates away.
+ * @param request The requested permission to be save temporarily.
+ */
+ fun saveTemporary(request: PermissionRequest? = null) = Unit
+
+ /**
+ * Clears any temporary permissions.
+ */
+ fun clearTemporaryPermissions() = Unit
+
+ /**
+ * Replaces an existing SitePermissions with the values of [sitePermissions] provided as a parameter.
+ * @param sitePermissions the sitePermissions to be updated.
+ * @param private indicates if the [SitePermissions] belongs to a private session.
+ */
+ suspend fun update(sitePermissions: SitePermissions, private: Boolean)
+
+ /**
+ * Finds all SitePermissions that match the [origin].
+ * @param origin the site to be used as filter in the search.
+ * @param private indicates if the [origin] belongs to a private session.
+ */
+ suspend fun findSitePermissionsBy(
+ origin: String,
+ includeTemporary: Boolean = false,
+ private: Boolean,
+ ): SitePermissions?
+
+ /**
+ * Deletes all sitePermissions that match the sitePermissions provided as a parameter.
+ * @param sitePermissions the sitePermissions to be deleted from the storage.
+ * @param private indicates if the [SitePermissions] belongs to a private session.
+ */
+ suspend fun remove(sitePermissions: SitePermissions, private: Boolean)
+
+ /**
+ * Deletes all sitePermissions sitePermissions.
+ */
+ suspend fun removeAll()
+
+ /**
+ * Returns all sitePermissions in the store.
+ */
+ suspend fun all(): List<SitePermissions>
+
+ /**
+ * Returns all saved [SitePermissions] instances as a [DataSource.Factory].
+ *
+ * A consuming app can transform the data source into a `LiveData<PagedList>` of when using RxJava2 into a
+ * `Flowable<PagedList>` or `Observable<PagedList>`, that can be observed.
+ *
+ * - https://developer.android.com/topic/libraries/architecture/paging/data
+ * - https://developer.android.com/topic/libraries/architecture/paging/ui
+ */
+ suspend fun getSitePermissionsPaged(): DataSource.Factory<Int, SitePermissions>
+
+ enum class Permission {
+ MICROPHONE, BLUETOOTH, CAMERA, LOCAL_STORAGE, NOTIFICATION, LOCATION, AUTOPLAY_AUDIBLE,
+ AUTOPLAY_INAUDIBLE, MEDIA_KEY_SYSTEM_ACCESS, STORAGE_ACCESS
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/Choice.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/Choice.kt
new file mode 100644
index 0000000000..185c8c9267
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/Choice.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.concept.engine.prompt
+
+import android.os.Parcel
+import android.os.Parcelable
+
+/**
+ * Value type that represents a select option, optgroup or menuitem html element.
+ *
+ * @property id of the option, optgroup or menuitem.
+ * @property enable indicate if item should be selectable or not.
+ * @property label The label for displaying the option, optgroup or menuitem.
+ * @property selected Indicate if the item should be pre-selected.
+ * @property isASeparator Indicating if the item should be a menu separator (only valid for menus).
+ * @property children Sub-items in a group, or null if not a group.
+ */
+data class Choice(
+ val id: String,
+ var enable: Boolean = true,
+ var label: String,
+ var selected: Boolean = false,
+ val isASeparator:
+ Boolean = false,
+ val children: Array<Choice>? = null,
+) : Parcelable {
+
+ val isGroupType get() = children != null
+
+ internal constructor(parcel: Parcel) : this(
+ parcel.readString() ?: "",
+ parcel.readByte() != 0.toByte(),
+ parcel.readString() ?: "",
+ parcel.readByte() != 0.toByte(),
+ parcel.readByte() != 0.toByte(),
+ parcel.createTypedArray(CREATOR),
+ )
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeString(id)
+ parcel.writeByte(if (enable) 1 else 0)
+ parcel.writeString(label)
+ parcel.writeByte(if (selected) 1 else 0)
+ parcel.writeByte(if (isASeparator) 1 else 0)
+ parcel.writeTypedArray(children, flags)
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ companion object CREATOR : Parcelable.Creator<Choice> {
+ override fun createFromParcel(parcel: Parcel): Choice {
+ return Choice(parcel)
+ }
+
+ override fun newArray(size: Int): Array<Choice?> {
+ return arrayOfNulls(size)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/PromptRequest.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/PromptRequest.kt
new file mode 100644
index 0000000000..fccfad6018
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/PromptRequest.kt
@@ -0,0 +1,445 @@
+/* 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.concept.engine.prompt
+
+import android.content.Context
+import android.net.Uri
+import mozilla.components.concept.engine.prompt.PromptRequest.Authentication.Level
+import mozilla.components.concept.engine.prompt.PromptRequest.Authentication.Method
+import mozilla.components.concept.engine.prompt.PromptRequest.TimeSelection.Type
+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 java.util.UUID
+
+/**
+ * Value type that represents a request for showing a native dialog for prompt web content.
+ *
+ * @param shouldDismissOnLoad Whether or not the dialog should automatically be dismissed when a new page is loaded.
+ * Defaults to `true`.
+ * @param uid [PromptRequest] unique identifier. Defaults to a random UUID.
+ * (This two parameters, though present in all subclasses are not evaluated in subclasses equals() calls)
+ */
+sealed class PromptRequest(
+ val shouldDismissOnLoad: Boolean = true,
+ val uid: String = UUID.randomUUID().toString(),
+) {
+ /**
+ * Value type that represents a request for a single choice prompt.
+ * @property choices All the possible options.
+ * @property onConfirm A callback indicating which option was selected.
+ * @property onDismiss A callback executed when dismissed.
+ */
+ data class SingleChoice(
+ val choices: Array<Choice>,
+ val onConfirm: (Choice) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for a multiple choice prompt.
+ * @property choices All the possible options.
+ * @property onConfirm A callback indicating witch options has been selected.
+ * @property onDismiss A callback executed when dismissed.
+ */
+ data class MultipleChoice(
+ val choices: Array<Choice>,
+ val onConfirm: (Array<Choice>) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for a menu choice prompt.
+ * @property choices All the possible options.
+ * @property onConfirm A callback indicating which option was selected.
+ * @property onDismiss A callback executed when dismissed.
+ */
+ data class MenuChoice(
+ val choices: Array<Choice>,
+ val onConfirm: (Choice) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for an alert prompt.
+ * @property title of the dialog.
+ * @property message the body of the dialog.
+ * @property hasShownManyDialogs tells if this page has shown multiple prompts within a short period of time.
+ * @property onConfirm tells the web page if it should continue showing alerts or not.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class Alert(
+ val title: String,
+ val message: String,
+ val hasShownManyDialogs: Boolean = false,
+ val onConfirm: (Boolean) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * BeforeUnloadPrompt represents the onbeforeunload prompt.
+ * This prompt is shown when a user is leaving a website and there is formation pending to be saved.
+ * For more information see https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload.
+ * @property title of the dialog.
+ * @property onLeave callback to notify that the user wants leave the site.
+ * @property onStay callback to notify that the user wants stay in the site.
+ */
+ data class BeforeUnload(
+ val title: String,
+ val onLeave: () -> Unit,
+ val onStay: () -> Unit,
+ ) : PromptRequest()
+
+ /**
+ * Value type that represents a request for a save credit card prompt.
+ * @property creditCard the [CreditCardEntry] to save or update.
+ * @property onConfirm callback that is called when the user confirms the save credit card request.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class SaveCreditCard(
+ val creditCard: CreditCardEntry,
+ val onConfirm: (CreditCardEntry) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(shouldDismissOnLoad = false), Dismissible
+
+ /**
+ * Value type that represents Identity Credential request prompts.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ sealed class IdentityCredential(
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(shouldDismissOnLoad = false), Dismissible {
+ /**
+ * Value type that represents Identity Credential request for selecting a [Provider] prompt.
+ * @property providers A list of providers which the user could select from.
+ * @property onConfirm callback to let the page know the user selected a provider.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class SelectProvider(
+ val providers: List<Provider>,
+ val onConfirm: (Provider) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : IdentityCredential(onDismiss), Dismissible
+
+ /**
+ * Value type that represents Identity Credential request for selecting an [Account] prompt.
+ * @property accounts A list of accounts which the user could select from.
+ * @property providerName The name of the provider that will be used for the login
+ * @property onConfirm callback to let the page know the user selected an account.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class SelectAccount(
+ val accounts: List<Account>,
+ val provider: Provider,
+ val onConfirm: (Account) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : IdentityCredential(onDismiss), Dismissible
+
+ /**
+ * Value type that represents Identity Credential request for a privacy policy prompt.
+ * @property privacyPolicyUrl A The URL where the policy for using this provider is hosted.
+ * @property termsOfServiceUrl The URL where the terms of service for using this provider are.
+ * @property providerDomain The domain of the provider.
+ * @property host The host of the provider.
+ * @property icon A base64 string for given icon for the provider; may be null.
+ * @property onConfirm callback to let the page know the user have confirmed or not the privacy policy.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class PrivacyPolicy(
+ val privacyPolicyUrl: String,
+ val termsOfServiceUrl: String,
+ val providerDomain: String,
+ val host: String,
+ val icon: String?,
+ val onConfirm: (Boolean) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : IdentityCredential(onDismiss), Dismissible
+ }
+
+ /**
+ * Value type that represents a request for a select credit card prompt.
+ * @property creditCards a list of [CreditCardEntry]s to select from.
+ * @property onConfirm callback that is called when the user confirms the credit card selection.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class SelectCreditCard(
+ val creditCards: List<CreditCardEntry>,
+ val onConfirm: (CreditCardEntry) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for a save login prompt.
+ * @property hint a value that helps to determine the appropriate prompting behavior.
+ * @property logins a list of logins that are associated with the current domain.
+ * @property onConfirm callback that is called when the user wants to save the login.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class SaveLoginPrompt(
+ val hint: Int,
+ val logins: List<LoginEntry>,
+ val onConfirm: (LoginEntry) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(shouldDismissOnLoad = false), Dismissible
+
+ /**
+ * Value type that represents a request for a select login prompt.
+ * @property logins a list of logins that are associated with the current domain.
+ * @property generatedPassword the suggested strong password that was generated.
+ * @property onConfirm callback that is called when the user wants to save the login.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class SelectLoginPrompt(
+ val logins: List<Login>,
+ val generatedPassword: String?,
+ val onConfirm: (Login) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for a select address prompt.
+ *
+ * This prompt is triggered by the user focusing on an address field.
+ *
+ * @property addresses List of addresses for the user to choose from.
+ * @property onConfirm Callback used to confirm the selected address.
+ * @property onDismiss Callback used to dismiss the address prompt.
+ */
+ data class SelectAddress(
+ val addresses: List<Address>,
+ val onConfirm: (Address) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for an alert prompt to enter a message.
+ * @property title title of the dialog.
+ * @property inputLabel the label of the field the user should fill.
+ * @property inputValue the default value of the field.
+ * @property hasShownManyDialogs tells if this page has shown multiple prompts within a short period of time.
+ * @property onConfirm tells the web page if it should continue showing alerts or not.
+ * @property onDismiss callback to let the page know the user dismissed the dialog.
+ */
+ data class TextPrompt(
+ val title: String,
+ val inputLabel: String,
+ val inputValue: String,
+ val hasShownManyDialogs: Boolean = false,
+ val onConfirm: (Boolean, String) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for a date prompt for picking a year, month, and day.
+ * @property title of the dialog.
+ * @property initialDate date that dialog should be set by default.
+ * @property minimumDate date allow to be selected.
+ * @property maximumDate date allow to be selected.
+ * @property type indicate which [Type] of selection de user wants.
+ * @property onConfirm callback that is called when the date is selected.
+ * @property onClear callback that is called when the user requests the picker to be clear up.
+ * @property onDismiss A callback executed when dismissed.
+ */
+ @Suppress("LongParameterList")
+ class TimeSelection(
+ val title: String,
+ val initialDate: java.util.Date,
+ val minimumDate: java.util.Date?,
+ val maximumDate: java.util.Date?,
+ val stepValue: String? = null,
+ val type: Type = Type.DATE,
+ val onConfirm: (java.util.Date) -> Unit,
+ val onClear: () -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible {
+ enum class Type {
+ DATE, DATE_AND_TIME, TIME, MONTH
+ }
+ }
+
+ /**
+ * Value type that represents a request for a selecting one or multiple files.
+ * @property mimeTypes a set of allowed mime types. Only these file types can be selected.
+ * @property isMultipleFilesSelection true if the user can select more that one file false otherwise.
+ * @property captureMode indicates if the local media capturing capabilities should be used,
+ * such as the camera or microphone.
+ * @property onSingleFileSelected callback to notify that the user has selected a single file.
+ * @property onMultipleFilesSelected callback to notify that the user has selected multiple files.
+ * @property onDismiss callback to notify that the user has canceled the file selection.
+ */
+ data class File(
+ val mimeTypes: Array<out String>,
+ val isMultipleFilesSelection: Boolean = false,
+ val captureMode: FacingMode = FacingMode.NONE,
+ val onSingleFileSelected: (Context, Uri) -> Unit,
+ val onMultipleFilesSelected: (Context, Array<Uri>) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible {
+
+ /**
+ * @deprecated Use the new primary constructor.
+ */
+ constructor(
+ mimeTypes: Array<out String>,
+ isMultipleFilesSelection: Boolean,
+ onSingleFileSelected: (Context, Uri) -> Unit,
+ onMultipleFilesSelected: (Context, Array<Uri>) -> Unit,
+ onDismiss: () -> Unit,
+ ) : this(
+ mimeTypes,
+ isMultipleFilesSelection,
+ FacingMode.NONE,
+ onSingleFileSelected,
+ onMultipleFilesSelected,
+ onDismiss,
+ )
+
+ enum class FacingMode {
+ NONE, ANY, FRONT_CAMERA, BACK_CAMERA
+ }
+ companion object {
+ /**
+ * Default default directory name for temporary uploads.
+ */
+ const val DEFAULT_UPLOADS_DIR_NAME = "/uploads"
+ }
+ }
+
+ /**
+ * Value type that represents a request for an authentication prompt.
+ * For more related info take a look at
+ * <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication>MDN docs</a>
+ * @property uri The URI for the auth request or null if unknown.
+ * @property title of the dialog.
+ * @property message the body of the dialog.
+ * @property userName default value provide for this session.
+ * @property password default value provide for this session.
+ * @property method type of authentication, valid values [Method.HOST] and [Method.PROXY].
+ * @property level indicates the level of security of the authentication like [Level.NONE],
+ * [Level.SECURED] and [Level.PASSWORD_ENCRYPTED].
+ * @property onlyShowPassword indicates if the dialog should only include a password field.
+ * @property previousFailed indicates if this request is the result of a previous failed attempt to login.
+ * @property isCrossOrigin indicates if this request is from a cross-origin sub-resource.
+ * @property onConfirm callback to indicate the user want to start the authentication flow.
+ * @property onDismiss callback to indicate the user dismissed this request.
+ */
+ data class Authentication(
+ val uri: String?,
+ val title: String,
+ val message: String,
+ val userName: String,
+ val password: String,
+ val method: Method,
+ val level: Level,
+ val onlyShowPassword: Boolean = false,
+ val previousFailed: Boolean = false,
+ val isCrossOrigin: Boolean = false,
+ val onConfirm: (String, String) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible {
+
+ enum class Level {
+ NONE, PASSWORD_ENCRYPTED, SECURED
+ }
+
+ enum class Method {
+ HOST, PROXY
+ }
+ }
+
+ /**
+ * Value type that represents a request for a selecting one or multiple files.
+ * @property defaultColor true if the user can select more that one file false otherwise.
+ * @property onConfirm callback to notify that the user has selected a color.
+ * @property onDismiss callback to notify that the user has canceled the dialog.
+ */
+ data class Color(
+ val defaultColor: String,
+ val onConfirm: (String) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for showing a pop-pup prompt.
+ * This occurs when content attempts to open a new window,
+ * in a way that doesn't appear to be the result of user input.
+ *
+ * @property targetUri the uri that the page is trying to open.
+ * @property onAllow callback to notify that the user wants to open the [targetUri].
+ * @property onDeny callback to notify that the user doesn't want to open the [targetUri].
+ */
+ data class Popup(
+ val targetUri: String,
+ val onAllow: () -> Unit,
+ val onDeny: () -> Unit,
+ override val onDismiss: () -> Unit = { onDeny() },
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for showing a
+ * <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm>confirm prompt</a>.
+ *
+ * The prompt can have up to three buttons, they could be positive, negative and neutral.
+ *
+ * @property title of the dialog.
+ * @property message the body of the dialog.
+ * @property hasShownManyDialogs tells if this page has shown multiple prompts within a short period of time.
+ * @property positiveButtonTitle optional title for the positive button.
+ * @property negativeButtonTitle optional title for the negative button.
+ * @property neutralButtonTitle optional title for the neutral button.
+ * @property onConfirmPositiveButton callback to notify that the user has clicked the positive button.
+ * @property onConfirmNegativeButton callback to notify that the user has clicked the negative button.
+ * @property onConfirmNeutralButton callback to notify that the user has clicked the neutral button.
+ * @property onDismiss callback to notify that the user has canceled the dialog.
+ */
+ data class Confirm(
+ val title: String,
+ val message: String,
+ val hasShownManyDialogs: Boolean = false,
+ val positiveButtonTitle: String = "",
+ val negativeButtonTitle: String = "",
+ val neutralButtonTitle: String = "",
+ val onConfirmPositiveButton: (Boolean) -> Unit,
+ val onConfirmNegativeButton: (Boolean) -> Unit,
+ val onConfirmNeutralButton: (Boolean) -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request to share data.
+ * https://w3c.github.io/web-share/
+ * @property data Share data containing title, text, and url of the request.
+ * @property onSuccess Callback to notify that the user hared with another app successfully.
+ * @property onFailure Callback to notify that the user attempted to share with another app, but it failed.
+ * @property onDismiss Callback to notify that the user aborted the share.
+ */
+ data class Share(
+ val data: ShareData,
+ val onSuccess: () -> Unit,
+ val onFailure: () -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ /**
+ * Value type that represents a request for a repost prompt.
+ *
+ * This prompt is shown whenever refreshing or navigating to a page needs resubmitting
+ * POST data that has been submitted already.
+ *
+ * @property onConfirm callback to notify that the user wants to refresh the webpage.
+ * @property onDismiss callback to notify that the user wants stay in the current webpage and not refresh it.
+ */
+ data class Repost(
+ val onConfirm: () -> Unit,
+ override val onDismiss: () -> Unit,
+ ) : PromptRequest(), Dismissible
+
+ interface Dismissible {
+ val onDismiss: () -> Unit
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/ShareData.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/ShareData.kt
new file mode 100644
index 0000000000..fe264e4fe5
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/prompt/ShareData.kt
@@ -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/. */
+
+package mozilla.components.concept.engine.prompt
+
+import android.annotation.SuppressLint
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Represents data to share for the Web Share and Web Share Target APIs.
+ * https://w3c.github.io/web-share/
+ * @property title Title for the share request.
+ * @property text Text for the share request.
+ * @property url URL for the share request.
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class ShareData(
+ val title: String? = null,
+ val text: String? = null,
+ val url: String? = null,
+) : Parcelable
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/request/RequestInterceptor.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/request/RequestInterceptor.kt
new file mode 100644
index 0000000000..54acc1e66b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/request/RequestInterceptor.kt
@@ -0,0 +1,107 @@
+/* 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.concept.engine.request
+
+import android.content.Intent
+import mozilla.components.browser.errorpages.ErrorType
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+
+/**
+ * Interface for classes that want to intercept load requests to allow custom behavior.
+ */
+interface RequestInterceptor {
+
+ /**
+ * An alternative response for an intercepted request.
+ */
+ sealed class InterceptionResponse {
+ data class Content(
+ val data: String,
+ val mimeType: String = "text/html",
+ val encoding: String = "UTF-8",
+ ) : InterceptionResponse()
+
+ /**
+ * The intercepted request URL to load.
+ *
+ * @param url The URL of the request.
+ * @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 Url(
+ val url: String,
+ val flags: LoadUrlFlags = LoadUrlFlags.select(
+ LoadUrlFlags.EXTERNAL,
+ LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE,
+ ),
+ val additionalHeaders: Map<String, String>? = null,
+ ) : InterceptionResponse()
+
+ data class AppIntent(val appIntent: Intent, val url: String) : InterceptionResponse()
+
+ /**
+ * Deny request without further action.
+ */
+ object Deny : InterceptionResponse()
+ }
+
+ /**
+ * An alternative response for an error request.
+ * Used to load an encoded URI directly.
+ */
+ data class ErrorResponse(val uri: String)
+
+ /**
+ * A request to open an URI. This is called before each page load to allow
+ * providing custom behavior.
+ *
+ * @param engineSession The engine session that initiated the callback.
+ * @param uri The URI of the request.
+ * @param lastUri The URI of the last request.
+ * @param hasUserGesture If the request is triggered by the user then true, else false.
+ * @param isSameDomain If the request is the same domain as the current URL then true, else false.
+ * @param isRedirect If the request is due to a redirect then true, else false.
+ * @param isDirectNavigation If the request is due to a direct navigation then true, else false.
+ * @param isSubframeRequest If the request is coming from a subframe then true, else false.
+ * @return An [InterceptionResponse] object containing alternative content
+ * or an alternative URL. Null if the original request should continue to
+ * be loaded.
+ */
+ @Suppress("LongParameterList")
+ fun onLoadRequest(
+ engineSession: EngineSession,
+ uri: String,
+ lastUri: String?,
+ hasUserGesture: Boolean,
+ isSameDomain: Boolean,
+ isRedirect: Boolean,
+ isDirectNavigation: Boolean,
+ isSubframeRequest: Boolean,
+ ): InterceptionResponse? = null
+
+ /**
+ * A request that the engine wasn't able to handle that resulted in an error.
+ *
+ * @param session The engine session that initiated the callback.
+ * @param errorType The error that was provided by the engine related to the
+ * type of error caused.
+ * @param uri The uri that resulted in the error.
+ * @return An [ErrorResponse] object containing content to display for the
+ * provided error type.
+ */
+ fun onErrorRequest(session: EngineSession, errorType: ErrorType, uri: String?): ErrorResponse? = null
+
+ /**
+ * Returns whether or not this [RequestInterceptor] should intercept load
+ * requests initiated by the app (via direct calls to [EngineSession.loadUrl]).
+ * All other requests triggered by users interacting with web content
+ * (e.g. following links) or redirects will always be intercepted.
+ *
+ * @return true if app initiated requests should be intercepted,
+ * otherwise false. Defaults to false.
+ */
+ fun interceptsAppInitiatedRequests() = false
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/search/SearchRequest.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/search/SearchRequest.kt
new file mode 100644
index 0000000000..8af1bf0c1e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/search/SearchRequest.kt
@@ -0,0 +1,10 @@
+/* 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.concept.engine.search
+
+/**
+ * Value type that represents a request for showing a search to the user.
+ */
+data class SearchRequest(val isPrivate: Boolean, val query: String)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/selection/SelectionActionDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/selection/SelectionActionDelegate.kt
new file mode 100644
index 0000000000..c358bcfe40
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/selection/SelectionActionDelegate.kt
@@ -0,0 +1,47 @@
+/* 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.concept.engine.selection
+
+/**
+ * Generic delegate for handling the context menu that is shown when text is selected.
+ */
+interface SelectionActionDelegate {
+ /**
+ * Gets Strings representing all possible selection actions.
+ *
+ * @returns String IDs for each action that could possibly be shown in the context menu. This
+ * array must include all actions, available or not, and must not change over the class lifetime.
+ */
+ fun getAllActions(): Array<String>
+
+ /**
+ * Checks if an action can be shown on a new selection context menu.
+ *
+ * @returns whether or not the the custom action with the id of [id] is currently available
+ * which may be informed by [selectedText].
+ */
+ fun isActionAvailable(id: String, selectedText: String): Boolean
+
+ /**
+ * Gets a title to be shown in the selection context menu.
+ *
+ * @returns the text that should be shown on the action.
+ */
+ fun getActionTitle(id: String): CharSequence?
+
+ /**
+ * Should perform the action with the id of [id].
+ *
+ * @returns [true] if the action was consumed.
+ */
+ fun performAction(id: String, selectedText: String): Boolean
+
+ /**
+ * Takes in a list of actions and sorts them.
+ *
+ * @returns the sorted list.
+ */
+ fun sortedActions(actions: Array<String>): Array<String>
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/serviceworker/ServiceWorkerDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/serviceworker/ServiceWorkerDelegate.kt
new file mode 100644
index 0000000000..75ee052a1e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/serviceworker/ServiceWorkerDelegate.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.concept.engine.serviceworker
+
+import mozilla.components.concept.engine.EngineSession
+
+/**
+ * Application delegate for handling all service worker requests.
+ */
+interface ServiceWorkerDelegate {
+ /**
+ * Handles requests to open a new tab using the provided [engineSession].
+ * Implementations should not try to load any url, this will be executed by the service worker
+ * through the [engineSession].
+ *
+ * @param engineSession New [EngineSession] in which a service worker will try to load a specific url.
+ *
+ * @return
+ * - `true` when a new tab is created and a service worker is allowed to open an url in it,
+ * - `false` otherwise.
+ */
+ fun addNewTab(engineSession: EngineSession): Boolean
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysis.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysis.kt
new file mode 100644
index 0000000000..110f7923e9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysis.kt
@@ -0,0 +1,51 @@
+/* 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.concept.engine.shopping
+
+/**
+ * Holds the result of the analysis of a shopping product.
+ *
+ * @property productId Product identifier (ASIN/SKU)
+ * @property analysisURL Analysis URL
+ * @property grade Reliability grade for the product's reviews
+ * @property adjustedRating Product rating adjusted to exclude untrusted reviews
+ * @property needsAnalysis Boolean indicating if the analysis is stale
+ * @property pageNotSupported Boolean indicating true if the page is not supported and false if supported
+ * @property notEnoughReviews Boolean indicating if there are not enough reviews
+ * @property lastAnalysisTime Time since the last analysis was performed
+ * @property deletedProductReported Boolean indicating if reported that this product has been deleted
+ * @property deletedProduct Boolean indicating if this product is now deleted
+ * @property highlights Object containing highlights for product
+ */
+data class ProductAnalysis(
+ val productId: String?,
+ val analysisURL: String?,
+ val grade: String?,
+ val adjustedRating: Double?,
+ val needsAnalysis: Boolean,
+ val pageNotSupported: Boolean,
+ val notEnoughReviews: Boolean,
+ val lastAnalysisTime: Long,
+ val deletedProductReported: Boolean,
+ val deletedProduct: Boolean,
+ val highlights: Highlight?,
+)
+
+/**
+ * Contains information about highlights of a product's reviews.
+ *
+ * @property quality Highlights about the quality of a product
+ * @property price Highlights about the price of a product
+ * @property shipping Highlights about the shipping of a product
+ * @property appearance Highlights about the appearance of a product
+ * @property competitiveness Highlights about the competitiveness of a product
+ */
+data class Highlight(
+ val quality: List<String>?,
+ val price: List<String>?,
+ val shipping: List<String>?,
+ val appearance: List<String>?,
+ val competitiveness: List<String>?,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysisStatus.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysisStatus.kt
new file mode 100644
index 0000000000..ae104254fd
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductAnalysisStatus.kt
@@ -0,0 +1,16 @@
+/* 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.concept.engine.shopping
+
+/**
+ * Holds the result of the analysis status of a shopping product.
+ *
+ * @property status String indicating the current status of the analysis
+ * @property progress Number indicating the progress of the analysis
+ */
+data class ProductAnalysisStatus(
+ val status: String,
+ val progress: Double,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductRecommendation.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductRecommendation.kt
new file mode 100644
index 0000000000..5ef4e751d8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/shopping/ProductRecommendation.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.concept.engine.shopping
+
+/**
+ * Contains information about a product recommendation.
+ *
+ * @property url Url of recommended product.
+ * @property analysisUrl Analysis URL.
+ * @property adjustedRating Adjusted rating.
+ * @property sponsored Whether or not it is a sponsored recommendation.
+ * @property imageUrl Url of product recommendation image.
+ * @property aid Unique identifier for the ad entity.
+ * @property name Name of recommended product.
+ * @property grade Grade of recommended product.
+ * @property price Price of recommended product.
+ * @property currency Currency of recommended product.
+ */
+data class ProductRecommendation(
+ val url: String,
+ val analysisUrl: String,
+ val adjustedRating: Double,
+ val sponsored: Boolean,
+ val imageUrl: String,
+ val aid: String,
+ val name: String,
+ val grade: String,
+ val price: String,
+ val currency: String,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/DetectedLanguages.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/DetectedLanguages.kt
new file mode 100644
index 0000000000..1aca245cb1
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/DetectedLanguages.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.concept.engine.translate
+
+/**
+* The representation of a translations detected document and user language.
+*
+* @property documentLangTag The auto-detected language tag of page. Usually used for determining the
+* best guess for translating "from".
+* @property supportedDocumentLang If the translation engine supports the document language.
+* @property userPreferredLangTag The user's preferred language tag. Usually used for determining the
+ * best guess for translating "to".
+*/
+data class DetectedLanguages(
+ val documentLangTag: String? = null,
+ val supportedDocumentLang: Boolean? = false,
+ val userPreferredLangTag: String? = null,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/Language.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/Language.kt
new file mode 100644
index 0000000000..d2a0e8b695
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/Language.kt
@@ -0,0 +1,16 @@
+/* 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.concept.engine.translate
+
+/**
+ * The language container for presenting language information to the user.
+ *
+ * @property code The BCP 47 code that represents the language.
+ * @property localizedDisplayName The translations engine localized display name of the language.
+ */
+data class Language(
+ val code: String,
+ val localizedDisplayName: String? = null,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageModel.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageModel.kt
new file mode 100644
index 0000000000..ec9cfa04ee
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageModel.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.concept.engine.translate
+
+/**
+ * The language model container for representing language model state to the user.
+ *
+ * Please note, a single LanguageModel is usually comprised of
+ * an aggregation of multiple machine learning models on the translations engine level. The engine
+ * has already handled this abstraction.
+ *
+ * @property language The specified language the language model set can process.
+ * @property isDownloaded If all the necessary models are downloaded.
+ * @property size The size of the total model download(s).
+ */
+data class LanguageModel(
+ val language: Language? = null,
+ val isDownloaded: Boolean = false,
+ val size: Long? = null,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageSetting.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageSetting.kt
new file mode 100644
index 0000000000..d5f742c451
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/LanguageSetting.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.concept.engine.translate
+
+/**
+ * The preferences setting a given language may have on the translations engine.
+ *
+ * @param languageSetting The specified language setting.
+ */
+enum class LanguageSetting(private val languageSetting: String) {
+ /**
+ * The translations engine should always expect a given language to be translated and
+ * automatically translate on page load.
+ */
+ ALWAYS("always"),
+
+ /**
+ * The translations engine should offer a given language to be translated. This is the default
+ * setting. Note, this means the language will parallel the global offer setting
+ */
+ OFFER("offer"),
+
+ /**
+ * The translations engine should never offer to translate a given language.
+ */
+ NEVER("never"),
+ ;
+
+ companion object {
+ /**
+ * Convenience method to map a string name to the enumerated type.
+ *
+ * @param languageSetting The specified language setting.
+ */
+ fun fromValue(languageSetting: String): LanguageSetting = when (languageSetting) {
+ "always" -> ALWAYS
+ "offer" -> OFFER
+ "never" -> NEVER
+ else ->
+ throw IllegalArgumentException("The language setting $languageSetting is not mapped.")
+ }
+ }
+
+ /**
+ * Helper function to transform a given [LanguageSetting] setting into its boolean counterpart.
+ *
+ * @param categoryToSetFor The [LanguageSetting] type that we would like to determine the
+ * boolean value for. For example, if trying to calculate a boolean 'isAlways',
+ * [categoryToSetFor] would be [LanguageSetting.ALWAYS].
+ *
+ * @return A boolean that corresponds to the language setting. Will return null if not enough
+ * information is present to make a determination.
+ */
+ fun toBoolean(
+ categoryToSetFor: LanguageSetting,
+ ): Boolean? {
+ when (this) {
+ ALWAYS -> {
+ return when (categoryToSetFor) {
+ ALWAYS -> true
+ // Cannot determine offer without more information
+ OFFER -> null
+ NEVER -> false
+ }
+ }
+
+ OFFER -> {
+ return when (categoryToSetFor) {
+ ALWAYS -> false
+ OFFER -> true
+ NEVER -> false
+ }
+ }
+
+ NEVER -> {
+ return when (categoryToSetFor) {
+ ALWAYS -> false
+ // Cannot determine offer without more information
+ OFFER -> null
+ NEVER -> true
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper function to transform a given [LanguageSetting] that represents a category and the given boolean to its
+ * correct [LanguageSetting]. The calling object should be the object to set for.
+ *
+ * For example, if trying to calculate a value for an `isAlways` boolean, then `this` should be [ALWAYS].
+ *
+ * @param value The given [Boolean] to convert to a [LanguageSetting].
+ * @return A language setting that corresponds to the boolean. Will return null if not enough information is present
+ * to make a determination.
+ */
+ fun toLanguageSetting(
+ value: Boolean,
+ ): LanguageSetting? {
+ when (this) {
+ ALWAYS -> {
+ return when (value) {
+ true -> ALWAYS
+ false -> OFFER
+ }
+ }
+
+ OFFER -> {
+ return when (value) {
+ true -> OFFER
+ // Cannot determine if it should be ALWAYS or NEVER without more information
+ false -> null
+ }
+ }
+
+ NEVER -> {
+ return when (value) {
+ true -> NEVER
+ false -> OFFER
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelManagementOptions.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelManagementOptions.kt
new file mode 100644
index 0000000000..eddb8b1672
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelManagementOptions.kt
@@ -0,0 +1,19 @@
+/* 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.concept.engine.translate
+
+/**
+ * The operations that can be performed on a given language model.
+ *
+ * @property languageToManage The BCP 47 language code to manage the models for.
+ * May be null when performing operations not at the "language" scope or level.
+ * @property operation The operation to perform.
+ * @property operationLevel At what scope or level the operations should be performed at.
+ */
+data class ModelManagementOptions(
+ val languageToManage: String? = null,
+ val operation: ModelOperation,
+ val operationLevel: OperationLevel,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelOperation.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelOperation.kt
new file mode 100644
index 0000000000..66ee50227c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/ModelOperation.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.concept.engine.translate
+
+/**
+ * The operations that can be performed on a language model.
+ */
+enum class ModelOperation(val operation: String) {
+ /**
+ * Download the model(s).
+ */
+ DOWNLOAD("download"),
+
+ /**
+ * Delete the model(s).
+ */
+ DELETE("delete"),
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/OperationLevel.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/OperationLevel.kt
new file mode 100644
index 0000000000..e39a3239ec
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/OperationLevel.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.concept.engine.translate
+
+/**
+ * The level or scope of a model operation.
+ */
+enum class OperationLevel(val operationLevel: String) {
+ /**
+ * Complete the operation for a given language.
+ */
+ LANGUAGE("language"),
+
+ /**
+ * Complete the operation on cache elements.
+ * (Elements that do not fully make a downloaded language package or [LanguageModel].)
+ */
+ CACHE("cache"),
+
+ /**
+ * Complete the operation all models.
+ */
+ ALL("all"),
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationDownloadSize.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationDownloadSize.kt
new file mode 100644
index 0000000000..37782dfde4
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationDownloadSize.kt
@@ -0,0 +1,27 @@
+/* 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.concept.engine.translate
+
+/**
+ * A data class to contain information related to the download size required for a given
+ * translation to/from pair.
+ *
+ * For the translations engine to complete a translation on a specified to/from pair,
+ * first, the necessary ML models must be downloaded to the device.
+ * This class represents the download state of the ML models necessary to translate the
+ * given to/from pair.
+ *
+ * @property fromLanguage The [Language] to translate from on a given translation.
+ * @property toLanguage The [Language] to translate to on a given translation.
+ * @property size The size of the download to perform the translation in bytes. Null means the value has
+ * yet to be received or an error occurred. Zero means no download required or else a model does not exist.
+ * @property error The [TranslationError] reported if an error occurred while fetching the size.
+ */
+data class TranslationDownloadSize(
+ val fromLanguage: Language,
+ val toLanguage: Language,
+ val size: Long? = null,
+ val error: TranslationError? = null,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt
new file mode 100644
index 0000000000..1885c650a4
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt
@@ -0,0 +1,46 @@
+/* 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.concept.engine.translate
+
+/**
+* The representation of the translations engine state.
+*
+* @property detectedLanguages Detected information about preferences and page information.
+* @property error If an error state occurred or an error was reported.
+* @property isEngineReady If the translation engine is primed for use or will need to be loaded.
+* @property requestedTranslationPair The language pair to translate. Usually populated after first request.
+*/
+
+data class TranslationEngineState(
+ val detectedLanguages: DetectedLanguages? = null,
+ val error: String? = null,
+ val isEngineReady: Boolean? = false,
+ val requestedTranslationPair: TranslationPair? = null,
+)
+
+/**
+ * Determines the best initial "to" language based on the translation state and user preferred
+ * languages.
+ *
+ * @param candidateLanguages The language options available to select as a final initial value.
+ * @return The best determined "to" language or null if a determination cannot be made.
+ */
+fun TranslationEngineState.initialToLanguage(candidateLanguages: List<Language>?): Language? {
+ return candidateLanguages?.find {
+ it.code == (requestedTranslationPair?.toLanguage ?: detectedLanguages?.userPreferredLangTag)
+ }
+}
+
+/**
+ * Determines the best initial "from" language based on the translation state and page state.
+ *
+ * @param candidateLanguages The language options available to select as a final initial value.
+ * @return The best determined "from" language or null if a determination cannot be made.
+ */
+fun TranslationEngineState.initialFromLanguage(candidateLanguages: List<Language>?): Language? {
+ return candidateLanguages?.find {
+ it.code == (requestedTranslationPair?.fromLanguage ?: detectedLanguages?.documentLangTag)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationError.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationError.kt
new file mode 100644
index 0000000000..1a1bd12319
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationError.kt
@@ -0,0 +1,175 @@
+/* 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.concept.engine.translate
+
+/**
+ * The types of translation errors that can occur. Has features for determining telemetry error
+ * names and determining if an error needs to be displayed.
+ *
+ * @param errorName The translation error name. The expected use is for telemetry.
+ * @param displayError Signal to determine if we need to specifically display an error for
+ * this given issue. (Some errors should only silently report telemetry or simply revert to the
+ * prior UI state.)
+ * @param cause The original throwable before it was converted into this error state.
+ */
+sealed class TranslationError(
+ val errorName: String,
+ val displayError: Boolean,
+ override val cause: Throwable?,
+) : Throwable(cause = cause) {
+
+ /**
+ * Default error for unexpected issues.
+ *
+ * @param cause The original throwable that lead us to the unknown error state.
+ */
+ class UnknownError(override val cause: Throwable) :
+ TranslationError(errorName = "unknown", displayError = false, cause = cause)
+
+ /**
+ * Default error for unexpected null value received on a non-null translations call.
+ */
+ class UnexpectedNull :
+ TranslationError(errorName = "unexpected-null", displayError = false, cause = null)
+
+ /**
+ * Default error when a translation session coordinator is not available.
+ */
+ class MissingSessionCoordinator :
+ TranslationError(errorName = "missing-session-coordinator", displayError = false, cause = null)
+
+ /**
+ * Translations engine does not work on the device architecture.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class EngineNotSupportedError(override val cause: Throwable?) :
+ TranslationError(errorName = "engine-not-supported", displayError = false, cause = cause)
+
+ /**
+ * Could not determine if the translations engine works on the device architecture.
+ *
+ * @param cause The original [Throwable] before it was converted into this error state.
+ */
+ class UnknownEngineSupportError(override val cause: Throwable?) :
+ TranslationError(errorName = "unknown-engine-support", displayError = false, cause = cause)
+
+ /**
+ * Generic could not compete a translation error.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class CouldNotTranslateError(override val cause: Throwable?) :
+ TranslationError(errorName = "could-not-translate", displayError = true, cause = cause)
+
+ /**
+ * Generic could not restore the page after a translation error.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class CouldNotRestoreError(override val cause: Throwable?) :
+ TranslationError(errorName = "could-not-restore", displayError = false, cause = cause)
+
+ /**
+ * Could not determine the translation download size between a given "to" and "from" language
+ * translation pair.
+ *
+ * @param cause The original [Throwable] before it was converted into this error state.
+ */
+ class CouldNotDetermineDownloadSizeError(override val cause: Throwable?) :
+ TranslationError(
+ errorName = "could-not-determine-translation-download-size",
+ displayError = false,
+ cause = cause,
+ )
+
+ /**
+ * Could not load language options error.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class CouldNotLoadLanguagesError(override val cause: Throwable?) :
+ TranslationError(errorName = "could-not-load-languages", displayError = true, cause = cause)
+
+ /**
+ * Could not load page settings error.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class CouldNotLoadPageSettingsError(override val cause: Throwable?) :
+ TranslationError(errorName = "could-not-load-settings", displayError = false, cause = cause)
+
+ /**
+ * Could not load language settings error.
+ *
+ * @param cause The original [Throwable] before it was converted into this error state.
+ */
+ class CouldNotLoadLanguageSettingsError(override val cause: Throwable?) :
+ TranslationError(errorName = "could-not-load-language-settings", displayError = false, cause = cause)
+
+ /**
+ * Could not load never translate sites error.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class CouldNotLoadNeverTranslateSites(override val cause: Throwable?) :
+ TranslationError(errorName = "could-not-load-never-translate-sites", displayError = false, cause = cause)
+
+ /**
+ * The language is not supported for translation.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class LanguageNotSupportedError(override val cause: Throwable?) :
+ TranslationError(errorName = "language-not-supported", displayError = true, cause = cause)
+
+ /**
+ * Could not retrieve information on the language model.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class ModelCouldNotRetrieveError(override val cause: Throwable?) :
+ TranslationError(
+ errorName = "model-could-not-retrieve",
+ displayError = false,
+ cause = cause,
+ )
+
+ /**
+ * Could not delete the language model.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class ModelCouldNotDeleteError(override val cause: Throwable?) :
+ TranslationError(errorName = "model-could-not-delete", displayError = false, cause = cause)
+
+ /**
+ * Could not download the language model.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class ModelCouldNotDownloadError(override val cause: Throwable?) :
+ TranslationError(
+ errorName = "model-could-not-download",
+ displayError = false,
+ cause = cause,
+ )
+
+ /**
+ * A language is required for language scoped requests.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class ModelLanguageRequiredError(override val cause: Throwable?) :
+ TranslationError(errorName = "model-language-required", displayError = false, cause = cause)
+
+ /**
+ * A download is required and the translate request specified do not download.
+ *
+ * @param cause The original throwable before it was converted into this error state.
+ */
+ class ModelDownloadRequiredError(override val cause: Throwable?) :
+ TranslationError(errorName = "model-download-required", displayError = false, cause = cause)
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt
new file mode 100644
index 0000000000..0f9b62029f
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt
@@ -0,0 +1,48 @@
+/* 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.concept.engine.translate
+
+/**
+ * The operation the translations engine is performing.
+ */
+enum class TranslationOperation {
+ /**
+ * The page should be translated.
+ */
+ TRANSLATE,
+
+ /**
+ * A translated page should be restored.
+ */
+ RESTORE,
+
+ /**
+ * The list of languages that the translation engine should fetch. This includes
+ * the languages supported for translating both "to" and "from" with their BCP-47 language tag
+ * and localized name.
+ */
+ FETCH_SUPPORTED_LANGUAGES,
+
+ /**
+ * The list of available language machine learning translation models the translation engine should fetch.
+ */
+ FETCH_LANGUAGE_MODELS,
+
+ /**
+ * The page related settings the translation engine should fetch.
+ */
+ FETCH_PAGE_SETTINGS,
+
+ /**
+ * Fetch the user preference on whether to offer, always translate, or never translate for
+ * all supported language settings.
+ */
+ FETCH_AUTOMATIC_LANGUAGE_SETTINGS,
+
+ /**
+ * The list of never translate sites the translation engine should fetch.
+ */
+ FETCH_NEVER_TRANSLATE_SITES,
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOptions.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOptions.kt
new file mode 100644
index 0000000000..0c17cfcaff
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOptions.kt
@@ -0,0 +1,15 @@
+/* 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.concept.engine.translate
+
+/**
+ * Translation options that map to the Gecko Translations Options.
+ *
+ * @property downloadModel If the necessary models should be downloaded on request. If false, then
+ * the translation will not complete and throw an exception if the models are not already available.
+ */
+data class TranslationOptions(
+ val downloadModel: Boolean = true,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.kt
new file mode 100644
index 0000000000..82367408b3
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettingOperation.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.concept.engine.translate
+
+/**
+ * The container for referring to the different page settings.
+ *
+ * See [TranslationPageSettings] for the corresponding data model
+ */
+enum class TranslationPageSettingOperation {
+ /**
+ * The system should offer a translation on a page.
+ */
+ UPDATE_ALWAYS_OFFER_POPUP,
+
+ /**
+ * The page's always translate language setting.
+ */
+ UPDATE_ALWAYS_TRANSLATE_LANGUAGE,
+
+ /**
+ * The page's never translate language setting.
+ */
+ UPDATE_NEVER_TRANSLATE_LANGUAGE,
+
+ /**
+ * The page's never translate site setting.
+ */
+ UPDATE_NEVER_TRANSLATE_SITE,
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.kt
new file mode 100644
index 0000000000..7c253d6af2
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPageSettings.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.concept.engine.translate
+
+/**
+ * Translation settings that relate to the page
+ *
+ * @property alwaysOfferPopup The setting for whether translations should automatically be offered.
+ * When true, the engine will offer to translate the page if the detected translatable page language
+ * is different from the user's preferred languages.
+ * @property alwaysTranslateLanguage The setting for whether the current page language should be
+ * automatically translated or not. When true, the page will automatically be translated by the
+ * translations engine.
+ * @property neverTranslateLanguage The setting for whether the current page language should offer a
+ * translation or not. When true, the engine will not offer a translation.
+ * @property neverTranslateSite The setting for whether the current site should be translated or not.
+ * When true, the engine will not offer a translation on the current host site.
+ */
+data class TranslationPageSettings(
+ val alwaysOfferPopup: Boolean? = null,
+ val alwaysTranslateLanguage: Boolean? = null,
+ val neverTranslateLanguage: Boolean? = null,
+ val neverTranslateSite: Boolean? = null,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPair.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPair.kt
new file mode 100644
index 0000000000..60a848fe5a
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationPair.kt
@@ -0,0 +1,16 @@
+/* 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.concept.engine.translate
+
+/**
+* The representation of the translation state.
+*
+* @property fromLanguage The language the page is translated from originally.
+* @property toLanguage The language the page is translated to that the user knows.
+*/
+data class TranslationPair(
+ val fromLanguage: String? = null,
+ val toLanguage: String? = null,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationSupport.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationSupport.kt
new file mode 100644
index 0000000000..033f55bb39
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationSupport.kt
@@ -0,0 +1,60 @@
+/* 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.concept.engine.translate
+
+/**
+ * The list of supported languages that may be translated to and translated from. Usually
+ * a given language will be bi-directional (translate both to and from),
+ * but this is not guaranteed, which is why the support response is two lists.
+ *
+ * @property fromLanguages The languages that the machine learning model may translate from.
+ * @property toLanguages The languages that the machine learning model may translate to.
+ */
+data class TranslationSupport(
+ val fromLanguages: List<Language>? = null,
+ val toLanguages: List<Language>? = null,
+)
+
+/**
+ * Convenience method to convert [this.fromLanguages] and [this.toLanguages] to a single language
+ * map for BCP 47 code to [Language] lookup.
+ *
+ * @return A combined map of the language options with the BCP 47 language as the key and the
+ * [Language] object as the value or null.
+ */
+fun TranslationSupport.toLanguageMap(): Map<String, Language>? {
+ val fromLanguagesMap = fromLanguages?.associate { it.code to it }
+ val toLanguagesMap = toLanguages?.associate { it.code to it }
+
+ return if (toLanguagesMap != null && fromLanguagesMap != null) {
+ toLanguagesMap + fromLanguagesMap
+ } else {
+ toLanguagesMap
+ ?: fromLanguagesMap
+ }
+}
+
+/**
+ * Convenience method to find a [Language] given a BCP 47 language code.
+ *
+ * @param languageCode The BCP 47 language code.
+ *
+ * @return The [Language] associated with the language code or null.
+ */
+fun TranslationSupport.findLanguage(languageCode: String): Language? {
+ return toLanguageMap()?.get(languageCode)
+}
+
+/**
+ * Convenience method to convert a language setting map using a BCP 47 code as a key to a map using
+ * [Language] as a key.
+ *
+ * @param languageSettings The map of language settings, where the key, [String], is a BCP 47 code.
+ */
+fun TranslationSupport.mapLanguageSettings(
+ languageSettings: Map<String, LanguageSetting>?,
+): Map<Language?, LanguageSetting>? {
+ return languageSettings?.mapKeys { findLanguage(it.key) }?.filterKeys { it != null }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationsRuntime.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationsRuntime.kt
new file mode 100644
index 0000000000..2f348d30b5
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationsRuntime.kt
@@ -0,0 +1,215 @@
+/* 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.concept.engine.translate
+
+import mozilla.components.concept.engine.EngineSession
+
+private var unsupportedError = "Translations support is not available in this engine."
+
+/**
+ * Entry point for interacting with runtime translation options.
+ */
+interface TranslationsRuntime {
+
+ /**
+ * Checks if the translations engine is supported or not. The engine only
+ * supports certain architectures.
+ *
+ * An example use case is checking if translations options should ever be displayed.
+ *
+ * @param onSuccess Callback invoked when successful with the compatibility status of running
+ * translations.
+ * @param onError Callback invoked if an issue occurred when determining status.
+ */
+ fun isTranslationsEngineSupported(
+ onSuccess: (Boolean) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Queries what language models are downloaded and will return the download size
+ * for the given language pair or else return an error.
+ *
+ * An example use case is checking how large of a download will occur for a given
+ * specifc translation.
+ *
+ * @param fromLanguage The language the translations engine will use to translate from.
+ * @param toLanguage The language the translations engine will use to translate to.
+ * @param onSuccess Callback invoked if the pair download size was fetched successfully. With
+ * the size in bytes that will be required to complete for the download. Zero bytes indicates
+ * no download is required.
+ * @param onError Callback invoked if an issue occurred when checking sizes.
+ */
+ fun getTranslationsPairDownloadSize(
+ fromLanguage: String,
+ toLanguage: String,
+ onSuccess: (Long) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Aggregates the states of complete models downloaded. Note, this function does not aggregate
+ * the cache or state of incomplete models downloaded.
+ *
+ * An example use case is listing the current install states of the language models.
+ *
+ * @param onSuccess Callback invoked if the states were correctly aggregated as a list.
+ * @param onError Callback invoked if an issue occurred when aggregating model state.
+ */
+ fun getTranslationsModelDownloadStates(
+ onSuccess: (List<LanguageModel>) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Fetches a list of to and from languages supported by the translations engine.
+ *
+ * An example use case is is for populating translation options.
+ *
+ * @param onSuccess Callback invoked if the list of to and from languages was retrieved.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun getSupportedTranslationLanguages(
+ onSuccess: (TranslationSupport) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Use to download and delete complete model sets for a given language. Can bulk update all
+ * models, a given language set, or the cache or incomplete models (models that are not a part
+ * of a complete language set).
+ *
+ * An example use case is for managing deleting and installing model sets.
+ *
+ * @param options The options for the operation.
+ * @param onSuccess Callback invoked if the operation completed successfully.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun manageTranslationsLanguageModel(
+ options: ModelManagementOptions,
+ onSuccess: () -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Retrieves the user preferred languages using the app language(s), web requested language(s),
+ * and OS language(s).
+ *
+ * An example use case is presenting translate "to language" options for the user. Note, the
+ * user's predicted first choice is also available via the state of the translation.
+ *
+ * @param onSuccess Callback invoked if the operation completed successfully with a list of user
+ * preferred languages.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun getUserPreferredLanguages(
+ onSuccess: (List<String>) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Retrieves the user preference on whether they would like translations to offer to translate
+ * on supported pages.
+ *
+ * @return The current translation offer preference value.
+ */
+ fun getTranslationsOfferPopup(): Boolean = throw UnsupportedOperationException(unsupportedError)
+
+ /**
+ * Sets the user preference on whether they would like translations to offer to translate
+ * on supported pages.
+ *
+ * @param offer The popup preference. True if the user would like to receive a popup
+ * recommendation to translate. False if they do not want translations suggestions.
+ */
+ fun setTranslationsOfferPopup(offer: Boolean): Unit =
+ throw UnsupportedOperationException(unsupportedError)
+
+ /**
+ * Gets the user preference on whether to offer, always translate, or never translate for a
+ * given BCP 47 language code. Note, when offer is set, this means the user has not specified
+ * an option or has else opted for default behavior.
+ *
+ * @param languageCode The BCP 47 language code to check the preference for.
+ * @param onSuccess Callback invoked if the operation completed successfully with the
+ * corresponding language setting.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun getLanguageSetting(
+ languageCode: String,
+ onSuccess: (LanguageSetting) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Sets the user preference on whether to offer, always translate, or never translate for a
+ * given BCP 47 language code.
+ *
+ * @param languageCode The BCP 47 language code to check the preference for.
+ * @param languageSetting The language setting for the language.
+ * @param onSuccess Callback invoked if the operation completed successfully with the
+ * corresponding language setting.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun setLanguageSetting(
+ languageCode: String,
+ languageSetting: LanguageSetting,
+ onSuccess: () -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Gets the user preference on whether to offer, always translate, or never translate for all
+ * supported languages. Note, when offer is set, this means the user has not specified
+ * an option or has else opted for default behavior.
+ *
+ * @param onSuccess Callback invoked if the operation completed successfully with the
+ * corresponding setting in a map of key of BCP 47 language code and value of LanguageSetting
+ * preference.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun getLanguageSettings(
+ onSuccess: (Map<String, LanguageSetting>) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Retrieves the list of sites that a user has specified to never translate.
+ *
+ * @param onSuccess Callback invoked if the operation completed successfully with a
+ * display-ready list of URI/URLs.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun getNeverTranslateSiteList(
+ onSuccess: (List<String>) -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+
+ /**
+ * Sets if a given site should be never translated or not. This function is for use when making
+ * global translation settings adjustments to never translate a specified site.
+ *
+ * Note, ideally only use results from {@link [getNeverTranslateSiteList]} to set the
+ * siteURL on this function to ensure correct scope.
+ *
+ * For setting the never translate preference on the currently displayed site, the best practice
+ * is to use {@link [EngineSession.setNeverTranslateSiteSetting]}.
+ *
+ * @param origin The website's URI/URL to set the never translate preference on. Recommend
+ * only using results from {@link getNeverTranslateSiteList} as this parameter to ensure proper
+ * scope. To set the current site, use instead
+ * {@link [EngineSession.setNeverTranslateSiteSetting]}.
+ * @param setting True if the site should never be translated. False if the site should be
+ * translated.
+ * @param onSuccess Callback invoked if the operation completed successfully.
+ * @param onError Callback invoked if an issue occurred.
+ */
+ fun setNeverTranslateSpecifiedSite(
+ origin: String,
+ setting: Boolean,
+ onSuccess: () -> Unit,
+ onError: (Throwable) -> Unit,
+ ): Unit = onError(UnsupportedOperationException(unsupportedError))
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/utils/EngineVersion.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/utils/EngineVersion.kt
new file mode 100644
index 0000000000..07842037c7
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/utils/EngineVersion.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.concept.engine.utils
+
+/**
+ * Release type - as compiled - of the engine.
+ */
+enum class EngineReleaseChannel {
+ UNKNOWN,
+ NIGHTLY,
+ BETA,
+ RELEASE,
+}
+
+/**
+ * Data class for engine versions using semantic versioning (major.minor.patch).
+ *
+ * @param major Major version number
+ * @param minor Minor version number
+ * @param patch Patch version number
+ * @param metadata Additional and optional metadata appended to the version number, e.g. for a version number of
+ * "68.0a1" [metadata] will contain "a1".
+ * @param releaseChannel Additional property indicating the release channel of this version.
+ */
+data class EngineVersion(
+ val major: Int,
+ val minor: Int,
+ val patch: Long,
+ val metadata: String? = null,
+ val releaseChannel: EngineReleaseChannel = EngineReleaseChannel.UNKNOWN,
+) {
+ operator fun compareTo(other: EngineVersion): Int {
+ return when {
+ major != other.major -> major - other.major
+ minor != other.minor -> minor - other.minor
+ patch != other.patch -> (patch - other.patch).toInt()
+ metadata != other.metadata -> when {
+ metadata == null -> -1
+ other.metadata == null -> 1
+ else -> metadata.compareTo(other.metadata)
+ }
+ releaseChannel != other.releaseChannel -> releaseChannel.compareTo(other.releaseChannel)
+ else -> 0
+ }
+ }
+
+ /**
+ * Returns true if this version number equals or is higher than the provided [major], [minor], [patch] version
+ * numbers.
+ */
+ fun isAtLeast(major: Int, minor: Int = 0, patch: Long = 0): Boolean {
+ return when {
+ this.major > major -> true
+ this.major < major -> false
+ this.minor > minor -> true
+ this.minor < minor -> false
+ this.patch >= patch -> true
+ else -> false
+ }
+ }
+
+ override fun toString(): String {
+ return buildString {
+ append(major)
+ append(".")
+ append(minor)
+ append(".")
+ append(patch)
+ if (metadata != null) {
+ append(metadata)
+ }
+ }
+ }
+
+ companion object {
+ /**
+ * Parses the given [version] string and returns an [EngineVersion]. Returns null if the [version] string could
+ * not be parsed successfully.
+ */
+ @Suppress("MagicNumber", "ReturnCount")
+ fun parse(version: String, releaseChannel: String? = null): EngineVersion? {
+ val majorRegex = "([0-9]+)"
+ val minorRegex = "\\.([0-9]+)"
+ val patchRegex = "(?:\\.([0-9]+))?"
+ val metadataRegex = "([^0-9].*)?"
+ val regex = "$majorRegex$minorRegex$patchRegex$metadataRegex".toRegex()
+ val result = regex.matchEntire(version) ?: return null
+
+ val major = result.groups[1]?.value ?: return null
+ val minor = result.groups[2]?.value ?: return null
+ val patch = result.groups[3]?.value ?: "0"
+ val metadata = result.groups[4]?.value
+ val engineReleaseChannel = when (releaseChannel) {
+ "nightly" -> EngineReleaseChannel.NIGHTLY
+ "beta" -> EngineReleaseChannel.BETA
+ "release" -> EngineReleaseChannel.RELEASE
+ else -> EngineReleaseChannel.UNKNOWN
+ }
+
+ return try {
+ EngineVersion(
+ major.toInt(),
+ minor.toInt(),
+ patch.toLong(),
+ metadata,
+ engineReleaseChannel,
+ )
+ } catch (e: NumberFormatException) {
+ null
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/Action.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/Action.kt
new file mode 100644
index 0000000000..9dd6b02740
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/Action.kt
@@ -0,0 +1,54 @@
+/* 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.concept.engine.webextension
+
+import android.graphics.Bitmap
+
+/**
+ * Value type that represents the state of a browser or page action within a [WebExtension].
+ *
+ * @property title The title of the browser action to be visible in the user interface.
+ * @property enabled Indicates if the browser action should be enabled or disabled.
+ * @property loadIcon A suspending function returning the icon in the provided size.
+ * @property badgeText The browser action's badge text.
+ * @property badgeTextColor The browser action's badge text color.
+ * @property badgeBackgroundColor The browser action's badge background color.
+ * @property onClick A callback to be executed when this browser action is clicked.
+ */
+data class Action(
+ val title: String?,
+ val enabled: Boolean?,
+ val loadIcon: (suspend (Int) -> Bitmap?)?,
+ val badgeText: String?,
+ val badgeTextColor: Int?,
+ val badgeBackgroundColor: Int?,
+ val onClick: () -> Unit,
+) {
+ /**
+ * Returns a copy of this [Action] with the provided override applied e.g. for tab-specific overrides.
+ * If the override is null, the original class is returned without making a new instance.
+ *
+ * @param override the action to use for overriding properties. Note that only the provided
+ * (non-null) properties of the override will be applied, all other properties will remain
+ * unchanged. An extension can send a tab-specific action and only include the properties
+ * it wants to override for the tab.
+ */
+ fun copyWithOverride(override: Action?) = if (override != null) {
+ Action(
+ title = override.title ?: title,
+ enabled = override.enabled ?: enabled,
+ badgeText = override.badgeText ?: badgeText,
+ badgeBackgroundColor = override.badgeBackgroundColor ?: badgeBackgroundColor,
+ badgeTextColor = override.badgeTextColor ?: badgeTextColor,
+ loadIcon = override.loadIcon ?: loadIcon,
+ onClick = override.onClick,
+ )
+ } else {
+ this
+ }
+}
+
+typealias WebExtensionBrowserAction = Action
+typealias WebExtensionPageAction = Action
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/InstallationMethod.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/InstallationMethod.kt
new file mode 100644
index 0000000000..13cbac20d6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/InstallationMethod.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.concept.engine.webextension
+
+/**
+ * The method used to install a [WebExtension].
+ */
+enum class InstallationMethod {
+ /**
+ * Indicates the [WebExtension] was installed from the add-ons manager.
+ */
+ MANAGER,
+
+ /**
+ * Indicates the [WebExtension] was installed from a file.
+ */
+ FROM_FILE,
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt
new file mode 100644
index 0000000000..de5077dda1
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtension.kt
@@ -0,0 +1,677 @@
+/* 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.concept.engine.webextension
+
+import android.graphics.Bitmap
+import android.net.Uri
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.Settings
+import org.json.JSONObject
+
+/**
+ * Represents a browser extension based on the WebExtension API:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions
+ *
+ * @property id the unique ID of this extension.
+ * @property url the url pointing to a resources path for locating the extension
+ * within the APK file e.g. resource://android/assets/extensions/my_web_ext.
+ * @property supportActions whether or not browser and page actions are handled when
+ * received from the web extension
+ */
+abstract class WebExtension(
+ val id: String,
+ val url: String,
+ val supportActions: Boolean,
+) {
+ /**
+ * Registers a [MessageHandler] for message events from background scripts.
+ *
+ * @param name the name of the native "application". This can either be the
+ * name of an application, web extension or a specific feature in case
+ * the web extension opens multiple [Port]s. There can only be one handler
+ * with this name per extension and the same name has to be used in
+ * JavaScript when calling `browser.runtime.connectNative` or
+ * `browser.runtime.sendNativeMessage`. Note that name must match
+ * /^\w+(\.\w+)*$/).
+ * @param messageHandler the message handler to be notified of messaging
+ * events e.g. a port was connected or a message received.
+ */
+ abstract fun registerBackgroundMessageHandler(name: String, messageHandler: MessageHandler)
+
+ /**
+ * Registers a [MessageHandler] for message events from content scripts.
+ *
+ * @param session the session to be observed / attach the message handler to.
+ * @param name the name of the native "application". This can either be the
+ * name of an application, web extension or a specific feature in case
+ * the web extension opens multiple [Port]s. There can only be one handler
+ * with this name per extension and session, and the same name has to be
+ * used in JavaScript when calling `browser.runtime.connectNative` or
+ * `browser.runtime.sendNativeMessage`. Note that name must match
+ * /^\w+(\.\w+)*$/).
+ * @param messageHandler the message handler to be notified of messaging
+ * events e.g. a port was connected or a message received.
+ */
+ abstract fun registerContentMessageHandler(session: EngineSession, name: String, messageHandler: MessageHandler)
+
+ /**
+ * Checks whether there is an existing content message handler for the provided
+ * session and "application" name.
+ *
+ * @param session the session the message handler was registered for.
+ * @param name the "application" name the message handler was registered for.
+ * @return true if a content message handler is active, otherwise false.
+ */
+ abstract fun hasContentMessageHandler(session: EngineSession, name: String): Boolean
+
+ /**
+ * Returns a connected port with the given name and for the provided
+ * [EngineSession], if one exists.
+ *
+ * @param name the name as provided to connectNative.
+ * @param session (optional) session to check for, null if port is from a
+ * background script.
+ * @return a matching port, or null if none is connected.
+ */
+ abstract fun getConnectedPort(name: String, session: EngineSession? = null): Port?
+
+ /**
+ * Disconnect a [Port] of the provided [EngineSession]. This method has
+ * no effect if there's no connected port with the given name.
+ *
+ * @param name the name as provided to connectNative, see
+ * [registerContentMessageHandler] and [registerBackgroundMessageHandler].
+ * @param session (options) session for which ports should disconnected,
+ * null if port is from a background script.
+ */
+ abstract fun disconnectPort(name: String, session: EngineSession? = null)
+
+ /**
+ * Registers an [ActionHandler] for this web extension. The handler will
+ * be invoked whenever browser and page action defaults change. To listen
+ * for session-specific overrides see registerActionHandler(
+ * EngineSession, ActionHandler).
+ *
+ * @param actionHandler the [ActionHandler] to be invoked when a browser or
+ * page action is received.
+ */
+ abstract fun registerActionHandler(actionHandler: ActionHandler)
+
+ /**
+ * Registers an [ActionHandler] for the provided [EngineSession]. The handler
+ * will be invoked whenever browser and page action overrides are received
+ * for the provided session.
+ *
+ * @param session the [EngineSession] the handler should be registered for.
+ * @param actionHandler the [ActionHandler] to be invoked when a
+ * session-specific browser or page action is received.
+ */
+ abstract fun registerActionHandler(session: EngineSession, actionHandler: ActionHandler)
+
+ /**
+ * Checks whether there is an existing action handler for the provided
+ * session.
+ *
+ * @param session the session the action handler was registered for.
+ * @return true if an action handler is registered, otherwise false.
+ */
+ abstract fun hasActionHandler(session: EngineSession): Boolean
+
+ /**
+ * Registers a [TabHandler] for this web extension. This handler will
+ * be invoked whenever a web extension wants to open a new tab. To listen
+ * for session-specific events (such as [TabHandler.onCloseTab]) use
+ * registerTabHandler(EngineSession, TabHandler) instead.
+ *
+ * @param tabHandler the [TabHandler] to be invoked when the web extension
+ * wants to open a new tab.
+ * @param defaultSettings used to pass default tab settings to any tabs opened by
+ * a web extension.
+ */
+ abstract fun registerTabHandler(tabHandler: TabHandler, defaultSettings: Settings?)
+
+ /**
+ * Registers a [TabHandler] for the provided [EngineSession]. The handler
+ * will be invoked whenever an existing tab should be closed or updated.
+ *
+ * @param tabHandler the [TabHandler] to be invoked when the web extension
+ * wants to update or close an existing tab.
+ */
+ abstract fun registerTabHandler(session: EngineSession, tabHandler: TabHandler)
+
+ /**
+ * Checks whether there is an existing tab handler for the provided
+ * session.
+ *
+ * @param session the session the tab handler was registered for.
+ * @return true if an tab handler is registered, otherwise false.
+ */
+ abstract fun hasTabHandler(session: EngineSession): Boolean
+
+ /**
+ * Returns additional information about this extension.
+ *
+ * @return extension [Metadata], or null if the extension isn't
+ * installed and there is no meta data available.
+ */
+ abstract fun getMetadata(): Metadata?
+
+ /**
+ * Checks whether or not this extension is built-in (packaged with the
+ * APK file) or coming from an external source.
+ */
+ open fun isBuiltIn(): Boolean = Uri.parse(url).scheme == "resource"
+
+ /**
+ * Checks whether or not this extension is enabled.
+ */
+ abstract fun isEnabled(): Boolean
+
+ /**
+ * Checks whether or not this extension is allowed in private browsing.
+ */
+ abstract fun isAllowedInPrivateBrowsing(): Boolean
+
+ /**
+ * Returns the icon of this extension as specified in the extension's manifest:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/icons
+ *
+ * @param size the desired size of the icon. The returned icon will be the closest
+ * available icon to the provided size.
+ */
+ abstract suspend fun loadIcon(size: Int): Bitmap?
+}
+
+/**
+ * A handler for web extension (browser and page) actions.
+ *
+ * Page action support will be addressed in:
+ * https://github.com/mozilla-mobile/android-components/issues/4470
+ */
+interface ActionHandler {
+
+ /**
+ * Invoked when a browser action is defined or updated.
+ *
+ * @param extension the extension that defined the browser action.
+ * @param session the [EngineSession] if this action is to be updated for a
+ * specific session, or null if this is to set a new default value.
+ * @param action the browser action as [Action].
+ */
+ fun onBrowserAction(extension: WebExtension, session: EngineSession?, action: Action) = Unit
+
+ /**
+ * Invoked when a page action is defined or updated.
+ *
+ * @param extension the extension that defined the browser action.
+ * @param session the [EngineSession] if this action is to be updated for a
+ * specific session, or null if this is to set a new default value.
+ * @param action the [Action]
+ */
+ fun onPageAction(extension: WebExtension, session: EngineSession?, action: Action) = Unit
+
+ /**
+ * Invoked when a browser or page action wants to toggle a popup view.
+ *
+ * @param extension the extension that defined the browser or page action.
+ * @param action the action as [Action].
+ * @return the [EngineSession] that was used for displaying the popup,
+ * or null if the popup was closed.
+ */
+ fun onToggleActionPopup(extension: WebExtension, action: Action): EngineSession? = null
+}
+
+/**
+ * A handler for all messaging related events, usable for both content and
+ * background scripts.
+ *
+ * [Port]s are exposed to consumers (higher level components) because
+ * how ports are used, how many there are and how messages map to it
+ * is feature-specific and depends on the design of the web extension.
+ * Therefore it makes most sense to let the extensions (higher-level
+ * features) deal with the management of ports.
+ */
+interface MessageHandler {
+
+ /**
+ * Invoked when a [Port] was connected as a result of a
+ * `browser.runtime.connectNative` call in JavaScript.
+ *
+ * @param port the connected port.
+ */
+ fun onPortConnected(port: Port) = Unit
+
+ /**
+ * Invoked when a [Port] was disconnected or the corresponding session was
+ * destroyed.
+ *
+ * @param port the disconnected port.
+ */
+ fun onPortDisconnected(port: Port) = Unit
+
+ /**
+ * Invoked when a message was received on the provided port.
+ *
+ * @param message the received message, either be a primitive type
+ * or a org.json.JSONObject.
+ * @param port the port the message was received on.
+ */
+ fun onPortMessage(message: Any, port: Port) = Unit
+
+ /**
+ * Invoked when a message was received as a result of a
+ * `browser.runtime.sendNativeMessage` call in JavaScript.
+ *
+ * @param message the received message, either be a primitive type
+ * or a org.json.JSONObject.
+ * @param source the session this message originated from if from a content
+ * script, otherwise null.
+ * @return the response to be sent for this message, either a primitive
+ * type or a org.json.JSONObject, null if no response should be sent.
+ */
+ fun onMessage(message: Any, source: EngineSession?): Any? = Unit
+}
+
+/**
+ * A handler for all tab related events (triggered by browser.tabs.* methods).
+ */
+interface TabHandler {
+
+ /**
+ * Invoked when a web extension attempts to open a new tab via
+ * browser.tabs.create.
+ *
+ * @param webExtension The [WebExtension] that wants to open the tab.
+ * @param engineSession an instance of engine session to open a new tab with.
+ * @param active whether or not the new tab should be active/selected.
+ * @param url the target url to be loaded in a new tab.
+ */
+ fun onNewTab(webExtension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) = Unit
+
+ /**
+ * Invoked when a web extension attempts to update a tab via
+ * browser.tabs.update.
+ *
+ * @param webExtension The [WebExtension] that wants to update the tab.
+ * @param engineSession an instance of engine session to open a new tab with.
+ * @param active whether or not the new tab should be active/selected.
+ * @param url the (optional) target url to be loaded in a new tab if it has changed.
+ * @return true if the tab was updated, otherwise false.
+ */
+ fun onUpdateTab(webExtension: WebExtension, engineSession: EngineSession, active: Boolean, url: String?) = false
+
+ /**
+ * Invoked when a web extension attempts to close a tab via
+ * browser.tabs.remove.
+ *
+ * @param webExtension The [WebExtension] that wants to remove the tab.
+ * @param engineSession then engine session of the tab to be closed.
+ * @return true if the tab was closed, otherwise false.
+ */
+ fun onCloseTab(webExtension: WebExtension, engineSession: EngineSession) = false
+}
+
+/**
+ * Represents a port for exchanging messages:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port
+ */
+abstract class Port(val engineSession: EngineSession? = null) {
+
+ /**
+ * Sends a message to this port.
+ *
+ * @param message the message to send.
+ */
+ abstract fun postMessage(message: JSONObject)
+
+ /**
+ * Returns the name of this port.
+ */
+ abstract fun name(): String
+
+ /**
+ * Returns the URL of the port sender.
+ */
+ abstract fun senderUrl(): String
+
+ /**
+ * Disconnects this port.
+ */
+ abstract fun disconnect()
+}
+
+/**
+ * Provides information about a [WebExtension].
+ */
+data class Metadata(
+ /**
+ * Version string:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version
+ */
+ val version: String,
+
+ /**
+ * Required extension permissions:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions
+ */
+ val permissions: List<String>,
+
+ /**
+ * Optional permissions requested or granted to this extension:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions
+ */
+ val optionalPermissions: List<String>,
+
+ /**
+ * Optional permissions granted to this extension:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions
+ */
+ val grantedOptionalPermissions: List<String>,
+
+ /**
+ * Optional origin permissions requested or granted to this extension:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions
+ */
+ val optionalOrigins: List<String>,
+
+ /**
+ * Optional origin permissions granted to this extension:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions
+ */
+ val grantedOptionalOrigins: List<String>,
+ /**
+ * Required host permissions:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions
+ */
+ val hostPermissions: List<String>,
+
+ /**
+ * Name of the extension:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/name
+ */
+ val name: String?,
+
+ /**
+ * Description of the extension:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/description
+ */
+ val description: String?,
+
+ /**
+ * Name of the extension developer:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer
+ */
+ val developerName: String?,
+
+ /**
+ * Url of the developer:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer
+ */
+ val developerUrl: String?,
+
+ /**
+ * Url of extension's homepage:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/homepage_url
+ */
+ val homepageUrl: String?,
+
+ /**
+ * Options page:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui
+ */
+ val optionsPageUrl: String?,
+
+ /**
+ * Whether or not the options page should be opened in a new tab:
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui#syntax
+ */
+ val openOptionsPageInTab: Boolean,
+
+ /**
+ * Describes the reason (or reasons) why an extension is disabled.
+ */
+ val disabledFlags: DisabledFlags,
+
+ /**
+ * Base URL for pages of this extension. Can be used to determine if a page
+ * is from / belongs to this extension.
+ */
+ val baseUrl: String,
+
+ /**
+ * The full description of this extension.
+ */
+ val fullDescription: String?,
+
+ /**
+ * The URL used to install this extension.
+ */
+ val downloadUrl: String?,
+
+ /**
+ * The string representation of the date that this extension was most recently updated
+ * (simplified ISO 8601 format).
+ */
+ val updateDate: String?,
+
+ /**
+ * The average rating of this extension.
+ */
+ val averageRating: Float,
+
+ /**
+ * The link to the review page for this extension.
+ */
+ val reviewUrl: String?,
+
+ /**
+ * The average rating of this extension.
+ */
+ val reviewCount: Int,
+
+ /**
+ * The creator name of this extension.
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer
+ */
+ val creatorName: String?,
+
+ /**
+ * The creator url of this extension.
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer
+ */
+ val creatorUrl: String?,
+
+ /**
+ * Whether or not this extension is temporary i.e. installed using a debug tool
+ * such as web-ext, and won't be retained when the application exits.
+ */
+ val temporary: Boolean = false,
+
+ /**
+ * The URL to the detail page of this extension.
+ */
+ val detailUrl: String?,
+
+ /**
+ * Indicates how this extension works with private browsing windows.
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/incognito
+ */
+ val incognito: Incognito,
+)
+
+/**
+ * Provides additional information about why an extension is being enabled or disabled.
+ */
+@Suppress("MagicNumber")
+enum class EnableSource(val id: Int) {
+ /**
+ * The extension is enabled or disabled by the user.
+ */
+ USER(1),
+
+ /**
+ * The extension is enabled or disabled by the application based
+ * on available support.
+ */
+ APP_SUPPORT(1 shl 1),
+}
+
+/**
+ * Flags to check for different reasons why an extension is disabled.
+ */
+class DisabledFlags internal constructor(val value: Int) {
+ companion object {
+ const val USER: Int = 1 shl 1
+ const val BLOCKLIST: Int = 1 shl 2
+ const val APP_SUPPORT: Int = 1 shl 3
+ const val SIGNATURE: Int = 1 shl 4
+ const val APP_VERSION: Int = 1 shl 5
+
+ /**
+ * Selects a combination of flags.
+ *
+ * @param flags the flags to select.
+ */
+ fun select(vararg flags: Int) = DisabledFlags(flags.sum())
+ }
+
+ /**
+ * Checks if the provided flag is set.
+ *
+ * @param flag the flag to check.
+ */
+ fun contains(flag: Int) = (value and flag) != 0
+}
+
+/**
+ * Incognito values that control how an extension works with private browsing windows.
+ */
+enum class Incognito {
+ /**
+ * The extension will see events from private and non-private windows and tabs.
+ */
+ SPANNING,
+
+ /**
+ * The extension will be split between private and non-private windows.
+ */
+ SPLIT,
+
+ /**
+ * Private tabs and windows are invisible to the extension.
+ */
+ NOT_ALLOWED,
+
+ ;
+
+ companion object {
+ /**
+ * Safely returns an Incognito value based on the input nullable string.
+ */
+ fun fromString(value: String?): Incognito {
+ return when (value) {
+ "split" -> SPLIT
+ "not_allowed" -> NOT_ALLOWED
+ else -> SPANNING
+ }
+ }
+ }
+}
+
+/**
+ * Returns whether or not the extension is disabled because it is unsupported.
+ */
+fun WebExtension.isUnsupported(): Boolean {
+ val flags = getMetadata()?.disabledFlags
+ return flags?.contains(DisabledFlags.APP_SUPPORT) == true
+}
+
+/**
+ * Returns whether or not the extension is disabled because it has been blocklisted.
+ */
+fun WebExtension.isBlockListed(): Boolean {
+ val flags = getMetadata()?.disabledFlags
+ return flags?.contains(DisabledFlags.BLOCKLIST) == true
+}
+
+/**
+ * Returns whether the extension is disabled because it isn't correctly signed.
+ */
+fun WebExtension.isDisabledUnsigned(): Boolean {
+ val flags = getMetadata()?.disabledFlags
+ return flags?.contains(DisabledFlags.SIGNATURE) == true
+}
+
+/**
+ * Returns whether the extension is disabled because it isn't compatible with the application version.
+ */
+fun WebExtension.isDisabledIncompatible(): Boolean {
+ val flags = getMetadata()?.disabledFlags
+ return flags?.contains(DisabledFlags.APP_VERSION) == true
+}
+
+/**
+ * An unexpected event that occurs when trying to perform an action on the extension like
+ * (but not exclusively) installing/uninstalling, removing or updating.
+ */
+open class WebExtensionException(throwable: Throwable, open val isRecoverable: Boolean = true) : Exception(throwable)
+
+/**
+ * An unexpected event that occurs when installing an extension.
+ */
+sealed class WebExtensionInstallException(
+ open val extensionName: String? = null,
+ throwable: Throwable,
+ override val isRecoverable: Boolean = true,
+) : WebExtensionException(throwable) {
+ /**
+ * The extension install was canceled by the user.
+ */
+ class UserCancelled(override val extensionName: String? = null, throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable)
+
+ /**
+ * The extension install was cancelled because the extension is blocklisted.
+ */
+ class Blocklisted(override val extensionName: String? = null, throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable)
+
+ /**
+ * The extension install was cancelled because the downloaded file
+ * seems to be corrupted in some way.
+ */
+ class CorruptFile(throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable, extensionName = null)
+
+ /**
+ * The extension install was cancelled because the file must be signed and isn't.
+ */
+ class NotSigned(throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable, extensionName = null)
+
+ /**
+ * The extension install was cancelled because it is incompatible.
+ */
+ class Incompatible(override val extensionName: String? = null, throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable)
+
+ /**
+ * The extension install failed because of a network error.
+ */
+ class NetworkFailure(override val extensionName: String? = null, throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable)
+
+ /**
+ * The extension install failed with an unknown error.
+ */
+ class Unknown(override val extensionName: String? = null, throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable)
+
+ /**
+ * The extension install failed because the extension type is not supported.
+ */
+ class UnsupportedAddonType(override val extensionName: String? = null, throwable: Throwable) :
+ WebExtensionInstallException(throwable = throwable)
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
new file mode 100644
index 0000000000..fce18e3863
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
@@ -0,0 +1,177 @@
+/* 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.concept.engine.webextension
+
+import mozilla.components.concept.engine.EngineSession
+
+/**
+ * Notifies applications or other components of engine events related to web
+ * extensions e.g. an extension was installed, or an extension wants to open
+ * a new tab.
+ */
+interface WebExtensionDelegate {
+
+ /**
+ * Invoked when a web extension was installed successfully.
+ *
+ * @param extension The installed extension.
+ */
+ fun onInstalled(extension: WebExtension) = Unit
+
+ /**
+ * Invoked when a web extension was uninstalled successfully.
+ *
+ * @param extension The uninstalled extension.
+ */
+ fun onUninstalled(extension: WebExtension) = Unit
+
+ /**
+ * Invoked when a web extension was enabled successfully.
+ *
+ * @param extension The enabled extension.
+ */
+ fun onEnabled(extension: WebExtension) = Unit
+
+ /**
+ * Invoked when a web extension was disabled successfully.
+ *
+ * @param extension The disabled extension.
+ */
+ fun onDisabled(extension: WebExtension) = Unit
+
+ /**
+ * Invoked when a web extension was started successfully.
+ *
+ * @param extension The extension that has completed its startup.
+ */
+ fun onReady(extension: WebExtension) = Unit
+
+ /**
+ * Invoked when a web extension in private browsing allowed is set.
+ *
+ * @param extension the modified [WebExtension] instance.
+ */
+ fun onAllowedInPrivateBrowsingChanged(extension: WebExtension) = Unit
+
+ /**
+ * Invoked when a web extension attempts to open a new tab via
+ * browser.tabs.create. Note that browser.tabs.update and browser.tabs.remove
+ * can only be observed using session-specific handlers,
+ * see [WebExtension.registerTabHandler].
+ *
+ * @param extension The [WebExtension] that wants to open a new tab.
+ * @param engineSession an instance of engine session to open a new tab with.
+ * @param active whether or not the new tab should be active/selected.
+ * @param url the target url to be loaded in a new tab.
+ */
+ fun onNewTab(extension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) = Unit
+
+ /**
+ * Invoked when a web extension defines a browser action. To listen for session-specific
+ * overrides of [Action]s and other action-specific events (e.g. opening a popup)
+ * see [WebExtension.registerActionHandler].
+ *
+ * @param extension The [WebExtension] defining the browser action.
+ * @param action the defined browser [Action].
+ */
+ fun onBrowserActionDefined(extension: WebExtension, action: Action) = Unit
+
+ /**
+ * Invoked when a web extension defines a page action. To listen for session-specific
+ * overrides of [Action]s and other action-specific events (e.g. opening a popup)
+ * see [WebExtension.registerActionHandler].
+ *
+ * @param extension The [WebExtension] defining the browser action.
+ * @param action the defined page [Action].
+ */
+ fun onPageActionDefined(extension: WebExtension, action: Action) = Unit
+
+ /**
+ * Invoked when a browser or page action wants to toggle a popup view.
+ *
+ * @param extension The [WebExtension] that wants to display the popup.
+ * @param engineSession The [EngineSession] to use for displaying the popup.
+ * @param action the [Action] that defines the popup.
+ * @return the [EngineSession] used to display the popup, or null if no popup
+ * was displayed.
+ */
+ fun onToggleActionPopup(
+ extension: WebExtension,
+ engineSession: EngineSession,
+ action: Action,
+ ): EngineSession? = null
+
+ /**
+ * Invoked during installation of a [WebExtension] to confirm the required permissions.
+ *
+ * @param extension the extension being installed. The required permissions can be
+ * accessed using [WebExtension.getMetadata] and [Metadata.permissions].
+ * @param onPermissionsGranted A callback to indicate whether the user has granted the [extension] permissions
+ * @return whether or not installation should process i.e. the permissions have been
+ * granted.
+ */
+ fun onInstallPermissionRequest(
+ extension: WebExtension,
+ onPermissionsGranted: ((Boolean) -> Unit),
+ ) = Unit
+
+ /**
+ * Invoked whenever the installation of a [WebExtension] failed.
+ *
+ * @param extension extension the extension that failed to be installed. It can be null when the
+ * extension couldn't be downloaded or the extension couldn't be parsed for example.
+ * @param exception the reason why the installation failed.
+ */
+ fun onInstallationFailedRequest(
+ extension: WebExtension?,
+ exception: WebExtensionInstallException,
+ ) = Unit
+
+ /**
+ * Invoked when a web extension has changed its permissions while trying to update to a
+ * new version. This requires user interaction as the updated extension will not be installed,
+ * until the user grants the new permissions.
+ *
+ * @param current The current [WebExtension].
+ * @param updated The update [WebExtension] that requires extra permissions.
+ * @param newPermissions Contains a list of all the new permissions.
+ * @param onPermissionsGranted A callback to indicate if the new permissions from the [updated] extension
+ * are granted or not.
+ */
+ fun onUpdatePermissionRequest(
+ current: WebExtension,
+ updated: WebExtension,
+ newPermissions: List<String>,
+ onPermissionsGranted: ((Boolean) -> Unit),
+ ) = Unit
+
+ /**
+ * Invoked when a web extension requests optional permissions. This requires user interaction since the
+ * user needs to grant or revoke these optional permissions.
+ *
+ * @param extension The [WebExtension].
+ * @param permissions The list of all the optional permissions.
+ * @param onPermissionsGranted A callback to indicate if the optional permissions have been granted or not.
+ */
+ fun onOptionalPermissionsRequest(
+ extension: WebExtension,
+ permissions: List<String>,
+ onPermissionsGranted: ((Boolean) -> Unit),
+ ) = Unit
+
+ /**
+ * Invoked when the list of installed extensions has been updated in the engine
+ * (the web extension runtime). This happens as a result of debugging tools (e.g
+ * web-ext) installing temporary extensions. It does not happen in the regular flow
+ * of installing / uninstalling extensions by the user.
+ */
+ fun onExtensionListUpdated() = Unit
+
+ /**
+ * Invoked when the extension process spawning has been disabled. This can occur because
+ * it has been killed or crashed too many times. A client should determine what to do next.
+ */
+ fun onDisabledExtensionProcessSpawning() = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionRuntime.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionRuntime.kt
new file mode 100644
index 0000000000..534da7e56e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionRuntime.kt
@@ -0,0 +1,220 @@
+/* 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.concept.engine.webextension
+
+import mozilla.components.concept.engine.CancellableOperation
+import java.lang.UnsupportedOperationException
+
+/**
+ * Entry point for interacting with the web extensions.
+ */
+interface WebExtensionRuntime {
+
+ /**
+ * Installs the provided built-in extension in this engine.
+ *
+ * @param id the unique ID of the extension.
+ * @param url the url pointing to either a resources path for locating the extension
+ * within the APK file (e.g. resource://android/assets/extensions/my_web_ext) or to a
+ * local (e.g. resource://android/assets/extensions/my_web_ext.xpi) XPI file. An error
+ * is thrown if a non-resource URL is passed.
+ * @param onSuccess (optional) callback invoked if the extension was installed successfully,
+ * providing access to the [WebExtension] object for bi-directional messaging.
+ * @param onError (optional) callback invoked if there was an error installing the extension.
+ * This callback is invoked with an [UnsupportedOperationException] in case the engine doesn't
+ * have web extension support.
+ */
+ fun installBuiltInWebExtension(
+ id: String,
+ url: String,
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { _ -> },
+ ): CancellableOperation {
+ onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+ return CancellableOperation.Noop()
+ }
+
+ /**
+ * Installs a [WebExtension] from the provided [url] in this engine.
+ *
+ * @param url the url pointing to an XPI file. An error is thrown when a resource URL is passed.
+ * @param onSuccess (optional) callback invoked if the extension was installed successfully,
+ * providing access to the [WebExtension] object for bi-directional messaging.
+ * @param installationMethod (optional) the method used to install a [WebExtension].
+ * @param onError (optional) callback invoked if there was an error installing the extension.
+ * This callback is invoked with an [UnsupportedOperationException] in case the engine doesn't
+ * have web extension support.
+ */
+ fun installWebExtension(
+ url: String,
+ installationMethod: InstallationMethod? = null,
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { _ -> },
+ ): CancellableOperation {
+ onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+ return CancellableOperation.Noop()
+ }
+
+ /**
+ * Updates the provided [extension] if a new version is available.
+ *
+ * @param extension the extension to be updated.
+ * @param onSuccess (optional) callback invoked if the extension was updated successfully,
+ * providing access to the [WebExtension] object for bi-directional messaging, if null is provided
+ * that means that the [WebExtension] hasn't been change since the last update.
+ * @param onError (optional) callback invoked if there was an error updating the extension.
+ * This callback is invoked with an [UnsupportedOperationException] in case the engine doesn't
+ * have web extension support.
+ */
+ fun updateWebExtension(
+ extension: WebExtension,
+ onSuccess: ((WebExtension?) -> Unit) = { },
+ onError: ((String, Throwable) -> Unit) = { _, _ -> },
+ ): Unit = onError(
+ extension.id,
+ UnsupportedOperationException("Web extension support is not available in this engine"),
+ )
+
+ /**
+ * Uninstalls the provided extension from this engine.
+ *
+ * @param ext the [WebExtension] to uninstall.
+ * @param onSuccess (optional) callback invoked if the extension was uninstalled successfully.
+ * @param onError (optional) callback invoked if there was an error uninstalling the extension.
+ * This callback is invoked with an [UnsupportedOperationException] in case the engine doesn't
+ * have web extension support.
+ */
+ fun uninstallWebExtension(
+ ext: WebExtension,
+ onSuccess: (() -> Unit) = { },
+ onError: ((String, Throwable) -> Unit) = { _, _ -> },
+ ): Unit = onError(ext.id, UnsupportedOperationException("Web extension support is not available in this engine"))
+
+ /**
+ * Lists the currently installed web extensions in this engine.
+ *
+ * @param onSuccess callback invoked with the list of of installed [WebExtension]s.
+ * @param onError (optional) callback invoked if there was an error querying
+ * the installed extensions. This callback is invoked with an [UnsupportedOperationException]
+ * in case the engine doesn't have web extension support.
+ */
+ fun listInstalledWebExtensions(
+ onSuccess: ((List<WebExtension>) -> Unit),
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+
+ /**
+ * Enables the provided [WebExtension]. If the extension is already enabled the [onSuccess]
+ * callback will be invoked, but this method has no effect on the extension.
+ *
+ * @param extension the extension to enable.
+ * @param source [EnableSource] to indicate why the extension is enabled.
+ * @param onSuccess (optional) callback invoked with the enabled [WebExtension]
+ * @param onError (optional) callback invoked if there was an error enabling
+ * the extensions. This callback is invoked with an [UnsupportedOperationException]
+ * in case the engine doesn't have web extension support.
+ */
+ fun enableWebExtension(
+ extension: WebExtension,
+ source: EnableSource = EnableSource.USER,
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+
+ /**
+ * Add the provided [permissions] and [origins] to the [WebExtension].
+ *
+ * @param extensionId the id of the [WebExtension].
+ * @param permissions [List] the list of permissions to be added to the [WebExtension].
+ * @param origins [List] the list of origins to be added to the [WebExtension].
+ * @param onSuccess (optional) callback invoked when permissions are added to the [WebExtension].
+ * @param onError (optional) callback invoked if there was an error adding permissions to
+ * the [WebExtension]. This callback is invoked with an [UnsupportedOperationException]
+ * in case the engine doesn't have web extension support.
+ */
+ fun addOptionalPermissions(
+ extensionId: String,
+ permissions: List<String> = emptyList(),
+ origins: List<String> = emptyList(),
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+
+ /**
+ * Remove the provided [permissions] and [origins] from the [WebExtension].
+ *
+ * @param extensionId the id of the [WebExtension].
+ * @param permissions [List] the list of permissions to be removed from the [WebExtension].
+ * @param origins [List] the list of origins to be removed from the [WebExtension].
+ * @param onSuccess (optional) callback invoked when permissions are removed from the [WebExtension].
+ * @param onError (optional) callback invoked if there was an error removing permissions from
+ * the [WebExtension]. This callback is invoked with an [UnsupportedOperationException]
+ * in case the engine doesn't have web extension support.
+ */
+ fun removeOptionalPermissions(
+ extensionId: String,
+ permissions: List<String> = emptyList(),
+ origins: List<String> = emptyList(),
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+
+ /**
+ * Disables the provided [WebExtension]. If the extension is already disabled the [onSuccess]
+ * callback will be invoked, but this method has no effect on the extension.
+ *
+ * @param extension the extension to disable.
+ * @param source [EnableSource] to indicate why the extension is disabled.
+ * @param onSuccess (optional) callback invoked with the enabled [WebExtension]
+ * @param onError (optional) callback invoked if there was an error disabling
+ * the installed extensions. This callback is invoked with an [UnsupportedOperationException]
+ * in case the engine doesn't have web extension support.
+ */
+ fun disableWebExtension(
+ extension: WebExtension,
+ source: EnableSource = EnableSource.USER,
+ onSuccess: ((WebExtension) -> Unit),
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = onError(UnsupportedOperationException("Web extension support is not available in this engine"))
+
+ /**
+ * Registers a [WebExtensionDelegate] to be notified of engine events
+ * related to web extensions
+ *
+ * @param webExtensionDelegate callback to be invoked for web extension events.
+ */
+ fun registerWebExtensionDelegate(
+ webExtensionDelegate: WebExtensionDelegate,
+ ): Unit = throw UnsupportedOperationException("Web extension support is not available in this engine")
+
+ /**
+ * Sets whether the provided [WebExtension] should be allowed to run in private browsing or not.
+ *
+ * @param extension the [WebExtension] instance to modify.
+ * @param allowed true if this extension should be allowed to run in private browsing pages, false otherwise.
+ * @param onSuccess (optional) callback invoked with modified [WebExtension] instance.
+ * @param onError (optional) callback invoked if there was an error setting private browsing preference
+ * the installed extensions. This callback is invoked with an [UnsupportedOperationException]
+ * in case the engine doesn't have web extension support.
+ */
+ fun setAllowedInPrivateBrowsing(
+ extension: WebExtension,
+ allowed: Boolean,
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { },
+ ): Unit = throw UnsupportedOperationException("Web extension support is not available in this engine")
+
+ /**
+ * Enable the extensions process spawning.
+ */
+ fun enableExtensionProcessSpawning(): Unit =
+ throw UnsupportedOperationException("Enabling extension process spawning is not available in this engine")
+
+ /**
+ * Disable the extensions process spawning.
+ */
+ fun disableExtensionProcessSpawning(): Unit =
+ throw UnsupportedOperationException("Disabling extension process spawning is not available in this engine")
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotification.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotification.kt
new file mode 100644
index 0000000000..bd77a3af02
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotification.kt
@@ -0,0 +1,44 @@
+/* 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.concept.engine.webnotifications
+
+import android.os.Parcelable
+import mozilla.components.concept.engine.Engine
+
+/**
+ * A notification sent by the Web Notifications API.
+ *
+ * @property title Title of the notification to be displayed in the first row.
+ * @property tag Tag used to identify the notification.
+ * @property body Body of the notification to be displayed in the second row.
+ * @property sourceUrl The URL of the page or Service Worker that generated the notification.
+ * @property iconUrl Large icon url to display in the notification.
+ * Corresponds to [android.app.Notification.Builder.setLargeIcon].
+ * @property direction Preference for text direction.
+ * @property lang language of the notification.
+ * @property requireInteraction Preference flag that indicates the notification should remain.
+ * @property engineNotification Notification instance native to [Engine] which can be
+ * sent across processes or persisted and restored later.
+ * @property timestamp Time when the notification was created.
+ * @property triggeredByWebExtension True if this notification was triggered by a
+ * web extension, otherwise false.
+ * @property privateBrowsing indicates if the [WebNotification] belongs to a private session.
+ * @property silent Whether or not the notification should be silent.
+ */
+data class WebNotification(
+ val title: String?,
+ val tag: String,
+ val body: String?,
+ val sourceUrl: String?,
+ val iconUrl: String?,
+ val direction: String?,
+ val lang: String?,
+ val requireInteraction: Boolean,
+ val engineNotification: Parcelable,
+ val timestamp: Long = System.currentTimeMillis(),
+ val triggeredByWebExtension: Boolean = false,
+ val privateBrowsing: Boolean,
+ val silent: Boolean = true,
+)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotificationDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotificationDelegate.kt
new file mode 100644
index 0000000000..9a5dca952d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webnotifications/WebNotificationDelegate.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.concept.engine.webnotifications
+
+/**
+ * Notifies applications or other components of engine events related to web
+ * notifications e.g. an notification is to be shown or is to be closed
+ */
+interface WebNotificationDelegate {
+ /**
+ * Invoked when a web notification is to be shown.
+ *
+ * @param webNotification The web notification intended to be shown.
+ */
+ fun onShowNotification(webNotification: WebNotification) = Unit
+
+ /**
+ * Invoked when a web notification is to be closed.
+ *
+ * @param webNotification The web notification intended to be closed.
+ */
+ fun onCloseNotification(webNotification: WebNotification) = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPush.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPush.kt
new file mode 100644
index 0000000000..bc76fd13af
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPush.kt
@@ -0,0 +1,80 @@
+/* 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.concept.engine.webpush
+
+import mozilla.components.concept.engine.Engine
+
+/**
+ * A handler for all WebPush messages and [subscriptions][0] to be delivered to the [Engine].
+ *
+ * [0]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription
+ */
+interface WebPushHandler {
+
+ /**
+ * Invoked when a push message has been delivered.
+ *
+ * @param scope The subscription identifier which usually represents the website's URI.
+ * @param message A [ByteArray] message.
+ */
+ fun onPushMessage(scope: String, message: ByteArray?)
+
+ /**
+ * Invoked when a subscription has now changed/expired.
+ */
+ fun onSubscriptionChanged(scope: String) = Unit
+}
+
+/**
+ * A data class representation of the [PushSubscription][0] web specification.
+ *
+ * [0]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription
+ *
+ * @param scope The subscription identifier which usually represents the website's URI.
+ * @param endpoint The Web Push endpoint for this subscription.
+ * This is the URL of a web service which implements the Web Push protocol.
+ * @param appServerKey A public key a server will use to send messages to client apps via a push server.
+ * @param publicKey The public key generated, to be provided to the app server for message encryption.
+ * @param authSecret A secret key generated, to be provided to the app server for use in encrypting
+ * and authenticating messages sent to the endpoint.
+ */
+data class WebPushSubscription(
+ val scope: String,
+ val endpoint: String,
+ val appServerKey: ByteArray?,
+ val publicKey: ByteArray,
+ val authSecret: ByteArray,
+) {
+ @Suppress("ComplexMethod")
+ override fun equals(other: Any?): Boolean {
+ /* auto-generated */
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as WebPushSubscription
+
+ if (scope != other.scope) return false
+ if (endpoint != other.endpoint) return false
+ if (appServerKey != null) {
+ if (other.appServerKey == null) return false
+ if (!appServerKey.contentEquals(other.appServerKey)) return false
+ } else if (other.appServerKey != null) return false
+ if (!publicKey.contentEquals(other.publicKey)) return false
+ if (!authSecret.contentEquals(other.authSecret)) return false
+
+ return true
+ }
+
+ @Suppress("MagicNumber")
+ override fun hashCode(): Int {
+ /* auto-generated */
+ var result = scope.hashCode()
+ result = 31 * result + endpoint.hashCode()
+ result = 31 * result + (appServerKey?.contentHashCode() ?: 0)
+ result = 31 * result + publicKey.contentHashCode()
+ result = 31 * result + authSecret.contentHashCode()
+ return result
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPushDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPushDelegate.kt
new file mode 100644
index 0000000000..92d4d2e135
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webpush/WebPushDelegate.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.concept.engine.webpush
+
+/**
+ * Notifies applications or other components of engine events related to Web Push notifications.
+ */
+interface WebPushDelegate {
+
+ /**
+ * Requests a WebPush subscription for the given Service Worker scope.
+ */
+ fun onGetSubscription(scope: String, onSubscription: (WebPushSubscription?) -> Unit) = Unit
+
+ /**
+ * Create a WebPush subscription for the given Service Worker scope.
+ */
+ fun onSubscribe(scope: String, serverKey: ByteArray?, onSubscribe: (WebPushSubscription?) -> Unit) = Unit
+
+ /**
+ * Remove a subscription for the given Service Worker scope.
+ *
+ * @return whether the unsubscribe was successful or not.
+ */
+ fun onUnsubscribe(scope: String, onUnsubscribe: (Boolean) -> Unit) = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/window/WindowRequest.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/window/WindowRequest.kt
new file mode 100644
index 0000000000..1237a9659c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/window/WindowRequest.kt
@@ -0,0 +1,44 @@
+/* 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.concept.engine.window
+
+import mozilla.components.concept.engine.EngineSession
+
+/**
+ * Represents a request to open or close a browser window.
+ */
+interface WindowRequest {
+
+ /**
+ * Describes the different types of window requests.
+ */
+ enum class Type { OPEN, CLOSE }
+
+ /**
+ * The [Type] of this window request, indicating whether to open or
+ * close a window.
+ */
+ val type: Type
+
+ /**
+ * The URL which should be opened in a new window. May be
+ * empty if the request was created from JavaScript (using
+ * window.open()).
+ */
+ val url: String
+
+ /**
+ * Prepares an [EngineSession] for the window request. This is used to
+ * attach state (e.g. a native session or view) to the engine session.
+ *
+ * @return the prepared and ready-to-use [EngineSession].
+ */
+ fun prepare(): EngineSession
+
+ /**
+ * Starts the window request.
+ */
+ fun start() = Unit
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Account.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Account.kt
new file mode 100644
index 0000000000..f1a67b471f
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Account.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.concept.identitycredential
+
+import android.annotation.SuppressLint
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Represents an Identity credential account:
+ * @property id An identifier for this [Account].
+ * @property email The email associated to this [Account].
+ * @property name The name of this [Account].
+ * @property icon An icon for the [Account], normally the profile picture
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class Account(
+ val id: Int,
+ val email: String,
+ val name: String,
+ val icon: String?,
+) : Parcelable
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Provider.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Provider.kt
new file mode 100644
index 0000000000..7ebc8521b8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/identitycredential/Provider.kt
@@ -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/. */
+
+package mozilla.components.concept.identitycredential
+
+import android.annotation.SuppressLint
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Represents an Identity credential provider:
+ * @property id An identifier for this [Provider].
+ * @property icon An icon of the provider, normally the logo of the brand.
+ * @property name The name of this [Provider].
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class Provider(
+ val id: Int,
+ val icon: String?,
+ val name: String,
+ val domain: String,
+) : Parcelable
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineSessionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineSessionTest.kt
new file mode 100644
index 0000000000..440cb53cb3
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineSessionTest.kt
@@ -0,0 +1,1099 @@
+/* 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.concept.engine
+
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+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.content.blocking.Tracker
+import mozilla.components.concept.engine.history.HistoryItem
+import mozilla.components.concept.engine.mediasession.MediaSession
+import mozilla.components.concept.engine.permission.PermissionRequest
+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.TranslationOptions
+import mozilla.components.concept.engine.window.WindowRequest
+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.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoInteractions
+import org.mockito.Mockito.verifyNoMoreInteractions
+import java.lang.reflect.Modifier
+
+class EngineSessionTest {
+ private val unknownHitResult = HitResult.UNKNOWN("file://foobar")
+
+ @Test
+ fun `registered observers will be notified`() {
+ val session = spy(DummyEngineSession())
+
+ val observer = mock(EngineSession.Observer::class.java)
+ val permissionRequest = mock(PermissionRequest::class.java)
+ val windowRequest = mock(WindowRequest::class.java)
+ session.register(observer)
+
+ val mediaSessionController: MediaSession.Controller = mock()
+ val mediaSessionMetadata: MediaSession.Metadata = mock()
+ val mediaSessionFeature: MediaSession.Feature = mock()
+ val mediaSessionPositionState: MediaSession.PositionState = mock()
+ val mediaSessionElementMetadata: MediaSession.ElementMetadata = mock()
+ val tracker = Tracker("tracker")
+
+ session.notifyInternalObservers { onScrollChange(1234, 4321) }
+ session.notifyInternalObservers { onScrollChange(2345, 5432) }
+ session.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ session.notifyInternalObservers { onLocationChange("https://www.firefox.com", false) }
+ session.notifyInternalObservers { onProgress(25) }
+ session.notifyInternalObservers { onProgress(100) }
+ session.notifyInternalObservers { onLoadingStateChange(true) }
+ session.notifyInternalObservers { onSecurityChange(true, "mozilla.org", "issuer") }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(true) }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onExcludedOnTrackingProtectionChange(true) }
+ session.notifyInternalObservers { onLongPress(unknownHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(true) }
+ session.notifyInternalObservers { onFind("search") }
+ session.notifyInternalObservers { onFindResult(0, 1, true) }
+ session.notifyInternalObservers { onFullScreenChange(true) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(1) }
+ session.notifyInternalObservers { onContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onMediaActivated(mediaSessionController) }
+ session.notifyInternalObservers { onMediaDeactivated() }
+ session.notifyInternalObservers { onMediaMetadataChanged(mediaSessionMetadata) }
+ session.notifyInternalObservers { onMediaFeatureChanged(mediaSessionFeature) }
+ session.notifyInternalObservers { onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) }
+ session.notifyInternalObservers { onMediaPositionStateChanged(mediaSessionPositionState) }
+ session.notifyInternalObservers { onMediaMuteChanged(true) }
+ session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
+ session.notifyInternalObservers { onCrash() }
+ session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
+ session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null) }
+ session.notifyInternalObservers { onProcessKilled() }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+
+ verify(observer).onLocationChange("https://www.mozilla.org", false)
+ verify(observer).onLocationChange("https://www.firefox.com", false)
+ verify(observer).onScrollChange(1234, 4321)
+ verify(observer).onScrollChange(2345, 5432)
+ verify(observer).onProgress(25)
+ verify(observer).onProgress(100)
+ verify(observer).onLoadingStateChange(true)
+ verify(observer).onSecurityChange(true, "mozilla.org", "issuer")
+ verify(observer).onTrackerBlockingEnabledChange(true)
+ verify(observer).onTrackerBlocked(tracker)
+ verify(observer).onExcludedOnTrackingProtectionChange(true)
+ verify(observer).onLongPress(unknownHitResult)
+ verify(observer).onDesktopModeChange(true)
+ verify(observer).onFind("search")
+ verify(observer).onFindResult(0, 1, true)
+ verify(observer).onFullScreenChange(true)
+ verify(observer).onMetaViewportFitChanged(1)
+ verify(observer).onAppPermissionRequest(permissionRequest)
+ verify(observer).onContentPermissionRequest(permissionRequest)
+ verify(observer).onCancelContentPermissionRequest(permissionRequest)
+ verify(observer).onWindowRequest(windowRequest)
+ verify(observer).onMediaActivated(mediaSessionController)
+ verify(observer).onMediaDeactivated()
+ verify(observer).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(observer).onMediaFeatureChanged(mediaSessionFeature)
+ verify(observer).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(observer).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(observer).onMediaMuteChanged(true)
+ verify(observer).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ verify(observer).onCrash()
+ verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
+ verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null)
+ verify(observer).onProcessKilled()
+ verify(observer).onShowDynamicToolbar()
+ verifyNoMoreInteractions(observer)
+ }
+
+ @Test
+ fun `observer will not be notified after calling unregister`() {
+ val session = spy(DummyEngineSession())
+ val observer = mock(EngineSession.Observer::class.java)
+ val otherHitResult = HitResult.UNKNOWN("file://foobaz")
+ val permissionRequest = mock(PermissionRequest::class.java)
+ val otherPermissionRequest = mock(PermissionRequest::class.java)
+ val windowRequest = mock(WindowRequest::class.java)
+ val otherWindowRequest = mock(WindowRequest::class.java)
+ val tracker = Tracker("tracker")
+
+ session.register(observer)
+
+ session.notifyInternalObservers { onScrollChange(1234, 4321) }
+ session.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ session.notifyInternalObservers { onProgress(25) }
+ session.notifyInternalObservers { onLoadingStateChange(true) }
+ session.notifyInternalObservers { onSecurityChange(true, "mozilla.org", "issuer") }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(true) }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onLongPress(unknownHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(true) }
+ session.notifyInternalObservers { onFind("search") }
+ session.notifyInternalObservers { onFindResult(0, 1, true) }
+ session.notifyInternalObservers { onFullScreenChange(true) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(1) }
+ session.notifyInternalObservers { onContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onCrash() }
+ session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
+ session.notifyInternalObservers { onLaunchIntentRequest("https://www.mozilla.org", null) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+ session.unregister(observer)
+
+ val mediaSessionController: MediaSession.Controller = mock()
+ val mediaSessionMetadata: MediaSession.Metadata = mock()
+ val mediaSessionFeature: MediaSession.Feature = mock()
+ val mediaSessionPositionState: MediaSession.PositionState = mock()
+ val mediaSessionElementMetadata: MediaSession.ElementMetadata = mock()
+
+ session.notifyInternalObservers { onScrollChange(2345, 5432) }
+ session.notifyInternalObservers { onLocationChange("https://www.firefox.com", false) }
+ session.notifyInternalObservers { onProgress(100) }
+ session.notifyInternalObservers { onLoadingStateChange(false) }
+ session.notifyInternalObservers { onSecurityChange(false, "", "") }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(false) }
+ session.notifyInternalObservers { onLongPress(otherHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(false) }
+ session.notifyInternalObservers { onFind("search2") }
+ session.notifyInternalObservers { onFindResult(0, 1, false) }
+ session.notifyInternalObservers { onFullScreenChange(false) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(2) }
+ session.notifyInternalObservers { onContentPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onMediaActivated(mediaSessionController) }
+ session.notifyInternalObservers { onMediaDeactivated() }
+ session.notifyInternalObservers { onMediaMetadataChanged(mediaSessionMetadata) }
+ session.notifyInternalObservers { onMediaFeatureChanged(mediaSessionFeature) }
+ session.notifyInternalObservers { onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) }
+ session.notifyInternalObservers { onMediaPositionStateChanged(mediaSessionPositionState) }
+ session.notifyInternalObservers { onMediaMuteChanged(true) }
+ session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
+ session.notifyInternalObservers { onCrash() }
+ session.notifyInternalObservers { onLoadRequest("https://www.mozilla.org", true, true) }
+ session.notifyInternalObservers { onLaunchIntentRequest("https://www.firefox.com", null) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+
+ verify(observer).onScrollChange(1234, 4321)
+ verify(observer).onLocationChange("https://www.mozilla.org", false)
+ verify(observer).onProgress(25)
+ verify(observer).onLoadingStateChange(true)
+ verify(observer).onSecurityChange(true, "mozilla.org", "issuer")
+ verify(observer).onTrackerBlockingEnabledChange(true)
+ verify(observer).onTrackerBlocked(tracker)
+ verify(observer).onLongPress(unknownHitResult)
+ verify(observer).onDesktopModeChange(true)
+ verify(observer).onFind("search")
+ verify(observer).onFindResult(0, 1, true)
+ verify(observer).onFullScreenChange(true)
+ verify(observer).onMetaViewportFitChanged(1)
+ verify(observer).onAppPermissionRequest(permissionRequest)
+ verify(observer).onContentPermissionRequest(permissionRequest)
+ verify(observer).onCancelContentPermissionRequest(permissionRequest)
+ verify(observer).onWindowRequest(windowRequest)
+ verify(observer).onCrash()
+ verify(observer).onLoadRequest("https://www.mozilla.org", true, true)
+ verify(observer).onLaunchIntentRequest("https://www.mozilla.org", null)
+ verify(observer).onShowDynamicToolbar()
+ verify(observer, never()).onScrollChange(2345, 5432)
+ verify(observer, never()).onLocationChange("https://www.firefox.com", false)
+ verify(observer, never()).onProgress(100)
+ verify(observer, never()).onLoadingStateChange(false)
+ verify(observer, never()).onSecurityChange(false, "", "")
+ verify(observer, never()).onTrackerBlockingEnabledChange(false)
+ verify(observer, never()).onTrackerBlocked(Tracker("Tracker"))
+ verify(observer, never()).onLongPress(otherHitResult)
+ verify(observer, never()).onDesktopModeChange(false)
+ verify(observer, never()).onFind("search2")
+ verify(observer, never()).onFindResult(0, 1, false)
+ verify(observer, never()).onFullScreenChange(false)
+ verify(observer, never()).onMetaViewportFitChanged(2)
+ verify(observer, never()).onAppPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onContentPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onCancelContentPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onWindowRequest(otherWindowRequest)
+ verify(observer, never()).onMediaActivated(mediaSessionController)
+ verify(observer, never()).onMediaDeactivated()
+ verify(observer, never()).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(observer, never()).onMediaFeatureChanged(mediaSessionFeature)
+ verify(observer, never()).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(observer, never()).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(observer, never()).onMediaMuteChanged(true)
+ verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ verify(observer, never()).onLoadRequest("https://www.mozilla.org", false, true)
+ verify(observer, never()).onLaunchIntentRequest("https://www.firefox.com", null)
+ verifyNoMoreInteractions(observer)
+ }
+
+ @Test
+ fun `observers will not be notified after calling unregisterObservers`() {
+ val session = spy(DummyEngineSession())
+ val observer = mock(EngineSession.Observer::class.java)
+ val otherObserver = mock(EngineSession.Observer::class.java)
+ val permissionRequest = mock(PermissionRequest::class.java)
+ val otherPermissionRequest = mock(PermissionRequest::class.java)
+ val windowRequest = mock(WindowRequest::class.java)
+ val otherWindowRequest = mock(WindowRequest::class.java)
+ val otherHitResult = HitResult.UNKNOWN("file://foobaz")
+ val tracker = Tracker("tracker")
+
+ session.register(observer)
+ session.register(otherObserver)
+
+ session.notifyInternalObservers { onScrollChange(1234, 4321) }
+ session.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ session.notifyInternalObservers { onProgress(25) }
+ session.notifyInternalObservers { onLoadingStateChange(true) }
+ session.notifyInternalObservers { onSecurityChange(true, "mozilla.org", "issuer") }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(true) }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onLongPress(unknownHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(true) }
+ session.notifyInternalObservers { onFind("search") }
+ session.notifyInternalObservers { onFindResult(0, 1, true) }
+ session.notifyInternalObservers { onFullScreenChange(true) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(1) }
+ session.notifyInternalObservers { onContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+
+ session.unregisterObservers()
+
+ var mediaSessionController: MediaSession.Controller = mock()
+ val mediaSessionMetadata: MediaSession.Metadata = mock()
+ val mediaSessionFeature: MediaSession.Feature = mock()
+ val mediaSessionPositionState: MediaSession.PositionState = mock()
+ val mediaSessionElementMetadata: MediaSession.ElementMetadata = mock()
+
+ session.notifyInternalObservers { onScrollChange(2345, 5432) }
+ session.notifyInternalObservers { onLocationChange("https://www.firefox.com", false) }
+ session.notifyInternalObservers { onProgress(100) }
+ session.notifyInternalObservers { onLoadingStateChange(false) }
+ session.notifyInternalObservers { onSecurityChange(false, "", "") }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(false) }
+ session.notifyInternalObservers { onLongPress(otherHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(false) }
+ session.notifyInternalObservers { onFind("search2") }
+ session.notifyInternalObservers { onFindResult(0, 1, false) }
+ session.notifyInternalObservers { onFullScreenChange(false) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(2) }
+ session.notifyInternalObservers { onContentPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onMediaActivated(mediaSessionController) }
+ session.notifyInternalObservers { onMediaDeactivated() }
+ session.notifyInternalObservers { onMediaMetadataChanged(mediaSessionMetadata) }
+ session.notifyInternalObservers { onMediaFeatureChanged(mediaSessionFeature) }
+ session.notifyInternalObservers { onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) }
+ session.notifyInternalObservers { onMediaPositionStateChanged(mediaSessionPositionState) }
+ session.notifyInternalObservers { onMediaMuteChanged(true) }
+ session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+
+ verify(observer).onScrollChange(1234, 4321)
+ verify(observer).onLocationChange("https://www.mozilla.org", false)
+ verify(observer).onProgress(25)
+ verify(observer).onLoadingStateChange(true)
+ verify(observer).onSecurityChange(true, "mozilla.org", "issuer")
+ verify(observer).onTrackerBlockingEnabledChange(true)
+ verify(observer).onTrackerBlocked(tracker)
+ verify(observer).onLongPress(unknownHitResult)
+ verify(observer).onDesktopModeChange(true)
+ verify(observer).onFind("search")
+ verify(observer).onFindResult(0, 1, true)
+ verify(observer).onFullScreenChange(true)
+ verify(observer).onMetaViewportFitChanged(1)
+ verify(observer).onAppPermissionRequest(permissionRequest)
+ verify(observer).onContentPermissionRequest(permissionRequest)
+ verify(observer).onCancelContentPermissionRequest(permissionRequest)
+ verify(observer).onWindowRequest(windowRequest)
+ verify(observer).onShowDynamicToolbar()
+ verify(observer, never()).onScrollChange(2345, 5432)
+ verify(observer, never()).onLocationChange("https://www.firefox.com", false)
+ verify(observer, never()).onProgress(100)
+ verify(observer, never()).onLoadingStateChange(false)
+ verify(observer, never()).onSecurityChange(false, "", "")
+ verify(observer, never()).onTrackerBlockingEnabledChange(false)
+ verify(observer, never()).onTrackerBlocked(Tracker("Tracker"))
+ verify(observer, never()).onLongPress(otherHitResult)
+ verify(observer, never()).onDesktopModeChange(false)
+ verify(observer, never()).onFind("search2")
+ verify(observer, never()).onFindResult(0, 1, false)
+ verify(observer, never()).onFullScreenChange(false)
+ verify(observer, never()).onMetaViewportFitChanged(2)
+ verify(observer, never()).onAppPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onContentPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onCancelContentPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onWindowRequest(otherWindowRequest)
+ verify(observer, never()).onMediaActivated(mediaSessionController)
+ verify(observer, never()).onMediaDeactivated()
+ verify(observer, never()).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(observer, never()).onMediaFeatureChanged(mediaSessionFeature)
+ verify(observer, never()).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(observer, never()).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(observer, never()).onMediaMuteChanged(true)
+ verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ verify(otherObserver, never()).onScrollChange(2345, 5432)
+ verify(otherObserver, never()).onLocationChange("https://www.firefox.com", false)
+ verify(otherObserver, never()).onProgress(100)
+ verify(otherObserver, never()).onLoadingStateChange(false)
+ verify(otherObserver, never()).onSecurityChange(false, "", "")
+ verify(otherObserver, never()).onTrackerBlockingEnabledChange(false)
+ verify(otherObserver, never()).onTrackerBlocked(Tracker("Tracker"))
+ verify(otherObserver, never()).onLongPress(otherHitResult)
+ verify(otherObserver, never()).onDesktopModeChange(false)
+ verify(otherObserver, never()).onFind("search2")
+ verify(otherObserver, never()).onFindResult(0, 1, false)
+ verify(otherObserver, never()).onFullScreenChange(false)
+ verify(otherObserver, never()).onMetaViewportFitChanged(2)
+ verify(otherObserver, never()).onAppPermissionRequest(otherPermissionRequest)
+ verify(otherObserver, never()).onContentPermissionRequest(otherPermissionRequest)
+ verify(otherObserver, never()).onCancelContentPermissionRequest(otherPermissionRequest)
+ verify(otherObserver, never()).onWindowRequest(otherWindowRequest)
+ verify(otherObserver, never()).onMediaActivated(mediaSessionController)
+ verify(otherObserver, never()).onMediaDeactivated()
+ verify(otherObserver, never()).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(otherObserver, never()).onMediaFeatureChanged(mediaSessionFeature)
+ verify(otherObserver, never()).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(otherObserver, never()).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(otherObserver, never()).onMediaMuteChanged(true)
+ verify(otherObserver, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ }
+
+ @Test
+ fun `observer will not be notified after session is closed`() {
+ val session = spy(DummyEngineSession())
+ val observer = mock(EngineSession.Observer::class.java)
+ val otherHitResult = HitResult.UNKNOWN("file://foobaz")
+ val permissionRequest = mock(PermissionRequest::class.java)
+ val otherPermissionRequest = mock(PermissionRequest::class.java)
+ val windowRequest = mock(WindowRequest::class.java)
+ val otherWindowRequest = mock(WindowRequest::class.java)
+ val tracker = Tracker("tracker")
+
+ session.register(observer)
+
+ session.notifyInternalObservers { onScrollChange(1234, 4321) }
+ session.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ session.notifyInternalObservers { onProgress(25) }
+ session.notifyInternalObservers { onLoadingStateChange(true) }
+ session.notifyInternalObservers { onSecurityChange(true, "mozilla.org", "issuer") }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(true) }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onLongPress(unknownHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(true) }
+ session.notifyInternalObservers { onFind("search") }
+ session.notifyInternalObservers { onFindResult(0, 1, true) }
+ session.notifyInternalObservers { onFullScreenChange(true) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(1) }
+ session.notifyInternalObservers { onContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+
+ session.close()
+
+ var mediaSessionController: MediaSession.Controller = mock()
+ val mediaSessionMetadata: MediaSession.Metadata = mock()
+ val mediaSessionFeature: MediaSession.Feature = mock()
+ val mediaSessionPositionState: MediaSession.PositionState = mock()
+ val mediaSessionElementMetadata: MediaSession.ElementMetadata = mock()
+
+ session.notifyInternalObservers { onScrollChange(2345, 5432) }
+ session.notifyInternalObservers { onLocationChange("https://www.firefox.com", false) }
+ session.notifyInternalObservers { onProgress(100) }
+ session.notifyInternalObservers { onLoadingStateChange(false) }
+ session.notifyInternalObservers { onSecurityChange(false, "", "") }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(false) }
+ session.notifyInternalObservers { onLongPress(otherHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(false) }
+ session.notifyInternalObservers { onFind("search2") }
+ session.notifyInternalObservers { onFindResult(0, 1, false) }
+ session.notifyInternalObservers { onFullScreenChange(false) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(2) }
+ session.notifyInternalObservers { onContentPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(otherPermissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(otherWindowRequest) }
+ session.notifyInternalObservers { onMediaActivated(mediaSessionController) }
+ session.notifyInternalObservers { onMediaDeactivated() }
+ session.notifyInternalObservers { onMediaMetadataChanged(mediaSessionMetadata) }
+ session.notifyInternalObservers { onMediaFeatureChanged(mediaSessionFeature) }
+ session.notifyInternalObservers { onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) }
+ session.notifyInternalObservers { onMediaPositionStateChanged(mediaSessionPositionState) }
+ session.notifyInternalObservers { onMediaMuteChanged(true) }
+ session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+
+ verify(observer).onScrollChange(1234, 4321)
+ verify(observer).onLocationChange("https://www.mozilla.org", false)
+ verify(observer).onProgress(25)
+ verify(observer).onLoadingStateChange(true)
+ verify(observer).onSecurityChange(true, "mozilla.org", "issuer")
+ verify(observer).onTrackerBlockingEnabledChange(true)
+ verify(observer).onTrackerBlocked(tracker)
+ verify(observer).onLongPress(unknownHitResult)
+ verify(observer).onDesktopModeChange(true)
+ verify(observer).onFind("search")
+ verify(observer).onFindResult(0, 1, true)
+ verify(observer).onFullScreenChange(true)
+ verify(observer).onMetaViewportFitChanged(1)
+ verify(observer).onAppPermissionRequest(permissionRequest)
+ verify(observer).onContentPermissionRequest(permissionRequest)
+ verify(observer).onCancelContentPermissionRequest(permissionRequest)
+ verify(observer).onWindowRequest(windowRequest)
+ verify(observer).onShowDynamicToolbar()
+ verify(observer, never()).onScrollChange(2345, 5432)
+ verify(observer, never()).onLocationChange("https://www.firefox.com", false)
+ verify(observer, never()).onProgress(100)
+ verify(observer, never()).onLoadingStateChange(false)
+ verify(observer, never()).onSecurityChange(false, "", "")
+ verify(observer, never()).onTrackerBlockingEnabledChange(false)
+ verify(observer, never()).onTrackerBlocked(Tracker("Tracker"))
+ verify(observer, never()).onLongPress(otherHitResult)
+ verify(observer, never()).onDesktopModeChange(false)
+ verify(observer, never()).onFind("search2")
+ verify(observer, never()).onFindResult(0, 1, false)
+ verify(observer, never()).onFullScreenChange(false)
+ verify(observer, never()).onMetaViewportFitChanged(2)
+ verify(observer, never()).onAppPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onContentPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onCancelContentPermissionRequest(otherPermissionRequest)
+ verify(observer, never()).onWindowRequest(otherWindowRequest)
+ verify(observer, never()).onMediaActivated(mediaSessionController)
+ verify(observer, never()).onMediaDeactivated()
+ verify(observer, never()).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(observer, never()).onMediaFeatureChanged(mediaSessionFeature)
+ verify(observer, never()).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(observer, never()).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(observer, never()).onMediaMuteChanged(true)
+ verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ verifyNoMoreInteractions(observer)
+ }
+
+ @Test
+ fun `registered observers are instance specific`() {
+ val session = spy(DummyEngineSession())
+ val otherSession = spy(DummyEngineSession())
+ val permissionRequest = mock(PermissionRequest::class.java)
+ val windowRequest = mock(WindowRequest::class.java)
+ val observer = mock(EngineSession.Observer::class.java)
+ val tracker = Tracker("tracker")
+ var mediaSessionController: MediaSession.Controller = mock()
+ val mediaSessionMetadata: MediaSession.Metadata = mock()
+ val mediaSessionFeature: MediaSession.Feature = mock()
+ val mediaSessionPositionState: MediaSession.PositionState = mock()
+ val mediaSessionElementMetadata: MediaSession.ElementMetadata = mock()
+ session.register(observer)
+
+ otherSession.notifyInternalObservers { onScrollChange(1234, 4321) }
+ otherSession.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ otherSession.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ otherSession.notifyInternalObservers { onProgress(25) }
+ otherSession.notifyInternalObservers { onLoadingStateChange(true) }
+ otherSession.notifyInternalObservers { onSecurityChange(true, "mozilla.org", "issuer") }
+ otherSession.notifyInternalObservers { onTrackerBlockingEnabledChange(true) }
+ otherSession.notifyInternalObservers { onTrackerBlocked(tracker) }
+ otherSession.notifyInternalObservers { onLongPress(unknownHitResult) }
+ otherSession.notifyInternalObservers { onDesktopModeChange(true) }
+ otherSession.notifyInternalObservers { onFind("search") }
+ otherSession.notifyInternalObservers { onFindResult(0, 1, true) }
+ otherSession.notifyInternalObservers { onFullScreenChange(true) }
+ otherSession.notifyInternalObservers { onMetaViewportFitChanged(1) }
+ otherSession.notifyInternalObservers { onContentPermissionRequest(permissionRequest) }
+ otherSession.notifyInternalObservers { onCancelContentPermissionRequest(permissionRequest) }
+ otherSession.notifyInternalObservers { onAppPermissionRequest(permissionRequest) }
+ otherSession.notifyInternalObservers { onWindowRequest(windowRequest) }
+ otherSession.notifyInternalObservers { onMediaActivated(mediaSessionController) }
+ otherSession.notifyInternalObservers { onMediaDeactivated() }
+ otherSession.notifyInternalObservers { onMediaMetadataChanged(mediaSessionMetadata) }
+ otherSession.notifyInternalObservers { onMediaFeatureChanged(mediaSessionFeature) }
+ otherSession.notifyInternalObservers { onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) }
+ otherSession.notifyInternalObservers { onMediaPositionStateChanged(mediaSessionPositionState) }
+ otherSession.notifyInternalObservers { onMediaMuteChanged(true) }
+ otherSession.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
+ otherSession.notifyInternalObservers { onShowDynamicToolbar() }
+ verify(observer, never()).onScrollChange(1234, 4321)
+ verify(observer, never()).onLocationChange("https://www.mozilla.org", false)
+ verify(observer, never()).onProgress(25)
+ verify(observer, never()).onLoadingStateChange(true)
+ verify(observer, never()).onSecurityChange(true, "mozilla.org", "issuer")
+ verify(observer, never()).onTrackerBlockingEnabledChange(true)
+ verify(observer, never()).onTrackerBlocked(tracker)
+ verify(observer, never()).onLongPress(unknownHitResult)
+ verify(observer, never()).onDesktopModeChange(true)
+ verify(observer, never()).onFind("search")
+ verify(observer, never()).onFindResult(0, 1, true)
+ verify(observer, never()).onFullScreenChange(true)
+ verify(observer, never()).onMetaViewportFitChanged(1)
+ verify(observer, never()).onAppPermissionRequest(permissionRequest)
+ verify(observer, never()).onContentPermissionRequest(permissionRequest)
+ verify(observer, never()).onCancelContentPermissionRequest(permissionRequest)
+ verify(observer, never()).onWindowRequest(windowRequest)
+ verify(observer, never()).onMediaActivated(mediaSessionController)
+ verify(observer, never()).onMediaDeactivated()
+ verify(observer, never()).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(observer, never()).onMediaFeatureChanged(mediaSessionFeature)
+ verify(observer, never()).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(observer, never()).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(observer, never()).onMediaMuteChanged(true)
+ verify(observer, never()).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ verify(observer, never()).onShowDynamicToolbar()
+
+ session.notifyInternalObservers { onScrollChange(1234, 4321) }
+ session.notifyInternalObservers { onLocationChange("https://www.mozilla.org", false) }
+ session.notifyInternalObservers { onProgress(25) }
+ session.notifyInternalObservers { onLoadingStateChange(true) }
+ session.notifyInternalObservers { onSecurityChange(true, "mozilla.org", "issuer") }
+ session.notifyInternalObservers { onTrackerBlockingEnabledChange(true) }
+ session.notifyInternalObservers { onTrackerBlocked(tracker) }
+ session.notifyInternalObservers { onLongPress(unknownHitResult) }
+ session.notifyInternalObservers { onDesktopModeChange(false) }
+ session.notifyInternalObservers { onFind("search") }
+ session.notifyInternalObservers { onFindResult(0, 1, true) }
+ session.notifyInternalObservers { onFullScreenChange(true) }
+ session.notifyInternalObservers { onMetaViewportFitChanged(1) }
+ session.notifyInternalObservers { onContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onCancelContentPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onAppPermissionRequest(permissionRequest) }
+ session.notifyInternalObservers { onWindowRequest(windowRequest) }
+ session.notifyInternalObservers { onMediaActivated(mediaSessionController) }
+ session.notifyInternalObservers { onMediaDeactivated() }
+ session.notifyInternalObservers { onMediaMetadataChanged(mediaSessionMetadata) }
+ session.notifyInternalObservers { onMediaFeatureChanged(mediaSessionFeature) }
+ session.notifyInternalObservers { onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING) }
+ session.notifyInternalObservers { onMediaPositionStateChanged(mediaSessionPositionState) }
+ session.notifyInternalObservers { onMediaMuteChanged(true) }
+ session.notifyInternalObservers { onMediaFullscreenChanged(true, mediaSessionElementMetadata) }
+ session.notifyInternalObservers { onShowDynamicToolbar() }
+ verify(observer, times(1)).onScrollChange(1234, 4321)
+ verify(observer, times(1)).onLocationChange("https://www.mozilla.org", false)
+ verify(observer, times(1)).onProgress(25)
+ verify(observer, times(1)).onLoadingStateChange(true)
+ verify(observer, times(1)).onSecurityChange(true, "mozilla.org", "issuer")
+ verify(observer, times(1)).onTrackerBlockingEnabledChange(true)
+ verify(observer, times(1)).onTrackerBlocked(tracker)
+ verify(observer, times(1)).onLongPress(unknownHitResult)
+ verify(observer, times(1)).onDesktopModeChange(false)
+ verify(observer, times(1)).onFind("search")
+ verify(observer, times(1)).onFindResult(0, 1, true)
+ verify(observer, times(1)).onFullScreenChange(true)
+ verify(observer, times(1)).onMetaViewportFitChanged(1)
+ verify(observer, times(1)).onAppPermissionRequest(permissionRequest)
+ verify(observer, times(1)).onContentPermissionRequest(permissionRequest)
+ verify(observer, times(1)).onCancelContentPermissionRequest(permissionRequest)
+ verify(observer, times(1)).onWindowRequest(windowRequest)
+ verify(observer, times(1)).onMediaActivated(mediaSessionController)
+ verify(observer, times(1)).onMediaDeactivated()
+ verify(observer, times(1)).onMediaMetadataChanged(mediaSessionMetadata)
+ verify(observer, times(1)).onMediaFeatureChanged(mediaSessionFeature)
+ verify(observer, times(1)).onMediaPlaybackStateChanged(MediaSession.PlaybackState.PLAYING)
+ verify(observer, times(1)).onMediaPositionStateChanged(mediaSessionPositionState)
+ verify(observer, times(1)).onMediaMuteChanged(true)
+ verify(observer, times(1)).onMediaFullscreenChanged(true, mediaSessionElementMetadata)
+ verify(observer, times(1)).onShowDynamicToolbar()
+ verifyNoMoreInteractions(observer)
+ }
+
+ @Test
+ fun `all HitResults are supported`() {
+ val session = spy(DummyEngineSession())
+ val observer = mock(EngineSession.Observer::class.java)
+ session.register(observer)
+
+ var hitResult: HitResult = HitResult.UNKNOWN("file://foobaz")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.EMAIL("mailto:asa@mozilla.com")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.PHONE("tel:+1234567890")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.IMAGE_SRC("file.png", "https://mozilla.org")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.IMAGE("file.png")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.AUDIO("file.mp3")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.GEO("geo:1,-1")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+
+ hitResult = HitResult.VIDEO("file.mp4")
+ session.notifyInternalObservers { onLongPress(hitResult) }
+ verify(observer, times(1)).onLongPress(hitResult)
+ }
+
+ @Test
+ fun `registered observer will be notified about download`() {
+ val session = spy(DummyEngineSession())
+
+ val observer = mock(EngineSession.Observer::class.java)
+ session.register(observer)
+
+ session.notifyInternalObservers {
+ onExternalResource(
+ url = "https://download.mozilla.org",
+ fileName = "firefox.apk",
+ contentLength = 1927392,
+ contentType = "application/vnd.android.package-archive",
+ cookie = "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;",
+ isPrivate = true,
+ skipConfirmation = false,
+ openInApp = false,
+ userAgent = "Components/1.0",
+ )
+ }
+
+ verify(observer).onExternalResource(
+ url = "https://download.mozilla.org",
+ fileName = "firefox.apk",
+ contentLength = 1927392,
+ contentType = "application/vnd.android.package-archive",
+ cookie = "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;",
+ isPrivate = true,
+ skipConfirmation = false,
+ openInApp = false,
+ userAgent = "Components/1.0",
+ )
+ }
+
+ @Test
+ fun `registered observer will be notified about history state`() {
+ val session = spy(DummyEngineSession())
+
+ val observer = mock(EngineSession.Observer::class.java)
+ session.register(observer)
+
+ session.notifyInternalObservers {
+ onHistoryStateChanged(
+ listOf(HistoryItem("Firefox download", "https://download.mozilla.org")),
+ currentIndex = 0,
+ )
+ }
+
+ verify(observer).onHistoryStateChanged(
+ historyList = listOf(
+ HistoryItem("Firefox download", "https://download.mozilla.org"),
+ ),
+ currentIndex = 0,
+ )
+ }
+
+ @Test
+ fun `tracking protection policies have correct categories`() {
+ val recommendedPolicy = TrackingProtectionPolicy.recommended()
+
+ assertEquals(
+ recommendedPolicy.trackingCategories.sumOf { it.id },
+ TrackingCategory.RECOMMENDED.id,
+ )
+
+ assertEquals(recommendedPolicy.cookiePolicy.id, CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS.id)
+ assertEquals(recommendedPolicy.cookiePolicyPrivateMode.id, recommendedPolicy.cookiePolicy.id)
+
+ val strictPolicy = TrackingProtectionPolicy.strict()
+
+ assertEquals(
+ strictPolicy.trackingCategories.sumOf { it.id },
+ TrackingCategory.STRICT.id,
+ )
+
+ assertEquals(strictPolicy.cookiePolicy.id, CookiePolicy.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS.id)
+ assertEquals(strictPolicy.cookiePolicyPrivateMode.id, strictPolicy.cookiePolicy.id)
+
+ val nonePolicy = TrackingProtectionPolicy.none()
+
+ assertEquals(
+ nonePolicy.trackingCategories.sumOf { it.id },
+ TrackingCategory.NONE.id,
+ )
+
+ assertEquals(nonePolicy.cookiePolicy.id, CookiePolicy.ACCEPT_ALL.id)
+ assertEquals(nonePolicy.cookiePolicyPrivateMode.id, CookiePolicy.ACCEPT_ALL.id)
+
+ val newPolicy = TrackingProtectionPolicy.select(
+ trackingCategories = arrayOf(
+ TrackingCategory.AD,
+ TrackingCategory.SOCIAL,
+ TrackingCategory.ANALYTICS,
+ TrackingCategory.CONTENT,
+ TrackingCategory.CRYPTOMINING,
+ TrackingCategory.FINGERPRINTING,
+ TrackingCategory.TEST,
+ ),
+ )
+
+ assertEquals(
+ newPolicy.trackingCategories.sumOf { it.id },
+ arrayOf(
+ TrackingCategory.AD,
+ TrackingCategory.SOCIAL,
+ TrackingCategory.ANALYTICS,
+ TrackingCategory.CONTENT,
+ TrackingCategory.CRYPTOMINING,
+ TrackingCategory.FINGERPRINTING,
+ TrackingCategory.TEST,
+ ).sumOf { it.id },
+ )
+ }
+
+ @Test
+ fun `tracking protection policies can be specified for session type`() {
+ val all = TrackingProtectionPolicy.strict()
+ val selected = TrackingProtectionPolicy.select(
+ trackingCategories = arrayOf(TrackingCategory.AD),
+
+ )
+
+ // Tracking protection policies should be applied to all sessions by default
+ assertTrue(all.useForPrivateSessions)
+ assertTrue(all.useForRegularSessions)
+ assertTrue(selected.useForPrivateSessions)
+ assertTrue(selected.useForRegularSessions)
+
+ val allForPrivate = TrackingProtectionPolicy.strict().forPrivateSessionsOnly()
+ assertTrue(allForPrivate.useForPrivateSessions)
+ assertFalse(allForPrivate.useForRegularSessions)
+
+ val selectedForRegular =
+ TrackingProtectionPolicy.select(trackingCategories = arrayOf(TrackingCategory.AD))
+ .forRegularSessionsOnly()
+
+ assertTrue(selectedForRegular.useForRegularSessions)
+ assertFalse(selectedForRegular.useForPrivateSessions)
+ }
+
+ @Test
+ fun `load flags can be selected`() {
+ assertEquals(LoadUrlFlags.NONE, LoadUrlFlags.none().value)
+ assertEquals(LoadUrlFlags.ALL, LoadUrlFlags.all().value)
+ assertEquals(LoadUrlFlags.EXTERNAL, LoadUrlFlags.external().value)
+
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.BYPASS_CACHE).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.BYPASS_PROXY).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.EXTERNAL).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.ALLOW_POPUPS).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.BYPASS_CLASSIFIER).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.LOAD_FLAGS_FORCE_ALLOW_DATA_URI).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.LOAD_FLAGS_REPLACE_HISTORY).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.ALLOW_ADDITIONAL_HEADERS).value))
+ assertTrue(LoadUrlFlags.all().contains(LoadUrlFlags.select(LoadUrlFlags.ALLOW_JAVASCRIPT_URL).value))
+
+ val flags = LoadUrlFlags.select(LoadUrlFlags.EXTERNAL)
+ assertTrue(flags.contains(LoadUrlFlags.EXTERNAL))
+ assertFalse(flags.contains(LoadUrlFlags.NONE))
+ assertFalse(flags.contains(LoadUrlFlags.ALLOW_POPUPS))
+ assertFalse(flags.contains(LoadUrlFlags.BYPASS_CACHE))
+ assertFalse(flags.contains(LoadUrlFlags.BYPASS_CLASSIFIER))
+ assertFalse(flags.contains(LoadUrlFlags.BYPASS_PROXY))
+ assertFalse(flags.contains(LoadUrlFlags.LOAD_FLAGS_FORCE_ALLOW_DATA_URI))
+ assertFalse(flags.contains(LoadUrlFlags.LOAD_FLAGS_REPLACE_HISTORY))
+ assertFalse(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 `engine observer has default methods`() {
+ val defaultObserver = object : EngineSession.Observer {}
+
+ defaultObserver.onTitleChange("")
+ defaultObserver.onScrollChange(0, 0)
+ defaultObserver.onLocationChange("", false)
+ defaultObserver.onPreviewImageChange("")
+ defaultObserver.onLongPress(HitResult.UNKNOWN(""))
+ defaultObserver.onExternalResource("", "")
+ defaultObserver.onDesktopModeChange(true)
+ defaultObserver.onSecurityChange(true)
+ defaultObserver.onTrackerBlocked(mock())
+ defaultObserver.onTrackerLoaded(mock())
+ defaultObserver.onTrackerBlockingEnabledChange(true)
+ defaultObserver.onExcludedOnTrackingProtectionChange(true)
+ defaultObserver.onFindResult(0, 0, false)
+ defaultObserver.onFind("text")
+ defaultObserver.onExternalResource("", "")
+ defaultObserver.onNavigationStateChange()
+ defaultObserver.onProgress(123)
+ defaultObserver.onLoadingStateChange(true)
+ defaultObserver.onFullScreenChange(true)
+ defaultObserver.onMetaViewportFitChanged(1)
+ defaultObserver.onAppPermissionRequest(mock(PermissionRequest::class.java))
+ defaultObserver.onContentPermissionRequest(mock(PermissionRequest::class.java))
+ defaultObserver.onCancelContentPermissionRequest(mock(PermissionRequest::class.java))
+ defaultObserver.onWindowRequest(mock(WindowRequest::class.java))
+ defaultObserver.onCrash()
+ defaultObserver.onShowDynamicToolbar()
+ }
+
+ @Test
+ fun `engine doesn't notify observers of clear data`() {
+ val session = spy(DummyEngineSession())
+ val observer = mock(EngineSession.Observer::class.java)
+ session.register(observer)
+
+ session.clearData()
+
+ verifyNoInteractions(observer)
+ }
+
+ @Test
+ fun `trackingProtectionPolicy contains should work with compound categories`() {
+ val recommendedPolicy = TrackingProtectionPolicy.recommended()
+
+ assertTrue(recommendedPolicy.contains(TrackingCategory.RECOMMENDED))
+ assertTrue(recommendedPolicy.contains(TrackingCategory.AD))
+ assertTrue(recommendedPolicy.contains(TrackingCategory.ANALYTICS))
+ assertTrue(recommendedPolicy.contains(TrackingCategory.SOCIAL))
+ assertTrue(recommendedPolicy.contains(TrackingCategory.TEST))
+ assertTrue(recommendedPolicy.contains(TrackingCategory.FINGERPRINTING))
+
+ assertTrue(recommendedPolicy.contains(TrackingCategory.CRYPTOMINING))
+ assertFalse(recommendedPolicy.contains(TrackingCategory.CONTENT))
+
+ val strictPolicy = TrackingProtectionPolicy.strict()
+
+ assertTrue(strictPolicy.contains(TrackingCategory.RECOMMENDED))
+ assertTrue(strictPolicy.contains(TrackingCategory.AD))
+ assertTrue(strictPolicy.contains(TrackingCategory.ANALYTICS))
+ assertTrue(strictPolicy.contains(TrackingCategory.SOCIAL))
+ assertTrue(strictPolicy.contains(TrackingCategory.TEST))
+ assertTrue(strictPolicy.contains(TrackingCategory.CRYPTOMINING))
+ assertFalse(strictPolicy.contains(TrackingCategory.CONTENT))
+ }
+
+ @Test
+ fun `TrackingSessionPolicies retain all expected fields during privacy transformations`() {
+ val strict = TrackingProtectionPolicy.strict()
+ val default = TrackingProtectionPolicy.recommended()
+ val customNormal = TrackingProtectionPolicy.select(
+ trackingCategories = emptyArray(),
+ cookiePolicy = CookiePolicy.ACCEPT_ONLY_FIRST_PARTY,
+ strictSocialTrackingProtection = true,
+ )
+ val customPrivate = TrackingProtectionPolicy.select(
+ trackingCategories = emptyArray(),
+ cookiePolicy = CookiePolicy.ACCEPT_ONLY_FIRST_PARTY,
+ strictSocialTrackingProtection = false,
+ )
+ val changedFields = listOf("useForPrivateSessions", "useForRegularSessions")
+
+ fun checkSavedFields(expect: TrackingProtectionPolicy, actual: TrackingProtectionPolicy) {
+ TrackingProtectionPolicy::class.java.declaredMethods
+ .filter { method -> changedFields.all { !method.name.lowercase().contains(it.lowercase()) } }
+ .filter { it.parameterCount == 0 } // Only keep getters
+ .filter { it.modifiers and Modifier.PUBLIC != 0 }
+ .filter { it.modifiers and Modifier.STATIC == 0 }
+ .forEach {
+ assertEquals(it.invoke(expect), it.invoke(actual))
+ }
+ }
+
+ listOf(
+ strict,
+ default,
+ customNormal,
+ ).forEach {
+ checkSavedFields(it, it.forRegularSessionsOnly())
+ }
+
+ checkSavedFields(customPrivate, customPrivate.forPrivateSessionsOnly())
+ }
+
+ @Test
+ fun `engine session observer has default methods`() {
+ val observer = object : EngineSession.Observer { }
+ val permissionRequest = mock(PermissionRequest::class.java)
+ val windowRequest = mock(WindowRequest::class.java)
+ val tracker: Tracker = mock()
+
+ observer.onScrollChange(1234, 4321)
+ observer.onLocationChange("https://www.mozilla.org", false)
+ observer.onLocationChange("https://www.firefox.com", false)
+ observer.onProgress(25)
+ observer.onProgress(100)
+ observer.onLoadingStateChange(true)
+ observer.onSecurityChange(true, "mozilla.org", "issuer")
+ observer.onTrackerBlockingEnabledChange(true)
+ observer.onTrackerBlocked(tracker)
+ observer.onExcludedOnTrackingProtectionChange(true)
+ observer.onLongPress(unknownHitResult)
+ observer.onDesktopModeChange(true)
+ observer.onFind("search")
+ observer.onFindResult(0, 1, true)
+ observer.onFullScreenChange(true)
+ observer.onMetaViewportFitChanged(1)
+ observer.onContentPermissionRequest(permissionRequest)
+ observer.onCancelContentPermissionRequest(permissionRequest)
+ observer.onAppPermissionRequest(permissionRequest)
+ observer.onWindowRequest(windowRequest)
+ observer.onCrash()
+ observer.onLoadRequest("https://www.mozilla.org", true, true)
+ observer.onLaunchIntentRequest("https://www.mozilla.org", null)
+ observer.onProcessKilled()
+ observer.onShowDynamicToolbar()
+ }
+}
+
+open class DummyEngineSession : EngineSession() {
+ override val settings: Settings
+ get() = mock(Settings::class.java)
+
+ override fun restoreState(state: EngineSessionState): Boolean { return false }
+
+ override fun loadUrl(
+ url: String,
+ parent: EngineSession?,
+ flags: LoadUrlFlags,
+ additionalHeaders: Map<String, String>?,
+ ) {}
+
+ override fun loadData(data: String, mimeType: String, encoding: String) {}
+
+ override fun requestPdfToDownload() {}
+
+ override fun requestPrintContent() {}
+
+ override fun stopLoading() {}
+
+ override fun reload(flags: LoadUrlFlags) {}
+
+ override fun goBack(userInteraction: Boolean) {}
+
+ override fun goForward(userInteraction: Boolean) {}
+
+ override fun goToHistoryIndex(index: Int) {}
+
+ override fun updateTrackingProtection(policy: TrackingProtectionPolicy) {}
+
+ override fun toggleDesktopMode(enable: Boolean, reload: Boolean) {}
+
+ override fun hasCookieBannerRuleForSession(
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun checkForPdfViewer(
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun requestProductRecommendations(
+ url: String,
+ onResult: (List<ProductRecommendation>) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun requestProductAnalysis(
+ url: String,
+ onResult: (ProductAnalysis) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun reanalyzeProduct(
+ url: String,
+ onResult: (String) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun requestAnalysisStatus(
+ url: String,
+ onResult: (ProductAnalysisStatus) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun sendClickAttributionEvent(
+ aid: String,
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun sendImpressionAttributionEvent(
+ aid: String,
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun sendPlacementAttributionEvent(
+ aid: String,
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun reportBackInStock(
+ url: String,
+ onResult: (String) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun requestTranslate(
+ fromLanguage: String,
+ toLanguage: String,
+ options: TranslationOptions?,
+ ) {}
+
+ override fun requestTranslationRestore() {}
+
+ override fun getNeverTranslateSiteSetting(
+ onResult: (Boolean) -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun setNeverTranslateSiteSetting(
+ setting: Boolean,
+ onResult: () -> Unit,
+ onException: (Throwable) -> Unit,
+ ) {}
+
+ override fun findAll(text: String) {}
+
+ override fun findNext(forward: Boolean) {}
+
+ override fun clearFindMatches() {}
+
+ override fun exitFullScreenMode() {}
+
+ override fun purgeHistory() {}
+
+ // Helper method to access the protected method from test cases.
+ fun notifyInternalObservers(block: Observer.() -> Unit) {
+ notifyObservers(block)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineTest.kt
new file mode 100644
index 0000000000..2449245343
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineTest.kt
@@ -0,0 +1,140 @@
+/* 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.concept.engine
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.JsonReader
+import mozilla.components.concept.base.profiler.Profiler
+import mozilla.components.concept.engine.Engine.BrowsingData
+import mozilla.components.concept.engine.utils.EngineVersion
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.lang.UnsupportedOperationException
+
+class EngineTest {
+
+ private val testEngine = object : Engine {
+ override val version: EngineVersion
+ get() = throw NotImplementedError("Not needed for test")
+
+ override fun createView(context: Context, attrs: AttributeSet?): EngineView {
+ throw NotImplementedError("Not needed for test")
+ }
+
+ override fun createSession(private: Boolean, contextId: String?): EngineSession {
+ throw NotImplementedError("Not needed for test")
+ }
+
+ override fun createSessionState(json: JSONObject): EngineSessionState {
+ throw NotImplementedError("Not needed for test")
+ }
+
+ override fun createSessionStateFrom(reader: JsonReader): EngineSessionState {
+ throw NotImplementedError("Not needed for test")
+ }
+
+ override fun name(): String {
+ throw NotImplementedError("Not needed for test")
+ }
+
+ override fun speculativeConnect(url: String) {
+ throw NotImplementedError("Not needed for test")
+ }
+
+ override val profiler: Profiler?
+ get() = throw NotImplementedError("Not needed for test")
+
+ override val settings: Settings
+ get() = throw NotImplementedError("Not needed for test")
+ }
+
+ @Test
+ fun `invokes default functions on trackingProtectionExceptionStore`() {
+ var wasExecuted = false
+ try {
+ testEngine.trackingProtectionExceptionStore
+ } catch (_: Exception) {
+ wasExecuted = true
+ }
+ assertTrue(wasExecuted)
+ }
+
+ @Test
+ fun `invokes error callback if webextensions not supported`() {
+ var exception: Throwable? = null
+ testEngine.installWebExtension("resource://path", onError = { e -> exception = e })
+ assertNotNull(exception)
+ assertTrue(exception is UnsupportedOperationException)
+
+ exception = null
+ testEngine.installBuiltInWebExtension("a-built-in", "resource://path", onError = { e -> exception = e })
+ assertNotNull(exception)
+ assertTrue(exception is UnsupportedOperationException)
+
+ exception = null
+ testEngine.listInstalledWebExtensions(onSuccess = { }, onError = { e -> exception = e })
+ assertNotNull(exception)
+ assertTrue(exception is UnsupportedOperationException)
+ }
+
+ @Test
+ fun `invokes error callback if clear data not supported`() {
+ var exception: Throwable? = null
+ testEngine.clearData(Engine.BrowsingData.all(), onError = { exception = it })
+ assertNotNull(exception)
+ assertTrue(exception is UnsupportedOperationException)
+ }
+
+ @Test
+ fun `browsing data types can be combined`() {
+ assertEquals(BrowsingData.ALL, BrowsingData.all().types)
+ assertTrue(BrowsingData.all().contains(BrowsingData.NETWORK_CACHE))
+ assertTrue(BrowsingData.all().contains(BrowsingData.IMAGE_CACHE))
+ assertTrue(BrowsingData.all().contains(BrowsingData.PERMISSIONS))
+ assertTrue(BrowsingData.all().contains(BrowsingData.DOM_STORAGES))
+ assertTrue(BrowsingData.all().contains(BrowsingData.COOKIES))
+ assertTrue(BrowsingData.all().contains(BrowsingData.AUTH_SESSIONS))
+ assertTrue(BrowsingData.all().contains(BrowsingData.allSiteSettings().types))
+ assertTrue(BrowsingData.all().contains(BrowsingData.allSiteData().types))
+ assertTrue(BrowsingData.all().contains(BrowsingData.allCaches().types))
+
+ assertEquals(BrowsingData.ALL_CACHES, BrowsingData.allCaches().types)
+ assertTrue(BrowsingData.allCaches().contains(BrowsingData.NETWORK_CACHE))
+ assertTrue(BrowsingData.allCaches().contains(BrowsingData.IMAGE_CACHE))
+ assertFalse(BrowsingData.allCaches().contains(BrowsingData.PERMISSIONS))
+ assertFalse(BrowsingData.allCaches().contains(BrowsingData.AUTH_SESSIONS))
+ assertFalse(BrowsingData.allCaches().contains(BrowsingData.COOKIES))
+ assertFalse(BrowsingData.allCaches().contains(BrowsingData.DOM_STORAGES))
+
+ assertEquals(BrowsingData.ALL_SITE_DATA, BrowsingData.allSiteData().types)
+ assertTrue(BrowsingData.allSiteData().contains(BrowsingData.NETWORK_CACHE))
+ assertTrue(BrowsingData.allSiteData().contains(BrowsingData.IMAGE_CACHE))
+ assertTrue(BrowsingData.allSiteData().contains(BrowsingData.PERMISSIONS))
+ assertTrue(BrowsingData.allSiteData().contains(BrowsingData.DOM_STORAGES))
+ assertTrue(BrowsingData.allSiteData().contains(BrowsingData.COOKIES))
+ assertFalse(BrowsingData.allSiteData().contains(BrowsingData.AUTH_SESSIONS))
+
+ assertEquals(BrowsingData.ALL_SITE_SETTINGS, BrowsingData.allSiteSettings().types)
+ assertTrue(BrowsingData.allSiteSettings().contains(BrowsingData.PERMISSIONS))
+ assertFalse(BrowsingData.allSiteSettings().contains(BrowsingData.IMAGE_CACHE))
+ assertFalse(BrowsingData.allSiteSettings().contains(BrowsingData.NETWORK_CACHE))
+ assertFalse(BrowsingData.allSiteSettings().contains(BrowsingData.AUTH_SESSIONS))
+ assertFalse(BrowsingData.allSiteSettings().contains(BrowsingData.COOKIES))
+ assertFalse(BrowsingData.allSiteSettings().contains(BrowsingData.DOM_STORAGES))
+
+ val browsingData = BrowsingData.select(BrowsingData.COOKIES, BrowsingData.DOM_STORAGES)
+ assertTrue(browsingData.contains(BrowsingData.COOKIES))
+ assertTrue(browsingData.contains(BrowsingData.DOM_STORAGES))
+ assertFalse(browsingData.contains(BrowsingData.AUTH_SESSIONS))
+ assertFalse(browsingData.contains(BrowsingData.IMAGE_CACHE))
+ assertFalse(browsingData.contains(BrowsingData.NETWORK_CACHE))
+ assertFalse(browsingData.contains(BrowsingData.PERMISSIONS))
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.kt
new file mode 100644
index 0000000000..9b58d9bcfc
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.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.concept.engine
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.widget.FrameLayout
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.engine.selection.SelectionActionDelegate
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class EngineViewTest {
+
+ @Test
+ fun `asView method returns underlying Android view`() {
+ val engineView = createDummyEngineView(testContext)
+
+ val view = engineView.asView()
+
+ assertTrue(view is FrameLayout)
+ }
+
+ @Test(expected = ClassCastException::class)
+ fun `asView method fails if class is not a view`() {
+ val engineView = BrokenEngineView()
+ engineView.asView()
+ }
+
+ @Test
+ fun lifecycleObserver() {
+ val engineView = spy(createDummyEngineView(testContext))
+ val observer = LifecycleObserver(engineView)
+
+ observer.onCreate(mock())
+ verify(engineView).onCreate()
+
+ observer.onDestroy(mock())
+ verify(engineView).onDestroy()
+
+ observer.onStart(mock())
+ verify(engineView).onStart()
+
+ observer.onStop(mock())
+ verify(engineView).onStop()
+
+ observer.onPause(mock())
+ verify(engineView).onPause()
+
+ observer.onResume(mock())
+ verify(engineView).onResume()
+ }
+
+ private fun createDummyEngineView(context: Context): EngineView = DummyEngineView(context)
+
+ open class DummyEngineView(context: Context) : FrameLayout(context), EngineView {
+ override fun setVerticalClipping(clippingHeight: Int) {}
+ override fun setDynamicToolbarMaxHeight(height: Int) {}
+ override fun setActivityContext(context: Context?) {}
+ override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) = Unit
+ override fun render(session: EngineSession) {}
+ override fun release() {}
+ override var selectionActionDelegate: SelectionActionDelegate? = null
+ }
+
+ // Class it not actually a View!
+ open class BrokenEngineView : EngineView {
+ override fun setVerticalClipping(clippingHeight: Int) {}
+ override fun setDynamicToolbarMaxHeight(height: Int) {}
+ override fun setActivityContext(context: Context?) {}
+ override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) = Unit
+ override fun render(session: EngineSession) {}
+ override fun release() {}
+ override var selectionActionDelegate: SelectionActionDelegate? = null
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/HitResultTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/HitResultTest.kt
new file mode 100644
index 0000000000..547463fd8b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/HitResultTest.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.concept.engine
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HitResultTest {
+ @Test
+ fun constructor() {
+ var result: HitResult = HitResult.UNKNOWN("file://foobar")
+ assertTrue(result is HitResult.UNKNOWN)
+ assertEquals(result.src, "file://foobar")
+
+ result = HitResult.IMAGE("https://mozilla.org/i.png")
+ assertEquals(result.src, "https://mozilla.org/i.png")
+
+ result = HitResult.IMAGE_SRC("https://mozilla.org/i.png", "https://firefox.com")
+ assertEquals(result.src, "https://mozilla.org/i.png")
+ assertEquals(result.uri, "https://firefox.com")
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt
new file mode 100644
index 0000000000..9793fed6f8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt
@@ -0,0 +1,474 @@
+/* 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.concept.engine
+
+import mozilla.components.concept.engine.InputResultDetail.Companion.INPUT_HANDLED_CONTENT_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.INPUT_HANDLED_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.INPUT_UNHANDLED_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.INPUT_UNKNOWN_HANDLING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.OVERSCROLL_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.SCROLL_BOTTOM_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.SCROLL_LEFT_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.SCROLL_RIGHT_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.SCROLL_TOP_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.SCROLL_TOSTRING_DESCRIPTION
+import mozilla.components.concept.engine.InputResultDetail.Companion.TOSTRING_SEPARATOR
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class InputResultDetailTest {
+ private lateinit var inputResultDetail: InputResultDetail
+
+ @Before
+ fun setup() {
+ inputResultDetail = InputResultDetail.newInstance()
+ }
+
+ @Test
+ fun `GIVEN InputResultDetail WHEN newInstance() is called with default parameters THEN a new instance with default values is returned`() {
+ assertEquals(INPUT_HANDLING_UNKNOWN, inputResultDetail.inputResult)
+ assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections)
+ assertEquals(OVERSCROLL_DIRECTIONS_NONE, inputResultDetail.overscrollDirections)
+ }
+
+ @Test
+ fun `GIVEN InputResultDetail WHEN newInstance() is called specifying overscroll enabled THEN a new instance with overscroll enabled is returned`() {
+ inputResultDetail = InputResultDetail.newInstance(true)
+ // Handling unknown but can overscroll. We need to preemptively allow for this,
+ // otherwise pull to refresh would not work for the entirety of the touch.
+ assertEquals(INPUT_HANDLING_UNKNOWN, inputResultDetail.inputResult)
+ assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections)
+ assertEquals(OVERSCROLL_DIRECTIONS_VERTICAL, inputResultDetail.overscrollDirections)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN copy is called with new values THEN the new values are set for the instance`() {
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertEquals(INPUT_HANDLED_CONTENT, inputResultDetail.inputResult)
+ assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections)
+ assertEquals(OVERSCROLL_DIRECTIONS_NONE, inputResultDetail.overscrollDirections)
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED, SCROLL_DIRECTIONS_RIGHT)
+ assertEquals(INPUT_HANDLED, inputResultDetail.inputResult)
+ assertEquals(SCROLL_DIRECTIONS_RIGHT, inputResultDetail.scrollDirections)
+ assertEquals(OVERSCROLL_DIRECTIONS_NONE, inputResultDetail.overscrollDirections)
+
+ inputResultDetail = inputResultDetail.copy(
+ INPUT_UNHANDLED,
+ SCROLL_DIRECTIONS_NONE,
+ OVERSCROLL_DIRECTIONS_HORIZONTAL,
+ )
+ assertEquals(INPUT_UNHANDLED, inputResultDetail.inputResult)
+ assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections)
+ assertEquals(OVERSCROLL_DIRECTIONS_HORIZONTAL, inputResultDetail.overscrollDirections)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN copy is called with new values THEN the invalid ones are filtered out`() {
+ inputResultDetail = inputResultDetail.copy(42, 42, 42)
+ assertEquals(INPUT_HANDLING_UNKNOWN, inputResultDetail.inputResult)
+ assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections)
+ assertEquals(OVERSCROLL_DIRECTIONS_NONE, inputResultDetail.overscrollDirections)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance with known touch handling WHEN copy is called with INPUT_HANDLING_UNKNOWN THEN this is not set`() {
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLING_UNKNOWN)
+
+ assertEquals(INPUT_HANDLED_CONTENT, inputResultDetail.inputResult)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN equals is called with another object of different type THEN it returns false`() {
+ assertFalse(inputResultDetail == Any())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN equals is called with another instance with different values THEN it returns false`() {
+ var differentInstance = InputResultDetail.newInstance(true)
+ assertFalse(inputResultDetail == differentInstance)
+
+ differentInstance = differentInstance.copy(SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_NONE)
+ assertFalse(inputResultDetail == differentInstance)
+
+ differentInstance = differentInstance.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_NONE)
+ assertFalse(inputResultDetail == differentInstance)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN equals is called with another instance with equal values THEN it returns true`() {
+ val equalValuesInstance = InputResultDetail.newInstance()
+
+ assertTrue(inputResultDetail == equalValuesInstance)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN equals is called with the same instance THEN it returns true`() {
+ assertTrue(inputResultDetail == inputResultDetail)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN hashCode is called for same values objects THEN it returns the same result`() {
+ assertEquals(inputResultDetail.hashCode(), inputResultDetail.hashCode())
+
+ assertEquals(inputResultDetail.hashCode(), InputResultDetail.newInstance().hashCode())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertEquals(inputResultDetail.hashCode(), InputResultDetail.newInstance(true).hashCode())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN hashCode is called for different values objects THEN it returns different results`() {
+ assertNotEquals(inputResultDetail.hashCode(), InputResultDetail.newInstance(true).hashCode())
+
+ inputResultDetail = inputResultDetail.copy(OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertNotEquals(inputResultDetail.hashCode(), InputResultDetail.newInstance().hashCode())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertNotEquals(inputResultDetail.hashCode(), InputResultDetail.newInstance().hashCode())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN toString is called THEN it returns a string referring to all data`() {
+ // Add as many details as possible. Scroll and overscroll is not possible at the same time.
+ inputResultDetail = inputResultDetail.copy(
+ inputResult = INPUT_HANDLED,
+ scrollDirections = SCROLL_DIRECTIONS_LEFT or SCROLL_DIRECTIONS_RIGHT or
+ SCROLL_DIRECTIONS_TOP or SCROLL_DIRECTIONS_BOTTOM,
+ )
+
+ val result = inputResultDetail.toString()
+
+ assertEquals(
+ StringBuilder("InputResultDetail \$${inputResultDetail.hashCode()} (")
+ .append("Input ${inputResultDetail.getInputResultHandledDescription()}. ")
+ .append(
+ "Content ${inputResultDetail.getScrollDirectionsDescription()} " +
+ "and ${inputResultDetail.getOverscrollDirectionsDescription()}",
+ )
+ .append(')')
+ .toString(),
+ result,
+ )
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN getInputResultHandledDescription is called THEN returns a string describing who will handle the touch`() {
+ assertEquals(INPUT_UNKNOWN_HANDLING_DESCRIPTION, inputResultDetail.getInputResultHandledDescription())
+
+ assertEquals(
+ INPUT_UNHANDLED_TOSTRING_DESCRIPTION,
+ inputResultDetail.copy(INPUT_UNHANDLED).getInputResultHandledDescription(),
+ )
+
+ assertEquals(
+ INPUT_HANDLED_TOSTRING_DESCRIPTION,
+ inputResultDetail.copy(INPUT_HANDLED).getInputResultHandledDescription(),
+ )
+
+ assertEquals(
+ INPUT_HANDLED_CONTENT_TOSTRING_DESCRIPTION,
+ inputResultDetail.copy(INPUT_HANDLED_CONTENT).getInputResultHandledDescription(),
+ )
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN getScrollDirectionsDescription is called THEN it returns a string describing what scrolling is possible`() {
+ assertEquals(SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION, inputResultDetail.getScrollDirectionsDescription())
+
+ inputResultDetail = inputResultDetail.copy(
+ inputResult = INPUT_HANDLED,
+ scrollDirections = SCROLL_DIRECTIONS_LEFT or SCROLL_DIRECTIONS_RIGHT or
+ SCROLL_DIRECTIONS_TOP or SCROLL_DIRECTIONS_BOTTOM,
+ )
+
+ assertEquals(
+ SCROLL_TOSTRING_DESCRIPTION +
+ "$SCROLL_LEFT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" +
+ "$SCROLL_TOP_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" +
+ "$SCROLL_RIGHT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" +
+ SCROLL_BOTTOM_TOSTRING_DESCRIPTION,
+ inputResultDetail.getScrollDirectionsDescription(),
+ )
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN getScrollDirectionsDescription is called for an unhandled touch THEN returns a string describing impossible scroll`() {
+ assertEquals(SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION, inputResultDetail.getScrollDirectionsDescription())
+
+ inputResultDetail = inputResultDetail.copy(
+ scrollDirections = SCROLL_DIRECTIONS_LEFT or SCROLL_DIRECTIONS_RIGHT or
+ SCROLL_DIRECTIONS_TOP or SCROLL_DIRECTIONS_BOTTOM,
+ )
+
+ assertEquals(SCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION, inputResultDetail.getScrollDirectionsDescription())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN getOverscrollDirectionsDescription is called THEN it returns a string describing what overscrolling is possible`() {
+ assertEquals(
+ OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION,
+ inputResultDetail.getOverscrollDirectionsDescription(),
+ )
+
+ inputResultDetail = inputResultDetail.copy(
+ inputResult = INPUT_HANDLED,
+ overscrollDirections = OVERSCROLL_DIRECTIONS_VERTICAL or OVERSCROLL_DIRECTIONS_HORIZONTAL,
+ )
+
+ assertEquals(
+ OVERSCROLL_TOSTRING_DESCRIPTION +
+ "$SCROLL_LEFT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" +
+ "$SCROLL_TOP_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" +
+ "$SCROLL_RIGHT_TOSTRING_DESCRIPTION$TOSTRING_SEPARATOR" +
+ SCROLL_BOTTOM_TOSTRING_DESCRIPTION,
+ inputResultDetail.getOverscrollDirectionsDescription(),
+ )
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail WHEN getOverscrollDirectionsDescription is called for unhandled touch THEN returns a string describing impossible overscroll`() {
+ assertEquals(
+ OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION,
+ inputResultDetail.getOverscrollDirectionsDescription(),
+ )
+
+ inputResultDetail = inputResultDetail.copy(
+ inputResult = INPUT_HANDLED_CONTENT,
+ overscrollDirections = OVERSCROLL_DIRECTIONS_VERTICAL or OVERSCROLL_DIRECTIONS_HORIZONTAL,
+ )
+
+ assertEquals(
+ OVERSCROLL_IMPOSSIBLE_TOSTRING_DESCRIPTION,
+ inputResultDetail.getOverscrollDirectionsDescription(),
+ )
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN isTouchHandlingUnknown is called THEN it returns true only if the inputResult is INPUT_HANDLING_UNKNOWN`() {
+ assertTrue(inputResultDetail.isTouchHandlingUnknown())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.isTouchHandlingUnknown())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertFalse(inputResultDetail.isTouchHandlingUnknown())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_UNHANDLED)
+ assertFalse(inputResultDetail.isTouchHandlingUnknown())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN isTouchHandledByBrowser is called THEN it returns true only if the inputResult is INPUT_HANDLED`() {
+ assertFalse(inputResultDetail.isTouchHandledByBrowser())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertTrue(inputResultDetail.isTouchHandledByBrowser())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertFalse(inputResultDetail.isTouchHandledByBrowser())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN isTouchHandledByWebsite is called THEN it returns true only if the inputResult is INPUT_HANDLED_CONTENT`() {
+ assertFalse(inputResultDetail.isTouchHandledByWebsite())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.isTouchHandledByWebsite())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertTrue(inputResultDetail.isTouchHandledByWebsite())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN isTouchUnhandled is called THEN it returns true only if the inputResult is INPUT_UNHANDLED`() {
+ assertFalse(inputResultDetail.isTouchUnhandled())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.isTouchUnhandled())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertFalse(inputResultDetail.isTouchUnhandled())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_UNHANDLED)
+ assertTrue(inputResultDetail.isTouchUnhandled())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canScrollToLeft is called THEN it returns true only if the browser can scroll the page to left`() {
+ assertFalse(inputResultDetail.canScrollToLeft())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.canScrollToLeft())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_LEFT)
+ assertFalse(inputResultDetail.canScrollToLeft())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertTrue(inputResultDetail.canScrollToLeft())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_NONE)
+ assertTrue(inputResultDetail.canScrollToLeft())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canScrollToTop is called THEN it returns true only if the browser can scroll the page to top`() {
+ assertFalse(inputResultDetail.canScrollToTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.canScrollToTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_TOP)
+ assertFalse(inputResultDetail.canScrollToTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertTrue(inputResultDetail.canScrollToTop())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertTrue(inputResultDetail.canScrollToTop())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canScrollToRight is called THEN it returns true only if the browser can scroll the page to right`() {
+ assertFalse(inputResultDetail.canScrollToRight())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.canScrollToRight())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_RIGHT)
+ assertFalse(inputResultDetail.canScrollToRight())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertTrue(inputResultDetail.canScrollToRight())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertTrue(inputResultDetail.canScrollToRight())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canScrollToBottom is called THEN it returns true only if the browser can scroll the page to bottom`() {
+ assertFalse(inputResultDetail.canScrollToBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.canScrollToBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_BOTTOM)
+ assertFalse(inputResultDetail.canScrollToBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertTrue(inputResultDetail.canScrollToBottom())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_NONE)
+ assertTrue(inputResultDetail.canScrollToBottom())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canOverscrollLeft is called THEN it returns true only in certain scenarios`() {
+ // The scenarios (for which there is not enough space in the method name) being:
+ // - event is not handled by the webpage
+ // - webpage cannot be scrolled to the left in which case scroll would need to happen first
+ // - the content can be overscrolled to the left. Webpages can request overscroll to be disabled.
+
+ assertFalse(inputResultDetail.canOverscrollLeft())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED, SCROLL_DIRECTIONS_BOTTOM, OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertTrue(inputResultDetail.canOverscrollLeft())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_UNHANDLED)
+ assertTrue(inputResultDetail.canOverscrollLeft())
+
+ inputResultDetail = inputResultDetail.copy(scrollDirections = SCROLL_DIRECTIONS_LEFT)
+ assertFalse(inputResultDetail.canOverscrollLeft())
+
+ inputResultDetail = inputResultDetail.copy(scrollDirections = SCROLL_DIRECTIONS_TOP, overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertTrue(inputResultDetail.canOverscrollLeft())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED, SCROLL_DIRECTIONS_RIGHT, OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertFalse(inputResultDetail.canOverscrollLeft())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canOverscrollTop is called THEN it returns true only in certain scenarios`() {
+ // The scenarios (for which there is not enough space in the method name) being:
+ // - event is not handled by the webpage
+ // - webpage cannot be scrolled to the top in which case scroll would need to happen first
+ // - the content can be overscrolled to the top. Webpages can request overscroll to be disabled.
+
+ assertFalse(inputResultDetail.canOverscrollTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertFalse(inputResultDetail.canOverscrollTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.canOverscrollTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_TOP, OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertFalse(inputResultDetail.canOverscrollTop())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_UNHANDLED, SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertTrue(inputResultDetail.canOverscrollTop())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertFalse(inputResultDetail.canOverscrollTop())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canOverscrollRight is called THEN it returns true only in certain scenarios`() {
+ // The scenarios (for which there is not enough space in the method name) being:
+ // - event is not handled by the webpage
+ // - webpage cannot be scrolled to the right in which case scroll would need to happen first
+ // - the content can be overscrolled to the right. Webpages can request overscroll to be disabled.
+
+ assertFalse(inputResultDetail.canOverscrollRight())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED, SCROLL_DIRECTIONS_BOTTOM, OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertTrue(inputResultDetail.canOverscrollRight())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_UNHANDLED)
+ assertTrue(inputResultDetail.canOverscrollRight())
+
+ inputResultDetail = inputResultDetail.copy(scrollDirections = SCROLL_DIRECTIONS_RIGHT)
+ assertFalse(inputResultDetail.canOverscrollRight())
+
+ inputResultDetail = inputResultDetail.copy(scrollDirections = SCROLL_DIRECTIONS_TOP, overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertTrue(inputResultDetail.canOverscrollRight())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED, SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertFalse(inputResultDetail.canOverscrollRight())
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail instance WHEN canOverscrollBottom is called THEN it returns true only in certain scenarios`() {
+ // The scenarios (for which there is not enough space in the method name) being:
+ // - event is not handled by the webpage
+ // - webpage cannot be scrolled to the bottom in which case scroll would need to happen first
+ // - the content can be overscrolled to the bottom. Webpages can request overscroll to be disabled.
+
+ assertFalse(inputResultDetail.canOverscrollBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT)
+ assertFalse(inputResultDetail.canOverscrollBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED)
+ assertFalse(inputResultDetail.canOverscrollBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_BOTTOM, OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertFalse(inputResultDetail.canOverscrollBottom())
+
+ inputResultDetail = inputResultDetail.copy(INPUT_UNHANDLED, SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_VERTICAL)
+ assertTrue(inputResultDetail.canOverscrollBottom())
+
+ inputResultDetail = inputResultDetail.copy(overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL)
+ assertFalse(inputResultDetail.canOverscrollBottom())
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/SettingsTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/SettingsTest.kt
new file mode 100644
index 0000000000..abdb1e8424
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/SettingsTest.kt
@@ -0,0 +1,227 @@
+/* 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.concept.engine
+
+import android.graphics.Color
+import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
+import mozilla.components.concept.engine.history.HistoryTrackingDelegate
+import mozilla.components.concept.engine.mediaquery.PreferredColorScheme
+import mozilla.components.concept.engine.request.RequestInterceptor
+import mozilla.components.support.test.expectException
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SettingsTest {
+
+ @Test
+ fun settingsThrowByDefault() {
+ val settings = object : Settings() { }
+
+ expectUnsupportedSettingException(
+ { settings.javascriptEnabled },
+ { settings.javascriptEnabled = false },
+ { settings.domStorageEnabled },
+ { settings.domStorageEnabled = false },
+ { settings.webFontsEnabled },
+ { settings.webFontsEnabled = false },
+ { settings.automaticFontSizeAdjustment },
+ { settings.automaticFontSizeAdjustment = false },
+ { settings.automaticLanguageAdjustment },
+ { settings.automaticLanguageAdjustment = false },
+ { settings.trackingProtectionPolicy },
+ { settings.trackingProtectionPolicy = TrackingProtectionPolicy.strict() },
+ { settings.historyTrackingDelegate },
+ { settings.historyTrackingDelegate = null },
+ { settings.requestInterceptor },
+ { settings.requestInterceptor = null },
+ { settings.userAgentString },
+ { settings.userAgentString = null },
+ { settings.mediaPlaybackRequiresUserGesture },
+ { settings.mediaPlaybackRequiresUserGesture = false },
+ { settings.javaScriptCanOpenWindowsAutomatically },
+ { settings.javaScriptCanOpenWindowsAutomatically = false },
+ { settings.displayZoomControls },
+ { settings.displayZoomControls = false },
+ { settings.loadWithOverviewMode },
+ { settings.loadWithOverviewMode = false },
+ { settings.useWideViewPort },
+ { settings.useWideViewPort = null },
+ { settings.allowFileAccess },
+ { settings.allowFileAccess = false },
+ { settings.allowContentAccess },
+ { settings.allowContentAccess = false },
+ { settings.allowFileAccessFromFileURLs },
+ { settings.allowFileAccessFromFileURLs = false },
+ { settings.allowUniversalAccessFromFileURLs },
+ { settings.allowUniversalAccessFromFileURLs = false },
+ { settings.verticalScrollBarEnabled },
+ { settings.verticalScrollBarEnabled = false },
+ { settings.horizontalScrollBarEnabled },
+ { settings.horizontalScrollBarEnabled = false },
+ { settings.remoteDebuggingEnabled },
+ { settings.remoteDebuggingEnabled = false },
+ { settings.supportMultipleWindows },
+ { settings.supportMultipleWindows = false },
+ { settings.preferredColorScheme },
+ { settings.preferredColorScheme = PreferredColorScheme.System },
+ { settings.testingModeEnabled },
+ { settings.testingModeEnabled = false },
+ { settings.suspendMediaWhenInactive },
+ { settings.suspendMediaWhenInactive = false },
+ { settings.fontInflationEnabled },
+ { settings.fontInflationEnabled = false },
+ { settings.fontSizeFactor },
+ { settings.fontSizeFactor = 1.0F },
+ { settings.forceUserScalableContent },
+ { settings.forceUserScalableContent = true },
+ { settings.loginAutofillEnabled },
+ { settings.loginAutofillEnabled = false },
+ { settings.clearColor },
+ { settings.clearColor = Color.BLUE },
+ { settings.enterpriseRootsEnabled },
+ { settings.enterpriseRootsEnabled = false },
+ { settings.emailTrackerBlockingPrivateBrowsing },
+ )
+ }
+
+ private fun expectUnsupportedSettingException(vararg blocks: () -> Unit) {
+ blocks.forEach { block ->
+ expectException(UnsupportedSettingException::class, block)
+ }
+ }
+
+ @Test
+ fun defaultSettings() {
+ val settings = DefaultSettings()
+ assertTrue(settings.domStorageEnabled)
+ assertTrue(settings.javascriptEnabled)
+ assertNull(settings.trackingProtectionPolicy)
+ assertNull(settings.historyTrackingDelegate)
+ assertNull(settings.requestInterceptor)
+ assertNull(settings.userAgentString)
+ assertTrue(settings.mediaPlaybackRequiresUserGesture)
+ assertFalse(settings.javaScriptCanOpenWindowsAutomatically)
+ assertTrue(settings.displayZoomControls)
+ assertTrue(settings.automaticFontSizeAdjustment)
+ assertTrue(settings.automaticLanguageAdjustment)
+ assertFalse(settings.loadWithOverviewMode)
+ assertNull(settings.useWideViewPort)
+ assertTrue(settings.allowContentAccess)
+ assertTrue(settings.allowFileAccess)
+ assertFalse(settings.allowFileAccessFromFileURLs)
+ assertFalse(settings.allowUniversalAccessFromFileURLs)
+ assertTrue(settings.verticalScrollBarEnabled)
+ assertTrue(settings.horizontalScrollBarEnabled)
+ assertFalse(settings.remoteDebuggingEnabled)
+ assertFalse(settings.supportMultipleWindows)
+ assertEquals(PreferredColorScheme.System, settings.preferredColorScheme)
+ assertFalse(settings.testingModeEnabled)
+ assertFalse(settings.suspendMediaWhenInactive)
+ assertNull(settings.fontInflationEnabled)
+ assertNull(settings.fontSizeFactor)
+ assertFalse(settings.forceUserScalableContent)
+ assertFalse(settings.loginAutofillEnabled)
+ assertNull(settings.clearColor)
+ assertFalse(settings.enterpriseRootsEnabled)
+ assertFalse(settings.queryParameterStripping)
+ assertFalse(settings.queryParameterStrippingPrivateBrowsing)
+ assertFalse(settings.emailTrackerBlockingPrivateBrowsing)
+ assertEquals("", settings.queryParameterStrippingAllowList)
+ assertEquals("", settings.queryParameterStrippingStripList)
+ assertEquals(EngineSession.CookieBannerHandlingMode.DISABLED, settings.cookieBannerHandlingMode)
+ assertEquals(EngineSession.CookieBannerHandlingMode.DISABLED, settings.cookieBannerHandlingModePrivateBrowsing)
+
+ val interceptor: RequestInterceptor = mock()
+ val historyTrackingDelegate: HistoryTrackingDelegate = mock()
+
+ val defaultSettings = DefaultSettings(
+ javascriptEnabled = false,
+ domStorageEnabled = false,
+ webFontsEnabled = false,
+ automaticFontSizeAdjustment = false,
+ automaticLanguageAdjustment = false,
+ trackingProtectionPolicy = TrackingProtectionPolicy.strict(),
+ historyTrackingDelegate = historyTrackingDelegate,
+ requestInterceptor = interceptor,
+ userAgentString = "userAgent",
+ mediaPlaybackRequiresUserGesture = false,
+ javaScriptCanOpenWindowsAutomatically = true,
+ displayZoomControls = false,
+ loadWithOverviewMode = true,
+ useWideViewPort = true,
+ allowContentAccess = false,
+ allowFileAccess = false,
+ allowFileAccessFromFileURLs = true,
+ allowUniversalAccessFromFileURLs = true,
+ verticalScrollBarEnabled = false,
+ horizontalScrollBarEnabled = false,
+ remoteDebuggingEnabled = true,
+ supportMultipleWindows = true,
+ preferredColorScheme = PreferredColorScheme.Dark,
+ testingModeEnabled = true,
+ suspendMediaWhenInactive = true,
+ fontInflationEnabled = false,
+ fontSizeFactor = 2.0F,
+ forceUserScalableContent = true,
+ loginAutofillEnabled = true,
+ clearColor = Color.BLUE,
+ enterpriseRootsEnabled = true,
+ queryParameterStripping = true,
+ queryParameterStrippingPrivateBrowsing = true,
+ queryParameterStrippingAllowList = "AllowList",
+ queryParameterStrippingStripList = "StripList",
+ cookieBannerHandlingModePrivateBrowsing = EngineSession.CookieBannerHandlingMode.REJECT_ALL,
+ cookieBannerHandlingDetectOnlyMode = true,
+ cookieBannerHandlingGlobalRules = true,
+ cookieBannerHandlingGlobalRulesSubFrames = true,
+ emailTrackerBlockingPrivateBrowsing = true,
+ )
+
+ assertFalse(defaultSettings.domStorageEnabled)
+ assertFalse(defaultSettings.javascriptEnabled)
+ assertFalse(defaultSettings.webFontsEnabled)
+ assertFalse(defaultSettings.automaticFontSizeAdjustment)
+ assertFalse(defaultSettings.automaticLanguageAdjustment)
+ assertEquals(TrackingProtectionPolicy.strict(), defaultSettings.trackingProtectionPolicy)
+ assertEquals(historyTrackingDelegate, defaultSettings.historyTrackingDelegate)
+ assertEquals(interceptor, defaultSettings.requestInterceptor)
+ assertEquals("userAgent", defaultSettings.userAgentString)
+ assertFalse(defaultSettings.mediaPlaybackRequiresUserGesture)
+ assertTrue(defaultSettings.javaScriptCanOpenWindowsAutomatically)
+ assertFalse(defaultSettings.displayZoomControls)
+ assertTrue(defaultSettings.loadWithOverviewMode)
+ assertEquals(defaultSettings.useWideViewPort, true)
+ assertFalse(defaultSettings.allowContentAccess)
+ assertFalse(defaultSettings.allowFileAccess)
+ assertTrue(defaultSettings.allowFileAccessFromFileURLs)
+ assertTrue(defaultSettings.allowUniversalAccessFromFileURLs)
+ assertFalse(defaultSettings.verticalScrollBarEnabled)
+ assertFalse(defaultSettings.horizontalScrollBarEnabled)
+ assertTrue(defaultSettings.remoteDebuggingEnabled)
+ assertTrue(defaultSettings.supportMultipleWindows)
+ assertEquals(PreferredColorScheme.Dark, defaultSettings.preferredColorScheme)
+ assertTrue(defaultSettings.testingModeEnabled)
+ assertTrue(defaultSettings.suspendMediaWhenInactive)
+ assertFalse(defaultSettings.fontInflationEnabled!!)
+ assertEquals(2.0F, defaultSettings.fontSizeFactor)
+ assertTrue(defaultSettings.forceUserScalableContent)
+ assertEquals(Color.BLUE, defaultSettings.clearColor)
+ assertTrue(defaultSettings.enterpriseRootsEnabled)
+ assertTrue(defaultSettings.queryParameterStripping)
+ assertTrue(defaultSettings.queryParameterStrippingPrivateBrowsing)
+ assertEquals("AllowList", defaultSettings.queryParameterStrippingAllowList)
+ assertEquals("StripList", defaultSettings.queryParameterStrippingStripList)
+ assertEquals(EngineSession.CookieBannerHandlingMode.DISABLED, defaultSettings.cookieBannerHandlingMode)
+ assertEquals(EngineSession.CookieBannerHandlingMode.REJECT_ALL, defaultSettings.cookieBannerHandlingModePrivateBrowsing)
+ assertTrue(defaultSettings.cookieBannerHandlingDetectOnlyMode)
+ assertTrue(defaultSettings.cookieBannerHandlingGlobalRules)
+ assertTrue(defaultSettings.cookieBannerHandlingGlobalRulesSubFrames)
+ assertTrue(defaultSettings.emailTrackerBlockingPrivateBrowsing)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/SizeTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/SizeTest.kt
new file mode 100644
index 0000000000..e3a8c493ae
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/SizeTest.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.concept.engine.manifest
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class SizeTest {
+
+ @Test
+ fun `parse standard sizes`() {
+ assertEquals(Size(512, 512), Size.parse("512x512"))
+ assertEquals(Size(16, 16), Size.parse("16x16"))
+ assertEquals(Size(100, 250), Size.parse("100x250"))
+ }
+
+ @Test
+ fun `get max and min lengths`() {
+ assertEquals(512, Size(512, 256).maxLength)
+ assertEquals(256, Size(512, 256).minLength)
+ assertEquals(250, Size(100, 250).maxLength)
+ assertEquals(100, Size(100, 250).minLength)
+ }
+
+ @Test
+ fun `parse any size`() {
+ assertEquals(Size.ANY, Size.parse("any"))
+ assertEquals(Size.ANY.width, Size.parse("any")!!.maxLength)
+ assertEquals(Size.ANY.height, Size.parse("any")!!.maxLength)
+ assertEquals(Size.ANY.width, Size.parse("any")!!.minLength)
+ assertEquals(Size.ANY.height, Size.parse("any")!!.minLength)
+ }
+
+ @Test
+ fun `return null for invalid sizes`() {
+ assertNull(Size.parse("192"))
+ assertNull(Size.parse("anywhere"))
+ assertNull(Size.parse("fooxbar"))
+ assertNull(Size.parse("x256"))
+ assertNull(Size.parse("64x"))
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt
new file mode 100644
index 0000000000..a00c111e2c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/manifest/WebAppManifestParserTest.kt
@@ -0,0 +1,603 @@
+/* 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.concept.engine.manifest
+
+import android.graphics.Color
+import android.graphics.Color.rgb
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.file.loadResourceAsString
+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.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WebAppManifestParserTest {
+
+ @Test
+ fun `getOrNull returns parsed manifest`() {
+ val sucessfulResult = WebAppManifestParser().parse(loadManifest("example_mdn.json"))
+ assertNotNull(sucessfulResult.getOrNull())
+
+ val failedResult = WebAppManifestParser().parse(loadManifest("invalid_json.json"))
+ assertNull(failedResult.getOrNull())
+ }
+
+ @Test
+ fun `Parsing example manifest from MDN`() {
+ val json = loadManifest("example_mdn.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("HackerWeb", manifest.name)
+ assertEquals("HackerWeb", manifest.shortName)
+ assertEquals(".", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.STANDALONE, manifest.display)
+ assertEquals(Color.WHITE, manifest.backgroundColor)
+ assertEquals("A simply readable Hacker News app.", manifest.description)
+ assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
+ assertNull(manifest.scope)
+ assertNull(manifest.themeColor)
+
+ assertEquals(6, manifest.icons.size)
+
+ assertEquals("images/touch/homescreen48.png", manifest.icons[0].src)
+ assertEquals("images/touch/homescreen72.png", manifest.icons[1].src)
+ assertEquals("images/touch/homescreen96.png", manifest.icons[2].src)
+ assertEquals("images/touch/homescreen144.png", manifest.icons[3].src)
+ assertEquals("images/touch/homescreen168.png", manifest.icons[4].src)
+ assertEquals("images/touch/homescreen192.png", manifest.icons[5].src)
+
+ assertEquals("image/png", manifest.icons[0].type)
+ assertEquals("image/png", manifest.icons[1].type)
+ assertEquals("image/png", manifest.icons[2].type)
+ assertEquals("image/png", manifest.icons[3].type)
+ assertEquals("image/png", manifest.icons[4].type)
+ assertEquals("image/png", manifest.icons[5].type)
+
+ assertEquals(1, manifest.icons[0].sizes.size)
+ assertEquals(1, manifest.icons[1].sizes.size)
+ assertEquals(1, manifest.icons[2].sizes.size)
+ assertEquals(1, manifest.icons[3].sizes.size)
+ assertEquals(1, manifest.icons[4].sizes.size)
+ assertEquals(1, manifest.icons[5].sizes.size)
+
+ assertEquals(48, manifest.icons[0].sizes[0].width)
+ assertEquals(72, manifest.icons[1].sizes[0].width)
+ assertEquals(96, manifest.icons[2].sizes[0].width)
+ assertEquals(144, manifest.icons[3].sizes[0].width)
+ assertEquals(168, manifest.icons[4].sizes[0].width)
+ assertEquals(192, manifest.icons[5].sizes[0].width)
+
+ assertEquals(48, manifest.icons[0].sizes[0].height)
+ assertEquals(72, manifest.icons[1].sizes[0].height)
+ assertEquals(96, manifest.icons[2].sizes[0].height)
+ assertEquals(144, manifest.icons[3].sizes[0].height)
+ assertEquals(168, manifest.icons[4].sizes[0].height)
+ assertEquals(192, manifest.icons[5].sizes[0].height)
+
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), manifest.icons[0].purpose)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), manifest.icons[1].purpose)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), manifest.icons[2].purpose)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), manifest.icons[3].purpose)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), manifest.icons[4].purpose)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), manifest.icons[5].purpose)
+ }
+
+ @Test
+ fun `Parsing example manifest from Google`() {
+ val json = loadManifest("example_google.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Google Maps", manifest.name)
+ assertEquals("Maps", manifest.shortName)
+ assertEquals("/maps/?source=pwa", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.STANDALONE, manifest.display)
+ assertEquals(rgb(51, 103, 214), manifest.backgroundColor)
+ assertNull(manifest.description)
+ assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
+ assertEquals("/maps/", manifest.scope)
+ assertEquals(rgb(51, 103, 214), manifest.themeColor)
+
+ assertEquals(2, manifest.icons.size)
+
+ manifest.icons[0].apply {
+ assertEquals("/images/icons-192.png", src)
+ assertEquals("image/png", type)
+ assertEquals(1, sizes.size)
+ assertEquals(192, sizes[0].width)
+ assertEquals(192, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+
+ manifest.icons[1].apply {
+ assertEquals("/images/icons-512.png", src)
+ assertEquals("image/png", type)
+ assertEquals(1, sizes.size)
+ assertEquals(512, sizes[0].width)
+ assertEquals(512, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+ }
+
+ @Test
+ fun `Parsing twitter mobile manifest`() {
+ val json = loadManifest("twitter_mobile.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Twitter", manifest.name)
+ assertEquals("Twitter", manifest.shortName)
+ assertEquals("/", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.STANDALONE, manifest.display)
+ assertEquals(Color.WHITE, manifest.backgroundColor)
+ assertEquals(
+ "It's what's happening. From breaking news and entertainment, sports and politics, " +
+ "to big events and everyday interests.",
+ manifest.description,
+ )
+ assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
+ assertEquals("/", manifest.scope)
+ assertEquals(Color.WHITE, manifest.themeColor)
+
+ assertEquals(2, manifest.icons.size)
+
+ manifest.icons[0].apply {
+ assertEquals("https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png", src)
+ assertEquals("image/png", type)
+ assertEquals(1, sizes.size)
+ assertEquals(192, sizes[0].width)
+ assertEquals(192, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+
+ manifest.icons[1].apply {
+ assertEquals("https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png", src)
+ assertEquals("image/png", type)
+ assertEquals(1, sizes.size)
+ assertEquals(512, sizes[0].width)
+ assertEquals(512, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+ }
+
+ @Test
+ fun `Parsing minimal manifest`() {
+ val json = loadManifest("minimal.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Minimal", manifest.name)
+ assertNull(manifest.shortName)
+ assertEquals("/", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.BROWSER, manifest.display)
+ assertNull(manifest.backgroundColor)
+ assertNull(manifest.description)
+ assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
+ assertNull(manifest.scope)
+ assertNull(manifest.themeColor)
+
+ assertEquals(0, manifest.icons.size)
+ }
+
+ @Test
+ fun `Parsing manifest with no name`() {
+ val json = loadManifest("minimal_short_name.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Minimal with Short Name", manifest.name)
+ assertEquals("Minimal with Short Name", manifest.shortName)
+ assertEquals("/", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.BROWSER, manifest.display)
+ assertNull(manifest.backgroundColor)
+ assertNull(manifest.description)
+ assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
+ assertNull(manifest.scope)
+ assertNull(manifest.themeColor)
+
+ assertEquals(0, manifest.icons.size)
+ }
+
+ @Test
+ fun `Parsing typical manifest from W3 spec`() {
+ val json = loadManifest("spec_typical.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Super Racer 3000", manifest.name)
+ assertEquals("Racer3K", manifest.shortName)
+ assertEquals("/racer/start.html", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.FULLSCREEN, manifest.display)
+ assertEquals(Color.RED, manifest.backgroundColor)
+ assertEquals("The ultimate futuristic racing game from the future!", manifest.description)
+ assertEquals(WebAppManifest.TextDirection.LTR, manifest.dir)
+ assertEquals("en", manifest.lang)
+ assertEquals(WebAppManifest.Orientation.LANDSCAPE, manifest.orientation)
+ assertEquals("/racer/", manifest.scope)
+ assertEquals(rgb(240, 248, 255), manifest.themeColor)
+
+ assertEquals(3, manifest.icons.size)
+
+ manifest.icons[0].apply {
+ assertEquals("icon/lowres.webp", src)
+ assertEquals("image/webp", type)
+ assertEquals(1, sizes.size)
+ assertEquals(64, sizes[0].width)
+ assertEquals(64, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+
+ manifest.icons[1].apply {
+ assertEquals("icon/lowres.png", src)
+ assertNull(type)
+ assertEquals(1, sizes.size)
+ assertEquals(64, sizes[0].width)
+ assertEquals(64, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+
+ manifest.icons[2].apply {
+ assertEquals("icon/hd_hi", src)
+ assertNull(type)
+ assertEquals(1, sizes.size)
+ assertEquals(128, sizes[0].width)
+ assertEquals(128, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+
+ assertEquals(2, manifest.relatedApplications.size)
+ assertFalse(manifest.preferRelatedApplications)
+
+ manifest.relatedApplications[0].apply {
+ assertEquals("play", platform)
+ assertEquals("https://play.google.com/store/apps/details?id=com.example.app1", url)
+ assertEquals("com.example.app1", id)
+ assertEquals("2", minVersion)
+ assertEquals(1, fingerprints.size)
+
+ assertEquals(
+ WebAppManifest.ExternalApplicationResource.Fingerprint(
+ type = "sha256_cert",
+ value = "92:5A:39:05:C5:B9:EA:BC:71:48:5F:F2",
+ ),
+ fingerprints[0],
+ )
+ }
+
+ manifest.relatedApplications[1].apply {
+ assertEquals("itunes", platform)
+ assertEquals("https://itunes.apple.com/app/example-app1/id123456789", url)
+ assertNull(id)
+ assertNull(minVersion)
+ assertEquals(0, fingerprints.size)
+ }
+ }
+
+ @Test
+ fun `Parsing manifest from Squoosh`() {
+ val json = loadManifest("squoosh.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Squoosh", manifest.name)
+ assertEquals("Squoosh", manifest.shortName)
+ assertEquals("/", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.STANDALONE, manifest.display)
+ assertEquals(Color.WHITE, manifest.backgroundColor)
+ assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
+ assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
+ assertNull(manifest.scope)
+ assertEquals(rgb(247, 143, 33), manifest.themeColor)
+
+ assertEquals(1, manifest.icons.size)
+
+ manifest.icons[0].apply {
+ assertEquals("/assets/icon-large.png", src)
+ assertEquals("image/png", type)
+ assertEquals(listOf(Size(1024, 1024)), sizes)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+
+ manifest.shareTarget!!.apply {
+ assertEquals("/?share-target", action)
+ assertEquals(WebAppManifest.ShareTarget.RequestMethod.POST, method)
+ assertEquals(WebAppManifest.ShareTarget.EncodingType.MULTIPART, encType)
+ assertEquals(
+ WebAppManifest.ShareTarget.Params(
+ title = "title",
+ text = "body",
+ url = "uri",
+ files = listOf(
+ WebAppManifest.ShareTarget.Files(
+ name = "file",
+ accept = listOf("image/*"),
+ ),
+ ),
+ ),
+ params,
+ )
+ }
+ }
+
+ @Test
+ fun `Parsing minimal manifest with share target`() {
+ val json = loadManifest("minimal_share_target.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("Minimal", manifest.name)
+ assertEquals("/", manifest.startUrl)
+
+ manifest.shareTarget!!.apply {
+ assertEquals("/share-target", action)
+ assertEquals(WebAppManifest.ShareTarget.RequestMethod.GET, method)
+ assertEquals(WebAppManifest.ShareTarget.EncodingType.URL_ENCODED, encType)
+ assertEquals(
+ WebAppManifest.ShareTarget.Params(
+ files = listOf(
+ WebAppManifest.ShareTarget.Files(
+ name = "file",
+ accept = listOf("image/*"),
+ ),
+ ),
+ ),
+ params,
+ )
+ }
+ }
+
+ @Test
+ fun `Parsing invalid JSON`() {
+ val json = loadManifest("invalid_json.json")
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Failure)
+ }
+
+ @Test
+ fun `Parsing invalid JSON string`() {
+ val json = loadManifestAsString("invalid_json.json")
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Failure)
+ }
+
+ @Test
+ fun `Parsing invalid JSON missing name fields`() {
+ val json = loadManifest("invalid_missing_name.json")
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Failure)
+ }
+
+ @Test
+ fun `Ignore missing share target action`() {
+ val json = loadManifest("minimal.json").apply {
+ put(
+ "share_target",
+ JSONObject().apply {
+ put("method", "POST")
+ },
+ )
+ }
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ assertNull(result.getOrNull()!!.shareTarget)
+ }
+
+ @Test
+ fun `Ignore invalid share target method`() {
+ val json = loadManifest("minimal.json").apply {
+ put(
+ "share_target",
+ JSONObject().apply {
+ put("action", "https://mozilla.com/target")
+ put("method", "PATCH")
+ },
+ )
+ }
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ assertNull(result.getOrNull()!!.shareTarget)
+ }
+
+ @Test
+ fun `Ignore invalid share target encoding type`() {
+ val json = loadManifest("minimal.json").apply {
+ put(
+ "share_target",
+ JSONObject().apply {
+ put("action", "https://mozilla.com/target")
+ put("enctype", "text/plain")
+ },
+ )
+ }
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ assertNull(result.getOrNull()!!.shareTarget)
+ }
+
+ @Test
+ fun `Ignore invalid share target method and encoding type combo`() {
+ val json = loadManifest("minimal.json").apply {
+ put(
+ "share_target",
+ JSONObject().apply {
+ put("action", "https://mozilla.com/target")
+ put("method", "GET")
+ put("enctype", "multipart/form-data")
+ },
+ )
+ }
+ val result = WebAppManifestParser().parse(json)
+
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ assertNull(result.getOrNull()!!.shareTarget)
+ }
+
+ @Test
+ fun `Parsing manifest with unusual values`() {
+ val json = loadManifest("unusual.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("The Sample Manifest", manifest.name)
+ assertEquals("Sample", manifest.shortName)
+ assertEquals("/start", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.MINIMAL_UI, manifest.display)
+ assertNull(manifest.backgroundColor)
+ assertNull(manifest.description)
+ assertEquals(WebAppManifest.TextDirection.RTL, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.PORTRAIT, manifest.orientation)
+ assertEquals("/", manifest.scope)
+ assertNull(manifest.themeColor)
+
+ assertEquals(2, manifest.icons.size)
+
+ manifest.icons[0].apply {
+ assertEquals("/images/icon/favicon.ico", src)
+ assertEquals("image/png", type)
+ assertEquals(3, sizes.size)
+ assertEquals(48, sizes[0].width)
+ assertEquals(48, sizes[0].height)
+ assertEquals(96, sizes[1].width)
+ assertEquals(96, sizes[1].height)
+ assertEquals(128, sizes[2].width)
+ assertEquals(128, sizes[2].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.MONOCHROME), purpose)
+ }
+
+ manifest.icons[1].apply {
+ assertEquals("/images/icon/512-512.png", src)
+ assertEquals("image/png", type)
+ assertEquals(1, sizes.size)
+ assertEquals("image/png", type)
+ assertEquals(512, sizes[0].width)
+ assertEquals(512, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.MASKABLE, WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+ }
+
+ @Test
+ fun `Parsing manifest where purpose field is array instead of string`() {
+ val json = loadManifest("purpose_array.json")
+ val result = WebAppManifestParser().parse(json)
+ assertTrue(result is WebAppManifestParser.Result.Success)
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertNotNull(manifest)
+ assertEquals("The Sample Manifest", manifest.name)
+ assertEquals("Sample", manifest.shortName)
+ assertEquals("/start", manifest.startUrl)
+ assertEquals(WebAppManifest.DisplayMode.MINIMAL_UI, manifest.display)
+ assertNull(manifest.backgroundColor)
+ assertNull(manifest.description)
+ assertEquals(WebAppManifest.TextDirection.RTL, manifest.dir)
+ assertNull(manifest.lang)
+ assertEquals(WebAppManifest.Orientation.PORTRAIT, manifest.orientation)
+ assertEquals("/", manifest.scope)
+ assertNull(manifest.themeColor)
+
+ assertEquals(2, manifest.icons.size)
+
+ manifest.icons[0].apply {
+ assertEquals("/images/icon/favicon.ico", src)
+ assertEquals("image/png", type)
+ assertEquals(3, sizes.size)
+ assertEquals(48, sizes[0].width)
+ assertEquals(48, sizes[0].height)
+ assertEquals(96, sizes[1].width)
+ assertEquals(96, sizes[1].height)
+ assertEquals(128, sizes[2].width)
+ assertEquals(128, sizes[2].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.MONOCHROME), purpose)
+ }
+
+ manifest.icons[1].apply {
+ assertEquals("/images/icon/512-512.png", src)
+ assertEquals("image/png", type)
+ assertEquals(1, sizes.size)
+ assertEquals("image/png", type)
+ assertEquals(512, sizes[0].width)
+ assertEquals(512, sizes[0].height)
+ assertEquals(setOf(WebAppManifest.Icon.Purpose.MASKABLE, WebAppManifest.Icon.Purpose.ANY), purpose)
+ }
+ }
+
+ @Test
+ fun `Serializing minimal manifest`() {
+ val manifest = WebAppManifest(name = "Mozilla", startUrl = "https://mozilla.org")
+ val json = WebAppManifestParser().serialize(manifest)
+
+ assertEquals("Mozilla", json.getString("name"))
+ assertEquals("https://mozilla.org", json.getString("start_url"))
+ }
+
+ @Test
+ fun `Serialize and parse W3 typical manifest`() {
+ val result = WebAppManifestParser().parse(loadManifest("spec_typical.json"))
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertEquals(
+ result,
+ WebAppManifestParser().parse(WebAppManifestParser().serialize(manifest)),
+ )
+ }
+
+ @Test
+ fun `Serialize and parse unusual manifest`() {
+ val result = WebAppManifestParser().parse(loadManifest("unusual.json"))
+ val manifest = (result as WebAppManifestParser.Result.Success).manifest
+
+ assertEquals(
+ result,
+ WebAppManifestParser().parse(WebAppManifestParser().serialize(manifest)),
+ )
+ }
+
+ private fun loadManifestAsString(fileName: String): String =
+ loadResourceAsString("/manifests/$fileName")
+
+ private fun loadManifest(fileName: String): JSONObject =
+ JSONObject(loadManifestAsString(fileName))
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/mediasession/MediaSessionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/mediasession/MediaSessionTest.kt
new file mode 100644
index 0000000000..15c3423db9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/mediasession/MediaSessionTest.kt
@@ -0,0 +1,230 @@
+/* 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.concept.engine.mediasession
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+class MediaSessionTest {
+ @Test
+ fun `media session feature works correctly`() {
+ var features = MediaSession.Feature()
+ assertFalse(features.contains(MediaSession.Feature.PLAY))
+ assertFalse(features.contains(MediaSession.Feature.PAUSE))
+ assertFalse(features.contains(MediaSession.Feature.STOP))
+ assertFalse(features.contains(MediaSession.Feature.SEEK_TO))
+ assertFalse(features.contains(MediaSession.Feature.SEEK_FORWARD))
+ assertFalse(features.contains(MediaSession.Feature.SEEK_BACKWARD))
+ assertFalse(features.contains(MediaSession.Feature.SKIP_AD))
+ assertFalse(features.contains(MediaSession.Feature.NEXT_TRACK))
+ assertFalse(features.contains(MediaSession.Feature.PREVIOUS_TRACK))
+ assertFalse(features.contains(MediaSession.Feature.FOCUS))
+
+ features = MediaSession.Feature(MediaSession.Feature.PLAY + MediaSession.Feature.PAUSE)
+ assert(features.contains(MediaSession.Feature.PLAY))
+ assert(features.contains(MediaSession.Feature.PAUSE))
+ assertFalse(features.contains(MediaSession.Feature.STOP))
+ assertFalse(features.contains(MediaSession.Feature.SEEK_TO))
+ assertFalse(features.contains(MediaSession.Feature.SEEK_FORWARD))
+ assertFalse(features.contains(MediaSession.Feature.SEEK_BACKWARD))
+ assertFalse(features.contains(MediaSession.Feature.SKIP_AD))
+ assertFalse(features.contains(MediaSession.Feature.NEXT_TRACK))
+ assertFalse(features.contains(MediaSession.Feature.PREVIOUS_TRACK))
+ assertFalse(features.contains(MediaSession.Feature.FOCUS))
+
+ features = MediaSession.Feature(MediaSession.Feature.STOP)
+ assertEquals(features, MediaSession.Feature(MediaSession.Feature.STOP))
+ assertEquals(features.hashCode(), MediaSession.Feature(MediaSession.Feature.STOP).hashCode())
+ }
+
+ @Test
+ fun `media session controller interface works correctly`() {
+ val fakeController = FakeController()
+ assertFalse(fakeController.pause)
+ assertFalse(fakeController.stop)
+ assertFalse(fakeController.play)
+ assertFalse(fakeController.seekTo)
+ assertFalse(fakeController.seekForward)
+ assertFalse(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.pause()
+ assert(fakeController.pause)
+ assertFalse(fakeController.stop)
+ assertFalse(fakeController.play)
+ assertFalse(fakeController.seekTo)
+ assertFalse(fakeController.seekForward)
+ assertFalse(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.stop()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assertFalse(fakeController.play)
+ assertFalse(fakeController.seekTo)
+ assertFalse(fakeController.seekForward)
+ assertFalse(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.play()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assertFalse(fakeController.seekTo)
+ assertFalse(fakeController.seekForward)
+ assertFalse(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.seekTo(123.0, true)
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assertFalse(fakeController.seekForward)
+ assertFalse(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.seekForward()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assert(fakeController.seekForward)
+ assertFalse(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.seekBackward()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assert(fakeController.seekForward)
+ assert(fakeController.seekBackward)
+ assertFalse(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.nextTrack()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assert(fakeController.seekForward)
+ assert(fakeController.seekBackward)
+ assert(fakeController.nextTrack)
+ assertFalse(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.previousTrack()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assert(fakeController.seekForward)
+ assert(fakeController.seekBackward)
+ assert(fakeController.nextTrack)
+ assert(fakeController.previousTrack)
+ assertFalse(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.skipAd()
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assert(fakeController.seekForward)
+ assert(fakeController.seekBackward)
+ assert(fakeController.nextTrack)
+ assert(fakeController.previousTrack)
+ assert(fakeController.skipAd)
+ assertFalse(fakeController.muteAudio)
+
+ fakeController.muteAudio(true)
+ assert(fakeController.pause)
+ assert(fakeController.stop)
+ assert(fakeController.play)
+ assert(fakeController.seekTo)
+ assert(fakeController.seekForward)
+ assert(fakeController.seekBackward)
+ assert(fakeController.nextTrack)
+ assert(fakeController.previousTrack)
+ assert(fakeController.skipAd)
+ assert(fakeController.muteAudio)
+ }
+}
+
+private class FakeController : MediaSession.Controller {
+ var pause = false
+ var stop = false
+ var play = false
+ var seekTo = false
+ var seekForward = false
+ var seekBackward = false
+ var nextTrack = false
+ var previousTrack = false
+ var skipAd = false
+ var muteAudio = false
+
+ override fun pause() {
+ pause = true
+ }
+
+ override fun stop() {
+ stop = true
+ }
+
+ override fun play() {
+ play = true
+ }
+
+ override fun seekTo(time: Double, fast: Boolean) {
+ seekTo = true
+ }
+
+ override fun seekForward() {
+ seekForward = true
+ }
+
+ override fun seekBackward() {
+ seekBackward = true
+ }
+
+ override fun nextTrack() {
+ nextTrack = true
+ }
+
+ override fun previousTrack() {
+ previousTrack = true
+ }
+
+ override fun skipAd() {
+ skipAd = true
+ }
+
+ override fun muteAudio(mute: Boolean) {
+ muteAudio = true
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionRequestTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionRequestTest.kt
new file mode 100644
index 0000000000..d5fc5ed50f
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionRequestTest.kt
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.engine.permission
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PermissionRequestTest {
+
+ @Test
+ fun `grantIf applies predicate to grant (but not reject) permission requests`() {
+ var request = MockPermissionRequest(listOf(Permission.ContentProtectedMediaId()))
+ request.grantIf { it is Permission.ContentAudioCapture }
+ assertFalse(request.granted)
+ assertFalse(request.rejected)
+
+ request.grantIf { it is Permission.ContentProtectedMediaId }
+ assertTrue(request.granted)
+ assertFalse(request.rejected)
+
+ request = MockPermissionRequest(listOf(Permission.Generic("test"), Permission.ContentProtectedMediaId()))
+ request.grantIf { it is Permission.Generic && it.id == "nomatch" }
+ assertFalse(request.granted)
+ assertFalse(request.rejected)
+
+ request.grantIf { it is Permission.Generic && it.id == "test" }
+ assertTrue(request.granted)
+ assertFalse(request.rejected)
+ }
+
+ @Test
+ fun `permission types are not equal if fields differ`() {
+ assertNotEquals(Permission.Generic("id"), Permission.Generic("id2"))
+ assertNotEquals(Permission.Generic("id"), Permission.Generic("id", "desc"))
+
+ assertNotEquals(Permission.ContentAudioCapture(), Permission.ContentAudioCapture("id"))
+ assertNotEquals(Permission.ContentAudioCapture("id"), Permission.ContentAudioCapture("id", "desc"))
+ assertNotEquals(Permission.ContentAudioMicrophone(), Permission.ContentAudioMicrophone("id"))
+ assertNotEquals(Permission.ContentAudioMicrophone("id"), Permission.ContentAudioMicrophone("id", "desc"))
+ assertNotEquals(Permission.ContentAudioOther(), Permission.ContentAudioOther("id"))
+ assertNotEquals(Permission.ContentAudioOther("id"), Permission.ContentAudioOther("id", "desc"))
+
+ assertNotEquals(Permission.ContentProtectedMediaId(), Permission.ContentProtectedMediaId("id"))
+ assertNotEquals(Permission.ContentProtectedMediaId("id"), Permission.ContentProtectedMediaId("id", "desc"))
+ assertNotEquals(Permission.ContentGeoLocation(), Permission.ContentGeoLocation("id"))
+ assertNotEquals(Permission.ContentGeoLocation("id"), Permission.ContentGeoLocation("id", "desc"))
+ assertNotEquals(Permission.ContentNotification(), Permission.ContentNotification("id"))
+ assertNotEquals(Permission.ContentNotification("id"), Permission.ContentNotification("id", "desc"))
+
+ assertNotEquals(Permission.ContentVideoCamera(), Permission.ContentVideoCamera("id"))
+ assertNotEquals(Permission.ContentVideoCamera("id"), Permission.ContentVideoCamera("id", "desc"))
+ assertNotEquals(Permission.ContentVideoCapture(), Permission.ContentVideoCapture("id"))
+ assertNotEquals(Permission.ContentVideoCapture("id"), Permission.ContentVideoCapture("id", "desc"))
+ assertNotEquals(Permission.ContentVideoScreen(), Permission.ContentVideoScreen("id"))
+ assertNotEquals(Permission.ContentVideoScreen("id"), Permission.ContentVideoScreen("id", "desc"))
+ assertNotEquals(Permission.ContentVideoOther(), Permission.ContentVideoOther("id"))
+ assertNotEquals(Permission.ContentVideoOther("id"), Permission.ContentVideoOther("id", "desc"))
+
+ assertNotEquals(Permission.AppAudio(), Permission.AppAudio("id"))
+ assertNotEquals(Permission.AppAudio("id"), Permission.AppAudio("id", "desc"))
+ assertNotEquals(Permission.AppCamera(), Permission.AppCamera("id"))
+ assertNotEquals(Permission.AppCamera("id"), Permission.AppCamera("id", "desc"))
+ assertNotEquals(Permission.AppLocationCoarse(), Permission.AppLocationCoarse("id"))
+ assertNotEquals(Permission.AppLocationCoarse("id"), Permission.AppLocationCoarse("id", "desc"))
+ assertNotEquals(Permission.AppLocationFine(), Permission.AppLocationFine("id"))
+ assertNotEquals(Permission.AppLocationFine("id"), Permission.AppLocationFine("id", "desc"))
+ }
+
+ private class MockPermissionRequest(
+ override val permissions: List<Permission>,
+ override val uri: String = "",
+ override val id: String = "",
+ ) : PermissionRequest {
+ var granted = false
+ var rejected = false
+
+ override fun grant(permissions: List<Permission>) {
+ granted = true
+ }
+
+ override fun reject() {
+ rejected = true
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionTest.kt
new file mode 100644
index 0000000000..2ece01a66b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/permission/PermissionTest.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.concept.engine.permission
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import kotlin.reflect.full.createInstance
+
+@RunWith(Parameterized::class)
+class PermissionTest<out T : Permission>(private val permission: T) {
+ @Test
+ fun `GIVEN a permission WHEN asking for it's default id THEN return permission's class name`() {
+ assertEquals(permission::class.java.simpleName, permission.id)
+ }
+
+ @Test
+ fun `GIVEN a permission WHEN asking for it's name THEN return permission's class name`() {
+ assertEquals(permission::class.java.simpleName, permission.name)
+ }
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun permissions() = Permission::class.sealedSubclasses.map {
+ it.createInstance()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ChoiceTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ChoiceTest.kt
new file mode 100644
index 0000000000..c8d85c6342
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ChoiceTest.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.concept.engine.prompt
+
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ChoiceTest {
+
+ @Test
+ fun `Create a choice`() {
+ val choice = Choice(id = "id", label = "label", children = arrayOf())
+ choice.selected = true
+ choice.enable = true
+ choice.label = "label"
+
+ assertEquals(choice.id, "id")
+ assertEquals(choice.label, "label")
+ assertEquals(choice.describeContents(), 0)
+ assertTrue(choice.enable)
+ assertFalse(choice.isASeparator)
+ assertTrue(choice.selected)
+ assertTrue(choice.isGroupType)
+ assertNotNull(choice.children)
+
+ choice.writeToParcel(mock(), 0)
+ Choice(mock())
+ Choice.createFromParcel(mock())
+ Choice.newArray(1)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/PromptRequestTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/PromptRequestTest.kt
new file mode 100644
index 0000000000..5c63c24202
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/PromptRequestTest.kt
@@ -0,0 +1,366 @@
+/* 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.concept.engine.prompt
+
+import mozilla.components.concept.engine.prompt.PromptRequest.Alert
+import mozilla.components.concept.engine.prompt.PromptRequest.Authentication
+import mozilla.components.concept.engine.prompt.PromptRequest.Color
+import mozilla.components.concept.engine.prompt.PromptRequest.Confirm
+import mozilla.components.concept.engine.prompt.PromptRequest.File
+import mozilla.components.concept.engine.prompt.PromptRequest.MenuChoice
+import mozilla.components.concept.engine.prompt.PromptRequest.MultipleChoice
+import mozilla.components.concept.engine.prompt.PromptRequest.Popup
+import mozilla.components.concept.engine.prompt.PromptRequest.Repost
+import mozilla.components.concept.engine.prompt.PromptRequest.SaveLoginPrompt
+import mozilla.components.concept.engine.prompt.PromptRequest.SelectCreditCard
+import mozilla.components.concept.engine.prompt.PromptRequest.SelectLoginPrompt
+import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice
+import mozilla.components.concept.engine.prompt.PromptRequest.TextPrompt
+import mozilla.components.concept.engine.prompt.PromptRequest.TimeSelection
+import mozilla.components.concept.engine.prompt.PromptRequest.TimeSelection.Type
+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.test.mock
+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.Test
+import java.util.Date
+
+class PromptRequestTest {
+
+ @Test
+ fun `SingleChoice`() {
+ val single = SingleChoice(emptyArray(), {}, {})
+ single.onConfirm(Choice(id = "", label = ""))
+ assertNotNull(single.choices)
+ }
+
+ @Test
+ fun `MultipleChoice`() {
+ val multiple = MultipleChoice(emptyArray(), {}, {})
+ multiple.onConfirm(arrayOf(Choice(id = "", label = "")))
+ assertNotNull(multiple.choices)
+ }
+
+ @Test
+ fun `MenuChoice`() {
+ val menu = MenuChoice(emptyArray(), {}, {})
+ menu.onConfirm(Choice(id = "", label = ""))
+ assertNotNull(menu.choices)
+ }
+
+ @Test
+ fun `Alert`() {
+ val alert = Alert("title", "message", true, {}, {})
+
+ assertEquals(alert.title, "title")
+ assertEquals(alert.message, "message")
+ assertEquals(alert.hasShownManyDialogs, true)
+
+ alert.onDismiss()
+ alert.onConfirm(true)
+
+ assertEquals(alert.title, "title")
+ assertEquals(alert.message, "message")
+ assertEquals(alert.hasShownManyDialogs, true)
+
+ alert.onDismiss()
+ alert.onConfirm(true)
+ }
+
+ @Test
+ fun `TextPrompt`() {
+ val textPrompt = TextPrompt(
+ "title",
+ "label",
+ "value",
+ true,
+ { _, _ -> },
+ {},
+ )
+
+ assertEquals(textPrompt.title, "title")
+ assertEquals(textPrompt.inputLabel, "label")
+ assertEquals(textPrompt.inputValue, "value")
+ assertEquals(textPrompt.hasShownManyDialogs, true)
+
+ textPrompt.onDismiss()
+ textPrompt.onConfirm(true, "")
+ }
+
+ @Test
+ fun `TimeSelection`() {
+ val dateRequest = TimeSelection(
+ "title",
+ Date(),
+ Date(),
+ Date(),
+ "1",
+ Type.DATE,
+ { _ -> },
+ {},
+ {},
+ )
+
+ assertEquals(dateRequest.title, "title")
+ assertEquals(dateRequest.type, Type.DATE)
+ assertEquals("1", dateRequest.stepValue)
+ assertNotNull(dateRequest.initialDate)
+ assertNotNull(dateRequest.minimumDate)
+ assertNotNull(dateRequest.maximumDate)
+
+ dateRequest.onConfirm(Date())
+ dateRequest.onClear()
+ }
+
+ @Test
+ fun `File`() {
+ val filePickerRequest = File(
+ emptyArray(),
+ true,
+ PromptRequest.File.FacingMode.NONE,
+ { _, _ -> },
+ { _, _ -> },
+ {},
+ )
+
+ assertTrue(filePickerRequest.mimeTypes.isEmpty())
+ assertTrue(filePickerRequest.isMultipleFilesSelection)
+ assertEquals(filePickerRequest.captureMode, PromptRequest.File.FacingMode.NONE)
+
+ filePickerRequest.onSingleFileSelected(mock(), mock())
+ filePickerRequest.onMultipleFilesSelected(mock(), emptyArray())
+ filePickerRequest.onDismiss()
+ }
+
+ @Test
+ fun `Authentication`() {
+ val promptRequest = Authentication(
+ "example.org",
+ "title",
+ "message",
+ "username",
+ "password",
+ PromptRequest.Authentication.Method.HOST,
+ PromptRequest.Authentication.Level.NONE,
+ false,
+ false,
+ false,
+ { _, _ -> },
+ {},
+ )
+
+ assertEquals(promptRequest.title, "title")
+ assertEquals(promptRequest.message, "message")
+ assertEquals(promptRequest.userName, "username")
+ assertEquals(promptRequest.password, "password")
+ assertFalse(promptRequest.onlyShowPassword)
+ assertFalse(promptRequest.previousFailed)
+ assertFalse(promptRequest.isCrossOrigin)
+
+ promptRequest.onConfirm("", "")
+ promptRequest.onDismiss()
+ }
+
+ @Test
+ fun `Color`() {
+ val onConfirm: (String) -> Unit = {}
+ val onDismiss: () -> Unit = {}
+
+ val colorRequest = Color("defaultColor", onConfirm, onDismiss)
+
+ assertEquals(colorRequest.defaultColor, "defaultColor")
+
+ colorRequest.onConfirm("")
+ colorRequest.onDismiss()
+ }
+
+ @Test
+ fun `Popup`() {
+ val popupRequest = Popup("http://mozilla.slack.com/", {}, {})
+
+ assertEquals(popupRequest.targetUri, "http://mozilla.slack.com/")
+
+ popupRequest.onAllow()
+ popupRequest.onDeny()
+ }
+
+ @Test
+ fun `Confirm`() {
+ val onConfirmPositiveButton: (Boolean) -> Unit = {}
+ val onConfirmNegativeButton: (Boolean) -> Unit = {}
+ val onConfirmNeutralButton: (Boolean) -> Unit = {}
+
+ val confirmRequest = Confirm(
+ "title",
+ "message",
+ false,
+ "positive",
+ "negative",
+ "neutral",
+ onConfirmPositiveButton,
+ onConfirmNegativeButton,
+ onConfirmNeutralButton,
+ {},
+ )
+
+ assertEquals(confirmRequest.title, "title")
+ assertEquals(confirmRequest.message, "message")
+ assertEquals(confirmRequest.positiveButtonTitle, "positive")
+ assertEquals(confirmRequest.negativeButtonTitle, "negative")
+ assertEquals(confirmRequest.neutralButtonTitle, "neutral")
+
+ confirmRequest.onConfirmPositiveButton(true)
+ confirmRequest.onConfirmNegativeButton(true)
+ confirmRequest.onConfirmNeutralButton(true)
+ }
+
+ @Test
+ fun `SaveLoginPrompt`() {
+ val onLoginDismiss: () -> Unit = {}
+ val onLoginConfirm: (LoginEntry) -> Unit = {}
+ val entry = LoginEntry("origin", username = "username", password = "password")
+
+ val loginSaveRequest = SaveLoginPrompt(0, listOf(entry), onLoginConfirm, onLoginDismiss)
+
+ assertEquals(loginSaveRequest.logins, listOf(entry))
+ assertEquals(loginSaveRequest.hint, 0)
+
+ loginSaveRequest.onConfirm(entry)
+ loginSaveRequest.onDismiss()
+ }
+
+ @Test
+ fun `SelectLoginPrompt`() {
+ val onLoginDismiss: () -> Unit = {}
+ val onLoginConfirm: (Login) -> Unit = {}
+ val login = Login(guid = "test-guid", origin = "origin", username = "username", password = "password")
+ val generatedPassword = "generatedPassword123#"
+
+ val loginSelectRequest =
+ SelectLoginPrompt(listOf(login), generatedPassword, onLoginConfirm, onLoginDismiss)
+
+ assertEquals(loginSelectRequest.logins, listOf(login))
+ assertEquals(loginSelectRequest.generatedPassword, generatedPassword)
+
+ loginSelectRequest.onConfirm(login)
+ loginSelectRequest.onDismiss()
+ }
+
+ @Test
+ fun `Repost`() {
+ var onAcceptWasCalled = false
+ var onDismissWasCalled = false
+
+ val repostRequest = Repost(
+ onConfirm = {
+ onAcceptWasCalled = true
+ },
+ onDismiss = {
+ onDismissWasCalled = true
+ },
+ )
+
+ repostRequest.onConfirm()
+ repostRequest.onDismiss()
+
+ assertTrue(onAcceptWasCalled)
+ assertTrue(onDismissWasCalled)
+ }
+
+ @Test
+ fun `GIVEN a list of credit cards WHEN SelectCreditCard is confirmed or dismissed THEN their respective callback is invoked`() {
+ val creditCard = CreditCardEntry(
+ guid = "id",
+ name = "Banana Apple",
+ number = "4111111111111110",
+ expiryMonth = "5",
+ expiryYear = "2030",
+ cardType = "amex",
+ )
+ var onDismissCalled = false
+ var onConfirmCalled = false
+ var confirmedCreditCard: CreditCardEntry? = null
+
+ val selectCreditCardRequest = SelectCreditCard(
+ creditCards = listOf(creditCard),
+ onDismiss = {
+ onDismissCalled = true
+ },
+ onConfirm = {
+ confirmedCreditCard = it
+ onConfirmCalled = true
+ },
+ )
+
+ assertEquals(selectCreditCardRequest.creditCards, listOf(creditCard))
+
+ selectCreditCardRequest.onConfirm(creditCard)
+
+ assertTrue(onConfirmCalled)
+ assertFalse(onDismissCalled)
+ assertEquals(creditCard, confirmedCreditCard)
+
+ onConfirmCalled = false
+ confirmedCreditCard = null
+
+ selectCreditCardRequest.onDismiss()
+
+ assertTrue(onDismissCalled)
+ assertFalse(onConfirmCalled)
+ assertNull(confirmedCreditCard)
+ }
+
+ @Test
+ fun `WHEN calling confirm or dismiss on the SelectAddress prompt request THEN the respective callback is invoked`() {
+ val address = Address(
+ guid = "1",
+ name = "Firefox",
+ organization = "-",
+ streetAddress = "street",
+ addressLevel3 = "address3",
+ addressLevel2 = "address2",
+ addressLevel1 = "address1",
+ postalCode = "1",
+ country = "Country",
+ tel = "1",
+ email = "@",
+ )
+ var onDismissCalled = false
+ var onConfirmCalled = false
+ var confirmedAddress: Address? = null
+
+ val selectAddresPromptRequest = PromptRequest.SelectAddress(
+ addresses = listOf(address),
+ onDismiss = {
+ onDismissCalled = true
+ },
+ onConfirm = {
+ confirmedAddress = it
+ onConfirmCalled = true
+ },
+ )
+
+ assertEquals(selectAddresPromptRequest.addresses, listOf(address))
+
+ selectAddresPromptRequest.onConfirm(address)
+
+ assertTrue(onConfirmCalled)
+ assertFalse(onDismissCalled)
+ assertEquals(address, confirmedAddress)
+
+ onConfirmCalled = false
+
+ selectAddresPromptRequest.onDismiss()
+
+ assertTrue(onDismissCalled)
+ assertFalse(onConfirmCalled)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ShareDataTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ShareDataTest.kt
new file mode 100644
index 0000000000..adb3bb7077
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/prompt/ShareDataTest.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.concept.engine.prompt
+
+import android.os.Bundle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.utils.ext.getParcelableCompat
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ShareDataTest {
+
+ @Test
+ fun `Create share data`() {
+ val onlyTitle = ShareData(title = "Title")
+ assertEquals("Title", onlyTitle.title)
+
+ val onlyText = ShareData(text = "Text")
+ assertEquals("Text", onlyText.text)
+
+ val onlyUrl = ShareData(url = "https://mozilla.org")
+ assertEquals("https://mozilla.org", onlyUrl.url)
+ }
+
+ @Test
+ fun `Save to bundle`() {
+ val noText = ShareData(title = "Title", url = "https://mozilla.org")
+ val noUrl = ShareData(title = "Title", text = "Text")
+ val bundle = Bundle().apply {
+ putParcelable("noText", noText)
+ putParcelable("noUrl", noUrl)
+ }
+ assertEquals(noText, bundle.getParcelableCompat("noText", ShareData::class.java))
+ assertEquals(noUrl, bundle.getParcelableCompat("noUrl", ShareData::class.java))
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/request/RequestInterceptorTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/request/RequestInterceptorTest.kt
new file mode 100644
index 0000000000..c31c08f4c7
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/request/RequestInterceptorTest.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.concept.engine.request
+
+import mozilla.components.browser.errorpages.ErrorType
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.request.RequestInterceptor.InterceptionResponse
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mockito.Mockito.mock
+
+class RequestInterceptorTest {
+
+ @Test
+ fun `match interception response`() {
+ val urlResponse = InterceptionResponse.Url("https://mozilla.org")
+ val contentResponse = InterceptionResponse.Content("data")
+
+ val url: String = urlResponse.url
+
+ val content: Triple<String, String, String> =
+ Triple(contentResponse.data, contentResponse.encoding, contentResponse.mimeType)
+
+ assertEquals("https://mozilla.org", url)
+ assertEquals(Triple("data", "UTF-8", "text/html"), content)
+ }
+
+ @Test
+ fun `interceptor has default methods`() {
+ val engineSession = mock(EngineSession::class.java)
+ val interceptor = object : RequestInterceptor { }
+ interceptor.onLoadRequest(engineSession, "url", null, false, false, false, false, false)
+ interceptor.onErrorRequest(engineSession, ErrorType.ERROR_UNKNOWN_SOCKET_TYPE, null)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/utils/EngineVersionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/utils/EngineVersionTest.kt
new file mode 100644
index 0000000000..6d1099f815
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/utils/EngineVersionTest.kt
@@ -0,0 +1,186 @@
+/* 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.concept.engine.utils
+
+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.Test
+
+class EngineVersionTest {
+ @Test
+ fun `Parse common Gecko versions`() {
+ EngineVersion.parse("67.0.1").assertIs(67, 0, 1)
+ EngineVersion.parse("68.0").assertIs(68, 0, 0)
+ EngineVersion.parse("69.0a1").assertIs(69, 0, 0, "a1")
+ EngineVersion.parse("70.0b1").assertIs(70, 0, 0, "b1")
+ EngineVersion.parse("68.3esr").assertIs(68, 3, 0, "esr")
+ }
+
+ @Test
+ fun `Parse common Chrome versions`() {
+ EngineVersion.parse("75.0.3770").assertIs(75, 0, 3770)
+ EngineVersion.parse("76.0.3809").assertIs(76, 0, 3809)
+ EngineVersion.parse("77.0").assertIs(77, 0, 0)
+ }
+
+ @Test
+ fun `Parse invalid versions`() {
+ assertNull(EngineVersion.parse("Hello World"))
+ assertNull(EngineVersion.parse("1.a"))
+ }
+
+ @Test
+ fun `Comparing versions`() {
+ assertTrue("68.0".toVersion() > "67.5.9".toVersion())
+ assertTrue("68.0.1".toVersion() == "68.0.1".toVersion())
+ assertTrue("76.0.3809".toVersion() < "77.0".toVersion())
+ assertTrue("69.0a1".toVersion() > "69.0".toVersion())
+ assertTrue("67.0.1 ".toVersion() < "67.0.2".toVersion())
+ assertTrue("68.3esr".toVersion() < "70.0b1".toVersion())
+ assertTrue("67.0".toVersion() < "67.0a1".toVersion())
+ assertTrue("67.0a1".toVersion() < "67.0b1".toVersion())
+ assertEquals(0, "68.0.1".compareTo("68.0.1"))
+ }
+
+ @Test
+ fun `Comparing with isAtLeast`() {
+ assertTrue("68.0.0".toVersion().isAtLeast(68))
+ assertTrue("68.0.0".toVersion().isAtLeast(67, 0, 7))
+ assertFalse("68.0.0".toVersion().isAtLeast(69))
+ assertTrue("76.0.3809".toVersion().isAtLeast(76, 0, 3809))
+ assertTrue("76.0.3809".toVersion().isAtLeast(76, 0, 3808))
+ assertFalse("76.0.3809".toVersion().isAtLeast(76, 0, 3810))
+ assertTrue("1.2.25".toVersion().isAtLeast(1, 1, 10))
+ assertTrue("1.2.25".toVersion().isAtLeast(1, 1, 25))
+ assertTrue("1.2.25".toVersion().isAtLeast(1, 2, 25))
+ }
+
+ @Test
+ fun `toString returns clean version string`() {
+ assertEquals("1.0.0", "1.0.0".toVersion().toString())
+ assertEquals("76.0.3809", "76.0.3809".toVersion().toString())
+ assertEquals("67.0.0a1", "67.0a1".toVersion().toString())
+ assertEquals("68.3.0esr", "68.3esr".toVersion().toString())
+ assertEquals("68.0.0", "68.0".toVersion().toString())
+ }
+
+ @Test
+ fun `GIVEN a nightly build of the engine WHEN parsing the version THEN add the correct release channel`() {
+ val result = EngineVersion.parse("0.0.1", "nightly")?.releaseChannel
+
+ assertEquals(EngineReleaseChannel.NIGHTLY, result)
+ }
+
+ @Test
+ fun `GIVEN a beta build of the engine WHEN parsing the version THEN add the correct release channel`() {
+ val result = EngineVersion.parse("0.0.1", "beta")?.releaseChannel
+
+ assertEquals(EngineReleaseChannel.BETA, result)
+ }
+
+ @Test
+ fun `GIVEN a release build of the engine WHEN parsing the version THEN add the correct release channel`() {
+ val result = EngineVersion.parse("0.0.1", "release")?.releaseChannel
+
+ assertEquals(EngineReleaseChannel.RELEASE, result)
+ }
+
+ @Test
+ fun `GIVEN a different build of the engine WHEN parsing the version THEN add the correct release channel`() {
+ val result = EngineVersion.parse("0.0.1", "aurora")?.releaseChannel
+
+ assertEquals(EngineReleaseChannel.UNKNOWN, result)
+ }
+
+ @Test
+ fun `GIVEN an unknown release type WHEN comparing to other versions THEN return a negative value`() {
+ val version = "0.0.1"
+ val unknown = EngineVersion.parse(version, "canary")
+
+ assertEquals(0, unknown!!.compareTo(unknown))
+ assertTrue(unknown < EngineVersion.parse(version, "nightly")!!)
+ assertTrue(unknown < EngineVersion.parse(version, "beta")!!)
+ assertTrue(unknown < EngineVersion.parse(version, "release")!!)
+ }
+
+ @Test
+ fun `GIVEN an nightly release type WHEN comparing to other versions THEN return the expected result`() {
+ val version = "0.0.1"
+ val nightly = EngineVersion.parse(version, "nightly")
+
+ assertEquals(0, nightly!!.compareTo(nightly))
+ assertTrue(nightly > EngineVersion.parse(version, "unknown")!!)
+ assertTrue(nightly < EngineVersion.parse(version, "beta")!!)
+ assertTrue(nightly < EngineVersion.parse(version, "release")!!)
+ }
+
+ @Test
+ fun `GIVEN an beta release type WHEN comparing to other versions THEN return the expected result`() {
+ val version = "0.0.1"
+ val beta = EngineVersion.parse(version, "beta")
+
+ assertEquals(0, beta!!.compareTo(beta))
+ assertTrue(beta > EngineVersion.parse(version, "unknown")!!)
+ assertTrue(beta > EngineVersion.parse(version, "nightly")!!)
+ assertTrue(beta < EngineVersion.parse(version, "release")!!)
+ }
+
+ @Test
+ fun `GIVEN a release type WHEN comparing to other versions THEN return the expected result`() {
+ val version = "0.0.1"
+ val release = EngineVersion.parse(version, "release")
+
+ assertEquals(0, release!!.compareTo(release))
+ assertTrue(release > EngineVersion.parse(version, "unknown")!!)
+ assertTrue(release > EngineVersion.parse(version, "nightly")!!)
+ assertTrue(release > EngineVersion.parse(version, "beta")!!)
+ }
+
+ @Test
+ fun `GIVEN a newer version of a less stable release WHEN comparing to other versions THEN return the expected result`() {
+ val debug = EngineVersion.parse("103.4567890", "test")
+ val nightly = EngineVersion.parse("102.1.0", "nightly")
+ val beta = EngineVersion.parse("101.0.0", "nightly")
+ val release = EngineVersion.parse("100.1.2", "release")
+
+ assertEquals(0, debug!!.compareTo(debug))
+ assertTrue(debug > nightly!!)
+ assertTrue(debug > beta!!)
+ assertTrue(debug > release!!)
+
+ assertEquals(0, nightly.compareTo(nightly))
+ assertTrue(nightly > beta)
+ assertTrue(nightly > release)
+
+ assertEquals(0, beta.compareTo(beta))
+ assertTrue(beta > release)
+
+ assertEquals(0, release.compareTo(release))
+ }
+}
+
+private fun String.toVersion() = EngineVersion.parse(this)!!
+
+private fun EngineVersion?.assertIs(
+ major: Int,
+ minor: Int,
+ patch: Long,
+ metadata: String? = null,
+) {
+ assertNotNull(this!!)
+
+ assertEquals(major, this.major)
+ assertEquals(minor, this.minor)
+ assertEquals(patch, this.patch)
+
+ if (metadata == null) {
+ assertNull(this.metadata)
+ } else {
+ assertEquals(metadata, this.metadata)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/ActionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/ActionTest.kt
new file mode 100644
index 0000000000..ebdeb52546
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/ActionTest.kt
@@ -0,0 +1,58 @@
+/* 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.concept.engine.webextension
+
+import android.graphics.Color
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ActionTest {
+
+ private val onClick: () -> Unit = {}
+ private val baseAction = Action(
+ title = "title",
+ enabled = false,
+ loadIcon = null,
+ badgeText = "badge",
+ badgeTextColor = Color.BLACK,
+ badgeBackgroundColor = Color.BLUE,
+ onClick = onClick,
+ )
+
+ @Test
+ fun `override using non-null attributes`() {
+ val overridden = baseAction.copyWithOverride(
+ Action(
+ title = "other",
+ enabled = null,
+ loadIcon = null,
+ badgeText = null,
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = null,
+ onClick = onClick,
+ ),
+ )
+
+ assertEquals(
+ Action(
+ title = "other",
+ enabled = false,
+ loadIcon = null,
+ badgeText = "badge",
+ badgeTextColor = Color.WHITE,
+ badgeBackgroundColor = Color.BLUE,
+ onClick = onClick,
+ ),
+ overridden,
+ )
+ }
+
+ @Test
+ fun `override using null action`() {
+ val overridden = baseAction.copyWithOverride(null)
+
+ assertEquals(baseAction, overridden)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt
new file mode 100644
index 0000000000..b7af664cb6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webextension/WebExtensionTest.kt
@@ -0,0 +1,105 @@
+/* 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.concept.engine.webextension
+
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.json.JSONObject
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WebExtensionTest {
+
+ @Test
+ fun `message handler has default methods`() {
+ val messageHandler = object : MessageHandler {}
+
+ messageHandler.onPortConnected(mock())
+ messageHandler.onPortDisconnected(mock())
+ messageHandler.onPortMessage(mock(), mock())
+ messageHandler.onMessage(mock(), mock())
+ }
+
+ @Test
+ fun `tab handler has default methods`() {
+ val tabHandler = object : TabHandler {}
+
+ tabHandler.onUpdateTab(mock(), mock(), false, "")
+ tabHandler.onCloseTab(mock(), mock())
+ tabHandler.onNewTab(mock(), mock(), false, "")
+ }
+
+ @Test
+ fun `action handler has default methods`() {
+ val actionHandler = object : ActionHandler {}
+
+ actionHandler.onPageAction(mock(), mock(), mock())
+ actionHandler.onBrowserAction(mock(), mock(), mock())
+ actionHandler.onToggleActionPopup(mock(), mock())
+ }
+
+ @Test
+ fun `port holds engine session`() {
+ val engineSession: EngineSession = mock()
+ val port = object : Port(engineSession) {
+ override fun name(): String {
+ return "test"
+ }
+
+ override fun disconnect() {}
+
+ override fun senderUrl(): String {
+ return "https://foo.bar"
+ }
+
+ override fun postMessage(message: JSONObject) { }
+ }
+
+ assertSame(engineSession, port.engineSession)
+ }
+
+ @Test
+ fun `disabled checks`() {
+ val extension: WebExtension = mock()
+ assertFalse(extension.isUnsupported())
+ assertFalse(extension.isBlockListed())
+ assertFalse(extension.isDisabledUnsigned())
+ assertFalse(extension.isDisabledIncompatible())
+
+ val metadata: Metadata = mock()
+ whenever(extension.getMetadata()).thenReturn(metadata)
+ assertFalse(extension.isUnsupported())
+ assertFalse(extension.isBlockListed())
+ assertFalse(extension.isDisabledUnsigned())
+ assertFalse(extension.isDisabledIncompatible())
+
+ whenever(metadata.disabledFlags).thenReturn(DisabledFlags.select(DisabledFlags.BLOCKLIST))
+ assertFalse(extension.isUnsupported())
+ assertTrue(extension.isBlockListed())
+ assertFalse(extension.isDisabledUnsigned())
+ assertFalse(extension.isDisabledIncompatible())
+
+ whenever(metadata.disabledFlags).thenReturn(DisabledFlags.select(DisabledFlags.APP_SUPPORT))
+ assertTrue(extension.isUnsupported())
+ assertFalse(extension.isBlockListed())
+ assertFalse(extension.isDisabledUnsigned())
+ assertFalse(extension.isDisabledIncompatible())
+
+ whenever(metadata.disabledFlags).thenReturn(DisabledFlags.select(DisabledFlags.SIGNATURE))
+ assertFalse(extension.isUnsupported())
+ assertFalse(extension.isBlockListed())
+ assertTrue(extension.isDisabledUnsigned())
+ assertFalse(extension.isDisabledIncompatible())
+
+ whenever(metadata.disabledFlags).thenReturn(DisabledFlags.select(DisabledFlags.APP_VERSION))
+ assertFalse(extension.isUnsupported())
+ assertFalse(extension.isBlockListed())
+ assertFalse(extension.isDisabledUnsigned())
+ assertTrue(extension.isDisabledIncompatible())
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webpush/WebPushSubscriptionTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webpush/WebPushSubscriptionTest.kt
new file mode 100644
index 0000000000..efe4a2146a
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/webpush/WebPushSubscriptionTest.kt
@@ -0,0 +1,112 @@
+/* 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.concept.engine.webpush
+
+import org.junit.Test
+
+class WebPushSubscriptionTest {
+
+ @Test
+ fun `constructor`() {
+ val scope = "https://mozilla.org"
+ val endpoint = "https://pushendpoint.mozilla.org/send/message/here"
+ val appServerKey = byteArrayOf(10, 2, 15, 11)
+ val publicKey = byteArrayOf(11, 10, 2, 15)
+ val authSecret = byteArrayOf(15, 11, 10, 2)
+ val sub = WebPushSubscription(
+ scope,
+ endpoint,
+ appServerKey,
+ publicKey,
+ authSecret,
+ )
+
+ assert(scope == sub.scope)
+ assert(endpoint == sub.endpoint)
+ assert(appServerKey.contentEquals(sub.appServerKey!!))
+ assert(publicKey.contentEquals(sub.publicKey))
+ assert(authSecret.contentEquals(sub.authSecret))
+ }
+
+ @Test
+ fun `WebPushSubscription equals`() {
+ val sub1 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ byteArrayOf(10, 2, 15, 11),
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+ val sub2 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ byteArrayOf(10, 2, 15, 11),
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+
+ assert(sub1 == sub2)
+ }
+
+ @Test
+ fun `WebPushSubscription equals with optional`() {
+ val sub1 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ byteArrayOf(10, 2, 15, 11),
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+
+ val sub2 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ null,
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+
+ assert(sub1 != sub2)
+
+ val sub3 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ byteArrayOf(10, 2, 15),
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+
+ val notSub = "notSub"
+
+ assert(sub1 != sub2)
+ assert(sub2 != sub3)
+ assert(sub1 != sub3)
+ assert(sub3 != sub1)
+ assert(sub3 != sub2)
+ assert(sub1 != notSub as Any)
+ }
+
+ @Test
+ fun `hashCode is generated consistently from the class data`() {
+ val sub1 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ byteArrayOf(10, 2, 15, 11),
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+
+ val sub2 = WebPushSubscription(
+ "https://mozilla.org",
+ "https://pushendpoint.mozilla.org/send/message/here",
+ null,
+ byteArrayOf(11, 10, 2, 15),
+ byteArrayOf(15, 11, 10, 2),
+ )
+
+ assert(sub1.hashCode() == sub1.hashCode())
+ assert(sub1.hashCode() != sub2.hashCode())
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_google.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_google.json
new file mode 100644
index 0000000000..16c54b4585
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_google.json
@@ -0,0 +1,21 @@
+{
+ "short_name": "Maps",
+ "name": "Google Maps",
+ "icons": [
+ {
+ "src": "/images/icons-192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "/images/icons-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": "/maps/?source=pwa",
+ "background_color": "#3367D6",
+ "display": "standalone",
+ "scope": "/maps/",
+ "theme_color": "#3367D6"
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json
new file mode 100644
index 0000000000..d08b78f9b7
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json
@@ -0,0 +1,37 @@
+{
+ "name": "HackerWeb",
+ "short_name": "HackerWeb",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "description": "A simply readable Hacker News app.",
+ "icons": [{
+ "src": "images/touch/homescreen48.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ }, {
+ "src": "images/touch/homescreen72.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ }, {
+ "src": "images/touch/homescreen96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }, {
+ "src": "images/touch/homescreen144.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ }, {
+ "src": "images/touch/homescreen168.png",
+ "sizes": "168x168",
+ "type": "image/png"
+ }, {
+ "src": "images/touch/homescreen192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }],
+ "related_applications": [{
+ "platform": "play",
+ "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb"
+ }]
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_json.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_json.json
new file mode 100644
index 0000000000..2bd69c8cb4
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_json.json
@@ -0,0 +1,3 @@
+{
+ "name": 12345
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_missing_name.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_missing_name.json
new file mode 100644
index 0000000000..a11c3cd2df
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/invalid_missing_name.json
@@ -0,0 +1,3 @@
+{
+ "start_url": "https://example.com"
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal.json
new file mode 100644
index 0000000000..785d1acefa
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal.json
@@ -0,0 +1,4 @@
+{
+ "name": "Minimal",
+ "start_url": "/"
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_share_target.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_share_target.json
new file mode 100644
index 0000000000..de6a3fce16
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_share_target.json
@@ -0,0 +1,13 @@
+{
+ "name": "Minimal",
+ "start_url": "/",
+ "share_target": {
+ "action": "/share-target",
+ "params": {
+ "files": {
+ "name": "file",
+ "accept": "image/*"
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_short_name.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_short_name.json
new file mode 100644
index 0000000000..270102b33a
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/minimal_short_name.json
@@ -0,0 +1,4 @@
+{
+ "short_name": "Minimal with Short Name",
+ "start_url": "/"
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/purpose_array.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/purpose_array.json
new file mode 100644
index 0000000000..a4a289e6f6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/purpose_array.json
@@ -0,0 +1,23 @@
+{
+ "name": "The Sample Manifest",
+ "short_name": "Sample",
+ "icons": [
+ {
+ "src": "/images/icon/favicon.ico",
+ "type": "image/png",
+ "sizes": "48x48 96x96 128x128",
+ "purpose": ["monochrome"]
+ },
+ {
+ "src": "/images/icon/512-512.png",
+ "type": "image/png",
+ "sizes": ["512x512"],
+ "purpose": ["maskable", "foo", "any"]
+ }
+ ],
+ "start_url": "/start",
+ "scope": "/",
+ "display": "minimal-ui",
+ "dir": "rtl",
+ "orientation": "portrait"
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json
new file mode 100644
index 0000000000..3f180353eb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json
@@ -0,0 +1,51 @@
+{
+ "lang": "en",
+ "dir": "ltr",
+ "name": "Super Racer 3000",
+ "description": "The ultimate futuristic racing game from the future!",
+ "short_name": "Racer3K",
+ "icons": [{
+ "src": "icon/lowres.webp",
+ "sizes": "64x64",
+ "type": "image/webp"
+ },{
+ "src": "icon/lowres.png",
+ "sizes": "64x64"
+ }, {
+ "src": "icon/hd_hi",
+ "sizes": "128x128"
+ }],
+ "scope": "/racer/",
+ "start_url": "/racer/start.html",
+ "display": "fullscreen",
+ "orientation": "landscape",
+ "theme_color": "#f0f8ff",
+ "background_color": "#FF0000",
+ "serviceworker": {
+ "src": "sw.js",
+ "scope": "/racer/",
+ "update_via_cache": "none"
+ },
+ "screenshots": [{
+ "src": "screenshots/in-game-1x.jpg",
+ "sizes": "640x480",
+ "type": "image/jpeg"
+ },{
+ "src": "screenshots/in-game-2x.jpg",
+ "sizes": "1280x920",
+ "type": "image/jpeg"
+ }],
+ "related_applications": [{
+ "platform": "play",
+ "url": "https://play.google.com/store/apps/details?id=com.example.app1",
+ "id": "com.example.app1",
+ "min_version": "2",
+ "fingerprints": [{
+ "type": "sha256_cert",
+ "value": "92:5A:39:05:C5:B9:EA:BC:71:48:5F:F2"
+ }]
+ }, {
+ "platform": "itunes",
+ "url": "https://itunes.apple.com/app/example-app1/id123456789"
+ }]
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/squoosh.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/squoosh.json
new file mode 100644
index 0000000000..9f8ebeb03c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/squoosh.json
@@ -0,0 +1,32 @@
+{
+ "name": "Squoosh",
+ "short_name": "Squoosh",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#ffffff",
+ "theme_color": "#f78f21",
+ "icons": [
+ {
+ "src": "/assets/icon-large.png",
+ "type": "image/png",
+ "sizes": "1024x1024"
+ }
+ ],
+ "share_target": {
+ "action": "/?share-target",
+ "method": "POST",
+ "enctype": "multipart/form-data",
+ "params": {
+ "title": "title",
+ "text": "body",
+ "url": "uri",
+ "files": [
+ {
+ "name": "file",
+ "accept": ["image/*"]
+ }
+ ]
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json
new file mode 100644
index 0000000000..142ce0317e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json
@@ -0,0 +1 @@
+{"background_color":"#ffffff","description":"It's what's happening. From breaking news and entertainment, sports and politics, to big events and everyday interests.","display":"standalone","gcm_sender_id":"49625052041","gcm_user_visible_only":true,"icons":[{"src":"https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png","sizes":"192x192","type":"image/png"},{"src":"https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png","sizes":"512x512","type":"image/png"}],"name":"Twitter","share_target":{"action":"compose/tweet","params":{"title":"title","text":"text","url":"url"}},"short_name":"Twitter","start_url":"/","theme_color":"#ffffff","scope":"/"}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/unusual.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/unusual.json
new file mode 100644
index 0000000000..e2f8212971
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/unusual.json
@@ -0,0 +1,35 @@
+{
+ "name": "The Sample Manifest",
+ "short_name": "Sample",
+ "icons": [
+ {
+ "src": "/images/icon/favicon.ico",
+ "type": "image/png",
+ "sizes": "48x48 96x96 128x128",
+ "purpose": "monochrome"
+ },
+ {
+ "src": "/images/icon/512-512.png",
+ "type": "image/png",
+ "sizes": "512x512",
+ "purpose": "maskable foo any"
+ }
+ ],
+ "start_url": "/start",
+ "scope": "/",
+ "display": "minimal-ui",
+ "dir": "rtl",
+ "orientation": "portrait",
+ "share_target": {
+ "action": "/",
+ "method": "get",
+ "params": {
+ "title": "title",
+ "url": "uri"
+ },
+ "files": {
+ "name": "file",
+ "accept": "image/*"
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/concept/engine/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/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/concept/engine/src/test/resources/robolectric.properties b/mobile/android/android-components/components/concept/engine/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28
diff --git a/mobile/android/android-components/components/concept/fetch/README.md b/mobile/android/android-components/components/concept/fetch/README.md
new file mode 100644
index 0000000000..e7c4e11f1b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/README.md
@@ -0,0 +1,166 @@
+# [Android Components](../../../README.md) > Concept > Fetch
+
+The `concept-fetch` component contains interfaces for defining an abstract HTTP client for fetching resources.
+
+The primary use of this component is to hide the actual implementation of the HTTP client from components required to make HTTP requests. This allows apps to configure a single app-wide used client without the components enforcing a particular dependency.
+
+The API and name of the component is inspired by the [Web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API).
+
+## 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:concept-fetch:{latest-version}"
+```
+
+### Performing requests
+
+#### Get a URL
+
+```Kotlin
+val request = Request(url)
+val response = client.fetch(request)
+val body = response.string()
+```
+
+A `Response` may hold references to other resources (e.g. streams). Therefore it's important to always close the `Response` object or its `Body`. This can be done by either consuming the content of the `Body` with one of the available methods or by using Kotlin's extension methods for using `Closeable` implementations (e.g. `use()`):
+
+```Kotlin
+client.fetch(Request(url)).use { response ->
+ val body = response.body.string()
+}
+```
+
+#### Post to a URL
+
+```Kotlin
+val request = Request(
+ url = "...",
+ method = Request.Method.POST,
+ body = Request.Body.fromStream(stream))
+
+client.fetch(request).use { response ->
+ if (response.success) {
+ // ...
+ }
+}
+```
+
+#### Github API example
+
+```Kotlin
+val request = Request(
+ url = "https://api.github.com/repos/mozilla-mobile/android-components/issues",
+ headers = MutableHeaders(
+ "User-Agent" to "AwesomeBrowser/1.0",
+ "Accept" to "application/json; q=0.5",
+ "Accept" to "application/vnd.github.v3+json"))
+
+client.fetch(request).use { response ->
+ val server = response.headers.get('Server')
+ val result = response.body.string()
+}
+```
+
+#### Posting a file
+
+```Kotlin
+val file = File("README.md")
+
+val request = Request(
+ url = "https://api.github.com/markdown/raw",
+ headers = MutableHeaders(
+ "Content-Type", "text/x-markdown; charset=utf-8"
+ ),
+ body = Request.Body.fromFile(file))
+
+client.fetch(request).use { response ->
+ if (request.success) {
+ // Upload was successful!
+ }
+}
+
+```
+
+#### Asynchronous requests
+
+Client implementations are synchronous. For asynchronous requests it's recommended to wrap a client in a Coroutine with a scope the calling code is in control of:
+
+```Kotlin
+val deferredResponse = async { client.fetch(request) }
+val body = deferredResponse.await().body.string()
+```
+
+### Interceptors
+
+Interceptors are a powerful mechanism to monitor, modify, retry, redirect or record requests as well as responses going through a `Client`. Interceptors can be used with any `concept-fetch` implementation.
+
+The `withInterceptors()` extension method can be used to create a wrapped `Client` that will use the provided interceptors for requests.
+
+```kotlin
+val response = HttpURLConnectionClient()
+ .withInterceptors(LoggingInterceptor(), RetryInterceptor())
+ .fetch(request)
+```
+
+The following example implements a simple `Interceptor` that logs requests and how long they took:
+
+```kotlin
+class LoggingInterceptor(
+ private val logger: Logger = Logger("Client")
+): Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ logger.info("Request to ${chain.request.url}")
+
+ val startTime = System.currentTimeMillis()
+
+ val response = chain.proceed(chain.request)
+
+ val took = System.currentTimeMillis() - startTime
+ logger.info("[${response.status}] took $took ms")
+
+ return response
+ }
+}
+```
+
+And the following example is a naive implementation of an interceptor that retries requests:
+
+```kotlin
+class NaiveRetryInterceptor(
+ private val maxRetries: Int = 3
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val response = chain.proceed(chain.request)
+ if (response.isSuccess) {
+ return response
+ }
+
+ return retry(chain) ?: response
+ }
+
+ fun retry(chain: Interceptor.Chain): Response? {
+ var lastResponse: Response? = null
+ var retries = 0
+
+ while (retries < maxRetries) {
+ lastResponse = chain.proceed(chain.request)
+ if (lastResponse.isSuccess) {
+ return lastResponse
+ }
+ retries++
+ }
+
+ return lastResponse
+ }
+}
+```
+
+## 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/concept/fetch/build.gradle b/mobile/android/android-components/components/concept/fetch/build.gradle
new file mode 100644
index 0000000000..ce95c4ec32
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/build.gradle
@@ -0,0 +1,44 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+
+ buildConfigField("String", "LIBRARY_VERSION", "\"" + config.componentsVersion + "\"")
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ buildFeatures {
+ buildConfig true
+ }
+
+ namespace 'mozilla.components.concept.fetch'
+}
+
+dependencies {
+ testImplementation ComponentsDependencies.kotlin_coroutines
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_mockwebserver
+ testImplementation ComponentsDependencies.testing_coroutines
+
+ testImplementation project(':support-test')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/fetch/proguard-rules.pro b/mobile/android/android-components/components/concept/fetch/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/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/concept/fetch/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/fetch/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/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/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt
new file mode 100644
index 0000000000..fbb9eb7c72
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.fetch
+
+import android.util.Base64
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.net.URLDecoder
+import java.nio.charset.Charset
+
+/**
+ * A generic [Client] for fetching resources via HTTP/s.
+ *
+ * Abstract base class / interface for clients implementing the `concept-fetch` component.
+ *
+ * The [Request]/[Response] API is inspired by the Web Fetch API:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
+ */
+abstract class Client {
+ /**
+ * Starts the process of fetching a resource from the network as described by the [Request] object. This call is
+ * synchronous.
+ *
+ * A [Response] may keep references to open streams. Therefore it's important to always close the [Response] or
+ * its [Response.Body].
+ *
+ * Use the `use()` extension method when performing multiple operations on the [Response] object:
+ *
+ * ```Kotlin
+ * client.fetch(request).use { response ->
+ * // Use response. Resources will get released automatically at the end of the block.
+ * }
+ * ```
+ *
+ * Alternatively you can use multiple `use*()` methods on the [Response.Body] object.
+ *
+ * @param request The request to be executed by this [Client].
+ * @return The [Response] returned by the server.
+ * @throws IOException if the request could not be executed due to cancellation, a connectivity problem or a
+ * timeout.
+ */
+ @Throws(IOException::class)
+ abstract fun fetch(request: Request): Response
+
+ /**
+ * Generates a [Response] based on the provided [Request] for a data URI.
+ *
+ * @param request The [Request] for the data URI.
+ * @return The generated [Response] including the decoded bytes as body.
+ */
+ @Suppress("ComplexMethod", "TooGenericExceptionCaught")
+ protected fun fetchDataUri(request: Request): Response {
+ if (!request.isDataUri()) {
+ throw IOException("Not a data URI")
+ }
+ return try {
+ val dataUri = request.url
+
+ val (contentType, bytes) = if (dataUri.contains(DATA_URI_BASE64_EXT)) {
+ dataUri.substringAfter(DATA_URI_SCHEME).substringBefore(DATA_URI_BASE64_EXT) to
+ Base64.decode(dataUri.substring(dataUri.lastIndexOf(',') + 1), Base64.DEFAULT)
+ } else {
+ val contentType = dataUri.substringAfter(DATA_URI_SCHEME).substringBefore(",")
+ val charset = if (contentType.contains(DATA_URI_CHARSET)) {
+ Charset.forName(contentType.substringAfter(DATA_URI_CHARSET).substringBefore(","))
+ } else {
+ Charsets.UTF_8
+ }
+ contentType to
+ URLDecoder.decode(dataUri.substring(dataUri.lastIndexOf(',') + 1), charset.name()).toByteArray()
+ }
+
+ val headers = MutableHeaders().apply {
+ set(Headers.Names.CONTENT_LENGTH, bytes.size.toString())
+ if (contentType.isNotEmpty()) {
+ set(Headers.Names.CONTENT_TYPE, contentType)
+ }
+ }
+
+ Response(
+ dataUri,
+ Response.SUCCESS,
+ headers,
+ Response.Body(ByteArrayInputStream(bytes), contentType),
+ )
+ } catch (e: Exception) {
+ throw IOException("Failed to decode data URI")
+ }
+ }
+
+ companion object {
+ const val DATA_URI_BASE64_EXT = ";base64"
+ const val DATA_URI_SCHEME = "data:"
+ const val DATA_URI_CHARSET = "charset="
+ }
+}
diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt
new file mode 100644
index 0000000000..9b49884bfe
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt
@@ -0,0 +1,168 @@
+/* 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.concept.fetch
+
+/**
+ * A collection of HTTP [Headers] (immutable) of a [Request] or [Response].
+ */
+interface Headers : Iterable<Header> {
+ /**
+ * Returns the number of headers (key / value combinations).
+ */
+ val size: Int
+
+ /**
+ * Gets the [Header] at the specified [index].
+ */
+ operator fun get(index: Int): Header
+
+ /**
+ * Returns the last values corresponding to the specified header field name. Or null if the header does not exist.
+ */
+ operator fun get(name: String): String?
+
+ /**
+ * Returns the list of values corresponding to the specified header field name.
+ */
+ fun getAll(name: String): List<String>
+
+ /**
+ * Sets the [Header] at the specified [index].
+ */
+ operator fun set(index: Int, header: Header)
+
+ /**
+ * Returns true if a [Header] with the given [name] exists.
+ */
+ operator fun contains(name: String): Boolean
+
+ /**
+ * A collection of common HTTP header names.
+ *
+ * A list of common HTTP request headers can be found at
+ * https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Standard_request_fields
+ *
+ * A list of common HTTP response headers can be found at
+ * https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Standard_response_fields
+ *
+ * @see [Headers.Values]
+ */
+ object Names {
+ const val CONTENT_DISPOSITION = "Content-Disposition"
+ const val CONTENT_RANGE = "Content-Range"
+ const val RANGE = "Range"
+ const val CONTENT_LENGTH = "Content-Length"
+ const val CONTENT_TYPE = "Content-Type"
+ const val COOKIE = "Cookie"
+ const val REFERRER = "Referer"
+ const val USER_AGENT = "User-Agent"
+ }
+
+ /**
+ * A collection of common HTTP header values.
+ *
+ * @see [Headers.Names]
+ */
+ object Values {
+ const val CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"
+ const val CONTENT_TYPE_APPLICATION_JSON = "application/json"
+ }
+}
+
+/**
+ * Represents a [Header] containing of a [name] and [value].
+ */
+data class Header(
+ val name: String,
+ val value: String,
+) {
+ init {
+ if (name.isEmpty()) {
+ throw IllegalArgumentException("Header name cannot be empty")
+ }
+ }
+}
+
+/**
+ * A collection of HTTP [Headers] (mutable) of a [Request] or [Response].
+ */
+class MutableHeaders(headers: List<Header>) : Headers, MutableIterable<Header> {
+
+ private val headers = headers.toMutableList()
+
+ constructor(vararg pairs: Pair<String, String>) : this(
+ pairs.map { (name, value) -> Header(name, value) }.toMutableList(),
+ )
+
+ /**
+ * Gets the [Header] at the specified [index].
+ */
+ override fun get(index: Int): Header = headers[index]
+
+ /**
+ * Returns the last value corresponding to the specified header field name. Or null if the header does not exist.
+ */
+ override fun get(name: String) =
+ headers.lastOrNull { header -> header.name.equals(name, ignoreCase = true) }?.value
+
+ /**
+ * Returns the list of values corresponding to the specified header field name.
+ */
+ override fun getAll(name: String): List<String> = headers
+ .filter { header -> header.name.equals(name, ignoreCase = true) }
+ .map { header -> header.value }
+
+ /**
+ * Sets the [Header] at the specified [index].
+ */
+ override fun set(index: Int, header: Header) {
+ headers[index] = header
+ }
+
+ /**
+ * Returns an iterator over the headers that supports removing elements during iteration.
+ */
+ override fun iterator(): MutableIterator<Header> = headers.iterator()
+
+ /**
+ * Returns true if a [Header] with the given [name] exists.
+ */
+ override operator fun contains(name: String): Boolean =
+ headers.any { it.name.equals(name, ignoreCase = true) }
+
+ /**
+ * Returns the number of headers (key / value combinations).
+ */
+ override val size: Int
+ get() = headers.size
+
+ /**
+ * Append a header without removing the headers already present.
+ */
+ fun append(name: String, value: String): MutableHeaders {
+ headers.add(Header(name, value))
+ return this
+ }
+
+ /**
+ * Set the only occurrence of the header; potentially overriding an already existing header.
+ */
+ fun set(name: String, value: String): MutableHeaders {
+ headers.forEachIndexed { index, current ->
+ if (current.name.equals(name, ignoreCase = true)) {
+ headers[index] = Header(name, value)
+ return this
+ }
+ }
+
+ return append(name, value)
+ }
+
+ override fun equals(other: Any?) = other is MutableHeaders && headers == other.headers
+
+ override fun hashCode() = headers.hashCode()
+}
+
+fun List<Header>.toMutableHeaders() = MutableHeaders(this)
diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt
new file mode 100644
index 0000000000..7ea1a46df3
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt
@@ -0,0 +1,190 @@
+/* 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.concept.fetch
+
+import android.net.Uri
+import mozilla.components.concept.fetch.Request.CookiePolicy
+import java.io.Closeable
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * The [Request] data class represents a resource request to be send by a [Client].
+ *
+ * It's API is inspired by the Request interface of the Web Fetch API:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Request
+ *
+ * @property url The URL of the request.
+ * @property method The request method (GET, POST, ..)
+ * @property headers Optional HTTP headers to be send with the request.
+ * @property connectTimeout A timeout to be used when connecting to the resource. If the timeout expires before the
+ * connection can be established, a [java.net.SocketTimeoutException] is raised. A timeout of zero is interpreted as an
+ * infinite timeout.
+ * @property readTimeout A timeout to be used when reading from the resource. If the timeout expires before there is
+ * data available for read, a java.net.SocketTimeoutException is raised. A timeout of zero is interpreted as an infinite
+ * timeout.
+ * @property body An optional body to be send with the request.
+ * @property redirect Whether the [Client] should follow redirects (HTTP 3xx) for this request or not.
+ * @property cookiePolicy A policy to specify whether or not cookies should be
+ * sent with the request, defaults to [CookiePolicy.INCLUDE]
+ * @property useCaches Whether caches should be used or a network request
+ * should be forced, defaults to true (use caches).
+ * @property private Whether the request should be performed in a private context, defaults to false.
+ * The feature is not support in all [Client]s, check support before using.
+ * @see [Headers.Names]
+ * @see [Headers.Values]
+ */
+data class Request(
+ val url: String,
+ val method: Method = Method.GET,
+ val headers: MutableHeaders? = MutableHeaders(),
+ val connectTimeout: Pair<Long, TimeUnit>? = null,
+ val readTimeout: Pair<Long, TimeUnit>? = null,
+ val body: Body? = null,
+ val redirect: Redirect = Redirect.FOLLOW,
+ val cookiePolicy: CookiePolicy = CookiePolicy.INCLUDE,
+ val useCaches: Boolean = true,
+ val private: Boolean = false,
+) {
+ var referrerUrl: String? = null
+ var conservative: Boolean = false
+
+ /**
+ * Create a Request for Backward compatibility.
+ * @property referrerUrl An optional url of the referrer.
+ * @property conservative Whether to turn off bleeding-edge network features to avoid breaking core browser
+ * functionality, defaults to false. Set to true for Mozilla services only.
+ */
+ constructor(
+ url: String,
+ method: Method = Method.GET,
+ headers: MutableHeaders? = MutableHeaders(),
+ connectTimeout: Pair<Long, TimeUnit>? = null,
+ readTimeout: Pair<Long, TimeUnit>? = null,
+ body: Body? = null,
+ redirect: Redirect = Redirect.FOLLOW,
+ cookiePolicy: CookiePolicy = CookiePolicy.INCLUDE,
+ useCaches: Boolean = true,
+ private: Boolean = false,
+ referrerUrl: String? = null,
+ conservative: Boolean = false,
+ ) : this(url, method, headers, connectTimeout, readTimeout, body, redirect, cookiePolicy, useCaches, private) {
+ this.referrerUrl = referrerUrl
+ this.conservative = conservative
+ }
+
+ /**
+ * A [Body] to be send with the [Request].
+ *
+ * @param stream A stream that will be read and send to the resource.
+ */
+ class Body(
+ private val stream: InputStream,
+ ) : Closeable {
+ companion object {
+ /**
+ * Create a [Body] from the provided [String].
+ */
+ fun fromString(value: String): Body = Body(value.byteInputStream())
+
+ /**
+ * Create a [Body] from the provided [File].
+ */
+ fun fromFile(file: File): Body = Body(file.inputStream())
+
+ /**
+ * Create a [Body] from the provided [unencodedParams] in the format of Content-Type
+ * "application/x-www-form-urlencoded". Parameters are formatted as "key1=value1&key2=value2..."
+ * and values are percent-encoded. If the given map is empty, the response body will contain the
+ * empty string.
+ *
+ * @see [Headers.Values.CONTENT_TYPE_FORM_URLENCODED]
+ */
+ fun fromParamsForFormUrlEncoded(vararg unencodedParams: Pair<String, String>): Body {
+ // It's unintuitive to use the Uri class format and encode
+ // but its GET query syntax is exactly what we need.
+ val uriBuilder = Uri.Builder()
+ unencodedParams.forEach { (key, value) -> uriBuilder.appendQueryParameter(key, value) }
+ val encodedBody = uriBuilder.build().encodedQuery ?: "" // null when the given map is empty.
+ return Body(encodedBody.byteInputStream())
+ }
+ }
+
+ /**
+ * Executes the given [block] function on the body's stream and then closes it down correctly whether an
+ * exception is thrown or not.
+ */
+ fun <R> useStream(block: (InputStream) -> R): R = use {
+ block(stream)
+ }
+
+ /**
+ * Closes this body and releases any system resources associated with it.
+ */
+ override fun close() {
+ try {
+ stream.close()
+ } catch (e: IOException) {
+ // Ignore
+ }
+ }
+ }
+
+ /**
+ * Request methods.
+ *
+ * The request method token is the primary source of request semantics;
+ * it indicates the purpose for which the client has made this request
+ * and what is expected by the client as a successful result.
+ *
+ * https://tools.ietf.org/html/rfc7231#section-4
+ */
+ enum class Method {
+ GET,
+ HEAD,
+ POST,
+ PUT,
+ DELETE,
+ CONNECT,
+ OPTIONS,
+ TRACE,
+ }
+
+ enum class Redirect {
+ /**
+ * Automatically follow redirects.
+ */
+ FOLLOW,
+
+ /**
+ * Do not follow redirects and let caller handle them manually.
+ */
+ MANUAL,
+ }
+
+ enum class CookiePolicy {
+ /**
+ * Include cookies when sending the request.
+ */
+ INCLUDE,
+
+ /**
+ * Do not send cookies with the request.
+ */
+ OMIT,
+ }
+}
+
+/**
+ * Checks whether or not the request is for a data URI.
+ */
+fun Request.isDataUri() = url.startsWith("data:")
+
+/**
+ * Checks whether or not the request is for a data blob.
+ */
+fun Request.isBlobUri() = url.startsWith("blob:")
diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt
new file mode 100644
index 0000000000..b72a0e2ef4
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt
@@ -0,0 +1,160 @@
+/* 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.concept.fetch
+
+import mozilla.components.concept.fetch.Response.Body
+import mozilla.components.concept.fetch.Response.Companion.CLIENT_ERROR_STATUS_RANGE
+import mozilla.components.concept.fetch.Response.Companion.SUCCESS_STATUS_RANGE
+import java.io.BufferedReader
+import java.io.Closeable
+import java.io.IOException
+import java.io.InputStream
+import java.nio.charset.Charset
+
+/**
+ * The [Response] data class represents a response to a [Request] send by a [Client].
+ *
+ * You can create a [Response] object using the constructor, but you are more likely to encounter a [Response] object
+ * being returned as the result of calling [Client.fetch].
+ *
+ * A [Response] may hold references to other resources (e.g. streams). Therefore it's important to always close the
+ * [Response] object or its [Body]. This can be done by either consuming the content of the [Body] with one of the
+ * available methods or by using Kotlin's extension methods for using [Closeable] implementations (like `use()`):
+ *
+ * ```Kotlin
+ * val response = ...
+ * response.use {
+ * // Use response. Resources will get released automatically at the end of the block.
+ * }
+ * ```
+ */
+data class Response(
+ val url: String,
+ val status: Int,
+ val headers: Headers,
+ val body: Body,
+) : Closeable {
+ /**
+ * Closes this [Response] and its [Body] and releases any system resources associated with it.
+ */
+ override fun close() {
+ body.close()
+ }
+
+ /**
+ * A [Body] returned along with the [Request].
+ *
+ * **The response body can be consumed only once.**.
+ *
+ * @param stream the input stream from which the response body can be read.
+ * @param contentType optional content-type as provided in the response
+ * header. If specified, an attempt will be made to look up the charset
+ * which will be used for decoding the body. If not specified, or if the
+ * charset can't be found, UTF-8 will be used for decoding.
+ */
+ open class Body(
+ private val stream: InputStream,
+ contentType: String? = null,
+ ) : Closeable, AutoCloseable {
+
+ @Suppress("TooGenericExceptionCaught")
+ private val charset = contentType?.let {
+ val charset = it.substringAfter("charset=")
+ try {
+ Charset.forName(charset)
+ } catch (e: Exception) {
+ Charsets.UTF_8
+ }
+ } ?: Charsets.UTF_8
+
+ /**
+ * Creates a usable stream from this body.
+ *
+ * Executes the given [block] function with the stream as parameter and then closes it down correctly
+ * whether an exception is thrown or not.
+ */
+ fun <R> useStream(block: (InputStream) -> R): R = use {
+ block(stream)
+ }
+
+ /**
+ * Creates a buffered reader from this body.
+ *
+ * Executes the given [block] function with the buffered reader as parameter and then closes it down correctly
+ * whether an exception is thrown or not.
+ *
+ * @param charset the optional charset to use when decoding the body. If not specified,
+ * the charset provided in the response content-type header will be used. If the header
+ * is missing or the charset is not supported, UTF-8 will be used.
+ * @param block a function to consume the buffered reader.
+ *
+ */
+ fun <R> useBufferedReader(charset: Charset? = null, block: (BufferedReader) -> R): R = use {
+ block(stream.bufferedReader(charset ?: this.charset))
+ }
+
+ /**
+ * Reads this body completely as a String.
+ *
+ * Takes care of closing the body down correctly whether an exception is thrown or not.
+ *
+ * @param charset the optional charset to use when decoding the body. If not specified,
+ * the charset provided in the response content-type header will be used. If the header
+ * is missing or the charset not supported, UTF-8 will be used.
+ */
+ fun string(charset: Charset? = null): String = use {
+ // We don't use a BufferedReader because it'd unnecessarily allocate more memory: if the
+ // BufferedReader is reading into a buffer whose length >= the BufferedReader's buffer
+ // length, then the BufferedReader reads directly into the other buffer as an optimization
+ // and the BufferedReader's buffer is unused (i.e. you get no benefit from the BufferedReader
+ // and you can just use a Reader). In this case, both the BufferedReader and readText
+ // would allocate a buffer of DEFAULT_BUFFER_SIZE so we removed the unnecessary
+ // BufferedReader and cut memory consumption in half. See
+ // https://github.com/mcomella/android-components/commit/db8488599f9f652b4d5775f70eeb4ab91462cbe6
+ // for code verifying this behavior.
+ //
+ // The allocation can be further optimized by setting the buffer size to Content-Length
+ // header. See https://github.com/mozilla-mobile/android-components/issues/11015
+ stream.reader(charset ?: this.charset).readText()
+ }
+
+ /**
+ * Closes this [Body] and releases any system resources associated with it.
+ */
+ override fun close() {
+ try {
+ stream.close()
+ } catch (e: IOException) {
+ // Ignore
+ }
+ }
+
+ companion object {
+ /**
+ * Creates an empty response body.
+ */
+ fun empty() = Body("".byteInputStream())
+ }
+ }
+
+ companion object {
+ val SUCCESS_STATUS_RANGE = 200..299
+ val CLIENT_ERROR_STATUS_RANGE = 400..499
+ const val SUCCESS = 200
+ const val NO_CONTENT = 204
+ }
+}
+
+/**
+ * Returns true if the response was successful (status in the range 200-299) or false otherwise.
+ */
+val Response.isSuccess: Boolean
+ get() = status in SUCCESS_STATUS_RANGE
+
+/**
+ * Returns true if the response was a client error (status in the range 400-499) or false otherwise.
+ */
+val Response.isClientError: Boolean
+ get() = status in CLIENT_ERROR_STATUS_RANGE
diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt
new file mode 100644
index 0000000000..d92c5ad5ab
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.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.concept.fetch.interceptor
+
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+
+/**
+ * An [Interceptor] for a [Client] implementation.
+ *
+ * Interceptors can monitor, modify, retry, redirect or record requests as well as responses going through a [Client].
+ */
+interface Interceptor {
+ /**
+ * Allows an [Interceptor] to intercept a request and modify request or response.
+ *
+ * An interceptor can retrieve the request by calling [Chain.request].
+ *
+ * If the interceptor wants to continue executing the chain (which will execute potentially other interceptors and
+ * may eventually perform the request) it can call [Chain.proceed] and pass along the original or a modified
+ * request.
+ *
+ * Finally the interceptor needs to return a [Response]. This can either be the [Response] from calling
+ * [Chain.proceed] - modified or unmodified - or a [Response] the interceptor created manually or obtained from
+ * a different source.
+ */
+ fun intercept(chain: Chain): Response
+
+ /**
+ * The request interceptor chain.
+ */
+ interface Chain {
+ /**
+ * The current request. May be modified by a previously executed interceptor.
+ */
+ val request: Request
+
+ /**
+ * Proceed executing the interceptor chain and eventually perform the request.
+ */
+ fun proceed(request: Request): Response
+ }
+}
+
+/**
+ * Creates a new [Client] instance that will use the provided list of [Interceptor] instances.
+ */
+fun Client.withInterceptors(
+ vararg interceptors: Interceptor,
+): Client = InterceptorClient(this, interceptors.toList())
+
+/**
+ * A [Client] instance that will wrap the provided [actualClient] and call the interceptor chain before executing
+ * the request.
+ */
+private class InterceptorClient(
+ private val actualClient: Client,
+ private val interceptors: List<Interceptor>,
+) : Client() {
+ override fun fetch(request: Request): Response =
+ InterceptorChain(actualClient, interceptors.toList(), request)
+ .proceed(request)
+}
+
+/**
+ * [InterceptorChain] implementation that keeps track of executing the chain of interceptors before executing the
+ * request on the provided [client].
+ */
+private class InterceptorChain(
+ private val client: Client,
+ private val interceptors: List<Interceptor>,
+ private var currentRequest: Request,
+) : Interceptor.Chain {
+ private var index = 0
+
+ override val request: Request
+ get() = currentRequest
+
+ override fun proceed(request: Request): Response {
+ currentRequest = request
+
+ return if (index < interceptors.size) {
+ val interceptor = interceptors[index]
+ index++
+ interceptor.intercept(this)
+ } else {
+ client.fetch(request)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt
new file mode 100644
index 0000000000..96e0663e7e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.fetch
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ClientTest {
+ @ExperimentalCoroutinesApi
+ @Test
+ fun `Async request with coroutines`() = runTest {
+ val client = TestClient(responseBody = Response.Body("Hello World".byteInputStream()))
+ val request = Request("https://www.mozilla.org")
+
+ val deferredResponse = async { client.fetch(request) }
+
+ val body = deferredResponse.await().body.string()
+ assertEquals("Hello World", body)
+ }
+}
+
+private class TestClient(
+ private val responseUrl: String? = null,
+ private val responseStatus: Int = 200,
+ private val responseHeaders: Headers = MutableHeaders(),
+ private val responseBody: Response.Body = Response.Body.empty(),
+) : Client() {
+ override fun fetch(request: Request): Response {
+ return Response(responseUrl ?: request.url, responseStatus, responseHeaders, responseBody)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt
new file mode 100644
index 0000000000..c84bd5f53a
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt
@@ -0,0 +1,240 @@
+/* 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.concept.fetch
+
+import mozilla.components.support.test.expectException
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.lang.IllegalArgumentException
+
+class HeadersTest {
+ @Test
+ fun `Creating Headers using constructor`() {
+ val headers = MutableHeaders(
+ "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",
+ "Dnt" to "1",
+ "User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0",
+ )
+
+ assertEquals(6, headers.size)
+
+ assertEquals("Accept", headers[0].name)
+ assertEquals("Accept-Encoding", headers[1].name)
+ assertEquals("Accept-Language", headers[2].name)
+ assertEquals("Connection", headers[3].name)
+ assertEquals("Dnt", headers[4].name)
+ assertEquals("User-Agent", headers[5].name)
+
+ assertEquals("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", headers[0].value)
+ assertEquals("gzip, deflate", headers[1].value)
+ assertEquals("en-US,en;q=0.5", headers[2].value)
+ assertEquals("keep-alive", headers[3].value)
+ assertEquals("1", headers[4].value)
+ assertEquals("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0", headers[5].value)
+ }
+
+ @Test
+ fun `Setting headers`() {
+ val headers = MutableHeaders()
+
+ headers.set("Accept-Encoding", "gzip, deflate")
+ headers.set("Connection", "keep-alive")
+ headers.set("Accept-Encoding", "gzip")
+ headers.set("Dnt", "1")
+
+ assertEquals(3, headers.size)
+
+ assertEquals("Accept-Encoding", headers[0].name)
+ assertEquals("Connection", headers[1].name)
+ assertEquals("Dnt", headers[2].name)
+
+ assertEquals("gzip", headers[0].value)
+ assertEquals("keep-alive", headers[1].value)
+ assertEquals("1", headers[2].value)
+ }
+
+ @Test
+ fun `Appending headers`() {
+ val headers = MutableHeaders()
+
+ headers.append("Accept-Encoding", "gzip, deflate")
+ headers.append("Connection", "keep-alive")
+ headers.append("Accept-Encoding", "gzip")
+ headers.append("Dnt", "1")
+
+ assertEquals(4, headers.size)
+
+ assertEquals("Accept-Encoding", headers[0].name)
+ assertEquals("Connection", headers[1].name)
+ assertEquals("Accept-Encoding", headers[2].name)
+ assertEquals("Dnt", headers[3].name)
+
+ assertEquals("gzip, deflate", headers[0].value)
+ assertEquals("keep-alive", headers[1].value)
+ assertEquals("gzip", headers[2].value)
+ assertEquals("1", headers[3].value)
+ }
+
+ @Test
+ fun `Overriding headers at index`() {
+ val headers = MutableHeaders().apply {
+ set("User-Agent", "Mozilla/5.0")
+ set("Connection", "keep-alive")
+ set("Accept-Encoding", "gzip")
+ }
+
+ headers[2] = Header("Dnt", "0")
+ headers[0] = Header("Accept-Language", "en-US,en;q=0.5")
+
+ assertEquals(3, headers.size)
+
+ assertEquals("Accept-Language", headers[0].name)
+ assertEquals("Connection", headers[1].name)
+ assertEquals("Dnt", headers[2].name)
+
+ assertEquals("en-US,en;q=0.5", headers[0].value)
+ assertEquals("keep-alive", headers[1].value)
+ assertEquals("0", headers[2].value)
+ }
+
+ @Test
+ fun `Contains header with name`() {
+ val headers = MutableHeaders().apply {
+ set("User-Agent", "Mozilla/5.0")
+ set("Connection", "keep-alive")
+ set("Accept-Encoding", "gzip")
+ }
+
+ assertTrue(headers.contains("User-Agent"))
+ assertTrue(headers.contains("Connection"))
+ assertTrue(headers.contains("Accept-Encoding"))
+
+ assertFalse(headers.contains("Accept-Language"))
+ assertFalse(headers.contains("Dnt"))
+ assertFalse(headers.contains("Accept"))
+ }
+
+ @Test
+ fun `Throws if header name is empty`() {
+ expectException(IllegalArgumentException::class) {
+ MutableHeaders(
+ "" to "Mozilla/5.0",
+ )
+ }
+
+ expectException(IllegalArgumentException::class) {
+ MutableHeaders()
+ .append("", "Mozilla/5.0")
+ }
+
+ expectException(IllegalArgumentException::class) {
+ MutableHeaders()
+ .set("", "Mozilla/5.0")
+ }
+
+ expectException(IllegalArgumentException::class) {
+ Header("", "Mozilla/5.0")
+ }
+ }
+
+ @Test
+ fun `Iterator usage`() {
+ val headers = MutableHeaders().apply {
+ set("User-Agent", "Mozilla/5.0")
+ set("Connection", "keep-alive")
+ set("Accept-Encoding", "gzip")
+ }
+
+ var i = 0
+ headers.forEach { _ -> i++ }
+
+ assertEquals(3, i)
+
+ assertNotNull(headers.firstOrNull { header -> header.name == "User-Agent" })
+ }
+
+ @Test
+ fun `Creating and modifying headers`() {
+ val headers = MutableHeaders(
+ "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",
+ "Dnt" to "1",
+ "User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0",
+ )
+
+ headers.set("Dnt", "0")
+ headers.set("User-Agent", "Mozilla/6.0")
+ headers.append("Accept", "*/*")
+
+ assertEquals(7, headers.size)
+
+ assertEquals("Accept", headers[0].name)
+ assertEquals("Accept-Encoding", headers[1].name)
+ assertEquals("Accept-Language", headers[2].name)
+ assertEquals("Connection", headers[3].name)
+ assertEquals("Dnt", headers[4].name)
+ assertEquals("User-Agent", headers[5].name)
+ assertEquals("Accept", headers[6].name)
+
+ assertEquals("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", headers[0].value)
+ assertEquals("gzip, deflate", headers[1].value)
+ assertEquals("en-US,en;q=0.5", headers[2].value)
+ assertEquals("keep-alive", headers[3].value)
+ assertEquals("0", headers[4].value)
+ assertEquals("Mozilla/6.0", headers[5].value)
+ assertEquals("*/*", headers[6].value)
+ }
+
+ @Test
+ fun `In operator`() {
+ val headers = MutableHeaders().apply {
+ set("User-Agent", "Mozilla/5.0")
+ set("Connection", "keep-alive")
+ set("Accept-Encoding", "gzip")
+ }
+
+ assertTrue("User-Agent" in headers)
+ assertTrue("Connection" in headers)
+ assertTrue("Accept-Encoding" in headers)
+
+ assertFalse("Accept-Language" in headers)
+ assertFalse("Accept" in headers)
+ assertFalse("Dnt" in headers)
+ }
+
+ @Test
+ fun `Get multiple headers by name`() {
+ val headers = MutableHeaders().apply {
+ append("Accept-Encoding", "gzip")
+ append("Accept-Encoding", "deflate")
+ append("Connection", "keep-alive")
+ }
+
+ val values = headers.getAll("Accept-Encoding")
+ assertEquals(2, values.size)
+ assertEquals("gzip", values[0])
+ assertEquals("deflate", values[1])
+ }
+
+ @Test
+ fun `Getting headers by name`() {
+ val headers = MutableHeaders().apply {
+ append("Accept-Encoding", "gzip")
+ append("Accept-Encoding", "deflate")
+ append("Connection", "keep-alive")
+ }
+
+ assertEquals("deflate", headers["Accept-Encoding"])
+ assertEquals("keep-alive", headers["Connection"])
+ }
+}
diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt
new file mode 100644
index 0000000000..d6b05456da
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt
@@ -0,0 +1,191 @@
+/* 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.concept.fetch
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+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.junit.runner.RunWith
+import org.mockito.Mockito.doThrow
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.net.URLEncoder
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class RequestTest {
+
+ @Test
+ fun `URL-only Request`() {
+ val request = Request("https://www.mozilla.org")
+
+ assertEquals("https://www.mozilla.org", request.url)
+ assertEquals(Request.Method.GET, request.method)
+ }
+
+ @Test
+ fun `Fully configured Request`() {
+ val request = Request(
+ url = "https://www.mozilla.org",
+ method = Request.Method.POST,
+ headers = MutableHeaders(
+ "Accept-Language" to "en-US,en;q=0.5",
+ "Connection" to "keep-alive",
+ "Dnt" to "1",
+ ),
+ connectTimeout = Pair(10, TimeUnit.SECONDS),
+ readTimeout = Pair(1, TimeUnit.MINUTES),
+ body = Request.Body.fromString("Hello World!"),
+ redirect = Request.Redirect.MANUAL,
+ cookiePolicy = Request.CookiePolicy.INCLUDE,
+ useCaches = true,
+ referrerUrl = "https://mozilla.org",
+ conservative = true,
+ )
+
+ assertEquals("https://www.mozilla.org", request.url)
+ assertEquals(Request.Method.POST, request.method)
+
+ assertEquals(10, request.connectTimeout!!.first)
+ assertEquals(TimeUnit.SECONDS, request.connectTimeout!!.second)
+
+ assertEquals(1, request.readTimeout!!.first)
+ assertEquals(TimeUnit.MINUTES, request.readTimeout!!.second)
+
+ assertEquals("Hello World!", request.body!!.useStream { it.bufferedReader().readText() })
+ assertEquals(Request.Redirect.MANUAL, request.redirect)
+ assertEquals(Request.CookiePolicy.INCLUDE, request.cookiePolicy)
+ assertEquals(true, request.useCaches)
+ assertEquals("https://mozilla.org", request.referrerUrl)
+ assertEquals(true, request.conservative)
+
+ val headers = request.headers!!
+ assertEquals(3, headers.size)
+
+ assertEquals("Accept-Language", headers[0].name)
+ assertEquals("Connection", headers[1].name)
+ assertEquals("Dnt", headers[2].name)
+
+ assertEquals("en-US,en;q=0.5", headers[0].value)
+ assertEquals("keep-alive", headers[1].value)
+ assertEquals("1", headers[2].value)
+ }
+
+ @Test
+ fun `Create request body from string`() {
+ val body = Request.Body.fromString("Hello World")
+ assertEquals("Hello World", body.readText())
+ }
+
+ @Test
+ fun `Create request body from file`() {
+ val file = File.createTempFile(UUID.randomUUID().toString(), UUID.randomUUID().toString())
+ file.writer().use { it.write("Banana") }
+
+ val body = Request.Body.fromFile(file)
+ assertEquals("Banana", body.readText())
+ }
+
+ @Test
+ fun `WHEN creating a request body from empty params THEN the empty string is returned`() {
+ assertEquals("", Request.Body.fromParamsForFormUrlEncoded().readText())
+ }
+
+ @Test
+ fun `WHEN creating a request body from params with empty keys or values THEN they are represented as the empty string in the result`() {
+ // In practice, we don't expect anyone to do this but this test is here as to documentation of what happens.
+ val expected = "=value&hello=world&key="
+ val body = Request.Body.fromParamsForFormUrlEncoded(
+ "" to "value",
+ "hello" to "world",
+ "key" to "",
+ )
+ assertEquals(expected, body.readText())
+ }
+
+ @Test
+ fun `WHEN creating a request body from non-alphabetized params for urlencoded THEN it's in the correct format and ordering`() {
+ val inputUrl = "https://github.com/mozilla-mobile/android-components/issues/2394"
+ val encodedURL = URLEncoder.encode(inputUrl, Charsets.UTF_8.name())
+ val expected = "v=2&url=$encodedURL"
+
+ val body = Request.Body.fromParamsForFormUrlEncoded(
+ "v" to "2",
+ "url" to inputUrl,
+ )
+ assertEquals(expected, body.readText())
+ }
+
+ @Test
+ fun `Closing body closes stream`() {
+ val stream: InputStream = mock()
+
+ val body = Request.Body(stream)
+
+ verify(stream, never()).close()
+
+ body.close()
+
+ verify(stream).close()
+ }
+
+ @Test
+ fun `Using stream closes stream`() {
+ val stream: InputStream = mock()
+
+ val body = Request.Body(stream)
+
+ verify(stream, never()).close()
+
+ body.useStream {
+ // Do nothing
+ }
+
+ verify(stream).close()
+ }
+
+ @Test
+ fun `Stream throwing on close`() {
+ val stream: InputStream = mock()
+ doThrow(IOException()).`when`(stream).close()
+
+ val body = Request.Body(stream)
+ body.close()
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `useStream rethrows and closes stream`() {
+ val stream: InputStream = mock()
+ val body = Request.Body(stream)
+
+ try {
+ body.useStream {
+ throw IllegalStateException()
+ }
+ } finally {
+ verify(stream).close()
+ }
+ }
+
+ @Test
+ fun `Is a blob Request`() {
+ var request = Request(url = "blob:https://mdn.mozillademos.org/d518464c-5075-9046")
+
+ assertTrue(request.isBlobUri())
+
+ request = Request(url = "https://mdn.mozillademos.org/d518464c-5075-9046")
+
+ assertFalse(request.isBlobUri())
+ }
+}
+
+private fun Request.Body.readText(): String = useStream { it.bufferedReader().readText() }
diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt
new file mode 100644
index 0000000000..625f580d3f
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.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.concept.fetch
+
+import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE
+import mozilla.components.support.test.mock
+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.mockito.Mockito
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import java.io.IOException
+import java.io.InputStream
+
+class ResponseTest {
+ @Test
+ fun `Creating String from Body`() {
+ val stream = "Hello World".byteInputStream()
+
+ val body = spy(Response.Body(stream))
+ assertEquals("Hello World", body.string())
+
+ verify(body).close()
+ }
+
+ @Test
+ fun `Creating BufferedReader from Body`() {
+ val stream = "Hello World".byteInputStream()
+
+ val body = spy(Response.Body(stream))
+
+ var readerUsed = false
+ body.useBufferedReader { reader ->
+ assertEquals("Hello World", reader.readText())
+ readerUsed = true
+ }
+
+ assertTrue(readerUsed)
+
+ verify(body).close()
+ }
+
+ @Test
+ fun `Creating BufferedReader from Body with custom Charset `() {
+ var stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1)
+ var body = spy(Response.Body(stream, "text/plain; charset=UTF-8"))
+ var readerUsed = false
+ body.useBufferedReader { reader ->
+ assertNotEquals("ÄäÖöÜü", reader.readText())
+ readerUsed = true
+ }
+ assertTrue(readerUsed)
+
+ stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1)
+ body = spy(Response.Body(stream, "text/plain; charset=UTF-8"))
+ readerUsed = false
+ body.useBufferedReader(Charsets.ISO_8859_1) { reader ->
+ assertEquals("ÄäÖöÜü", reader.readText())
+ readerUsed = true
+ }
+ assertTrue(readerUsed)
+
+ verify(body).close()
+ }
+
+ @Test
+ fun `Creating String from Body with custom Charset `() {
+ var stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1)
+ var body = spy(Response.Body(stream, "text/plain; charset=UTF-8"))
+ assertNotEquals("ÄäÖöÜü", body.string())
+
+ stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1)
+ body = spy(Response.Body(stream, "text/plain; charset=UTF-8"))
+ assertEquals("ÄäÖöÜü", body.string(Charsets.ISO_8859_1))
+
+ verify(body).close()
+ }
+
+ @Test
+ fun `Creating Body with invalid charset falls back to UTF-8`() {
+ var stream = "ÄäÖöÜü".byteInputStream(Charsets.UTF_8)
+ var body = spy(Response.Body(stream, "text/plain; charset=invalid"))
+ var readerUsed = false
+ body.useBufferedReader { reader ->
+ assertEquals("ÄäÖöÜü", reader.readText())
+ readerUsed = true
+ }
+ assertTrue(readerUsed)
+
+ verify(body).close()
+ }
+
+ @Test
+ fun `Using InputStream from Body`() {
+ val body = spy(Response.Body("Hello World".byteInputStream()))
+
+ var streamUsed = false
+ body.useStream { stream ->
+ assertEquals("Hello World", stream.bufferedReader().readText())
+ streamUsed = true
+ }
+
+ assertTrue(streamUsed)
+
+ verify(body).close()
+ }
+
+ @Test
+ fun `Closing Body closes stream`() {
+ val stream = spy("Hello World".byteInputStream())
+
+ val body = spy(Response.Body(stream))
+ body.close()
+
+ verify(stream).close()
+ }
+
+ @Test
+ fun `success() extension function returns true for 2xx response codes`() {
+ assertTrue(Response("https://www.mozilla.org", 200, headers = mock(), body = mock()).isSuccess)
+ assertTrue(Response("https://www.mozilla.org", 203, headers = mock(), body = mock()).isSuccess)
+
+ assertFalse(Response("https://www.mozilla.org", 404, headers = mock(), body = mock()).isSuccess)
+ assertFalse(Response("https://www.mozilla.org", 500, headers = mock(), body = mock()).isSuccess)
+ assertFalse(Response("https://www.mozilla.org", 302, headers = mock(), body = mock()).isSuccess)
+ }
+
+ @Test
+ fun `clientError() extension function returns true for 4xx response codes`() {
+ assertTrue(Response("https://www.mozilla.org", 404, headers = mock(), body = mock()).isClientError)
+ assertTrue(Response("https://www.mozilla.org", 403, headers = mock(), body = mock()).isClientError)
+
+ assertFalse(Response("https://www.mozilla.org", 200, headers = mock(), body = mock()).isClientError)
+ assertFalse(Response("https://www.mozilla.org", 203, headers = mock(), body = mock()).isClientError)
+ assertFalse(Response("https://www.mozilla.org", 500, headers = mock(), body = mock()).isClientError)
+ assertFalse(Response("https://www.mozilla.org", 302, headers = mock(), body = mock()).isClientError)
+ }
+
+ @Test
+ fun `Fully configured Response`() {
+ val response = Response(
+ url = "https://www.mozilla.org",
+ status = 200,
+ headers = MutableHeaders(
+ CONTENT_TYPE to "text/html; charset=utf-8",
+ "Connection" to "Close",
+ "Expires" to "Thu, 08 Nov 2018 15:41:43 GMT",
+ ),
+ body = Response.Body("Hello World".byteInputStream()),
+ )
+
+ assertEquals("https://www.mozilla.org", response.url)
+ assertEquals(200, response.status)
+ assertEquals("Hello World", response.body.string())
+
+ val headers = response.headers
+ assertEquals(3, headers.size)
+
+ assertEquals("Content-Type", headers[0].name)
+ assertEquals("Connection", headers[1].name)
+ assertEquals("Expires", headers[2].name)
+
+ assertEquals("text/html; charset=utf-8", headers[0].value)
+ assertEquals("Close", headers[1].value)
+ assertEquals("Thu, 08 Nov 2018 15:41:43 GMT", headers[2].value)
+ }
+
+ @Test
+ fun `Closing body closes stream of body`() {
+ val stream: InputStream = mock()
+ val response = Response("url", 200, MutableHeaders(), Response.Body(stream))
+
+ verify(stream, never()).close()
+
+ response.body.close()
+
+ verify(stream).close()
+ }
+
+ @Test
+ fun `Closing response closes stream of body`() {
+ val stream: InputStream = mock()
+ val response = Response("url", 200, MutableHeaders(), Response.Body(stream))
+
+ verify(stream, never()).close()
+
+ response.close()
+
+ verify(stream).close()
+ }
+
+ @Test
+ fun `Empty body`() {
+ val body = Response.Body.empty()
+ assertEquals("", body.string())
+ }
+
+ @Test
+ fun `Creating string closes stream`() {
+ val stream: InputStream = spy("".byteInputStream())
+ val body = Response.Body(stream)
+
+ verify(stream, never()).close()
+
+ body.string()
+
+ verify(stream).close()
+ }
+
+ @Test(expected = TestException::class)
+ fun `Using buffered reader closes stream`() {
+ val stream: InputStream = spy("".byteInputStream())
+ val body = Response.Body(stream)
+
+ verify(stream, never()).close()
+
+ try {
+ body.useBufferedReader {
+ throw TestException()
+ }
+ } finally {
+ verify(stream).close()
+ }
+ }
+
+ @Test(expected = TestException::class)
+ fun `Using stream closes stream`() {
+ val stream: InputStream = spy("".byteInputStream())
+ val body = Response.Body(stream)
+
+ verify(stream, never()).close()
+
+ try {
+ body.useStream {
+ throw TestException()
+ }
+ } finally {
+ verify(stream).close()
+ }
+ }
+
+ @Test
+ fun `Stream throwing on close`() {
+ val stream: InputStream = mock()
+ Mockito.doThrow(IOException()).`when`(stream).close()
+
+ val body = Response.Body(stream)
+ body.close()
+ }
+}
+
+private class TestException : RuntimeException()
diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt
new file mode 100644
index 0000000000..3237665c12
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt
@@ -0,0 +1,132 @@
+/* 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.concept.fetch.interceptor
+
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.Response
+import mozilla.components.concept.fetch.isSuccess
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class InterceptorTest {
+ @Test
+ fun `Interceptors are invoked`() {
+ var interceptorInvoked1 = false
+ var interceptorInvoked2 = false
+
+ val interceptor1 = object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ interceptorInvoked1 = true
+ return chain.proceed(chain.request)
+ }
+ }
+
+ val interceptor2 = object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ interceptorInvoked2 = true
+ return chain.proceed(chain.request)
+ }
+ }
+
+ val fake = FakeClient()
+ val client = fake.withInterceptors(interceptor1, interceptor2)
+
+ assertFalse(interceptorInvoked1)
+ assertFalse(interceptorInvoked2)
+
+ val response = client.fetch(Request(url = "https://www.mozilla.org"))
+ assertTrue(fake.resourceFetched)
+ assertTrue(response.isSuccess)
+
+ assertTrue(interceptorInvoked1)
+ assertTrue(interceptorInvoked2)
+ }
+
+ @Test
+ fun `Interceptors are invoked in order`() {
+ val order = mutableListOf<String>()
+
+ val fake = FakeClient()
+ val client = fake.withInterceptors(
+ object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ assertEquals("https://www.mozilla.org", chain.request.url)
+ order.add("A")
+ return chain.proceed(
+ chain.request.copy(
+ url = chain.request.url + "/a",
+ ),
+ )
+ }
+ },
+ object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ assertEquals("https://www.mozilla.org/a", chain.request.url)
+ order.add("B")
+ return chain.proceed(
+ chain.request.copy(
+ url = chain.request.url + "/b",
+ ),
+ )
+ }
+ },
+ object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ assertEquals("https://www.mozilla.org/a/b", chain.request.url)
+ order.add("C")
+ return chain.proceed(
+ chain.request.copy(
+ url = chain.request.url + "/c",
+ ),
+ )
+ }
+ },
+ )
+
+ val response = client.fetch(Request(url = "https://www.mozilla.org"))
+ assertTrue(fake.resourceFetched)
+ assertTrue(response.isSuccess)
+
+ assertEquals("https://www.mozilla.org/a/b/c", response.url)
+ assertEquals(listOf("A", "B", "C"), order)
+ }
+
+ @Test
+ fun `Intercepted request is never fetched`() {
+ val fake = FakeClient()
+ val client = fake.withInterceptors(
+ object : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ return Response("https://www.firefox.com", 203, MutableHeaders(), Response.Body.empty())
+ }
+ },
+ )
+
+ val response = client.fetch(Request(url = "https://www.mozilla.org"))
+ assertFalse(fake.resourceFetched)
+ assertTrue(response.isSuccess)
+ assertEquals(203, response.status)
+ }
+}
+
+private class FakeClient(
+ val response: Response? = null,
+) : Client() {
+ var resourceFetched = false
+
+ override fun fetch(request: Request): Response {
+ resourceFetched = true
+ return response ?: Response(
+ url = request.url,
+ status = 200,
+ body = Response.Body.empty(),
+ headers = MutableHeaders(),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/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/concept/fetch/src/test/resources/robolectric.properties b/mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28
diff --git a/mobile/android/android-components/components/concept/menu/build.gradle b/mobile/android/android-components/components/concept/menu/build.gradle
new file mode 100644
index 0000000000..3ba2e45a89
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/build.gradle
@@ -0,0 +1,38 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.menu'
+}
+
+dependencies {
+ implementation ComponentsDependencies.androidx_annotation
+ implementation ComponentsDependencies.androidx_appcompat
+ implementation project(':support-base')
+ implementation project(':support-ktx')
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation project(':support-test')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/menu/proguard-rules.pro b/mobile/android/android-components/components/concept/menu/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/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/concept/menu/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/menu/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/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/concept/menu/src/main/java/mozilla/components/concept/menu/MenuButton.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuButton.kt
new file mode 100644
index 0000000000..c59f25cb70
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuButton.kt
@@ -0,0 +1,47 @@
+/* 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.concept.menu
+
+import androidx.annotation.ColorInt
+import mozilla.components.concept.menu.candidate.MenuEffect
+import mozilla.components.support.base.observer.Observable
+
+/**
+ * A `three-dot` button used for expanding menus.
+ *
+ * If you are using a browser toolbar, do not use this class directly.
+ */
+interface MenuButton : Observable<MenuButton.Observer> {
+
+ /**
+ * Sets a [MenuController] that will be used to create a menu when this button is clicked.
+ */
+ var menuController: MenuController?
+
+ /**
+ * Show the indicator for a browser menu effect.
+ */
+ fun setEffect(effect: MenuEffect?)
+
+ /**
+ * Sets the tint of the 3-dot menu icon.
+ */
+ fun setColorFilter(@ColorInt color: Int)
+
+ /**
+ * Observer for the menu button.
+ */
+ interface Observer {
+ /**
+ * Listener called when the menu is shown.
+ */
+ fun onShow() = Unit
+
+ /**
+ * Listener called when the menu is dismissed.
+ */
+ fun onDismiss() = Unit
+ }
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuController.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuController.kt
new file mode 100644
index 0000000000..d3a8e0f7e6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuController.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.concept.menu
+
+import android.view.View
+import android.widget.PopupWindow
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.support.base.observer.Observable
+
+/**
+ * Controls a popup menu composed of MenuCandidate objects.
+ */
+interface MenuController : Observable<MenuController.Observer> {
+
+ /**
+ * @param anchor The view on which to pin the popup window.
+ * @param orientation The preferred orientation to show the popup window.
+ * @param autoDismiss True if the popup window should be dismissed when the device orientation
+ * is changed.
+ */
+ fun show(
+ anchor: View,
+ orientation: Orientation? = null,
+ autoDismiss: Boolean = true,
+ ): PopupWindow
+
+ /**
+ * Dismiss the menu popup if the menu is visible.
+ */
+ fun dismiss()
+
+ /**
+ * Changes the contents of the menu.
+ */
+ fun submitList(list: List<MenuCandidate>)
+
+ /**
+ * Observer for the menu controller.
+ */
+ interface Observer {
+ /**
+ * Called when the menu contents have changed.
+ */
+ fun onMenuListSubmit(list: List<MenuCandidate>) = Unit
+
+ /**
+ * Called when the menu has been dismissed.
+ */
+ fun onDismiss() = Unit
+ }
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuStyle.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuStyle.kt
new file mode 100644
index 0000000000..7db0b70b35
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/MenuStyle.kt
@@ -0,0 +1,44 @@
+/* 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.concept.menu
+
+import android.content.res.ColorStateList
+import androidx.annotation.ColorInt
+import androidx.annotation.Px
+
+/**
+ * Declare custom styles for a menu.
+ *
+ * @property backgroundColor Custom background color for the menu.
+ * @property minWidth Custom minimum width for the menu.
+ * @property maxWidth Custom maximum width for the menu.
+ * @property horizontalOffset Custom horizontal offset for the menu.
+ * @property verticalOffset Custom vertical offset for the menu.
+ * @property completelyOverlap Forces menu to overlap the anchor completely.
+ */
+data class MenuStyle(
+ val backgroundColor: ColorStateList? = null,
+ @Px val minWidth: Int? = null,
+ @Px val maxWidth: Int? = null,
+ @Px val horizontalOffset: Int? = null,
+ @Px val verticalOffset: Int? = null,
+ val completelyOverlap: Boolean = false,
+) {
+ constructor(
+ @ColorInt backgroundColor: Int,
+ @Px minWidth: Int? = null,
+ @Px maxWidth: Int? = null,
+ @Px horizontalOffset: Int? = null,
+ @Px verticalOffset: Int? = null,
+ completelyOverlap: Boolean = false,
+ ) : this(
+ backgroundColor = ColorStateList.valueOf(backgroundColor),
+ minWidth = minWidth,
+ maxWidth = maxWidth,
+ horizontalOffset = horizontalOffset,
+ verticalOffset = verticalOffset,
+ completelyOverlap = completelyOverlap,
+ )
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Orientation.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Orientation.kt
new file mode 100644
index 0000000000..60d453480c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Orientation.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.concept.menu
+
+import android.view.Gravity
+
+/**
+ * Indicates the preferred orientation to show the menu.
+ */
+enum class Orientation {
+ /**
+ * Position the menu above the toolbar.
+ */
+ UP,
+
+ /**
+ * Position the menu below the toolbar.
+ */
+ DOWN,
+
+ ;
+
+ companion object {
+
+ /**
+ * Returns an orientation that matches the given [Gravity] value.
+ * Meant to be used with a CoordinatorLayout's gravity.
+ */
+ fun fromGravity(gravity: Int): Orientation {
+ return if ((gravity and Gravity.BOTTOM) == Gravity.BOTTOM) {
+ UP
+ } else {
+ DOWN
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Side.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Side.kt
new file mode 100644
index 0000000000..6a956f6e73
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/Side.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.concept.menu
+
+/**
+ * Indicates the starting or ending side of the menu or an option.
+ */
+enum class Side {
+ /**
+ * Starting side (top or left).
+ */
+ START,
+
+ /**
+ * Ending side (bottom or right).
+ */
+ END,
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/ContainerStyle.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/ContainerStyle.kt
new file mode 100644
index 0000000000..df6242b0e9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/ContainerStyle.kt
@@ -0,0 +1,16 @@
+/* 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.concept.menu.candidate
+
+/**
+ * Describes styling for the menu option container.
+ *
+ * @property isVisible When false, the option will not be displayed.
+ * @property isEnabled When false, the option will be greyed out and disabled.
+ */
+data class ContainerStyle(
+ val isVisible: Boolean = true,
+ val isEnabled: Boolean = true,
+)
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuCandidate.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuCandidate.kt
new file mode 100644
index 0000000000..48a12909d1
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuCandidate.kt
@@ -0,0 +1,123 @@
+/* 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.concept.menu.candidate
+
+/**
+ * Menu option data classes to be shown in the browser menu.
+ */
+sealed class MenuCandidate {
+ abstract val containerStyle: ContainerStyle
+}
+
+/**
+ * Interactive menu option that displays some text.
+ *
+ * @property text Text to display.
+ * @property start Icon to display before the text.
+ * @property end Icon to display after the text.
+ * @property textStyle Styling to apply to the text.
+ * @property containerStyle Styling to apply to the container.
+ * @property effect Effects to apply to the option.
+ * @property onClick Click listener called when this menu option is clicked.
+ */
+data class TextMenuCandidate(
+ val text: String,
+ val start: MenuIcon? = null,
+ val end: MenuIcon? = null,
+ val textStyle: TextStyle = TextStyle(),
+ override val containerStyle: ContainerStyle = ContainerStyle(),
+ val effect: MenuCandidateEffect? = null,
+ val onClick: () -> Unit = {},
+) : MenuCandidate()
+
+/**
+ * Menu option that displays static text.
+ *
+ * @property text Text to display.
+ * @property height Custom height for the menu option.
+ * @property textStyle Styling to apply to the text.
+ * @property containerStyle Styling to apply to the container.
+ */
+data class DecorativeTextMenuCandidate(
+ val text: String,
+ val height: Int? = null,
+ val textStyle: TextStyle = TextStyle(),
+ override val containerStyle: ContainerStyle = ContainerStyle(),
+) : MenuCandidate()
+
+/**
+ * Menu option that shows a switch or checkbox.
+ *
+ * @property text Text to display.
+ * @property start Icon to display before the text.
+ * @property end Compound button to display after the text.
+ * @property textStyle Styling to apply to the text.
+ * @property containerStyle Styling to apply to the container.
+ * @property effect Effects to apply to the option.
+ * @property onCheckedChange Listener called when this menu option is checked or unchecked.
+ */
+data class CompoundMenuCandidate(
+ val text: String,
+ val isChecked: Boolean,
+ val start: MenuIcon? = null,
+ val end: ButtonType,
+ val textStyle: TextStyle = TextStyle(),
+ override val containerStyle: ContainerStyle = ContainerStyle(),
+ val effect: MenuCandidateEffect? = null,
+ val onCheckedChange: (Boolean) -> Unit = {},
+) : MenuCandidate() {
+
+ /**
+ * Compound button types to display with the compound menu option.
+ */
+ enum class ButtonType {
+ CHECKBOX,
+ SWITCH,
+ }
+}
+
+/**
+ * Menu option that opens a nested sub menu.
+ *
+ * @property id Unique ID for this nested menu. Can be a resource ID.
+ * @property text Text to display.
+ * @property start Icon to display before the text.
+ * @property end Icon to display after the text.
+ * @property subMenuItems Nested menu items to display.
+ * If null, this item will instead return to the root menu.
+ * @property textStyle Styling to apply to the text.
+ * @property containerStyle Styling to apply to the container.
+ * @property effect Effects to apply to the option.
+ */
+data class NestedMenuCandidate(
+ val id: Int,
+ val text: String,
+ val start: MenuIcon? = null,
+ val end: DrawableMenuIcon? = null,
+ val subMenuItems: List<MenuCandidate>? = emptyList(),
+ val textStyle: TextStyle = TextStyle(),
+ override val containerStyle: ContainerStyle = ContainerStyle(),
+ val effect: MenuCandidateEffect? = null,
+) : MenuCandidate()
+
+/**
+ * Displays a row of small menu options.
+ *
+ * @property items Small menu options to display.
+ * @property containerStyle Styling to apply to the container.
+ */
+data class RowMenuCandidate(
+ val items: List<SmallMenuCandidate>,
+ override val containerStyle: ContainerStyle = ContainerStyle(),
+) : MenuCandidate()
+
+/**
+ * Menu option to display a horizontal divider.
+ *
+ * @property containerStyle Styling to apply to the divider.
+ */
+data class DividerMenuCandidate(
+ override val containerStyle: ContainerStyle = ContainerStyle(),
+) : MenuCandidate()
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuEffect.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuEffect.kt
new file mode 100644
index 0000000000..b30104a636
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuEffect.kt
@@ -0,0 +1,46 @@
+/* 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.concept.menu.candidate
+
+import androidx.annotation.ColorInt
+
+/**
+ * Describes an effect for the menu.
+ * Effects can also alter the button to open the menu.
+ */
+sealed class MenuEffect
+
+/**
+ * Describes an effect for a menu candidate and its container.
+ * Effects can also alter the button that opens the menu.
+ */
+sealed class MenuCandidateEffect : MenuEffect()
+
+/**
+ * Describes an effect for a menu icon.
+ * Effects can also alter the button that opens the menu.
+ */
+sealed class MenuIconEffect : MenuEffect()
+
+/**
+ * Displays a notification dot.
+ * Used for highlighting new features to the user, such as what's new or a recommended feature.
+ *
+ * @property notificationTint Tint for the notification dot displayed on the icon and menu button.
+ */
+data class LowPriorityHighlightEffect(
+ @ColorInt val notificationTint: Int,
+) : MenuIconEffect()
+
+/**
+ * Changes the background of the menu item.
+ * Used for errors that require user attention, like sync errors.
+ *
+ * @property backgroundTint Tint for the menu item background color.
+ * Also used to highlight the menu button.
+ */
+data class HighPriorityHighlightEffect(
+ @ColorInt val backgroundTint: Int,
+) : MenuCandidateEffect()
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt
new file mode 100644
index 0000000000..84a8135012
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/MenuIcon.kt
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.menu.candidate
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import androidx.annotation.ColorInt
+import androidx.annotation.DrawableRes
+import androidx.appcompat.content.res.AppCompatResources
+
+/**
+ * Menu option data classes to be shown alongside menu options
+ */
+sealed class MenuIcon
+
+/**
+ * Menu icon that displays a drawable.
+ *
+ * @property drawable Drawable icon to display.
+ * @property tint Tint to apply to the drawable.
+ * @property effect Effects to apply to the icon.
+ */
+data class DrawableMenuIcon(
+ override val drawable: Drawable?,
+ @ColorInt override val tint: Int? = null,
+ val effect: MenuIconEffect? = null,
+) : MenuIcon(), MenuIconWithDrawable {
+
+ constructor(
+ context: Context,
+ @DrawableRes resource: Int,
+ @ColorInt tint: Int? = null,
+ effect: MenuIconEffect? = null,
+ ) : this(AppCompatResources.getDrawable(context, resource), tint, effect)
+}
+
+/**
+ * Menu icon that displays an image button.
+ *
+ * @property drawable Drawable icon to display.
+ * @property tint Tint to apply to the drawable.
+ * @property onClick Click listener called when this menu option is clicked.
+ */
+data class DrawableButtonMenuIcon(
+ override val drawable: Drawable?,
+ @ColorInt override val tint: Int? = null,
+ val onClick: () -> Unit = {},
+) : MenuIcon(), MenuIconWithDrawable {
+
+ constructor(
+ context: Context,
+ @DrawableRes resource: Int,
+ @ColorInt tint: Int? = null,
+ onClick: () -> Unit = {},
+ ) : this(AppCompatResources.getDrawable(context, resource), tint, onClick)
+}
+
+/**
+ * Menu icon that displays a drawable.
+ *
+ * @property loadDrawable Function that creates drawable icon to display.
+ * @property loadingDrawable Drawable that is displayed while loadDrawable is running.
+ * @property fallbackDrawable Drawable that is displayed if loadDrawable fails.
+ * @property tint Tint to apply to the drawable.
+ * @property effect Effects to apply to the icon.
+ */
+data class AsyncDrawableMenuIcon(
+ val loadDrawable: suspend (width: Int, height: Int) -> Drawable?,
+ val loadingDrawable: Drawable? = null,
+ val fallbackDrawable: Drawable? = null,
+ @ColorInt val tint: Int? = null,
+ val effect: MenuIconEffect? = null,
+) : MenuIcon()
+
+/**
+ * Menu icon to display additional text at the end of a menu option.
+ *
+ * @property text Text to display.
+ * @property backgroundTint Color to show behind text.
+ * @property textStyle Styling to apply to the text.
+ */
+data class TextMenuIcon(
+ val text: String,
+ @ColorInt val backgroundTint: Int? = null,
+ val textStyle: TextStyle = TextStyle(),
+) : MenuIcon()
+
+/**
+ * Interface shared by all [MenuIcon]s with drawables.
+ */
+interface MenuIconWithDrawable {
+ val drawable: Drawable?
+
+ @get:ColorInt val tint: Int?
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/SmallMenuCandidate.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/SmallMenuCandidate.kt
new file mode 100644
index 0000000000..51e6fd68cc
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/SmallMenuCandidate.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.concept.menu.candidate
+
+/**
+ * Small icon button menu option. Can only be used with [RowMenuCandidate].
+ *
+ * @property contentDescription Description of the icon.
+ * @property icon Icon to display.
+ * @property containerStyle Styling to apply to the container.
+ * @property onLongClick Listener called when this menu option is long clicked.
+ * @property onClick Click listener called when this menu option is clicked.
+ */
+data class SmallMenuCandidate(
+ val contentDescription: String,
+ val icon: DrawableMenuIcon,
+ val containerStyle: ContainerStyle = ContainerStyle(),
+ val onLongClick: (() -> Boolean)? = null,
+ val onClick: () -> Unit = {},
+)
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/TextStyle.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/TextStyle.kt
new file mode 100644
index 0000000000..eb85581f53
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/candidate/TextStyle.kt
@@ -0,0 +1,46 @@
+/* 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.concept.menu.candidate
+
+import android.graphics.Typeface
+import android.view.View
+import androidx.annotation.ColorInt
+import androidx.annotation.Dimension
+import androidx.annotation.IntDef
+
+/**
+ * Describes styling for text inside a menu option.
+ *
+ * @param size: The size of the text.
+ * @param color: The color to apply to the text.
+ */
+data class TextStyle(
+ @Dimension(unit = Dimension.PX) val size: Float? = null,
+ @ColorInt val color: Int? = null,
+ @TypefaceStyle val textStyle: Int = Typeface.NORMAL,
+ @TextAlignment val textAlignment: Int = View.TEXT_ALIGNMENT_INHERIT,
+)
+
+/**
+ * Enum for [Typeface] values.
+ */
+@IntDef(value = [Typeface.NORMAL, Typeface.BOLD, Typeface.ITALIC, Typeface.BOLD_ITALIC])
+annotation class TypefaceStyle
+
+/**
+ * Enum for text alignment values.
+ */
+@IntDef(
+ value = [
+ View.TEXT_ALIGNMENT_GRAVITY,
+ View.TEXT_ALIGNMENT_INHERIT,
+ View.TEXT_ALIGNMENT_CENTER,
+ View.TEXT_ALIGNMENT_TEXT_START,
+ View.TEXT_ALIGNMENT_TEXT_END,
+ View.TEXT_ALIGNMENT_VIEW_START,
+ View.TEXT_ALIGNMENT_VIEW_END,
+ ],
+)
+annotation class TextAlignment
diff --git a/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/ext/MenuCandidate.kt b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/ext/MenuCandidate.kt
new file mode 100644
index 0000000000..b10ebf4ee1
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/main/java/mozilla/components/concept/menu/ext/MenuCandidate.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.concept.menu.ext
+
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.DividerMenuCandidate
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.concept.menu.candidate.MenuEffect
+import mozilla.components.concept.menu.candidate.MenuIcon
+import mozilla.components.concept.menu.candidate.MenuIconEffect
+import mozilla.components.concept.menu.candidate.NestedMenuCandidate
+import mozilla.components.concept.menu.candidate.RowMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+
+private fun MenuIcon?.effect(): MenuIconEffect? =
+ if (this is DrawableMenuIcon) effect else null
+
+/**
+ * Find the effects used by the menu.
+ * Disabled and invisible menu items are not included.
+ */
+fun List<MenuCandidate>.effects(): Sequence<MenuEffect> = this.asSequence()
+ .filter { option -> option.containerStyle.isVisible && option.containerStyle.isEnabled }
+ .flatMap { option ->
+ when (option) {
+ is TextMenuCandidate ->
+ sequenceOf(option.effect, option.start.effect(), option.end.effect()).filterNotNull()
+ is CompoundMenuCandidate ->
+ sequenceOf(option.effect, option.start.effect()).filterNotNull()
+ is NestedMenuCandidate ->
+ sequenceOf(option.effect, option.start.effect(), option.end.effect()).filterNotNull() +
+ option.subMenuItems?.effects().orEmpty()
+ is RowMenuCandidate ->
+ option.items.asSequence()
+ .filter { it.containerStyle.isVisible && it.containerStyle.isEnabled }
+ .mapNotNull { it.icon.effect }
+ is DecorativeTextMenuCandidate, is DividerMenuCandidate -> emptySequence()
+ }
+ }
+
+/**
+ * Find a [NestedMenuCandidate] in the list with a matching [id].
+ */
+fun List<MenuCandidate>.findNestedMenuCandidate(id: Int): NestedMenuCandidate? = this.asSequence()
+ .mapNotNull { it as? NestedMenuCandidate }
+ .find { it.id == id }
+
+/**
+ * Select the highlight with the highest priority.
+ */
+fun Sequence<MenuEffect>.max() = maxByOrNull {
+ // Select the highlight with the highest priority
+ when (it) {
+ is HighPriorityHighlightEffect -> 2
+ is LowPriorityHighlightEffect -> 1
+ }
+}
diff --git a/mobile/android/android-components/components/concept/menu/src/test/java/mozilla/components/concept/menu/ext/MenuCandidateTest.kt b/mobile/android/android-components/components/concept/menu/src/test/java/mozilla/components/concept/menu/ext/MenuCandidateTest.kt
new file mode 100644
index 0000000000..5326143dd4
--- /dev/null
+++ b/mobile/android/android-components/components/concept/menu/src/test/java/mozilla/components/concept/menu/ext/MenuCandidateTest.kt
@@ -0,0 +1,179 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.menu.ext
+
+import android.graphics.Color
+import mozilla.components.concept.menu.candidate.CompoundMenuCandidate
+import mozilla.components.concept.menu.candidate.ContainerStyle
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.DividerMenuCandidate
+import mozilla.components.concept.menu.candidate.DrawableMenuIcon
+import mozilla.components.concept.menu.candidate.HighPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.LowPriorityHighlightEffect
+import mozilla.components.concept.menu.candidate.RowMenuCandidate
+import mozilla.components.concept.menu.candidate.SmallMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MenuCandidateTest {
+
+ @Test
+ fun `higher priority items will be selected by max`() {
+ assertEquals(
+ HighPriorityHighlightEffect(Color.BLACK),
+ sequenceOf(
+ LowPriorityHighlightEffect(Color.BLUE),
+ HighPriorityHighlightEffect(Color.BLACK),
+ ).max(),
+ )
+ }
+
+ @Test
+ fun `items earlier in sequence will be selected by max`() {
+ assertEquals(
+ LowPriorityHighlightEffect(Color.BLUE),
+ sequenceOf(
+ LowPriorityHighlightEffect(Color.BLUE),
+ LowPriorityHighlightEffect(Color.YELLOW),
+ ).max(),
+ )
+ }
+
+ @Test
+ fun `effects returns effects from row candidate`() {
+ assertEquals(
+ listOf(
+ LowPriorityHighlightEffect(Color.BLUE),
+ LowPriorityHighlightEffect(Color.YELLOW),
+ ),
+ listOf(
+ RowMenuCandidate(
+ listOf(
+ SmallMenuCandidate(
+ "",
+ icon = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.BLUE),
+ ),
+ ),
+ SmallMenuCandidate(
+ "",
+ icon = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.RED),
+ ),
+ containerStyle = ContainerStyle(isVisible = false),
+ ),
+ SmallMenuCandidate(
+ "",
+ icon = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.RED),
+ ),
+ containerStyle = ContainerStyle(isEnabled = false),
+ ),
+ SmallMenuCandidate(
+ "",
+ icon = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.YELLOW),
+ ),
+ ),
+ ),
+ ),
+ ).effects().toList(),
+ )
+ }
+
+ @Test
+ fun `effects returns effects from text candidates`() {
+ assertEquals(
+ listOf(
+ HighPriorityHighlightEffect(Color.BLUE),
+ LowPriorityHighlightEffect(Color.YELLOW),
+ HighPriorityHighlightEffect(Color.BLACK),
+ HighPriorityHighlightEffect(Color.BLUE),
+ LowPriorityHighlightEffect(Color.RED),
+ ),
+ listOf(
+ TextMenuCandidate(
+ "",
+ start = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.YELLOW),
+ ),
+ effect = HighPriorityHighlightEffect(Color.BLUE),
+ ),
+ DecorativeTextMenuCandidate(""),
+ TextMenuCandidate(""),
+ DividerMenuCandidate(),
+ TextMenuCandidate(
+ "",
+ effect = HighPriorityHighlightEffect(Color.BLACK),
+ ),
+ TextMenuCandidate(
+ "",
+ containerStyle = ContainerStyle(isVisible = false),
+ effect = HighPriorityHighlightEffect(Color.BLACK),
+ ),
+ TextMenuCandidate(
+ "",
+ end = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.RED),
+ ),
+ effect = HighPriorityHighlightEffect(Color.BLUE),
+ ),
+ ).effects().toList(),
+ )
+ }
+
+ @Test
+ fun `effects returns effects from compound candidates`() {
+ assertEquals(
+ listOf(
+ HighPriorityHighlightEffect(Color.BLUE),
+ LowPriorityHighlightEffect(Color.YELLOW),
+ HighPriorityHighlightEffect(Color.BLACK),
+ LowPriorityHighlightEffect(Color.RED),
+ ),
+ listOf(
+ CompoundMenuCandidate(
+ "",
+ isChecked = true,
+ start = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.YELLOW),
+ ),
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ effect = HighPriorityHighlightEffect(Color.BLUE),
+ ),
+ CompoundMenuCandidate(
+ "",
+ isChecked = false,
+ end = CompoundMenuCandidate.ButtonType.SWITCH,
+ effect = HighPriorityHighlightEffect(Color.BLACK),
+ ),
+ CompoundMenuCandidate(
+ "",
+ isChecked = false,
+ end = CompoundMenuCandidate.ButtonType.SWITCH,
+ containerStyle = ContainerStyle(isEnabled = false),
+ effect = HighPriorityHighlightEffect(Color.BLACK),
+ ),
+ CompoundMenuCandidate(
+ "",
+ isChecked = true,
+ start = DrawableMenuIcon(
+ null,
+ effect = LowPriorityHighlightEffect(Color.RED),
+ ),
+ end = CompoundMenuCandidate.ButtonType.CHECKBOX,
+ ),
+ ).effects().toList(),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/concept/push/README.md b/mobile/android/android-components/components/concept/push/README.md
new file mode 100644
index 0000000000..dd596fdc78
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/README.md
@@ -0,0 +1,23 @@
+# [Android Components](../../../README.md) > Concept > Push
+
+An abstract definition of a push service component.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md)):
+
+```Groovy
+implementation "org.mozilla.components:concept-push:{latest-version}"
+```
+
+### Implementing a Push service.
+
+TBD
+
+## 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/concept/push/build.gradle b/mobile/android/android-components/components/concept/push/build.gradle
new file mode 100644
index 0000000000..a1999b52c9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/build.gradle
@@ -0,0 +1,38 @@
+/* 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/. */
+
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.push'
+}
+
+dependencies {
+ implementation project(':support-base')
+
+ testImplementation project(':support-test')
+ testImplementation ComponentsDependencies.testing_junit
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/push/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/push/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/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/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt
new file mode 100644
index 0000000000..909ee23737
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt
@@ -0,0 +1,87 @@
+/* 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.concept.push
+
+import androidx.annotation.VisibleForTesting
+
+/**
+ * A push notification processor that handles registration and new messages from the [PushService] provided.
+ * Starting Push in the Application's onCreate is recommended.
+ */
+interface PushProcessor {
+
+ /**
+ * Start the push processor and any service associated.
+ */
+ fun initialize()
+
+ /**
+ * Removes all push subscriptions from the device.
+ */
+ fun shutdown()
+
+ /**
+ * A new registration token has been received.
+ */
+ fun onNewToken(newToken: String)
+
+ /**
+ * A new push message has been received.
+ * The message contains the payload as sent by the
+ * Autopush server, and it will be read at a lower
+ * abstraction layer.
+ */
+ fun onMessageReceived(message: Map<String, String>)
+
+ /**
+ * An error has occurred.
+ */
+ fun onError(error: PushError)
+
+ /**
+ * Requests the [PushService] to renew it's registration with it's provider.
+ */
+ fun renewRegistration()
+
+ companion object {
+ /**
+ * Initialize and installs the PushProcessor into the application.
+ * This needs to be called in the application's onCreate before a push service has started.
+ */
+ fun install(processor: PushProcessor) {
+ instance = processor
+ }
+
+ @Volatile
+ private var instance: PushProcessor? = null
+
+ @VisibleForTesting
+ internal fun reset() {
+ instance = null
+ }
+ val requireInstance: PushProcessor
+ get() = instance ?: throw IllegalStateException(
+ "You need to call PushProcessor.install() on your Push instance from Application.onCreate().",
+ )
+ }
+}
+
+/**
+ * Various error types.
+ */
+sealed class PushError(override val message: String) : Exception() {
+ data class Registration(override val message: String) : PushError(message)
+ data class Network(override val message: String) : PushError(message)
+
+ /**
+ * @property cause Original exception from Rust code.
+ */
+ data class Rust(
+ override val cause: Throwable?,
+ override val message: String = cause?.message.orEmpty(),
+ ) : PushError(message)
+ data class MalformedMessage(override val message: String) : PushError(message)
+ data class ServiceUnavailable(override val message: String) : PushError(message)
+}
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushService.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushService.kt
new file mode 100644
index 0000000000..4308fb2d1e
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushService.kt
@@ -0,0 +1,41 @@
+/* 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.concept.push
+
+import android.content.Context
+
+/**
+ * Implemented by push services like Firebase Cloud Messaging SDKs to allow
+ * the [PushProcessor] to manage their lifecycle.
+ */
+interface PushService {
+
+ /**
+ * Starts the push service.
+ */
+ fun start(context: Context)
+
+ /**
+ * Stops the push service.
+ */
+ fun stop()
+
+ /**
+ * Tells the push service to delete the registration token.
+ */
+ fun deleteToken()
+
+ /**
+ * If the push service is support on the device.
+ */
+ fun isServiceAvailable(context: Context): Boolean
+
+ companion object {
+ /**
+ * Message key for "channel ID" in a push message.
+ */
+ const val MESSAGE_KEY_CHANNEL_ID = "chid"
+ }
+}
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/exceptions/SubscriptionException.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/exceptions/SubscriptionException.kt
new file mode 100644
index 0000000000..de60f5b071
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/exceptions/SubscriptionException.kt
@@ -0,0 +1,17 @@
+/* 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/. */
+
+@file:Suppress("MatchingDeclarationName")
+
+package mozilla.components.concept.push.exceptions
+
+/**
+ * Signals that a subscription method has been invoked at an illegal or inappropriate time.
+ *
+ * See also [Exception].
+ */
+class SubscriptionException(
+ override val message: String? = null,
+ override val cause: Throwable? = null,
+) : Exception()
diff --git a/mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushErrorTest.kt b/mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushErrorTest.kt
new file mode 100644
index 0000000000..13e2013597
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushErrorTest.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.concept.push
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PushErrorTest {
+ @Test
+ fun `all PushError sets description`() {
+ // This test is mostly to satisfy coverage.
+
+ var error: PushError = PushError.MalformedMessage("message")
+ assertEquals("message", error.message)
+
+ error = PushError.Network("network")
+ assertEquals("network", error.message)
+
+ error = PushError.Registration("reg")
+ assertEquals("reg", error.message)
+
+ val exception = IllegalStateException()
+ val rustError = PushError.Rust(exception, "rust")
+ assertEquals("rust", rustError.message)
+ assertEquals(exception, rustError.cause)
+
+ error = PushError.ServiceUnavailable("service")
+ assertEquals("service", error.message)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushProcessorTest.kt b/mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushProcessorTest.kt
new file mode 100644
index 0000000000..f8fad03aed
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/test/java/mozilla/components/concept/push/PushProcessorTest.kt
@@ -0,0 +1,55 @@
+/* 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.concept.push
+
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertNotNull
+import org.junit.Before
+import org.junit.Test
+
+class PushProcessorTest {
+
+ @Before
+ fun setup() {
+ PushProcessor.reset()
+ }
+
+ @Test
+ fun install() {
+ val processor: PushProcessor = mock()
+
+ PushProcessor.install(processor)
+
+ assertNotNull(PushProcessor.requireInstance)
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `requireInstance throws if install not called first`() {
+ PushProcessor.requireInstance
+ }
+
+ @Test
+ fun init() {
+ val push = TestPushProcessor()
+
+ PushProcessor.install(push)
+
+ assertNotNull(PushProcessor.requireInstance)
+ }
+
+ class TestPushProcessor : PushProcessor {
+ override fun initialize() {}
+
+ override fun shutdown() {}
+
+ override fun onNewToken(newToken: String) {}
+
+ override fun onMessageReceived(message: Map<String, String>) {}
+
+ override fun onError(error: PushError) {}
+
+ override fun renewRegistration() {}
+ }
+}
diff --git a/mobile/android/android-components/components/concept/push/src/test/resources/robolectric.properties b/mobile/android/android-components/components/concept/push/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28
diff --git a/mobile/android/android-components/components/concept/storage/README.md b/mobile/android/android-components/components/concept/storage/README.md
new file mode 100644
index 0000000000..5afcfb9e29
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/README.md
@@ -0,0 +1,28 @@
+# [Android Components](../../../README.md) > Concept > Storage
+
+The `concept-storage` component contains interfaces and abstract classes that describe a "core data" storage layer.
+
+This abstraction makes it possible to build components that work independently of the storage layer being used.
+
+Currently a single store implementation is available:
+- [syncable, Rust Places storage](../../browser/storage-sync) - compatible with the Firefox Sync ecosystem
+
+## 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:concept-storage:{latest-version}"
+```
+
+### Integration
+
+One way to interact with a `concept-storage` component is via [feature-storage](../../features/storage/README.md), which provides "glue" implementations that make use of storage. For example, a `features.storage.HistoryTrackingFeature` allows a `concept.engine.Engine` to keep track of visits and page meta information.
+
+## 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/concept/storage/build.gradle b/mobile/android/android-components/components/concept/storage/build.gradle
new file mode 100644
index 0000000000..b12cce53ae
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/build.gradle
@@ -0,0 +1,41 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-parcelize'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.storage'
+}
+
+dependencies {
+ // Necessary because we use 'suspend'. Fun fact: this module will compile just fine without this
+ // dependency, but it will crash at runtime.
+ // Included via 'api' because this module is unusable without coroutines.
+ api ComponentsDependencies.kotlin_coroutines
+
+ implementation project(':support-ktx')
+ implementation ComponentsDependencies.androidx_annotation
+
+ testImplementation project(':support-test')
+ testImplementation ComponentsDependencies.testing_junit
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/storage/proguard-rules.pro b/mobile/android/android-components/components/concept/storage/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/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/concept/storage/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/storage/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/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/concept/storage/src/main/java/mozilla/components/concept/storage/BookmarksStorage.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/BookmarksStorage.kt
new file mode 100644
index 0000000000..85050cea94
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/BookmarksStorage.kt
@@ -0,0 +1,182 @@
+/* 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.concept.storage
+
+/**
+ * An interface which defines read/write operations for bookmarks data.
+ */
+interface BookmarksStorage : Storage {
+
+ /**
+ * Produces a bookmarks tree for the given guid string.
+ *
+ * @param guid The bookmark guid to obtain.
+ * @param recursive Whether to recurse and obtain all levels of children.
+ * @return The populated root starting from the guid.
+ */
+ suspend fun getTree(guid: String, recursive: Boolean = false): BookmarkNode?
+
+ /**
+ * Obtains the details of a bookmark without children, if one exists with that guid. Otherwise, null.
+ *
+ * @param guid The bookmark guid to obtain.
+ * @return The bookmark node or null if it does not exist.
+ */
+ suspend fun getBookmark(guid: String): BookmarkNode?
+
+ /**
+ * Produces a list of all bookmarks with the given URL.
+ *
+ * @param url The URL string.
+ * @return The list of bookmarks that match the URL
+ */
+ suspend fun getBookmarksWithUrl(url: String): List<BookmarkNode>
+
+ /**
+ * Produces a list of the most recently added bookmarks.
+ *
+ * @param limit The maximum number of entries to return.
+ * @param maxAge Optional parameter used to filter out entries older than this number of milliseconds.
+ * @param currentTime Optional parameter for current time. Defaults toSystem.currentTimeMillis()
+ * @return The list of bookmarks that have been recently added up to the limit number of items.
+ */
+ suspend fun getRecentBookmarks(
+ limit: Int,
+ maxAge: Long? = null,
+ currentTime: Long = System.currentTimeMillis(),
+ ): List<BookmarkNode>
+
+ /**
+ * Searches bookmarks with a query string.
+ *
+ * @param query The query string to search.
+ * @param limit The maximum number of entries to return.
+ * @return The list of matching bookmark nodes up to the limit number of items.
+ */
+ suspend fun searchBookmarks(query: String, limit: Int = defaultBookmarkSearchLimit): List<BookmarkNode>
+
+ /**
+ * Adds a new bookmark item to a given node.
+ *
+ * Sync behavior: will add new bookmark item to remote devices.
+ *
+ * @param parentGuid The parent guid of the new node.
+ * @param url The URL of the bookmark item to add.
+ * @param title The title of the bookmark item to add.
+ * @param position The optional position to add the new node or null to append.
+ * @return The guid of the newly inserted bookmark item.
+ */
+ suspend fun addItem(parentGuid: String, url: String, title: String, position: UInt?): String
+
+ /**
+ * Adds a new bookmark folder to a given node.
+ *
+ * Sync behavior: will add new separator to remote devices.
+ *
+ * @param parentGuid The parent guid of the new node.
+ * @param title The title of the bookmark folder to add.
+ * @param position The optional position to add the new node or null to append.
+ * @return The guid of the newly inserted bookmark item.
+ */
+ suspend fun addFolder(parentGuid: String, title: String, position: UInt? = null): String
+
+ /**
+ * Adds a new bookmark separator to a given node.
+ *
+ * Sync behavior: will add new separator to remote devices.
+ *
+ * @param parentGuid The parent guid of the new node.
+ * @param position The optional position to add the new node or null to append.
+ * @return The guid of the newly inserted bookmark item.
+ */
+ suspend fun addSeparator(parentGuid: String, position: UInt?): String
+
+ /**
+ * Edits the properties of an existing bookmark item and/or moves an existing one underneath a new parent guid.
+ *
+ * Sync behavior: will alter bookmark item on remote devices.
+ *
+ * @param guid The guid of the item to update.
+ * @param info The info to change in the bookmark.
+ */
+ suspend fun updateNode(guid: String, info: BookmarkInfo)
+
+ /**
+ * Deletes a bookmark node and all of its children, if any.
+ *
+ * Sync behavior: will remove bookmark from remote devices.
+ *
+ * @return Whether the bookmark existed or not.
+ */
+ suspend fun deleteNode(guid: String): Boolean
+
+ /**
+ * Counts the number of bookmarks in the trees under the specified GUIDs.
+
+ * @param guids The guids of folders to query.
+ * @return Count of all bookmark items (ie, no folders or separators) in all specified folders
+ * recursively. Empty folders, non-existing GUIDs and non-existing items will return zero.
+ * The result is implementation dependant if the trees overlap.
+ */
+ suspend fun countBookmarksInTrees(guids: List<String>): UInt
+
+ companion object {
+ const val defaultBookmarkSearchLimit = 10
+ }
+}
+
+/**
+ * Represents a bookmark record.
+ *
+ * @property type The [BookmarkNodeType] of this record.
+ * @property guid The id.
+ * @property parentGuid The id of the parent node in the tree.
+ * @property position The position of this node in the tree.
+ * @property title A title of the page.
+ * @property url The url of the page.
+ * @property dateAdded Creation time, in milliseconds since the unix epoch.
+ * @property children The list of children of this bookmark node in the tree.
+ */
+data class BookmarkNode(
+ val type: BookmarkNodeType,
+ val guid: String,
+ val parentGuid: String?,
+ val position: UInt?,
+ val title: String?,
+ val url: String?,
+ val dateAdded: Long,
+ val children: List<BookmarkNode>?,
+) {
+ /**
+ * Removes [children] from [BookmarkNode.children] and returns the new modified [BookmarkNode].
+ *
+ * DOES NOT delete the bookmarks from storage, so this should only be used where you are
+ * batching deletes, or where the deletes are otherwise pending.
+ *
+ * In the general case you should try and avoid using this - just delete the items from
+ * storage then re-fetch the parent node.
+ */
+ operator fun minus(children: Set<BookmarkNode>): BookmarkNode {
+ val removedChildrenGuids = children.map { it.guid }
+ return this.copy(children = this.children?.filterNot { removedChildrenGuids.contains(it.guid) })
+ }
+}
+
+/**
+ * Class for making alterations to any bookmark node
+ */
+data class BookmarkInfo(
+ val parentGuid: String?,
+ val position: UInt?,
+ val title: String?,
+ val url: String?,
+)
+
+/**
+ * The types of bookmark nodes
+ */
+enum class BookmarkNodeType {
+ ITEM, FOLDER, SEPARATOR
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Cancellable.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Cancellable.kt
new file mode 100644
index 0000000000..ade91321c9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Cancellable.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.concept.storage
+
+/**
+ * Storage that allows to stop and clean in progress operations.
+ */
+interface Cancellable {
+ /**
+ * Cleans up all background work and operations queue.
+ */
+ fun cleanup() {
+ // no-op
+ }
+
+ /**
+ * Cleans up all pending write operations.
+ */
+ fun cancelWrites() {
+ // no-op
+ }
+
+ /**
+ * Cleans up all pending read operations.
+ */
+ fun cancelReads() {
+ // no-op
+ }
+
+ /**
+ * Cleans up pending read operations in preparation for a new query.
+ * This is useful when the same storage is shared between multiple functionalities and will
+ * allow preventing overlapped cancel requests.
+ *
+ * @param nextQuery Next query to cancel reads for.
+ */
+ fun cancelReads(nextQuery: String) {
+ // no-op
+ }
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt
new file mode 100644
index 0000000000..f619a9d1e9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt
@@ -0,0 +1,503 @@
+/* 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.concept.storage
+
+import android.annotation.SuppressLint
+import android.os.Parcelable
+import androidx.annotation.VisibleForTesting
+import kotlinx.parcelize.Parcelize
+import mozilla.components.concept.storage.CreditCard.Companion.ellipsesEnd
+import mozilla.components.concept.storage.CreditCard.Companion.ellipsesStart
+import mozilla.components.concept.storage.CreditCard.Companion.ellipsis
+import mozilla.components.support.ktx.kotlin.last4Digits
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+/**
+ * An interface which defines read/write methods for credit card and address data.
+ */
+interface CreditCardsAddressesStorage {
+
+ /**
+ * Inserts the provided credit card into the database, and returns
+ * the newly added [CreditCard].
+ *
+ * @param creditCardFields A [NewCreditCardFields] record to add.
+ * @return [CreditCard] for the added credit card.
+ */
+ suspend fun addCreditCard(creditCardFields: NewCreditCardFields): CreditCard
+
+ /**
+ * Updates the fields in the provided credit card.
+ *
+ * @param guid Unique identifier for the desired credit card.
+ * @param creditCardFields A set of credit card fields, wrapped in [UpdatableCreditCardFields], to update.
+ */
+ suspend fun updateCreditCard(guid: String, creditCardFields: UpdatableCreditCardFields)
+
+ /**
+ * Retrieves the credit card from the underlying storage layer by its unique identifier.
+ *
+ * @param guid Unique identifier for the desired credit card.
+ * @return [CreditCard] record if it exists or null otherwise.
+ */
+ suspend fun getCreditCard(guid: String): CreditCard?
+
+ /**
+ * Retrieves a list of all the credit cards.
+ *
+ * @return A list of all [CreditCard].
+ */
+ suspend fun getAllCreditCards(): List<CreditCard>
+
+ /**
+ * Deletes the credit card with the given [guid].
+ *
+ * @param guid Unique identifier for the desired credit card.
+ * @return True if the deletion did anything, false otherwise.
+ */
+ suspend fun deleteCreditCard(guid: String): Boolean
+
+ /**
+ * Marks the credit card with the given [guid] as `in-use`.
+ *
+ * @param guid Unique identifier for the desired credit card.
+ */
+ suspend fun touchCreditCard(guid: String)
+
+ /**
+ * Inserts the provided address into the database, and returns
+ * the newly added [Address].
+ *
+ * @param addressFields A [UpdatableAddressFields] record to add.
+ * @return [Address] for the added address.
+ */
+ suspend fun addAddress(addressFields: UpdatableAddressFields): Address
+
+ /**
+ * Retrieves the address from the underlying storage layer by its unique identifier.
+ *
+ * @param guid Unique identifier for the desired address.
+ * @return [Address] record if it exists or null otherwise.
+ */
+ suspend fun getAddress(guid: String): Address?
+
+ /**
+ * Retrieves a list of all the addresses.
+ *
+ * @return A list of all [Address].
+ */
+ suspend fun getAllAddresses(): List<Address>
+
+ /**
+ * Updates the fields in the provided address.
+ *
+ * @param guid Unique identifier for the desired address.
+ * @param address The address fields to update.
+ */
+ suspend fun updateAddress(guid: String, address: UpdatableAddressFields)
+
+ /**
+ * Delete the address with the given [guid].
+ *
+ * @return True if the deletion did anything, false otherwise.
+ */
+ suspend fun deleteAddress(guid: String): Boolean
+
+ /**
+ * Marks the address with the given [guid] as `in-use`.
+ *
+ * @param guid Unique identifier for the desired address.
+ */
+ suspend fun touchAddress(guid: String)
+
+ /**
+ * Returns an instance of [CreditCardCrypto] that knows how to encrypt and decrypt credit card
+ * numbers.
+ *
+ * @return [CreditCardCrypto] instance.
+ */
+ fun getCreditCardCrypto(): CreditCardCrypto
+
+ /**
+ * Removes any encrypted data from this storage. Useful after encountering key loss.
+ */
+ suspend fun scrubEncryptedData()
+}
+
+/**
+ * An interface that defines methods for encrypting and decrypting a credit card number.
+ */
+interface CreditCardCrypto : KeyProvider {
+
+ /**
+ * Encrypt a [CreditCardNumber.Plaintext] using the provided key. A `null` result means a
+ * bad key was provided. In that case caller should obtain a new key and try again.
+ *
+ * @param key The encryption key to encrypt the plaintext credit card number.
+ * @param plaintextCardNumber A plaintext credit card number to be encrypted.
+ * @return An encrypted credit card number or `null` if a bad [key] was provided.
+ */
+ fun encrypt(
+ key: ManagedKey,
+ plaintextCardNumber: CreditCardNumber.Plaintext,
+ ): CreditCardNumber.Encrypted?
+
+ /**
+ * Decrypt a [CreditCardNumber.Encrypted] using the provided key. A `null` result means a
+ * bad key was provided. In that case caller should obtain a new key and try again.
+ *
+ * @param key The encryption key to decrypt the decrypt credit card number.
+ * @param encryptedCardNumber An encrypted credit card number to be decrypted.
+ * @return A plaintext, non-encrypted credit card number or `null` if a bad [key] was provided.
+ */
+ fun decrypt(
+ key: ManagedKey,
+ encryptedCardNumber: CreditCardNumber.Encrypted,
+ ): CreditCardNumber.Plaintext?
+}
+
+/**
+ * A credit card number. This structure exists to provide better typing at the API surface.
+ *
+ * @property number Either a plaintext or a ciphertext of the credit card number, depending on the subtype.
+ */
+sealed class CreditCardNumber(val number: String) {
+ /**
+ * An encrypted credit card number.
+ */
+ @SuppressLint("ParcelCreator")
+ @Parcelize
+ data class Encrypted(private val data: String) : CreditCardNumber(data), Parcelable
+
+ /**
+ * A plaintext, non-encrypted credit card number.
+ */
+ data class Plaintext(private val data: String) : CreditCardNumber(data)
+}
+
+/**
+ * Information about a credit card.
+ *
+ * @property guid The unique identifier for this credit card.
+ * @property billingName The credit card billing name.
+ * @property encryptedCardNumber The encrypted credit card number.
+ * @property cardNumberLast4 The last 4 digits of the credit card number.
+ * @property expiryMonth The credit card expiry month.
+ * @property expiryYear The credit card expiry year.
+ * @property cardType The credit card network ID.
+ * @property timeCreated Time of creation in milliseconds from the unix epoch.
+ * @property timeLastUsed Time of last use in milliseconds from the unix epoch.
+ * @property timeLastModified Time of last modified in milliseconds from the unix epoch.
+ * @property timesUsed Number of times the credit card was used.
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class CreditCard(
+ val guid: String,
+ val billingName: String,
+ val encryptedCardNumber: CreditCardNumber.Encrypted,
+ val cardNumberLast4: String,
+ val expiryMonth: Long,
+ val expiryYear: Long,
+ val cardType: String,
+ val timeCreated: Long = 0L,
+ val timeLastUsed: Long? = 0L,
+ val timeLastModified: Long = 0L,
+ val timesUsed: Long = 0L,
+) : Parcelable {
+ val obfuscatedCardNumber: String
+ get() = ellipsesStart +
+ ellipsis + ellipsis + ellipsis + ellipsis +
+ cardNumberLast4 +
+ ellipsesEnd
+
+ companion object {
+ // Left-To-Right Embedding (LTE) mark
+ const val ellipsesStart = "\u202A"
+
+ // One dot ellipsis
+ const val ellipsis = "\u2022\u2060\u2006\u2060"
+
+ // Pop Directional Formatting (PDF) mark
+ const val ellipsesEnd = "\u202C"
+ }
+}
+
+/**
+ * Credit card autofill entry.
+ *
+ * This contains the data needed to handle autofill but not the data related to the DB record.
+ *
+ * @property guid The unique identifier for this credit card.
+ * @property name The credit card billing name.
+ * @property number The credit card number.
+ * @property expiryMonth The credit card expiry month.
+ * @property expiryYear The credit card expiry year.
+ * @property cardType The credit card network ID.
+ */
+@Parcelize
+data class CreditCardEntry(
+ val guid: String? = null,
+ val name: String,
+ val number: String,
+ val expiryMonth: String,
+ val expiryYear: String,
+ val cardType: String,
+) : Parcelable {
+ val obfuscatedCardNumber: String
+ get() = ellipsesStart +
+ ellipsis + ellipsis + ellipsis + ellipsis +
+ number.last4Digits() +
+ ellipsesEnd
+
+ /**
+ * Credit card expiry date formatted according to the locale. Returns an empty string if either
+ * the expiration month or expiration year is not set.
+ */
+ val expiryDate: String
+ get() {
+ return if (expiryMonth.isEmpty() || expiryYear.isEmpty()) {
+ ""
+ } else {
+ val dateFormat = SimpleDateFormat(DATE_PATTERN, Locale.getDefault())
+
+ val calendar = Calendar.getInstance()
+ calendar.set(Calendar.DAY_OF_MONTH, 1)
+ // Subtract 1 from the expiry month since Calendar.Month is based on a 0-indexed.
+ calendar.set(Calendar.MONTH, expiryMonth.toInt() - 1)
+ calendar.set(Calendar.YEAR, expiryYear.toInt())
+
+ dateFormat.format(calendar.time)
+ }
+ }
+
+ /**
+ * Whether this entry contains all data needed to be considered well-formed.
+ */
+ val isValid: Boolean
+ get() = number.isNotEmpty() && expiryDate.isNotEmpty()
+
+ companion object {
+ // Date format pattern for the credit card expiry date.
+ private const val DATE_PATTERN = "MM/yyyy"
+ }
+}
+
+/**
+ * Information about a new credit card.
+ * Use this when creating a credit card via [CreditCardsAddressesStorage.addCreditCard].
+ *
+ * @property billingName The credit card billing name.
+ * @property plaintextCardNumber A plaintext credit card number.
+ * @property cardNumberLast4 The last 4 digits of the credit card number.
+ * @property expiryMonth The credit card expiry month.
+ * @property expiryYear The credit card expiry year.
+ * @property cardType The credit card network ID.
+ */
+data class NewCreditCardFields(
+ val billingName: String,
+ val plaintextCardNumber: CreditCardNumber.Plaintext,
+ val cardNumberLast4: String,
+ val expiryMonth: Long,
+ val expiryYear: Long,
+ val cardType: String,
+)
+
+/**
+ * Information about a new credit card.
+ * Use this when creating a credit card via [CreditCardsAddressesStorage.updateAddress].
+ *
+ * @property billingName The credit card billing name.
+ * @property cardNumber A [CreditCardNumber] that is either encrypted or plaintext. Passing in plaintext
+ * version will update the stored credit card number.
+ * @property cardNumberLast4 The last 4 digits of the credit card number.
+ * @property expiryMonth The credit card expiry month.
+ * @property expiryYear The credit card expiry year.
+ * @property cardType The credit card network ID.
+ */
+data class UpdatableCreditCardFields(
+ val billingName: String,
+ val cardNumber: CreditCardNumber,
+ val cardNumberLast4: String,
+ val expiryMonth: Long,
+ val expiryYear: Long,
+ val cardType: String,
+)
+
+/**
+ * Information about a address.
+ *
+ * @property guid The unique identifier for this address.
+ * @property name A person's full name, typically made up of a first, middle and last name, e.g. John Joe Doe.
+ * @property organization Organization.
+ * @property streetAddress Street address.
+ * @property addressLevel3 Sublocality (Suburb) name type.
+ * @property addressLevel2 Locality (City/Town) name type.
+ * @property addressLevel1 Province/State name type.
+ * @property postalCode Postal code.
+ * @property country Country.
+ * @property tel Telephone number.
+ * @property email E-mail address.
+ * @property timeCreated Time of creation in milliseconds from the unix epoch.
+ * @property timeLastUsed Time of last use in milliseconds from the unix epoch.
+ * @property timeLastModified Time of last modified in milliseconds from the unix epoch.
+ * @property timesUsed Number of times the address was used.
+ */
+@SuppressLint("ParcelCreator")
+@Parcelize
+data class Address(
+ val guid: String,
+ val name: String,
+ val organization: String,
+ val streetAddress: String,
+ val addressLevel3: String,
+ val addressLevel2: String,
+ val addressLevel1: String,
+ val postalCode: String,
+ val country: String,
+ val tel: String,
+ val email: String,
+ val timeCreated: Long = 0L,
+ val timeLastUsed: Long? = 0L,
+ val timeLastModified: Long = 0L,
+ val timesUsed: Long = 0L,
+) : Parcelable {
+
+ /**
+ * Returns a label for the [Address]. The ordering is based on the
+ * priorities defined by the desktop code found here:
+ * https://searchfox.org/mozilla-central/rev/d989c65584ded72c2de85cb40bede7ac2f176387/toolkit/components/formautofill/FormAutofillUtils.jsm#323
+ */
+ val addressLabel: String
+ get() = listOf(
+ streetAddress.toOneLineAddress(),
+ addressLevel3,
+ addressLevel2,
+ organization,
+ addressLevel1,
+ country,
+ postalCode,
+ tel,
+ email,
+ ).filter { it.isNotEmpty() }.joinToString(", ")
+
+ companion object {
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun String.toOneLineAddress(): String =
+ this.split("\n").joinToString(separator = " ") { it.trim() }
+ }
+}
+
+/**
+ * Information about a new address. This is what you pass to create or update an address.
+ *
+ * @property name A person's full name, typically made up of a first, middle and last name, e.g. John Joe Doe.
+ * @property organization Organization.
+ * @property streetAddress Street address.
+ * @property addressLevel3 Sublocality (Suburb) name type.
+ * @property addressLevel2 Locality (City/Town) name type.
+ * @property addressLevel1 Province/State name type.
+ * @property postalCode Postal code.
+ * @property country Country.
+ * @property tel Telephone number.
+ * @property email E-mail address.
+ */
+data class UpdatableAddressFields(
+ val name: String,
+ val organization: String,
+ val streetAddress: String,
+ val addressLevel3: String,
+ val addressLevel2: String,
+ val addressLevel1: String,
+ val postalCode: String,
+ val country: String,
+ val tel: String,
+ val email: String,
+)
+
+/**
+ * Provides a method for checking whether or not a given credit card can be stored.
+ */
+interface CreditCardValidationDelegate {
+
+ /**
+ * The result from validating a given [CreditCard] against the credit card storage. This will
+ * include whether or not it can be created or updated.
+ */
+ sealed class Result {
+ /**
+ * Indicates that the [CreditCard] does not currently exist in the storage, and a new
+ * credit card entry can be created.
+ */
+ object CanBeCreated : Result()
+
+ /**
+ * Indicates that a matching [CreditCard] was found in the storage, and the [CreditCard]
+ * can be used to update its information.
+ */
+ data class CanBeUpdated(val foundCreditCard: CreditCard) : Result()
+ }
+
+ /**
+ * Determines whether a [CreditCardEntry] can be added or updated in the credit card storage.
+ *
+ * @param creditCard [CreditCardEntry] to be added or updated in the credit card storage.
+ * @return [Result] that indicates whether or not the [CreditCardEntry] should be saved or
+ * updated.
+ */
+ suspend fun shouldCreateOrUpdate(creditCard: CreditCardEntry): Result
+}
+
+/**
+ * Used to handle [Address] and [CreditCard] storage so that the underlying engine doesn't have to.
+ * An instance of this should be attached to the Gecko runtime in order to be used.
+ */
+interface CreditCardsAddressesStorageDelegate : KeyProvider {
+
+ /**
+ * Decrypt a [CreditCardNumber.Encrypted] into its plaintext equivalent or `null` if
+ * it fails to decrypt.
+ *
+ * @param key The encryption key to decrypt the decrypt credit card number.
+ * @param encryptedCardNumber An encrypted credit card number to be decrypted.
+ * @return A plaintext, non-encrypted credit card number.
+ */
+ suspend fun decrypt(
+ key: ManagedKey,
+ encryptedCardNumber: CreditCardNumber.Encrypted,
+ ): CreditCardNumber.Plaintext?
+
+ /**
+ * Returns all stored addresses. This is called when the engine believes an address field
+ * should be autofilled.
+ *
+ * @return A list of all stored addresses.
+ */
+ suspend fun onAddressesFetch(): List<Address>
+
+ /**
+ * Saves the given address to storage.
+ *
+ * @param address [Address] to be saved or updated in the address storage.
+ */
+ suspend fun onAddressSave(address: Address)
+
+ /**
+ * Returns all stored credit cards. This is called when the engine believes a credit card
+ * field should be autofilled.
+ *
+ * @return A list of all stored credit cards.
+ */
+ suspend fun onCreditCardsFetch(): List<CreditCard>
+
+ /**
+ * Saves the given credit card to storage.
+ *
+ * @param creditCard [CreditCardEntry] to be saved or updated in the credit card storage.
+ */
+ suspend fun onCreditCardSave(creditCard: CreditCardEntry)
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryMetadataStorage.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryMetadataStorage.kt
new file mode 100644
index 0000000000..56d9315f29
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryMetadataStorage.kt
@@ -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/. */
+
+package mozilla.components.concept.storage
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * The possible document types to record history metadata for.
+ */
+enum class DocumentType {
+ Regular,
+ Media,
+}
+
+/**
+ * Represents the different types of history metadata observations.
+ */
+sealed class HistoryMetadataObservation {
+ /**
+ * A [HistoryMetadataObservation] to increment the total view time.
+ */
+ data class ViewTimeObservation(
+ val viewTime: Int,
+ ) : HistoryMetadataObservation()
+
+ /**
+ * A [HistoryMetadataObservation] to update the document type.
+ */
+ data class DocumentTypeObservation(
+ val documentType: DocumentType,
+ ) : HistoryMetadataObservation()
+}
+
+/**
+ * Represents a set of history metadata values that uniquely identify a record. Note that
+ * when recording observations, the same set of values may or may not cause a new record to be
+ * created, depending on the de-bouncing logic of the underlying storage i.e. recording history
+ * metadata observations with the exact same values may be combined into a single record.
+ *
+ * @property url A url of the page.
+ * @property searchTerm An optional search term if this record was
+ * created as part of a search by the user.
+ * @property referrerUrl An optional url of the parent/referrer if
+ * this record was created in response to a user opening
+ * a page in a new tab.
+ */
+@Parcelize
+data class HistoryMetadataKey(
+ val url: String,
+ val searchTerm: String? = null,
+ val referrerUrl: String? = null,
+) : Parcelable
+
+/**
+ * Represents a history metadata record, which describes metadata for a history visit, such as metadata
+ * about the page itself as well as metadata about how the page was opened.
+ *
+ * @property key The [HistoryMetadataKey] of this record.
+ * @property title A title of the page.
+ * @property createdAt When this metadata record was created.
+ * @property updatedAt The last time this record was updated.
+ * @property totalViewTime Total time the user viewed the page associated with this record.
+ * @property documentType The [DocumentType] of the page.
+ * @property previewImageUrl A preview image of the page (a.k.a. the hero image), if available.
+ */
+data class HistoryMetadata(
+ val key: HistoryMetadataKey,
+ val title: String?,
+ val createdAt: Long,
+ val updatedAt: Long,
+ val totalViewTime: Int,
+ val documentType: DocumentType,
+ val previewImageUrl: String?,
+)
+
+/**
+ * Represents a history highlight, a URL of interest.
+ * The highlights are produced via [HistoryMetadataStorage.getHistoryHighlights].
+ *
+ * @param score A relative score of this highlight. Useful to compare against other highlights.
+ * @param placeId An ID of the history entry ("page") represented by this highlight.
+ * @param url A url of the page.
+ * @param title A title of the page, if available.
+ * @param previewImageUrl A preview image of the page (a.k.a. the hero image), if available.
+ */
+data class HistoryHighlight(
+ val score: Double,
+ val placeId: Int,
+ val url: String,
+ val title: String?,
+ val previewImageUrl: String?,
+)
+
+/**
+ * Weights of factors that contribute to ranking [HistoryHighlight].
+ * An input to [HistoryMetadataStorage.getHistoryHighlights].
+ * For example, (1.0, 1.0) for equal weights. Equal weights represent equal importance of these
+ * factors during ranking.
+ *
+ * @param viewTime A weight specifying importance of cumulative view time of a page.
+ * @param frequency A weight specifying importance of frequency of visits to a page.
+ */
+data class HistoryHighlightWeights(
+ val viewTime: Double,
+ val frequency: Double,
+)
+
+/**
+ * An interface for interacting with a storage that manages [HistoryMetadata].
+ */
+interface HistoryMetadataStorage : Cancellable {
+ /**
+ * Returns the most recent [HistoryMetadata] for the provided [url].
+ *
+ * @param url Url to search by.
+ * @return [HistoryMetadata] if there's a matching record, `null` otherwise.
+ */
+ suspend fun getLatestHistoryMetadataForUrl(url: String): HistoryMetadata?
+
+ /**
+ * Returns all [HistoryMetadata] where [HistoryMetadata.updatedAt] is greater or equal to [since].
+ *
+ * @param since Timestamp to search by.
+ * @return A `List` of matching [HistoryMetadata], ordered by [HistoryMetadata.updatedAt] DESC.
+ * Empty if nothing is found.
+ */
+ suspend fun getHistoryMetadataSince(since: Long): List<HistoryMetadata>
+
+ /**
+ * Returns all [HistoryMetadata] where [HistoryMetadata.updatedAt] is between [start] and [end], inclusive.
+ *
+ * @param start A `start` timestamp.
+ * @param end An `end` timestamp.
+ * @return A `List` of matching [HistoryMetadata], ordered by [HistoryMetadata.updatedAt] DESC.
+ * Empty if nothing is found.
+ */
+ suspend fun getHistoryMetadataBetween(start: Long, end: Long): List<HistoryMetadata>
+
+ /**
+ * Searches through [HistoryMetadata] by [query], matching records by [HistoryMetadataKey.url],
+ * [HistoryMetadata.title] and [HistoryMetadataKey.searchTerm].
+ *
+ * @param query A search query.
+ * @param limit A maximum number of records to return.
+ * @return A `List` of matching [HistoryMetadata], ordered by [HistoryMetadata.updatedAt] DESC.
+ * Empty if nothing is found.
+ */
+ suspend fun queryHistoryMetadata(query: String, limit: Int): List<HistoryMetadata>
+
+ /**
+ * Returns a list of [HistoryHighlight] objects, ranked relative to each other according to [weights].
+ *
+ * @param weights A set of weights used by the ranking algorithm.
+ * @param limit A maximum number of records to return.
+ * @return A `List` of [HistoryHighlight], ordered by [HistoryHighlight.score] DESC.
+ * Empty if nothing is found.
+ */
+ suspend fun getHistoryHighlights(weights: HistoryHighlightWeights, limit: Int): List<HistoryHighlight>
+
+ /**
+ * Records the provided [HistoryMetadataObservation] and updates the record identified by the
+ * provided [HistoryMetadataKey].
+ *
+ * @param key the [HistoryMetadataKey] identifying the metadata records
+ * @param observation the [HistoryMetadataObservation] to record.
+ */
+ suspend fun noteHistoryMetadataObservation(key: HistoryMetadataKey, observation: HistoryMetadataObservation)
+
+ /**
+ * Deletes [HistoryMetadata] with [HistoryMetadata.updatedAt] older than [olderThan].
+ *
+ * @param olderThan A timestamp to delete records by. Exclusive.
+ */
+ suspend fun deleteHistoryMetadataOlderThan(olderThan: Long)
+
+ /**
+ * Deletes metadata records that match [HistoryMetadataKey].
+ */
+ suspend fun deleteHistoryMetadata(key: HistoryMetadataKey)
+
+ /**
+ * Deletes metadata records that match [searchTerm] (case insensitive).
+ */
+ suspend fun deleteHistoryMetadata(searchTerm: String)
+
+ /**
+ * Deletes all metadata records for the provided [url].
+ */
+ suspend fun deleteHistoryMetadataForUrl(url: String)
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryStorage.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryStorage.kt
new file mode 100644
index 0000000000..20af370f62
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/HistoryStorage.kt
@@ -0,0 +1,237 @@
+/* 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.concept.storage
+
+/**
+ * An interface which defines read/write methods for history data.
+ */
+interface HistoryStorage : Storage {
+ /**
+ * Records a visit to a page.
+ * @param uri of the page which was visited.
+ * @param visit Information about the visit; see [PageVisit].
+ */
+ suspend fun recordVisit(uri: String, visit: PageVisit)
+
+ /**
+ * Records an observation about a page.
+ * @param uri of the page for which to record an observation.
+ * @param observation a [PageObservation] which encapsulates meta data observed about the page.
+ */
+ suspend fun recordObservation(uri: String, observation: PageObservation)
+
+ /**
+ * @return True if provided [uri] can be added to the storage layer.
+ */
+ fun canAddUri(uri: String): Boolean
+
+ /**
+ * Maps a list of page URIs to a list of booleans indicating if each URI was visited.
+ * @param uris a list of page URIs about which "visited" information is being requested.
+ * @return A list of booleans indicating visited status of each
+ * corresponding page URI from [uris].
+ */
+ suspend fun getVisited(uris: List<String>): List<Boolean>
+
+ /**
+ * Retrieves a list of all visited pages.
+ * @return A list of all visited page URIs.
+ */
+ suspend fun getVisited(): List<String>
+
+ /**
+ * Retrieves detailed information about all visits that occurred in the given time range.
+ * @param start The (inclusive) start time to bound the query.
+ * @param end The (inclusive) end time to bound the query.
+ * @param excludeTypes List of visit types to exclude.
+ * @return A list of all visits within the specified range, described by [VisitInfo].
+ */
+ suspend fun getDetailedVisits(
+ start: Long,
+ end: Long = Long.MAX_VALUE,
+ excludeTypes: List<VisitType> = listOf(),
+ ): List<VisitInfo>
+
+ /**
+ * Return a "page" of history results. Each page will have visits in descending order
+ * with respect to their visit timestamps. In the case of ties, their row id will
+ * be used.
+ *
+ * Note that you may get surprising results if the items in the database change
+ * while you are paging through records.
+ *
+ * @param offset The offset where the page begins.
+ * @param count The number of items to return in the page.
+ * @param excludeTypes List of visit types to exclude.
+ */
+ suspend fun getVisitsPaginated(
+ offset: Long,
+ count: Long,
+ excludeTypes: List<VisitType> = listOf(),
+ ): List<VisitInfo>
+
+ /**
+ * Returns a list of the top frecent site infos limited by the given number of items and
+ * frecency threshold sorted by most to least frecent.
+ *
+ * @param numItems the number of top frecent sites to return in the list.
+ * @param frecencyThreshold frecency threshold option for filtering visited sites based on
+ * their frecency score.
+ * @return a list of the [TopFrecentSiteInfo], most frecent first.
+ */
+ suspend fun getTopFrecentSites(
+ numItems: Int,
+ frecencyThreshold: FrecencyThresholdOption,
+ ): List<TopFrecentSiteInfo>
+
+ /**
+ * Retrieves suggestions matching the [query].
+ * @param query A query by which to search the underlying store.
+ * @return A List of [SearchResult] matching the query, in no particular order.
+ */
+ fun getSuggestions(query: String, limit: Int): List<SearchResult>
+
+ /**
+ * Remove all locally stored data.
+ */
+ suspend fun deleteEverything()
+
+ /**
+ * Remove history visits in an inclusive range from [since] to now.
+ * @param since A unix timestamp, milliseconds.
+ */
+ suspend fun deleteVisitsSince(since: Long)
+
+ /**
+ * Remove history visits in an inclusive range from [startTime] to [endTime].
+ * @param startTime A unix timestamp, milliseconds.
+ * @param endTime A unix timestamp, milliseconds.
+ */
+ suspend fun deleteVisitsBetween(startTime: Long, endTime: Long)
+
+ /**
+ * Remove all history visits for a given [url].
+ * @param url A page URL for which to remove visits.
+ */
+ suspend fun deleteVisitsFor(url: String)
+
+ /**
+ * Remove a specific visit for a given [url].
+ * @param url A page URL for which to remove a visit.
+ * @param timestamp A unix timestamp, milliseconds, of a visit to be removed.
+ */
+ suspend fun deleteVisit(url: String, timestamp: Long)
+}
+
+/**
+ * Information to record about a visit.
+ *
+ * @property visitType The transition type for this visit. See [VisitType].
+ * @property redirectSource Optional; if this visit is redirecting to another page,
+ * what kind of redirect is it? See [RedirectSource] for the options.
+ */
+data class PageVisit(
+ val visitType: VisitType,
+ val redirectSource: RedirectSource? = null,
+)
+
+/**
+ * A redirect source describes how a page redirected to another page.
+ */
+enum class RedirectSource {
+ // The page temporarily redirected to another page.
+ TEMPORARY,
+
+ // The page permanently redirected to another page.
+ PERMANENT,
+}
+
+/**
+ * Metadata information observed in a page to record.
+ *
+ * @property title The title of the page.
+ * @property previewImageUrl The preview image of the page (e.g. the hero image), if available.
+ */
+data class PageObservation(
+ val title: String? = null,
+ val previewImageUrl: String? = null,
+)
+
+/**
+ * Information about a top frecent site. This represents a most frequently visited site.
+ *
+ * @property url The URL of the page that was visited.
+ * @property title The title of the page that was visited, if known.
+ */
+data class TopFrecentSiteInfo(
+ val url: String,
+ val title: String?,
+)
+
+/**
+ * Frecency threshold options for fetching top frecent sites.
+ */
+enum class FrecencyThresholdOption {
+ /** Returns all visited pages. */
+ NONE,
+
+ /** Skip visited pages that were only visited once. */
+ SKIP_ONE_TIME_PAGES,
+}
+
+/**
+ * Information about a history visit.
+ *
+ * @property url The URL of the page that was visited.
+ * @property title The title of the page that was visited, if known.
+ * @property visitTime The time the page was visited in integer milliseconds since the unix epoch.
+ * @property visitType What the transition type of the visit is, expressed as [VisitType].
+ * @property previewImageUrl The preview image of the page (e.g. the hero image), if available.
+ * @property isRemote Distinguishes visits made on the device and those that come from Sync.
+ */
+data class VisitInfo(
+ val url: String,
+ val title: String?,
+ val visitTime: Long,
+ val visitType: VisitType,
+ val previewImageUrl: String?,
+ var isRemote: Boolean,
+)
+
+/**
+ * Visit type constants as defined by Desktop Firefox.
+ */
+@Suppress("MagicNumber")
+enum class VisitType(val type: Int) {
+
+ // User followed a link.
+ LINK(1),
+
+ // User typed a URL or selected it from the UI (autocomplete results, etc).
+ TYPED(2),
+ BOOKMARK(3),
+ EMBED(4),
+ REDIRECT_PERMANENT(5),
+ REDIRECT_TEMPORARY(6),
+ DOWNLOAD(7),
+ FRAMED_LINK(8),
+ RELOAD(9),
+}
+
+/**
+ * Encapsulates a set of properties which define a result of querying history storage.
+ *
+ * @property id A permanent identifier which might be used for caching or at the UI layer.
+ * @property url A URL of the page.
+ * @property score An unbounded, nonlinear score (larger is more relevant) which is used to rank
+ * this [SearchResult] against others.
+ * @property title An optional title of the page.
+ */
+data class SearchResult(
+ val id: String,
+ val url: String,
+ val score: Int,
+ val title: String? = null,
+)
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyManager.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyManager.kt
new file mode 100644
index 0000000000..cd5b2356a6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyManager.kt
@@ -0,0 +1,108 @@
+/* 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.concept.storage
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.IllegalStateException
+
+/**
+ * Knows how to manage (generate, store, validate) keys and recover from their loss.
+ */
+abstract class KeyManager : KeyProvider {
+ // Exists to ensure that key generation/validation/recovery flow is synchronized.
+ private val keyMutex = Mutex()
+
+ /**
+ * @return Generated key.
+ */
+ abstract fun createKey(): String
+
+ /**
+ * Determines if [rawKey] is still valid for a given [canary], or if recovery is necessary.
+ * @return Optional [KeyGenerationReason.RecoveryNeeded] if recovery is necessary.
+ */
+ abstract fun isKeyRecoveryNeeded(rawKey: String, canary: String): KeyGenerationReason.RecoveryNeeded?
+
+ /**
+ * Returns a stored canary, if there's one. A canary is some known string encrypted with the managed key.
+ * @return an optional, stored canary string.
+ */
+ abstract fun getStoredCanary(): String?
+
+ /**
+ * Returns a stored key, if there's one.
+ */
+ abstract fun getStoredKey(): String?
+
+ /**
+ * Stores [key]; using the key, generate and store a canary.
+ */
+ abstract fun storeKeyAndCanary(key: String)
+
+ /**
+ * Recover from key loss that happened due to [reason].
+ * If this KeyManager wraps a storage layer, it should probably remove the now-unreadable data.
+ */
+ abstract suspend fun recoverFromKeyLoss(reason: KeyGenerationReason.RecoveryNeeded)
+
+ override suspend fun getOrGenerateKey(): ManagedKey = keyMutex.withLock {
+ val managedKey = getManagedKey()
+
+ (managedKey.wasGenerated as? KeyGenerationReason.RecoveryNeeded)?.let {
+ recoverFromKeyLoss(managedKey.wasGenerated)
+ }
+ return managedKey
+ }
+
+ /**
+ * Access should be guarded by [keyMutex].
+ */
+ private fun getManagedKey(): ManagedKey {
+ val storedCanaryPhrase = getStoredCanary()
+ val storedKey = getStoredKey()
+
+ return when {
+ // We expected the key to be present, and it is.
+ storedKey != null && storedCanaryPhrase != null -> {
+ // Make sure that the key is valid.
+ when (val recoveryReason = isKeyRecoveryNeeded(storedKey, storedCanaryPhrase)) {
+ is KeyGenerationReason -> ManagedKey(generateAndStoreKey(), recoveryReason)
+ null -> ManagedKey(storedKey)
+ }
+ }
+
+ // The key is present, but we didn't expect it to be there.
+ storedKey != null && storedCanaryPhrase == null -> {
+ // This isn't expected to happen. We can't check this key's validity.
+ ManagedKey(generateAndStoreKey(), KeyGenerationReason.RecoveryNeeded.AbnormalState)
+ }
+
+ // We expected the key to be present, but it's gone missing on us.
+ storedKey == null && storedCanaryPhrase != null -> {
+ // At this point, we're forced to generate a new key to recover and move forward.
+ // However, that means that any data that was previously encrypted is now unreadable.
+ ManagedKey(generateAndStoreKey(), KeyGenerationReason.RecoveryNeeded.Lost)
+ }
+
+ // We didn't expect the key to be present, and it's not.
+ storedKey == null && storedCanaryPhrase == null -> {
+ // Normal case when interacting with this class for the first time.
+ ManagedKey(generateAndStoreKey(), KeyGenerationReason.New)
+ }
+
+ else -> throw IllegalStateException()
+ }
+ }
+
+ /**
+ * Access should be guarded by [keyMutex].
+ */
+ private fun generateAndStoreKey(): String {
+ return createKey().also { newKey ->
+ storeKeyAndCanary(newKey)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyProvider.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyProvider.kt
new file mode 100644
index 0000000000..ba1bede11f
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/KeyProvider.kt
@@ -0,0 +1,58 @@
+/* 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.concept.storage
+
+/**
+ * Knows how to provide a [ManagedKey].
+ */
+interface KeyProvider {
+ /**
+ * Fetches or generates a new encryption key.
+ *
+ * @return [ManagedKey] that wraps the encryption key.
+ */
+ suspend fun getOrGenerateKey(): ManagedKey
+}
+
+/**
+ * An encryption key, with an optional [wasGenerated] field used to indicate if it was freshly
+ * generated. In that case, a [KeyGenerationReason] is supplied, allowing consumers to detect
+ * potential key loss or corruption.
+ * If [wasGenerated] is `null`, that means an existing key was successfully read from the key storage.
+ */
+data class ManagedKey(
+ val key: String,
+ val wasGenerated: KeyGenerationReason? = null,
+)
+
+/**
+ * Describes why a key was generated.
+ */
+sealed class KeyGenerationReason {
+ /**
+ * A new key, not previously present in the store.
+ */
+ object New : KeyGenerationReason()
+
+ /**
+ * Something went wrong with the previously stored key.
+ */
+ sealed class RecoveryNeeded : KeyGenerationReason() {
+ /**
+ * Previously stored key was lost, and a new key was generated as its replacement.
+ */
+ object Lost : RecoveryNeeded()
+
+ /**
+ * Previously stored key was corrupted, and a new key was generated as its replacement.
+ */
+ object Corrupt : RecoveryNeeded()
+
+ /**
+ * Storage layer encountered an abnormal state, which lead to key loss. A new key was generated.
+ */
+ object AbnormalState : RecoveryNeeded()
+ }
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/LoginsStorage.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/LoginsStorage.kt
new file mode 100644
index 0000000000..47ffc8145b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/LoginsStorage.kt
@@ -0,0 +1,277 @@
+/* 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.concept.storage
+
+import kotlinx.coroutines.Deferred
+
+/**
+ * A login stored in the database
+ */
+data class Login(
+ /**
+ * The unique identifier for this login entry.
+ */
+ val guid: String,
+ /**
+ * The username for this login entry.
+ */
+ val username: String,
+ /**
+ * The password for this login entry.
+ */
+ val password: String,
+ /**
+ * The origin this login entry applies to.
+ */
+ val origin: String,
+ /**
+ * The origin this login entry was submitted to.
+ * This only applies to form-based login entries.
+ * It's derived from the action attribute set on the form element.
+ */
+ val formActionOrigin: String? = null,
+ /**
+ * The HTTP realm this login entry was requested for.
+ * This only applies to non-form-based login entries.
+ * It's derived from the WWW-Authenticate header set in a HTTP 401
+ * response, see RFC2617 for details.
+ */
+ val httpRealm: String? = null,
+ /**
+ * HTML field associated with the [username].
+ */
+ val usernameField: String = "",
+ /**
+ * HTML field associated with the [password].
+ */
+ val passwordField: String = "",
+ /**
+ * Number of times this password has been used.
+ */
+ val timesUsed: Long = 0L,
+ /**
+ * Time of creation in milliseconds from the unix epoch.
+ */
+ val timeCreated: Long = 0L,
+ /**
+ * Time of last use in milliseconds from the unix epoch.
+ */
+ val timeLastUsed: Long = 0L,
+ /**
+ * Time of last password change in milliseconds from the unix epoch.
+ */
+ val timePasswordChanged: Long = 0L,
+) {
+ /**
+ * Converts [Login] into a [LoginEntry].
+ */
+ fun toEntry() = LoginEntry(
+ origin = origin,
+ formActionOrigin = formActionOrigin,
+ httpRealm = httpRealm,
+ usernameField = usernameField,
+ passwordField = passwordField,
+ username = username,
+ password = password,
+ )
+}
+
+/**
+ * Login autofill entry
+ *
+ * This contains the data needed to handle autofill but not the data related to
+ * the DB record. [LoginsStorage] methods that save data typically input
+ * [LoginEntry] instances. This allows the storage backend handle
+ * dupe-checking issues like determining which login record should be updated
+ * for a given [LoginEntry]. [LoginEntry] also represents the login data
+ * that's editable in the API.
+ *
+ * All fields have the same meaning as in [Login].
+ */
+data class LoginEntry(
+ val origin: String,
+ val formActionOrigin: String? = null,
+ val httpRealm: String? = null,
+ val usernameField: String = "",
+ val passwordField: String = "",
+ val username: String,
+ val password: String,
+)
+
+/**
+ * Login where the sensitive data is the encrypted.
+ *
+ * This have the same fields as [Login] except username and password is replaced with [secFields]
+ */
+data class EncryptedLogin(
+ val guid: String,
+ val origin: String,
+ val formActionOrigin: String? = null,
+ val httpRealm: String? = null,
+ val usernameField: String = "",
+ val passwordField: String = "",
+ val timesUsed: Long = 0L,
+ val timeCreated: Long = 0L,
+ val timeLastUsed: Long = 0L,
+ val timePasswordChanged: Long = 0L,
+ val secFields: String,
+)
+
+/**
+ * An interface describing a storage layer for logins/passwords.
+ */
+interface LoginsStorage : AutoCloseable {
+ /**
+ * Clears out all local state, bringing us back to the state before the first write (or sync).
+ */
+ suspend fun wipeLocal()
+
+ /**
+ * Deletes the login with the given GUID.
+ *
+ * @return True if the deletion did anything, false otherwise.
+ */
+ suspend fun delete(guid: String): Boolean
+
+ /**
+ * Fetches a password from the underlying storage layer by its GUID
+ *
+ * @param guid Unique identifier for the desired record.
+ * @return [Login] record, or `null` if the record does not exist.
+ */
+ suspend fun get(guid: String): Login?
+
+ /**
+ * Marks that a login has been used
+ *
+ * @param guid Unique identifier for the desired record.
+ */
+ suspend fun touch(guid: String)
+
+ /**
+ * Fetches the full list of logins from the underlying storage layer.
+ *
+ * @return A list of stored [Login] records.
+ */
+ suspend fun list(): List<Login>
+
+ /**
+ * Calculate how we should save a login
+ *
+ * For a [LoginEntry] to save find an existing [Login] to be update (if
+ * any).
+ *
+ * @param entry [LoginEntry] being saved
+ * @return [Login] that should be updated, or null if the login should be added
+ */
+ suspend fun findLoginToUpdate(entry: LoginEntry): Login?
+
+ /**
+ * Inserts the provided login into the database
+
+ * This will return an error result if the provided record is invalid
+ * (missing password, origin, or doesn't have exactly one of formSubmitURL
+ * and httpRealm).
+ *
+ * @param login [LoginEntry] to add.
+ * @return [EncryptedLogin] that was added
+ */
+ suspend fun add(entry: LoginEntry): EncryptedLogin
+
+ /**
+ * Updates an existing login in the database
+ *
+ * This will throw if `guid` does not refer to a record that exists in the
+ * database, or if the provided record is invalid (missing password,
+ * origin, or doesn't have exactly one of formSubmitURL and httpRealm).
+ *
+ * @param guid Unique identifier for the record
+ * @param login [LoginEntry] to add.
+ * @return [EncryptedLogin] that was added
+ */
+ suspend fun update(guid: String, entry: LoginEntry): EncryptedLogin
+
+ /**
+ * Checks if a record exists for a [LoginEntry] and calls either add() or update()
+ *
+ * This will throw if the provided record is invalid (missing password,
+ * origin, or doesn't have exactly one of formSubmitURL and httpRealm).
+ *
+ * @param login [LoginEntry] to add or update.
+ * @return [EncryptedLogin] that was added
+ */
+ suspend fun addOrUpdate(entry: LoginEntry): EncryptedLogin
+
+ /**
+ * Fetch the list of logins for some origin from the underlying storage layer.
+ *
+ * @param origin A host name used to look up logins
+ * @return A list of [Login] objects, representing matching logins.
+ */
+ suspend fun getByBaseDomain(origin: String): List<Login>
+
+ /**
+ * Decrypt an [EncryptedLogin]
+ *
+ * @param login [EncryptedLogin] to decrypt
+ * @return [Login] with decrypted data
+ */
+ suspend fun decryptLogin(login: EncryptedLogin): Login
+}
+
+/**
+ * Validates a [LoginEntry] that will be saved and calculates if saving it
+ * would update an existing [Login] or create a new one.
+ */
+interface LoginValidationDelegate {
+ /**
+ * The result of validating a given [Login] against currently stored [Login]s. This will
+ * include whether it can be created, updated, or neither, along with an explanation of any errors.
+ */
+ sealed class Result {
+ /**
+ * Indicates that the [Login] does not currently exist in the storage, and a new entry
+ * with its information can be made.
+ */
+ object CanBeCreated : Result()
+
+ /**
+ * Indicates that a matching [Login] was found in storage, and the [Login] can be used
+ * to update its information.
+ */
+ data class CanBeUpdated(val foundLogin: Login) : Result()
+ }
+
+ /**
+ *
+ * Checks whether a [login] should be saved or updated.
+ *
+ * @returns a [Result], detailing whether a [login] should be saved or updated.
+ */
+ fun shouldUpdateOrCreateAsync(entry: LoginEntry): Deferred<Result>
+}
+
+/**
+ * Used to handle [Login] storage so that the underlying engine doesn't have to. An instance of
+ * this should be attached to the Gecko runtime in order to be used.
+ */
+interface LoginStorageDelegate {
+ /**
+ * Called after a [login] has been autofilled into web content.
+ */
+ fun onLoginUsed(login: Login)
+
+ /**
+ * Given a [domain], returns the matching [Login]s found in [loginStorage].
+ *
+ * This is called when the engine believes a field should be autofilled.
+ */
+ fun onLoginFetch(domain: String): Deferred<List<Login>>
+
+ /**
+ * Called when a [LogenEntry] should be added or updated.
+ */
+ fun onLoginSave(login: LoginEntry)
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Storage.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Storage.kt
new file mode 100644
index 0000000000..72c01ebc14
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/Storage.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.concept.storage
+
+/**
+ * An interface which provides generic operations for storing browser data like history and bookmarks.
+ */
+interface Storage : Cancellable {
+ /**
+ * Make sure underlying database connections are established.
+ */
+ suspend fun warmUp()
+
+ /**
+ * Runs internal database maintenance tasks
+ */
+ suspend fun runMaintenance(dbSizeLimit: UInt)
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/StorageMaintenanceRegistry.kt b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/StorageMaintenanceRegistry.kt
new file mode 100644
index 0000000000..44feb6e8da
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/main/java/mozilla/components/concept/storage/StorageMaintenanceRegistry.kt
@@ -0,0 +1,27 @@
+/* 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.concept.storage
+
+/**
+ * An interface which registers and unregisters storage maintenance WorkManager workers
+ * that run maintenance on storages.
+ */
+interface StorageMaintenanceRegistry {
+
+ /**
+ * Registers a storage maintenance worker that prunes database when its size exceeds a size limit.
+ * See also [Storage.runMaintenance].
+ * */
+ fun registerStorageMaintenanceWorker()
+
+ /**
+ * Unregisters the storage maintenance worker that is registered
+ * by [StorageMaintenanceRegistry.registerStorageMaintenanceWorker].
+ * See also [Storage.runMaintenance].
+ *
+ * @param uniqueWorkName Unique name of the work request that needs to be unregistered.
+ * */
+ fun unregisterStorageMaintenanceWorker(uniqueWorkName: String)
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/AddressTest.kt b/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/AddressTest.kt
new file mode 100644
index 0000000000..9d775d417b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/AddressTest.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.concept.storage
+
+import mozilla.components.concept.storage.Address.Companion.toOneLineAddress
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class AddressTest {
+
+ @Test
+ fun `WHEN all address properties are present THEN full address present in label`() {
+ val address = generateAddress()
+ val expected =
+ "${address.streetAddress}, ${address.addressLevel3}, ${address.addressLevel2}, " +
+ "${address.organization}, ${address.addressLevel1}, ${address.country}, " +
+ "${address.postalCode}, ${address.tel}, ${address.email}"
+
+ assertEquals(expected, address.addressLabel)
+ }
+
+ @Test
+ fun `WHEN any address properties are missing THEN label only includes only properties that are available`() {
+ val address = generateAddress(
+ addressLevel3 = "",
+ organization = "",
+ email = "",
+ )
+ val expected =
+ "${address.streetAddress}, ${address.addressLevel2}, ${address.addressLevel1}, " +
+ "${address.country}, ${address.postalCode}, ${address.tel}"
+
+ assertEquals(expected, address.addressLabel)
+ }
+
+ @Test
+ fun `WHEN no address properties are present THEN label is the empty string`() {
+ val address = generateAddress(
+ name = "",
+ organization = "",
+ streetAddress = "",
+ addressLevel3 = "",
+ addressLevel2 = "",
+ addressLevel1 = "",
+ postalCode = "",
+ country = "",
+ tel = "",
+ email = "",
+ )
+
+ assertEquals("", address.addressLabel)
+ }
+
+ @Test
+ fun `GIVEN multiline street address WHEN one line address is called THEN an one line address is returned`() {
+ val streetAddress = """
+ line1
+ line2
+ line3
+ """.trimIndent()
+
+ assertEquals("line1 line2 line3", streetAddress.toOneLineAddress())
+ }
+
+ private fun generateAddress(
+ guid: String = "",
+ name: String = "Firefox The Browser",
+ organization: String = "Mozilla",
+ streetAddress: String = "street",
+ addressLevel3: String = "3",
+ addressLevel2: String = "2",
+ addressLevel1: String = "1",
+ postalCode: String = "code",
+ country: String = "country",
+ tel: String = "tel",
+ email: String = "email",
+ ) = Address(
+ guid = guid,
+ name = name,
+ organization = organization,
+ streetAddress = streetAddress,
+ addressLevel3 = addressLevel3,
+ addressLevel2 = addressLevel2,
+ addressLevel1 = addressLevel1,
+ postalCode = postalCode,
+ country = country,
+ tel = tel,
+ email = email,
+ )
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/BookmarkNodeTest.kt b/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/BookmarkNodeTest.kt
new file mode 100644
index 0000000000..087504c32c
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/BookmarkNodeTest.kt
@@ -0,0 +1,119 @@
+/* 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.concept.storage
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BookmarkNodeTest {
+
+ private val bookmarkChild1 = testBookmarkItem(
+ url = "http://www.mockurl.com/1",
+ title = "Child 1",
+ )
+ private val bookmarkChild2 = testBookmarkItem(
+ url = "http://www.mockurl.com/2",
+ title = "Child 2",
+ )
+ private val bookmarkChild3 = testBookmarkItem(
+ url = "http://www.mockurl.com/3",
+ title = "Child 3",
+ )
+ private val allChildren = listOf(bookmarkChild1, bookmarkChild2)
+
+ @Test
+ fun `GIVEN a bookmark node with children WHEN subtracting a sub set of children THEN the children subset is removed and rest remains`() {
+ val bookmarkNode = testFolder("parent1", "root", allChildren)
+ val subsetToSubtract = setOf(bookmarkChild1)
+ val expectedRemainingSubset = listOf(bookmarkChild2)
+ val bookmarkNodeSubsetRemoved = bookmarkNode.minus(subsetToSubtract)
+ assertEquals(expectedRemainingSubset, bookmarkNodeSubsetRemoved.children)
+ }
+
+ @Test
+ fun `GIVEN a bookmark node with children WHEN subtracting a set of all children THEN all children are removed and empty list remains`() {
+ val bookmarkNode = testFolder("parent1", "root", allChildren)
+ val setOfAllChildren = setOf(bookmarkChild1, bookmarkChild2)
+ val bookmarkNodeAllChildrenRemoved = bookmarkNode.minus(setOfAllChildren)
+ assertEquals(emptyList<BookmarkNode>(), bookmarkNodeAllChildrenRemoved.children)
+ }
+
+ @Test
+ fun `GIVEN a bookmark node with children WHEN subtracting a set of non-children THEN no children are removed`() {
+ val setOfNonChildren = setOf(bookmarkChild3)
+ val bookmarkNode = testFolder("parent1", "root", allChildren)
+ val bookmarkNodeNonChildrenRemoved = bookmarkNode.minus(setOfNonChildren)
+ assertEquals(allChildren, bookmarkNodeNonChildrenRemoved.children)
+ }
+
+ @Test
+ fun `GIVEN a bookmark node with children WHEN subtracting an empty set THEN no children are removed`() {
+ val bookmarkNode = testFolder("parent1", "root", allChildren)
+ val bookmarkNodeEmptySetRemoved = bookmarkNode.minus(emptySet())
+ assertEquals(allChildren, bookmarkNodeEmptySetRemoved.children)
+ }
+
+ @Test
+ fun `GIVEN a bookmark node with an empty list as children WHEN subtracting a set of non-children from an empty parent THEN an empty list remains`() {
+ val parentWithEmptyList = testFolder("parent1", "root", emptyList())
+ val setOfAllChildren = setOf(bookmarkChild1, bookmarkChild2)
+ val parentWithEmptyListNonChildRemoved = parentWithEmptyList.minus(setOfAllChildren)
+ assertEquals(emptyList<BookmarkNode>(), parentWithEmptyListNonChildRemoved.children)
+ }
+
+ @Test
+ fun `GIVEN a bookmark node with null as children WHEN subtracting a set of non-children from a parent with null children THEN null remains`() {
+ val parentWithNullList = testFolder("parent1", "root", null)
+ val parentWithNullListNonChildRemoved = parentWithNullList.minus(allChildren.toSet())
+ assertEquals(null, parentWithNullListNonChildRemoved.children)
+ }
+
+ @Test
+ fun `GIVEN a bookmark node with children WHEN subtracting a sub-set of children THEN the rest of the parents object should remain the same`() {
+ val bookmarkNode = testFolder("parent1", "root", allChildren)
+ val subsetToSubtract = setOf(bookmarkChild1)
+ val expectedRemainingSubset = listOf(bookmarkChild2)
+ val resultBookmarkNode = bookmarkNode.minus(subsetToSubtract)
+
+ // We're pinning children to the same value so we can compare the rest.
+ val restOfResult = resultBookmarkNode.copy(children = expectedRemainingSubset)
+ val restOfOriginal = bookmarkNode.copy(children = expectedRemainingSubset)
+ assertEquals(restOfResult, restOfOriginal)
+ }
+
+ private fun testBookmarkItem(
+ parentGuid: String = "someFolder",
+ url: String,
+ title: String = "Item for $url",
+ guid: String = "guid#${Math.random() * 1000}",
+ position: UInt = 0u,
+ ) = BookmarkNode(
+ type = BookmarkNodeType.ITEM,
+ dateAdded = 0,
+ children = null,
+ guid = guid,
+ parentGuid = parentGuid,
+ position = position,
+ title = title,
+ url = url,
+ )
+
+ private fun testFolder(
+ guid: String,
+ parentGuid: String? = null,
+ children: List<BookmarkNode>?,
+ title: String = "Folder: $guid",
+ position: UInt = 0u,
+ ) = BookmarkNode(
+ type = BookmarkNodeType.FOLDER,
+ url = null,
+ dateAdded = 0,
+ guid = guid,
+ parentGuid = parentGuid,
+ position = position,
+ title = title,
+ children = children,
+ )
+}
diff --git a/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt b/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt
new file mode 100644
index 0000000000..8dd797aef8
--- /dev/null
+++ b/mobile/android/android-components/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt
@@ -0,0 +1,94 @@
+/* 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.concept.storage
+
+import mozilla.components.concept.storage.CreditCard.Companion.ellipsesEnd
+import mozilla.components.concept.storage.CreditCard.Companion.ellipsesStart
+import mozilla.components.concept.storage.CreditCard.Companion.ellipsis
+import mozilla.components.support.ktx.kotlin.last4Digits
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+class CreditCardEntryTest {
+
+ private val creditCard = CreditCardEntry(
+ guid = "1",
+ name = "Banana Apple",
+ number = "4111111111111110",
+ expiryMonth = "5",
+ expiryYear = "2030",
+ cardType = "amex",
+ )
+
+ @Test
+ fun `WHEN obfuscatedCardNumber getter is called THEN the expected obfuscated card number is returned`() {
+ assertEquals(
+ ellipsesStart +
+ ellipsis + ellipsis + ellipsis + ellipsis +
+ creditCard.number.last4Digits() +
+ ellipsesEnd,
+ creditCard.obfuscatedCardNumber,
+ )
+ }
+
+ @Test
+ fun `WHEN expiryDdate getter is called THEN the expected expiry date string is returned`() {
+ assertEquals("0${creditCard.expiryMonth}/${creditCard.expiryYear}", creditCard.expiryDate)
+ }
+
+ @Test
+ fun `GIVEN empty expiration date strings WHEN a credit card needs to display its full expiration date THEN the an empty string is returned`() {
+ val creditCardWithoutYear = CreditCardEntry(
+ guid = "1",
+ name = "Banana Apple",
+ number = "4111111111111110",
+ expiryMonth = "5",
+ expiryYear = "",
+ cardType = "amex",
+ )
+ val creditCardWithoutMonth = CreditCardEntry(
+ guid = "1",
+ name = "Banana Apple",
+ number = "4111111111111110",
+ expiryMonth = "",
+ expiryYear = "2030",
+ cardType = "amex",
+ )
+ val creditCardWithoutFullDate = CreditCardEntry(
+ guid = "1",
+ name = "Banana Apple",
+ number = "4111111111111110",
+ expiryMonth = "",
+ expiryYear = "",
+ cardType = "amex",
+ )
+
+ assertEquals("", creditCardWithoutYear.expiryDate)
+ assertEquals("", creditCardWithoutMonth.expiryDate)
+ assertEquals("", creditCardWithoutFullDate.expiryDate)
+ }
+
+ @Test
+ fun `GIVEN empty number THEN entry is considered invalid`() {
+ val entry = creditCard.copy(number = "")
+
+ assertFalse(entry.isValid)
+ }
+
+ @Test
+ fun `GIVEN empty expiry month THEN entry is considered invalid`() {
+ val entry = creditCard.copy(expiryMonth = "")
+
+ assertFalse(entry.isValid)
+ }
+
+ @Test
+ fun `GIVEN empty expiry year THEN entry is considered invalid`() {
+ val entry = creditCard.copy(expiryYear = "")
+
+ assertFalse(entry.isValid)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/sync/README.md b/mobile/android/android-components/components/concept/sync/README.md
new file mode 100644
index 0000000000..787a6a3af2
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/README.md
@@ -0,0 +1,26 @@
+# [Android Components](../../../README.md) > Concept > Sync
+
+The `concept-sync` component contains interfaces and types that describe various aspects of data synchronization.
+
+This abstraction makes it possible to create different implementations of synchronization backends, without tightly
+coupling concrete implementations of storage, accounts and sync sub-systems.
+
+## 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:concept-sync:{latest-version}"
+```
+
+### Integration
+
+TODO
+
+## 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/concept/sync/build.gradle b/mobile/android/android-components/components/concept/sync/build.gradle
new file mode 100644
index 0000000000..31a356155a
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/build.gradle
@@ -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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.sync'
+}
+
+dependencies {
+ // Necessary because we use 'suspend'. Fun fact: this module will compile just fine without this
+ // dependency, but it will crash at runtime.
+ // Included via 'api' because this module is unusable without coroutines.
+ api ComponentsDependencies.kotlin_coroutines
+
+ // Observables are part of the public API of this module.
+ api project(':support-base')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/sync/proguard-rules.pro b/mobile/android/android-components/components/concept/sync/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/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/concept/sync/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/sync/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/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/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt
new file mode 100644
index 0000000000..fe46cc5b90
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt
@@ -0,0 +1,65 @@
+/* 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.concept.sync
+
+/**
+ * Allows monitoring events targeted at the current account/device.
+ */
+interface AccountEventsObserver {
+ /** The callback called when an account event is received */
+ fun onEvents(events: List<AccountEvent>)
+}
+
+typealias OuterDeviceCommandIncoming = DeviceCommandIncoming
+
+/**
+ * Incoming account events.
+ */
+sealed class AccountEvent {
+ /** An incoming command from another device */
+ data class DeviceCommandIncoming(val command: OuterDeviceCommandIncoming) : AccountEvent()
+
+ /** The account's profile was updated */
+ object ProfileUpdated : AccountEvent()
+
+ /** The authentication state of the account changed - eg, the password changed */
+ object AccountAuthStateChanged : AccountEvent()
+
+ /** The account itself was destroyed */
+ object AccountDestroyed : AccountEvent()
+
+ /** Another device connected to the account */
+ data class DeviceConnected(val deviceName: String) : AccountEvent()
+
+ /** A device (possibly this one) disconnected from the account */
+ data class DeviceDisconnected(val deviceId: String, val isLocalDevice: Boolean) : AccountEvent()
+
+ /** An unknown account event. Should be gracefully ignore */
+ object Unknown : AccountEvent()
+}
+
+/**
+ * Incoming device commands (ie, targeted at the current device.)
+ */
+sealed class DeviceCommandIncoming {
+ /** A command to open a list of tabs on the current device */
+ class TabReceived(val from: Device?, val entries: List<TabData>) : DeviceCommandIncoming()
+}
+
+/**
+ * Outgoing device commands (ie, targeted at other devices.)
+ */
+sealed class DeviceCommandOutgoing {
+ /** A command to open a tab on another device */
+ class SendTab(val title: String, val url: String) : DeviceCommandOutgoing()
+}
+
+/**
+ * Information about a tab sent with tab related commands.
+ */
+data class TabData(
+ val title: String,
+ val url: String,
+)
diff --git a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt
new file mode 100644
index 0000000000..94b022ce20
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt
@@ -0,0 +1,175 @@
+/* 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.concept.sync
+
+import android.content.Context
+import androidx.annotation.MainThread
+import androidx.lifecycle.LifecycleOwner
+import mozilla.components.support.base.observer.Observable
+
+/**
+ * Represents a result of interacting with a backend service which may return an authentication error.
+ */
+sealed class ServiceResult {
+ /**
+ * All good.
+ */
+ object Ok : ServiceResult()
+
+ /**
+ * Auth error.
+ */
+ object AuthError : ServiceResult()
+
+ /**
+ * Error that isn't auth.
+ */
+ object OtherError : ServiceResult()
+}
+
+/**
+ * Describes available interactions with the current device and other devices associated with an [OAuthAccount].
+ */
+interface DeviceConstellation : Observable<AccountEventsObserver> {
+ /**
+ * Perform actions necessary to finalize device initialization based on [authType].
+ * @param authType Type of an authentication event we're experiencing.
+ * @param config A [DeviceConfig] that describes current device.
+ * @return A boolean success flag.
+ */
+ suspend fun finalizeDevice(authType: AuthType, config: DeviceConfig): ServiceResult
+
+ /**
+ * Current state of the constellation. May be missing if state was never queried.
+ * @return [ConstellationState] describes current and other known devices in the constellation.
+ */
+ fun state(): ConstellationState?
+
+ /**
+ * Allows monitoring state of the device constellation via [DeviceConstellationObserver].
+ * Use this to be notified of changes to the current device or other devices.
+ */
+ @MainThread
+ fun registerDeviceObserver(observer: DeviceConstellationObserver, owner: LifecycleOwner, autoPause: Boolean)
+
+ /**
+ * Set name of the current device.
+ * @param name New device name.
+ * @param context An application context, used for updating internal caches.
+ * @return A boolean success flag.
+ */
+ suspend fun setDeviceName(name: String, context: Context): Boolean
+
+ /**
+ * Set a [DevicePushSubscription] for the current device.
+ * @param subscription A new [DevicePushSubscription].
+ * @return A boolean success flag.
+ */
+ suspend fun setDevicePushSubscription(subscription: DevicePushSubscription): Boolean
+
+ /**
+ * Send a command to a specified device.
+ * @param targetDeviceId A device ID of the recipient.
+ * @param outgoingCommand An event to send.
+ * @return A boolean success flag.
+ */
+ suspend fun sendCommandToDevice(targetDeviceId: String, outgoingCommand: DeviceCommandOutgoing): Boolean
+
+ /**
+ * Process a raw event, obtained via a push message or some other out-of-band mechanism.
+ * @param payload A raw, plaintext payload to be processed.
+ * @return A boolean success flag.
+ */
+ suspend fun processRawEvent(payload: String): Boolean
+
+ /**
+ * Refreshes [ConstellationState]. Registered [DeviceConstellationObserver] observers will be notified.
+ *
+ * @return A boolean success flag.
+ */
+ suspend fun refreshDevices(): Boolean
+
+ /**
+ * Polls for any pending [DeviceCommandIncoming] commands.
+ * In case of new commands, registered [AccountEventsObserver] observers will be notified.
+ *
+ * @return A boolean success flag.
+ */
+ suspend fun pollForCommands(): Boolean
+}
+
+/**
+ * Describes current device and other devices in the constellation.
+ */
+// N.B.: currentDevice should not be nullable.
+// See https://github.com/mozilla-mobile/android-components/issues/8768
+data class ConstellationState(val currentDevice: Device?, val otherDevices: List<Device>)
+
+/**
+ * Allows monitoring constellation state.
+ */
+interface DeviceConstellationObserver {
+ fun onDevicesUpdate(constellation: ConstellationState)
+}
+
+/**
+ * Describes a type of the physical device in the constellation.
+ */
+enum class DeviceType {
+ DESKTOP,
+ MOBILE,
+ TABLET,
+ TV,
+ VR,
+ UNKNOWN,
+}
+
+/**
+ * Describes an Autopush-compatible push channel subscription.
+ */
+data class DevicePushSubscription(
+ val endpoint: String,
+ val publicKey: String,
+ val authKey: String,
+)
+
+/**
+ * Configuration for the current device.
+ *
+ * @property name An initial name to use for the device record which will be created during authentication.
+ * This can be changed later via [DeviceConstellation.setDeviceName].
+ * @property type Type of a device - mobile, desktop - used for displaying identifying icons on other devices.
+ * This cannot be changed once device record is created.
+ * @property capabilities A set of device capabilities, such as SEND_TAB.
+ * @property secureStateAtRest A flag indicating whether or not to use encrypted storage for the persisted account
+ * state.
+ */
+data class DeviceConfig(
+ val name: String,
+ val type: DeviceType,
+ val capabilities: Set<DeviceCapability>,
+ val secureStateAtRest: Boolean = false,
+)
+
+/**
+ * Capabilities that a [Device] may have.
+ */
+enum class DeviceCapability {
+ SEND_TAB,
+}
+
+/**
+ * Describes a device in the [DeviceConstellation].
+ */
+data class Device(
+ val id: String,
+ val displayName: String,
+ val deviceType: DeviceType,
+ val isCurrentDevice: Boolean,
+ val lastAccessTime: Long?,
+ val capabilities: List<DeviceCapability>,
+ val subscriptionExpired: Boolean,
+ val subscription: DevicePushSubscription?,
+)
diff --git a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt
new file mode 100644
index 0000000000..7737d4bc36
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt
@@ -0,0 +1,358 @@
+/* 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.concept.sync
+
+import kotlinx.coroutines.Deferred
+
+/**
+ * An object that represents a login flow initiated by [OAuthAccount].
+ * @property state OAuth state parameter, identifying a specific authentication flow.
+ * This string is randomly generated during [OAuthAccount.beginOAuthFlow] and [OAuthAccount.beginPairingFlow].
+ * @property url Url which needs to be loaded to go through the authentication flow identified by [state].
+ */
+data class AuthFlowUrl(val state: String, val url: String)
+
+/**
+ * Represents a specific type of an "in-flight" migration state that could result from intermittent
+ * issues during [OAuthAccount.migrateFromAccount].
+ */
+enum class InFlightMigrationState(val reuseSessionToken: Boolean) {
+ /**
+ * "Copy" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionToken].
+ */
+ COPY_SESSION_TOKEN(false),
+
+ /**
+ * "Reuse" in-flight migration present. Can retry migration via [OAuthAccount.retryMigrateFromSessionToken].
+ */
+ REUSE_SESSION_TOKEN(true),
+}
+
+/**
+ * Data structure describing FxA and Sync credentials necessary to sign-in into an FxA account.
+ */
+data class MigratingAccountInfo(
+ val sessionToken: String,
+ val kSync: String,
+ val kXCS: String,
+)
+
+/**
+ * Representing all the possible entry points into FxA
+ *
+ * These entry points will be reflected in the authentication URL and will be tracked
+ * in server telemetry to allow studying authentication entry points independently.
+ *
+ * If you are introducing a new path to the firefox accounts sign in please add a new entry point
+ * here.
+ */
+interface FxAEntryPoint {
+ val entryName: String
+}
+
+/**
+ * Facilitates testing consumers of FirefoxAccount.
+ */
+interface OAuthAccount : AutoCloseable {
+
+ /**
+ * Constructs a URL used to begin the OAuth flow for the requested scopes and keys.
+ *
+ * @param scopes List of OAuth scopes for which the client wants access
+ * @param entryPoint The UI entryPoint used to start this flow. An arbitrary
+ * string which is recorded in telemetry by the server to help analyze the
+ * most effective touchpoints
+ * @return [AuthFlowUrl] if available, `null` in case of a failure
+ */
+ suspend fun beginOAuthFlow(
+ scopes: Set<String>,
+ entryPoint: FxAEntryPoint,
+ ): AuthFlowUrl?
+
+ /**
+ * Constructs a URL used to begin the pairing flow for the requested scopes and pairingUrl.
+ *
+ * @param pairingUrl URL string for pairing
+ * @param scopes List of OAuth scopes for which the client wants access
+ * @param entryPoint The UI entryPoint used to start this flow. An arbitrary
+ * string which is recorded in telemetry by the server to help analyze the
+ * most effective touchpoints
+ * @return [AuthFlowUrl] if available, `null` in case of a failure
+ */
+ suspend fun beginPairingFlow(
+ pairingUrl: String,
+ scopes: Set<String>,
+ entryPoint: FxAEntryPoint,
+ ): AuthFlowUrl?
+
+ /**
+ * Returns current FxA Device ID for an authenticated account.
+ *
+ * @return Current device's FxA ID, if available. `null` otherwise.
+ */
+ fun getCurrentDeviceId(): String?
+
+ /**
+ * Returns session token for an authenticated account.
+ *
+ * @return Current account's session token, if available. `null` otherwise.
+ */
+ fun getSessionToken(): String?
+
+ /**
+ * Fetches the profile object for the current client either from the existing cached state
+ * or from the server (requires the client to have access to the profile scope).
+ *
+ * @param ignoreCache Fetch the profile information directly from the server
+ * @return Profile (optional, if successfully retrieved) representing the user's basic profile info
+ */
+ suspend fun getProfile(ignoreCache: Boolean = false): Profile?
+
+ /**
+ * Authenticates the current account using the [code] and [state] parameters obtained via the
+ * OAuth flow initiated by [beginOAuthFlow].
+ *
+ * Modifies the FirefoxAccount state.
+ * @param code OAuth code string
+ * @param state state token string
+ * @return Deferred boolean representing success or failure
+ */
+ suspend fun completeOAuthFlow(code: String, state: String): Boolean
+
+ /**
+ * Tries to fetch an access token for the given scope.
+ *
+ * @param singleScope Single OAuth scope (no spaces) for which the client wants access
+ * @return [AccessTokenInfo] that stores the token, along with its scope, key and
+ * expiration timestamp (in seconds) since epoch when complete
+ */
+ suspend fun getAccessToken(singleScope: String): AccessTokenInfo?
+
+ /**
+ * Call this whenever an authentication error was encountered while using an access token
+ * issued by [getAccessToken].
+ */
+ fun authErrorDetected()
+
+ /**
+ * This method should be called when a request made with an OAuth token failed with an
+ * authentication error. It will re-build cached state and perform a connectivity check.
+ *
+ * In time, fxalib will grow a similar method, at which point we'll just relay to it.
+ * See https://github.com/mozilla/application-services/issues/1263
+ *
+ * @param singleScope An oauth scope for which to check authorization state.
+ * @return An optional [Boolean] flag indicating if we're connected, or need to go through
+ * re-authentication. A null result means we were not able to determine state at this time.
+ */
+ suspend fun checkAuthorizationStatus(singleScope: String): Boolean?
+
+ /**
+ * Fetches the token server endpoint, for authentication using the SAML bearer flow.
+ *
+ * @return Token server endpoint URL string, `null` if it couldn't be obtained.
+ */
+ suspend fun getTokenServerEndpointURL(): String?
+
+ /**
+ * Fetches the URL for the user to manage their account
+ *
+ * @param entryPoint A string which will be included as a query param in the URL for metrics.
+ * @return The URL which should be opened in a browser tab.
+ */
+ suspend fun getManageAccountURL(entryPoint: FxAEntryPoint): String?
+
+ /**
+ * Get the pairing URL to navigate to on the Authority side (typically a computer).
+ *
+ * @return The URL to show the pairing user
+ */
+ fun getPairingAuthorityURL(): String
+
+ /**
+ * Registers a callback for when the account state gets persisted
+ *
+ * @param callback the account state persistence callback
+ */
+ fun registerPersistenceCallback(callback: StatePersistenceCallback)
+
+ /**
+ * Returns the device constellation for the current account
+ *
+ * @return Device constellation for the current account
+ */
+ fun deviceConstellation(): DeviceConstellation
+
+ /**
+ * Reset internal account state and destroy current device record.
+ * Use this when device record is no longer relevant, e.g. while logging out. On success, other
+ * devices will no longer see the current device in their device lists.
+ *
+ * @return A [Deferred] that will be resolved with a success flag once operation is complete.
+ * Failure indicates that we may have failed to destroy current device record. Nothing to do for
+ * the consumer; device record will be cleaned up eventually via TTL.
+ */
+ suspend fun disconnect(): Boolean
+
+ /**
+ * Serializes the current account's authentication state as a JSON string, for persistence in
+ * the Android KeyStore/shared preferences. The authentication state can be restored using
+ * [FirefoxAccount.fromJSONString].
+ *
+ * @return String containing the authentication details in JSON format
+ */
+ fun toJSONString(): String
+}
+
+/**
+ * Describes a delegate object that is used by [OAuthAccount] to persist its internal state as it changes.
+ */
+interface StatePersistenceCallback {
+ /**
+ * @param data Account state representation as a string (e.g. as json).
+ */
+ fun persist(data: String)
+}
+
+sealed class AuthType {
+ /**
+ * Account restored from hydrated state on disk.
+ */
+ object Existing : AuthType()
+
+ /**
+ * Account created in response to a sign-in.
+ */
+ object Signin : AuthType()
+
+ /**
+ * Account created in response to a sign-up.
+ */
+ object Signup : AuthType()
+
+ /**
+ * Account created via pairing (similar to sign-in, but without requiring credentials).
+ */
+ object Pairing : AuthType()
+
+ /**
+ * Account was created for an unknown external reason, hopefully identified by [action].
+ */
+ data class OtherExternal(val action: String?) : AuthType()
+
+ /**
+ * Account created via a shared account state from another app via the copy token flow.
+ */
+ object MigratedCopy : AuthType()
+
+ /**
+ * Account created via a shared account state from another app via the reuse token flow.
+ */
+ object MigratedReuse : AuthType()
+
+ /**
+ * Existing account was recovered from an authentication problem.
+ */
+ object Recovered : AuthType()
+}
+
+/**
+ * Different types of errors that may be encountered during authorization.
+ * Intermittent network problems are the most common reason for these errors.
+ */
+enum class AuthFlowError {
+ /**
+ * Couldn't begin authorization, i.e. failed to obtain an authorization URL.
+ */
+ FailedToBeginAuth,
+
+ /**
+ * Couldn't complete authorization after user entered valid credentials/paired correctly.
+ */
+ FailedToCompleteAuth,
+}
+
+/**
+ * Observer interface which lets its users monitor account state changes and major events.
+ * (XXX - there's some tension between this and the
+ * mozilla.components.concept.sync.AccountEvent we should resolve!)
+ */
+interface AccountObserver {
+ /**
+ * Account state has been resolved and can now be queried.
+ *
+ * @param authenticatedAccount Currently resolved authenticated account, if any.
+ */
+ fun onReady(authenticatedAccount: OAuthAccount?) = Unit
+
+ /**
+ * Account just got logged out.
+ */
+ fun onLoggedOut() = Unit
+
+ /**
+ * Account was successfully authenticated.
+ *
+ * @param account An authenticated instance of a [OAuthAccount].
+ * @param authType Describes what kind of authentication event caused this invocation.
+ */
+ fun onAuthenticated(account: OAuthAccount, authType: AuthType) = Unit
+
+ /**
+ * Account's profile is now available.
+ * @param profile A fresh version of account's [Profile].
+ */
+ fun onProfileUpdated(profile: Profile) = Unit
+
+ /**
+ * Account needs to be re-authenticated (e.g. due to a password change).
+ */
+ fun onAuthenticationProblems() = Unit
+
+ /**
+ * Encountered an error during an authentication or migration flow.
+ * @param error Exact error encountered.
+ */
+ fun onFlowError(error: AuthFlowError) = Unit
+}
+
+data class Avatar(
+ val url: String,
+ val isDefault: Boolean,
+)
+
+data class Profile(
+ val uid: String?,
+ val email: String?,
+ val avatar: Avatar?,
+ val displayName: String?,
+)
+
+/**
+ * Scoped key data.
+ *
+ * @property kid The JWK key identifier.
+ * @property k The JWK key data.
+ */
+data class OAuthScopedKey(
+ val kty: String,
+ val scope: String,
+ val kid: String,
+ val k: String,
+)
+
+/**
+ * The result of authentication with FxA via an OAuth flow.
+ *
+ * @property token The access token produced by the flow.
+ * @property key An OAuthScopedKey if present.
+ * @property expiresAt The expiry date timestamp of this token since unix epoch (in seconds).
+ */
+data class AccessTokenInfo(
+ val scope: String,
+ val token: String,
+ val key: OAuthScopedKey?,
+ val expiresAt: Long,
+)
diff --git a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Sync.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Sync.kt
new file mode 100644
index 0000000000..51b24d0752
--- /dev/null
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Sync.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.concept.sync
+
+/**
+ * Results of running a sync via [SyncableStore.sync].
+ */
+sealed class SyncStatus {
+ /**
+ * Sync succeeded successfully.
+ */
+ object Ok : SyncStatus()
+
+ /**
+ * Sync completed with an error.
+ */
+ data class Error(val exception: Exception) : SyncStatus()
+}
+
+/**
+ * A Firefox Sync friendly auth object which can be obtained from [OAuthAccount].
+ *
+ * Why is there a Firefox Sync-shaped authentication object at the concept level, you ask?
+ * Mainly because this is what the [SyncableStore] consumes in order to actually perform
+ * synchronization, which is in turn implemented by `places`-backed storage layer.
+ * If this class lived in `services-firefox-accounts`, we'd end up with an ugly dependency situation
+ * between services and storage components.
+ *
+ * Turns out that building a generic description of an authentication/synchronization layer is not
+ * quite the way to go when you only have a single, legacy implementation.
+ *
+ * However, this may actually improve once we retire the tokenserver from the architecture.
+ * We could also consider a heavier use of generics, as well.
+ */
+data class SyncAuthInfo(
+ val kid: String,
+ val fxaAccessToken: String,
+ val fxaAccessTokenExpiresAt: Long,
+ val syncKey: String,
+ val tokenServerUrl: String,
+)
+
+/**
+ * Describes a "sync" entry point for a storage layer.
+ */
+interface SyncableStore {
+ /**
+ * Registers this storage with a sync manager.
+ */
+ fun registerWithSyncManager()
+}
diff --git a/mobile/android/android-components/components/concept/tabstray/README.md b/mobile/android/android-components/components/concept/tabstray/README.md
new file mode 100644
index 0000000000..56b5838a55
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Concept > Tabstray
+
+Abstract definition of a tabs tray component.
+
+## 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:concept-tabstray:{latest-version}"
+```
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/concept/tabstray/build.gradle b/mobile/android/android-components/components/concept/tabstray/build.gradle
new file mode 100644
index 0000000000..67d5ae9d25
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/build.gradle
@@ -0,0 +1,33 @@
+/* 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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.tabstray'
+}
+
+dependencies {
+ api project(':concept-engine')
+
+ implementation project(':support-base')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/tabstray/proguard-rules.pro b/mobile/android/android-components/components/concept/tabstray/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/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/concept/tabstray/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/tabstray/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/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/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tab.kt b/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tab.kt
new file mode 100644
index 0000000000..4de7bc7fe6
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tab.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.concept.tabstray
+
+import android.graphics.Bitmap
+import mozilla.components.concept.engine.mediasession.MediaSession
+
+/**
+ * Data class representing a tab to be displayed in a [TabsTray].
+ *
+ * @property id Unique ID of the tab.
+ * @property url Current URL of the tab.
+ * @property title Current title of the tab (or an empty [String]]).
+ * @property private whether or not the session is private.
+ * @property icon Current icon of the tab (or null)
+ * @property thumbnail Current thumbnail of the tab (or null)
+ * @property playbackState Current media session playback state for the tab (or null)
+ * @property controller Current media session controller for the tab (or null)
+ * @property lastAccess The last time this tab was selected.
+ * @property createdAt When the tab was first created.
+ * @property searchTerm the last used search term for this tab or from the originating tab, or an
+ * empty string if no search was executed.
+ */
+@Deprecated(
+ "This will be removed in a future release",
+ ReplaceWith("TabSessionState", "mozilla.components.browser.state.state"),
+)
+data class Tab(
+ val id: String,
+ val url: String,
+ val title: String = "",
+ val private: Boolean = false,
+ val icon: Bitmap? = null,
+ val thumbnail: Bitmap? = null,
+ val playbackState: MediaSession.PlaybackState? = null,
+ val controller: MediaSession.Controller? = null,
+ val lastAccess: Long = 0L,
+ val createdAt: Long = 0L,
+ val searchTerm: String = "",
+)
diff --git a/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tabs.kt b/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tabs.kt
new file mode 100644
index 0000000000..a6d83d4297
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/Tabs.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.concept.tabstray
+
+/**
+ * Aggregate data type keeping a reference to the list of tabs and the index of the selected tab.
+ *
+ * @property list The list of tabs.
+ * @property selectedTabId Id of the selected tab in the list of tabs (or null).
+ */
+@Deprecated(
+ "This will be removed in future versions",
+ ReplaceWith("TabList", "mozilla.components.feature.tabs.tabstray"),
+)
+@Suppress("Deprecation")
+data class Tabs(
+ val list: List<Tab>,
+ val selectedTabId: String?,
+)
diff --git a/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/TabsTray.kt b/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/TabsTray.kt
new file mode 100644
index 0000000000..0b85de4f74
--- /dev/null
+++ b/mobile/android/android-components/components/concept/tabstray/src/main/java/mozilla/components/concept/tabstray/TabsTray.kt
@@ -0,0 +1,44 @@
+/* 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.concept.tabstray
+
+import mozilla.components.support.base.observer.Observable
+
+/**
+ * Generic interface for components that provide "tabs tray" functionality.
+ */
+@Deprecated("This will be removed in a future release", ReplaceWith("TabsTray", "mozilla.components.browser.tabstray"))
+@Suppress("Deprecation")
+interface TabsTray : Observable<TabsTray.Observer> {
+ /**
+ * Interface to be implemented by classes that want to observe a tabs tray.
+ */
+ interface Observer {
+ /**
+ * One or many tabs have been added or removed.
+ */
+ fun onTabsUpdated() = Unit
+
+ /**
+ * A new tab has been selected.
+ */
+ fun onTabSelected(tab: Tab)
+
+ /**
+ * A tab has been closed.
+ */
+ fun onTabClosed(tab: Tab)
+ }
+
+ /**
+ * Updates the list of tabs.
+ */
+ fun updateTabs(tabs: Tabs)
+
+ /**
+ * Called when binding a new item to get if it should be shown as selected or not.
+ */
+ fun isTabSelected(tabs: Tabs, position: Int): Boolean
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/README.md b/mobile/android/android-components/components/concept/toolbar/README.md
new file mode 100644
index 0000000000..60e050ce0d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Concept > Toolbar
+
+Abstract definition of a browser toolbar component.
+
+## 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:concept-toolbar:{latest-version}"
+```
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/concept/toolbar/build.gradle b/mobile/android/android-components/components/concept/toolbar/build.gradle
new file mode 100644
index 0000000000..54e303cd11
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/build.gradle
@@ -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/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.concept.toolbar'
+}
+
+dependencies {
+ implementation ComponentsDependencies.androidx_annotation
+ implementation ComponentsDependencies.androidx_appcompat
+ implementation ComponentsDependencies.androidx_core_ktx
+ api project(':support-base')
+ implementation project(':support-ktx')
+
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation project(':support-test')
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/concept/toolbar/proguard-rules.pro b/mobile/android/android-components/components/concept/toolbar/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/concept/toolbar/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/toolbar/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/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/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteDelegate.kt b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteDelegate.kt
new file mode 100644
index 0000000000..ec68a17633
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteDelegate.kt
@@ -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/. */
+
+package mozilla.components.concept.toolbar
+
+/**
+ * Describes an object to which a [AutocompleteResult] may be applied.
+ * Usually, this will delegate to a specific text view.
+ */
+interface AutocompleteDelegate {
+ /**
+ * @param result Apply result of autocompletion.
+ * @param onApplied a lambda/callback invoked if (and only if) the result has been
+ * applied. A result may be discarded by implementations because it is stale or
+ * the autocomplete request has been cancelled.
+ */
+ fun applyAutocompleteResult(result: AutocompleteResult, onApplied: () -> Unit = { })
+
+ /**
+ * Autocompletion was invoked and no match was returned.
+ */
+ fun noAutocompleteResult(input: String)
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteProvider.kt b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteProvider.kt
new file mode 100644
index 0000000000..0534bbc007
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteProvider.kt
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.concept.toolbar
+
+/**
+ * Object providing autocomplete suggestions for the toolbar.
+ * More such objects can be set for the same toolbar with each getting results from a different source.
+ * If more providers are used the [autocompletePriority] property allows to easily set an order
+ * for the results and the suggestion of which provider should be tried to be applied first.
+ */
+interface AutocompleteProvider : Comparable<AutocompleteProvider> {
+ /**
+ * Retrieves an autocomplete suggestion which best matches [query].
+ *
+ * @param query Segment of text to be autocompleted.
+ *
+ * @return Optional domain URL which best matches the query.
+ */
+ suspend fun getAutocompleteSuggestion(query: String): AutocompleteResult?
+
+ /**
+ * Order in which this provider will be queried for autocomplete suggestions in relation ot others.
+ * - a lower priority means that this provider must be called before others with a higher priority.
+ * - an equal priority offers no ordering guarantees.
+ *
+ * Defaults to `0`.
+ */
+ val autocompletePriority: Int
+ get() = 0
+
+ override fun compareTo(other: AutocompleteProvider): Int {
+ return autocompletePriority - other.autocompletePriority
+ }
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteResult.kt b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteResult.kt
new file mode 100644
index 0000000000..145188c4d4
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/AutocompleteResult.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.concept.toolbar
+
+/**
+ * Describes an autocompletion result.
+ *
+ * @property input Input for which this AutocompleteResult is being provided.
+ * @property text AutocompleteResult of autocompletion, text to be displayed.
+ * @property url AutocompleteResult of autocompletion, full matching url.
+ * @property source Name of the autocompletion source.
+ * @property totalItems A total number of results also available.
+ */
+data class AutocompleteResult(
+ val input: String,
+ val text: String,
+ val url: String,
+ val source: String,
+ val totalItems: Int,
+)
diff --git a/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/ScrollableToolbar.kt b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/ScrollableToolbar.kt
new file mode 100644
index 0000000000..86af351c26
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/ScrollableToolbar.kt
@@ -0,0 +1,34 @@
+/* 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.concept.toolbar
+
+/**
+ * Interface to be implemented by components that provide hiding-on-scroll toolbar functionality.
+ */
+interface ScrollableToolbar {
+
+ /**
+ * Enable scrolling of the dynamic toolbar. Restore this functionality after [disableScrolling] stopped it.
+ *
+ * The toolbar may have other intrinsic checks depending on which the toolbar will be animated or not.
+ */
+ fun enableScrolling()
+
+ /**
+ * Completely disable scrolling of the dynamic toolbar.
+ * Use [enableScrolling] to restore the functionality.
+ */
+ fun disableScrolling()
+
+ /**
+ * Force the toolbar to expand.
+ */
+ fun expand()
+
+ /**
+ * Force the toolbar to collapse. Only if dynamic.
+ */
+ fun collapse()
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/Toolbar.kt b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/Toolbar.kt
new file mode 100644
index 0000000000..55244b4f6b
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/main/java/mozilla/components/concept/toolbar/Toolbar.kt
@@ -0,0 +1,563 @@
+/* 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.concept.toolbar
+
+import android.graphics.drawable.Drawable
+import android.view.View
+import android.view.View.NO_ID
+import android.view.ViewGroup
+import android.widget.ImageButton
+import android.widget.ImageView
+import androidx.annotation.ColorRes
+import androidx.annotation.Dimension
+import androidx.annotation.Dimension.Companion.DP
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.AppCompatImageButton
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.ktx.android.content.res.resolveAttribute
+import mozilla.components.support.ktx.android.view.setPadding
+import java.lang.ref.WeakReference
+
+/**
+ * Interface to be implemented by components that provide browser toolbar functionality.
+ */
+@Suppress("TooManyFunctions")
+interface Toolbar : ScrollableToolbar {
+ /**
+ * Sets/Gets the title to be displayed on the toolbar.
+ */
+ var title: String
+
+ /**
+ * Sets/Gets the URL to be displayed on the toolbar.
+ */
+ var url: CharSequence
+
+ /**
+ * Sets/gets private mode.
+ *
+ * In private mode the IME should not update any personalized data such as typing history and personalized language
+ * model based on what the user typed.
+ */
+ var private: Boolean
+
+ /**
+ * Sets/Gets the site security to be displayed on the toolbar.
+ */
+ var siteSecure: SiteSecurity
+
+ /**
+ * Sets/Gets the highlight icon to be displayed on the toolbar.
+ */
+ var highlight: Highlight
+
+ /**
+ * Sets/Gets the site tracking protection state to be displayed on the toolbar.
+ */
+ var siteTrackingProtection: SiteTrackingProtection
+
+ /**
+ * Displays the currently used search terms as part of this Toolbar.
+ *
+ * @param searchTerms the search terms used by the current session
+ */
+ fun setSearchTerms(searchTerms: String)
+
+ /**
+ * Displays the given loading progress. Expects values in the range [0, 100].
+ */
+ fun displayProgress(progress: Int)
+
+ /**
+ * Should be called by an activity when the user pressed the back key of the device.
+ *
+ * @return Returns true if the back press event was handled and should not be propagated further.
+ */
+ fun onBackPressed(): Boolean
+
+ /**
+ * Should be called by the host activity when it enters the stop state.
+ */
+ fun onStop()
+
+ /**
+ * Registers the given function to be invoked when the user selected a new URL i.e. is done
+ * editing.
+ *
+ * If the function returns `true` then the toolbar will automatically switch to "display mode". Otherwise it
+ * remains in "edit mode".
+ *
+ * @param listener the listener function
+ */
+ fun setOnUrlCommitListener(listener: (String) -> Boolean)
+
+ /**
+ * Registers the given function to be invoked when users changes text in the toolbar.
+ *
+ * @param filter A function which will perform autocompletion and send results to [AutocompleteDelegate].
+ */
+ fun setAutocompleteListener(filter: suspend (String, AutocompleteDelegate) -> Unit)
+
+ /**
+ * Attempt to restart the autocomplete functionality with the current user input.
+ */
+ fun refreshAutocomplete() = Unit
+
+ /**
+ * Adds an action to be displayed on the right side of the toolbar in display mode.
+ *
+ * Related:
+ * https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Browser_action
+ */
+ fun addBrowserAction(action: Action)
+
+ /**
+ * Removes a previously added browser action (see [addBrowserAction]). If the the provided
+ * actions was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ fun removeBrowserAction(action: Action)
+
+ /**
+ * Removes a previously added page action (see [addBrowserAction]). If the the provided
+ * actions was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ fun removePageAction(action: Action)
+
+ /**
+ * Removes a previously added navigation action (see [addNavigationAction]). If the the provided
+ * actions was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ fun removeNavigationAction(action: Action)
+
+ /**
+ * Declare that the actions (navigation actions, browser actions, page actions) have changed and
+ * should be updated if needed.
+ */
+ fun invalidateActions()
+
+ /**
+ * Adds an action to be displayed on the right side of the URL in display mode.
+ *
+ * Related:
+ * https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Page_actions
+ */
+ fun addPageAction(action: Action)
+
+ /**
+ * Adds an action to be displayed on the far left side of the URL in display mode.
+ */
+ fun addNavigationAction(action: Action)
+
+ /**
+ * Adds an action to be displayed at the start of the URL in edit mode.
+ */
+ fun addEditActionStart(action: Action)
+
+ /**
+ * Adds an action to be displayed at the end of the URL in edit mode.
+ */
+ fun addEditActionEnd(action: Action)
+
+ /**
+ * Removes an action at the end of the URL in edit mode.
+ */
+ fun removeEditActionEnd(action: Action)
+
+ /**
+ * Hides the menu button in display mode.
+ */
+ fun hideMenuButton()
+
+ /**
+ * Shows the menu button in display mode.
+ */
+ fun showMenuButton()
+
+ /**
+ * Sets the horizontal padding in display mode.
+ */
+ fun setDisplayHorizontalPadding(horizontalPadding: Int)
+
+ /**
+ * Hides the page action separator in display mode.
+ */
+ fun hidePageActionSeparator()
+
+ /**
+ * Shows the page action separator in display mode.
+ */
+ fun showPageActionSeparator()
+
+ /**
+ * Casts this toolbar to an Android View object.
+ */
+ fun asView(): View = this as View
+
+ /**
+ * Registers the given listener to be invoked when the user edits the URL.
+ */
+ fun setOnEditListener(listener: OnEditListener)
+
+ /**
+ * Switches to URL displaying mode (from editing mode) if supported by the toolbar implementation.
+ */
+ fun displayMode()
+
+ /**
+ * Switches to URL editing mode (from display mode) if supported by the toolbar implementation,
+ * and focuses the URL input field based on the cursor selection.
+ *
+ * @param cursorPlacement Where the cursor should be set after focusing on the URL input field.
+ */
+ fun editMode(cursorPlacement: CursorPlacement = CursorPlacement.ALL)
+
+ /**
+ * Dismisses the display toolbar popup menu
+ */
+ fun dismissMenu()
+
+ /**
+ * Listener to be invoked when the user edits the URL.
+ */
+ interface OnEditListener {
+ /**
+ * Fired when the toolbar switches to edit mode.
+ */
+ fun onStartEditing() = Unit
+
+ /**
+ * Fired when the user presses the back button while in edit mode.
+ */
+ fun onCancelEditing(): Boolean = true
+
+ /**
+ * Fired when the toolbar switches back to display mode.
+ */
+ fun onStopEditing() = Unit
+
+ /**
+ * Fired whenever the user changes the text in the address bar.
+ */
+ fun onTextChanged(text: String) = Unit
+
+ /**
+ * Fired when user clears input by tapping the clear input button.
+ */
+ fun onInputCleared() = Unit
+ }
+
+ /**
+ * Generic interface for actions to be added to the toolbar.
+ */
+ interface Action {
+ val visible: () -> Boolean
+ get() = { true }
+
+ val autoHide: () -> Boolean
+ get() = { false }
+
+ val weight: () -> Int
+ get() = { -1 }
+
+ fun createView(parent: ViewGroup): View
+
+ fun bind(view: View)
+ }
+
+ /**
+ * An action button to be added to the toolbar.
+ *
+ * @param imageDrawable The drawable to be shown.
+ * @param contentDescription The content description to use.
+ * @param visible Lambda that returns true or false to indicate whether this button should be shown.
+ * @param autoHide Lambda that returns true or false to indicate whether this button should auto hide.
+ * @param weight Lambda that returns an integer to indicate weight of an action. The lesser the weight,
+ * the closer it is to the url. A default weight -1 indicates, the position is not cared for
+ * and action will be appended at the end.
+ * @param padding A optional custom padding.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param longClickListener Callback that will be invoked whenever the button is long-pressed.
+ * @param listener Callback that will be invoked whenever the button is pressed
+ */
+ open class ActionButton(
+ val imageDrawable: Drawable? = null,
+ val contentDescription: String,
+ override val visible: () -> Boolean = { true },
+ override val autoHide: () -> Boolean = { false },
+ override val weight: () -> Int = { -1 },
+ private val background: Int = 0,
+ private val padding: Padding? = null,
+ @ColorRes val iconTintColorResource: Int = ViewGroup.NO_ID,
+ private val longClickListener: (() -> Unit)? = null,
+ private val listener: () -> Unit,
+ ) : Action {
+ private var view: WeakReference<AppCompatImageButton>? = null
+
+ override fun createView(parent: ViewGroup): View =
+ AppCompatImageButton(parent.context).also { imageButton ->
+ view = WeakReference(imageButton)
+
+ imageButton.setImageDrawable(imageDrawable)
+ imageButton.contentDescription = contentDescription
+ imageButton.setTintResource(iconTintColorResource)
+ imageButton.setOnClickListener { listener.invoke() }
+ imageButton.setOnLongClickListener {
+ longClickListener?.invoke()
+ true
+ }
+ imageButton.isLongClickable = longClickListener != null
+
+ val backgroundResource = if (background == 0) {
+ parent.context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless)
+ } else {
+ background
+ }
+
+ imageButton.setBackgroundResource(backgroundResource)
+ padding?.let { imageButton.setPadding(it) }
+ }
+
+ /**
+ * Changes the content description and the tint colour of the view.
+ *
+ * @param contentDescription The content description to use.
+ * @param tintColorResource ID of color resource to tint the icon.
+ */
+ fun updateView(
+ contentDescription: String? = null,
+ @ColorRes tintColorResource: Int = ViewGroup.NO_ID,
+ ) {
+ view?.get()?.let {
+ it.contentDescription = contentDescription
+ it.setTintResource(tintColorResource)
+ }
+ }
+
+ override fun bind(view: View) = Unit
+ }
+
+ /**
+ * An action button with two states, selected and unselected. When the button is pressed, the
+ * state changes automatically.
+ *
+ * @param imageDrawable The drawable to be shown if the button is in unselected state.
+ * @param imageSelectedDrawable The drawable to be shown if the button is in selected state.
+ * @param contentDescription The content description to use if the button is in unselected state.
+ * @param contentDescriptionSelected The content description to use if the button is in selected state.
+ * @param visible Lambda that returns true or false to indicate whether this button should be shown.
+ * @param weight Lambda that returns an integer to indicate weight of an action. The lesser the weight,
+ * the closer it is to the url. A default weight -1 indicates, the position is not cared for
+ * and action will be appended at the end.
+ * @param selected Sets whether this button should be selected initially.
+ * @param padding A optional custom padding.
+ * @param listener Callback that will be invoked whenever the checked state changes.
+ */
+ open class ActionToggleButton(
+ internal val imageDrawable: Drawable,
+ internal val imageSelectedDrawable: Drawable,
+ private val contentDescription: String,
+ private val contentDescriptionSelected: String,
+ override val visible: () -> Boolean = { true },
+ override val weight: () -> Int = { -1 },
+ private var selected: Boolean = false,
+ @DrawableRes private val background: Int = 0,
+ private val padding: Padding? = null,
+ private val listener: (Boolean) -> Unit,
+ ) : Action {
+ private var view: WeakReference<ImageButton>? = null
+
+ override fun createView(parent: ViewGroup): View = AppCompatImageButton(parent.context).also { imageButton ->
+ view = WeakReference(imageButton)
+
+ imageButton.scaleType = ImageView.ScaleType.CENTER
+ imageButton.setOnClickListener { toggle() }
+ imageButton.isSelected = selected
+
+ updateViewState()
+
+ val backgroundResource = if (background == 0) {
+ parent.context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless)
+ } else {
+ background
+ }
+
+ imageButton.setBackgroundResource(backgroundResource)
+ padding?.let { imageButton.setPadding(it) }
+ }
+
+ /**
+ * Changes the selected state of the action to the inverse of its current state.
+ *
+ * @param notifyListener If true (default) the listener will be notified about the state change.
+ */
+ fun toggle(notifyListener: Boolean = true) {
+ setSelected(!selected, notifyListener)
+ }
+
+ /**
+ * Changes the selected state of the action.
+ *
+ * @param selected The new selected state
+ * @param notifyListener If true (default) the listener will be notified about a state change.
+ */
+ fun setSelected(selected: Boolean, notifyListener: Boolean = true) {
+ if (this.selected == selected) {
+ // Nothing to do here.
+ return
+ }
+
+ this.selected = selected
+ updateViewState()
+
+ if (notifyListener) {
+ listener.invoke(selected)
+ }
+ }
+
+ /**
+ * Returns the current selected state of the action.
+ */
+ fun isSelected() = selected
+
+ private fun updateViewState() {
+ view?.get()?.let {
+ it.isSelected = selected
+
+ if (selected) {
+ it.setImageDrawable(imageSelectedDrawable)
+ it.contentDescription = contentDescriptionSelected
+ } else {
+ it.setImageDrawable(imageDrawable)
+ it.contentDescription = contentDescription
+ }
+ }
+ }
+
+ override fun bind(view: View) = Unit
+ }
+
+ /**
+ * An "empty" action with a desired width to be used as "placeholder".
+ *
+ * @param desiredWidth The desired width in density independent pixels for this action.
+ * @param padding A optional custom padding.
+ */
+ open class ActionSpace(
+ @Dimension(unit = DP) private val desiredWidth: Int,
+ private val padding: Padding? = null,
+ ) : Action {
+ override fun createView(parent: ViewGroup): View = View(parent.context).apply {
+ minimumWidth = desiredWidth
+ padding?.let { this.setPadding(it) }
+ }
+
+ override fun bind(view: View) = Unit
+ }
+
+ /**
+ * An action that just shows a static, non-clickable image.
+ *
+ * @param imageDrawable The drawable to be shown.
+ * @param contentDescription Optional content description to be used. If no content description
+ * is provided then this view will be treated as not important for
+ * accessibility.
+ * @param padding A optional custom padding.
+ */
+ open class ActionImage(
+ private val imageDrawable: Drawable,
+ private val contentDescription: String? = null,
+ private val padding: Padding? = null,
+ ) : Action {
+
+ override fun createView(parent: ViewGroup): View = AppCompatImageView(parent.context).also { image ->
+ image.minimumWidth = imageDrawable.intrinsicWidth
+ image.setImageDrawable(imageDrawable)
+
+ image.contentDescription = contentDescription
+ image.importantForAccessibility = if (contentDescription.isNullOrEmpty()) {
+ View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ } else {
+ View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
+ }
+ padding?.let { pd -> image.setPadding(pd) }
+ }
+
+ override fun bind(view: View) = Unit
+ }
+
+ enum class SiteSecurity {
+ INSECURE,
+ SECURE,
+ }
+
+ /**
+ * Indicates which tracking protection status a site has.
+ */
+ enum class SiteTrackingProtection {
+ /**
+ * The site has tracking protection enabled, but none trackers have been blocked or detected.
+ */
+ ON_NO_TRACKERS_BLOCKED,
+
+ /**
+ * The site has tracking protection enabled, and trackers have been blocked or detected.
+ */
+ ON_TRACKERS_BLOCKED,
+
+ /**
+ * Tracking protection has been disabled for a specific site.
+ */
+ OFF_FOR_A_SITE,
+
+ /**
+ * Tracking protection has been disabled for all sites.
+ */
+ OFF_GLOBALLY,
+ }
+
+ /**
+ * Indicates the reason why a highlight icon is shown or hidden.
+ */
+ enum class Highlight {
+ /**
+ * The site has changed its permissions from their default values.
+ */
+ PERMISSIONS_CHANGED,
+
+ /**
+ * The site does not show a dot indicator.
+ */
+ NONE,
+ }
+
+ /**
+ * Indicates where the cursor should be set after focusing on the URL input field.
+ */
+ enum class CursorPlacement {
+ /**
+ * All of the text in the input field should be selected.
+ */
+ ALL,
+
+ /**
+ * No text should be selected and the cursor should be placed at the end of the text.
+ */
+ END,
+ }
+}
+
+private fun AppCompatImageButton.setTintResource(@ColorRes tintColorResource: Int) {
+ if (tintColorResource != NO_ID) {
+ imageTintList = ContextCompat.getColorStateList(context, tintColorResource)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionButtonTest.kt b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionButtonTest.kt
new file mode 100644
index 0000000000..ddcbccdfe9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionButtonTest.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.concept.toolbar
+
+import android.widget.LinearLayout
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ActionButtonTest {
+
+ @Test
+ fun `set padding`() {
+ var button = Toolbar.ActionButton(mock(), "imageResource") {}
+ val linearLayout = LinearLayout(testContext)
+ var view = button.createView(linearLayout)
+
+ assertEquals(view.paddingLeft, 0)
+ assertEquals(view.paddingTop, 0)
+ assertEquals(view.paddingRight, 0)
+ assertEquals(view.paddingBottom, 0)
+
+ button = Toolbar.ActionButton(
+ mock(),
+ "imageResource",
+ padding = Padding(16, 20, 24, 28),
+ ) {}
+
+ view = button.createView(linearLayout)
+ view.paddingLeft
+ assertEquals(view.paddingLeft, 16)
+ assertEquals(view.paddingTop, 20)
+ assertEquals(view.paddingRight, 24)
+ assertEquals(view.paddingBottom, 28)
+ }
+
+ @Test
+ fun `constructor with drawables`() {
+ val visibilityListener = { false }
+ val button = Toolbar.ActionButton(
+ mock(),
+ "image",
+ visibilityListener,
+ { false },
+ { -1 },
+ 0,
+ null,
+ ) { }
+ assertNotNull(button.imageDrawable)
+ assertEquals("image", button.contentDescription)
+ assertEquals(visibilityListener, button.visible)
+ assertEquals(Unit, button.bind(mock()))
+
+ val buttonVisibility = Toolbar.ActionButton(mock(), "image") {}
+ assertEquals(true, buttonVisibility.visible())
+ }
+
+ @Test
+ fun `set contentDescription`() {
+ val button = Toolbar.ActionButton(mock(), "image") { }
+ val linearLayout = LinearLayout(testContext)
+ val view = button.createView(linearLayout)
+
+ button.updateView("contentDescription")
+
+ assertEquals("contentDescription", view.contentDescription)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionImageTest.kt b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionImageTest.kt
new file mode 100644
index 0000000000..2992103063
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionImageTest.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.concept.toolbar
+
+import android.graphics.drawable.Drawable
+import android.view.View
+import android.view.ViewGroup
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class ActionImageTest {
+
+ @Test
+ fun `setting minimumWidth`() {
+ val drawable: Drawable = mock()
+ val image = Toolbar.ActionImage(drawable)
+ val emptyImage = Toolbar.ActionImage(mock())
+
+ val viewGroup: ViewGroup = mock()
+ `when`(viewGroup.context).thenReturn(testContext)
+ `when`(drawable.intrinsicWidth).thenReturn(5)
+
+ val emptyImageView = emptyImage.createView(viewGroup)
+ assertEquals(0, emptyImageView.minimumWidth)
+
+ val imageView = image.createView(viewGroup)
+ assertTrue(imageView.minimumWidth != 0)
+ }
+
+ @Test
+ fun `accessibility description provided`() {
+ val image = Toolbar.ActionImage(mock())
+ var imageAccessible = Toolbar.ActionImage(mock(), "image")
+ val viewGroup: ViewGroup = mock()
+ `when`(viewGroup.context).thenReturn(testContext)
+
+ val imageView = image.createView(viewGroup)
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, imageView.importantForAccessibility)
+
+ var imageViewAccessible = imageAccessible.createView(viewGroup)
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, imageViewAccessible.importantForAccessibility)
+
+ imageAccessible = Toolbar.ActionImage(mock(), "")
+ imageViewAccessible = imageAccessible.createView(viewGroup)
+ assertEquals(View.IMPORTANT_FOR_ACCESSIBILITY_NO, imageViewAccessible.importantForAccessibility)
+ }
+
+ @Test
+ fun `bind is not implemented`() {
+ val button = Toolbar.ActionImage(mock())
+ assertEquals(Unit, button.bind(mock()))
+ }
+
+ @Test
+ fun `padding is set`() {
+ var image = Toolbar.ActionImage(mock())
+ val viewGroup: ViewGroup = mock()
+ `when`(viewGroup.context).thenReturn(testContext)
+ var view = image.createView(viewGroup)
+
+ assertEquals(view.paddingLeft, 0)
+ assertEquals(view.paddingTop, 0)
+ assertEquals(view.paddingRight, 0)
+ assertEquals(view.paddingBottom, 0)
+
+ image = Toolbar.ActionImage(mock(), padding = Padding(16, 20, 24, 28))
+
+ view = image.createView(viewGroup)
+ assertEquals(view.paddingLeft, 16)
+ assertEquals(view.paddingTop, 20)
+ assertEquals(view.paddingRight, 24)
+ assertEquals(view.paddingBottom, 28)
+ }
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionSpaceTest.kt b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionSpaceTest.kt
new file mode 100644
index 0000000000..6c4d3da1b9
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionSpaceTest.kt
@@ -0,0 +1,47 @@
+/* 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.concept.toolbar
+
+import android.widget.LinearLayout
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ActionSpaceTest {
+
+ @Test
+ fun `Toolbar ActionSpace must set padding`() {
+ var space = Toolbar.ActionSpace(0)
+ val linearLayout = LinearLayout(testContext)
+ var view = space.createView(linearLayout)
+
+ assertEquals(view.paddingLeft, 0)
+ assertEquals(view.paddingTop, 0)
+ assertEquals(view.paddingRight, 0)
+ assertEquals(view.paddingBottom, 0)
+
+ space = Toolbar.ActionSpace(
+ 0,
+ padding = Padding(16, 20, 24, 28),
+ )
+
+ view = space.createView(linearLayout)
+ assertEquals(view.paddingLeft, 16)
+ assertEquals(view.paddingTop, 20)
+ assertEquals(view.paddingRight, 24)
+ assertEquals(view.paddingBottom, 28)
+ }
+
+ @Test
+ fun `bind is not implemented`() {
+ val button = Toolbar.ActionSpace(0)
+ assertEquals(Unit, button.bind(mock()))
+ }
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionToggleButtonTest.kt b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionToggleButtonTest.kt
new file mode 100644
index 0000000000..0c47f626c5
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/test/java/mozilla/components/concept/toolbar/ActionToggleButtonTest.kt
@@ -0,0 +1,213 @@
+/* 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.concept.toolbar
+
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.UUID
+
+@RunWith(AndroidJUnit4::class)
+class ActionToggleButtonTest {
+
+ @Test
+ fun `clicking view will toggle state`() {
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {}
+ val view = button.createView(FrameLayout(testContext))
+
+ assertFalse(button.isSelected())
+
+ view.performClick()
+
+ assertTrue(button.isSelected())
+
+ view.performClick()
+
+ assertFalse(button.isSelected())
+ }
+
+ @Test
+ fun `clicking view will invoke listener`() {
+ var listenerInvoked = false
+
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {
+ listenerInvoked = true
+ }
+
+ val view = button.createView(FrameLayout(testContext))
+
+ assertFalse(listenerInvoked)
+
+ view.performClick()
+
+ assertTrue(listenerInvoked)
+ }
+
+ @Test
+ fun `toggle will invoke listener`() {
+ var listenerInvoked = false
+
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {
+ listenerInvoked = true
+ }
+
+ assertFalse(listenerInvoked)
+
+ button.toggle()
+
+ assertTrue(listenerInvoked)
+ }
+
+ @Test
+ fun `toggle will not invoke listener if notifyListener is set to false`() {
+ var listenerInvoked = false
+
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {
+ listenerInvoked = true
+ }
+
+ assertFalse(listenerInvoked)
+
+ button.toggle(notifyListener = false)
+
+ assertFalse(listenerInvoked)
+ }
+
+ @Test
+ fun `setSelected will invoke listener`() {
+ var listenerInvoked = false
+
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {
+ listenerInvoked = true
+ }
+
+ assertFalse(button.isSelected())
+ assertFalse(listenerInvoked)
+
+ button.setSelected(true)
+
+ assertTrue(listenerInvoked)
+ }
+
+ @Test
+ fun `setSelected will not invoke listener if value has not changed`() {
+ var listenerInvoked = false
+
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {
+ listenerInvoked = true
+ }
+
+ assertFalse(button.isSelected())
+ assertFalse(listenerInvoked)
+
+ button.setSelected(false)
+
+ assertFalse(listenerInvoked)
+ }
+
+ @Test
+ fun `setSelected will not invoke listener if notifyListener is set to false`() {
+ var listenerInvoked = false
+
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {
+ listenerInvoked = true
+ }
+
+ assertFalse(button.isSelected())
+ assertFalse(listenerInvoked)
+
+ button.setSelected(true, notifyListener = false)
+
+ assertFalse(listenerInvoked)
+ }
+
+ @Test
+ fun `isSelected will always return correct state`() {
+ val button =
+ Toolbar.ActionToggleButton(mock(), mock(), UUID.randomUUID().toString(), UUID.randomUUID().toString()) {}
+ assertFalse(button.isSelected())
+
+ button.toggle()
+ assertTrue(button.isSelected())
+
+ button.setSelected(true)
+ assertTrue(button.isSelected())
+
+ button.setSelected(false)
+ assertFalse(button.isSelected())
+
+ button.setSelected(true, notifyListener = false)
+ assertTrue(button.isSelected())
+
+ button.toggle(notifyListener = false)
+ assertFalse(button.isSelected())
+
+ val view = button.createView(FrameLayout(testContext))
+ view.performClick()
+ assertTrue(button.isSelected())
+ }
+
+ @Test
+ fun `Toolbar ActionToggleButton must set padding`() {
+ var button = Toolbar.ActionToggleButton(mock(), mock(), "imageResource", "") {}
+ val linearLayout = LinearLayout(testContext)
+ var view = button.createView(linearLayout)
+ val padding = Padding(16, 20, 24, 28)
+
+ assertEquals(view.paddingLeft, 0)
+ assertEquals(view.paddingTop, 0)
+ assertEquals(view.paddingRight, 0)
+ assertEquals(view.paddingBottom, 0)
+
+ button = Toolbar.ActionToggleButton(mock(), mock(), "imageResource", "", padding = padding) {}
+
+ view = button.createView(linearLayout)
+ view.paddingLeft
+ assertEquals(view.paddingLeft, 16)
+ assertEquals(view.paddingTop, 20)
+ assertEquals(view.paddingRight, 24)
+ assertEquals(view.paddingBottom, 28)
+ }
+
+ @Test
+ fun `default constructor with drawables`() {
+ var selectedValue = false
+ val visibility = { true }
+ val button = Toolbar.ActionToggleButton(mock(), mock(), "image", "selected", visible = visibility) { value ->
+ selectedValue = value
+ }
+ assertEquals(true, button.visible())
+ assertNotNull(button.imageDrawable)
+ assertNotNull(button.imageSelectedDrawable)
+ assertEquals(visibility, button.visible)
+ button.setSelected(true)
+ assertTrue(selectedValue)
+
+ val buttonVisibility = Toolbar.ActionToggleButton(mock(), mock(), "image", "selected", background = 0) { }
+ assertTrue(buttonVisibility.visible())
+ }
+
+ @Test
+ fun `bind is not implemented`() {
+ val button = Toolbar.ActionToggleButton(mock(), mock(), "image", "imageSelected") {}
+ assertEquals(Unit, button.bind(mock()))
+ }
+}
diff --git a/mobile/android/android-components/components/concept/toolbar/src/test/resources/robolectric.properties b/mobile/android/android-components/components/concept/toolbar/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/concept/toolbar/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28