diff --git a/mobile/android/.eslintrc.js b/mobile/android/.eslintrc.js index 872785553e13..007930aab6b5 100644 --- a/mobile/android/.eslintrc.js +++ b/mobile/android/.eslintrc.js @@ -25,6 +25,8 @@ module.exports = { "chrome/**", // Bug 1425048. "components/extensions/**", + // Bug 1425034. + "modules/WebsiteMetadata.jsm", ], rules: { "no-unused-vars": "off", diff --git a/mobile/android/chrome/geckoview/ErrorPageEventHandler.js b/mobile/android/chrome/geckoview/ErrorPageEventHandler.js new file mode 100644 index 000000000000..0ea10510cd86 --- /dev/null +++ b/mobile/android/chrome/geckoview/ErrorPageEventHandler.js @@ -0,0 +1,83 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { GeckoViewUtils } = ChromeUtils.import( + "resource://gre/modules/GeckoViewUtils.jsm" +); +const { PrivateBrowsingUtils } = ChromeUtils.import( + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { debug, warn } = GeckoViewUtils.initLogging("ErrorPageEventHandler"); // eslint-disable-line no-unused-vars + +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", +}); + +var EXPORTED_SYMBOLS = ["ErrorPageEventHandler"]; + +var ErrorPageEventHandler = { + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "click": { + // Don't trust synthetic events + if (!aEvent.isTrusted) { + return; + } + + let target = aEvent.originalTarget; + let errorDoc = target.ownerDocument; + + // If the event came from an ssl error page, it is probably either the "Add + // Exception…" or "Get me out of here!" button + if (errorDoc.documentURI.startsWith("about:certerror?e=nssBadCert")) { + let perm = errorDoc.getElementById("permanentExceptionButton"); + let temp = errorDoc.getElementById("temporaryExceptionButton"); + if (target == temp || target == perm) { + // Handle setting an cert exception and reloading the page + let uri = Services.io.newURI(errorDoc.location.href); + let docShell = BrowserApp.selectedBrowser.docShell; + let securityInfo = docShell.failedChannel.securityInfo; + securityInfo.QueryInterface(Ci.nsITransportSecurityInfo); + let cert = securityInfo.serverCert; + let overrideService = Cc[ + "@mozilla.org/security/certoverride;1" + ].getService(Ci.nsICertOverrideService); + let flags = 0; + if (securityInfo.isUntrusted) { + flags |= overrideService.ERROR_UNTRUSTED; + } + if (securityInfo.isDomainMismatch) { + flags |= overrideService.ERROR_MISMATCH; + } + if (securityInfo.isNotValidAtThisTime) { + flags |= overrideService.ERROR_TIME; + } + let temporary = + target == temp || + PrivateBrowsingUtils.isWindowPrivate(errorDoc.defaultView); + overrideService.rememberValidityOverride( + uri.asciiHost, + uri.port, + cert, + flags, + temporary + ); + errorDoc.location.reload(); + } else if ( + target == errorDoc.getElementById("getMeOutOfHereButton") + ) { + errorDoc.location = "about:home"; + } + } + break; + } + } + }, +}; diff --git a/mobile/android/chrome/geckoview/jar.mn b/mobile/android/chrome/geckoview/jar.mn index bcd63113bc19..855d53d2237e 100644 --- a/mobile/android/chrome/geckoview/jar.mn +++ b/mobile/android/chrome/geckoview/jar.mn @@ -9,6 +9,7 @@ geckoview.jar: content/config.js % override chrome://global/content/config.xhtml chrome://geckoview/content/config.xhtml + content/ErrorPageEventHandler.js content/geckoview.xhtml content/geckoview.js content/GeckoViewAutofillChild.js diff --git a/mobile/android/components/extensions/ext-android.js b/mobile/android/components/extensions/ext-android.js index e17a6e1a04d9..af7dc6d623c1 100644 --- a/mobile/android/components/extensions/ext-android.js +++ b/mobile/android/components/extensions/ext-android.js @@ -77,6 +77,13 @@ extensions.registerModules({ manifest: ["browser_action"], paths: [["browserAction"]], }, + browsingData: { + url: "chrome://geckoview/content/ext-browsingData.js", + schema: "chrome://geckoview/content/schemas/browsing_data.json", + scopes: ["addon_parent"], + manifest: ["browsing_data"], + paths: [["browsingData"]], + }, pageAction: { url: "chrome://geckoview/content/ext-pageAction.js", schema: "chrome://extensions/content/schemas/page_action.json", diff --git a/mobile/android/components/extensions/ext-browsingData.js b/mobile/android/components/extensions/ext-browsingData.js new file mode 100644 index 000000000000..5f0f58ad16e3 --- /dev/null +++ b/mobile/android/components/extensions/ext-browsingData.js @@ -0,0 +1,176 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Sanitizer", + "resource://gre/modules/Sanitizer.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "SharedPreferences", + "resource://gre/modules/SharedPreferences.jsm" +); + +const clearCache = () => { + // Clearing the cache does not support timestamps. + return Sanitizer.clearItem("cache"); +}; + +const clearCookies = async function(options) { + let cookieMgr = Services.cookies; + let yieldCounter = 0; + const YIELD_PERIOD = 10; + + if (options.since) { + // Convert it to microseconds + let since = options.since * 1000; + // Iterate through the cookies and delete any created after our cutoff. + for (let cookie of cookieMgr.cookies) { + if (cookie.creationTime >= since) { + // This cookie was created after our cutoff, clear it. + cookieMgr.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + + if (++yieldCounter % YIELD_PERIOD == 0) { + await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long. + } + } + } + } else { + // Remove everything. + cookieMgr.removeAll(); + } +}; + +const clearDownloads = options => { + return Sanitizer.clearItem("downloadHistory", options.since); +}; + +const clearFormData = options => { + return Sanitizer.clearItem("formdata", options.since); +}; + +const doRemoval = (options, dataToRemove, extension) => { + if ( + options.originTypes && + (options.originTypes.protectedWeb || options.originTypes.extension) + ) { + return Promise.reject({ + message: + "Firefox does not support protectedWeb or extension as originTypes.", + }); + } + + let removalPromises = []; + let invalidDataTypes = []; + for (let dataType in dataToRemove) { + if (dataToRemove[dataType]) { + switch (dataType) { + case "cache": + removalPromises.push(clearCache()); + break; + case "cookies": + removalPromises.push(clearCookies(options)); + break; + case "downloads": + removalPromises.push(clearDownloads(options)); + break; + case "formData": + removalPromises.push(clearFormData(options)); + break; + default: + invalidDataTypes.push(dataType); + } + } + } + if (extension && invalidDataTypes.length) { + extension.logger.warn( + `Firefox does not support dataTypes: ${invalidDataTypes.toString()}.` + ); + } + return Promise.all(removalPromises); +}; + +this.browsingData = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + browsingData: { + settings() { + const PREF_DOMAIN = "android.not_a_preference.privacy.clear"; + const PREF_KEY_PREFIX = "private.data."; + // The following prefs are the only ones in Firefox that match corresponding + // values used by Chrome when returning settings. + const PREF_LIST = [ + "cache", + "history", + "formdata", + "cookies_sessions", + "downloadFiles", + ]; + + let dataTrue = SharedPreferences.forProfile().getSetPref(PREF_DOMAIN); + let name; + + let dataToRemove = {}; + let dataRemovalPermitted = {}; + + for (let item of PREF_LIST) { + // The property formData needs a different case than the + // formdata preference. + switch (item) { + case "formdata": + name = "formData"; + break; + case "cookies_sessions": + name = "cookies"; + break; + case "downloadFiles": + name = "downloads"; + break; + default: + name = item; + } + dataToRemove[name] = dataTrue.includes(`${PREF_KEY_PREFIX}${item}`); + // Firefox doesn't have the same concept of dataRemovalPermitted + // as Chrome, so it will always be true. + dataRemovalPermitted[name] = true; + } + // We do not provide option to delete history by time + // so, since value is given 0, which means Everything + return Promise.resolve({ + options: { since: 0 }, + dataToRemove, + dataRemovalPermitted, + }); + }, + remove(options, dataToRemove) { + return doRemoval(options, dataToRemove, extension); + }, + removeCache(options) { + return doRemoval(options, { cache: true }); + }, + removeCookies(options) { + return doRemoval(options, { cookies: true }); + }, + removeDownloads(options) { + return doRemoval(options, { downloads: true }); + }, + removeFormData(options) { + return doRemoval(options, { formData: true }); + }, + }, + }; + } +}; diff --git a/mobile/android/components/extensions/jar.mn b/mobile/android/components/extensions/jar.mn index 12501d6752ba..792a3b61d5c7 100644 --- a/mobile/android/components/extensions/jar.mn +++ b/mobile/android/components/extensions/jar.mn @@ -7,6 +7,7 @@ geckoview.jar: content/ext-c-android.js content/ext-c-tabs.js content/ext-browserAction.js + content/ext-browsingData.js content/ext-pageAction.js content/ext-tabs.js content/ext-utils.js diff --git a/mobile/android/components/extensions/schemas/browsing_data.json b/mobile/android/components/extensions/schemas/browsing_data.json new file mode 100644 index 000000000000..e89107872f4a --- /dev/null +++ b/mobile/android/components/extensions/schemas/browsing_data.json @@ -0,0 +1,421 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [{ + "type": "string", + "enum": [ + "browsingData" + ] + }] + } + ] + }, + { + "namespace": "browsingData", + "description": "Use the chrome.browsingData API to remove browsing data from a user's local profile.", + "permissions": ["browsingData"], + "types": [ + { + "id": "RemovalOptions", + "type": "object", + "description": "Options that determine exactly what data will be removed.", + "properties": { + "since": { + "$ref": "extensionTypes.Date", + "optional": true, + "description": "Remove data accumulated on or after this date, represented in milliseconds since the epoch (accessible via the getTime method of the JavaScript Date object). If absent, defaults to 0 (which would remove all browsing data)." + }, + "hostnames": { + "type": "array", + "items": {"type": "string", "format": "hostname"}, + "optional": true, + "description": "Only remove data associated with these hostnames (only applies to cookies)." + }, + "originTypes": { + "type": "object", + "optional": true, + "description": "An object whose properties specify which origin types ought to be cleared. If this object isn't specified, it defaults to clearing only \"unprotected\" origins. Please ensure that you really want to remove application data before adding 'protectedWeb' or 'extensions'.", + "properties": { + "unprotectedWeb": { + "type": "boolean", + "optional": true, + "description": "Normal websites." + }, + "protectedWeb": { + "type": "boolean", + "optional": true, + "description": "Websites that have been installed as hosted applications (be careful!)." + }, + "extension": { + "type": "boolean", + "optional": true, + "description": "Extensions and packaged applications a user has installed (be _really_ careful!)." + } + } + } + } + }, + { + "id": "DataTypeSet", + "type": "object", + "description": "A set of data types. Missing data types are interpreted as false.", + "properties": { + "cache": { + "type": "boolean", + "optional": true, + "description": "The browser's cache. Note: when removing data, this clears the entire cache: it is not limited to the range you specify." + }, + "cookies": { + "type": "boolean", + "optional": true, + "description": "The browser's cookies." + }, + "downloads": { + "type": "boolean", + "optional": true, + "description": "The browser's download list." + }, + "formData": { + "type": "boolean", + "optional": true, + "description": "The browser's stored form data." + }, + "history": { + "type": "boolean", + "optional": true, + "description": "The browser's history." + }, + "indexedDB": { + "type": "boolean", + "optional": true, + "description": "Websites' IndexedDB data." + }, + "localStorage": { + "type": "boolean", + "optional": true, + "description": "Websites' local storage data." + }, + "serverBoundCertificates": { + "type": "boolean", + "optional": true, + "description": "Server-bound certificates." + }, + "passwords": { + "type": "boolean", + "optional": true, + "description": "Stored passwords." + }, + "pluginData": { + "type": "boolean", + "optional": true, + "description": "Plugins' data." + }, + "serviceWorkers": { + "type": "boolean", + "optional": true, + "description": "Service Workers." + } + } + } + ], + "functions": [ + { + "name": "settings", + "description": "Reports which types of data are currently selected in the 'Clear browsing data' settings UI. Note: some of the data types included in this API are not available in the settings UI, and some UI settings control more than one data type listed here.", + "type": "function", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "options": { + "$ref": "RemovalOptions" + }, + "dataToRemove": { + "$ref": "DataTypeSet", + "description": "All of the types will be present in the result, with values of true if they are both selected to be removed and permitted to be removed, otherwise false." + }, + "dataRemovalPermitted": { + "$ref": "DataTypeSet", + "description": "All of the types will be present in the result, with values of true if they are permitted to be removed (e.g., by enterprise policy) and false if not." + } + } + } + ] + } + ] + }, + { + "name": "remove", + "description": "Clears various types of browsing data stored in a user's profile.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "dataToRemove", + "$ref": "DataTypeSet", + "description": "The set of data types to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Called when deletion has completed.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeAppcache", + "description": "Clears websites' appcache data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' appcache data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeCache", + "description": "Clears the browser's cache.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's cache has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeCookies", + "description": "Clears the browser's cookies and server-bound certificates modified within a particular timeframe.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's cookies and server-bound certificates have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeDownloads", + "description": "Clears the browser's list of downloaded files (not the downloaded files themselves).", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's list of downloaded files has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeFileSystems", + "description": "Clears websites' file system data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' file systems have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeFormData", + "description": "Clears the browser's stored form data (autofill).", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's form data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeHistory", + "description": "Clears the browser's history.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's history has cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeIndexedDB", + "description": "Clears websites' IndexedDB data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' IndexedDB data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeLocalStorage", + "description": "Clears websites' local storage data.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' local storage has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removePluginData", + "description": "Clears plugins' data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when plugins' data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removePasswords", + "description": "Clears the browser's stored passwords.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's passwords have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeWebSQL", + "description": "Clears websites' WebSQL data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' WebSQL databases have been cleared.", + "optional": true, + "parameters": [] + } + ] + } + ] + } +] diff --git a/mobile/android/components/extensions/schemas/jar.mn b/mobile/android/components/extensions/schemas/jar.mn index 9f15031cd1db..4a8829f6dad7 100644 --- a/mobile/android/components/extensions/schemas/jar.mn +++ b/mobile/android/components/extensions/schemas/jar.mn @@ -3,5 +3,6 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. geckoview.jar: + content/schemas/browsing_data.json content/schemas/gecko_view_addons.json content/schemas/tabs.json diff --git a/mobile/android/components/extensions/test/mochitest/chrome.ini b/mobile/android/components/extensions/test/mochitest/chrome.ini index 901c516d0a29..af66eaf21632 100644 --- a/mobile/android/components/extensions/test/mochitest/chrome.ini +++ b/mobile/android/components/extensions/test/mochitest/chrome.ini @@ -4,4 +4,8 @@ support-files = ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js tags = webextensions +[test_ext_browsingData_cookies_cache.html] +[test_ext_browsingData_downloads.html] +[test_ext_browsingData_formdata.html] +[test_ext_browsingData_settings.html] [test_ext_options_ui.html] diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_cookies_cache.html b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_cookies_cache.html new file mode 100644 index 000000000000..1671ba454df0 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_cookies_cache.html @@ -0,0 +1,147 @@ + + + + BrowsingData Cookies test + + + + + + + + + + + diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_downloads.html b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_downloads.html new file mode 100644 index 000000000000..30080ce60363 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_downloads.html @@ -0,0 +1,123 @@ + + + + BrowsingData Settings test + + + + + + + + + + diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_formdata.html b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_formdata.html new file mode 100644 index 000000000000..c59ff64a3e7f --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_formdata.html @@ -0,0 +1,149 @@ + + + + BrowsingData FormData test + + + + + + + + + + diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_settings.html b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_settings.html new file mode 100644 index 000000000000..dd6825b9e3af --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_browsingData_settings.html @@ -0,0 +1,98 @@ + + + + BrowsingData Settings test + + + + + + + + + + + diff --git a/mobile/android/locales/en-US/chrome/browser.properties b/mobile/android/locales/en-US/chrome/browser.properties index e3523c7f5bdd..6e604a8542e3 100644 --- a/mobile/android/locales/en-US/chrome/browser.properties +++ b/mobile/android/locales/en-US/chrome/browser.properties @@ -320,6 +320,9 @@ contextmenu.paste=Paste contextmenu.call=Call +# Select UI +selectHelper.closeMultipleSelectDialog=Done + #Input widgets UI inputWidgetHelper.date=Pick a date inputWidgetHelper.datetime-local=Pick a date and a time diff --git a/mobile/android/modules/Accounts.jsm b/mobile/android/modules/Accounts.jsm new file mode 100644 index 000000000000..79301884991c --- /dev/null +++ b/mobile/android/modules/Accounts.jsm @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["Accounts"]; + +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +/** + * A promise-based API for querying the existence of Sync accounts, + * and accessing the Sync setup wizard. + * + * Usage: + * + * Cu.import("resource://gre/modules/Accounts.jsm"); + * Accounts.anySyncAccountsExist().then( + * (exist) => { + * console.log("Accounts exist? " + exist); + * if (!exist) { + * Accounts.launchSetup(); + * } + * }, + * (err) => { + * console.log("We failed so hard."); + * } + * ); + */ +var Accounts = Object.freeze({ + _accountsExist: function(kind) { + return EventDispatcher.instance + .sendRequestForResult({ + type: "Accounts:Exist", + kind: kind, + }) + .then(data => data.exists); + }, + + firefoxAccountsExist: function() { + return this._accountsExist("fxa"); + }, + + anySyncAccountsExist: function() { + return this._accountsExist("any"); + }, + + /** + * Fire-and-forget: open the Firefox accounts activity, which + * will be the Getting Started screen if FxA isn't yet set up. + * + * Optional extras are passed, as a JSON string, to the Firefox + * Account Getting Started activity in the extras bundle of the + * activity launch intent, under the key "extras". + * + * There is no return value from this method. + */ + launchSetup: function(extras) { + EventDispatcher.instance.sendRequest({ + type: "Accounts:Create", + extras: extras, + }); + }, + + _addDefaultEndpoints: function(json) { + let newData = Cu.cloneInto(json, {}, { cloneFunctions: false }); + let associations = { + authServerEndpoint: "identity.fxaccounts.auth.uri", + profileServerEndpoint: "identity.fxaccounts.remote.profile.uri", + tokenServerEndpoint: "identity.sync.tokenserver.uri", + }; + for (let key in associations) { + newData[key] = + newData[key] || Services.urlFormatter.formatURLPref(associations[key]); + } + return newData; + }, + + /** + * Create a new Android Account corresponding to the given + * fxa-content-server "login" JSON datum. The new account will be + * in the "Engaged" state, and will start syncing immediately. + * + * It is an error if an Android Account already exists. + * + * Returns a Promise that resolves to a boolean indicating success. + */ + createFirefoxAccountFromJSON: function(json) { + return EventDispatcher.instance.sendRequestForResult({ + type: "Accounts:CreateFirefoxAccountFromJSON", + json: this._addDefaultEndpoints(json), + }); + }, + + /** + * Move an existing Android Account to the "Engaged" state with the given + * fxa-content-server "login" JSON datum. The account will (re)start + * syncing immediately, unless the user has manually configured the account + * to not Sync. + * + * It is an error if no Android Account exists. + * + * Returns a Promise that resolves to a boolean indicating success. + */ + updateFirefoxAccountFromJSON: function(json) { + return EventDispatcher.instance.sendRequestForResult({ + type: "Accounts:UpdateFirefoxAccountFromJSON", + json: this._addDefaultEndpoints(json), + }); + }, + + /** + * Notify that profile for Android Account has updated. + * The account will re-fetch the profile image. + * + * It is an error if no Android Account exists. + * + * There is no return value from this method. + */ + notifyFirefoxAccountProfileChanged: function() { + EventDispatcher.instance.sendRequest({ + type: "Accounts:ProfileUpdated", + }); + }, + + /** + * Fetch information about an existing Android Firefox Account. + * + * Returns a Promise that resolves to null if no Android Firefox Account + * exists, or an object including at least a string-valued 'email' key. + */ + getFirefoxAccount: function() { + return EventDispatcher.instance + .sendRequestForResult({ + type: "Accounts:Exist", + kind: "fxa", + }) + .then(data => { + if (!data || !data.exists) { + return null; + } + delete data.exists; + return data; + }); + }, + + /** + * Delete an existing Android Firefox Account. + * + * It is an error if no Android Account exists. + * + * Returns a Promise that resolves to a boolean indicating success. + */ + deleteFirefoxAccount: function() { + return EventDispatcher.instance.sendRequestForResult({ + type: "Accounts:DeleteFirefoxAccount", + }); + }, + + showSyncPreferences: function() { + // Only show Sync preferences of an existing Android Account. + return Accounts.getFirefoxAccount().then(account => { + if (!account) { + throw new Error( + "Can't show Sync preferences of non-existent Firefox Account!" + ); + } + return EventDispatcher.instance.sendRequestForResult({ + type: "Accounts:ShowSyncPreferences", + }); + }); + }, +}); diff --git a/mobile/android/modules/ActionBarHandler.jsm b/mobile/android/modules/ActionBarHandler.jsm new file mode 100644 index 000000000000..3a77696f728b --- /dev/null +++ b/mobile/android/modules/ActionBarHandler.jsm @@ -0,0 +1,904 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ActionBarHandler"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", + EventDispatcher: "resource://gre/modules/Messaging.jsm", + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + Snackbars: "resource://gre/modules/Snackbars.jsm", + UITelemetry: "resource://gre/modules/UITelemetry.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "ParentalControls", + "@mozilla.org/parental-controls-service;1", + "nsIParentalControlsService" +); + +var Strings = {}; + +XPCOMUtils.defineLazyGetter(Strings, "browser", _ => + Services.strings.createBundle("chrome://browser/locale/browser.properties") +); + +const PHONE_REGEX = /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/; // Are we a phone #? + +/** + * ActionBarHandler Object and methods. Interface between Gecko Text Selection code + * (AccessibleCaret, etc) and the Mobile ActionBar UI. + */ +var ActionBarHandler = { + // Error codes returned from _init(). + START_TOUCH_ERROR: { + NO_CONTENT_WINDOW: "No valid content Window found.", + NONE: "", + }, + + _nextSelectionID: 1, // Next available. + _selectionID: null, // Unique Selection ID, assigned each time we _init(). + + _boundingClientRect: null, // Current selections boundingClientRect. + _actionBarActions: null, // Most-recent set of actions sent to ActionBar. + + /** + * Receive and act on AccessibleCarets caret state-change + * (mozcaretstatechanged) events. + */ + handleEvent: function(e) { + e.stopImmediatePropagation(); + + if (e.reason === "presscaret" || e.reason === "releasecaret") { + const dispatcher = this._getDispatcher(Services.focus.focusedWindow); + if (dispatcher) { + dispatcher.sendRequest({ + type: "GeckoView:PinOnScreen", + pinned: e.reason === "presscaret", + }); + } + } + + // Close an open ActionBar, if carets no longer logically visible. + if (this._selectionID && !e.caretVisible) { + this._uninit(false); + return; + } + + if (!this._selectionID && e.collapsed) { + switch (e.reason) { + case "longpressonemptycontent": + case "taponcaret": + // Show ActionBar when long pressing on an empty input or single + // tapping on the caret. + this._init(e.boundingClientRect); + break; + + case "updateposition": + // Do not show ActionBar when single tapping on an non-empty editable + // input. + break; + + default: + break; + } + return; + } + + // Open a closed ActionBar if carets actually visible. + if (!this._selectionID && e.caretVisuallyVisible) { + this._init(e.boundingClientRect); + return; + } + + // Else, update an open ActionBar. + if (this._selectionID) { + if (!this._selectionHasChanged()) { + // Still the same active selection. + if (e.reason == "presscaret" || e.reason == "scroll") { + // boundingClientRect doesn't matter since we are hiding the floating + // toolbar. + this._updateVisibility(); + } else { + // Selection changes update boundingClientRect. + this._boundingClientRect = e.boundingClientRect; + let forceUpdate = + e.reason == "updateposition" || e.reason == "releasecaret"; + this._sendActionBarActions(forceUpdate); + } + } else { + // We've started a new selection entirely. + this._uninit(false); + this._init(e.boundingClientRect); + } + } + }, + + /** + * ActionBarHandler notification observers. + */ + onEvent: function(event, data, callback) { + switch (event) { + // User click an ActionBar button. + case "TextSelection:Action": { + if (!this._selectionID) { + break; + } + for (let type in this.actions) { + let action = this.actions[type]; + if (action.id == data.id) { + action.action(this._targetElement, this._contentWindow); + break; + } + } + break; + } + + // Provide selected text to FindInPageBar on request. + case "TextSelection:Get": { + try { + callback.onSuccess(this._getSelectedText()); + } catch (e) { + callback.onError(e.toString()); + } + this._uninit(); + break; + } + + // User closed ActionBar by clicking "checkmark" button. + case "TextSelection:End": { + // End the requested selection only. + if (this._selectionID == data.selectionID) { + this._uninit(); + } + break; + } + } + }, + + _getDispatcher: function(win) { + try { + return GeckoViewUtils.getDispatcherForWindow(win); + } catch (e) { + return null; + } + }, + + /** + * Called when Gecko AccessibleCaret becomes visible. + */ + _init: function(boundingClientRect) { + let [element, win] = this._getSelectionTargets(); + let dispatcher = this._getDispatcher(win); + if (!win || !dispatcher) { + return this.START_TOUCH_ERROR.NO_CONTENT_WINDOW; + } + + // Hold the ActionBar ID provided by Gecko. + this._selectionID = this._nextSelectionID++; + [this._targetElement, this._contentWindow] = [element, win]; + this._boundingClientRect = boundingClientRect; + + // Open the ActionBar, send it's actions list. + dispatcher.sendRequest({ + type: "TextSelection:ActionbarInit", + selectionID: this._selectionID, + }); + this._sendActionBarActions(true); + + return this.START_TOUCH_ERROR.NONE; + }, + + /** + * Called when content is scrolled and handles are hidden. + */ + _updateVisibility: function() { + let win = this._contentWindow; + let dispatcher = this._getDispatcher(win); + if (!dispatcher) { + return; + } + dispatcher.sendRequest({ + type: "TextSelection:Visibility", + selectionID: this._selectionID, + }); + }, + + /** + * Determines the window containing the selection, and its + * editable element if present. + */ + _getSelectionTargets: function() { + let [element, win] = [ + Services.focus.focusedElement, + Services.focus.focusedWindow, + ]; + if (!element) { + // No focused editable. + return [null, win]; + } + + // Return focused editable text element and its window. + if ( + (ChromeUtils.getClassName(element) === "HTMLInputElement" && + element.mozIsTextField(false)) || + ChromeUtils.getClassName(element) === "HTMLTextAreaElement" || + element.isContentEditable + ) { + return [element, win]; + } + + // Focused element can't contain text. + return [null, win]; + }, + + /** + * The active Selection has changed, if the current focused element / win, + * pair, or state of the win's designMode changes. + */ + _selectionHasChanged: function() { + let [element, win] = this._getSelectionTargets(); + return ( + this._targetElement !== element || + this._contentWindow !== win || + this._isInDesignMode(this._contentWindow) !== this._isInDesignMode(win) + ); + }, + + /** + * Called when Gecko AccessibleCaret becomes hidden, + * ActionBar is closed by user "close" request, or as a result of object + * methods such as SELECT_ALL, PASTE, etc. + */ + _uninit: function(clearSelection = true) { + // Bail if there's no active selection. + if (!this._selectionID) { + return; + } + + let win = this._contentWindow; + let dispatcher = this._getDispatcher(win); + if (dispatcher) { + // Close the ActionBar. + dispatcher.sendRequest({ + type: "TextSelection:ActionbarUninit", + }); + } + + // Clear the selection ID to complete the uninit(), but leave our reference + // to selectionTargets (_targetElement, _contentWindow) in case we need + // a final clearSelection(). + this._selectionID = null; + this._boundingClientRect = null; + + // Clear selection required if triggered by self, or TextSelection icon + // actions. If called by Gecko CaretStateChangedEvent, + // visibility state is already correct. + if (clearSelection) { + this._clearSelection(); + } + }, + + /** + * Final UI cleanup when Actionbar is closed by icon click, or where + * we terminate selection state after before/after actionbar actions + * (Cut, Copy, Paste, Search, Share, Call). + */ + _clearSelection: function( + element = this._targetElement, + win = this._contentWindow + ) { + // Commit edit compositions, and clear focus from editables. + if (element) { + let editor = this._getEditor(element, win); + if (editor.composing) { + editor.forceCompositionEnd(); + } + element.blur(); + } + + // Remove Selection from non-editables and now-unfocused contentEditables. + if (!element || element.isContentEditable) { + this._getSelection().removeAllRanges(); + } + }, + + /** + * Called to determine current ActionBar actions and send to TextSelection + * handler. By default we only send if current action state differs from + * the previous. + * @param By default we only send an ActionBarStatus update message if + * there is a change from the previous state. sendAlways can be + * set by init() for example, where we want to always send the + * current state. + */ + _sendActionBarActions: function(sendAlways) { + let actions = this._getActionBarActions(); + + let actionCountUnchanged = + this._actionBarActions && + actions.length === this._actionBarActions.length; + let actionsMatch = + actionCountUnchanged && + this._actionBarActions.every((e, i) => { + return e.id === actions[i].id; + }); + + let win = this._contentWindow; + let dispatcher = this._getDispatcher(win); + if (!dispatcher) { + return; + } + + if (sendAlways || !actionsMatch) { + dispatcher.sendRequest({ + type: "TextSelection:ActionbarStatus", + selectionID: this._selectionID, + actions: actions, + x: this._boundingClientRect.x, + y: this._boundingClientRect.y, + width: this._boundingClientRect.width, + height: this._boundingClientRect.height, + }); + } + + this._actionBarActions = actions; + }, + + /** + * Determine and return current ActionBar state. + */ + _getActionBarActions: function( + element = this._targetElement, + win = this._contentWindow + ) { + let actions = []; + + for (let type in this.actions) { + let action = this.actions[type]; + if (action.selector.matches(element, win)) { + let a = { + id: action.id, + label: this._getActionValue(action, "label", "", element), + icon: this._getActionValue( + action, + "icon", + "drawable://ic_status_logo", + element + ), + order: this._getActionValue(action, "order", 0, element), + floatingOrder: this._getActionValue( + action, + "floatingOrder", + 9, + element + ), + showAsAction: this._getActionValue( + action, + "showAsAction", + true, + element + ), + }; + actions.push(a); + } + } + actions.sort((a, b) => b.order - a.order); + + return actions; + }, + + /** + * Provides a value from an action. If the action defines the value as a function, + * we return the result of calling the function. Otherwise, we return the value + * itself. If the value isn't defined for this action, will return a default. + */ + _getActionValue: function(obj, name, defaultValue, element) { + if (!(name in obj)) { + return defaultValue; + } + + if (typeof obj[name] == "function") { + return obj[name](element); + } + + return obj[name]; + }, + + /** + * Actionbar callback methods. + */ + actions: { + SELECT_ALL: { + id: "selectall_action", + label: () => Strings.browser.GetStringFromName("contextmenu.selectAll"), + icon: "drawable://ab_select_all", + order: 5, + floatingOrder: 5, + + selector: { + matches: function(element, win) { + // For editable, check its length. For default contentWindow, assume + // true, else there'd been nothing to long-press to open ActionBar. + return element ? element.textLength != 0 : true; + }, + }, + + action: function(element, win) { + // Some Mobile keyboards such as SwiftKeyboard, provide auto-suggest + // style highlights via composition selections in editables. + if (element) { + // If we have an active composition string, commit it, and + // ensure proper element focus. + let editor = ActionBarHandler._getEditor(element, win); + if (editor.composing) { + element.blur(); + element.focus(); + } + } + + // Close ActionBarHandler, then selectAll, and display handles. + ActionBarHandler._getDocShell(win).doCommand("cmd_selectAll"); + UITelemetry.addEvent("action.1", "actionbar", null, "select_all"); + }, + }, + + CUT: { + id: "cut_action", + label: () => Strings.browser.GetStringFromName("contextmenu.cut"), + icon: "drawable://ab_cut", + order: 4, + floatingOrder: 1, + + selector: { + matches: function(element, win) { + // Can cut from editable, or design-mode document. + if (!element && !ActionBarHandler._isInDesignMode(win)) { + return false; + } + // Don't allow "cut" from password fields. + if ( + element && + ChromeUtils.getClassName(element) === "HTMLInputElement" && + !element.mozIsTextField(true) + ) { + return false; + } + // Don't allow "cut" from disabled/readonly fields. + if (element && (element.disabled || element.readOnly)) { + return false; + } + // Allow if selected text exists. + return ActionBarHandler._getSelectedText().length > 0; + }, + }, + + action: function(element, win) { + ActionBarHandler._getEditor(element, win).cut(); + + let msg = Strings.browser.GetStringFromName( + "selectionHelper.textCopied" + ); + Snackbars.show(msg, Snackbars.LENGTH_LONG); + + ActionBarHandler._uninit(); + UITelemetry.addEvent("action.1", "actionbar", null, "cut"); + }, + }, + + COPY: { + id: "copy_action", + label: () => Strings.browser.GetStringFromName("contextmenu.copy"), + icon: "drawable://ab_copy", + order: 3, + floatingOrder: 2, + + selector: { + matches: function(element, win) { + // Don't allow "copy" from password fields. + if ( + element && + ChromeUtils.getClassName(element) === "HTMLInputElement" && + !element.mozIsTextField(true) + ) { + return false; + } + // Allow if selected text exists. + return ActionBarHandler._getSelectedText().length > 0; + }, + }, + + action: function(element, win) { + ActionBarHandler._getDocShell(win).doCommand("cmd_copy"); + + let msg = Strings.browser.GetStringFromName( + "selectionHelper.textCopied" + ); + Snackbars.show(msg, Snackbars.LENGTH_LONG); + + ActionBarHandler._uninit(); + UITelemetry.addEvent("action.1", "actionbar", null, "copy"); + }, + }, + + PASTE: { + id: "paste_action", + label: () => Strings.browser.GetStringFromName("contextmenu.paste"), + icon: "drawable://ab_paste", + order: 2, + floatingOrder: 3, + + selector: { + matches: function(element, win) { + // Can paste to editable, or design-mode document. + if (!element && !ActionBarHandler._isInDesignMode(win)) { + return false; + } + // Can't paste into disabled/readonly fields. + if (element && (element.disabled || element.readOnly)) { + return false; + } + // Can't paste if Clipboard empty. + let flavors = ["text/unicode"]; + return Services.clipboard.hasDataMatchingFlavors( + flavors, + Ci.nsIClipboard.kGlobalClipboard + ); + }, + }, + + action: function(element, win) { + // Paste the clipboard, then close the ActionBarHandler and ActionBar. + ActionBarHandler._getEditor(element, win).paste( + Ci.nsIClipboard.kGlobalClipboard + ); + ActionBarHandler._uninit(); + UITelemetry.addEvent("action.1", "actionbar", null, "paste"); + }, + }, + + CALL: { + id: "call_action", + label: () => Strings.browser.GetStringFromName("contextmenu.call"), + icon: "drawable://phone", + order: 1, + floatingOrder: 0, + + selector: { + matches: function(element, win) { + return ActionBarHandler._getSelectedPhoneNumber() != null; + }, + }, + + action: function(element, win) { + let uri = "tel:" + ActionBarHandler._getSelectedPhoneNumber(); + let chrome = GeckoViewUtils.getChromeWindow(win); + if (chrome.BrowserApp && chrome.BrowserApp.loadURI) { + chrome.BrowserApp.loadURI(uri); + } else { + let bwin = chrome.browserDOMWindow; + if (bwin) { + bwin.openURI( + Services.io.newURI(uri), + win, + Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + Ci.nsIBrowserDOMWindow.OPEN_NEW, + win.document.nodePrincipal + ); + } + } + + ActionBarHandler._uninit(); + UITelemetry.addEvent("action.1", "actionbar", null, "call"); + }, + }, + + SEARCH: { + id: "search_action", + label: () => + Strings.browser.formatStringFromName("contextmenu.search", [ + Services.search.defaultEngine.name, + ]), + icon: "drawable://ab_search", + order: 1, + floatingOrder: 6, + + selector: { + matches: function(element, win) { + // Allow if selected text exists. + return ActionBarHandler._getSelectedText().length > 0; + }, + }, + + action: function(element, win) { + let selectedText = BrowserUtils.trimSelection( + ActionBarHandler._getSelectedText() + ); + ActionBarHandler._uninit(); + + // Set current tab as parent of new tab, + // and set new tab as private if the parent is. + let searchSubmission = Services.search.defaultEngine.getSubmission( + selectedText + ); + let chrome = GeckoViewUtils.getChromeWindow(win); + if ( + chrome.BrowserApp && + chrome.BrowserApp.selectedTab && + chrome.BrowserApp.addTab + ) { + let parent = chrome.BrowserApp.selectedTab; + let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser); + chrome.BrowserApp.addTab(searchSubmission.uri.spec, { + parentId: parent.id, + selected: true, + isPrivate: isPrivate, + }); + } else { + let bwin = chrome.browserDOMWindow; + if (bwin) { + bwin.openURI( + searchSubmission.uri, + win, + Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + Ci.nsIBrowserDOMWindow.OPEN_NEW, + win.document.nodePrincipal + ); + } + } + + UITelemetry.addEvent("action.1", "actionbar", null, "search"); + }, + }, + + SEARCH_ADD: { + id: "search_add_action", + label: () => + Strings.browser.GetStringFromName("contextmenu.addSearchEngine3"), + icon: "drawable://ab_add_search_engine", + order: 0, + floatingOrder: 8, + + selector: { + matches: function(element, win) { + let chrome = GeckoViewUtils.getChromeWindow(win); + if (!chrome.SearchEngines) { + return false; + } + if ( + !element || + ChromeUtils.getClassName(element) !== "HTMLInputElement" + ) { + return false; + } + let form = element.form; + if (!form || element.type == "password") { + return false; + } + + let method = form.method.toUpperCase(); + let canAddEngine = + method == "GET" || + (method == "POST" && + (form.enctype != "text/plain" && + form.enctype != "multipart/form-data")); + if (!canAddEngine) { + return false; + } + + // If SearchEngine query finds it, then we don't want action to add displayed. + if (chrome.SearchEngines.visibleEngineExists(element)) { + return false; + } + + return true; + }, + }, + + action: function(element, win) { + UITelemetry.addEvent( + "action.1", + "actionbar", + null, + "add_search_engine" + ); + + // Engines are added asynch. If required, update SelectionUI on callback. + let chrome = GeckoViewUtils.getChromeWindow(win); + chrome.SearchEngines.addEngine(element, result => { + if (result) { + ActionBarHandler._sendActionBarActions(true); + } + }); + }, + }, + + SHARE: { + id: "share_action", + label: () => Strings.browser.GetStringFromName("contextmenu.share"), + icon: "drawable://ic_menu_share", + order: 0, + floatingOrder: 4, + + selector: { + matches: function(element, win) { + if (!ParentalControls.isAllowed(ParentalControls.SHARE)) { + return false; + } + // Allow if selected text exists. + return ActionBarHandler._getSelectedText().length > 0; + }, + }, + + action: function(element, win) { + let title = win.document.title; + if (title && title.length > 200) { + let ellipsis = "\u2026"; + try { + ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + title = title.slice(0, 200) + ellipsis; // Add ellipsis. + } else if (!title) { + title = win.location.href; + } + EventDispatcher.instance.sendRequest({ + type: "Share:Text", + text: ActionBarHandler._getSelectedText(), + title: title, + }); + + ActionBarHandler._uninit(); + UITelemetry.addEvent("action.1", "actionbar", null, "share"); + }, + }, + }, + + /** + * Provides UUID service for generating action ID's. + */ + get _idService() { + delete this._idService; + return (this._idService = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator + )); + }, + + /** + * The targetElement holds an editable element containing a + * selection or a caret. + */ + get _targetElement() { + if (this._targetElementRef) { + return this._targetElementRef.get(); + } + return null; + }, + + set _targetElement(element) { + this._targetElementRef = Cu.getWeakReference(element); + }, + + /** + * The contentWindow holds the selection, or the targetElement + * if it's an editable. + */ + get _contentWindow() { + if (this._contentWindowRef) { + return this._contentWindowRef.get(); + } + return null; + }, + + set _contentWindow(aContentWindow) { + this._contentWindowRef = Cu.getWeakReference(aContentWindow); + }, + + /** + * If we have an active selection, is it part of a designMode document? + */ + _isInDesignMode: function(win) { + return this._selectionID && win.document.designMode === "on"; + }, + + /** + * Get current DocShell object. + */ + _getDocShell: function(win) { + return win.docShell; + }, + + /** + * Provides the currently selected text, for either an editable, + * or for the default contentWindow. + */ + _getSelectedText: function() { + // Can be called from FindInPageBar "TextSelection:Get", when there + // is no active selection. + if (!this._selectionID) { + return ""; + } + + let selection = this._getSelection(); + + // Textarea can contain LF, etc. + if ( + this._targetElement && + ChromeUtils.getClassName(this._targetElement) === "HTMLTextAreaElement" + ) { + let flags = + Ci.nsIDocumentEncoder.OutputPreformatted | + Ci.nsIDocumentEncoder.OutputRaw; + return selection.toStringWithFormat("text/plain", flags, 0); + } + + // Return explicitly selected text. + return selection.toString(); + }, + + /** + * Tests whether a given element is editable. + */ + _isElementEditable: function(element) { + if (!element) { + return false; + } + let elementClass = ChromeUtils.getClassName(element); + return ( + elementClass === "HTMLInputElement" || + elementClass === "HTMLTextAreaElement" + ); + }, + + /** + * Provides the Selection for either an editor, or from the + * default window. + */ + _getSelection: function( + element = this._targetElement, + win = this._contentWindow + ) { + return this._isElementEditable(element) + ? this._getEditor(element).selection + : win.getSelection(); + }, + + /** + * Returns an nsEditor or nsHTMLEditor. + */ + _getEditor: function( + element = this._targetElement, + win = this._contentWindow + ) { + if (this._isElementEditable(element)) { + return element.editor; + } + + return win.docShell.editingSession.getEditorForWindow(win); + }, + + /** + * Call / Phone Helper methods. + */ + _getSelectedPhoneNumber: function() { + let selectedText = this._getSelectedText().trim(); + return this._isPhoneNumber(selectedText) ? selectedText : null; + }, + + _isPhoneNumber: function(selectedText) { + return PHONE_REGEX.test(selectedText); + }, +}; diff --git a/mobile/android/modules/DownloadNotifications.jsm b/mobile/android/modules/DownloadNotifications.jsm new file mode 100644 index 000000000000..84badd60afc8 --- /dev/null +++ b/mobile/android/modules/DownloadNotifications.jsm @@ -0,0 +1,348 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DownloadNotifications"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "Downloads", + "resource://gre/modules/Downloads.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Notifications", + "resource://gre/modules/Notifications.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Snackbars", + "resource://gre/modules/Snackbars.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "UITelemetry", + "resource://gre/modules/UITelemetry.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "ParentalControls", + "@mozilla.org/parental-controls-service;1", + "nsIParentalControlsService" +); + +XPCOMUtils.defineLazyGetter(this, "strings", () => + Services.strings.createBundle("chrome://browser/locale/browser.properties") +); + +Object.defineProperty(this, "window", { + get: () => Services.wm.getMostRecentWindow("navigator:browser"), +}); + +const kButtons = { + PAUSE: new DownloadNotificationButton( + "pause", + "drawable://pause", + "alertDownloadsPause" + ), + RESUME: new DownloadNotificationButton( + "resume", + "drawable://play", + "alertDownloadsResume" + ), + CANCEL: new DownloadNotificationButton( + "cancel", + "drawable://close", + "alertDownloadsCancel" + ), +}; + +var notifications = new Map(); + +var DownloadNotifications = { + _notificationKey: "downloads", + + observe: function(subject, topic, data) { + if (topic === "chrome-document-loaded") { + this.init(); + } + }, + + init: function() { + Downloads.getList(Downloads.ALL) + .then(list => list.addView(this)) + .then(() => (this._viewAdded = true), Cu.reportError); + + // All click, cancel, and button presses will be handled by this handler as part of the Notifications callback API. + Notifications.registerHandler(this._notificationKey, this); + }, + + onDownloadAdded: function(download) { + // Don't create notifications for pre-existing succeeded downloads. + // We still add notifications for canceled downloads in case the + // user decides to retry the download. + if (download.succeeded && !this._viewAdded) { + return; + } + + if (!ParentalControls.isAllowed(ParentalControls.DOWNLOAD)) { + download.cancel().catch(Cu.reportError); + download.removePartialData().catch(Cu.reportError); + Snackbars.show( + strings.GetStringFromName("downloads.disabledInGuest"), + Snackbars.LENGTH_LONG + ); + return; + } + + let notification = new DownloadNotification(download); + notifications.set(download, notification); + notification.showOrUpdate(); + + // If this is a new download, show a snackbar as well. + if (this._viewAdded) { + Snackbars.show( + strings.GetStringFromName("alertDownloadsToast"), + Snackbars.LENGTH_LONG + ); + } + }, + + onDownloadChanged: function(download) { + let notification = notifications.get(download); + + if (download.succeeded) { + let file = new FileUtils.File(download.target.path); + + Snackbars.show( + strings.formatStringFromName("alertDownloadSucceeded", [file.leafName]), + Snackbars.LENGTH_LONG, + { + action: { + label: strings.GetStringFromName("helperapps.open"), + callback: () => { + UITelemetry.addEvent("launch.1", "toast", null, "downloads"); + try { + file.launch(); + } catch (ex) { + this.showInAboutDownloads(download); + } + if (notification) { + notification.hide(); + } + }, + }, + } + ); + } + + if (notification) { + notification.showOrUpdate(); + } + }, + + onDownloadRemoved: function(download) { + let notification = notifications.get(download); + if (!notification) { + Cu.reportError("Download doesn't have a notification."); + return; + } + + notification.hide(); + notifications.delete(download); + }, + + _findDownloadForCookie: function(cookie) { + return Downloads.getList(Downloads.ALL) + .then(list => list.getAll()) + .then(downloads => { + for (let download of downloads) { + let cookie2 = getCookieFromDownload(download); + if (cookie2 === cookie) { + return download; + } + } + + throw new Error("Couldn't find download for " + cookie); + }); + }, + + onCancel: function(cookie) { + // TODO: I'm not sure what we do here... + }, + + showInAboutDownloads: function(download) { + let hash = "#" + window.encodeURIComponent(download.target.path); + + // Force using string equality to find a tab + window.BrowserApp.selectOrAddTab("about:downloads" + hash, null, { + startsWith: true, + }); + }, + + onClick: function(cookie) { + this._findDownloadForCookie(cookie) + .then(download => { + if (download.succeeded) { + // We don't call Download.launch(), because there's (currently) no way to + // tell if the file was actually launched or not, and we want to show + // about:downloads if the launch failed. + let file = new FileUtils.File(download.target.path); + try { + file.launch(); + } catch (ex) { + this.showInAboutDownloads(download); + } + } else { + this.showInAboutDownloads(download); + } + }) + .catch(Cu.reportError); + }, + + onButtonClick: function(button, cookie) { + this._findDownloadForCookie(cookie) + .then(download => { + if (button === kButtons.PAUSE.buttonId) { + download.cancel().catch(Cu.reportError); + } else if (button === kButtons.RESUME.buttonId) { + download.start().catch(Cu.reportError); + } else if (button === kButtons.CANCEL.buttonId) { + download.cancel().catch(Cu.reportError); + download.removePartialData().catch(Cu.reportError); + } + }) + .catch(Cu.reportError); + }, +}; + +function getCookieFromDownload(download) { + // Arbitrary value used to truncate long Data URLs. See bug 1497526 + const maxUrlLength = 1024; + return ( + download.target.path + + download.source.url.slice(-maxUrlLength) + + download.startTime + ); +} + +function DownloadNotification(download) { + this.download = download; + this._fileName = OS.Path.basename(download.target.path); + + this.id = null; +} + +DownloadNotification.prototype = { + _updateFromDownload: function() { + this._downloading = !this.download.stopped; + this._paused = this.download.canceled && this.download.hasPartialData; + this._succeeded = this.download.succeeded; + + this._show = this._downloading || this._paused || this._succeeded; + }, + + get options() { + if (!this._show) { + return null; + } + + let options = { + icon: "drawable://alert_download", + cookie: getCookieFromDownload(this.download), + handlerKey: DownloadNotifications._notificationKey, + }; + + if (this._downloading) { + options.icon = "drawable://alert_download_animation"; + if (this.download.currentBytes == 0) { + this._updateOptionsForStatic(options, "alertDownloadsStart2"); + } else { + let buttons = this.download.hasPartialData + ? [kButtons.PAUSE, kButtons.CANCEL] + : [kButtons.CANCEL]; + this._updateOptionsForOngoing(options, buttons); + } + } else if (this._paused) { + this._updateOptionsForOngoing(options, [ + kButtons.RESUME, + kButtons.CANCEL, + ]); + } else if (this._succeeded) { + options.persistent = false; + this._updateOptionsForStatic(options, "alertDownloadsDone2"); + } + + return options; + }, + + _updateOptionsForStatic: function(options, titleName) { + options.title = strings.GetStringFromName(titleName); + options.message = this._fileName; + }, + + _updateOptionsForOngoing: function(options, buttons) { + options.title = this._fileName; + options.message = this.download.progress + "%"; + options.buttons = buttons; + options.ongoing = true; + options.progress = this.download.progress; + options.persistent = true; + }, + + showOrUpdate: function() { + this._updateFromDownload(); + + if (this._show) { + if (!this.id) { + this.id = Notifications.create(this.options); + } else if (!this.options.ongoing) { + // We need to explictly cancel ongoing notifications, + // since updating them to be non-ongoing doesn't seem + // to work. See bug 1130834. + Notifications.cancel(this.id); + this.id = Notifications.create(this.options); + } else { + Notifications.update(this.id, this.options); + } + } else { + this.hide(); + } + }, + + hide: function() { + if (this.id) { + Notifications.cancel(this.id); + this.id = null; + } + }, +}; + +function DownloadNotificationButton( + buttonId, + iconUrl, + titleStringName, + onClicked +) { + this.buttonId = buttonId; + this.title = strings.GetStringFromName(titleStringName); + this.icon = iconUrl; +} diff --git a/mobile/android/modules/FormAssistant.jsm b/mobile/android/modules/FormAssistant.jsm new file mode 100644 index 000000000000..b56be2656d75 --- /dev/null +++ b/mobile/android/modules/FormAssistant.jsm @@ -0,0 +1,438 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["FormAssistant"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.jsm", + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +var FormAssistant = { + // Weak-ref used to keep track of the currently focused element. + _currentFocusedElement: null, + + // Whether we're in the middle of an autocomplete. + _doingAutocomplete: false, + + // Last state received in "PanZoom:StateChange" observer. + _lastPanZoomState: "NOTHING", + + init: function() { + Services.obs.addObserver(this, "PanZoom:StateChange"); + }, + + _onPopupResponse: function(currentElement, message) { + switch (message.action) { + case "autocomplete": { + this._doingAutocomplete = true; + + // If we have an active composition string, commit it before sending + // the autocomplete event with the text that will replace it. + try { + if (currentElement.editor.composing) { + currentElement.editor.forceCompositionEnd(); + } + } catch (e) {} + + currentElement.setUserInput(message.value); + + let event = currentElement.ownerDocument.createEvent("Events"); + event.initEvent("DOMAutoComplete", true, true); + currentElement.dispatchEvent(event); + + this._doingAutocomplete = false; + break; + } + + case "remove": { + FormHistory.update({ + op: "remove", + fieldname: currentElement.name, + value: message.value, + }); + break; + } + } + }, + + observe: function(aSubject, aTopic, aData) { + switch (aTopic) { + case "PanZoom:StateChange": + // If the user is just touching the screen and we haven't entered a pan + // or zoom state yet do nothing. + if (aData == "TOUCHING" || aData == "WAITING_LISTENERS") { + break; + } + let focused = this.focusedElement; + if (aData == "NOTHING") { + if (!focused || this._showValidationMessage(focused)) { + break; + } + this._showAutoCompleteSuggestions(focused, hasResults => { + if (!hasResults) { + this._hideFormAssistPopup(focused); + } + }); + } else if (focused) { + // temporarily hide the form assist popup while we're panning or zooming the page + this._hideFormAssistPopup(focused); + } + this._lastPanZoomState = aData; + break; + } + }, + + notifyInvalidSubmit: function(aFormElement, aInvalidElements) { + if (!aInvalidElements.length) { + return; + } + + // Ignore this notificaiton if the current tab doesn't contain the invalid element + let currentElement = aInvalidElements[0]; + let focused = this.focusedElement; + if (focused && focused.ownerGlobal.top !== currentElement.ownerGlobal.top) { + return; + } + + // Our focus listener will show the element's validation message + currentElement.focus(); + }, + + get focusedElement() { + return this._currentFocusedElement && this._currentFocusedElement.get(); + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "focus": { + let currentElement = aEvent.target; + // Only show a validation message on focus. + if ( + this._showValidationMessage(currentElement) || + this._isAutoComplete(currentElement) + ) { + this._currentFocusedElement = Cu.getWeakReference(currentElement); + // Start listening to resizes. + currentElement.ownerGlobal.addEventListener("resize", this, { + capture: true, + mozSystemGroup: true, + once: true, + }); + } + break; + } + + case "blur": { + let focused = this.focusedElement; + if (focused) { + this._hideFormAssistPopup(focused); + } + this._currentFocusedElement = null; + break; + } + + case "click": { + let currentElement = aEvent.target; + if (currentElement !== this.focusedElement) { + break; + } + + // Prioritize a form validation message over autocomplete suggestions + // when the element is first focused (a form validation message will + // only be available if an invalid form was submitted) + if (this._isValidateable(currentElement)) { + break; + } + + let checkResultsClick = hasResults => { + if (!hasResults && currentElement === this.focusedElement) { + this._hideFormAssistPopup(currentElement); + } + }; + + this._showAutoCompleteSuggestions(currentElement, checkResultsClick); + break; + } + + case "input": { + let currentElement = aEvent.target; + + // If this element isn't focused, we're already in middle of an + // autocomplete, or its value hasn't changed, don't show the + // autocomplete popup. + if (currentElement !== this.focusedElement || this._doingAutocomplete) { + break; + } + + // Since we can only show one popup at a time, prioritize autocomplete + // suggestions over a form validation message + let checkResultsInput = hasResults => { + if ( + hasResults || + currentElement !== this.focusedElement || + this._showValidationMessage(currentElement) + ) { + return; + } + // If we're not showing autocomplete suggestions, hide the form assist popup + this._hideFormAssistPopup(currentElement); + }; + + this._showAutoCompleteSuggestions(currentElement, checkResultsInput); + break; + } + + case "resize": { + let focused = this.focusedElement; + if (focused && focused.ownerGlobal == aEvent.target) { + // Reposition the popup as in the case of pan/zoom. + this.observe(null, "PanZoom:StateChange", this._lastPanZoomState); + // Continue to listen to resizes. + focused.ownerGlobal.addEventListener("resize", this, { + capture: true, + mozSystemGroup: true, + once: true, + }); + } + break; + } + } + }, + + // We only want to show autocomplete suggestions for certain elements + _isAutoComplete: function(aElement) { + return ( + ChromeUtils.getClassName(aElement) === "HTMLInputElement" && + !aElement.readOnly && + !this._isDisabledElement(aElement) && + aElement.type !== "password" && + aElement.autocomplete !== "off" + ); + }, + + // Retrieves autocomplete suggestions for an element from the form autocomplete service. + // aCallback(array_of_suggestions) is called when results are available. + _getAutoCompleteSuggestions: function(aSearchString, aElement, aCallback) { + // Cache the form autocomplete service for future use + if (!this._formAutoCompleteService) { + this._formAutoCompleteService = Cc[ + "@mozilla.org/satchel/form-autocomplete;1" + ].getService(Ci.nsIFormAutoComplete); + } + + let resultsAvailable = function(results) { + let suggestions = []; + for (let i = 0; i < results.matchCount; i++) { + let value = results.getValueAt(i); + + // Do not show the value if it is the current one in the input field + if (value == aSearchString) { + continue; + } + + // Supply a label and value, since they can differ for datalist suggestions + suggestions.push({ label: value, value: value }); + } + aCallback(suggestions); + }; + + this._formAutoCompleteService.autoCompleteSearchAsync( + aElement.name || aElement.id, + aSearchString, + aElement, + null, + null, + resultsAvailable + ); + }, + + /** + * This function is similar to getListSuggestions from + * components/satchel/src/nsInputListAutoComplete.js but sadly this one is + * used by the autocomplete.xml binding which is not in used in fennec + */ + _getListSuggestions: function(aElement) { + if ( + ChromeUtils.getClassName(aElement) !== "HTMLInputElement" || + !aElement.list + ) { + return []; + } + + let suggestions = []; + let filter = !aElement.hasAttribute("mozNoFilter"); + let lowerFieldValue = aElement.value.toLowerCase(); + + let options = aElement.list.options; + let length = options.length; + for (let i = 0; i < length; i++) { + let item = options.item(i); + + let label = item.value; + if (item.label) { + label = item.label; + } else if (item.text) { + label = item.text; + } + + if (filter && !label.toLowerCase().includes(lowerFieldValue)) { + continue; + } + suggestions.push({ label: label, value: item.value }); + } + + return suggestions; + }, + + // Retrieves autocomplete suggestions for an element from the form autocomplete service + // and sends the suggestions to the Java UI, along with element position data. As + // autocomplete queries are asynchronous, calls aCallback when done with a true + // argument if results were found and false if no results were found. + _showAutoCompleteSuggestions: function(aElement, aCallback) { + if (!this._isAutoComplete(aElement)) { + aCallback(false); + return; + } + + let isEmpty = aElement.value.length === 0; + + let resultsAvailable = autoCompleteSuggestions => { + // On desktop, we show datalist suggestions below autocomplete suggestions, + // without duplicates removed. + let listSuggestions = this._getListSuggestions(aElement); + let suggestions = autoCompleteSuggestions.concat(listSuggestions); + + // Return false if there are no suggestions to show + if (!suggestions.length || aElement !== this.focusedElement) { + aCallback(false); + return; + } + + GeckoViewUtils.getDispatcherForWindow(aElement.ownerGlobal).sendRequest( + { + type: "FormAssist:AutoCompleteResult", + suggestions: suggestions, + rect: this._getBoundingContentRect(aElement), + isEmpty: isEmpty, + }, + { + onSuccess: response => this._onPopupResponse(aElement, response), + onError: error => Cu.reportError(error), + } + ); + + aCallback(true); + }; + + this._getAutoCompleteSuggestions( + aElement.value, + aElement, + resultsAvailable + ); + }, + + // Only show a validation message if the user submitted an invalid form, + // there's a non-empty message string, and the element is the correct type + _isValidateable: function(aElement) { + return ( + (ChromeUtils.getClassName(aElement) === "HTMLInputElement" || + ChromeUtils.getClassName(aElement) === "HTMLTextAreaElement" || + ChromeUtils.getClassName(aElement) === "HTMLSelectElement" || + ChromeUtils.getClassName(aElement) === "HTMLButtonElement") && + aElement.matches(":-moz-ui-invalid") && + aElement.validationMessage + ); + }, + + // Sends a validation message and position data for an element to the Java UI. + // Returns true if there's a validation message to show, false otherwise. + _showValidationMessage: function(aElement) { + if (!this._isValidateable(aElement)) { + return false; + } + + GeckoViewUtils.getDispatcherForWindow(aElement.ownerGlobal).sendRequest({ + type: "FormAssist:ValidationMessage", + validationMessage: aElement.validationMessage, + rect: this._getBoundingContentRect(aElement), + }); + return true; + }, + + _hideFormAssistPopup: function(aElement) { + if (!aElement.ownerGlobal) { + return; + } + GeckoViewUtils.getDispatcherForWindow(aElement.ownerGlobal).sendRequest({ + type: "FormAssist:Hide", + }); + }, + + _isDisabledElement: function(aElement) { + let currentElement = aElement; + while (currentElement) { + if (currentElement.disabled) { + return true; + } + currentElement = currentElement.parentElement; + } + return false; + }, + + _getBoundingContentRect: function(aElement) { + if (!aElement) { + return { x: 0, y: 0, w: 0, h: 0 }; + } + + let document = aElement.ownerDocument; + while (document.defaultView.frameElement) { + document = document.defaultView.frameElement.ownerDocument; + } + + let scrollX = 0, + scrollY = 0; + let r = aElement.getBoundingClientRect(); + + // step out of iframes and frames, offsetting scroll values + for ( + let frame = aElement.ownerGlobal; + frame.frameElement; + frame = frame.parent + ) { + // adjust client coordinates' origin to be top left of iframe viewport + let rect = frame.frameElement.getBoundingClientRect(); + let left = frame.getComputedStyle(frame.frameElement).borderLeftWidth; + let top = frame.getComputedStyle(frame.frameElement).borderTopWidth; + scrollX += rect.left + parseInt(left); + scrollY += rect.top + parseInt(top); + } + + // The rect computed above is relative to the origin of the viewport frame, + // i.e. the layout viewport origin, but the consumer of the + // FormAssist::AutoCompleteResult messaage expects a rect relative to + // the visual viewport origin, so translate between the two. + let offsetX = {}, + offsetY = {}; + aElement.ownerGlobal.windowUtils.getVisualViewportOffsetRelativeToLayoutViewport( + offsetX, + offsetY + ); + + return { + x: r.left + scrollX - offsetX.value, + y: r.top + scrollY - offsetY.value, + w: r.width, + h: r.height, + }; + }, +}; + +FormAssistant.init(); diff --git a/mobile/android/modules/FxAccountsWebChannel.jsm b/mobile/android/modules/FxAccountsWebChannel.jsm new file mode 100644 index 000000000000..995b5e7ce91e --- /dev/null +++ b/mobile/android/modules/FxAccountsWebChannel.jsm @@ -0,0 +1,531 @@ +// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Firefox Accounts Web Channel. + * + * Use the WebChannel component to receive messages about account + * state changes. + */ +var EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"]; + +const { Accounts } = ChromeUtils.import("resource://gre/modules/Accounts.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { WebChannel } = ChromeUtils.import( + "resource://gre/modules/WebChannel.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const log = ChromeUtils.import( + "resource://gre/modules/AndroidLog.jsm", + {} +).AndroidLog.bind("FxAccounts"); + +const WEBCHANNEL_ID = "account_updates"; + +const COMMAND_LOADED = "fxaccounts:loaded"; +const COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account"; +const COMMAND_LOGIN = "fxaccounts:login"; +const COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password"; +const COMMAND_DELETE_ACCOUNT = "fxaccounts:delete_account"; +const COMMAND_PROFILE_CHANGE = "profile:change"; +const COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences"; + +const PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash"; + +XPCOMUtils.defineLazyGetter(this, "strings", () => + Services.strings.createBundle( + "chrome://browser/locale/aboutAccounts.properties" + ) +); + +ChromeUtils.defineModuleGetter( + this, + "Snackbars", + "resource://gre/modules/Snackbars.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Prompt", + "resource://gre/modules/Prompt.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "UITelemetry", + "resource://gre/modules/UITelemetry.jsm" +); + +this.FxAccountsWebChannelHelpers = function() {}; + +this.FxAccountsWebChannelHelpers.prototype = { + /** + * Get the hash of account name of the previously signed in account. + */ + getPreviousAccountNameHashPref() { + try { + return Services.prefs.getStringPref(PREF_LAST_FXA_USER); + } catch (_) { + return ""; + } + }, + + /** + * Given an account name, set the hash of the previously signed in account. + * + * @param acctName the account name of the user's account. + */ + setPreviousAccountNameHashPref(acctName) { + Services.prefs.setStringPref(PREF_LAST_FXA_USER, this.sha256(acctName)); + }, + + /** + * Given a string, returns the SHA265 hash in base64. + */ + sha256(str) { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + // Data is an array of bytes. + let data = converter.convertToByteArray(str, {}); + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + hasher.update(data, data.length); + + return hasher.finish(true); + }, +}; + +/** + * Create a new FxAccountsWebChannel to listen for account updates. + * + * @param {Object} options Options + * @param {Object} options + * @param {String} options.content_uri + * The FxA Content server uri + * @param {String} options.channel_id + * The ID of the WebChannel + * @param {String} options.helpers + * Helpers functions. Should only be passed in for testing. + * @constructor + */ +this.FxAccountsWebChannel = function(options) { + if (!options) { + throw new Error("Missing configuration options"); + } + if (!options.content_uri) { + throw new Error("Missing 'content_uri' option"); + } + this._contentUri = options.content_uri; + + if (!options.channel_id) { + throw new Error("Missing 'channel_id' option"); + } + this._webChannelId = options.channel_id; + + // options.helpers is only specified by tests. + this._helpers = options.helpers || new FxAccountsWebChannelHelpers(options); + + this._setupChannel(); +}; + +this.FxAccountsWebChannel.prototype = { + /** + * WebChannel that is used to communicate with content page + */ + _channel: null, + + /** + * WebChannel ID. + */ + _webChannelId: null, + /** + * WebChannel origin, used to validate origin of messages + */ + _webChannelOrigin: null, + + /** + * Release all resources that are in use. + */ + tearDown() { + this._channel.stopListening(); + this._channel = null; + this._channelCallback = null; + }, + + /** + * Configures and registers a new WebChannel + * + * @private + */ + _setupChannel() { + // if this.contentUri is present but not a valid URI, then this will throw an error. + try { + this._webChannelOrigin = Services.io.newURI(this._contentUri); + this._registerChannel(); + } catch (e) { + log.e(e.toString()); + throw e; + } + }, + + /** + * Create a new channel with the WebChannelBroker, setup a callback listener + * @private + */ + _registerChannel() { + /** + * Processes messages that are called back from the FxAccountsChannel + * + * @param webChannelId {String} + * Command webChannelId + * @param message {Object} + * Command message + * @param sendingContext {Object} + * Message sending context. + * @param sendingContext.browsingContext {BrowsingContext} + * The browsing context from which the + * WebChannelMessageToChrome was sent. + * @param sendingContext.eventTarget {EventTarget} + * The where the message was sent. + * @param sendingContext.principal {Principal} + * The of the EventTarget where the message was sent. + * @private + * + */ + let listener = (webChannelId, message, sendingContext) => { + if (message) { + let command = message.command; + let data = message.data; + log.d("FxAccountsWebChannel message received, command: " + command); + + // Respond to the message with true or false. + let respond = data => { + let response = { + command: command, + messageId: message.messageId, + data: data, + }; + log.d("Sending response to command: " + command); + this._channel.send(response, sendingContext); + }; + + switch (command) { + case COMMAND_LOADED: + // Note: we want a message manager here that about:accounts can + // add a listener to, so use the docshell's message manager, + // not the sending context itself. + let { docShell } = sendingContext.browsingContext; + let mm = docShell.messageManager; + mm.sendAsyncMessage(COMMAND_LOADED); + break; + + case COMMAND_CAN_LINK_ACCOUNT: + Accounts.getFirefoxAccount() + .then(account => { + if (account) { + // If we /have/ an Android Account, we never allow the user to + // login to a different account. They need to manually delete + // the first Android Account and then create a new one. + if (account.email == data.email) { + // In future, we should use a UID for this comparison. + log.d( + "Relinking existing Android Account: email addresses agree." + ); + respond({ ok: true }); + } else { + log.w( + "Not relinking existing Android Account: email addresses disagree!" + ); + let message = strings.GetStringFromName( + "relinkDenied.message" + ); + let buttonLabel = strings.GetStringFromName( + "relinkDenied.openPrefs" + ); + Snackbars.show(message, Snackbars.LENGTH_LONG, { + action: { + label: buttonLabel, + callback: () => { + // We have an account, so this opens Sync native preferences. + Accounts.launchSetup(); + }, + }, + }); + respond({ ok: false }); + } + } else { + // If we /don't have/ an Android Account, we warn if we're + // connecting to a new Account. This is to minimize surprise; + // we never did this when changing accounts via the native UI. + let prevAcctHash = this._helpers.getPreviousAccountNameHashPref(); + let shouldShowWarning = + prevAcctHash && + prevAcctHash != this._helpers.sha256(data.email); + + if (shouldShowWarning) { + log.w( + "Warning about creating a new Android Account: previously linked to different email address!" + ); + let message = strings.formatStringFromName( + "relinkVerify.message", + [data.email] + ); + let browser = + sendingContext.browsingContext && + sendingContext.browsingContext.top.embedderElement; + new Prompt({ + window: browser && browser.ownerGlobal, + title: strings.GetStringFromName("relinkVerify.title"), + message: message, + buttons: [ + // This puts Cancel on the right. + strings.GetStringFromName("relinkVerify.cancel"), + strings.GetStringFromName("relinkVerify.continue"), + ], + }).show(result => + respond({ ok: result && result.button == 1 }) + ); + } else { + log.d( + "Not warning about creating a new Android Account: no previously linked email address." + ); + respond({ ok: true }); + } + } + }) + .catch(e => { + log.e(e.toString()); + respond({ ok: false }); + }); + break; + + case COMMAND_LOGIN: + // Either create a new Android Account or re-connect an existing + // Android Account here. There's not much to be done if we don't + // succeed or get an error. + Accounts.getFirefoxAccount() + .then(account => { + // "action" will be introduced in https://github.com/mozilla/fxa/issues/1998. + // We're both backwards and forwards compatible here, falling back on heuristics + // if its missing, and passing it along otherwise. + // WebChannel "login data" payload docs at the time this comment was written: + // https://github.com/mozilla/fxa/blob/8701348cdd79dbdc9879b2b4a55a23a135a32bc1/packages/fxa-content-server/docs/relier-communication-protocols/fx-webchannel.md#loginData + if (!data.hasOwnProperty("action")) { + // This is how way we can determine if we're logging-in or signing-up. + // Currently, a choice of what to sync (CWTS) is only presented to the user during signup. + // This is likely to change in the future - we will offer CWTS to first-time Sync users as well. + // Once those changes occur, "action" is expected to be present in the webchannel message data, + // letting us avoid this codepath. + if ("offeredSyncEngines" in data) { + data.action = "signup"; + } else { + data.action = "signin"; + } + } + + if (!account) { + return Accounts.createFirefoxAccountFromJSON(data).then( + success => { + if (!success) { + throw new Error("Could not create Firefox Account!"); + } + UITelemetry.addEvent( + "action.1", + "content", + null, + "fxaccount-create" + ); + return success; + } + ); + } + + // At this point, we're reconnectig to an existing account (due to a password change, or a device disconnect). + // As far as `action` parameter is concerned that we pass along in `data`, this is considered a `signin`. + // Receiving native code is expected to differentiate between a "signin" and a "reconnect" by looking + // at account presence. + // We also send `updateFirefoxAccountFromJSON` in case of a password change, and in that case "action" + // is explicitely set to "passwordChange". + return Accounts.updateFirefoxAccountFromJSON(data).then( + success => { + if (!success) { + throw new Error("Could not update Firefox Account!"); + } + UITelemetry.addEvent( + "action.1", + "content", + null, + "fxaccount-login" + ); + return success; + } + ); + }) + .then(success => { + if (!success) { + throw new Error( + "Could not create or update Firefox Account!" + ); + } + + // Remember who it is so we can show a relink warning when appropriate. + this._helpers.setPreviousAccountNameHashPref(data.email); + + log.i("Created or updated Firefox Account."); + }) + .catch(e => { + log.e(e.toString()); + }); + break; + + case COMMAND_CHANGE_PASSWORD: + // Only update an existing Android Account. + Accounts.getFirefoxAccount() + .then(account => { + if (!account) { + throw new Error( + "Can't change password of non-existent Firefox Account!" + ); + } + + // "action" will be introduced in https://github.com/mozilla/fxa/issues/1998. + // In case it's missing, hard-code it below. Once "action" parameter is added + // for COMMAND_CHANGE_PASSWORD payload, it's expected to be the same. + if (!data.hasOwnProperty("action")) { + data.action = "passwordChange"; + } else if (data.action != "passwordChange") { + throw new Error( + "Expected 'action' to be 'passwordChange', but saw " + + data.action + ); + } + + return Accounts.updateFirefoxAccountFromJSON(data); + }) + .then(success => { + if (!success) { + throw new Error("Could not change Firefox Account password!"); + } + UITelemetry.addEvent( + "action.1", + "content", + null, + "fxaccount-changepassword" + ); + log.i("Changed Firefox Account password."); + }) + .catch(e => { + log.e(e.toString()); + }); + break; + + case COMMAND_DELETE_ACCOUNT: + // The fxa-content-server has already confirmed the user's intent. + // Bombs away. There's no recovery from failure, and not even a + // real need to check an account exists (although we do, for error + // messaging only). + Accounts.getFirefoxAccount() + .then(account => { + if (!account) { + throw new Error("Can't delete non-existent Firefox Account!"); + } + return Accounts.deleteFirefoxAccount().then(success => { + if (!success) { + throw new Error("Could not delete Firefox Account!"); + } + UITelemetry.addEvent( + "action.1", + "content", + null, + "fxaccount-delete" + ); + log.i("Firefox Account deleted."); + }); + }) + .catch(e => { + log.e(e.toString()); + }); + break; + + case COMMAND_PROFILE_CHANGE: + // Only update an existing Android Account. + Accounts.getFirefoxAccount() + .then(account => { + if (!account) { + throw new Error( + "Can't change profile of non-existent Firefox Account!" + ); + } + UITelemetry.addEvent( + "action.1", + "content", + null, + "fxaccount-changeprofile" + ); + return Accounts.notifyFirefoxAccountProfileChanged(); + }) + .catch(e => { + log.e(e.toString()); + }); + break; + + case COMMAND_SYNC_PREFERENCES: + UITelemetry.addEvent( + "action.1", + "content", + null, + "fxaccount-syncprefs" + ); + Accounts.showSyncPreferences().catch(e => { + log.e(e.toString()); + }); + break; + + default: + log.w( + "Ignoring unrecognized FxAccountsWebChannel command: " + + JSON.stringify(command) + ); + break; + } + } + }; + + this._channelCallback = listener; + this._channel = new WebChannel(this._webChannelId, this._webChannelOrigin); + this._channel.listen(listener); + + log.d( + "FxAccountsWebChannel registered: " + + this._webChannelId + + " with origin " + + this._webChannelOrigin.prePath + ); + }, +}; + +var singleton; +// The entry-point for this module, which ensures only one of our channels is +// ever created - we require this because the WebChannel is global in scope and +// allowing multiple channels would cause such notifications to be sent multiple +// times. +var EnsureFxAccountsWebChannel = () => { + if (!singleton) { + let contentUri = Services.urlFormatter.formatURLPref( + "identity.fxaccounts.remote.webchannel.uri" + ); + // The FxAccountsWebChannel listens for events and updates the Java layer. + singleton = new this.FxAccountsWebChannel({ + content_uri: contentUri, + channel_id: WEBCHANNEL_ID, + }); + } +}; diff --git a/mobile/android/modules/HelperApps.jsm b/mobile/android/modules/HelperApps.jsm new file mode 100644 index 000000000000..36222cb3b5bd --- /dev/null +++ b/mobile/android/modules/HelperApps.jsm @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* globals ContentAreaUtils */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "Prompt", + "resource://gre/modules/Prompt.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "EventDispatcher", + "resource://gre/modules/Messaging.jsm" +); + +XPCOMUtils.defineLazyGetter(this, "ContentAreaUtils", function() { + let ContentAreaUtils = {}; + Services.scriptloader.loadSubScript( + "chrome://global/content/contentAreaUtils.js", + ContentAreaUtils + ); + return ContentAreaUtils; +}); + +const NON_FILE_URI_IGNORED_MIME_TYPES = new Set([ + "text/html", + "application/xhtml+xml", +]); + +var EXPORTED_SYMBOLS = ["App", "HelperApps"]; + +class App { + constructor(data) { + this.name = data.name; + this.isDefault = data.isDefault; + this.packageName = data.packageName; + this.activityName = data.activityName; + this.iconUri = "-moz-icon://" + data.packageName; + } + + // callback will be null if a result is not requested + launch(uri, callback) { + HelperApps._launchApp(this, uri, callback); + return false; + } +} + +var HelperApps = { + get defaultBrowsers() { + delete this.defaultBrowsers; + this.defaultBrowsers = this._collectDefaultBrowsers(); + return this.defaultBrowsers; + }, + + _collectDefaultBrowsers() { + let httpHandlers = this._getHandlers("http://www.example.com", { + filterBrowsers: false, + filterHtml: false, + }); + let httpsHandlers = this._getHandlers("https://www.example.com", { + filterBrowsers: false, + filterHtml: false, + }); + return { ...httpHandlers, ...httpsHandlers }; + }, + + // Finds handlers that have registered for urls ending in html. Some apps, like + // the Samsung Video player, will only appear for these urls. + get defaultHtmlHandlers() { + delete this.defaultHtmlHandlers; + return (this.defaultHtmlHandlers = this._getHandlers( + "http://www.example.com/index.html", + { + filterBrowsers: false, + filterHtml: false, + } + )); + }, + + _getHandlers(url, options) { + let values = {}; + + let handlers = this.getAppsForUri(Services.io.newURI(url), options); + handlers.forEach(app => { + values[app.name] = app; + }); + + return values; + }, + + get protoSvc() { + delete this.protoSvc; + return (this.protoSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService)); + }, + + get urlHandlerService() { + delete this.urlHandlerService; + return (this.urlHandlerService = Cc[ + "@mozilla.org/uriloader/external-url-handler-service;1" + ].getService(Ci.nsIExternalURLHandlerService)); + }, + + prompt(apps, promptOptions, callback) { + let p = new Prompt(promptOptions).addIconGrid({ items: apps }); + p.show(callback); + }, + + getAppsForProtocol(scheme) { + let protoHandlers = this.protoSvc.getProtocolHandlerInfoFromOS(scheme, {}) + .possibleApplicationHandlers; + + let results = {}; + for (let i = 0; i < protoHandlers.length; i++) { + try { + let protoApp = protoHandlers.queryElementAt(i, Ci.nsIHandlerApp); + results[protoApp.name] = new App({ + name: protoApp.name, + description: protoApp.detailedDescription, + }); + } catch (e) {} + } + + return results; + }, + + getAppsForUri(uri, flags = {}, callback) { + // Return early for well-known internal schemes + if (!uri || uri.schemeIs("about") || uri.schemeIs("chrome")) { + if (callback) { + callback([]); + } + return []; + } + + flags.filterBrowsers = + "filterBrowsers" in flags ? flags.filterBrowsers : true; + flags.filterHtml = "filterHtml" in flags ? flags.filterHtml : true; + + // Query for apps that can/can't handle the mimetype + let msg = this._getMessage("Intent:GetHandlers", uri, flags); + let parseData = apps => { + if (!apps) { + return []; + } + + apps = this._parseApps(apps); + + if (flags.filterBrowsers) { + apps = apps.filter(app => app.name && !this.defaultBrowsers[app.name]); + } + + // Some apps will register for html files (the Samsung Video player) but should be shown + // for non-HTML files (like videos). This filters them only if the page has an htm of html + // file extension. + if (flags.filterHtml) { + // Matches from the first '.' to the end of the string, '?', or '#' + let ext = /\.([^\?#]*)/.exec(uri.pathQueryRef); + if (ext && (ext[1] === "html" || ext[1] === "htm")) { + apps = apps.filter( + app => app.name && !this.defaultHtmlHandlers[app.name] + ); + } + } + + return apps; + }; + + if (!callback) { + let data = null; + // Use dispatch to enable synchronous callback for Gecko thread event. + EventDispatcher.instance.dispatch(msg.type, msg, { + onSuccess: result => { + data = result; + }, + onError: () => { + throw new Error("Intent:GetHandler callback failed"); + }, + }); + if (data === null) { + throw new Error("Intent:GetHandler did not return data"); + } + return parseData(data); + } + EventDispatcher.instance.sendRequestForResult(msg).then(data => { + callback(parseData(data)); + }); + }, + + launchUri(uri) { + let msg = this._getMessage("Intent:Open", uri); + EventDispatcher.instance.sendRequest(msg); + }, + + _parseApps(appInfo) { + // appInfo -> {apps: [app1Label, app1Default, app1PackageName, app1ActivityName, app2Label, app2Defaut, ...]} + // see GeckoAppShell.java getHandlersForIntent function for details + const numAttr = 4; // 4 elements per ResolveInfo: label, default, package name, activity name. + + let apps = []; + for (let i = 0; i < appInfo.length; i += numAttr) { + apps.push( + new App({ + name: appInfo[i], + isDefault: appInfo[i + 1], + packageName: appInfo[i + 2], + activityName: appInfo[i + 3], + }) + ); + } + + return apps; + }, + + _getMessage(type, uri, options = {}) { + let mimeType = options.mimeType; + if (uri && mimeType == undefined) { + mimeType = ContentAreaUtils.getMIMETypeForURI(uri) || ""; + if ( + uri.scheme != "file" && + NON_FILE_URI_IGNORED_MIME_TYPES.has(mimeType) + ) { + // We're guessing the MIME type based on the extension, which especially + // with non-local HTML documents will yield inconsistent results, as those + // commonly use URLs without any sort of extension, too. + // At the same time, apps offering to handle certain URLs in lieu of a + // browser often don't expect a MIME type to be used, and correspondingly + // register their intent filters without a MIME type. + // This means that when we *do* guess a non-empty MIME type because this + // time the URL *did* end on .(x)htm(l), Android won't offer any apps whose + // intent filter doesn't explicitly include that MIME type. + // Therefore, if the MIME type looks like something from that category, + // don't bother including it in the Intent for non-local files. + mimeType = ""; + } + } + + return { + type: type, + mime: mimeType, + action: options.action || "", // empty action string defaults to android.intent.action.VIEW + url: uri ? uri.displaySpec : "", + packageName: options.packageName || "", + className: options.className || "", + }; + }, + + _launchApp(app, uri, callback) { + if (callback) { + let msg = this._getMessage("Intent:OpenForResult", uri, { + packageName: app.packageName, + className: app.activityName, + }); + + EventDispatcher.instance.sendRequestForResult(msg).then(callback); + } else { + let msg = this._getMessage("Intent:Open", uri, { + packageName: app.packageName, + className: app.activityName, + }); + + EventDispatcher.instance.sendRequest(msg); + } + }, +}; diff --git a/mobile/android/modules/Home.jsm b/mobile/android/modules/Home.jsm new file mode 100644 index 000000000000..4bdd41a74568 --- /dev/null +++ b/mobile/android/modules/Home.jsm @@ -0,0 +1,545 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["Home"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { SharedPreferences } = ChromeUtils.import( + "resource://gre/modules/SharedPreferences.jsm" +); +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); + +// Keep this in sync with the constant defined in PanelAuthCache.java +const PREFS_PANEL_AUTH_PREFIX = "home_panels_auth_"; + +// Default weight for a banner message. +const DEFAULT_WEIGHT = 100; + +// See bug 915424 +function resolveGeckoURI(aURI) { + if (!aURI) { + throw new Error("Can't resolve an empty uri"); + } + + if (aURI.startsWith("chrome://")) { + let registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIChromeRegistry + ); + return registry.convertChromeURL(Services.io.newURI(aURI)).spec; + } else if (aURI.startsWith("resource://")) { + let handler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + return handler.resolveURI(Services.io.newURI(aURI)); + } + return aURI; +} + +function BannerMessage(options) { + let uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator + ); + this.id = uuidgen.generateUUID().toString(); + + if ("text" in options && options.text != null) { + this.text = options.text; + } + + if ("icon" in options && options.icon != null) { + this.iconURI = resolveGeckoURI(options.icon); + } + + if ("onshown" in options && typeof options.onshown === "function") { + this.onshown = options.onshown; + } + + if ("onclick" in options && typeof options.onclick === "function") { + this.onclick = options.onclick; + } + + if ("ondismiss" in options && typeof options.ondismiss === "function") { + this.ondismiss = options.ondismiss; + } + + let weight = parseInt(options.weight, 10); + this.weight = weight > 0 ? weight : DEFAULT_WEIGHT; +} + +// We need this object to have access to the HomeBanner +// private members without leaking it outside Home.jsm. +var HomeBannerMessageHandlers; + +var HomeBanner = (function() { + // Whether there is a "HomeBanner:Get" request we couldn't fulfill. + let _pendingRequest = false; + + // Functions used to handle messages sent from Java. + HomeBannerMessageHandlers = { + "HomeBanner:Get": function handleBannerGet(data) { + if (Object.keys(_messages).length > 0) { + _sendBannerData(); + } else { + _pendingRequest = true; + } + }, + }; + + // Holds the messages that will rotate through the banner. + let _messages = {}; + + // Choose a random message from the set of messages, biasing towards those with higher weight. + // Weight logic copied from desktop snippets: + // https://github.com/mozilla/snippets-service/blob/7d80edb8b1cddaed075275c2fc7cdf69a10f4003/snippets/base/templates/base/includes/snippet_js.html#L119 + let _sendBannerData = function() { + let totalWeight = 0; + for (let key in _messages) { + let message = _messages[key]; + totalWeight += message.weight; + message.totalWeight = totalWeight; + } + + let threshold = Math.random() * totalWeight; + for (let key in _messages) { + let message = _messages[key]; + if (threshold < message.totalWeight) { + EventDispatcher.instance + .sendRequestForResult({ + type: "HomeBanner:Data", + id: message.id, + text: message.text, + iconURI: message.iconURI, + }) + .then(id => _handleShown(id)); + return; + } + } + }; + + let _handleShown = function(id) { + let message = _messages[id]; + if (message.onshown) { + message.onshown(); + } + }; + + let _handleClick = function(id) { + let message = _messages[id]; + if (message.onclick) { + message.onclick(); + } + }; + + let _handleDismiss = function(id) { + let message = _messages[id]; + if (message.ondismiss) { + message.ondismiss(); + } + }; + + return Object.freeze({ + onEvent: function(event, data, callback) { + switch (event) { + case "HomeBanner:Click": + _handleClick(data.id); + break; + + case "HomeBanner:Dismiss": + _handleDismiss(data.id); + break; + } + }, + + /** + * Adds a new banner message to the rotation. + * + * @return id Unique identifer for the message. + */ + add: function(options) { + let message = new BannerMessage(options); + _messages[message.id] = message; + + // If this is the first message we're adding, add + // observers to listen for requests from the Java UI. + if (Object.keys(_messages).length == 1) { + EventDispatcher.instance.registerListener(this, [ + "HomeBanner:Click", + "HomeBanner:Dismiss", + ]); + + // Send a message to Java if there's a pending "HomeBanner:Get" request. + if (_pendingRequest) { + _pendingRequest = false; + _sendBannerData(); + } + } + + return message.id; + }, + + /** + * Removes a banner message from the rotation. + * + * @param id The id of the message to remove. + */ + remove: function(id) { + if (!(id in _messages)) { + throw new Error( + "Home.banner: Can't remove message that doesn't exist: id = " + id + ); + } + + delete _messages[id]; + + // If there are no more messages, remove the observers. + if (Object.keys(_messages).length == 0) { + EventDispatcher.instance.unregisterListener(this, [ + "HomeBanner:Click", + "HomeBanner:Dismiss", + ]); + } + }, + }); +})(); + +// We need this object to have access to the HomePanels +// private members without leaking it outside Home.jsm. +var HomePanelsMessageHandlers; + +var HomePanels = (function() { + // Functions used to handle messages sent from Java. + HomePanelsMessageHandlers = { + "HomePanels:Get": function handlePanelsGet(data) { + let requestId = data.requestId; + let ids = data.ids || null; + + let panels = []; + for (let id in _registeredPanels) { + // Null ids means we want to fetch all available panels + if (ids == null || ids.includes(id)) { + try { + panels.push(_generatePanel(id)); + } catch (e) { + Cu.reportError( + "Home.panels: Invalid options, panel.id = " + id + ": " + e + ); + } + } + } + + EventDispatcher.instance.sendRequest({ + type: "HomePanels:Data", + panels: panels, + requestId: requestId, + }); + }, + + "HomePanels:Authenticate": function handlePanelsAuthenticate(data) { + // Generate panel options to get auth handler. + let id = data.id; + let options = _registeredPanels[id](); + if (!options.auth) { + throw new Error("Home.panels: Invalid auth for panel.id = " + id); + } + if ( + !options.auth.authenticate || + typeof options.auth.authenticate !== "function" + ) { + throw new Error( + "Home.panels: Invalid auth authenticate function: panel.id = " + + this.id + ); + } + options.auth.authenticate(); + }, + + "HomePanels:RefreshView": function handlePanelsRefreshView(data) { + let options = _registeredPanels[data.panelId](); + let view = options.views[data.viewIndex]; + + if (!view) { + throw new Error( + "Home.panels: Invalid view for panel.id = " + + `${data.panelId}, view.index = ${data.viewIndex}` + ); + } + + if (!view.onrefresh || typeof view.onrefresh !== "function") { + throw new Error( + "Home.panels: Invalid onrefresh for panel.id = " + + `${data.panelId}, view.index = ${data.viewIndex}` + ); + } + + view.onrefresh(); + }, + + "HomePanels:Installed": function handlePanelsInstalled(data) { + let id = data.id; + _assertPanelExists(id); + + let options = _registeredPanels[id](); + if (!options.oninstall) { + return; + } + if (typeof options.oninstall !== "function") { + throw new Error( + "Home.panels: Invalid oninstall function: panel.id = " + this.id + ); + } + options.oninstall(); + }, + + "HomePanels:Uninstalled": function handlePanelsUninstalled(data) { + let id = data.id; + _assertPanelExists(id); + + let options = _registeredPanels[id](); + if (!options.onuninstall) { + return; + } + if (typeof options.onuninstall !== "function") { + throw new Error( + "Home.panels: Invalid onuninstall function: panel.id = " + this.id + ); + } + options.onuninstall(); + }, + }; + + // Holds the current set of registered panels that can be + // installed, updated, uninstalled, or unregistered. It maps + // panel ids with the functions that dynamically generate + // their respective panel options. This is used to retrieve + // the current list of available panels in the system. + // See HomePanels:Get handler. + let _registeredPanels = {}; + + // Valid layouts for a panel. + let Layout = Object.freeze({ + FRAME: "frame", + }); + + // Valid types of views for a dataset. + let View = Object.freeze({ + LIST: "list", + GRID: "grid", + }); + + // Valid item types for a panel view. + let Item = Object.freeze({ + ARTICLE: "article", + IMAGE: "image", + ICON: "icon", + }); + + // Valid item handlers for a panel view. + let ItemHandler = Object.freeze({ + BROWSER: "browser", + INTENT: "intent", + }); + + function Panel(id, options) { + this.id = id; + this.title = options.title; + this.layout = options.layout; + this.views = options.views; + this.default = !!options.default; + + if (!this.id || !this.title) { + throw new Error( + "Home.panels: Can't create a home panel without an id and title!" + ); + } + + if (!this.layout) { + // Use FRAME layout by default + this.layout = Layout.FRAME; + } else if (!_valueExists(Layout, this.layout)) { + throw new Error( + "Home.panels: Invalid layout for panel: panel.id = " + + `${this.id}, panel.layout =${this.layout}` + ); + } + + for (let view of this.views) { + if (!_valueExists(View, view.type)) { + throw new Error( + "Home.panels: Invalid view type: panel.id = " + + `${this.id}, view.type = ${view.type}` + ); + } + + if (!view.itemType) { + if (view.type == View.LIST) { + // Use ARTICLE item type by default in LIST views + view.itemType = Item.ARTICLE; + } else if (view.type == View.GRID) { + // Use IMAGE item type by default in GRID views + view.itemType = Item.IMAGE; + } + } else if (!_valueExists(Item, view.itemType)) { + throw new Error( + "Home.panels: Invalid item type: panel.id = " + + `${this.id}, view.itemType = ${view.itemType}` + ); + } + + if (!view.itemHandler) { + // Use BROWSER item handler by default + view.itemHandler = ItemHandler.BROWSER; + } else if (!_valueExists(ItemHandler, view.itemHandler)) { + throw new Error( + "Home.panels: Invalid item handler: panel.id = " + + `${this.id}, view.itemHandler = ${view.itemHandler}` + ); + } + + if (!view.dataset) { + throw new Error( + "Home.panels: No dataset provided for view: panel.id = " + + `${this.id}, view.type = ${view.type}` + ); + } + + if (view.onrefresh) { + view.refreshEnabled = true; + } + } + + if (options.auth) { + if (!options.auth.messageText) { + throw new Error( + "Home.panels: Invalid auth messageText: panel.id = " + this.id + ); + } + if (!options.auth.buttonText) { + throw new Error( + "Home.panels: Invalid auth buttonText: panel.id = " + this.id + ); + } + + this.authConfig = { + messageText: options.auth.messageText, + buttonText: options.auth.buttonText, + }; + + // Include optional image URL if it is specified. + if (options.auth.imageUrl) { + this.authConfig.imageUrl = options.auth.imageUrl; + } + } + + if (options.position >= 0) { + this.position = options.position; + } + } + + let _generatePanel = function(id) { + let options = _registeredPanels[id](); + return new Panel(id, options); + }; + + // Helper function used to see if a value is in an object. + let _valueExists = function(obj, value) { + for (let key in obj) { + if (obj[key] == value) { + return true; + } + } + return false; + }; + + let _assertPanelExists = function(id) { + if (!(id in _registeredPanels)) { + throw new Error("Home.panels: Panel doesn't exist: id = " + id); + } + }; + + return Object.freeze({ + Layout: Layout, + View: View, + Item: Item, + ItemHandler: ItemHandler, + + register: function(id, optionsCallback) { + // Bail if the panel already exists + if (id in _registeredPanels) { + throw new Error("Home.panels: Panel already exists: id = " + id); + } + + if (!optionsCallback || typeof optionsCallback !== "function") { + throw new Error( + "Home.panels: Panel callback must be a function: id = " + id + ); + } + + _registeredPanels[id] = optionsCallback; + }, + + unregister: function(id) { + _assertPanelExists(id); + + delete _registeredPanels[id]; + }, + + install: function(id) { + _assertPanelExists(id); + + EventDispatcher.instance.sendRequest({ + type: "HomePanels:Install", + panel: _generatePanel(id), + }); + }, + + uninstall: function(id) { + _assertPanelExists(id); + + EventDispatcher.instance.sendRequest({ + type: "HomePanels:Uninstall", + id: id, + }); + }, + + update: function(id) { + _assertPanelExists(id); + + EventDispatcher.instance.sendRequest({ + type: "HomePanels:Update", + panel: _generatePanel(id), + }); + }, + + setAuthenticated: function(id, isAuthenticated) { + _assertPanelExists(id); + + let authKey = PREFS_PANEL_AUTH_PREFIX + id; + let sharedPrefs = SharedPreferences.forProfile(); + sharedPrefs.setBoolPref(authKey, isAuthenticated); + }, + }); +})(); + +// Public API +var Home = Object.freeze({ + banner: HomeBanner, + panels: HomePanels, + + // Lazy notification observer registered in browser.js + onEvent: function(event, data, callback) { + if (event in HomeBannerMessageHandlers) { + HomeBannerMessageHandlers[event](data); + } else if (event in HomePanelsMessageHandlers) { + HomePanelsMessageHandlers[event](data); + } else { + Cu.reportError( + "Home.observe: message handler not found for event: " + event + ); + } + }, +}); diff --git a/mobile/android/modules/HomeProvider.jsm b/mobile/android/modules/HomeProvider.jsm new file mode 100644 index 000000000000..52f8288bc720 --- /dev/null +++ b/mobile/android/modules/HomeProvider.jsm @@ -0,0 +1,431 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["HomeProvider"]; + +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Sqlite } = ChromeUtils.import("resource://gre/modules/Sqlite.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +/* + * SCHEMA_VERSION history: + * 1: Create HomeProvider (bug 942288) + * 2: Add filter column to items table (bug 942295/975841) + * 3: Add background_color and background_url columns (bug 1157539) + */ +const SCHEMA_VERSION = 3; + +// The maximum number of items you can attempt to save at once. +const MAX_SAVE_COUNT = 100; + +XPCOMUtils.defineLazyGetter(this, "DB_PATH", function() { + return OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite"); +}); + +const PREF_STORAGE_LAST_SYNC_TIME_PREFIX = "home.storage.lastSyncTime."; +const PREF_SYNC_UPDATE_MODE = "home.sync.updateMode"; +const PREF_SYNC_CHECK_INTERVAL_SECS = "home.sync.checkIntervalSecs"; + +XPCOMUtils.defineLazyGetter(this, "gSyncCheckIntervalSecs", function() { + return Services.prefs.getIntPref(PREF_SYNC_CHECK_INTERVAL_SECS); +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gUpdateTimerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +/** + * All SQL statements should be defined here. + */ +const SQL = { + createItemsTable: + "CREATE TABLE items (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "dataset_id TEXT NOT NULL, " + + "url TEXT," + + "title TEXT," + + "description TEXT," + + "image_url TEXT," + + "background_color TEXT," + + "background_url TEXT," + + "filter TEXT," + + "created INTEGER" + + ")", + + dropItemsTable: "DROP TABLE items", + + insertItem: + "INSERT INTO items (dataset_id, url, title, description, image_url, background_color, background_url, filter, created) " + + "VALUES (:dataset_id, :url, :title, :description, :image_url, :background_color, :background_url, :filter, :created)", + + deleteFromDataset: "DELETE FROM items WHERE dataset_id = :dataset_id", + + addColumnBackgroundColor: + "ALTER TABLE items ADD COLUMN background_color TEXT", + + addColumnBackgroundUrl: "ALTER TABLE items ADD COLUMN background_url TEXT", +}; + +/** + * Technically this function checks to see if the user is on a local network, + * but we express this as "wifi" to the user. + */ +function isUsingWifi() { + let network = Cc["@mozilla.org/network/network-link-service;1"].getService( + Ci.nsINetworkLinkService + ); + return ( + network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_WIFI || + network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET + ); +} + +function getNowInSeconds() { + return Math.round(Date.now() / 1000); +} + +function getLastSyncPrefName(datasetId) { + return PREF_STORAGE_LAST_SYNC_TIME_PREFIX + datasetId; +} + +// Whether or not we've registered an update timer. +var gTimerRegistered = false; + +// Map of datasetId -> { interval: , callback: } +var gSyncCallbacks = {}; + +/** + * nsITimerCallback implementation. Checks to see if it's time to sync any registered datasets. + * + * @param timer The timer which has expired. + */ +function syncTimerCallback(timer) { + for (let datasetId in gSyncCallbacks) { + let lastSyncTime = Services.prefs.getIntPref( + getLastSyncPrefName(datasetId), + 0 + ); + + let now = getNowInSeconds(); + let { interval: interval, callback: callback } = gSyncCallbacks[datasetId]; + + if (lastSyncTime < now - interval) { + let success = HomeProvider.requestSync(datasetId, callback); + if (success) { + Services.prefs.setIntPref(getLastSyncPrefName(datasetId), now); + } + } + } +} + +var HomeStorage = function(datasetId) { + this.datasetId = datasetId; +}; + +var ValidationError = function(message) { + this.name = "ValidationError"; + this.message = message; +}; +ValidationError.prototype = new Error(); +ValidationError.prototype.constructor = ValidationError; + +var HomeProvider = Object.freeze({ + ValidationError: ValidationError, + + /** + * Returns a storage associated with a given dataset identifer. + * + * @param datasetId + * (string) Unique identifier for the dataset. + * + * @return HomeStorage + */ + getStorage: function(datasetId) { + return new HomeStorage(datasetId); + }, + + /** + * Checks to see if it's an appropriate time to sync. + * + * @param datasetId Unique identifier for the dataset to sync. + * @param callback Function to call when it's time to sync, called with datasetId as a parameter. + * + * @return boolean Whether or not we were able to sync. + */ + requestSync: function(datasetId, callback) { + // Make sure it's a good time to sync. + if ( + Services.prefs.getIntPref(PREF_SYNC_UPDATE_MODE) === 1 && + !isUsingWifi() + ) { + Cu.reportError( + "HomeProvider: Failed to sync because device is not on a local network" + ); + return false; + } + + callback(datasetId); + return true; + }, + + /** + * Specifies that a sync should be requested for the given dataset and update interval. + * + * @param datasetId Unique identifier for the dataset to sync. + * @param interval Update interval in seconds. By default, this is throttled to 3600 seconds (1 hour). + * @param callback Function to call when it's time to sync, called with datasetId as a parameter. + */ + addPeriodicSync: function(datasetId, interval, callback) { + // Warn developers if they're expecting more frequent notifications that we allow. + if (interval < gSyncCheckIntervalSecs) { + Cu.reportError( + "HomeProvider: Warning for dataset " + + datasetId + + " : Sync notifications are throttled to " + + gSyncCheckIntervalSecs + + " seconds" + ); + } + + gSyncCallbacks[datasetId] = { + interval: interval, + callback: callback, + }; + + if (!gTimerRegistered) { + gUpdateTimerManager.registerTimer( + "home-provider-sync-timer", + syncTimerCallback, + gSyncCheckIntervalSecs + ); + gTimerRegistered = true; + } + }, + + /** + * Removes a periodic sync timer. + * + * @param datasetId Dataset to sync. + */ + removePeriodicSync: function(datasetId) { + delete gSyncCallbacks[datasetId]; + Services.prefs.clearUserPref(getLastSyncPrefName(datasetId)); + // You can't unregister a update timer, so we don't try to do that. + }, +}); + +var gDatabaseEnsured = false; + +/** + * Creates the database schema. + */ +function createDatabase(db) { + return db.execute(SQL.createItemsTable); +} + +/** + * Migrates the database schema to a new version. + */ +async function upgradeDatabase(db, oldVersion, newVersion) { + switch (oldVersion) { + case 1: + // Migration from v1 to latest: + // Recreate the items table discarding any + // existing data. + await db.execute(SQL.dropItemsTable); + await db.execute(SQL.createItemsTable); + break; + + case 2: + // Migration from v2 to latest: + // Add new columns: background_color, background_url + await db.execute(SQL.addColumnBackgroundColor); + await db.execute(SQL.addColumnBackgroundUrl); + break; + } +} + +/** + * Opens a database connection and makes sure that the database schema version + * is correct, performing migrations if necessary. Consumers should be sure + * to close any database connections they open. + * + * @return Promise + * @resolves Handle on an opened SQLite database. + */ +async function getDatabaseConnection() { + let db = await Sqlite.openConnection({ path: DB_PATH }); + if (gDatabaseEnsured) { + return db; + } + + try { + // Check to see if we need to perform any migrations. + let dbVersion = parseInt(await db.getSchemaVersion()); + + // getSchemaVersion() returns a 0 int if the schema + // version is undefined. + if (dbVersion === 0) { + await createDatabase(db); + } else if (dbVersion < SCHEMA_VERSION) { + await upgradeDatabase(db, dbVersion, SCHEMA_VERSION); + } + + await db.setSchemaVersion(SCHEMA_VERSION); + } catch (e) { + // Close the DB connection before passing the exception to the consumer. + await db.close(); + throw e; + } + + gDatabaseEnsured = true; + return db; +} + +/** + * Validates an item to be saved to the DB. + * + * @param item + * (object) item object to be validated. + */ +function validateItem(datasetId, item) { + if (!item.url) { + throw new ValidationError( + "HomeStorage: All rows must have an URL: datasetId = " + datasetId + ); + } + + if (!item.image_url && !item.title && !item.description) { + throw new ValidationError( + "HomeStorage: All rows must have at least an image URL, " + + "or a title or a description: datasetId = " + + datasetId + ); + } +} + +var gRefreshTimers = {}; + +/** + * Sends a message to Java to refresh the given dataset. Delays sending + * messages to avoid successive refreshes, which can result in flashing views. + */ +function refreshDataset(datasetId) { + // Bail if there's already a refresh timer waiting to fire + if (gRefreshTimers[datasetId]) { + return; + } + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback( + function(timer) { + delete gRefreshTimers[datasetId]; + + EventDispatcher.instance.sendRequest({ + type: "HomePanels:RefreshDataset", + datasetId: datasetId, + }); + }, + 100, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + gRefreshTimers[datasetId] = timer; +} + +HomeStorage.prototype = { + /** + * Saves data rows to the DB. + * + * @param data + * An array of JS objects represnting row items to save. + * Each object may have the following properties: + * - url (string) + * - title (string) + * - description (string) + * - image_url (string) + * - filter (string) + * @param options + * A JS object holding additional cofiguration properties. + * The following properties are currently supported: + * - replace (boolean): Whether or not to replace existing items. + * + * @return Promise + * @resolves When the operation has completed. + */ + async save(data, options) { + if (data && data.length > MAX_SAVE_COUNT) { + throw new Error( + `save failed for dataset = ${this.datasetId}: ` + + `you cannot save more than ${MAX_SAVE_COUNT} items at once` + ); + } + + let db = await getDatabaseConnection(); + try { + await db.executeTransaction( + async function save_transaction() { + if (options && options.replace) { + await db.executeCached(SQL.deleteFromDataset, { + dataset_id: this.datasetId, + }); + } + + // Insert data into DB. + for (let item of data) { + validateItem(this.datasetId, item); + + // XXX: Directly pass item as params? More validation for item? + let params = { + dataset_id: this.datasetId, + url: item.url, + title: item.title, + description: item.description, + image_url: item.image_url, + background_color: item.background_color, + background_url: item.background_url, + filter: item.filter, + created: Date.now(), + }; + await db.executeCached(SQL.insertItem, params); + } + }.bind(this) + ); + } finally { + await db.close(); + } + + refreshDataset(this.datasetId); + }, + + /** + * Deletes all rows associated with this storage. + * + * @return Promise + * @resolves When the operation has completed. + */ + async deleteAll() { + let db = await getDatabaseConnection(); + try { + let params = { dataset_id: this.datasetId }; + await db.executeCached(SQL.deleteFromDataset, params); + } finally { + await db.close(); + } + + refreshDataset(this.datasetId); + }, +}; diff --git a/mobile/android/modules/InputWidgetHelper.jsm b/mobile/android/modules/InputWidgetHelper.jsm new file mode 100644 index 000000000000..f187b3eb45c7 --- /dev/null +++ b/mobile/android/modules/InputWidgetHelper.jsm @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["InputWidgetHelper"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Prompt: "resource://gre/modules/Prompt.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +var InputWidgetHelper = { + _uiBusy: false, + + strings: function() { + if (!this._strings) { + this._strings = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); + } + return this._strings; + }, + + handleEvent: function(aEvent) { + this.handleClick(aEvent.composedTarget); + }, + + handleClick: function(aTarget) { + // if we're busy looking at a InputWidget we want to eat any clicks that + // come to us, but not to process them + if ( + this._uiBusy || + !this.hasInputWidget(aTarget) || + this._isDisabledElement(aTarget) + ) { + return; + } + + this._uiBusy = true; + this.show(aTarget); + this._uiBusy = false; + }, + + show: function(aElement) { + let type = aElement.type; + new Prompt({ + window: aElement.ownerGlobal, + title: this.strings().GetStringFromName("inputWidgetHelper." + type), + buttons: [ + this.strings().GetStringFromName("inputWidgetHelper.set"), + this.strings().GetStringFromName("inputWidgetHelper.clear"), + this.strings().GetStringFromName("inputWidgetHelper.cancel"), + ], + }) + .addDatePicker({ + value: aElement.value, + type: type, + step: this._getInputTimeStep(aElement), + min: aElement.min, + max: aElement.max, + }) + .show(data => { + let changed = false; + if (data.button == -1) { + // This type is not supported with this android version. + return; + } + if (data.button == 1) { + // The user cleared the value. + if (aElement.value != "") { + aElement.value = ""; + changed = true; + } + } else if (data.button == 0) { + // Commit the new value. + if (aElement.value != data[type]) { + aElement.value = data[type + "0"]; + changed = true; + } + } + // Else the user canceled the input. + + if (changed) { + this.fireOnChange(aElement); + } + }); + }, + + hasInputWidget: function(aElement) { + let win = aElement.ownerGlobal; + if (!(aElement instanceof win.HTMLInputElement)) { + return false; + } + + let type = aElement.type; + if ( + type == "date" || + type == "datetime-local" || + type == "week" || + type == "month" || + type == "time" + ) { + return true; + } + + return false; + }, + + fireOnChange: function(aElement) { + let win = aElement.ownerGlobal; + win.setTimeout(function() { + aElement.dispatchEvent(new win.Event("input", { bubbles: true })); + aElement.dispatchEvent(new win.Event("change", { bubbles: true })); + }, 0); + }, + + _isDisabledElement: function(aElement) { + let currentElement = aElement; + while (currentElement) { + if (currentElement.disabled) { + return true; + } + + currentElement = currentElement.parentElement; + } + return false; + }, + + // The step in milliseconds. + _getInputTimeStep: function(aElement) { + try { + // Delegate the implementation to HTMLInputElement::GetStep. + let tmpInput = aElement.ownerDocument.createElement("input"); + tmpInput.type = aElement.type; + tmpInput.step = aElement.step; + // May throw if the type is unsupported. + tmpInput.stepUp(); + return tmpInput.valueAsNumber || 0; // Prefer 0 over NaN. + } catch (e) { + return 0; + } + }, +}; diff --git a/mobile/android/modules/LightweightThemeConsumer.jsm b/mobile/android/modules/LightweightThemeConsumer.jsm new file mode 100644 index 000000000000..1bf74f07eade --- /dev/null +++ b/mobile/android/modules/LightweightThemeConsumer.jsm @@ -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/. */ + +var EXPORTED_SYMBOLS = ["LightweightThemeConsumer"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { LightweightThemeManager } = ChromeUtils.import( + "resource://gre/modules/LightweightThemeManager.jsm" +); +const { ExtensionUtils } = ChromeUtils.import( + "resource://gre/modules/ExtensionUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "EventDispatcher", + "resource://gre/modules/Messaging.jsm" +); + +const DEFAULT_THEME_ID = "default-theme@mozilla.org"; + +let RESOLVE_PROPERTIES = ["headerURL"]; + +let handlers = new ExtensionUtils.DefaultMap(proto => { + try { + return Cc[`@mozilla.org/network/protocol;1?name=${proto}`].getService( + Ci.nsISubstitutingProtocolHandler + ); + } catch (e) { + return null; + } +}); + +// The Java front-end code cannot understand internal protocols like +// resource:, so resolve them to their underlying file: or jar: URIs +// when possible. +function maybeResolveURL(url) { + try { + let uri = Services.io.newURI(url); + let handler = handlers.get(uri.scheme); + if (handler) { + return handler.resolveURI(uri); + } + } catch (e) { + Cu.reportError(e); + } + return url; +} + +class LightweightThemeConsumer { + constructor(aDocument) { + this._doc = aDocument; + Services.obs.addObserver(this, "lightweight-theme-styling-update"); + + this._update(LightweightThemeManager.currentThemeWithFallback); + } + + observe(aSubject, aTopic, aData) { + if (aTopic == "lightweight-theme-styling-update") { + this._update(aSubject.wrappedJSObject.theme); + } + } + + destroy() { + Services.obs.removeObserver(this, "lightweight-theme-styling-update"); + this._doc = null; + } + + _update(aData) { + let active = aData && aData.id !== DEFAULT_THEME_ID; + let msg = { + type: active ? "LightweightTheme:Update" : "LightweightTheme:Disable", + }; + + if (active) { + msg.data = { ...aData }; + for (let prop of RESOLVE_PROPERTIES) { + if (msg.data[prop]) { + msg.data[prop] = maybeResolveURL(msg.data[prop]); + } + } + } + EventDispatcher.instance.sendRequest(msg); + } +} diff --git a/mobile/android/modules/MediaPlayerApp.jsm b/mobile/android/modules/MediaPlayerApp.jsm new file mode 100644 index 000000000000..977fd88acacd --- /dev/null +++ b/mobile/android/modules/MediaPlayerApp.jsm @@ -0,0 +1,159 @@ +// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["MediaPlayerApp"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); + +// Helper function for sending commands to Java. +function send(type, data, callback) { + let msg = { + type: type, + }; + + for (let i in data) { + msg[i] = data[i]; + } + + EventDispatcher.instance + .sendRequestForResult(msg) + .then(result => callback(result, null), error => callback(null, error)); +} + +/* These apps represent players supported natively by the platform. This class will proxy commands + * to native controls */ +function MediaPlayerApp(service) { + this.service = service; + this.location = service.location; + this.id = service.uuid; +} + +MediaPlayerApp.prototype = { + start: function start(callback) { + send("MediaPlayer:Start", { id: this.id }, (result, err) => { + if (callback) { + callback(err == null); + } + }); + }, + + stop: function stop(callback) { + send("MediaPlayer:Stop", { id: this.id }, (result, err) => { + if (callback) { + callback(err == null); + } + }); + }, + + remoteMedia: function remoteMedia(callback, listener) { + if (callback) { + callback(new RemoteMedia(this.id, listener)); + } + }, +}; + +/* RemoteMedia provides a proxy to a native media player session. + */ +function RemoteMedia(id, listener) { + this._id = id; + this._listener = listener; + + if ("onRemoteMediaStart" in this._listener) { + Services.tm.dispatchToMainThread(() => { + this._listener.onRemoteMediaStart(this); + }); + } +} + +RemoteMedia.prototype = { + shutdown: function shutdown() { + EventDispatcher.instance.unregisterListener(this, [ + "MediaPlayer:Playing", + "MediaPlayer:Paused", + ]); + + this._send("MediaPlayer:End", {}, (result, err) => { + this._status = "shutdown"; + if ("onRemoteMediaStop" in this._listener) { + this._listener.onRemoteMediaStop(this); + } + }); + }, + + play: function play() { + this._send("MediaPlayer:Play", {}, (result, err) => { + if (err) { + Cu.reportError("Can't play " + err); + this.shutdown(); + return; + } + + this._status = "started"; + }); + }, + + pause: function pause() { + this._send("MediaPlayer:Pause", {}, (result, err) => { + if (err) { + Cu.reportError("Can't pause " + err); + this.shutdown(); + return; + } + + this._status = "paused"; + }); + }, + + load: function load(aData) { + this._send("MediaPlayer:Load", aData, (result, err) => { + if (err) { + Cu.reportError("Can't load " + err); + this.shutdown(); + return; + } + + EventDispatcher.instance.registerListener(this, [ + "MediaPlayer:Playing", + "MediaPlayer:Paused", + ]); + this._status = "started"; + }); + }, + + get status() { + return this._status; + }, + + onEvent: function(event, message, callback) { + switch (event) { + case "MediaPlayer:Playing": + if (this._status !== "started") { + this._status = "started"; + if ("onRemoteMediaStatus" in this._listener) { + this._listener.onRemoteMediaStatus(this); + } + } + break; + case "MediaPlayer:Paused": + if (this._status !== "paused") { + this._status = "paused"; + if ("onRemoteMediaStatus" in this._listener) { + this._listener.onRemoteMediaStatus(this); + } + } + break; + } + }, + + _send: function(msg, data, callback) { + data.id = this._id; + send(msg, data, callback); + }, +}; diff --git a/mobile/android/modules/NetErrorHelper.jsm b/mobile/android/modules/NetErrorHelper.jsm new file mode 100644 index 000000000000..8f5ded9c5b5d --- /dev/null +++ b/mobile/android/modules/NetErrorHelper.jsm @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); +const { UITelemetry } = ChromeUtils.import( + "resource://gre/modules/UITelemetry.jsm" +); + +var EXPORTED_SYMBOLS = ["NetErrorHelper"]; + +const KEY_CODE_ENTER = 13; + +/* Handlers is a list of objects that will be notified when an error page is shown + * or when an event occurs on the page that they are registered to handle. Registration + * is done by just adding yourself to the dictionary. + * + * handlers.myKey = { + * onPageShown: function(browser) { }, + * handleEvent: function(event) { }, + * } + * + * The key that you register yourself with should match the ID of the element you want to + * watch for click events on. + */ + +var handlers = {}; + +function NetErrorHelper(browser) { + browser.addEventListener("click", this.handleClick, true); + + let listener = () => { + browser.removeEventListener("click", this.handleClick, true); + browser.removeEventListener("pagehide", listener, true); + }; + browser.addEventListener("pagehide", listener, true); + + // Handlers may want to customize the page + for (let id in handlers) { + if (handlers[id].onPageShown) { + handlers[id].onPageShown(browser); + } + } +} + +NetErrorHelper.attachToBrowser = function(browser) { + return new NetErrorHelper(browser); +}; + +NetErrorHelper.prototype = { + handleClick: function(event) { + let node = event.target; + + while (node) { + if (node.id in handlers && handlers[node.id].handleClick) { + handlers[node.id].handleClick(event); + return; + } + + node = node.parentNode; + } + }, +}; + +handlers.searchbutton = { + onPageShown: function(browser) { + let search = browser.contentDocument.querySelector("#searchbox"); + if (!search) { + return; + } + + let browserWin = Services.wm.getMostRecentWindow("navigator:browser"); + let tab = browserWin.BrowserApp.getTabForBrowser(browser); + + // If there is no stored userRequested, just hide the searchbox + if (!tab.userRequested) { + search.style.display = "none"; + } else { + let text = browser.contentDocument.querySelector("#searchtext"); + text.value = tab.userRequested; + text.addEventListener("keypress", event => { + if (event.keyCode === KEY_CODE_ENTER) { + this.doSearch(event.target.value); + } + }); + } + }, + + handleClick: function(event) { + let value = event.target.previousElementSibling.value; + this.doSearch(value); + }, + + doSearch: function(value) { + UITelemetry.addEvent("neterror.1", "button", null, "search"); + let engine = Services.search.defaultEngine; + let uri = engine.getSubmission(value).uri; + + let browserWin = Services.wm.getMostRecentWindow("navigator:browser"); + // Reset the user search to whatever the new search term was + browserWin.BrowserApp.loadURI(uri.spec, undefined, { + isSearch: true, + userRequested: value, + }); + }, +}; + +handlers.wifi = { + // This registers itself with the nsIObserverService as a weak ref, + // so we have to implement GetWeakReference as well. + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIObserver, + Ci.nsISupportsWeakReference, + ]), + + GetWeakReference: function() { + return Cu.getWeakReference(this); + }, + + onPageShown: function(browser) { + // If we have a connection, don't bother showing the wifi toggle. + let network = Cc["@mozilla.org/network/network-link-service;1"].getService( + Ci.nsINetworkLinkService + ); + if (network.isLinkUp && network.linkStatusKnown) { + let nodes = browser.contentDocument.querySelectorAll("#wifi"); + for (let i = 0; i < nodes.length; i++) { + nodes[i].style.display = "none"; + } + } + }, + + handleClick: function(event) { + let node = event.target; + while (node && node.id !== "wifi") { + node = node.parentNode; + } + + if (!node) { + return; + } + + UITelemetry.addEvent("neterror.1", "button", null, "wifitoggle"); + // Show indeterminate progress while we wait for the network. + node.disabled = true; + node.classList.add("inProgress"); + + this.node = Cu.getWeakReference(node); + Services.obs.addObserver(this, "network:link-status-changed", true); + + EventDispatcher.instance.sendRequest({ + type: "Wifi:Enable", + }); + }, + + observe: function(subject, topic, data) { + let node = this.node.get(); + if (!node) { + return; + } + + // Remove the progress bar + node.disabled = false; + node.classList.remove("inProgress"); + + let network = Cc["@mozilla.org/network/network-link-service;1"].getService( + Ci.nsINetworkLinkService + ); + if (network.isLinkUp && network.linkStatusKnown) { + // If everything worked, reload the page + UITelemetry.addEvent("neterror.1", "button", null, "wifitoggle.reload"); + Services.obs.removeObserver(this, "network:link-status-changed"); + + // Even at this point, Android sometimes lies about the real state of the network and this reload request fails. + // Add a 500ms delay before refreshing the page. + node.ownerGlobal.setTimeout(function() { + node.ownerDocument.location.reload(false); + }, 500); + } + }, +}; diff --git a/mobile/android/modules/Notifications.jsm b/mobile/android/modules/Notifications.jsm new file mode 100644 index 000000000000..015eab8e73d9 --- /dev/null +++ b/mobile/android/modules/Notifications.jsm @@ -0,0 +1,270 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); + +var EXPORTED_SYMBOLS = ["Notifications"]; + +var _notificationsMap = {}; +var _handlersMap = {}; + +function Notification(aId, aOptions) { + this._id = aId; + this._when = new Date().getTime(); + this.fillWithOptions(aOptions); +} + +Notification.prototype = { + fillWithOptions: function(aOptions) { + if ("icon" in aOptions && aOptions.icon != null) { + this._icon = aOptions.icon; + } else { + throw new Error("Notification icon is mandatory"); + } + + if ("title" in aOptions && aOptions.title != null) { + this._title = aOptions.title; + } else { + throw new Error("Notification title is mandatory"); + } + + if ("message" in aOptions && aOptions.message != null) { + this._message = aOptions.message; + } else { + this._message = null; + } + + if ("priority" in aOptions && aOptions.priority != null) { + this._priority = aOptions.priority; + } + + if ("buttons" in aOptions && aOptions.buttons != null) { + if (aOptions.buttons.length > 3) { + throw new Error("Too many buttons provided. The max number is 3"); + } + + this._buttons = {}; + for (let i = 0; i < aOptions.buttons.length; i++) { + let button_id = aOptions.buttons[i].buttonId; + this._buttons[button_id] = aOptions.buttons[i]; + } + } else { + this._buttons = null; + } + + if ("ongoing" in aOptions && aOptions.ongoing != null) { + this._ongoing = aOptions.ongoing; + } else { + this._ongoing = false; + } + + if ("progress" in aOptions && aOptions.progress != null) { + this._progress = aOptions.progress; + } else { + this._progress = null; + } + + if ("onCancel" in aOptions && aOptions.onCancel != null) { + this._onCancel = aOptions.onCancel; + } else { + this._onCancel = null; + } + + if ("onClick" in aOptions && aOptions.onClick != null) { + this._onClick = aOptions.onClick; + } else { + this._onClick = null; + } + + if ("cookie" in aOptions && aOptions.cookie != null) { + this._cookie = aOptions.cookie; + } else { + this._cookie = null; + } + + if ("handlerKey" in aOptions && aOptions.handlerKey != null) { + this._handlerKey = aOptions.handlerKey; + } + + if ("persistent" in aOptions && aOptions.persistent != null) { + this._persistent = aOptions.persistent; + } else { + this._persistent = false; + } + }, + + show: function() { + let msg = { + id: this._id, + title: this._title, + smallIcon: this._icon, + ongoing: this._ongoing, + when: this._when, + persistent: this._persistent, + }; + + if (this._message) { + msg.text = this._message; + } + + if (this._progress) { + msg.progress_value = this._progress; + msg.progress_max = 100; + msg.progress_indeterminate = false; + } else if (Number.isNaN(this._progress)) { + msg.progress_value = 0; + msg.progress_max = 0; + msg.progress_indeterminate = true; + } + + if (this._cookie) { + msg.cookie = JSON.stringify(this._cookie); + } + + if (this._priority) { + msg.priority = this._priority; + } + + if (this._buttons) { + msg.actions = []; + let buttonName; + for (buttonName in this._buttons) { + let button = this._buttons[buttonName]; + let obj = { + buttonId: button.buttonId, + title: button.title, + icon: button.icon, + }; + msg.actions.push(obj); + } + } + + if (this._light) { + msg.light = this._light; + } + + if (this._handlerKey) { + msg.handlerKey = this._handlerKey; + } + + EventDispatcher.instance.dispatch("Notification:Show", msg); + return this; + }, + + cancel: function() { + let msg = { + id: this._id, + handlerKey: this._handlerKey, + cookie: JSON.stringify(this._cookie), + }; + EventDispatcher.instance.dispatch("Notification:Hide", msg); + }, +}; + +var Notifications = { + get idService() { + delete this.idService; + return (this.idService = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator + )); + }, + + registerHandler: function(key, handler) { + if (!_handlersMap[key]) { + _handlersMap[key] = []; + } + _handlersMap[key].push(handler); + }, + + unregisterHandler: function(key, handler) { + let h = _handlersMap[key]; + if (!h) { + return; + } + let i = h.indexOf(handler); + if (i > -1) { + h.splice(i, 1); + } + }, + + create: function notif_notify(aOptions) { + let id = this.idService.generateUUID().toString(); + + let notification = new Notification(id, aOptions); + _notificationsMap[id] = notification; + notification.show(); + + return id; + }, + + update: function notif_update(aId, aOptions) { + let notification = _notificationsMap[aId]; + if (!notification) { + throw new Error("Unknown notification id"); + } + notification.fillWithOptions(aOptions); + notification.show(); + }, + + cancel: function notif_cancel(aId) { + let notification = _notificationsMap[aId]; + if (notification) { + notification.cancel(); + } + }, + + onEvent: function notif_onEvent(event, data, callback) { + let id = data.id; + let handlerKey = data.handlerKey; + let cookie = data.cookie ? JSON.parse(data.cookie) : undefined; + let notification = _notificationsMap[id]; + + switch (data.eventType) { + case "notification-clicked": + if (notification && notification._onClick) { + notification._onClick(id, notification._cookie); + } + + if (handlerKey) { + _handlersMap[handlerKey].forEach(function(handler) { + handler.onClick(cookie); + }); + } + + break; + case "notification-button-clicked": + if (handlerKey) { + _handlersMap[handlerKey].forEach(function(handler) { + handler.onButtonClick(data.buttonId, cookie); + }); + } + + break; + case "notification-cleared": + case "notification-closed": + if (handlerKey) { + _handlersMap[handlerKey].forEach(function(handler) { + handler.onCancel(cookie); + }); + } + + if (notification && notification._onCancel) { + notification._onCancel(id, notification._cookie); + } + delete _notificationsMap[id]; // since the notification was dismissed, we no longer need to hold a reference. + break; + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +EventDispatcher.instance.registerListener(Notifications, "Notification:Event"); diff --git a/mobile/android/modules/Prompt.jsm b/mobile/android/modules/Prompt.jsm new file mode 100644 index 000000000000..02cb6b6aa708 --- /dev/null +++ b/mobile/android/modules/Prompt.jsm @@ -0,0 +1,336 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EventDispatcher: "resource://gre/modules/Messaging.jsm", + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +var EXPORTED_SYMBOLS = ["Prompt", "DoorHanger"]; + +function log(msg) { + Services.console.logStringMessage(msg); +} + +function Prompt(aOptions) { + this.window = "window" in aOptions ? aOptions.window : null; + + this.msg = { async: true }; + + if (this.window) { + let window = GeckoViewUtils.getChromeWindow(this.window); + let tab = + window && + window.document.documentElement.getAttribute("windowtype") === + "navigator:browser" && + window.BrowserApp && + window.BrowserApp.getTabForWindow(this.window); + if (tab) { + this.msg.tabId = tab.id; + } + } + + if (aOptions.priority === 1) { + this.msg.type = "Prompt:ShowTop"; + } else { + this.msg.type = "Prompt:Show"; + } + + if ("title" in aOptions && aOptions.title != null) { + this.msg.title = aOptions.title; + } + + if ("message" in aOptions && aOptions.message != null) { + this.msg.text = aOptions.message; + } + + if ("buttons" in aOptions && aOptions.buttons != null) { + this.msg.buttons = aOptions.buttons; + } + + if ("doubleTapButton" in aOptions && aOptions.doubleTapButton != null) { + this.msg.doubleTapButton = aOptions.doubleTapButton; + } + + if ("hint" in aOptions && aOptions.hint != null) { + this.msg.hint = aOptions.hint; + } +} + +Prompt.prototype = { + setHint: function(aHint) { + if (!aHint) { + delete this.msg.hint; + } else { + this.msg.hint = aHint; + } + return this; + }, + + addButton: function(aOptions) { + if (!this.msg.buttons) { + this.msg.buttons = []; + } + this.msg.buttons.push(aOptions.label); + return this; + }, + + _addInput: function(aOptions) { + let obj = aOptions; + if (this[aOptions.type + "_count"] === undefined) { + this[aOptions.type + "_count"] = 0; + } + + obj.id = aOptions.id || aOptions.type + this[aOptions.type + "_count"]; + this[aOptions.type + "_count"]++; + + if (!this.msg.inputs) { + this.msg.inputs = []; + } + this.msg.inputs.push(obj); + return this; + }, + + addCheckbox: function(aOptions) { + return this._addInput({ + type: "checkbox", + label: aOptions.label, + checked: aOptions.checked, + id: aOptions.id, + }); + }, + + addTextbox: function(aOptions) { + return this._addInput({ + type: "textbox", + value: aOptions.value, + hint: aOptions.hint, + autofocus: aOptions.autofocus, + id: aOptions.id, + }); + }, + + addNumber: function(aOptions) { + return this._addInput({ + type: "number", + value: aOptions.value, + hint: aOptions.hint, + autofocus: aOptions.autofocus, + id: aOptions.id, + }); + }, + + addPassword: function(aOptions) { + return this._addInput({ + type: "password", + value: aOptions.value, + hint: aOptions.hint, + autofocus: aOptions.autofocus, + id: aOptions.id, + }); + }, + + addDatePicker: function(aOptions) { + return this._addInput({ + type: aOptions.type || "date", + value: aOptions.value, + id: aOptions.id, + step: aOptions.step, + max: aOptions.max, + min: aOptions.min, + }); + }, + + addColorPicker: function(aOptions) { + return this._addInput({ + type: "color", + value: aOptions.value, + id: aOptions.id, + }); + }, + + addLabel: function(aOptions) { + return this._addInput({ + type: "label", + label: aOptions.label, + id: aOptions.id, + }); + }, + + addMenulist: function(aOptions) { + return this._addInput({ + type: "menulist", + values: aOptions.values, + id: aOptions.id, + }); + }, + + addIconGrid: function(aOptions) { + return this._addInput({ + type: "icongrid", + items: aOptions.items, + id: aOptions.id, + }); + }, + + addTabs: function(aOptions) { + return this._addInput({ + type: "tabs", + items: aOptions.items, + id: aOptions.id, + }); + }, + + show: function(callback) { + this.callback = callback; + log("Sending message"); + this._innerShow(); + }, + + _innerShow: function() { + let dispatcher; + if (this.window) { + dispatcher = GeckoViewUtils.getDispatcherForWindow(this.window); + } + if (!dispatcher) { + [dispatcher] = GeckoViewUtils.getActiveDispatcherAndWindow(); + } + + dispatcher.sendRequestForResult(this.msg).then(data => { + if (this.callback) { + this.callback(data); + } + }); + }, + + _setListItems: function(aItems) { + this.msg.listitems = []; + + aItems.forEach(function(item) { + let obj = { id: item.id }; + + obj.label = item.label; + + if (item.disabled) { + obj.disabled = true; + } + + if (item.selected) { + if (!this.msg.choiceMode) { + this.msg.choiceMode = "single"; + } + obj.selected = item.selected; + } + + if (item.header) { + obj.isGroup = true; + } + + if (item.menu) { + obj.isParent = true; + } + + if (item.child) { + obj.inGroup = true; + } + + if (item.showAsActions) { + obj.showAsActions = item.showAsActions; + } + + if (item.icon) { + obj.icon = item.icon; + } + + this.msg.listitems.push(obj); + }, this); + return this; + }, + + setSingleChoiceItems: function(aItems) { + return this._setListItems(aItems); + }, + + setMultiChoiceItems: function(aItems) { + this.msg.choiceMode = "multiple"; + return this._setListItems(aItems); + }, +}; + +var DoorHanger = { + _getTabId: function(aWindow, aBrowserApp) { + let tab = + aBrowserApp.getTabForWindow(aWindow.top) || aBrowserApp.selectedTab; + return tab ? tab.id : -1; + }, + + show: function(aWindow, aMessage, aValue, aButtons, aOptions, aCategory) { + let chromeWin = GeckoViewUtils.getChromeWindow(aWindow); + if (chromeWin.NativeWindow && chromeWin.NativeWindow.doorhanger) { + // We're dealing with browser.js. + return chromeWin.NativeWindow.doorhanger.show( + aMessage, + aValue, + aButtons, + this._getTabId(aWindow, chromeWin.BrowserApp), + aOptions, + aCategory + ); + } + + // We're dealing with GeckoView (e.g. custom tabs). + aButtons = aButtons || []; + + // Extract callbacks into a separate array, and replace each callback in + // the buttons array with an index into the callback array. + let callbacks = aButtons.map((aButton, aIndex) => { + let cb = aButton.callback; + aButton.callback = aIndex; + return cb; + }); + + EventDispatcher.for(chromeWin) + .sendRequestForResult({ + type: "Doorhanger:Add", + message: aMessage, + value: aValue, + buttons: aButtons, + options: aOptions || {}, + category: aCategory, + defaultCallback: aOptions && aOptions.defaultCallback ? -1 : undefined, + }) + .then(response => { + if (response.callback === -1) { + // Default case. + aOptions.defaultCallback(response.checked, response.inputs); + return; + } + // Pass the value of the optional checkbox to the callback + callbacks[response.callback](response.checked, response.inputs); + }); + }, + + hide: function(aWindow, aValue) { + let chromeWin = GeckoViewUtils.getChromeWindow(aWindow); + if (chromeWin.NativeWindow && chromeWin.NativeWindow.doorhanger) { + // We're dealing with browser.js. + return chromeWin.NativeWindow.doorhanger.hide( + aValue, + this._getTabId(aWindow, chromeWin.BrowserApp) + ); + } + + EventDispatcher.for(chromeWin).sendRequest({ + type: "Doorhanger:Remove", + value: aValue, + }); + }, +}; diff --git a/mobile/android/modules/RuntimePermissions.jsm b/mobile/android/modules/RuntimePermissions.jsm new file mode 100644 index 000000000000..6169dc9cddd6 --- /dev/null +++ b/mobile/android/modules/RuntimePermissions.jsm @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["RuntimePermissions"]; + +ChromeUtils.defineModuleGetter( + this, + "EventDispatcher", + "resource://gre/modules/Messaging.jsm" +); + +// See: http://developer.android.com/reference/android/Manifest.permission.html +const ACCESS_COARSE_LOCATION = "android.permission.ACCESS_COARSE_LOCATION"; +const ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION"; +const CAMERA = "android.permission.CAMERA"; +const RECORD_AUDIO = "android.permission.RECORD_AUDIO"; +const WRITE_EXTERNAL_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE"; +const READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE"; + +var RuntimePermissions = { + ACCESS_COARSE_LOCATION: ACCESS_COARSE_LOCATION, + ACCESS_FINE_LOCATION: ACCESS_FINE_LOCATION, + CAMERA: CAMERA, + RECORD_AUDIO: RECORD_AUDIO, + WRITE_EXTERNAL_STORAGE: WRITE_EXTERNAL_STORAGE, + READ_EXTERNAL_STORAGE: READ_EXTERNAL_STORAGE, + + /** + * Check whether the permissions have been granted or not. If needed prompt the user to accept the permissions. + * + * @returns A promise resolving to true if all the permissions have been granted or false if any of the + * permissions have been denied. + */ + waitForPermissions: function(permission) { + let permissions = [].concat(permission); + + let msg = { + type: "RuntimePermissions:Check", + permissions: permissions, + shouldPrompt: true, + }; + + return EventDispatcher.instance.sendRequestForResult(msg); + }, + + /** + * Check whether the specified permissions have already been granted or not. + * + * @returns A promise resolving to true if all the permissions are already granted or false if any of the + * permissions are not granted. + */ + checkPermissions: function(permission) { + let permissions = [].concat(permission); + + let msg = { + type: "RuntimePermissions:Check", + permissions: permissions, + shouldPrompt: false, + }; + + return EventDispatcher.instance.sendRequestForResult(msg); + }, +}; diff --git a/mobile/android/modules/Sanitizer.jsm b/mobile/android/modules/Sanitizer.jsm new file mode 100644 index 000000000000..16ff524b48e7 --- /dev/null +++ b/mobile/android/modules/Sanitizer.jsm @@ -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/. */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Integration } = ChromeUtils.import( + "resource://gre/modules/Integration.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Accounts: "resource://gre/modules/Accounts.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + EventDispatcher: "resource://gre/modules/Messaging.jsm", + FormHistory: "resource://gre/modules/FormHistory.jsm", + OfflineAppCacheHelper: "resource://gre/modules/offlineAppCache.jsm", + OS: "resource://gre/modules/osfile.jsm", + ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(this, { + quotaManagerService: [ + "@mozilla.org/dom/quota-manager-service;1", + "nsIQuotaManagerService", + ], +}); + +/* global DownloadIntegration */ +Integration.downloads.defineModuleGetter( + this, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.jsm" +); + +var EXPORTED_SYMBOLS = ["Sanitizer"]; + +function Sanitizer() {} +Sanitizer.prototype = { + clearItem: function(aItemName, startTime, clearUnfinishedDownloads) { + // Only a subset of items support deletion with startTime. + // Those who do not will be rejected with error message. + if (typeof startTime != "undefined") { + switch (aItemName) { + // Normal call to DownloadFiles remove actual data from storage, but our web-extension consumer + // deletes only download history. So, for this reason we are passing a flag 'deleteFiles'. + case "downloadHistory": + return this._clear("downloadFiles", { + startTime, + deleteFiles: false, + }); + case "formdata": + return this._clear(aItemName, { startTime }); + default: + return Promise.reject({ + message: `Invalid argument: ${aItemName} does not support startTime argument.`, + }); + } + } else if ( + aItemName === "downloadFiles" && + typeof clearUnfinishedDownloads != "undefined" + ) { + return this._clear(aItemName, { clearUnfinishedDownloads }); + } else { + return this._clear(aItemName); + } + }, + + _clear: function(aItemName, options) { + let item = this.items[aItemName]; + let canClear = item.canClear; + if (typeof canClear == "function") { + let maybeDoClear = async () => { + let canClearResult = await new Promise(resolve => { + canClear(resolve); + }); + + if (canClearResult) { + return item.clear(options); + } + }; + return maybeDoClear(); + } else if (canClear) { + return item.clear(options); + } + }, + + // This code is mostly based on the Sanitizer code for desktop Firefox + // (browser/modules/Sanitzer.jsm), however over the course of time some + // general differences have evolved: + // - async shutdown (and seenException handling) isn't implemented in Fennec + // - currently there is only limited support for range-based clearing of data + + // Any further specific differences caused by architectural differences between + // Fennec and desktop Firefox are documented below for each item. + items: { + // The difference is specifically the Sanitize:Cache message, + // so that the Android front-end can clear its caches as well, + // while everything else is unchanged. + cache: { + clear: function() { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_CACHE", refObj); + + try { + Services.cache2.clear(); + } catch (er) {} + + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(null); + try { + imageCache.clearCache(false); // true=chrome, false=content + } catch (er) {} + + return EventDispatcher.instance + .sendRequestForResult({ type: "Sanitize:Cache" }) + .catch(err => { + Cu.reportError( + `Java-side cache clearing failed with error: ${err}` + ); + }) + .then(() => { + TelemetryStopwatch.finish("FX_SANITIZE_CACHE", refObj); + }); + }, + + get canClear() { + return true; + }, + }, + + // Compared to desktop, we don't clear plugin data, as plugins + // aren't supported on Android. + cookies: { + clear: function() { + return new Promise(function(resolve, reject) { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_COOKIES_2", refObj); + + Services.cookies.removeAll(); + + TelemetryStopwatch.finish("FX_SANITIZE_COOKIES_2", refObj); + + // Clear deviceIds. Done asynchronously (returns before complete). + try { + let mediaMgr = Cc["@mozilla.org/mediaManagerService;1"].getService( + Ci.nsIMediaManagerService + ); + mediaMgr.sanitizeDeviceIds(0); + } catch (er) {} + + resolve(); + }); + }, + + get canClear() { + return true; + }, + }, + + // Same as desktop Firefox. + siteSettings: { + async clear() { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_SITESETTINGS", refObj); + + // Clear site-specific permissions like "Allow this site to open popups" + Services.perms.removeAll(); + + // Clear site-specific settings like page-zoom level + Cc["@mozilla.org/content-pref/service;1"] + .getService(Ci.nsIContentPrefService2) + .removeAllDomains(null); + + // Clear site security settings + var sss = Cc["@mozilla.org/ssservice;1"].getService( + Ci.nsISiteSecurityService + ); + sss.clearAll(); + + // Clear push subscriptions + await new Promise((resolve, reject) => { + let push = Cc["@mozilla.org/push/Service;1"].getService( + Ci.nsIPushService + ); + push.clearForDomain("*", status => { + if (Components.isSuccessCode(status)) { + resolve(); + } else { + reject(new Error("Error clearing push subscriptions: " + status)); + } + }); + }); + TelemetryStopwatch.finish("FX_SANITIZE_SITESETTINGS", refObj); + }, + + get canClear() { + return true; + }, + }, + + // Same as desktop Firefox. + offlineApps: { + async clear() { + // AppCache + // This doesn't wait for the cleanup to be complete. + OfflineAppCacheHelper.clear(); + + // LocalStorage + Services.obs.notifyObservers(null, "extension:purge-localStorage"); + + // ServiceWorkers + await ServiceWorkerCleanUp.removeAll(); + + // QuotaManager + let promises = []; + await new Promise(resolve => { + quotaManagerService.getUsage(request => { + if (request.resultCode != Cr.NS_OK) { + // We are probably shutting down. We don't want to propagate the + // error, rejecting the promise. + resolve(); + return; + } + + for (let item of request.result) { + let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin( + item.origin + ); + let uri = principal.URI; + if ( + uri.scheme == "http" || + uri.scheme == "https" || + uri.scheme == "file" + ) { + promises.push( + new Promise(r => { + let req = quotaManagerService.clearStoragesForPrincipal( + principal + ); + req.callback = () => { + r(); + }; + }) + ); + } + } + resolve(); + }); + }); + + return Promise.all(promises); + }, + + get canClear() { + return true; + }, + }, + + // History on Android is implemented by the Java frontend and requires + // different handling. Everything else is the same as for desktop Firefox. + history: { + clear: function() { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_HISTORY", refObj); + + return EventDispatcher.instance + .sendRequestForResult({ type: "Sanitize:ClearHistory" }) + .catch(e => Cu.reportError("Java-side history clearing failed: " + e)) + .then(function() { + TelemetryStopwatch.finish("FX_SANITIZE_HISTORY", refObj); + try { + Services.obs.notifyObservers( + null, + "browser:purge-session-history" + ); + } catch (e) {} + + try { + var predictor = Cc["@mozilla.org/network/predictor;1"].getService( + Ci.nsINetworkPredictor + ); + predictor.reset(); + } catch (e) {} + }); + }, + + get canClear() { + // bug 347231: Always allow clearing history due to dependencies on + // the browser:purge-session-history notification. (like error console) + return true; + }, + }, + + // Equivalent to openWindows on desktop, but specific to Fennec's implementation + // of tabbed browsing and the session store. + openTabs: { + clear: function() { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_OPENWINDOWS", refObj); + + return EventDispatcher.instance + .sendRequestForResult({ type: "Sanitize:OpenTabs" }) + .catch(e => Cu.reportError("Java-side tab clearing failed: " + e)) + .then(function() { + try { + // clear "Recently Closed" tabs in Android App + Services.obs.notifyObservers(null, "browser:purge-session-tabs"); + } catch (e) {} + TelemetryStopwatch.finish("FX_SANITIZE_OPENWINDOWS", refObj); + }); + }, + + get canClear() { + return true; + }, + }, + + // Specific to Fennec. + searchHistory: { + clear: function() { + return EventDispatcher.instance + .sendRequestForResult({ + type: "Sanitize:ClearHistory", + clearSearchHistory: true, + }) + .catch(e => + Cu.reportError("Java-side search history clearing failed: " + e) + ); + }, + + get canClear() { + return true; + }, + }, + + // Browser search is handled by searchHistory above and the find bar doesn't + // require extra handling. FormHistory itself is cleared like on desktop. + formdata: { + clear: function({ startTime = 0 } = {}) { + return new Promise(function(resolve, reject) { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_FORMDATA", refObj); + + // Conver time to microseconds + let time = startTime * 1000; + FormHistory.update( + { + op: "remove", + firstUsedStart: time, + }, + { + handleCompletion() { + TelemetryStopwatch.finish("FX_SANITIZE_FORMDATA", refObj); + resolve(); + }, + } + ); + }); + }, + + canClear: function(aCallback) { + let count = 0; + let countDone = { + handleResult: function(aResult) { + count = aResult; + }, + handleError: function(aError) { + Cu.reportError(aError); + }, + handleCompletion: function(aReason) { + aCallback(aReason == 0 && count > 0); + }, + }; + FormHistory.count({}, countDone); + }, + }, + + // Adapted from desktop, but heavily modified - see comments below. + downloadFiles: { + async clear({ + startTime = 0, + deleteFiles = true, + clearUnfinishedDownloads = false, + } = {}) { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_DOWNLOADS", refObj); + + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + var finalizePromises = []; + + // Logic copied from DownloadList.removeFinished. Ideally, we would + // just use that method directly, but we want to be able to remove the + // downloaded files as well. + for (let download of downloads) { + let downloadFinished = + download.stopped && (!download.hasPartialData || download.error); + if ( + (downloadFinished || clearUnfinishedDownloads) && + download.startTime.getTime() >= startTime + ) { + // Remove the download first, so that the views don't get the change + // notifications that may occur during finalization. + await list.remove(download); + // Ensure that the download is stopped and no partial data is kept. + // This works even if the download state has changed meanwhile. We + // don't need to wait for the procedure to be complete before + // processing the other downloads in the list. + finalizePromises.push( + download.finalize(true).then(() => null, Cu.reportError) + ); + + if (deleteFiles) { + // Delete the downloaded files themselves. + OS.File.remove(download.target.path).then( + () => null, + ex => { + if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) { + Cu.reportError(ex); + } + } + ); + } + } + } + + await Promise.all(finalizePromises); + await DownloadIntegration.forceSave(); + TelemetryStopwatch.finish("FX_SANITIZE_DOWNLOADS", refObj); + }, + + get canClear() { + return true; + }, + }, + + // Specific to Fennec. + passwords: { + clear: function() { + return new Promise(function(resolve, reject) { + Services.logins.removeAllLogins(); + resolve(); + }); + }, + + get canClear() { + let count = Services.logins.countLogins("", "", ""); // count all logins + return count > 0; + }, + }, + + // Same as desktop Firefox. + sessions: { + clear: function() { + return new Promise(function(resolve, reject) { + let refObj = {}; + TelemetryStopwatch.start("FX_SANITIZE_SESSIONS", refObj); + + // clear all auth tokens + var sdr = Cc["@mozilla.org/security/sdr;1"].getService( + Ci.nsISecretDecoderRing + ); + sdr.logoutAndTeardown(); + + // clear FTP and plain HTTP auth sessions + Services.obs.notifyObservers(null, "net:clear-active-logins"); + + TelemetryStopwatch.finish("FX_SANITIZE_SESSIONS", refObj); + resolve(); + }); + }, + + get canClear() { + return true; + }, + }, + + // Specific to Fennec. + syncedTabs: { + clear: function() { + return EventDispatcher.instance + .sendRequestForResult({ type: "Sanitize:ClearSyncedTabs" }) + .catch(e => + Cu.reportError("Java-side synced tabs clearing failed: " + e) + ); + }, + + canClear: function(aCallback) { + Accounts.anySyncAccountsExist() + .then(aCallback) + .catch(function(err) { + Cu.reportError("Java-side synced tabs clearing failed: " + err); + aCallback(false); + }); + }, + }, + }, +}; + +var Sanitizer = new Sanitizer(); diff --git a/mobile/android/modules/SelectHelper.jsm b/mobile/android/modules/SelectHelper.jsm new file mode 100644 index 000000000000..00b02da0c693 --- /dev/null +++ b/mobile/android/modules/SelectHelper.jsm @@ -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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["SelectHelper"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Prompt: "resource://gre/modules/Prompt.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +var SelectHelper = { + _uiBusy: false, + + strings: function() { + if (!this._strings) { + this._strings = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); + } + return this._strings; + }, + + handleEvent: function(event) { + this.handleClick(event.composedTarget); + }, + + handleClick: function(target) { + // if we're busy looking at a select we want to eat any clicks that + // come to us, but not to process them + if ( + this._uiBusy || + !this._isMenu(target) || + this._isDisabledElement(target) + ) { + return; + } + + this._uiBusy = true; + this.show(target); + this._uiBusy = false; + }, + + // This is a callback function to be provided to prompt.show(callBack). + // It will update which Option elements in a Select have been selected + // or unselected and fire the onChange event. + _promptCallBack: function(data, element) { + let win = element.ownerGlobal; + let selected = data.list; + + if (this._isXULElement(element, "menulist")) { + if (element.selectedIndex != selected[0]) { + element.selectedIndex = selected[0]; + this.fireOnCommand(element); + } + } else if (element instanceof win.HTMLSelectElement) { + let changed = false; + let i = 0; // The index for the element from `data.list` that we are currently examining. + this.forVisibleOptions(element, function(node) { + if (node.selected && !selected.includes(i)) { + changed = true; + node.selected = false; + } else if (!node.selected && selected.includes(i)) { + changed = true; + node.selected = true; + } + i++; + }); + + if (changed) { + this.fireOnChange(element); + } + } + }, + + show: function(element) { + let list = this.getListForElement(element); + let p = new Prompt({ + window: element.ownerGlobal, + }); + + if (element.multiple) { + p.addButton({ + label: this.strings().GetStringFromName( + "selectHelper.closeMultipleSelectDialog" + ), + }).setMultiChoiceItems(list); + } else { + p.setSingleChoiceItems(list); + } + + p.show(data => { + this._promptCallBack(data, element); + }); + }, + + _isXULElement: function(element, tag) { + return ( + (!tag || element.localName == tag) && + element.namespaceURI == + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ); + }, + + _isMenu: function(element) { + let win = element.ownerGlobal; + return ( + element instanceof win.HTMLSelectElement || + this._isXULElement(element, "menulist") + ); + }, + + // Return a list of Option elements within a Select excluding + // any that were not visible. + getListForElement: function(element) { + let index = 0; + let items = []; + this.forVisibleOptions(element, function(node, options, parent) { + let item = { + label: node.text || node.label, + header: options.isGroup, + disabled: node.disabled, + id: index, + selected: node.selected, + }; + + if (parent) { + item.child = true; + item.disabled = item.disabled || parent.disabled; + } + items.push(item); + index++; + }); + return items; + }, + + // Apply a function to all visible Option elements in a Select + forVisibleOptions: function(element, aFunction, parent = null) { + let win = element.ownerGlobal; + if (this._isXULElement(element, "menulist")) { + element = element.menupopup; + } + let children = element.children; + let numChildren = children.length; + + // if there are no children in this select, we add a dummy row so that at least something appears + if (numChildren == 0) { + aFunction.call(this, { label: "" }, { isGroup: false }, parent); + } + + for (let i = 0; i < numChildren; i++) { + let child = children[i]; + let style = win.getComputedStyle(child); + if (style.display !== "none") { + if ( + child instanceof win.HTMLOptionElement || + this._isXULElement(child) + ) { + aFunction.call(this, child, { isGroup: false }, parent); + } else if (child instanceof win.HTMLOptGroupElement) { + aFunction.call(this, child, { isGroup: true }); + this.forVisibleOptions(child, aFunction, child); + } + } + } + }, + + fireOnChange: function(element) { + let win = element.ownerGlobal; + win.setTimeout(function() { + element.dispatchEvent(new win.Event("input", { bubbles: true })); + element.dispatchEvent(new win.Event("change", { bubbles: true })); + }, 0); + }, + + fireOnCommand: function(element) { + let win = element.ownerGlobal; + let event = element.ownerDocument.createEvent("XULCommandEvent"); + event.initCommandEvent( + "command", + true, + true, + element.defaultView, + 0, + false, + false, + false, + false, + null, + 0 + ); + win.setTimeout(function() { + element.dispatchEvent(event); + }, 0); + }, + + _isDisabledElement: function(element) { + let currentElement = element; + while (currentElement) { + // Must test with === in case a form has a field named "disabled". See bug 1263589. + if (currentElement.disabled === true) { + return true; + } + currentElement = currentElement.parentElement; + } + return false; + }, +}; diff --git a/mobile/android/modules/SharedPreferences.jsm b/mobile/android/modules/SharedPreferences.jsm new file mode 100644 index 000000000000..522418f38583 --- /dev/null +++ b/mobile/android/modules/SharedPreferences.jsm @@ -0,0 +1,294 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["SharedPreferences"]; + +const { EventDispatcher } = ChromeUtils.import( + "resource://gre/modules/Messaging.jsm" +); + +var Scope = Object.freeze({ + APP: "app", + PROFILE: "profile", + GLOBAL: "global", +}); + +/** + * Public API to getting a SharedPreferencesImpl instance. These scopes mirror GeckoSharedPrefs. + */ +var SharedPreferences = { + forApp: function() { + return new SharedPreferencesImpl({ scope: Scope.APP }); + }, + + forProfile: function() { + return new SharedPreferencesImpl({ scope: Scope.PROFILE }); + }, + + /** + * Get SharedPreferences for the named profile; if the profile name is null, + * returns the preferences for the current profile (just like |forProfile|). + */ + forProfileName: function(profileName) { + return new SharedPreferencesImpl({ + scope: Scope.PROFILE, + profileName: profileName, + }); + }, + + /** + * Get SharedPreferences for the given Android branch; if the branch is null, + * returns the default preferences branch for the application, which is the + * output of |PreferenceManager.getDefaultSharedPreferences|. + */ + forAndroid: function(branch) { + return new SharedPreferencesImpl({ scope: Scope.GLOBAL, branch: branch }); + }, +}; + +/** + * Create an interface to an Android SharedPreferences branch. + * + * options {Object} with the following valid keys: + * - scope {String} (required) specifies the scope of preferences that should be accessed. + * - branch {String} (only when using Scope.GLOBAL) should be a string describing a preferences branch, + * like "UpdateService" or "background.data", or null to access the + * default preferences branch for the application. + * - profileName {String} (optional, only valid when using Scope.PROFILE) + */ +function SharedPreferencesImpl(options = {}) { + if (!(this instanceof SharedPreferencesImpl)) { + return new SharedPreferencesImpl(options); + } + + if (options.scope == null || options.scope == undefined) { + throw new Error("Shared Preferences must specifiy a scope."); + } + + this._scope = options.scope; + this._profileName = options.profileName; + this._branch = options.branch; + this._observers = {}; +} + +SharedPreferencesImpl.prototype = Object.freeze({ + _set: function _set(prefs) { + EventDispatcher.instance.sendRequest({ + type: "SharedPreferences:Set", + preferences: prefs, + scope: this._scope, + profileName: this._profileName, + branch: this._branch, + }); + }, + + _setOne: function _setOne(prefName, value, type) { + let prefs = []; + prefs.push({ + name: prefName, + value: value, + type: type, + }); + this._set(prefs); + }, + + setBoolPref: function setBoolPref(prefName, value) { + this._setOne(prefName, value, "bool"); + }, + + setCharPref: function setCharPref(prefName, value) { + this._setOne(prefName, value, "string"); + }, + + setSetPref: function setCharPref(prefName, value) { + this._setOne(prefName, value, "set"); + }, + + setIntPref: function setIntPref(prefName, value) { + this._setOne(prefName, value, "int"); + }, + + _get: function _get(prefs, callback) { + let result = null; + + // Use dispatch instead of sendRequestForResult because callbacks for + // Gecko thread events are synchronous when used with dispatch(), so we + // don't have to spin the event loop here to wait for a result. + EventDispatcher.instance.dispatch( + "SharedPreferences:Get", + { + preferences: prefs, + scope: this._scope, + profileName: this._profileName, + branch: this._branch, + }, + { + onSuccess: values => { + result = values; + }, + onError: msg => { + throw new Error("Cannot get preference: " + msg); + }, + } + ); + + return result; + }, + + _getOne: function _getOne(prefName, type) { + let prefs = [ + { + name: prefName, + type: type, + }, + ]; + let values = this._get(prefs); + if (values.length != 1) { + throw new Error("Got too many values: " + values.length); + } + return values[0].value; + }, + + getBoolPref: function getBoolPref(prefName) { + return this._getOne(prefName, "bool"); + }, + + getCharPref: function getCharPref(prefName) { + return this._getOne(prefName, "string"); + }, + + getSetPref: function getSetPref(prefName) { + return this._getOne(prefName, "set"); + }, + + getIntPref: function getIntPref(prefName) { + return this._getOne(prefName, "int"); + }, + + /** + * Invoke `observer` after a change to the preference `domain` in + * the current branch. + * + * `observer` should implement the nsIObserver.observe interface. + */ + addObserver: function addObserver(domain, observer, holdWeak) { + if (!domain) { + throw new Error("domain must not be null"); + } + if (!observer) { + throw new Error("observer must not be null"); + } + if (holdWeak) { + throw new Error("Weak references not yet implemented."); + } + + if (!this._observers.hasOwnProperty(domain)) { + this._observers[domain] = []; + } + if (this._observers[domain].indexOf(observer) > -1) { + return; + } + + this._observers[domain].push(observer); + + this._updateAndroidListener(); + }, + + /** + * Do not invoke `observer` after a change to the preference + * `domain` in the current branch. + */ + removeObserver: function removeObserver(domain, observer) { + if (!this._observers.hasOwnProperty(domain)) { + return; + } + let index = this._observers[domain].indexOf(observer); + if (index < 0) { + return; + } + + this._observers[domain].splice(index, 1); + if (this._observers[domain].length < 1) { + delete this._observers[domain]; + } + + this._updateAndroidListener(); + }, + + _updateAndroidListener: function _updateAndroidListener() { + if (this._listening && Object.keys(this._observers).length < 1) { + this._uninstallAndroidListener(); + } + if (!this._listening && Object.keys(this._observers).length > 0) { + this._installAndroidListener(); + } + }, + + _installAndroidListener: function _installAndroidListener() { + if (this._listening) { + return; + } + this._listening = true; + + EventDispatcher.instance.registerListener( + this, + "SharedPreferences:Changed" + ); + + EventDispatcher.instance.sendRequest({ + type: "SharedPreferences:Observe", + enable: true, + scope: this._scope, + profileName: this._profileName, + branch: this._branch, + }); + }, + + onEvent: function _onEvent(event, msg, callback) { + if (event !== "SharedPreferences:Changed") { + return; + } + + if ( + msg.scope !== this._scope || + (this._scope === Scope.PROFILE && + msg.profileName !== this._profileName) || + (this._scope === Scope.GLOBAL && msg.branch !== this._branch) + ) { + return; + } + + if (!this._observers.hasOwnProperty(msg.key)) { + return; + } + + let observers = this._observers[msg.key]; + for (let obs of observers) { + obs.observe(obs, msg.key, msg.value); + } + }, + + _uninstallAndroidListener: function _uninstallAndroidListener() { + if (!this._listening) { + return; + } + this._listening = false; + + EventDispatcher.instance.unregisterListener( + this, + "SharedPreferences:Changed" + ); + + EventDispatcher.instance.sendRequest({ + type: "SharedPreferences:Observe", + enable: false, + scope: this._scope, + profileName: this._profileName, + branch: this._branch, + }); + }, +}); diff --git a/mobile/android/modules/Snackbars.jsm b/mobile/android/modules/Snackbars.jsm new file mode 100644 index 000000000000..77e33fb73ac5 --- /dev/null +++ b/mobile/android/modules/Snackbars.jsm @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["Snackbars"]; + +ChromeUtils.defineModuleGetter( + this, + "EventDispatcher", + "resource://gre/modules/Messaging.jsm" +); + +const LENGTH_INDEFINITE = -2; +const LENGTH_LONG = 0; +const LENGTH_SHORT = -1; + +var Snackbars = { + LENGTH_INDEFINITE: LENGTH_INDEFINITE, + LENGTH_LONG: LENGTH_LONG, + LENGTH_SHORT: LENGTH_SHORT, + + show: function(aMessage, aDuration, aOptions) { + // Takes care of the deprecated toast calls + if (typeof aDuration === "string") { + [aDuration, aOptions] = migrateToastIfNeeded(aDuration, aOptions); + } + + let msg = { + type: "Snackbar:Show", + message: aMessage, + duration: aDuration, + }; + + if (aOptions && aOptions.backgroundColor) { + msg.backgroundColor = aOptions.backgroundColor; + } + + if (aOptions && aOptions.action) { + msg.action = {}; + + if (aOptions.action.label) { + msg.action.label = aOptions.action.label; + } + + EventDispatcher.instance + .sendRequestForResult(msg) + .then(result => aOptions.action.callback()) + .catch(result => { + if (aOptions.action.rejection) { + aOptions.action.rejection(result); + } else if (result === null) { + /* The snackbar was dismissed without executing the callback, nothing to do here. */ + } else { + Cu.reportError(result); + } + }); + } else { + EventDispatcher.instance.sendRequest(msg); + } + }, +}; + +function migrateToastIfNeeded(aDuration, aOptions) { + let duration; + if (aDuration === "long") { + duration = LENGTH_LONG; + } else { + duration = LENGTH_SHORT; + } + + let options = {}; + if (aOptions && aOptions.button) { + options.action = { + label: aOptions.button.label, + callback: () => aOptions.button.callback(), + }; + } + return [duration, options]; +} diff --git a/mobile/android/modules/WebrtcUI.jsm b/mobile/android/modules/WebrtcUI.jsm new file mode 100644 index 000000000000..69da05a017d5 --- /dev/null +++ b/mobile/android/modules/WebrtcUI.jsm @@ -0,0 +1,452 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["WebrtcUI"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + DoorHanger: "resource://gre/modules/Prompt.jsm", + Notifications: "resource://gre/modules/Notifications.jsm", + RuntimePermissions: "resource://gre/modules/RuntimePermissions.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "MediaManagerService", + "@mozilla.org/mediaManagerService;1", + "nsIMediaManagerService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "ParentalControls", + "@mozilla.org/parental-controls-service;1", + "nsIParentalControlsService" +); + +var Strings = {}; + +XPCOMUtils.defineLazyGetter(Strings, "brand", _ => + Services.strings.createBundle("chrome://branding/locale/brand.properties") +); +XPCOMUtils.defineLazyGetter(Strings, "browser", _ => + Services.strings.createBundle("chrome://browser/locale/browser.properties") +); + +var WebrtcUI = { + _notificationId: null, + + // Add-ons can override stock permission behavior by doing: + // + // var stockObserve = WebrtcUI.observe; + // + // webrtcUI.observe = function(aSubject, aTopic, aData) { + // switch (aTopic) { + // case "PeerConnection:request": { + // // new code. + // break; + // ... + // default: + // return stockObserve.call(this, aSubject, aTopic, aData); + // + // See browser/modules/webrtcUI.jsm for details. + + observe: function(aSubject, aTopic, aData) { + if (aTopic === "getUserMedia:ask-device-permission") { + RuntimePermissions.waitForPermissions( + this._determineNeededRuntimePermissions(aData) + ).then(_ => { + Services.obs.notifyObservers( + aSubject, + "getUserMedia:got-device-permission" + ); + }); + } else if (aTopic === "getUserMedia:request") { + RuntimePermissions.checkPermissions( + this._determineNeededRuntimePermissions(aSubject) + ).then(permissionGranted => { + if (permissionGranted) { + WebrtcUI.handleGumRequest(aSubject, aTopic, aData); + } else { + Services.obs.notifyObservers( + null, + "getUserMedia:response:deny", + aSubject.callID + ); + } + }); + } else if (aTopic === "PeerConnection:request") { + this.handlePCRequest(aSubject, aTopic, aData); + } else if (aTopic === "recording-device-events") { + this.notify(); + } else if (aTopic === "VideoCapture:Paused") { + if (this._notificationId) { + Notifications.cancel(this._notificationId); + this._notificationId = null; + } + } else if (aTopic === "VideoCapture:Resumed") { + this.notify(); + } + }, + + notify: function() { + let windows = MediaManagerService.activeMediaCaptureWindows; + let count = windows.length; + let msg = {}; + if (count == 0) { + if (this._notificationId) { + Notifications.cancel(this._notificationId); + this._notificationId = null; + } + } else { + let notificationOptions = { + title: Strings.brand.GetStringFromName("brandShortName"), + when: null, // hide the date row + light: [0xff9500ff, 1000, 1000], + ongoing: true, + }; + + let cameraActive = false; + let audioActive = false; + for (let i = 0; i < count; i++) { + let win = windows.queryElementAt(i, Ci.nsIDOMWindow); + let hasCamera = {}; + let hasMicrophone = {}; + const screen = {}; + const window = {}; + const browser = {}; + MediaManagerService.mediaCaptureWindowState( + win, + hasCamera, + hasMicrophone, + screen, + window, + browser, + true + ); + if (hasCamera.value != MediaManagerService.STATE_NOCAPTURE) { + cameraActive = true; + } + if (hasMicrophone.value != MediaManagerService.STATE_NOCAPTURE) { + audioActive = true; + } + } + + if (cameraActive && audioActive) { + notificationOptions.message = Strings.browser.GetStringFromName( + "getUserMedia.sharingCameraAndMicrophone.message2" + ); + notificationOptions.icon = "drawable:alert_mic_camera"; + } else if (cameraActive) { + notificationOptions.message = Strings.browser.GetStringFromName( + "getUserMedia.sharingCamera.message2" + ); + notificationOptions.icon = "drawable:alert_camera"; + } else if (audioActive) { + notificationOptions.message = Strings.browser.GetStringFromName( + "getUserMedia.sharingMicrophone.message2" + ); + notificationOptions.icon = "drawable:alert_mic"; + } else { + // somethings wrong. lets throw + throw new Error("Couldn't find any cameras or microphones being used"); + } + + if (this._notificationId) { + Notifications.update(this._notificationId, notificationOptions); + } else { + this._notificationId = Notifications.create(notificationOptions); + } + if (count > 1) { + msg.count = count; + } + } + }, + + handlePCRequest: function handlePCRequest(aSubject, aTopic, aData) { + aSubject = aSubject.wrappedJSObject; + let { callID } = aSubject; + // Also available: windowID, isSecure, innerWindowID. For contentWindow do: + // + // let contentWindow = Services.wm.getOuterWindowWithId(windowID); + + Services.obs.notifyObservers(null, "PeerConnection:response:allow", callID); + }, + + handleGumRequest: function handleGumRequest(aSubject, aTopic, aData) { + let constraints = aSubject.getConstraints(); + let contentWindow = Services.wm.getOuterWindowWithId(aSubject.windowID); + + contentWindow.navigator.mozGetUserMediaDevices( + constraints, + function(devices) { + if (!ParentalControls.isAllowed(ParentalControls.CAMERA_MICROPHONE)) { + Services.obs.notifyObservers( + null, + "getUserMedia:response:deny", + aSubject.callID + ); + WebrtcUI.showBlockMessage(contentWindow, devices); + return; + } + + WebrtcUI.prompt( + contentWindow, + aSubject.callID, + constraints.audio, + constraints.video, + devices + ); + }, + function(error) { + Cu.reportError(error); + }, + aSubject.innerWindowID, + aSubject.callID + ); + }, + + getDeviceButtons: function(audioDevices, videoDevices, aCallID, aPrincipal) { + return [ + { + label: Strings.browser.GetStringFromName( + "getUserMedia.denyRequest.label" + ), + callback: function() { + Services.obs.notifyObservers( + null, + "getUserMedia:response:deny", + aCallID + ); + }, + }, + { + label: Strings.browser.GetStringFromName( + "getUserMedia.shareRequest.label" + ), + callback: function(checked /* ignored */, inputs) { + let allowedDevices = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + + let audioId = 0; + if (inputs && inputs.audioDevice != undefined) { + audioId = inputs.audioDevice; + } + if (audioDevices[audioId]) { + allowedDevices.appendElement(audioDevices[audioId]); + } + + let videoId = 0; + if (inputs && inputs.videoSource != undefined) { + videoId = inputs.videoSource; + } + if (videoDevices[videoId]) { + allowedDevices.appendElement(videoDevices[videoId]); + let perms = Services.perms; + // Although the lifetime is "session" it will be removed upon + // use so it's more of a one-shot. + perms.addFromPrincipal( + aPrincipal, + "MediaManagerVideo", + perms.ALLOW_ACTION, + perms.EXPIRE_SESSION + ); + } + + Services.obs.notifyObservers( + allowedDevices, + "getUserMedia:response:allow", + aCallID + ); + }, + positive: true, + }, + ]; + }, + + _determineNeededRuntimePermissions: function(aSubject) { + let permissions = []; + + let constraints; + if (typeof aSubject === "string") { + constraints = { + video: aSubject === "video" || aSubject === "all", + audio: aSubject === "audio" || aSubject === "all", + }; + } else { + constraints = aSubject.getConstraints(); + } + + if (constraints.video) { + permissions.push(RuntimePermissions.CAMERA); + } + if (constraints.audio) { + permissions.push(RuntimePermissions.RECORD_AUDIO); + } + + return permissions; + }, + + // Get a list of string names for devices. Ensures that none of the strings are blank + _getList: function(aDevices, aType) { + let defaultCount = 0; + return aDevices.map(function(device) { + // if this is a Camera input, convert the name to something readable + let res = /Camera\ \d+,\ Facing (front|back)/.exec(device.name); + if (res) { + return Strings.browser.GetStringFromName( + "getUserMedia." + aType + "." + res[1] + "Camera" + ); + } + + if (device.name.startsWith("&") && device.name.endsWith(";")) { + return Strings.browser.GetStringFromName( + device.name.substring(1, device.name.length - 1) + ); + } + + if (device.name.trim() == "") { + defaultCount++; + return Strings.browser.formatStringFromName( + "getUserMedia." + aType + ".default", + [defaultCount] + ); + } + return device.name; + }, this); + }, + + _addDevicesToOptions: function(aDevices, aType, aOptions) { + if (aDevices.length) { + // Filter out empty items from the list + let list = this._getList(aDevices, aType); + + if (list.length > 0) { + aOptions.inputs.push({ + id: aType, + type: "menulist", + label: Strings.browser.GetStringFromName( + "getUserMedia." + aType + ".prompt" + ), + values: list, + }); + } + } + }, + + showBlockMessage: function(aWindow, aDevices) { + let microphone = false; + let camera = false; + + for (let device of aDevices) { + device = device.QueryInterface(Ci.nsIMediaDevice); + if (device.type == "audioinput") { + microphone = true; + } else if (device.type == "videoinput") { + camera = true; + } + } + + let message; + if (microphone && !camera) { + message = Strings.browser.GetStringFromName( + "getUserMedia.blockedMicrophoneAccess" + ); + } else if (camera && !microphone) { + message = Strings.browser.GetStringFromName( + "getUserMedia.blockedCameraAccess" + ); + } else { + message = Strings.browser.GetStringFromName( + "getUserMedia.blockedCameraAndMicrophoneAccess" + ); + } + + DoorHanger.show(aWindow, message, "webrtc-blocked"); + }, + + getChromeWindow: function getChromeWindow(aWindow) { + return aWindow.docShell.rootTreeItem.domWindow; + }, + + prompt: function prompt( + aContentWindow, + aCallID, + aAudioRequested, + aVideoRequested, + aDevices + ) { + let audioDevices = []; + let videoDevices = []; + for (let device of aDevices) { + device = device.QueryInterface(Ci.nsIMediaDevice); + switch (device.type) { + case "audioinput": + if (aAudioRequested) { + audioDevices.push(device); + } + break; + case "videoinput": + if (aVideoRequested) { + videoDevices.push(device); + } + break; + } + } + + let requestType; + if (audioDevices.length && videoDevices.length) { + requestType = "CameraAndMicrophone"; + } else if (audioDevices.length) { + requestType = "Microphone"; + } else if (videoDevices.length) { + requestType = "Camera"; + } else { + return; + } + + let chromeWin = this.getChromeWindow(aContentWindow); + let principal = aContentWindow.document.nodePrincipal; + let host = principal.URI.host; + let requestor = + chromeWin.BrowserApp && chromeWin.BrowserApp.manifest + ? "'" + chromeWin.BrowserApp.manifest.name + "'" + : host; + let message = Strings.browser.formatStringFromName( + "getUserMedia.share" + requestType + ".message", + [requestor] + ); + + let options = { inputs: [] }; + if (videoDevices.length > 1 || audioDevices.length > 0) { + // videoSource is both the string used for l10n lookup and the object that will be returned + this._addDevicesToOptions(videoDevices, "videoSource", options); + } + + if (audioDevices.length > 1 || videoDevices.length > 0) { + this._addDevicesToOptions(audioDevices, "audioDevice", options); + } + + let buttons = this.getDeviceButtons( + audioDevices, + videoDevices, + aCallID, + principal + ); + + DoorHanger.show( + aContentWindow, + message, + "webrtc-request", + buttons, + options, + "WEBRTC" + ); + }, +}; diff --git a/mobile/android/modules/WebsiteMetadata.jsm b/mobile/android/modules/WebsiteMetadata.jsm new file mode 100644 index 000000000000..f0f1ef509a72 --- /dev/null +++ b/mobile/android/modules/WebsiteMetadata.jsm @@ -0,0 +1,527 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["WebsiteMetadata"]; + +ChromeUtils.defineModuleGetter( + this, + "EventDispatcher", + "resource://gre/modules/Messaging.jsm" +); + +var WebsiteMetadata = { + /** + * Asynchronously parse the document extract metadata. A 'Website:Metadata' event with the metadata + * will be sent. + */ + parseAsynchronously: function(doc) { + let metadata = getMetadata(doc, doc.location.href, { + image_url: metadataRules.image_url, + provider: metadataRules.provider, + description_length: metadataRules.description_length, + }); + + // No metadata was extracted, so don't bother sending it. + if (Object.keys(metadata).length === 0) { + return; + } + + let msg = { + type: "Website:Metadata", + location: doc.location.href, + hasImage: metadata.image_url && metadata.image_url !== "", + metadata: JSON.stringify(metadata), + }; + + EventDispatcher.instance.sendRequest(msg); + }, +}; + +// ################################################################################################# +// # Modified version of makeUrlAbsolute() to not import url parser library (and dependencies) +// ################################################################################################# + +function makeUrlAbsolute(context, relative) { + var a = context.doc.createElement("a"); + a.href = relative; + return a.href; +} + +// ################################################################################################# +// # page-metadata-parser +// # https://github.com/mozilla/page-metadata-parser/ +// # 61c58cbd0f0bf2153df832a388a79c66b288b98c +// ################################################################################################# + +function buildRuleset(name, rules, processors) { + const reversedRules = Array.from(rules).reverse(); + const builtRuleset = ruleset( + ...reversedRules.map(([query, handler], order) => + rule(dom(query), node => [ + { + score: order, + flavor: name, + notes: handler(node), + }, + ]) + ) + ); + + return (doc, context) => { + const kb = builtRuleset.score(doc); + const maxNode = kb.max(name); + + if (maxNode) { + let value = maxNode.flavors.get(name); + + if (processors) { + processors.forEach(processor => { + value = processor(value, context); + }); + } + + if (value) { + if (value.trim) { + return value.trim(); + } + return value; + } + } + }; +} + +const descriptionRules = [ + [ + 'meta[property="og:description"]', + node => node.element.getAttribute("content"), + ], + ['meta[name="description"]', node => node.element.getAttribute("content")], +]; + +const metadataRules = { + description: { + rules: descriptionRules, + }, + + description_length: { + rules: descriptionRules, + processors: [description => description.length], + }, + + icon_url: { + rules: [ + [ + 'link[rel="apple-touch-icon"]', + node => node.element.getAttribute("href"), + ], + [ + 'link[rel="apple-touch-icon-precomposed"]', + node => node.element.getAttribute("href"), + ], + ['link[rel="icon"]', node => node.element.getAttribute("href")], + ['link[rel="fluid-icon"]', node => node.element.getAttribute("href")], + ['link[rel="shortcut icon"]', node => node.element.getAttribute("href")], + ['link[rel="Shortcut Icon"]', node => node.element.getAttribute("href")], + ['link[rel="mask-icon"]', node => node.element.getAttribute("href")], + ], + processors: [(icon_url, context) => makeUrlAbsolute(context, icon_url)], + }, + + image_url: { + rules: [ + [ + 'meta[property="og:image:secure_url"]', + node => node.element.getAttribute("content"), + ], + [ + 'meta[property="og:image:url"]', + node => node.element.getAttribute("content"), + ], + [ + 'meta[property="og:image"]', + node => node.element.getAttribute("content"), + ], + [ + 'meta[property="twitter:image"]', + node => node.element.getAttribute("content"), + ], + ['meta[name="thumbnail"]', node => node.element.getAttribute("content")], + ], + processors: [(image_url, context) => makeUrlAbsolute(context, image_url)], + }, + + keywords: { + rules: [ + ['meta[name="keywords"]', node => node.element.getAttribute("content")], + ], + processors: [ + keywords => keywords.split(",").map(keyword => keyword.trim()), + ], + }, + + title: { + rules: [ + [ + 'meta[property="og:title"]', + node => node.element.getAttribute("content"), + ], + [ + 'meta[property="twitter:title"]', + node => node.element.getAttribute("content"), + ], + ['meta[name="hdl"]', node => node.element.getAttribute("content")], + ["title", node => node.element.text], + ], + }, + + type: { + rules: [ + [ + 'meta[property="og:type"]', + node => node.element.getAttribute("content"), + ], + ], + }, + + url: { + rules: [ + ['meta[property="og:url"]', node => node.element.getAttribute("content")], + ['link[rel="canonical"]', node => node.element.getAttribute("href")], + ], + }, + + provider: { + rules: [ + [ + 'meta[property="og:site_name"]', + node => node.element.getAttribute("content"), + ], + ], + }, +}; + +function getMetadata(doc, url, rules) { + const metadata = {}; + const context = { url, doc }; + const ruleSet = rules || metadataRules; + + Object.keys(ruleSet).map(metadataKey => { + const metadataRule = ruleSet[metadataKey]; + + if (Array.isArray(metadataRule.rules)) { + const builtRule = buildRuleset( + metadataKey, + metadataRule.rules, + metadataRule.processors + ); + metadata[metadataKey] = builtRule(doc, context); + } else { + metadata[metadataKey] = getMetadata(doc, url, metadataRule); + } + }); + + return metadata; +} + +// ################################################################################################# +// # Fathom dependencies resolved +// ################################################################################################# + +// const {forEach} = require('wu'); +function forEach(fn, obj) { + for (let x of obj) { + fn(x); + } +} + +function best(iterable, by, isBetter) { + let bestSoFar, bestKeySoFar; + let isFirst = true; + forEach(function(item) { + const key = by(item); + if (isBetter(key, bestKeySoFar) || isFirst) { + bestSoFar = item; + bestKeySoFar = key; + isFirst = false; + } + }, iterable); + if (isFirst) { + throw new Error("Tried to call best() on empty iterable"); + } + return bestSoFar; +} + +// const {max} = require('./utils'); +function max(iterable, by = identity) { + return best(iterable, by, (a, b) => a > b); +} + +// ################################################################################################# +// # Fathom +// # https://github.com/mozilla/fathom +// # cac59e470816f17fc1efd4a34437b585e3e451cd +// ################################################################################################# + +// Get a key of a map, first setting it to a default value if it's missing. +function getDefault(map, key, defaultMaker) { + if (map.has(key)) { + return map.get(key); + } + const defaultValue = defaultMaker(); + map.set(key, defaultValue); + return defaultValue; +} + +// Construct a filtration network of rules. +function ruleset(...rules) { + const rulesByInputFlavor = new Map(); // [someInputFlavor: [rule, ...]] + + // File each rule under its input flavor: + forEach( + rule => + getDefault(rulesByInputFlavor, rule.source.inputFlavor, () => []).push( + rule + ), + rules + ); + + return { + // Iterate over a DOM tree or subtree, building up a knowledgebase, a + // data structure holding scores and annotations for interesting + // elements. Return the knowledgebase. + // + // This is the "rank" portion of the rank-and-yank algorithm. + score: function(tree) { + const kb = knowledgebase(); + + // Introduce the whole DOM into the KB as flavor 'dom' to get + // things started: + const nonterminals = [[{ tree }, "dom"]]; // [[node, flavor], [node, flavor], ...] + + // While there are new facts, run the applicable rules over them to + // generate even newer facts. Repeat until everything's fully + // digested. Rules run in no particular guaranteed order. + while (nonterminals.length) { + const [inNode, inFlavor] = nonterminals.pop(); + for (let rule of getDefault(rulesByInputFlavor, inFlavor, () => [])) { + const outFacts = resultsOf(rule, inNode, inFlavor, kb); + for (let fact of outFacts) { + const outNode = kb.nodeForElement(fact.element); + + // No matter whether or not this flavor has been + // emitted before for this node, we multiply the score. + // We want to be able to add rules that refine the + // scoring of a node, without having to rewire the path + // of flavors that winds through the ruleset. + // + // 1 score per Node is plenty. That simplifies our + // data, our rankers, our flavor system (since we don't + // need to represent score axes), and our engine. If + // somebody wants more score axes, they can fake it + // themselves with notes, thus paying only for what + // they eat. (We can even provide functions that help + // with that.) Most rulesets will probably be concerned + // with scoring only 1 thing at a time anyway. So, + // rankers return a score multiplier + 0 or more new + // flavors with optional notes. Facts can never be + // deleted from the KB by rankers (or order would start + // to matter); after all, they're *facts*. + outNode.score *= fact.score; + + // Add a new annotation to a node--but only if there + // wasn't already one of the given flavor already + // there; otherwise there's no point. + // + // You might argue that we might want to modify an + // existing note here, but that would be a bad + // idea. Notes of a given flavor should be + // considered immutable once laid down. Otherwise, the + // order of execution of same-flavored rules could + // matter, hurting pluggability. Emit a new flavor and + // a new note if you want to do that. + // + // Also, choosing not to add a new fact to nonterminals + // when we're not adding a new flavor saves the work of + // running the rules against it, which would be + // entirely redundant and perform no new work (unless + // the rankers were nondeterministic, but don't do + // that). + if (!outNode.flavors.has(fact.flavor)) { + outNode.flavors.set(fact.flavor, fact.notes); + kb.indexNodeByFlavor(outNode, fact.flavor); // TODO: better encapsulation rather than indexing explicitly + nonterminals.push([outNode, fact.flavor]); + } + } + } + } + return kb; + }, + }; +} + +// Construct a container for storing and querying facts, where a fact has a +// flavor (used to dispatch further rules upon), a corresponding DOM element, a +// score, and some other arbitrary notes opaque to fathom. +function knowledgebase() { + const nodesByFlavor = new Map(); // Map{'texty' -> [NodeA], + // 'spiffy' -> [NodeA, NodeB]} + // NodeA = {element: , + // + // // Global nodewide score. Add + // // custom ones with notes if + // // you want. + // score: 8, + // + // // Flavors is a map of flavor names to notes: + // flavors: Map{'texty' -> {ownText: 'blah', + // someOtherNote: 'foo', + // someCustomScore: 10}, + // // This is an empty note: + // 'fluffy' -> undefined}} + const nodesByElement = new Map(); + + return { + // Return the "node" (our own data structure that we control) that + // corresponds to a given DOM element, creating one if necessary. + nodeForElement: function(element) { + return getDefault(nodesByElement, element, () => ({ + element, + score: 1, + flavors: new Map(), + })); + }, + + // Return the highest-scored node of the given flavor, undefined if + // there is none. + max: function(flavor) { + const nodes = nodesByFlavor.get(flavor); + return nodes === undefined ? undefined : max(nodes, node => node.score); + }, + + // Let the KB know that a new flavor has been added to an element. + indexNodeByFlavor: function(node, flavor) { + getDefault(nodesByFlavor, flavor, () => []).push(node); + }, + + nodesOfFlavor: function(flavor) { + return getDefault(nodesByFlavor, flavor, () => []); + }, + }; +} + +// Apply a rule (as returned by a call to rule()) to a fact, and return the +// new facts that result. +function resultsOf(rule, node, flavor, kb) { + // If more types of rule pop up someday, do fancier dispatching here. + return rule.source.flavor === "flavor" + ? resultsOfFlavorRule(rule, node, flavor) + : resultsOfDomRule(rule, node, kb); +} + +// Pull the DOM tree off the special property of the root "dom" fact, and query +// against it. +function* resultsOfDomRule(rule, specialDomNode, kb) { + // Use the special "tree" property of the special starting node: + const matches = specialDomNode.tree.querySelectorAll(rule.source.selector); + + for (let i = 0; i < matches.length; i++) { + // matches is a NodeList, which doesn't conform to iterator protocol + const element = matches[i]; + const newFacts = explicitFacts(rule.ranker(kb.nodeForElement(element))); + for (let fact of newFacts) { + if (fact.element === undefined) { + fact.element = element; + } + if (fact.flavor === undefined) { + throw new Error( + "Rankers of dom() rules must return a flavor in each fact. Otherwise, there is no way for that fact to be used later." + ); + } + yield fact; + } + } +} + +function* resultsOfFlavorRule(rule, node, flavor) { + const newFacts = explicitFacts(rule.ranker(node)); + + for (let fact of newFacts) { + // If the ranker didn't specify a different element, assume it's + // talking about the one we passed in: + if (fact.element === undefined) { + fact.element = node.element; + } + if (fact.flavor === undefined) { + fact.flavor = flavor; + } + yield fact; + } +} + +// Take the possibly abbreviated output of a ranker function, and make it +// explicitly an iterable with a defined score. +// +// Rankers can return undefined, which means "no facts", a single fact, or an +// array of facts. +function* explicitFacts(rankerResult) { + const array = + rankerResult === undefined + ? [] + : Array.isArray(rankerResult) + ? rankerResult + : [rankerResult]; + for (let fact of array) { + if (fact.score === undefined) { + fact.score = 1; + } + yield fact; + } +} + +// TODO: For the moment, a lot of responsibility is on the rankers to return a +// pretty big data structure of up to 4 properties. This is a bit verbose for +// an arrow function (as I hope we can use most of the time) and the usual case +// will probably be returning just a score multiplier. Make that case more +// concise. + +// TODO: It is likely that rankers should receive the notes of their input type +// as a 2nd arg, for brevity. + +// Return a condition that uses a DOM selector to find its matches from the +// original DOM tree. +// +// For consistency, Nodes will still be delivered to the transformers, but +// they'll have empty flavors and score = 1. +// +// Condition constructors like dom() and flavor() build stupid, introspectable +// objects that the query engine can read. They don't actually do the query +// themselves. That way, the query planner can be smarter than them, figuring +// out which indices to use based on all of them. (We'll probably keep a heap +// by each dimension's score and a hash by flavor name, for starters.) Someday, +// fancy things like this may be possible: rule(and(tag('p'), klass('snork')), +// ...) +function dom(selector) { + return { + flavor: "dom", + inputFlavor: "dom", + selector, + }; +} + +// Return a condition that discriminates on nodes of the knowledgebase by flavor. +function flavor(inputFlavor) { + return { + flavor: "flavor", + inputFlavor, + }; +} + +function rule(source, ranker) { + return { + source, + ranker, + }; +} diff --git a/mobile/android/modules/moz.build b/mobile/android/modules/moz.build index 70fe2a097993..f85e8a296c11 100644 --- a/mobile/android/modules/moz.build +++ b/mobile/android/modules/moz.build @@ -6,10 +6,40 @@ # Most files are General, a few exceptions with Files('**'): + BUG_COMPONENT = ('Firefox for Android', 'General') + +with Files('DownloadNotifications.jsm'): + BUG_COMPONENT = ('Firefox for Android', 'Download Manager') + +with Files('HomeProvider.jsm'): + BUG_COMPONENT = ('Firefox for Android', 'Data Providers') + +with Files('geckoview/**'): BUG_COMPONENT = ('GeckoView', 'General') DIRS += ['geckoview'] EXTRA_JS_MODULES += [ + 'Accounts.jsm', + 'ActionBarHandler.jsm', 'dbg-browser-actors.js', + 'DownloadNotifications.jsm', + 'FormAssistant.jsm', + 'FxAccountsWebChannel.jsm', + 'HelperApps.jsm', + 'Home.jsm', + 'HomeProvider.jsm', + 'InputWidgetHelper.jsm', + 'LightweightThemeConsumer.jsm', + 'MediaPlayerApp.jsm', + 'NetErrorHelper.jsm', + 'Notifications.jsm', + 'Prompt.jsm', + 'RuntimePermissions.jsm', + 'Sanitizer.jsm', + 'SelectHelper.jsm', + 'SharedPreferences.jsm', + 'Snackbars.jsm', + 'WebrtcUI.jsm', + 'WebsiteMetadata.jsm' ] diff --git a/mobile/android/tests/browser/chrome/chrome.ini b/mobile/android/tests/browser/chrome/chrome.ini index 2cc22efe35a0..cbf847979627 100644 --- a/mobile/android/tests/browser/chrome/chrome.ini +++ b/mobile/android/tests/browser/chrome/chrome.ini @@ -33,6 +33,7 @@ skip-if = debug [test_desktop_useragent.html] [test_device_search_engine.html] [test_get_last_visited.html] +[test_home_provider.html] [test_hidden_select_option.html] [test_identity_mode.html] [test_media_playback.html] @@ -58,6 +59,8 @@ support-files = skip-if = !debug # bug 1519580 [test_session_undo_close_tab.html] [test_session_zombification.html] +[test_settings_fontinflation.html] +[test_shared_preferences.html] [test_simple_discovery.html] [test_video_discovery.html] [test_web_channel.html] diff --git a/mobile/android/tests/browser/chrome/test_hidden_select_option.html b/mobile/android/tests/browser/chrome/test_hidden_select_option.html new file mode 100644 index 000000000000..2db7e85369cf --- /dev/null +++ b/mobile/android/tests/browser/chrome/test_hidden_select_option.html @@ -0,0 +1,99 @@ + + + + + + Test for Bug 1178722 + + + + + + + + + +

+ Mozilla Bug 1178722 + +

+ +
+
+ + diff --git a/mobile/android/tests/browser/chrome/test_home_provider.html b/mobile/android/tests/browser/chrome/test_home_provider.html new file mode 100644 index 000000000000..e4c73bd8ea60 --- /dev/null +++ b/mobile/android/tests/browser/chrome/test_home_provider.html @@ -0,0 +1,167 @@ + + + + + + Test for Bug 942288 + + + + + + +Mozilla Bug 942288 +
+Migrated from Robocop testHomeProvider +

+ +
+
+ + diff --git a/mobile/android/tests/browser/chrome/test_select_disabled.html b/mobile/android/tests/browser/chrome/test_select_disabled.html new file mode 100644 index 000000000000..5f1859f4c042 --- /dev/null +++ b/mobile/android/tests/browser/chrome/test_select_disabled.html @@ -0,0 +1,82 @@ + + + + + + Test for Bug 1263589 + + + + + + + + +

+ +

+ + + + + +
+ +
+
+ + +
+ + +
+ + +

+ +
+
+ + diff --git a/mobile/android/tests/browser/chrome/test_settings_fontinflation.html b/mobile/android/tests/browser/chrome/test_settings_fontinflation.html new file mode 100644 index 000000000000..102880e66486 --- /dev/null +++ b/mobile/android/tests/browser/chrome/test_settings_fontinflation.html @@ -0,0 +1,246 @@ + + + + + + Test for Bug 1328868 + + + + + + + + +Mozilla Bug 1328868 +

+ +
+
+ + diff --git a/mobile/android/tests/browser/chrome/test_shared_preferences.html b/mobile/android/tests/browser/chrome/test_shared_preferences.html new file mode 100644 index 000000000000..791ffa7ae804 --- /dev/null +++ b/mobile/android/tests/browser/chrome/test_shared_preferences.html @@ -0,0 +1,235 @@ + + + + + + Test for Bug 866271 + + + + + + + +Mozilla Bug 866271 +
+Migrated from Robocop testSharedPreferences +

+ +
+
+ + diff --git a/mobile/android/themes/core/jar.mn b/mobile/android/themes/core/jar.mn index 3fbec754ab35..b4e612ccb726 100644 --- a/mobile/android/themes/core/jar.mn +++ b/mobile/android/themes/core/jar.mn @@ -14,6 +14,7 @@ chrome.jar: skin/aboutPrivateBrowsing.css (aboutPrivateBrowsing.css) skin/aboutReader.css (aboutReader.css) skin/defines.css (defines.css) + skin/netError.css (netError.css) skin/spinner.css (spinner.css) % override chrome://global/skin/aboutReader.css chrome://browser/skin/aboutReader.css diff --git a/mobile/android/themes/core/netError.css b/mobile/android/themes/core/netError.css new file mode 100644 index 000000000000..205f660d8053 --- /dev/null +++ b/mobile/android/themes/core/netError.css @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +html, +body { + margin: 0; + padding: 0; + height: 100%; + --moz-vertical-spacing: 10px; + --moz-background-height: 32px; +} + +body { + /* Add a set of stripes at the top of pages */ + background-image: linear-gradient(-45deg, #dfe8ee, #dfe8ee 33%, + #ecf0f3 33%, #ecf0f3 66%, + #dfe8ee 66%, #dfe8ee); + background-size: 64px var(--moz-background-height); + background-repeat: repeat-x; + + background-color: #f1f1f1; + padding: 0 20px; + + font-weight: 300; + font-size: 13px; + -moz-text-size-adjust: none; + font-family: sans-serif; +} + + +ul { + /* Shove the list indicator so that its left aligned, but use outside so that text + * doesn't don't wrap the text around it */ + padding: 0 1em; + margin: 0; + list-style: round outside none; +} + +#errorShortDesc, +li:not(:last-of-type) { + /* Margins between the li and buttons below it won't be collapsed. Remove the bottom margin here. */ + margin: var(--moz-vertical-spacing) 0; +} + +li > button { + /* Removing the normal padding on the li so this stretched edge to edge. */ + margin-left: -1em; + margin-right: -1em; + width: calc(100% + 2em); +} + +/* Push the #ignoreWarningButton to the bottom on the blocked site page */ +.blockedsite > #errorPageContainer > #errorLongContent { + flex: 1; +} + +h1 { + margin: 0; + /* Since this has an underline, use padding for vertical spacing rather than margin */ + padding: var(--moz-vertical-spacing) 0; + font-weight: 300; + border-bottom: 1px solid #e0e2e5; +} + +h2 { + font-size: small; + padding: 0; + margin: var(--moz-vertical-spacing) 0; +} + +p { + margin: var(--moz-vertical-spacing) 0; +} + +button { + /* Force buttons to display: block here to try and enfoce collapsing margins */ + display: block; + width: 100%; + border: none; + padding: 1rem; + font-family: sans-serif; + background-color: #e0e2e5; + font-weight: 300; + border-radius: 2px; + background-image: none; + margin: var(--moz-vertical-spacing) 0 0; +} + +button.inProgress { + background-image: linear-gradient(-45deg, #dfe8ee, #dfe8ee 33%, + #ecf0f3 33%, #ecf0f3 66%, + #dfe8ee 66%, #dfe8ee); + background-size: 37px 5px; + background-repeat: repeat-x; + animation: progress 6s linear infinite; +} + +@keyframes progress { + from { background-position: 0 100%; } + to { background-position: 100% 100%; } +} + +.certerror { + background-image: linear-gradient(-45deg, #f0d000, #f0d000 33%, + #fedc00 33%, #fedc00 66%, + #f0d000 66%, #f0d000); +} + +.blockedsite { + background-image: linear-gradient(-45deg, #9b2e2e, #9b2e2e 33%, + #a83232 33%, #a83232 66%, + #9b2e2e 66%, #9b2e2e); + background-color: #b14646; + color: white; +} + +#errorPageContainer { + /* If the page is greater than 550px center the content. + * This number should be kept in sync with the media query for tablets below */ + max-width: 550px; + margin: 0 auto; + transform: translateY(var(--moz-background-height)); + padding-bottom: var(--moz-vertical-spacing); + + min-height: calc(100% - var(--moz-background-height) - var(--moz-vertical-spacing)); + display: flex; + flex-direction: column; +} + +/* Expanders have a structure of + *
+ *

Title

+ *

Content

+ *
+ * + * This shows an arrow to the right of the h2 element, and hides the content when collapsed="true". */ +.expander { + margin: var(--moz-vertical-spacing) 0; + background-image: url("chrome://geckoview/skin/images/dropmarker.svg"); + background-repeat: no-repeat; + /* dropmarker.svg is 10x7. Ensure that its centered in the middle of an 18x18 box */ + background-position: 3px 5.5px; + background-size: 10px 7px; + padding-left: 18px; +} + +div[collapsed="true"] > .expander { + background-image: url("chrome://geckoview/skin/images/dropmarker-right.svg"); + /* dropmarker.svg is 7x10. Ensure that its centered in the middle of an 18x18 box */ + background-size: 7px 10px; + background-position: 5.5px 4px; +} + +div[hidden] > .expander, +div[hidden] > .expander + *, +div[collapsed="true"] > .expander + * { + display: none; +} + +.blockedsite h1 { + border-bottom-color: #9b2e2e; +} + +.blockedsite button { + background-color: #9b2e2e; + color: white; +} + +/* Style warning button to look like a small text link in the + bottom. This is preferable to just using a text link + since there is already a mechanism in browser.js for trapping + oncommand events from unprivileged chrome pages (ErrorPageEventHandler).*/ +#ignoreWarningButton { + width: calc(100% + 40px); + -moz-appearance: none; + background: #b14646; + border: none; + text-decoration: underline; + margin: 0; + margin-inline-start: -20px; + font-size: smaller; + border-radius: 0; +} + +#advisory_provider { + color: white; + text-decoration: underline; +} + +/* On large screen devices (hopefully a 7+ inch tablet, we already center content (see #errorPageContainer above). + Apply tablet specific styles here */ +@media (min-width: 550px) { + button { + min-width: 160px; + width: auto; + } + + /* If the tablet is tall as well, add some padding to make content feel a bit more centered */ + @media (min-height: 550px) { + #errorPageContainer { + padding-top: 64px; + min-height: calc(100% - 64px); + } + } +} + +#searchbox { + padding: 0; + display: flex; + margin: var(--moz-vertical-spacing) -1em; +} + +#searchbox > input { + flex: 3; + padding: 0em 3em 0em 1em; + width: 100%; + border: none; + font-family: sans-serif; + background-image: none; + background-color: white; + border-radius-top-right: none; + border-radius-bottom-right: none; +} + +#searchbox > button { + flex: 1; + margin: 0; + width: auto; +} +