WebExtension.kt

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy 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 androidx.core.net.toUri
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 = url.toUri().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 API permissions:
     * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions
     */
    val requiredPermissions: List<String>,

    /**
     * Required origin permissions:
     * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions
     */
    val requiredOrigins: List<String>,

    /**
     * Optional API permissions for this extension:
     * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions
     */
    val optionalPermissions: List<String>,

    /**
     * Optional API 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 for 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>,

    /**
     * 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),
}

/**
 * Holds all the information which the user has submitted
 * as part of a confirmation of a permissions prompt request.
 */
data class PermissionPromptResponse(
    val isPermissionsGranted: Boolean,
    val isPrivateModeGranted: Boolean = false,
)

/**
 * 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
        const val SOFT_BLOCKLIST: Int = 1 shl 6

        /**
         * 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 soft-blocked.
 */
fun WebExtension.isSoftBlocked(): Boolean {
    val flags = getMetadata()?.disabledFlags
    return flags?.contains(DisabledFlags.SOFT_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 extensionId: String? = null,
    open val extensionName: String? = null,
    open val extensionVersion: 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 extensionId: String? = null,
        override val extensionName: String? = null,
        override val extensionVersion: 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)

    /**
     * The extension can only be installed via Enterprise Policies.
     */
    class AdminInstallOnly(override val extensionName: String? = null, throwable: Throwable) :
        WebExtensionInstallException(throwable = throwable)

    /**
     * The extension install was cancelled because the extension is soft-blocked.
     */
    class SoftBlocked(
        override val extensionId: String? = null,
        override val extensionName: String? = null,
        override val extensionVersion: String? = null,
        throwable: Throwable,
    ) :
        WebExtensionInstallException(throwable = throwable)
}