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