diff options
Diffstat (limited to 'mobile/android/chrome/content/browser.js')
-rw-r--r-- | mobile/android/chrome/content/browser.js | 6999 |
1 files changed, 6999 insertions, 0 deletions
diff --git a/mobile/android/chrome/content/browser.js b/mobile/android/chrome/content/browser.js new file mode 100644 index 000000000..b00e1af15 --- /dev/null +++ b/mobile/android/chrome/content/browser.js @@ -0,0 +1,6999 @@ +// -*- 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 Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; +var Cr = Components.results; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/AddonManager.jsm"); +Cu.import("resource://gre/modules/AsyncPrefs.jsm"); +Cu.import("resource://gre/modules/DelayedInit.jsm"); + +if (AppConstants.ACCESSIBILITY) { + XPCOMUtils.defineLazyModuleGetter(this, "AccessFu", + "resource://gre/modules/accessibility/AccessFu.jsm"); +} + +XPCOMUtils.defineLazyModuleGetter(this, "SpatialNavigation", + "resource://gre/modules/SpatialNavigation.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadNotifications", + "resource://gre/modules/DownloadNotifications.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "JNI", + "resource://gre/modules/JNI.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", + "resource://gre/modules/UITelemetry.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Messaging", + "resource://gre/modules/Messaging.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UserAgentOverrides", + "resource://gre/modules/UserAgentOverrides.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerContent", + "resource://gre/modules/LoginManagerContent.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerParent", + "resource://gre/modules/LoginManagerParent.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "SafeBrowsing", + "resource://gre/modules/SafeBrowsing.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Sanitizer", + "resource://gre/modules/Sanitizer.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Prompt", + "resource://gre/modules/Prompt.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "HelperApps", + "resource://gre/modules/HelperApps.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "SSLExceptions", + "resource://gre/modules/SSLExceptions.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FormHistory", + "resource://gre/modules/FormHistory.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "uuidgen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +XPCOMUtils.defineLazyServiceGetter(this, "Profiler", + "@mozilla.org/tools/profiler;1", + "nsIProfiler"); + +XPCOMUtils.defineLazyModuleGetter(this, "SimpleServiceDiscovery", + "resource://gre/modules/SimpleServiceDiscovery.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "CharsetMenu", + "resource://gre/modules/CharsetMenu.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetErrorHelper", + "resource://gre/modules/NetErrorHelper.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils", + "resource://gre/modules/PermissionsUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Preferences", + "resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "SharedPreferences", + "resource://gre/modules/SharedPreferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Notifications", + "resource://gre/modules/Notifications.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "RuntimePermissions", "resource://gre/modules/RuntimePermissions.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "WebsiteMetadata", "resource://gre/modules/WebsiteMetadata.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "FontEnumerator", + "@mozilla.org/gfx/fontenumerator;1", + "nsIFontEnumerator"); + +var lazilyLoadedBrowserScripts = [ + ["SelectHelper", "chrome://browser/content/SelectHelper.js"], + ["InputWidgetHelper", "chrome://browser/content/InputWidgetHelper.js"], + ["MasterPassword", "chrome://browser/content/MasterPassword.js"], + ["PluginHelper", "chrome://browser/content/PluginHelper.js"], + ["OfflineApps", "chrome://browser/content/OfflineApps.js"], + ["Linkifier", "chrome://browser/content/Linkify.js"], + ["CastingApps", "chrome://browser/content/CastingApps.js"], + ["RemoteDebugger", "chrome://browser/content/RemoteDebugger.js"], +]; +if (!AppConstants.RELEASE_OR_BETA) { + lazilyLoadedBrowserScripts.push( + ["WebcompatReporter", "chrome://browser/content/WebcompatReporter.js"]); +} + +lazilyLoadedBrowserScripts.forEach(function (aScript) { + let [name, script] = aScript; + XPCOMUtils.defineLazyGetter(window, name, function() { + let sandbox = {}; + Services.scriptloader.loadSubScript(script, sandbox); + return sandbox[name]; + }); +}); + +var lazilyLoadedObserverScripts = [ + ["MemoryObserver", ["memory-pressure", "Memory:Dump"], "chrome://browser/content/MemoryObserver.js"], + ["ConsoleAPI", ["console-api-log-event"], "chrome://browser/content/ConsoleAPI.js"], + ["FindHelper", ["FindInPage:Opened", "FindInPage:Closed", "Tab:Selected"], "chrome://browser/content/FindHelper.js"], + ["PermissionsHelper", ["Permissions:Check", "Permissions:Get", "Permissions:Clear"], "chrome://browser/content/PermissionsHelper.js"], + ["FeedHandler", ["Feeds:Subscribe"], "chrome://browser/content/FeedHandler.js"], + ["Feedback", ["Feedback:Show"], "chrome://browser/content/Feedback.js"], + ["EmbedRT", ["GeckoView:ImportScript"], "chrome://browser/content/EmbedRT.js"], + ["Reader", ["Reader:AddToCache", "Reader:RemoveFromCache"], "chrome://browser/content/Reader.js"], + ["PrintHelper", ["Print:PDF"], "chrome://browser/content/PrintHelper.js"], +]; + +lazilyLoadedObserverScripts.push( +["ActionBarHandler", ["TextSelection:Get", "TextSelection:Action", "TextSelection:End"], + "chrome://browser/content/ActionBarHandler.js"] +); + +if (AppConstants.MOZ_WEBRTC) { + lazilyLoadedObserverScripts.push( + ["WebrtcUI", ["getUserMedia:request", + "PeerConnection:request", + "recording-device-events", + "VideoCapture:Paused", + "VideoCapture:Resumed"], "chrome://browser/content/WebrtcUI.js"]) +} + +lazilyLoadedObserverScripts.forEach(function (aScript) { + let [name, notifications, script] = aScript; + XPCOMUtils.defineLazyGetter(window, name, function() { + let sandbox = {}; + Services.scriptloader.loadSubScript(script, sandbox); + return sandbox[name]; + }); + let observer = (s, t, d) => { + Services.obs.removeObserver(observer, t); + Services.obs.addObserver(window[name], t, false); + window[name].observe(s, t, d); // Explicitly notify new observer + }; + notifications.forEach((notification) => { + Services.obs.addObserver(observer, notification, false); + }); +}); + +// Lazily-loaded browser scripts that use message listeners. +[ + ["Reader", [ + ["Reader:AddToCache", false], + ["Reader:RemoveFromCache", false], + ["Reader:ArticleGet", false], + ["Reader:DropdownClosed", true], // 'true' allows us to survive mid-air cycle-collection. + ["Reader:DropdownOpened", false], + ["Reader:FaviconRequest", false], + ["Reader:ToolbarHidden", false], + ["Reader:SystemUIVisibility", false], + ["Reader:UpdateReaderButton", false], + ], "chrome://browser/content/Reader.js"], +].forEach(aScript => { + let [name, messages, script] = aScript; + XPCOMUtils.defineLazyGetter(window, name, function() { + let sandbox = {}; + Services.scriptloader.loadSubScript(script, sandbox); + return sandbox[name]; + }); + + let mm = window.getGroupMessageManager("browsers"); + let listener = (message) => { + mm.removeMessageListener(message.name, listener); + let listenAfterClose = false; + for (let [name, laClose] of messages) { + if (message.name === name) { + listenAfterClose = laClose; + break; + } + } + + mm.addMessageListener(message.name, window[name], listenAfterClose); + window[name].receiveMessage(message); + }; + + messages.forEach((message) => { + let [name, listenAfterClose] = message; + mm.addMessageListener(name, listener, listenAfterClose); + }); +}); + +// Lazily-loaded JS modules that use observer notifications +[ + ["Home", ["HomeBanner:Get", "HomePanels:Get", "HomePanels:Authenticate", "HomePanels:RefreshView", + "HomePanels:Installed", "HomePanels:Uninstalled"], "resource://gre/modules/Home.jsm"], +].forEach(module => { + let [name, notifications, resource] = module; + XPCOMUtils.defineLazyModuleGetter(this, name, resource); + let observer = (s, t, d) => { + Services.obs.removeObserver(observer, t); + Services.obs.addObserver(this[name], t, false); + this[name].observe(s, t, d); // Explicitly notify new observer + }; + notifications.forEach(notification => { + Services.obs.addObserver(observer, notification, false); + }); +}); + +XPCOMUtils.defineLazyServiceGetter(this, "Haptic", + "@mozilla.org/widget/hapticfeedback;1", "nsIHapticFeedback"); + +XPCOMUtils.defineLazyServiceGetter(this, "ParentalControls", + "@mozilla.org/parental-controls-service;1", "nsIParentalControlsService"); + +XPCOMUtils.defineLazyServiceGetter(this, "DOMUtils", + "@mozilla.org/inspector/dom-utils;1", "inIDOMUtils"); + +XPCOMUtils.defineLazyServiceGetter(window, "URIFixup", + "@mozilla.org/docshell/urifixup;1", "nsIURIFixup"); + +if (AppConstants.MOZ_WEBRTC) { + XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService", + "@mozilla.org/mediaManagerService;1", "nsIMediaManagerService"); +} + +XPCOMUtils.defineLazyModuleGetter(this, "Log", + "resource://gre/modules/AndroidLog.jsm", "AndroidLog"); + +// Define the "dump" function as a binding of the Log.d function so it specifies +// the "debug" priority and a log tag. +function dump(msg) { + Log.d("Browser", msg); +} + +const kStateActive = 0x00000001; // :active pseudoclass for elements + +const kXLinkNamespace = "http://www.w3.org/1999/xlink"; + +function fuzzyEquals(a, b) { + return (Math.abs(a - b) < 1e-6); +} + +XPCOMUtils.defineLazyGetter(this, "ContentAreaUtils", function() { + let ContentAreaUtils = {}; + Services.scriptloader.loadSubScript("chrome://global/content/contentAreaUtils.js", ContentAreaUtils); + return ContentAreaUtils; +}); + +XPCOMUtils.defineLazyModuleGetter(this, "Rect", "resource://gre/modules/Geometry.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Point", "resource://gre/modules/Geometry.jsm"); + +function resolveGeckoURI(aURI) { + if (!aURI) + throw "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, null, null)).spec; + } else if (aURI.startsWith("resource://")) { + let handler = Services.io.getProtocolHandler("resource").QueryInterface(Ci.nsIResProtocolHandler); + return handler.resolveURI(Services.io.newURI(aURI, null, null)); + } + return aURI; +} + +/** + * Cache of commonly used string bundles. + */ +var Strings = { + init: function () { + XPCOMUtils.defineLazyGetter(Strings, "brand", () => Services.strings.createBundle("chrome://branding/locale/brand.properties")); + XPCOMUtils.defineLazyGetter(Strings, "browser", () => Services.strings.createBundle("chrome://browser/locale/browser.properties")); + XPCOMUtils.defineLazyGetter(Strings, "reader", () => Services.strings.createBundle("chrome://global/locale/aboutReader.properties")); + }, + + flush: function () { + Services.strings.flushBundles(); + this.init(); + }, +}; + +Strings.init(); + +const kFormHelperModeDisabled = 0; +const kFormHelperModeEnabled = 1; +const kFormHelperModeDynamic = 2; // disabled on tablets +const kMaxHistoryListSize = 50; + +function InitLater(fn, object, name) { + return DelayedInit.schedule(fn, object, name, 15000 /* 15s max wait */); +} + +var BrowserApp = { + _tabs: [], + _selectedTab: null, + + get isTablet() { + let sysInfo = Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2); + delete this.isTablet; + return this.isTablet = sysInfo.get("tablet"); + }, + + get isOnLowMemoryPlatform() { + let memory = Cc["@mozilla.org/xpcom/memory-service;1"].getService(Ci.nsIMemory); + delete this.isOnLowMemoryPlatform; + return this.isOnLowMemoryPlatform = memory.isLowMemoryPlatform(); + }, + + deck: null, + + startup: function startup() { + window.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow = new nsBrowserAccess(); + dump("zerdatime " + Date.now() + " - browser chrome startup finished."); + Services.obs.notifyObservers(this.browser, "BrowserChrome:Ready", null); + + this.deck = document.getElementById("browsers"); + + BrowserEventHandler.init(); + + ViewportHandler.init(); + + Services.androidBridge.browserApp = this; + + Services.obs.addObserver(this, "Locale:OS", false); + Services.obs.addObserver(this, "Locale:Changed", false); + Services.obs.addObserver(this, "Tab:Load", false); + Services.obs.addObserver(this, "Tab:Selected", false); + Services.obs.addObserver(this, "Tab:Closed", false); + Services.obs.addObserver(this, "Session:Back", false); + Services.obs.addObserver(this, "Session:Forward", false); + Services.obs.addObserver(this, "Session:Navigate", false); + Services.obs.addObserver(this, "Session:Reload", false); + Services.obs.addObserver(this, "Session:Stop", false); + Services.obs.addObserver(this, "SaveAs:PDF", false); + Services.obs.addObserver(this, "Browser:Quit", false); + Services.obs.addObserver(this, "ScrollTo:FocusedInput", false); + Services.obs.addObserver(this, "Sanitize:ClearData", false); + Services.obs.addObserver(this, "FullScreen:Exit", false); + Services.obs.addObserver(this, "Passwords:Init", false); + Services.obs.addObserver(this, "FormHistory:Init", false); + Services.obs.addObserver(this, "android-get-pref", false); + Services.obs.addObserver(this, "android-set-pref", false); + Services.obs.addObserver(this, "gather-telemetry", false); + Services.obs.addObserver(this, "keyword-search", false); + Services.obs.addObserver(this, "sessionstore-state-purge-complete", false); + Services.obs.addObserver(this, "Fonts:Reload", false); + Services.obs.addObserver(this, "Vibration:Request", false); + + Messaging.addListener(this.getHistory.bind(this), "Session:GetHistory"); + + window.addEventListener("fullscreen", function() { + Messaging.sendRequest({ + type: window.fullScreen ? "ToggleChrome:Hide" : "ToggleChrome:Show" + }); + }, false); + + window.addEventListener("fullscreenchange", (e) => { + // This event gets fired on the document and its entire ancestor chain + // of documents. When enabling fullscreen, it is fired on the top-level + // document first and goes down; when disabling the order is reversed + // (per spec). This means the last event on enabling will be for the innermost + // document, which will have fullscreenElement set correctly. + let doc = e.target; + Messaging.sendRequest({ + type: doc.fullscreenElement ? "DOMFullScreen:Start" : "DOMFullScreen:Stop", + rootElement: doc.fullscreenElement == doc.documentElement + }); + + if (this.fullscreenTransitionTab) { + // Tab selection has changed during a fullscreen transition, handle it now. + let tab = this.fullscreenTransitionTab; + this.fullscreenTransitionTab = null; + this._handleTabSelected(tab); + } + }, false); + + NativeWindow.init(); + FormAssistant.init(); + IndexedDB.init(); + XPInstallObserver.init(); + CharacterEncoding.init(); + ActivityObserver.init(); + RemoteDebugger.init(); + UserAgentOverrides.init(); + DesktopUserAgent.init(); + Distribution.init(); + Tabs.init(); + SearchEngines.init(); + Experiments.init(); + + // XXX maybe we don't do this if the launch was kicked off from external + Services.io.offline = false; + + // Broadcast a UIReady message so add-ons know we are finished with startup + let event = document.createEvent("Events"); + event.initEvent("UIReady", true, false); + window.dispatchEvent(event); + + if (this._startupStatus) { + this.onAppUpdated(); + } + + if (!ParentalControls.isAllowed(ParentalControls.INSTALL_EXTENSION)) { + // Disable extension installs + Services.prefs.setIntPref("extensions.enabledScopes", 1); + Services.prefs.setIntPref("extensions.autoDisableScopes", 1); + Services.prefs.setBoolPref("xpinstall.enabled", false); + } else if (ParentalControls.parentalControlsEnabled) { + Services.prefs.clearUserPref("extensions.enabledScopes"); + Services.prefs.clearUserPref("extensions.autoDisableScopes"); + Services.prefs.setBoolPref("xpinstall.enabled", true); + } + + if (ParentalControls.parentalControlsEnabled) { + let isBlockListEnabled = ParentalControls.isAllowed(ParentalControls.BLOCK_LIST); + Services.prefs.setBoolPref("browser.safebrowsing.forbiddenURIs.enabled", isBlockListEnabled); + Services.prefs.setBoolPref("browser.safebrowsing.allowOverride", !isBlockListEnabled); + + let isTelemetryEnabled = ParentalControls.isAllowed(ParentalControls.TELEMETRY); + Services.prefs.setBoolPref("toolkit.telemetry.enabled", isTelemetryEnabled); + + let isHealthReportEnabled = ParentalControls.isAllowed(ParentalControls.HEALTH_REPORT); + SharedPreferences.forApp().setBoolPref("android.not_a_preference.healthreport.uploadEnabled", isHealthReportEnabled); + } + + let sysInfo = Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2); + if (sysInfo.get("version") < 16) { + let defaults = Services.prefs.getDefaultBranch(null); + defaults.setBoolPref("media.autoplay.enabled", false); + } + + InitLater(() => { + // The order that context menu items are added is important + // Make sure the "Open in App" context menu item appears at the bottom of the list + this.initContextMenu(); + ExternalApps.init(); + }, NativeWindow, "contextmenus"); + + if (AppConstants.ACCESSIBILITY) { + InitLater(() => AccessFu.attach(window), window, "AccessFu"); + } + + // Don't delay loading content.js because when we restore reader mode tabs, + // we require the reader mode scripts in content.js right away. + let mm = window.getGroupMessageManager("browsers"); + mm.loadFrameScript("chrome://browser/content/content.js", true); + + // We can't delay registering WebChannel listeners: if the first page is + // about:accounts, which can happen when starting the Firefox Account flow + // from the first run experience, or via the Firefox Account Status + // Activity, we can and do miss messages from the fxa-content-server. + // However, we never allow suitably restricted profiles from listening to + // fxa-content-server messages. + if (ParentalControls.isAllowed(ParentalControls.MODIFY_ACCOUNTS)) { + console.log("browser.js: loading Firefox Accounts WebChannel"); + Cu.import("resource://gre/modules/FxAccountsWebChannel.jsm"); + EnsureFxAccountsWebChannel(); + } else { + console.log("browser.js: not loading Firefox Accounts WebChannel; this profile cannot connect to Firefox Accounts."); + } + + // Notify Java that Gecko has loaded. + Messaging.sendRequest({ type: "Gecko:Ready" }); + + this.deck.addEventListener("DOMContentLoaded", function BrowserApp_delayedStartup() { + BrowserApp.deck.removeEventListener("DOMContentLoaded", BrowserApp_delayedStartup, false); + + InitLater(() => Cu.import("resource://gre/modules/NotificationDB.jsm")); + InitLater(() => Cu.import("resource://gre/modules/PresentationDeviceInfoManager.jsm")); + + InitLater(() => Services.obs.notifyObservers(window, "browser-delayed-startup-finished", "")); + InitLater(() => Messaging.sendRequest({ type: "Gecko:DelayedStartup" })); + + if (!AppConstants.RELEASE_OR_BETA) { + InitLater(() => WebcompatReporter.init()); + } + + // Collect telemetry data. + // We do this at startup because we want to move away from "gather-telemetry" (bug 1127907) + InitLater(() => { + Telemetry.addData("FENNEC_TRACKING_PROTECTION_STATE", parseInt(BrowserApp.getTrackingProtectionState())); + Telemetry.addData("ZOOMED_VIEW_ENABLED", Services.prefs.getBoolPref("ui.zoomedview.enabled")); + }); + + InitLater(() => LightWeightThemeWebInstaller.init()); + InitLater(() => SpatialNavigation.init(BrowserApp.deck, null), window, "SpatialNavigation"); + InitLater(() => CastingApps.init(), window, "CastingApps"); + InitLater(() => Services.search.init(), Services, "search"); + InitLater(() => DownloadNotifications.init(), window, "DownloadNotifications"); + + // Bug 778855 - Perf regression if we do this here. To be addressed in bug 779008. + InitLater(() => SafeBrowsing.init(), window, "SafeBrowsing"); + + InitLater(() => Cc["@mozilla.org/login-manager;1"].getService(Ci.nsILoginManager)); + InitLater(() => LoginManagerParent.init(), window, "LoginManagerParent"); + + }, false); + + // Pass caret StateChanged events to ActionBarHandler. + window.addEventListener("mozcaretstatechanged", e => { + ActionBarHandler.caretStateChangedHandler(e); + }, /* useCapture = */ true, /* wantsUntrusted = */ false); + }, + + get _startupStatus() { + delete this._startupStatus; + + let savedMilestone = null; + try { + savedMilestone = Services.prefs.getCharPref("browser.startup.homepage_override.mstone"); + } catch (e) { + } + let ourMilestone = AppConstants.MOZ_APP_VERSION; + this._startupStatus = ""; + if (ourMilestone != savedMilestone) { + Services.prefs.setCharPref("browser.startup.homepage_override.mstone", ourMilestone); + this._startupStatus = savedMilestone ? "upgrade" : "new"; + } + + return this._startupStatus; + }, + + /** + * Pass this a locale string, such as "fr" or "es_ES". + */ + setLocale: function (locale) { + console.log("browser.js: requesting locale set: " + locale); + Messaging.sendRequest({ type: "Locale:Set", locale: locale }); + }, + + initContextMenu: function () { + // We pass a thunk in place of a raw label string. This allows the + // context menu to automatically accommodate locale changes without + // having to be rebuilt. + let stringGetter = name => () => Strings.browser.GetStringFromName(name); + + // TODO: These should eventually move into more appropriate classes + NativeWindow.contextmenus.add(stringGetter("contextmenu.openInNewTab"), + NativeWindow.contextmenus.linkOpenableNonPrivateContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_open_new_tab"); + UITelemetry.addEvent("loadurl.1", "contextmenu", null); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + ContentAreaUtils.urlSecurityCheck(url, aTarget.ownerDocument.nodePrincipal); + let tab = BrowserApp.addTab(url, { selected: false, parentId: BrowserApp.selectedTab.id }); + + let newtabStrings = Strings.browser.GetStringFromName("newtabpopup.opened"); + let label = PluralForm.get(1, newtabStrings).replace("#1", 1); + let buttonLabel = Strings.browser.GetStringFromName("newtabpopup.switch"); + + Snackbars.show(label, Snackbars.LENGTH_LONG, { + action: { + label: buttonLabel, + callback: () => { BrowserApp.selectTab(tab); }, + } + }); + }); + + let showOpenInPrivateTab = true; + if ("@mozilla.org/parental-controls-service;1" in Cc) { + let pc = Cc["@mozilla.org/parental-controls-service;1"].createInstance(Ci.nsIParentalControlsService); + showOpenInPrivateTab = pc.isAllowed(Ci.nsIParentalControlsService.PRIVATE_BROWSING); + } + + if (showOpenInPrivateTab) { + NativeWindow.contextmenus.add(stringGetter("contextmenu.openInPrivateTab"), + NativeWindow.contextmenus.linkOpenableContext, + function (aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_open_new_tab"); + UITelemetry.addEvent("loadurl.1", "contextmenu", null); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + ContentAreaUtils.urlSecurityCheck(url, aTarget.ownerDocument.nodePrincipal); + let tab = BrowserApp.addTab(url, {selected: false, parentId: BrowserApp.selectedTab.id, isPrivate: true}); + + let newtabStrings = Strings.browser.GetStringFromName("newprivatetabpopup.opened"); + let label = PluralForm.get(1, newtabStrings).replace("#1", 1); + let buttonLabel = Strings.browser.GetStringFromName("newtabpopup.switch"); + Snackbars.show(label, Snackbars.LENGTH_LONG, { + action: { + label: buttonLabel, + callback: () => { BrowserApp.selectTab(tab); }, + } + }); + }); + } + + NativeWindow.contextmenus.add(stringGetter("contextmenu.copyLink"), + NativeWindow.contextmenus.linkCopyableContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_copy_link"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + NativeWindow.contextmenus._copyStringToDefaultClipboard(url); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.copyEmailAddress"), + NativeWindow.contextmenus.emailLinkContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_copy_email"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + let emailAddr = NativeWindow.contextmenus._stripScheme(url); + NativeWindow.contextmenus._copyStringToDefaultClipboard(emailAddr); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.copyPhoneNumber"), + NativeWindow.contextmenus.phoneNumberLinkContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_copy_phone"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + let phoneNumber = NativeWindow.contextmenus._stripScheme(url); + NativeWindow.contextmenus._copyStringToDefaultClipboard(phoneNumber); + }); + + NativeWindow.contextmenus.add({ + label: stringGetter("contextmenu.shareLink"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, // Show above HTML5 menu items + selector: NativeWindow.contextmenus._disableRestricted("SHARE", NativeWindow.contextmenus.linkShareableContext), + showAsActions: function(aElement) { + return { + title: aElement.textContent.trim() || aElement.title.trim(), + uri: NativeWindow.contextmenus._getLinkURL(aElement), + }; + }, + icon: "drawable://ic_menu_share", + callback: function(aTarget) { + // share.1 telemetry is handled in Java via PromptList + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_link"); + } + }); + + NativeWindow.contextmenus.add({ + label: stringGetter("contextmenu.shareEmailAddress"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, + selector: NativeWindow.contextmenus._disableRestricted("SHARE", NativeWindow.contextmenus.emailLinkContext), + showAsActions: function(aElement) { + let url = NativeWindow.contextmenus._getLinkURL(aElement); + let emailAddr = NativeWindow.contextmenus._stripScheme(url); + let title = aElement.textContent || aElement.title; + return { + title: title, + uri: emailAddr, + }; + }, + icon: "drawable://ic_menu_share", + callback: function(aTarget) { + // share.1 telemetry is handled in Java via PromptList + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_email"); + } + }); + + NativeWindow.contextmenus.add({ + label: stringGetter("contextmenu.sharePhoneNumber"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, + selector: NativeWindow.contextmenus._disableRestricted("SHARE", NativeWindow.contextmenus.phoneNumberLinkContext), + showAsActions: function(aElement) { + let url = NativeWindow.contextmenus._getLinkURL(aElement); + let phoneNumber = NativeWindow.contextmenus._stripScheme(url); + let title = aElement.textContent || aElement.title; + return { + title: title, + uri: phoneNumber, + }; + }, + icon: "drawable://ic_menu_share", + callback: function(aTarget) { + // share.1 telemetry is handled in Java via PromptList + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_phone"); + } + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.addToContacts"), + NativeWindow.contextmenus._disableRestricted("ADD_CONTACT", NativeWindow.contextmenus.emailLinkContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_contact_email"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + Messaging.sendRequest({ + type: "Contact:Add", + email: url + }); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.addToContacts"), + NativeWindow.contextmenus._disableRestricted("ADD_CONTACT", NativeWindow.contextmenus.phoneNumberLinkContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_contact_phone"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + Messaging.sendRequest({ + type: "Contact:Add", + phone: url + }); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.bookmarkLink"), + NativeWindow.contextmenus._disableRestricted("BOOKMARK", NativeWindow.contextmenus.linkBookmarkableContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_bookmark"); + UITelemetry.addEvent("save.1", "contextmenu", null, "bookmark"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + let title = aTarget.textContent || aTarget.title || url; + Messaging.sendRequest({ + type: "Bookmark:Insert", + url: url, + title: title + }); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.playMedia"), + NativeWindow.contextmenus.mediaContext("media-paused"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_play"); + aTarget.play(); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.pauseMedia"), + NativeWindow.contextmenus.mediaContext("media-playing"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_pause"); + aTarget.pause(); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.showControls2"), + NativeWindow.contextmenus.mediaContext("media-hidingcontrols"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_controls_media"); + aTarget.setAttribute("controls", true); + }); + + NativeWindow.contextmenus.add({ + label: stringGetter("contextmenu.shareMedia"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, + selector: NativeWindow.contextmenus._disableRestricted( + "SHARE", NativeWindow.contextmenus.videoContext()), + showAsActions: function(aElement) { + let url = (aElement.currentSrc || aElement.src); + let title = aElement.textContent || aElement.title; + return { + title: title, + uri: url, + type: "video/*", + }; + }, + icon: "drawable://ic_menu_share", + callback: function(aTarget) { + // share.1 telemetry is handled in Java via PromptList + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_media"); + } + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.fullScreen"), + NativeWindow.contextmenus.videoContext("not-fullscreen"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_fullscreen"); + aTarget.requestFullscreen(); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.mute"), + NativeWindow.contextmenus.mediaContext("media-unmuted"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_mute"); + aTarget.muted = true; + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.unmute"), + NativeWindow.contextmenus.mediaContext("media-muted"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_unmute"); + aTarget.muted = false; + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.viewImage"), + NativeWindow.contextmenus.imageLocationCopyableContext, + function(aTarget) { + let url = aTarget.src; + ContentAreaUtils.urlSecurityCheck(url, aTarget.ownerDocument.nodePrincipal, + Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT); + + UITelemetry.addEvent("action.1", "contextmenu", null, "web_view_image"); + UITelemetry.addEvent("loadurl.1", "contextmenu", null); + BrowserApp.selectedBrowser.loadURI(url); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.copyImageLocation"), + NativeWindow.contextmenus.imageLocationCopyableContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_copy_image"); + + let url = aTarget.src; + NativeWindow.contextmenus._copyStringToDefaultClipboard(url); + }); + + NativeWindow.contextmenus.add({ + label: stringGetter("contextmenu.shareImage"), + selector: NativeWindow.contextmenus._disableRestricted("SHARE", NativeWindow.contextmenus.imageShareableContext), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, // Show above HTML5 menu items + showAsActions: function(aTarget) { + let doc = aTarget.ownerDocument; + let imageCache = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools) + .getImgCacheForDocument(doc); + let props = imageCache.findEntryProperties(aTarget.currentURI, doc); + let src = aTarget.src; + return { + title: src, + uri: src, + type: "image/*", + }; + }, + icon: "drawable://ic_menu_share", + menu: true, + callback: function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_image"); + } + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.saveImage"), + NativeWindow.contextmenus.imageSaveableContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_save_image"); + UITelemetry.addEvent("save.1", "contextmenu", null, "image"); + + RuntimePermissions.waitForPermissions(RuntimePermissions.WRITE_EXTERNAL_STORAGE).then(function(permissionGranted) { + if (!permissionGranted) { + return; + } + + ContentAreaUtils.saveImageURL(aTarget.currentURI.spec, null, "SaveImageTitle", + false, true, aTarget.ownerDocument.documentURIObject, + aTarget.ownerDocument); + }); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.setImageAs"), + NativeWindow.contextmenus._disableRestricted("SET_IMAGE", NativeWindow.contextmenus.imageSaveableContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_background_image"); + + let src = aTarget.src; + Messaging.sendRequest({ + type: "Image:SetAs", + url: src + }); + }); + + NativeWindow.contextmenus.add( + function(aTarget) { + if (aTarget instanceof HTMLVideoElement) { + // If a video element is zero width or height, its essentially + // an HTMLAudioElement. + if (aTarget.videoWidth == 0 || aTarget.videoHeight == 0 ) + return Strings.browser.GetStringFromName("contextmenu.saveAudio"); + return Strings.browser.GetStringFromName("contextmenu.saveVideo"); + } else if (aTarget instanceof HTMLAudioElement) { + return Strings.browser.GetStringFromName("contextmenu.saveAudio"); + } + return Strings.browser.GetStringFromName("contextmenu.saveVideo"); + }, NativeWindow.contextmenus.mediaSaveableContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_save_media"); + UITelemetry.addEvent("save.1", "contextmenu", null, "media"); + + let url = aTarget.currentSrc || aTarget.src; + let filePickerTitleKey = (aTarget instanceof HTMLVideoElement && + (aTarget.videoWidth != 0 && aTarget.videoHeight != 0)) + ? "SaveVideoTitle" : "SaveAudioTitle"; + // Skipped trying to pull MIME type out of cache for now + ContentAreaUtils.internalSave(url, null, null, null, null, false, + filePickerTitleKey, null, aTarget.ownerDocument.documentURIObject, + aTarget.ownerDocument, true, null); + }); + + NativeWindow.contextmenus.add(stringGetter("contextmenu.showImage"), + NativeWindow.contextmenus.imageBlockingPolicyContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_show_image"); + aTarget.setAttribute("data-ctv-show", "true"); + aTarget.setAttribute("src", aTarget.getAttribute("data-ctv-src")); + + // Shows a snackbar to unblock all images if browser.image_blocking.enabled is enabled. + let blockedImgs = aTarget.ownerDocument.querySelectorAll("[data-ctv-src]"); + if (blockedImgs.length == 0) { + return; + } + let message = Strings.browser.GetStringFromName("imageblocking.downloadedImage"); + Snackbars.show(message, Snackbars.LENGTH_LONG, { + action: { + label: Strings.browser.GetStringFromName("imageblocking.showAllImages"), + callback: () => { + UITelemetry.addEvent("action.1", "toast", null, "web_show_all_image"); + for (let i = 0; i < blockedImgs.length; ++i) { + blockedImgs[i].setAttribute("data-ctv-show", "true"); + blockedImgs[i].setAttribute("src", blockedImgs[i].getAttribute("data-ctv-src")); + } + }, + } + }); + }); + }, + + onAppUpdated: function() { + // initialize the form history and passwords databases on upgrades + Services.obs.notifyObservers(null, "FormHistory:Init", ""); + Services.obs.notifyObservers(null, "Passwords:Init", ""); + + if (this._startupStatus === "upgrade") { + this._migrateUI(); + } + }, + + _migrateUI: function() { + const UI_VERSION = 3; + let currentUIVersion = 0; + try { + currentUIVersion = Services.prefs.getIntPref("browser.migration.version"); + } catch(ex) {} + if (currentUIVersion >= UI_VERSION) { + return; + } + + if (currentUIVersion < 1) { + // Migrate user-set "plugins.click_to_play" pref. See bug 884694. + // Because the default value is true, a user-set pref means that the pref was set to false. + if (Services.prefs.prefHasUserValue("plugins.click_to_play")) { + Services.prefs.setIntPref("plugin.default.state", Ci.nsIPluginTag.STATE_ENABLED); + Services.prefs.clearUserPref("plugins.click_to_play"); + } + + // Migrate the "privacy.donottrackheader.value" pref. See bug 1042135. + if (Services.prefs.prefHasUserValue("privacy.donottrackheader.value")) { + // Make sure the doNotTrack value conforms to the conversion from + // three-state to two-state. (This reverts a setting of "please track me" + // to the default "don't say anything"). + if (Services.prefs.getBoolPref("privacy.donottrackheader.enabled") && + (Services.prefs.getIntPref("privacy.donottrackheader.value") != 1)) { + Services.prefs.clearUserPref("privacy.donottrackheader.enabled"); + } + + // This pref has been removed, so always clear it. + Services.prefs.clearUserPref("privacy.donottrackheader.value"); + } + + // Set the search activity default pref on app upgrade if it has not been set already. + if (!Services.prefs.prefHasUserValue("searchActivity.default.migrated")) { + Services.prefs.setBoolPref("searchActivity.default.migrated", true); + SearchEngines.migrateSearchActivityDefaultPref(); + } + + Reader.migrateCache().catch(e => Cu.reportError("Error migrating Reader cache: " + e)); + + // We removed this pref from user visible settings, so we should reset it. + // Power users can go into about:config to re-enable this if they choose. + if (Services.prefs.prefHasUserValue("nglayout.debug.paint_flashing")) { + Services.prefs.clearUserPref("nglayout.debug.paint_flashing"); + } + } + + if (currentUIVersion < 2) { + let name; + if (Services.prefs.prefHasUserValue("browser.search.defaultenginename")) { + name = Services.prefs.getCharPref("browser.search.defaultenginename"); + } + if (!name && Services.prefs.prefHasUserValue("browser.search.defaultenginename.US")) { + name = Services.prefs.getCharPref("browser.search.defaultenginename.US"); + } + if (name) { + Services.search.init(() => { + let engine = Services.search.getEngineByName(name); + if (engine) { + Services.search.defaultEngine = engine; + Services.obs.notifyObservers(null, "default-search-engine-migrated", ""); + } + }); + } + } + + if (currentUIVersion < 3) { + const kOldSafeBrowsingPref = "browser.safebrowsing.enabled"; + // Default value is set to true, a user pref means that the pref was + // set to false. + if (Services.prefs.prefHasUserValue(kOldSafeBrowsingPref) && + !Services.prefs.getBoolPref(kOldSafeBrowsingPref)) { + Services.prefs.setBoolPref("browser.safebrowsing.phishing.enabled", + false); + // Should just remove support for the pref entirely, even if it's + // only in about:config + Services.prefs.clearUserPref(kOldSafeBrowsingPref); + } + } + + // Update the migration version. + Services.prefs.setIntPref("browser.migration.version", UI_VERSION); + }, + + // This function returns false during periods where the browser displayed document is + // different from the browser content document, so user actions and some kinds of viewport + // updates should be ignored. This period starts when we start loading a new page or + // switch tabs, and ends when the new browser content document has been drawn and handed + // off to the compositor. + isBrowserContentDocumentDisplayed: function() { + try { + if (!Services.androidBridge.isContentDocumentDisplayed(window)) + return false; + } catch (e) { + return false; + } + + let tab = this.selectedTab; + if (!tab) + return false; + return tab.contentDocumentIsDisplayed; + }, + + contentDocumentChanged: function() { + window.top.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils).isFirstPaint = true; + Services.androidBridge.contentDocumentChanged(window); + }, + + get tabs() { + return this._tabs; + }, + + set selectedTab(aTab) { + if (this._selectedTab == aTab) + return; + + if (this._selectedTab) { + this._selectedTab.setActive(false); + } + + this._selectedTab = aTab; + if (!aTab) + return; + + aTab.setActive(true); + this.contentDocumentChanged(); + this.deck.selectedPanel = aTab.browser; + // Focus the browser so that things like selection will be styled correctly. + aTab.browser.focus(); + }, + + get selectedBrowser() { + if (this._selectedTab) + return this._selectedTab.browser; + return null; + }, + + getTabForId: function getTabForId(aId) { + let tabs = this._tabs; + for (let i=0; i < tabs.length; i++) { + if (tabs[i].id == aId) + return tabs[i]; + } + return null; + }, + + getTabForBrowser: function getTabForBrowser(aBrowser) { + let tabs = this._tabs; + for (let i = 0; i < tabs.length; i++) { + if (tabs[i].browser == aBrowser) + return tabs[i]; + } + return null; + }, + + getTabForWindow: function getTabForWindow(aWindow) { + let tabs = this._tabs; + for (let i = 0; i < tabs.length; i++) { + if (tabs[i].browser.contentWindow == aWindow) + return tabs[i]; + } + return null; + }, + + getBrowserForWindow: function getBrowserForWindow(aWindow) { + let tabs = this._tabs; + for (let i = 0; i < tabs.length; i++) { + if (tabs[i].browser.contentWindow == aWindow) + return tabs[i].browser; + } + return null; + }, + + getBrowserForDocument: function getBrowserForDocument(aDocument) { + let tabs = this._tabs; + for (let i = 0; i < tabs.length; i++) { + if (tabs[i].browser.contentDocument == aDocument) + return tabs[i].browser; + } + return null; + }, + + loadURI: function loadURI(aURI, aBrowser, aParams) { + aBrowser = aBrowser || this.selectedBrowser; + if (!aBrowser) + return; + + aParams = aParams || {}; + + let flags = "flags" in aParams ? aParams.flags : Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let postData = ("postData" in aParams && aParams.postData) ? aParams.postData : null; + let referrerURI = "referrerURI" in aParams ? aParams.referrerURI : null; + let charset = "charset" in aParams ? aParams.charset : null; + + let tab = this.getTabForBrowser(aBrowser); + if (tab) { + if ("userRequested" in aParams) tab.userRequested = aParams.userRequested; + tab.isSearch = ("isSearch" in aParams) ? aParams.isSearch : false; + } + + try { + aBrowser.loadURIWithFlags(aURI, flags, referrerURI, charset, postData); + } catch(e) { + if (tab) { + let message = { + type: "Content:LoadError", + tabID: tab.id + }; + Messaging.sendRequest(message); + dump("Handled load error: " + e) + } + } + }, + + addTab: function addTab(aURI, aParams) { + aParams = aParams || {}; + + let newTab = new Tab(aURI, aParams); + + if (typeof aParams.tabIndex == "number") { + this._tabs.splice(aParams.tabIndex, 0, newTab); + } else { + this._tabs.push(newTab); + } + + let selected = "selected" in aParams ? aParams.selected : true; + if (selected) + this.selectedTab = newTab; + + let pinned = "pinned" in aParams ? aParams.pinned : false; + if (pinned) { + let ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + ss.setTabValue(newTab, "appOrigin", aURI); + } + + let evt = document.createEvent("UIEvents"); + evt.initUIEvent("TabOpen", true, false, window, null); + newTab.browser.dispatchEvent(evt); + + return newTab; + }, + + // Use this method to close a tab from JS. This method sends a message + // to Java to close the tab in the Java UI (we'll get a Tab:Closed message + // back from Java when that happens). + closeTab: function closeTab(aTab) { + if (!aTab) { + Cu.reportError("Error trying to close tab (tab doesn't exist)"); + return; + } + + let message = { + type: "Tab:Close", + tabID: aTab.id + }; + Messaging.sendRequest(message); + }, + + // Calling this will update the state in BrowserApp after a tab has been + // closed in the Java UI. + _handleTabClosed: function _handleTabClosed(aTab, aShowUndoSnackbar) { + if (aTab == this.selectedTab) + this.selectedTab = null; + + let tabIndex = this._tabs.indexOf(aTab); + + let evt = document.createEvent("UIEvents"); + evt.initUIEvent("TabClose", true, false, window, tabIndex); + aTab.browser.dispatchEvent(evt); + + if (aShowUndoSnackbar) { + // Get a title for the undo close snackbar. Fall back to the URL if there is no title. + let ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + let closedTabData = ss.getClosedTabs(window)[0]; + + let message; + let title = closedTabData.entries[closedTabData.index - 1].title; + let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(aTab.browser); + + if (isPrivate) { + message = Strings.browser.GetStringFromName("privateClosedMessage.message"); + } else if (title) { + message = Strings.browser.formatStringFromName("undoCloseToast.message", [title], 1); + } else { + message = Strings.browser.GetStringFromName("undoCloseToast.messageDefault"); + } + + Snackbars.show(message, Snackbars.LENGTH_LONG, { + action: { + label: Strings.browser.GetStringFromName("undoCloseToast.action2"), + callback: function() { + UITelemetry.addEvent("undo.1", "toast", null, "closetab"); + ss.undoCloseTab(window, closedTabData); + } + } + }); + } + + aTab.destroy(); + this._tabs.splice(tabIndex, 1); + }, + + // Use this method to select a tab from JS. This method sends a message + // to Java to select the tab in the Java UI (we'll get a Tab:Selected message + // back from Java when that happens). + selectTab: function selectTab(aTab) { + if (!aTab) { + Cu.reportError("Error trying to select tab (tab doesn't exist)"); + return; + } + + // There's nothing to do if the tab is already selected + if (aTab == this.selectedTab) + return; + + let doc = this.selectedBrowser.contentDocument; + if (doc.fullscreenElement) { + // We'll finish the tab selection once the fullscreen transition has ended, + // remember the new tab for this. + this.fullscreenTransitionTab = aTab; + doc.exitFullscreen(); + } + + let message = { + type: "Tab:Select", + tabID: aTab.id + }; + Messaging.sendRequest(message); + }, + + /** + * Gets an open tab with the given URL. + * + * @param aURL URL to look for + * @param aOptions Options for the search. Currently supports: + ** @option startsWith a Boolean indicating whether to search for a tab who's url starts with the + * requested url. Useful if you want to ignore hash codes on the end of a url. For instance + * to have about:downloads match about:downloads#123. + * @return the tab with the given URL, or null if no such tab exists + */ + getTabWithURL: function getTabWithURL(aURL, aOptions) { + aOptions = aOptions || {}; + let uri = Services.io.newURI(aURL, null, null); + for (let i = 0; i < this._tabs.length; ++i) { + let tab = this._tabs[i]; + if (aOptions.startsWith) { + if (tab.browser.currentURI.spec.startsWith(aURL)) { + return tab; + } + } else { + if (tab.browser.currentURI.equals(uri)) { + return tab; + } + } + } + return null; + }, + + /** + * If a tab with the given URL already exists, that tab is selected. + * Otherwise, a new tab is opened with the given URL. + * + * @param aURL URL to open + * @param aParam Options used if a tab is created + * @param aFlags Options for the search. Currently supports: + ** @option startsWith a Boolean indicating whether to search for a tab who's url starts with the + * requested url. Useful if you want to ignore hash codes on the end of a url. For instance + * to have about:downloads match about:downloads#123. + */ + selectOrAddTab: function selectOrAddTab(aURL, aParams, aFlags) { + let tab = this.getTabWithURL(aURL, aFlags); + if (tab == null) { + tab = this.addTab(aURL, aParams); + } else { + this.selectTab(tab); + } + + return tab; + }, + + // This method updates the state in BrowserApp after a tab has been selected + // in the Java UI. + _handleTabSelected: function _handleTabSelected(aTab) { + if (this.fullscreenTransitionTab) { + // Defer updating to "fullscreenchange" if tab selection happened during + // a fullscreen transition. + return; + } + this.selectedTab = aTab; + + let evt = document.createEvent("UIEvents"); + evt.initUIEvent("TabSelect", true, false, window, null); + aTab.browser.dispatchEvent(evt); + }, + + quit: function quit(aClear = { sanitize: {}, dontSaveSession: false }) { + // Notify all windows that an application quit has been requested. + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null); + + // Quit aborted. + if (cancelQuit.data) { + return; + } + + Services.obs.notifyObservers(null, "quit-application-proceeding", null); + + // Tell session store to forget about this window + if (aClear.dontSaveSession) { + let ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + ss.removeWindow(window); + } + + BrowserApp.sanitize(aClear.sanitize, function() { + let appStartup = Cc["@mozilla.org/toolkit/app-startup;1"].getService(Ci.nsIAppStartup); + appStartup.quit(Ci.nsIAppStartup.eForceQuit); + }, true); + }, + + saveAsPDF: function saveAsPDF(aBrowser) { + RuntimePermissions.waitForPermissions(RuntimePermissions.WRITE_EXTERNAL_STORAGE).then(function(permissionGranted) { + if (!permissionGranted) { + return; + } + + Task.spawn(function* () { + let fileName = ContentAreaUtils.getDefaultFileName(aBrowser.contentTitle, aBrowser.currentURI, null, null); + fileName = fileName.trim() + ".pdf"; + + let downloadsDir = yield Downloads.getPreferredDownloadsDirectory(); + let file = OS.Path.join(downloadsDir, fileName); + + // Force this to have a unique name. + let openedFile = yield OS.File.openUnique(file, { humanReadable: true }); + file = openedFile.path; + yield openedFile.file.close(); + + let download = yield Downloads.createDownload({ + source: aBrowser.contentWindow, + target: file, + saver: "pdf", + startTime: Date.now(), + }); + + let list = yield Downloads.getList(download.source.isPrivate ? Downloads.PRIVATE : Downloads.PUBLIC) + yield list.add(download); + yield download.start(); + }); + }); + }, + + // These values come from pref_tracking_protection_entries in arrays.xml. + PREF_TRACKING_PROTECTION_ENABLED: "2", + PREF_TRACKING_PROTECTION_ENABLED_PB: "1", + PREF_TRACKING_PROTECTION_DISABLED: "0", + + /** + * Returns the current state of the tracking protection pref. + * (0 = Disabled, 1 = Enabled in PB, 2 = Enabled) + */ + getTrackingProtectionState: function() { + if (Services.prefs.getBoolPref("privacy.trackingprotection.enabled")) { + return this.PREF_TRACKING_PROTECTION_ENABLED; + } + if (Services.prefs.getBoolPref("privacy.trackingprotection.pbmode.enabled")) { + return this.PREF_TRACKING_PROTECTION_ENABLED_PB; + } + return this.PREF_TRACKING_PROTECTION_DISABLED; + }, + + sanitize: function (aItems, callback, aShutdown) { + let success = true; + var promises = []; + + for (let key in aItems) { + if (!aItems[key]) + continue; + + key = key.replace("private.data.", ""); + + switch (key) { + case "cookies_sessions": + promises.push(Sanitizer.clearItem("cookies")); + promises.push(Sanitizer.clearItem("sessions")); + break; + default: + promises.push(Sanitizer.clearItem(key)); + } + } + + Promise.all(promises).then(function() { + Messaging.sendRequest({ + type: "Sanitize:Finished", + success: true, + shutdown: aShutdown === true + }); + + if (callback) { + callback(); + } + }).catch(function(err) { + Messaging.sendRequest({ + type: "Sanitize:Finished", + error: err, + success: false, + shutdown: aShutdown === true + }); + + if (callback) { + callback(); + } + }) + }, + + getFocusedInput: function(aBrowser, aOnlyInputElements = false) { + if (!aBrowser) + return null; + + let doc = aBrowser.contentDocument; + if (!doc) + return null; + + let focused = doc.activeElement; + while (focused instanceof HTMLFrameElement || focused instanceof HTMLIFrameElement) { + doc = focused.contentDocument; + focused = doc.activeElement; + } + + if (focused instanceof HTMLInputElement && + (focused.mozIsTextField(false) || focused.type === "number")) { + return focused; + } + + if (aOnlyInputElements) + return null; + + if (focused && (focused instanceof HTMLTextAreaElement || focused.isContentEditable)) { + + if (focused instanceof HTMLBodyElement) { + // we are putting focus into a contentEditable frame. scroll the frame into + // view instead of the contentEditable document contained within, because that + // results in a better user experience + focused = focused.ownerDocument.defaultView.frameElement; + } + return focused; + } + return null; + }, + + scrollToFocusedInput: function(aBrowser, aAllowZoom = true) { + let formHelperMode = Services.prefs.getIntPref("formhelper.mode"); + if (formHelperMode == kFormHelperModeDisabled) + return; + + let dwu = aBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + if (!dwu) { + return; + } + + let apzFlushDone = function() { + Services.obs.removeObserver(apzFlushDone, "apz-repaints-flushed", false); + dwu.zoomToFocusedInput(); + }; + + let paintDone = function() { + window.removeEventListener("MozAfterPaint", paintDone, false); + if (dwu.flushApzRepaints()) { + Services.obs.addObserver(apzFlushDone, "apz-repaints-flushed", false); + } else { + apzFlushDone(); + } + }; + + let gotResizeWindow = false; + let resizeWindow = function(e) { + gotResizeWindow = true; + aBrowser.contentWindow.removeEventListener("resize", resizeWindow, false); + if (dwu.isMozAfterPaintPending) { + window.addEventListener("MozAfterPaint", paintDone, false); + } else { + paintDone(); + } + } + + aBrowser.contentWindow.addEventListener("resize", resizeWindow, false); + + // The "resize" event sometimes fails to fire, so set a timer to catch that case + // and unregister the event listener. See Bug 1253469 + setTimeout(function(e) { + if (!gotResizeWindow) { + aBrowser.contentWindow.removeEventListener("resize", resizeWindow, false); + dwu.zoomToFocusedInput(); + } + }, 500); + }, + + getUALocalePref: function () { + try { + return Services.prefs.getComplexValue("general.useragent.locale", Ci.nsIPrefLocalizedString).data; + } catch (e) { + try { + return Services.prefs.getCharPref("general.useragent.locale"); + } catch (ee) { + return undefined; + } + } + }, + + getOSLocalePref: function () { + try { + return Services.prefs.getCharPref("intl.locale.os"); + } catch (e) { + return undefined; + } + }, + + setLocalizedPref: function (pref, value) { + let pls = Cc["@mozilla.org/pref-localizedstring;1"] + .createInstance(Ci.nsIPrefLocalizedString); + pls.data = value; + Services.prefs.setComplexValue(pref, Ci.nsIPrefLocalizedString, pls); + }, + + observe: function(aSubject, aTopic, aData) { + let browser = this.selectedBrowser; + + switch (aTopic) { + + case "Session:Back": + browser.goBack(); + break; + + case "Session:Forward": + browser.goForward(); + break; + + case "Session:Navigate": + let index = JSON.parse(aData); + let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + let historySize = webNav.sessionHistory.count; + + if (index < 0) { + index = 0; + Log.e("Browser", "Negative index truncated to zero"); + } else if (index >= historySize) { + Log.e("Browser", "Incorrect index " + index + " truncated to " + historySize - 1); + index = historySize - 1; + } + + browser.gotoIndex(index); + break; + + case "Session:Reload": { + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + + // Check to see if this is a message to enable/disable mixed content blocking. + if (aData) { + let data = JSON.parse(aData); + + if (data.bypassCache) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE | + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY; + } + + if (data.contentType === "tracking") { + // Convert document URI into the format used by + // nsChannelClassifier::ShouldEnableTrackingProtection + // (any scheme turned into https is correct) + let normalizedUrl = Services.io.newURI("https://" + browser.currentURI.hostPort, null, null); + if (data.allowContent) { + // Add the current host in the 'trackingprotection' consumer of + // the permission manager using a normalized URI. This effectively + // places this host on the tracking protection white list. + if (PrivateBrowsingUtils.isBrowserPrivate(browser)) { + PrivateBrowsingUtils.addToTrackingAllowlist(normalizedUrl); + } else { + Services.perms.add(normalizedUrl, "trackingprotection", Services.perms.ALLOW_ACTION); + Telemetry.addData("TRACKING_PROTECTION_EVENTS", 1); + } + } else { + // Remove the current host from the 'trackingprotection' consumer + // of the permission manager. This effectively removes this host + // from the tracking protection white list (any list actually). + if (PrivateBrowsingUtils.isBrowserPrivate(browser)) { + PrivateBrowsingUtils.removeFromTrackingAllowlist(normalizedUrl); + } else { + Services.perms.remove(normalizedUrl, "trackingprotection"); + Telemetry.addData("TRACKING_PROTECTION_EVENTS", 2); + } + } + } + } + + // Try to use the session history to reload so that framesets are + // handled properly. If the window has no session history, fall back + // to using the web navigation's reload method. + let webNav = browser.webNavigation; + try { + let sh = webNav.sessionHistory; + if (sh) + webNav = sh.QueryInterface(Ci.nsIWebNavigation); + } catch (e) {} + webNav.reload(flags); + break; + } + + case "Session:Stop": + browser.stop(); + break; + + case "Tab:Load": { + let data = JSON.parse(aData); + let url = data.url; + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP + | Ci.nsIWebNavigation.LOAD_FLAGS_FIXUP_SCHEME_TYPOS; + + // Pass LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL to prevent any loads from + // inheriting the currently loaded document's principal. + if (data.userEntered) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL; + } + + let delayLoad = ("delayLoad" in data) ? data.delayLoad : false; + let params = { + selected: ("selected" in data) ? data.selected : !delayLoad, + parentId: ("parentId" in data) ? data.parentId : -1, + flags: flags, + tabID: data.tabID, + isPrivate: (data.isPrivate === true), + pinned: (data.pinned === true), + delayLoad: (delayLoad === true), + desktopMode: (data.desktopMode === true) + }; + + params.userRequested = url; + + if (data.engine) { + let engine = Services.search.getEngineByName(data.engine); + if (engine) { + let submission = engine.getSubmission(url); + url = submission.uri.spec; + params.postData = submission.postData; + params.isSearch = true; + } + } + + if (data.newTab) { + this.addTab(url, params); + } else { + if (data.tabId) { + // Use a specific browser instead of the selected browser, if it exists + let specificBrowser = this.getTabForId(data.tabId).browser; + if (specificBrowser) + browser = specificBrowser; + } + this.loadURI(url, browser, params); + } + break; + } + + case "Tab:Selected": + this._handleTabSelected(this.getTabForId(parseInt(aData))); + break; + + case "Tab:Closed": { + let data = JSON.parse(aData); + this._handleTabClosed(this.getTabForId(data.tabId), data.showUndoToast); + break; + } + + case "keyword-search": + // This event refers to a search via the URL bar, not a bookmarks + // keyword search. Note that this code assumes that the user can only + // perform a keyword search on the selected tab. + this.selectedTab.isSearch = true; + + // Don't store queries in private browsing mode. + let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.selectedTab.browser); + let query = isPrivate ? "" : aData; + + let engine = aSubject.QueryInterface(Ci.nsISearchEngine); + Messaging.sendRequest({ + type: "Search:Keyword", + identifier: engine.identifier, + name: engine.name, + query: query + }); + break; + + case "Browser:Quit": + // Add-ons like QuitNow and CleanQuit provide aData as an empty-string (""). + // Pass undefined to invoke the methods default parms. + this.quit(aData ? JSON.parse(aData) : undefined); + break; + + case "SaveAs:PDF": + this.saveAsPDF(browser); + break; + + case "ScrollTo:FocusedInput": + // these messages come from a change in the viewable area and not user interaction + // we allow scrolling to the selected input, but not zooming the page + this.scrollToFocusedInput(browser, false); + break; + + case "Sanitize:ClearData": + this.sanitize(JSON.parse(aData)); + break; + + case "FullScreen:Exit": + browser.contentDocument.exitFullscreen(); + break; + + case "Passwords:Init": { + let storage = Cc["@mozilla.org/login-manager/storage/mozStorage;1"]. + getService(Ci.nsILoginManagerStorage); + storage.initialize(); + Services.obs.removeObserver(this, "Passwords:Init"); + break; + } + + case "FormHistory:Init": { + // Force creation/upgrade of formhistory.sqlite + FormHistory.count({}); + Services.obs.removeObserver(this, "FormHistory:Init"); + break; + } + + case "android-get-pref": { + // These pref names are not "real" pref names. They are used in the + // setting menu, and these are passed when initializing the setting + // menu. aSubject is a nsIWritableVariant to hold the pref value. + aSubject.QueryInterface(Ci.nsIWritableVariant); + + switch (aData) { + // The plugin pref is actually two separate prefs, so + // we need to handle it differently + case "plugin.enable": + aSubject.setAsAString(PluginHelper.getPluginPreference()); + break; + + // Handle master password + case "privacy.masterpassword.enabled": + aSubject.setAsBool(MasterPassword.enabled); + break; + + case "privacy.trackingprotection.state": { + aSubject.setAsAString(this.getTrackingProtectionState()); + break; + } + + // Crash reporter submit pref must be fetched from nsICrashReporter + // service. + case "datareporting.crashreporter.submitEnabled": + let crashReporterBuilt = "nsICrashReporter" in Ci && + Services.appinfo instanceof Ci.nsICrashReporter; + if (crashReporterBuilt) { + aSubject.setAsBool(Services.appinfo.submitReports); + } + break; + } + break; + } + + case "android-set-pref": { + // Pseudo-prefs. aSubject is an nsIWritableVariant that holds the pref + // value. Set to empty to signal the pref was handled. + aSubject.QueryInterface(Ci.nsIWritableVariant); + let value = aSubject.QueryInterface(Ci.nsIVariant); + + switch (aData) { + // The plugin pref is actually two separate prefs, so we need to + // handle it differently. + case "plugin.enable": + PluginHelper.setPluginPreference(value); + aSubject.setAsEmpty(); + break; + + // MasterPassword pref is not real, we just need take action and leave + case "privacy.masterpassword.enabled": + if (MasterPassword.enabled) { + MasterPassword.removePassword(value); + } else { + MasterPassword.setPassword(value); + } + aSubject.setAsEmpty(); + break; + + // "privacy.trackingprotection.state" is not a "real" pref name, but + // it's used in the setting menu. By default + // "privacy.trackingprotection.pbmode.enabled" is true, and + // "privacy.trackingprotection.enabled" is false. + case "privacy.trackingprotection.state": { + switch (value) { + // Tracking protection disabled. + case this.PREF_TRACKING_PROTECTION_DISABLED: + Services.prefs.setBoolPref("privacy.trackingprotection.pbmode.enabled", false); + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false); + break; + // Tracking protection only in private browsing, + case this.PREF_TRACKING_PROTECTION_ENABLED_PB: + Services.prefs.setBoolPref("privacy.trackingprotection.pbmode.enabled", true); + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false); + break; + // Tracking protection everywhere. + case this.PREF_TRACKING_PROTECTION_ENABLED: + Services.prefs.setBoolPref("privacy.trackingprotection.pbmode.enabled", true); + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", true); + break; + } + aSubject.setAsEmpty(); + break; + } + + // Crash reporter preference is in a service; set and return. + case "datareporting.crashreporter.submitEnabled": + let crashReporterBuilt = "nsICrashReporter" in Ci && + Services.appinfo instanceof Ci.nsICrashReporter; + if (crashReporterBuilt) { + Services.appinfo.submitReports = value; + aSubject.setAsEmpty(); + } + break; + } + break; + } + + case "sessionstore-state-purge-complete": + Messaging.sendRequest({ type: "Session:StatePurged" }); + break; + + case "gather-telemetry": + Messaging.sendRequest({ type: "Telemetry:Gather" }); + break; + + case "Locale:OS": + // We know the system locale. We use this for generating Accept-Language headers. + console.log("Locale:OS: " + aData); + let currentOSLocale = this.getOSLocalePref(); + if (currentOSLocale == aData) { + break; + } + + console.log("New OS locale."); + + // Ensure that this choice is immediately persisted, because + // Gecko won't be told again if it forgets. + Services.prefs.setCharPref("intl.locale.os", aData); + Services.prefs.savePrefFile(null); + + let appLocale = this.getUALocalePref(); + + this.computeAcceptLanguages(aData, appLocale); + break; + + case "Locale:Changed": + if (aData) { + // The value provided to Locale:Changed should be a BCP47 language tag + // understood by Gecko -- for example, "es-ES" or "de". + console.log("Locale:Changed: " + aData); + + // We always write a localized pref, even though sometimes the value is a char pref. + // (E.g., on desktop single-locale builds.) + this.setLocalizedPref("general.useragent.locale", aData); + } else { + // Resetting. + console.log("Switching to system locale."); + Services.prefs.clearUserPref("general.useragent.locale"); + } + + Services.prefs.setBoolPref("intl.locale.matchOS", !aData); + + // Ensure that this choice is immediately persisted, because + // Gecko won't be told again if it forgets. + Services.prefs.savePrefFile(null); + + // Blow away the string cache so that future lookups get the + // correct locale. + Strings.flush(); + + // Make sure we use the right Accept-Language header. + let osLocale; + try { + // This should never not be set at this point, but better safe than sorry. + osLocale = Services.prefs.getCharPref("intl.locale.os"); + } catch (e) { + } + + this.computeAcceptLanguages(osLocale, aData); + break; + + case "Fonts:Reload": + FontEnumerator.updateFontList(); + break; + + case "Vibration:Request": + if (aSubject instanceof Navigator) { + let navigator = aSubject; + let buttons = [ + { + label: Strings.browser.GetStringFromName("vibrationRequest.denyButton"), + callback: function() { + navigator.setVibrationPermission(false); + } + }, + { + label: Strings.browser.GetStringFromName("vibrationRequest.allowButton"), + callback: function() { + navigator.setVibrationPermission(true); + }, + positive: true + } + ]; + let message = Strings.browser.GetStringFromName("vibrationRequest.message"); + let options = {}; + NativeWindow.doorhanger.show(message, "vibration-request", buttons, + BrowserApp.selectedTab.id, options, "VIBRATION"); + } + break; + + default: + dump('BrowserApp.observe: unexpected topic "' + aTopic + '"\n'); + break; + + } + }, + + /** + * Set intl.accept_languages accordingly. + * + * After Bug 881510 this will also accept a real Accept-Language choice as + * input; all Accept-Language logic lives here. + * + * osLocale should never be null, but this method is safe regardless. + * appLocale may explicitly be null. + */ + computeAcceptLanguages(osLocale, appLocale) { + let defaultBranch = Services.prefs.getDefaultBranch(null); + let defaultAccept = defaultBranch.getComplexValue("intl.accept_languages", Ci.nsIPrefLocalizedString).data; + console.log("Default intl.accept_languages = " + defaultAccept); + + // A guard for potential breakage. Bug 438031. + // This should not be necessary, because we're reading from the default branch, + // but better safe than sorry. + if (defaultAccept && defaultAccept.startsWith("chrome://")) { + defaultAccept = null; + } else { + // Ensure lowercase everywhere so we can compare. + defaultAccept = defaultAccept.toLowerCase(); + } + + if (appLocale) { + appLocale = appLocale.toLowerCase(); + } + + if (osLocale) { + osLocale = osLocale.toLowerCase(); + } + + // Eliminate values if they're present in the default. + let chosen; + if (defaultAccept) { + // intl.accept_languages is a comma-separated list, with no q-value params. Those + // are added when the header is generated. + chosen = defaultAccept.split(",") + .map(String.trim) + .filter((x) => (x != appLocale && x != osLocale)); + } else { + chosen = []; + } + + if (osLocale) { + chosen.unshift(osLocale); + } + + if (appLocale && appLocale != osLocale) { + chosen.unshift(appLocale); + } + + let result = chosen.join(","); + console.log("Setting intl.accept_languages to " + result); + this.setLocalizedPref("intl.accept_languages", result); + }, + + // nsIAndroidBrowserApp + get selectedTab() { + return this._selectedTab; + }, + + // nsIAndroidBrowserApp + getBrowserTab: function(tabId) { + return this.getTabForId(tabId); + }, + + getUITelemetryObserver: function() { + return UITelemetry; + }, + + // This method will return a list of history items and toIndex based on the action provided from the fromIndex to toIndex, + // optionally selecting selIndex (if fromIndex <= selIndex <= toIndex) + getHistory: function(data) { + let action = data.action; + let webNav = BrowserApp.getTabForId(data.tabId).window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + let historyIndex = webNav.sessionHistory.index; + let historySize = webNav.sessionHistory.count; + let canGoBack = webNav.canGoBack; + let canGoForward = webNav.canGoForward; + let listitems = []; + let fromIndex = 0; + let toIndex = historySize - 1; + let selIndex = historyIndex; + + if (action == "BACK" && canGoBack) { + fromIndex = Math.max(historyIndex - kMaxHistoryListSize, 0); + toIndex = historyIndex; + selIndex = historyIndex; + } else if (action == "FORWARD" && canGoForward) { + fromIndex = historyIndex; + toIndex = Math.min(historySize - 1, historyIndex + kMaxHistoryListSize); + selIndex = historyIndex; + } else if (action == "ALL" && (canGoBack || canGoForward)){ + fromIndex = historyIndex - kMaxHistoryListSize / 2; + toIndex = historyIndex + kMaxHistoryListSize / 2; + if (fromIndex < 0) { + toIndex -= fromIndex; + } + + if (toIndex > historySize - 1) { + fromIndex -= toIndex - (historySize - 1); + toIndex = historySize - 1; + } + + fromIndex = Math.max(fromIndex, 0); + selIndex = historyIndex; + } else { + // return empty list immediately. + return { + "historyItems": listitems, + "toIndex": toIndex + }; + } + + let browser = this.selectedBrowser; + let hist = browser.sessionHistory; + for (let i = toIndex; i >= fromIndex; i--) { + let entry = hist.getEntryAtIndex(i, false); + let item = { + title: entry.title || entry.URI.spec, + url: entry.URI.spec, + selected: (i == selIndex) + }; + listitems.push(item); + } + + return { + "historyItems": listitems, + "toIndex": toIndex + }; + }, +}; + +var NativeWindow = { + init: function() { + Services.obs.addObserver(this, "Menu:Clicked", false); + Services.obs.addObserver(this, "Doorhanger:Reply", false); + this.contextmenus.init(); + }, + + loadDex: function(zipFile, implClass) { + Messaging.sendRequest({ + type: "Dex:Load", + zipfile: zipFile, + impl: implClass || "Main" + }); + }, + + unloadDex: function(zipFile) { + Messaging.sendRequest({ + type: "Dex:Unload", + zipfile: zipFile + }); + }, + + menu: { + _callbacks: [], + _menuId: 1, + toolsMenuID: -1, + add: function() { + let options; + if (arguments.length == 1) { + options = arguments[0]; + } else if (arguments.length == 3) { + Log.w("Browser", "This menu addon API has been deprecated. Instead, use the options object API."); + options = { + name: arguments[0], + callback: arguments[2] + }; + } else { + throw "Incorrect number of parameters"; + } + + options.type = "Menu:Add"; + options.id = this._menuId; + + Messaging.sendRequest(options); + this._callbacks[this._menuId] = options.callback; + this._menuId++; + return this._menuId - 1; + }, + + remove: function(aId) { + Messaging.sendRequest({ type: "Menu:Remove", id: aId }); + }, + + update: function(aId, aOptions) { + if (!aOptions) + return; + + Messaging.sendRequest({ + type: "Menu:Update", + id: aId, + options: aOptions + }); + } + }, + + doorhanger: { + _callbacks: {}, + _callbacksId: 0, + _promptId: 0, + + /** + * @param aOptions + * An options JavaScript object holding additional properties for the + * notification. The following properties are currently supported: + * persistence: An integer. The notification will not automatically + * dismiss for this many page loads. If persistence is set + * to -1, the doorhanger will never automatically dismiss. + * persistWhileVisible: + * A boolean. If true, a visible notification will always + * persist across location changes. + * timeout: A time in milliseconds. The notification will not + * automatically dismiss before this time. + * + * checkbox: A string to appear next to a checkbox under the notification + * message. The button callback functions will be called with + * the checked state as an argument. + * + * actionText: An object that specifies a clickable string, a type of action, + * and a bundle blob for the consumer to create a click action. + * { text: <text>, + * type: <type>, + * bundle: <blob-object> } + * + * @param aCategory + * Doorhanger type to display (e.g., LOGIN) + */ + show: function(aMessage, aValue, aButtons, aTabID, aOptions, aCategory) { + if (aButtons == null) { + aButtons = []; + } + + if (aButtons.length > 2) { + console.log("Doorhanger can have a maximum of two buttons!"); + aButtons.length = 2; + } + + aButtons.forEach((function(aButton) { + this._callbacks[this._callbacksId] = { cb: aButton.callback, prompt: this._promptId }; + aButton.callback = this._callbacksId; + this._callbacksId++; + }).bind(this)); + + this._promptId++; + let json = { + type: "Doorhanger:Add", + message: aMessage, + value: aValue, + buttons: aButtons, + // use the current tab if none is provided + tabID: aTabID || BrowserApp.selectedTab.id, + options: aOptions || {}, + category: aCategory + }; + Messaging.sendRequest(json); + }, + + hide: function(aValue, aTabID) { + Messaging.sendRequest({ + type: "Doorhanger:Remove", + value: aValue, + tabID: aTabID + }); + } + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic == "Menu:Clicked") { + if (this.menu._callbacks[aData]) + this.menu._callbacks[aData](); + } else if (aTopic == "Doorhanger:Reply") { + let data = JSON.parse(aData); + let reply_id = data["callback"]; + + if (this.doorhanger._callbacks[reply_id]) { + // Pass the value of the optional checkbox to the callback + let checked = data["checked"]; + this.doorhanger._callbacks[reply_id].cb(checked, data.inputs); + + let prompt = this.doorhanger._callbacks[reply_id].prompt; + for (let id in this.doorhanger._callbacks) { + if (this.doorhanger._callbacks[id].prompt == prompt) { + delete this.doorhanger._callbacks[id]; + } + } + } + } + }, + + contextmenus: { + items: {}, // a list of context menu items that we may show + DEFAULT_HTML5_ORDER: -1, // Sort order for HTML5 context menu items + + init: function() { + // Accessing "NativeWindow.contextmenus" initializes context menus if needed. + BrowserApp.deck.addEventListener( + "contextmenu", (e) => NativeWindow.contextmenus.show(e), false); + }, + + add: function() { + let args; + if (arguments.length == 1) { + args = arguments[0]; + } else if (arguments.length == 3) { + args = { + label : arguments[0], + selector: arguments[1], + callback: arguments[2] + }; + } else { + throw "Incorrect number of parameters"; + } + + if (!args.label) + throw "Menu items must have a name"; + + let cmItem = new ContextMenuItem(args); + this.items[cmItem.id] = cmItem; + return cmItem.id; + }, + + remove: function(aId) { + delete this.items[aId]; + }, + + // Although we do not use this ourselves anymore, add-ons may still + // need it as it has been documented, so we shouldn't remove it. + SelectorContext: function(aSelector) { + return { + matches: function(aElt) { + if (aElt.matches) + return aElt.matches(aSelector); + return false; + } + }; + }, + + linkOpenableNonPrivateContext: { + matches: function linkOpenableNonPrivateContextMatches(aElement) { + let doc = aElement.ownerDocument; + if (!doc || PrivateBrowsingUtils.isContentWindowPrivate(doc.defaultView)) { + return false; + } + + return NativeWindow.contextmenus.linkOpenableContext.matches(aElement); + } + }, + + linkOpenableContext: { + matches: function linkOpenableContextMatches(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri) { + let scheme = uri.scheme; + let dontOpen = /^(javascript|mailto|news|snews|tel)$/; + return (scheme && !dontOpen.test(scheme)); + } + return false; + } + }, + + linkCopyableContext: { + matches: function linkCopyableContextMatches(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri) { + let scheme = uri.scheme; + let dontCopy = /^(mailto|tel)$/; + return (scheme && !dontCopy.test(scheme)); + } + return false; + } + }, + + linkShareableContext: { + matches: function linkShareableContextMatches(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri) { + let scheme = uri.scheme; + let dontShare = /^(about|chrome|file|javascript|mailto|resource|tel)$/; + return (scheme && !dontShare.test(scheme)); + } + return false; + } + }, + + linkBookmarkableContext: { + matches: function linkBookmarkableContextMatches(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri) { + let scheme = uri.scheme; + let dontBookmark = /^(mailto|tel)$/; + return (scheme && !dontBookmark.test(scheme)); + } + return false; + } + }, + + emailLinkContext: { + matches: function emailLinkContextMatches(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri) + return uri.schemeIs("mailto"); + return false; + } + }, + + phoneNumberLinkContext: { + matches: function phoneNumberLinkContextMatches(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri) + return uri.schemeIs("tel"); + return false; + } + }, + + imageLocationCopyableContext: { + matches: function imageLinkCopyableContextMatches(aElement) { + if (aElement instanceof Ci.nsIDOMHTMLImageElement) { + // The image is blocked by Tap-to-load Images + if (aElement.hasAttribute("data-ctv-src") && !aElement.hasAttribute("data-ctv-show")) { + return false; + } + } + return (aElement instanceof Ci.nsIImageLoadingContent && aElement.currentURI); + } + }, + + imageSaveableContext: { + matches: function imageSaveableContextMatches(aElement) { + if (aElement instanceof Ci.nsIDOMHTMLImageElement) { + // The image is blocked by Tap-to-load Images + if (aElement.hasAttribute("data-ctv-src") && !aElement.hasAttribute("data-ctv-show")) { + return false; + } + } + if (aElement instanceof Ci.nsIImageLoadingContent && aElement.currentURI) { + // The image must be loaded to allow saving + let request = aElement.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST); + return (request && (request.imageStatus & request.STATUS_SIZE_AVAILABLE)); + } + return false; + } + }, + + imageShareableContext: { + matches: function imageShareableContextMatches(aElement) { + let imgSrc = ''; + if (aElement instanceof Ci.nsIDOMHTMLImageElement) { + imgSrc = aElement.src; + } else if (aElement instanceof Ci.nsIImageLoadingContent && + aElement.currentURI && + aElement.currentURI.spec) { + imgSrc = aElement.currentURI.spec; + } + + // In order to share an image, we need to pass the image src over IPC via an Intent (in + // `ApplicationPackageManager.queryIntentActivities`). However, the transaction has a 1MB limit + // (shared by all transactions in progress) - otherwise we crash! (bug 1243305) + // https://developer.android.com/reference/android/os/TransactionTooLargeException.html + // + // The transaction limit is 1MB and we arbitrarily choose to cap this transaction at 1/4 of that = 250,000 bytes. + // In Java, a UTF-8 character is 1-4 bytes so, 250,000 bytes / 4 bytes/char = 62,500 char + let MAX_IMG_SRC_LEN = 62500; + let isTooLong = imgSrc.length >= MAX_IMG_SRC_LEN; + return !isTooLong && this.NativeWindow.contextmenus.imageSaveableContext.matches(aElement); + }.bind(this) + }, + + mediaSaveableContext: { + matches: function mediaSaveableContextMatches(aElement) { + return (aElement instanceof HTMLVideoElement || + aElement instanceof HTMLAudioElement); + } + }, + + imageBlockingPolicyContext: { + matches: function imageBlockingPolicyContextMatches(aElement) { + if (aElement instanceof Ci.nsIDOMHTMLImageElement && aElement.getAttribute("data-ctv-src")) { + // Only show the menuitem if we are blocking the image + if (aElement.getAttribute("data-ctv-show") == "true") { + return false; + } + return true; + } + return false; + } + }, + + mediaContext: function(aMode) { + return { + matches: function(aElt) { + if (aElt instanceof Ci.nsIDOMHTMLMediaElement) { + let hasError = aElt.error != null || aElt.networkState == aElt.NETWORK_NO_SOURCE; + if (hasError) + return false; + + let paused = aElt.paused || aElt.ended; + if (paused && aMode == "media-paused") + return true; + if (!paused && aMode == "media-playing") + return true; + let controls = aElt.controls; + if (!controls && aMode == "media-hidingcontrols") + return true; + + let muted = aElt.muted; + if (muted && aMode == "media-muted") + return true; + else if (!muted && aMode == "media-unmuted") + return true; + } + return false; + } + }; + }, + + videoContext: function(aMode) { + return { + matches: function(aElt) { + if (aElt instanceof HTMLVideoElement) { + if (!aMode) { + return true; + } + var isFullscreen = aElt.ownerDocument.fullscreenElement == aElt; + if (aMode == "not-fullscreen") { + return !isFullscreen; + } + if (aMode == "fullscreen") { + return isFullscreen; + } + } + return false; + } + }; + }, + + /* Holds a WeakRef to the original target element this context menu was shown for. + * Most API's will have to walk up the tree from this node to find the correct element + * to act on + */ + get _target() { + if (this._targetRef) + return this._targetRef.get(); + return null; + }, + + set _target(aTarget) { + if (aTarget) + this._targetRef = Cu.getWeakReference(aTarget); + else this._targetRef = null; + }, + + get defaultContext() { + delete this.defaultContext; + return this.defaultContext = Strings.browser.GetStringFromName("browser.menu.context.default"); + }, + + /* Gets menuitems for an arbitrary node + * Parameters: + * element - The element to look at. If this element has a contextmenu attribute, the + * corresponding contextmenu will be used. + */ + _getHTMLContextMenuItemsForElement: function(element) { + let htmlMenu = element.contextMenu; + if (!htmlMenu) { + return []; + } + + htmlMenu.QueryInterface(Components.interfaces.nsIHTMLMenu); + htmlMenu.sendShowEvent(); + + return this._getHTMLContextMenuItemsForMenu(htmlMenu, element); + }, + + /* Add a menuitem for an HTML <menu> node + * Parameters: + * menu - The <menu> element to iterate through for menuitems + * target - The target element these context menu items are attached to + */ + _getHTMLContextMenuItemsForMenu: function(menu, target) { + let items = []; + for (let i = 0; i < menu.childNodes.length; i++) { + let elt = menu.childNodes[i]; + if (!elt.label) + continue; + + items.push(new HTMLContextMenuItem(elt, target)); + } + + return items; + }, + + // Searches the current list of menuitems to show for any that match this id + _findMenuItem: function(aId) { + if (!this.menus) { + return null; + } + + for (let context in this.menus) { + let menu = this.menus[context]; + for (let i = 0; i < menu.length; i++) { + if (menu[i].id === aId) { + return menu[i]; + } + } + } + return null; + }, + + // Returns true if there are any context menu items to show + _shouldShow: function() { + for (let context in this.menus) { + let menu = this.menus[context]; + if (menu.length > 0) { + return true; + } + } + return false; + }, + + /* Returns a label to be shown in a tabbed ui if there are multiple "contexts". For instance, if this + * is an image inside an <a> tag, we may have a "link" context and an "image" one. + */ + _getContextType: function(element) { + // For anchor nodes, we try to use the scheme to pick a string + if (element instanceof Ci.nsIDOMHTMLAnchorElement) { + let uri = this.makeURI(this._getLinkURL(element)); + try { + return Strings.browser.GetStringFromName("browser.menu.context." + uri.scheme); + } catch(ex) { } + } + + // Otherwise we try the nodeName + try { + return Strings.browser.GetStringFromName("browser.menu.context." + element.nodeName.toLowerCase()); + } catch(ex) { } + + // Fallback to the default + return this.defaultContext; + }, + + // Adds context menu items added through the add-on api + _getNativeContextMenuItems: function(element, x, y) { + let res = []; + for (let itemId of Object.keys(this.items)) { + let item = this.items[itemId]; + + if (!this._findMenuItem(item.id) && item.matches(element, x, y)) { + res.push(item); + } + } + + return res; + }, + + /* Checks if there are context menu items to show, and if it finds them + * sends a contextmenu event to content. We also send showing events to + * any html5 context menus we are about to show, and fire some local notifications + * for chrome consumers to do lazy menuitem construction + */ + show: function(event) { + // Android Long-press / contextmenu event provides clientX/Y data. This is not provided + // by mochitest: test_browserElement_inproc_ContextmenuEvents.html. + if (!event.clientX || !event.clientY) { + return; + } + + // If the event was already defaultPrevented by somebody (web content, or + // some other part of gecko), then don't do anything with it. + if (event.defaultPrevented) { + return; + } + + // Use the highlighted element for the context menu target. When accessibility is + // enabled, elements may not be highlighted so use the event target instead. + this._target = BrowserEventHandler._highlightElement || event.target; + if (!this._target) { + return; + } + + // Try to build a list of contextmenu items. If successful, actually show the + // native context menu by passing the list to Java. + this._buildMenu(event.clientX, event.clientY); + if (this._shouldShow()) { + BrowserEventHandler._cancelTapHighlight(); + + // Consume / preventDefault the event, and show the contextmenu. + event.preventDefault(); + this._innerShow(this._target, event.clientX, event.clientY); + this._target = null; + + return; + } + + // If no context-menu for long-press event, it may be meant to trigger text-selection. + this.menus = null; + Services.obs.notifyObservers( + {target: this._target, x: event.clientX, y: event.clientY}, "context-menu-not-shown", ""); + }, + + // Returns a title for a context menu. If no title attribute exists, will fall back to looking for a url + _getTitle: function(node) { + if (node.hasAttribute && node.hasAttribute("title")) { + return node.getAttribute("title"); + } + return this._getUrl(node); + }, + + // Returns a url associated with a node + _getUrl: function(node) { + if ((node instanceof Ci.nsIDOMHTMLAnchorElement && node.href) || + (node instanceof Ci.nsIDOMHTMLAreaElement && node.href)) { + return this._getLinkURL(node); + } else if (node instanceof Ci.nsIImageLoadingContent && node.currentURI) { + // The image is blocked by Tap-to-load Images + let originalURL = node.getAttribute("data-ctv-src"); + if (originalURL) { + return originalURL; + } + return node.currentURI.spec; + } else if (node instanceof Ci.nsIDOMHTMLMediaElement) { + let srcUrl = node.currentSrc || node.src; + // If URL prepended with blob or mediasource, we'll remove it. + return srcUrl.replace(/^(?:blob|mediasource):/, ''); + } + + return ""; + }, + + // Adds an array of menuitems to the current list of items to show, in the correct context + _addMenuItems: function(items, context) { + if (!this.menus[context]) { + this.menus[context] = []; + } + this.menus[context] = this.menus[context].concat(items); + }, + + /* Does the basic work of building a context menu to show. Will combine HTML and Native + * context menus items, as well as sorting menuitems into different menus based on context. + */ + _buildMenu: function(x, y) { + // now walk up the tree and for each node look for any context menu items that apply + let element = this._target; + + // this.menus holds a hashmap of "contexts" to menuitems associated with that context + // For instance, if the user taps an image inside a link, we'll have something like: + // { + // link: [ ContextMenuItem, ContextMenuItem ] + // image: [ ContextMenuItem, ContextMenuItem ] + // } + this.menus = {}; + + while (element) { + let context = this._getContextType(element); + + // First check for any html5 context menus that might exist... + var items = this._getHTMLContextMenuItemsForElement(element); + if (items.length > 0) { + this._addMenuItems(items, context); + } + + // then check for any context menu items registered in the ui. + items = this._getNativeContextMenuItems(element, x, y); + if (items.length > 0) { + this._addMenuItems(items, context); + } + + // walk up the tree and find more items to show + element = element.parentNode; + } + }, + + // Walks the DOM tree to find a title from a node + _findTitle: function(node) { + let title = ""; + while(node && !title) { + title = this._getTitle(node); + node = node.parentNode; + } + return title; + }, + + /* Reformats the list of menus to show into an object that can be sent to Prompt.jsm + * If there is one menu, will return a flat array of menuitems. If there are multiple + * menus, will return an array with appropriate tabs/items inside it. i.e. : + * [ + * { label: "link", items: [...] }, + * { label: "image", items: [...] } + * ] + */ + _reformatList: function(target) { + let contexts = Object.keys(this.menus); + + if (contexts.length === 1) { + // If there's only one context, we'll only show a single flat single select list + return this._reformatMenuItems(target, this.menus[contexts[0]]); + } + + // If there are multiple contexts, we'll only show a tabbed ui with multiple lists + return this._reformatListAsTabs(target, this.menus); + }, + + /* Reformats the list of menus to show into an object that can be sent to Prompt.jsm's + * addTabs method. i.e. : + * { link: [...], image: [...] } becomes + * [ { label: "link", items: [...] } ] + * + * Also reformats items and resolves any parmaeters that aren't known until display time + * (for instance Helper app menu items adjust their title to reflect what Helper App can be used for this link). + */ + _reformatListAsTabs: function(target, menus) { + let itemArray = []; + + // Sort the keys so that "link" is always first + let contexts = Object.keys(this.menus); + contexts.sort((context1, context2) => { + if (context1 === this.defaultContext) { + return -1; + } else if (context2 === this.defaultContext) { + return 1; + } + return 0; + }); + + contexts.forEach(context => { + itemArray.push({ + label: context, + items: this._reformatMenuItems(target, menus[context]) + }); + }); + + return itemArray; + }, + + /* Reformats an array of ContextMenuItems into an array that can be handled by Prompt.jsm. Also reformats items + * and resolves any parmaeters that aren't known until display time + * (for instance Helper app menu items adjust their title to reflect what Helper App can be used for this link). + */ + _reformatMenuItems: function(target, menuitems) { + let itemArray = []; + + for (let i = 0; i < menuitems.length; i++) { + let t = target; + while(t) { + if (menuitems[i].matches(t)) { + let val = menuitems[i].getValue(t); + + // hidden menu items will return null from getValue + if (val) { + itemArray.push(val); + break; + } + } + + t = t.parentNode; + } + } + + return itemArray; + }, + + // Called where we're finally ready to actually show the contextmenu. Sorts the items and shows a prompt. + _innerShow: function(target, x, y) { + Haptic.performSimpleAction(Haptic.LongPress); + + // spin through the tree looking for a title for this context menu + let title = this._findTitle(target); + + for (let context in this.menus) { + let menu = this.menus[context]; + menu.sort((a,b) => { + if (a.order === b.order) { + return 0; + } + return (a.order > b.order) ? 1 : -1; + }); + } + + let useTabs = Object.keys(this.menus).length > 1; + let prompt = new Prompt({ + window: target.ownerDocument.defaultView, + title: useTabs ? undefined : title + }); + + let items = this._reformatList(target); + if (useTabs) { + prompt.addTabs({ + id: "tabs", + items: items + }); + } else { + prompt.setSingleChoiceItems(items); + } + + prompt.show(this._promptDone.bind(this, target, x, y, items)); + }, + + // Called when the contextmenu prompt is closed + _promptDone: function(target, x, y, items, data) { + if (data.button == -1) { + // Prompt was cancelled, or an ActionView was used. + return; + } + + let selectedItemId; + if (data.tabs) { + let menu = items[data.tabs.tab]; + selectedItemId = menu.items[data.tabs.item].id; + } else { + selectedItemId = items[data.list[0]].id + } + + let selectedItem = this._findMenuItem(selectedItemId); + this.menus = null; + + if (!selectedItem || !selectedItem.matches || !selectedItem.callback) { + return; + } + + // for menuitems added using the native UI, pass the dom element that matched that item to the callback + while (target) { + if (selectedItem.matches(target, x, y)) { + selectedItem.callback(target, x, y); + break; + } + target = target.parentNode; + } + }, + + // XXX - These are stolen from Util.js, we should remove them if we bring it back + makeURLAbsolute: function makeURLAbsolute(base, url) { + // Note: makeURI() will throw if url is not a valid URI + return this.makeURI(url, null, this.makeURI(base)).spec; + }, + + makeURI: function makeURI(aURL, aOriginCharset, aBaseURI) { + return Services.io.newURI(aURL, aOriginCharset, aBaseURI); + }, + + _getLink: function(aElement) { + if (aElement.nodeType == Ci.nsIDOMNode.ELEMENT_NODE && + ((aElement instanceof Ci.nsIDOMHTMLAnchorElement && aElement.href) || + (aElement instanceof Ci.nsIDOMHTMLAreaElement && aElement.href) || + aElement instanceof Ci.nsIDOMHTMLLinkElement || + aElement.getAttributeNS(kXLinkNamespace, "type") == "simple")) { + try { + let url = this._getLinkURL(aElement); + return Services.io.newURI(url, null, null); + } catch (e) {} + } + return null; + }, + + _disableRestricted: function _disableRestricted(restriction, selector) { + return { + matches: function _disableRestrictedMatches(aElement, aX, aY) { + if (!ParentalControls.isAllowed(ParentalControls[restriction])) { + return false; + } + + return selector.matches(aElement, aX, aY); + } + }; + }, + + _getLinkURL: function ch_getLinkURL(aLink) { + let href = aLink.href; + if (href) + return href; + + href = aLink.getAttribute("href") || + aLink.getAttributeNS(kXLinkNamespace, "href"); + if (!href || !href.match(/\S/)) { + // Without this we try to save as the current doc, + // for example, HTML case also throws if empty + throw "Empty href"; + } + + return this.makeURLAbsolute(aLink.baseURI, href); + }, + + _copyStringToDefaultClipboard: function(aString) { + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); + clipboard.copyString(aString); + Snackbars.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), Snackbars.LENGTH_LONG); + }, + + _stripScheme: function(aString) { + let index = aString.indexOf(":"); + return aString.slice(index + 1); + } + } +}; + +XPCOMUtils.defineLazyModuleGetter(this, "PageActions", + "resource://gre/modules/PageActions.jsm"); + +// These alias to the old, deprecated NativeWindow interfaces +[ + ["pageactions", "resource://gre/modules/PageActions.jsm", "PageActions"], + ["toast", "resource://gre/modules/Snackbars.jsm", "Snackbars"] +].forEach(item => { + let [name, script, exprt] = item; + + XPCOMUtils.defineLazyGetter(NativeWindow, name, () => { + var err = Strings.browser.formatStringFromName("nativeWindow.deprecated", ["NativeWindow." + name, script], 2); + Cu.reportError(err); + + let sandbox = {}; + Cu.import(script, sandbox); + return sandbox[exprt]; + }); +}); + +var LightWeightThemeWebInstaller = { + init: function sh_init() { + let temp = {}; + Cu.import("resource://gre/modules/LightweightThemeConsumer.jsm", temp); + let theme = new temp.LightweightThemeConsumer(document); + BrowserApp.deck.addEventListener("InstallBrowserTheme", this, false, true); + BrowserApp.deck.addEventListener("PreviewBrowserTheme", this, false, true); + BrowserApp.deck.addEventListener("ResetBrowserThemePreview", this, false, true); + + if (ParentalControls.parentalControlsEnabled && + !this._manager.currentTheme && + ParentalControls.isAllowed(ParentalControls.DEFAULT_THEME)) { + // We are using the DEFAULT_THEME restriction to differentiate between restricted profiles & guest mode - Bug 1199596 + this._installParentalControlsTheme(); + } + }, + + handleEvent: function (event) { + switch (event.type) { + case "InstallBrowserTheme": + case "PreviewBrowserTheme": + case "ResetBrowserThemePreview": + // ignore requests from background tabs + if (event.target.ownerDocument.defaultView.top != content) + return; + } + + switch (event.type) { + case "InstallBrowserTheme": + this._installRequest(event); + break; + case "PreviewBrowserTheme": + this._preview(event); + break; + case "ResetBrowserThemePreview": + this._resetPreview(event); + break; + case "pagehide": + case "TabSelect": + this._resetPreview(); + break; + } + }, + + get _manager () { + let temp = {}; + Cu.import("resource://gre/modules/LightweightThemeManager.jsm", temp); + delete this._manager; + return this._manager = temp.LightweightThemeManager; + }, + + _installParentalControlsTheme: function() { + let mgr = this._manager; + let parentalControlsTheme = { + "headerURL": "resource://android/assets/parental_controls_theme.png", + "name": "Parental Controls Theme", + "id": "parental-controls-theme@mozilla.org" + }; + + mgr.addBuiltInTheme(parentalControlsTheme); + mgr.themeChanged(parentalControlsTheme); + }, + + _installRequest: function (event) { + let node = event.target; + let data = this._getThemeFromNode(node); + if (!data) + return; + + if (this._isAllowed(node)) { + this._install(data); + return; + } + + let allowButtonText = Strings.browser.GetStringFromName("lwthemeInstallRequest.allowButton"); + let message = Strings.browser.formatStringFromName("lwthemeInstallRequest.message", [node.ownerDocument.location.hostname], 1); + let buttons = [{ + label: allowButtonText, + callback: function () { + LightWeightThemeWebInstaller._install(data); + }, + positive: true + }]; + + NativeWindow.doorhanger.show(message, "Personas", buttons, BrowserApp.selectedTab.id); + }, + + _install: function (newLWTheme) { + this._manager.currentTheme = newLWTheme; + }, + + _previewWindow: null, + _preview: function (event) { + if (!this._isAllowed(event.target)) + return; + let data = this._getThemeFromNode(event.target); + if (!data) + return; + this._resetPreview(); + + this._previewWindow = event.target.ownerDocument.defaultView; + this._previewWindow.addEventListener("pagehide", this, true); + BrowserApp.deck.addEventListener("TabSelect", this, false); + this._manager.previewTheme(data); + }, + + _resetPreview: function (event) { + if (!this._previewWindow || + event && !this._isAllowed(event.target)) + return; + + this._previewWindow.removeEventListener("pagehide", this, true); + this._previewWindow = null; + BrowserApp.deck.removeEventListener("TabSelect", this, false); + + this._manager.resetPreview(); + }, + + _isAllowed: function (node) { + // Make sure the whitelist has been imported to permissions + PermissionsUtils.importFromPrefs("xpinstall.", "install"); + + let pm = Services.perms; + + let uri = node.ownerDocument.documentURIObject; + if (!uri.schemeIs("https")) { + return false; + } + + return pm.testPermission(uri, "install") == pm.ALLOW_ACTION; + }, + + _getThemeFromNode: function (node) { + return this._manager.parseTheme(node.getAttribute("data-browsertheme"), node.baseURI); + } +}; + +var DesktopUserAgent = { + DESKTOP_UA: null, + TCO_DOMAIN: "t.co", + TCO_REPLACE: / Gecko.*/, + + init: function ua_init() { + Services.obs.addObserver(this, "DesktopMode:Change", false); + UserAgentOverrides.addComplexOverride(this.onRequest.bind(this)); + + // See https://developer.mozilla.org/en/Gecko_user_agent_string_reference + this.DESKTOP_UA = Cc["@mozilla.org/network/protocol;1?name=http"] + .getService(Ci.nsIHttpProtocolHandler).userAgent + .replace(/Android \d.+?; [a-zA-Z]+/, "X11; Linux x86_64") + .replace(/Gecko\/[0-9\.]+/, "Gecko/20100101"); + }, + + onRequest: function(channel, defaultUA) { + if (AppConstants.NIGHTLY_BUILD && this.TCO_DOMAIN == channel.URI.host) { + // Force the referrer + channel.referrer = channel.URI; + + // Send a bot-like UA to t.co to get a real redirect. We strip off the + // "Gecko/x.y Firefox/x.y" part + return defaultUA.replace(this.TCO_REPLACE, ""); + } + + let channelWindow = this._getWindowForRequest(channel); + let tab = BrowserApp.getTabForWindow(channelWindow); + if (tab) { + return this.getUserAgentForTab(tab); + } + + return null; + }, + + getUserAgentForWindow: function ua_getUserAgentForWindow(aWindow) { + let tab = BrowserApp.getTabForWindow(aWindow.top); + if (tab) { + return this.getUserAgentForTab(tab); + } + + return null; + }, + + getUserAgentForTab: function ua_getUserAgentForTab(aTab) { + // Send desktop UA if "Request Desktop Site" is enabled. + if (aTab.desktopMode) { + return this.DESKTOP_UA; + } + + return null; + }, + + _getRequestLoadContext: function ua_getRequestLoadContext(aRequest) { + if (aRequest && aRequest.notificationCallbacks) { + try { + return aRequest.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (ex) { } + } + + if (aRequest && aRequest.loadGroup && aRequest.loadGroup.notificationCallbacks) { + try { + return aRequest.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (ex) { } + } + + return null; + }, + + _getWindowForRequest: function ua_getWindowForRequest(aRequest) { + let loadContext = this._getRequestLoadContext(aRequest); + if (loadContext) { + try { + return loadContext.associatedWindow; + } catch (e) { + // loadContext.associatedWindow can throw when there's no window + } + } + return null; + }, + + observe: function ua_observe(aSubject, aTopic, aData) { + if (aTopic === "DesktopMode:Change") { + let args = JSON.parse(aData); + let tab = BrowserApp.getTabForId(args.tabId); + if (tab) { + tab.reloadWithMode(args.desktopMode); + } + } + } +}; + + +function nsBrowserAccess() { +} + +nsBrowserAccess.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIBrowserDOMWindow]), + + _getBrowser: function _getBrowser(aURI, aOpener, aWhere, aFlags) { + let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL); + if (isExternal && aURI && aURI.schemeIs("chrome")) + return null; + + let loadflags = isExternal ? + Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL : + Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_DEFAULTWINDOW) { + if (isExternal) { + aWhere = Services.prefs.getIntPref("browser.link.open_external"); + } else { + aWhere = Services.prefs.getIntPref("browser.link.open_newwindow"); + } + } + + Services.io.offline = false; + + let referrer; + if (aOpener) { + try { + let location = aOpener.location; + referrer = Services.io.newURI(location, null, null); + } catch(e) { } + } + + let ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + let pinned = false; + + if (aURI && aWhere == Ci.nsIBrowserDOMWindow.OPEN_SWITCHTAB) { + pinned = true; + let spec = aURI.spec; + let tabs = BrowserApp.tabs; + for (let i = 0; i < tabs.length; i++) { + let appOrigin = ss.getTabValue(tabs[i], "appOrigin"); + if (appOrigin == spec) { + let tab = tabs[i]; + BrowserApp.selectTab(tab); + return tab.browser; + } + } + } + + // If OPEN_SWITCHTAB was not handled above, we need to open a new tab, + // along with other OPEN_ values that create a new tab. + let newTab = (aWhere == Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW || + aWhere == Ci.nsIBrowserDOMWindow.OPEN_NEWTAB || + aWhere == Ci.nsIBrowserDOMWindow.OPEN_SWITCHTAB); + let isPrivate = false; + + if (newTab) { + let parentId = -1; + if (!isExternal && aOpener) { + let parent = BrowserApp.getTabForWindow(aOpener.top); + if (parent) { + parentId = parent.id; + isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser); + } + } + + let openerWindow = (aFlags & Ci.nsIBrowserDOMWindow.OPEN_NO_OPENER) ? null : aOpener; + // BrowserApp.addTab calls loadURIWithFlags with the appropriate params + let tab = BrowserApp.addTab(aURI ? aURI.spec : "about:blank", { flags: loadflags, + referrerURI: referrer, + external: isExternal, + parentId: parentId, + opener: openerWindow, + selected: true, + isPrivate: isPrivate, + pinned: pinned }); + + return tab.browser; + } + + // OPEN_CURRENTWINDOW and illegal values + let browser = BrowserApp.selectedBrowser; + if (aURI && browser) { + browser.loadURIWithFlags(aURI.spec, loadflags, referrer, null, null); + } + + return browser; + }, + + openURI: function browser_openURI(aURI, aOpener, aWhere, aFlags) { + let browser = this._getBrowser(aURI, aOpener, aWhere, aFlags); + return browser ? browser.contentWindow : null; + }, + + openURIInFrame: function browser_openURIInFrame(aURI, aParams, aWhere, aFlags) { + let browser = this._getBrowser(aURI, null, aWhere, aFlags); + return browser ? browser.QueryInterface(Ci.nsIFrameLoaderOwner) : null; + }, + + isTabContentWindow: function(aWindow) { + return BrowserApp.getBrowserForWindow(aWindow) != null; + }, + + canClose() { + return BrowserUtils.canCloseWindow(window); + }, +}; + + +function Tab(aURL, aParams) { + this.filter = null; + this.browser = null; + this.id = 0; + this.lastTouchedAt = Date.now(); + this._zoom = 1.0; + this._drawZoom = 1.0; + this._restoreZoom = false; + this.userScrollPos = { x: 0, y: 0 }; + this.contentDocumentIsDisplayed = true; + this.pluginDoorhangerTimeout = null; + this.shouldShowPluginDoorhanger = true; + this.clickToPlayPluginsActivated = false; + this.desktopMode = false; + this.originalURI = null; + this.hasTouchListener = false; + this.playingAudio = false; + + this.create(aURL, aParams); +} + +/* + * Sanity limit for URIs passed to UI code. + * + * 2000 is the typical industry limit, largely due to older IE versions. + * + * We use 25000, so we'll allow almost any value through. + * + * Still, this truncation doesn't affect history, so this is only a practical + * concern in two ways: the truncated value is used when editing URIs, and as + * the key for favicon fetches. + */ +const MAX_URI_LENGTH = 25000; + +/* + * Similar restriction for titles. This is only a display concern. + */ +const MAX_TITLE_LENGTH = 255; + +/** + * Ensure that a string is of a sane length. + */ +function truncate(text, max) { + if (!text || !max) { + return text; + } + + if (text.length <= max) { + return text; + } + + return text.slice(0, max) + "…"; +} + +Tab.prototype = { + create: function(aURL, aParams) { + if (this.browser) + return; + + aParams = aParams || {}; + + this.browser = document.createElement("browser"); + this.browser.setAttribute("type", "content-targetable"); + this.browser.setAttribute("messagemanagergroup", "browsers"); + + if (Preferences.get("browser.tabs.remote.force-enable", false)) { + this.browser.setAttribute("remote", "true"); + } + + this.browser.permanentKey = {}; + + // Check if we have a "parent" window which we need to set as our opener + if ("opener" in aParams) { + this.browser.presetOpenerWindow(aParams.opener); + } + + // Make sure the previously selected panel remains selected. The selected panel of a deck is + // not stable when panels are added. + let selectedPanel = BrowserApp.deck.selectedPanel; + BrowserApp.deck.insertBefore(this.browser, aParams.sibling || null); + BrowserApp.deck.selectedPanel = selectedPanel; + + let attrs = {}; + if (BrowserApp.manifestUrl) { + let appsService = Cc["@mozilla.org/AppsService;1"].getService(Ci.nsIAppsService); + let manifest = appsService.getAppByManifestURL(BrowserApp.manifestUrl); + if (manifest) { + let app = manifest.QueryInterface(Ci.mozIApplication); + this.browser.docShell.frameType = Ci.nsIDocShell.FRAME_TYPE_APP; + attrs['appId'] = app.localId; + } + } + + // Must be called after appendChild so the docShell has been created. + this.setActive(false); + + let isPrivate = ("isPrivate" in aParams) && aParams.isPrivate; + if (isPrivate) { + attrs['privateBrowsingId'] = 1; + } + + this.browser.docShell.setOriginAttributes(attrs); + + // Set the new docShell load flags based on network state. + if (Tabs.useCache) { + this.browser.docShell.defaultLoadFlags |= Ci.nsIRequest.LOAD_FROM_CACHE; + } + + this.browser.stop(); + + // Only set tab uri if uri is valid + let uri = null; + let title = aParams.title || aURL; + try { + uri = Services.io.newURI(aURL, null, null).spec; + } catch (e) {} + + // When the tab is stubbed from Java, there's a window between the stub + // creation and the tab creation in Gecko where the stub could be removed + // or the selected tab can change (which is easiest to hit during startup). + // To prevent these races, we need to differentiate between tab stubs from + // Java and new tabs from Gecko. + let stub = false; + + if (!aParams.zombifying) { + if ("tabID" in aParams) { + this.id = aParams.tabID; + stub = true; + } else { + let jenv = JNI.GetForThread(); + let jTabs = JNI.LoadClass(jenv, "org.mozilla.gecko.Tabs", { + static_methods: [ + { name: "getNextTabId", sig: "()I" } + ], + }); + this.id = jTabs.getNextTabId(); + JNI.UnloadClasses(jenv); + } + + this.desktopMode = ("desktopMode" in aParams) ? aParams.desktopMode : false; + + let message = { + type: "Tab:Added", + tabID: this.id, + uri: truncate(uri, MAX_URI_LENGTH), + parentId: ("parentId" in aParams) ? aParams.parentId : -1, + tabIndex: ("tabIndex" in aParams) ? aParams.tabIndex : -1, + external: ("external" in aParams) ? aParams.external : false, + selected: ("selected" in aParams || aParams.cancelEditMode === true) ? aParams.selected : true, + cancelEditMode: aParams.cancelEditMode === true, + title: truncate(title, MAX_TITLE_LENGTH), + delayLoad: aParams.delayLoad || false, + desktopMode: this.desktopMode, + isPrivate: isPrivate, + stub: stub + }; + Messaging.sendRequest(message); + } + + let flags = Ci.nsIWebProgress.NOTIFY_STATE_ALL | + Ci.nsIWebProgress.NOTIFY_LOCATION | + Ci.nsIWebProgress.NOTIFY_SECURITY; + this.filter = Cc["@mozilla.org/appshell/component/browser-status-filter;1"].createInstance(Ci.nsIWebProgress); + this.filter.addProgressListener(this, flags) + this.browser.addProgressListener(this.filter, flags); + this.browser.sessionHistory.addSHistoryListener(this); + + this.browser.addEventListener("DOMContentLoaded", this, true); + this.browser.addEventListener("DOMFormHasPassword", this, true); + this.browser.addEventListener("DOMInputPasswordAdded", this, true); + this.browser.addEventListener("DOMLinkAdded", this, true); + this.browser.addEventListener("DOMLinkChanged", this, true); + this.browser.addEventListener("DOMMetaAdded", this, false); + this.browser.addEventListener("DOMTitleChanged", this, true); + this.browser.addEventListener("DOMAudioPlaybackStarted", this, true); + this.browser.addEventListener("DOMAudioPlaybackStopped", this, true); + this.browser.addEventListener("DOMWindowClose", this, true); + this.browser.addEventListener("DOMWillOpenModalDialog", this, true); + this.browser.addEventListener("DOMAutoComplete", this, true); + this.browser.addEventListener("blur", this, true); + this.browser.addEventListener("pageshow", this, true); + this.browser.addEventListener("MozApplicationManifest", this, true); + this.browser.addEventListener("TabPreZombify", this, true); + + // Note that the XBL binding is untrusted + this.browser.addEventListener("PluginBindingAttached", this, true, true); + this.browser.addEventListener("VideoBindingAttached", this, true, true); + this.browser.addEventListener("VideoBindingCast", this, true, true); + + Services.obs.addObserver(this, "before-first-paint", false); + Services.obs.addObserver(this, "media-playback", false); + Services.obs.addObserver(this, "media-playback-resumed", false); + + // Always intialise new tabs with basic session store data to avoid + // problems with functions that always expect it to be present + this.browser.__SS_data = { + entries: [{ + url: aURL, + title: truncate(title, MAX_TITLE_LENGTH) + }], + index: 1, + desktopMode: this.desktopMode, + isPrivate: isPrivate + }; + + if (aParams.delayLoad) { + // If this is a zombie tab, mark the browser for delay loading, which will + // restore the tab when selected using the session data added above + this.browser.__SS_restore = true; + } else { + let flags = "flags" in aParams ? aParams.flags : Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let postData = ("postData" in aParams && aParams.postData) ? aParams.postData.value : null; + let referrerURI = "referrerURI" in aParams ? aParams.referrerURI : null; + let charset = "charset" in aParams ? aParams.charset : null; + + // The search term the user entered to load the current URL + this.userRequested = "userRequested" in aParams ? aParams.userRequested : ""; + this.isSearch = "isSearch" in aParams ? aParams.isSearch : false; + + try { + this.browser.loadURIWithFlags(aURL, flags, referrerURI, charset, postData); + } catch(e) { + let message = { + type: "Content:LoadError", + tabID: this.id + }; + Messaging.sendRequest(message); + dump("Handled load error: " + e); + } + } + }, + + /** + * Reloads the tab with the desktop mode setting. + */ + reloadWithMode: function (aDesktopMode) { + // notify desktopmode for PIDOMWindow + let win = this.browser.contentWindow; + let dwi = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + dwi.setDesktopModeViewport(aDesktopMode); + + // Set desktop mode for tab and send change to Java + if (this.desktopMode != aDesktopMode) { + this.desktopMode = aDesktopMode; + Messaging.sendRequest({ + type: "DesktopMode:Changed", + desktopMode: aDesktopMode, + tabID: this.id + }); + } + + // Only reload the page for http/https schemes + let currentURI = this.browser.currentURI; + if (!currentURI.schemeIs("http") && !currentURI.schemeIs("https")) + return; + + let url = currentURI.spec; + // We need LOAD_FLAGS_BYPASS_CACHE here since we're changing the User-Agent + // string, and servers typically don't use the Vary: User-Agent header, so + // not doing this means that we'd get some of the previously cached content. + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE | + Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + if (this.originalURI && !this.originalURI.equals(currentURI)) { + // We were redirected; reload the original URL + url = this.originalURI.spec; + } + + this.browser.docShell.loadURI(url, flags, null, null, null); + }, + + destroy: function() { + if (!this.browser) + return; + + this.browser.removeProgressListener(this.filter); + this.filter.removeProgressListener(this); + this.filter = null; + this.browser.sessionHistory.removeSHistoryListener(this); + + this.browser.removeEventListener("DOMContentLoaded", this, true); + this.browser.removeEventListener("DOMFormHasPassword", this, true); + this.browser.removeEventListener("DOMInputPasswordAdded", this, true); + this.browser.removeEventListener("DOMLinkAdded", this, true); + this.browser.removeEventListener("DOMLinkChanged", this, true); + this.browser.removeEventListener("DOMMetaAdded", this, false); + this.browser.removeEventListener("DOMTitleChanged", this, true); + this.browser.removeEventListener("DOMAudioPlaybackStarted", this, true); + this.browser.removeEventListener("DOMAudioPlaybackStopped", this, true); + this.browser.removeEventListener("DOMWindowClose", this, true); + this.browser.removeEventListener("DOMWillOpenModalDialog", this, true); + this.browser.removeEventListener("DOMAutoComplete", this, true); + this.browser.removeEventListener("blur", this, true); + this.browser.removeEventListener("pageshow", this, true); + this.browser.removeEventListener("MozApplicationManifest", this, true); + this.browser.removeEventListener("TabPreZombify", this, true); + + this.browser.removeEventListener("PluginBindingAttached", this, true, true); + this.browser.removeEventListener("VideoBindingAttached", this, true, true); + this.browser.removeEventListener("VideoBindingCast", this, true, true); + + Services.obs.removeObserver(this, "before-first-paint"); + Services.obs.removeObserver(this, "media-playback", false); + Services.obs.removeObserver(this, "media-playback-resumed", false); + + // Make sure the previously selected panel remains selected. The selected panel of a deck is + // not stable when panels are removed. + let selectedPanel = BrowserApp.deck.selectedPanel; + BrowserApp.deck.removeChild(this.browser); + BrowserApp.deck.selectedPanel = selectedPanel; + + this.browser = null; + }, + + // This should be called to update the browser when the tab gets selected/unselected + setActive: function setActive(aActive) { + if (!this.browser || !this.browser.docShell) + return; + + this.lastTouchedAt = Date.now(); + + if (aActive) { + this.browser.setAttribute("type", "content-primary"); + this.browser.focus(); + this.browser.docShellIsActive = true; + Reader.updatePageAction(this); + ExternalApps.updatePageAction(this.browser.currentURI, this.browser.contentDocument); + } else { + this.browser.setAttribute("type", "content-targetable"); + this.browser.docShellIsActive = false; + this.browser.blur(); + } + }, + + getActive: function getActive() { + return this.browser.docShellIsActive; + }, + + // These constants are used to prioritize high quality metadata over low quality data, so that + // we can collect data as we find meta tags, and replace low quality metadata with higher quality + // matches. For instance a msApplicationTile icon is a better tile image than an og:image tag. + METADATA_GOOD_MATCH: 10, + METADATA_NORMAL_MATCH: 1, + + addMetadata: function(type, value, quality = 1) { + if (!this.metatags) { + this.metatags = { + url: this.browser.currentURI.specIgnoringRef + }; + } + + if (type == "touchIconList") { + if (!this.metatags['touchIconList']) { + this.metatags['touchIconList'] = {}; + } + this.metatags.touchIconList[quality] = value; + } else if (!this.metatags[type] || this.metatags[type + "_quality"] < quality) { + this.metatags[type] = value; + this.metatags[type + "_quality"] = quality; + } + }, + + sanitizeRelString: function(linkRel) { + // Sanitize the rel string + let list = []; + if (linkRel) { + list = linkRel.toLowerCase().split(/\s+/); + let hash = {}; + list.forEach(function(value) { hash[value] = true; }); + list = []; + for (let rel in hash) + list.push("[" + rel + "]"); + } + return list; + }, + + makeFaviconMessage: function(eventTarget) { + // We want to get the largest icon size possible for our UI. + let maxSize = 0; + + // We use the sizes attribute if available + // see http://www.whatwg.org/specs/web-apps/current-work/multipage/links.html#rel-icon + if (eventTarget.hasAttribute("sizes")) { + let sizes = eventTarget.getAttribute("sizes").toLowerCase(); + + if (sizes == "any") { + // Since Java expects an integer, use -1 to represent icons with sizes="any" + maxSize = -1; + } else { + let tokens = sizes.split(" "); + tokens.forEach(function(token) { + // TODO: check for invalid tokens + let [w, h] = token.split("x"); + maxSize = Math.max(maxSize, Math.max(w, h)); + }); + } + } + return { + type: "Link:Favicon", + tabID: this.id, + href: resolveGeckoURI(eventTarget.href), + size: maxSize, + mime: eventTarget.getAttribute("type") || "" + }; + }, + + makeFeedMessage: function(eventTarget, targetType) { + try { + // urlSecurityCeck will throw if things are not OK + ContentAreaUtils.urlSecurityCheck(eventTarget.href, + eventTarget.ownerDocument.nodePrincipal, + Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL); + + if (!this.browser.feeds) + this.browser.feeds = []; + + this.browser.feeds.push({ + href: eventTarget.href, + title: eventTarget.title, + type: targetType + }); + + return { + type: "Link:Feed", + tabID: this.id + }; + } catch (e) { + return null; + } + }, + + sendOpenSearchMessage: function(eventTarget) { + let type = eventTarget.type && eventTarget.type.toLowerCase(); + // Replace all starting or trailing spaces or spaces before "*;" globally w/ "". + type = type.replace(/^\s+|\s*(?:;.*)?$/g, ""); + + // Check that type matches opensearch. + let isOpenSearch = (type == "application/opensearchdescription+xml"); + if (isOpenSearch && eventTarget.title && /^(?:https?|ftp):/i.test(eventTarget.href)) { + Services.search.init(() => { + let visibleEngines = Services.search.getVisibleEngines(); + // NOTE: Engines are currently identified by name, but this can be changed + // when Engines are identified by URL (see bug 335102). + if (visibleEngines.some(function(e) { + return e.name == eventTarget.title; + })) { + // This engine is already present, do nothing. + return null; + } + + if (this.browser.engines) { + // This engine has already been handled, do nothing. + if (this.browser.engines.some(function(e) { + return e.url == eventTarget.href; + })) { + return null; + } + } else { + this.browser.engines = []; + } + + // Get favicon. + let iconURL = eventTarget.ownerDocument.documentURIObject.prePath + "/favicon.ico"; + + let newEngine = { + title: eventTarget.title, + url: eventTarget.href, + iconURL: iconURL + }; + + this.browser.engines.push(newEngine); + + // Don't send a message to display engines if we've already handled an engine. + if (this.browser.engines.length > 1) + return null; + + // Broadcast message that this tab contains search engines that should be visible. + Messaging.sendRequest({ + type: "Link:OpenSearch", + tabID: this.id, + visible: true + }); + }); + } + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "DOMContentLoaded": { + let target = aEvent.originalTarget; + + // ignore on frames and other documents + if (target != this.browser.contentDocument) + return; + + // Sample the background color of the page and pass it along. (This is used to draw the + // checkerboard.) Right now we don't detect changes in the background color after this + // event fires; it's not clear that doing so is worth the effort. + var backgroundColor = null; + try { + let { contentDocument, contentWindow } = this.browser; + let computedStyle = contentWindow.getComputedStyle(contentDocument.body); + backgroundColor = computedStyle.backgroundColor; + } catch (e) { + // Ignore. Catching and ignoring exceptions here ensures that Talos succeeds. + } + + let docURI = target.documentURI; + let errorType = ""; + if (docURI.startsWith("about:certerror")) { + errorType = "certerror"; + } + else if (docURI.startsWith("about:blocked")) { + errorType = "blocked"; + } + else if (docURI.startsWith("about:neterror")) { + let error = docURI.search(/e\=/); + let duffUrl = docURI.search(/\&u\=/); + let errorExtra = decodeURIComponent(docURI.slice(error + 2, duffUrl)); + // Here is a list of errorExtra types (et_*) + // http://mxr.mozilla.org/mozilla-central/source/mobile/android/chrome/content/netError.xhtml#287 + UITelemetry.addEvent("neterror.1", "content", null, errorExtra); + errorType = "neterror"; + } + + // Attach a listener to watch for "click" events bubbling up from error + // pages and other similar page. This lets us fix bugs like 401575 which + // require error page UI to do privileged things, without letting error + // pages have any privilege themselves. + if (docURI.startsWith("about:neterror")) { + NetErrorHelper.attachToBrowser(this.browser); + } + + Messaging.sendRequest({ + type: "DOMContentLoaded", + tabID: this.id, + bgColor: backgroundColor, + errorType: errorType, + metadata: this.metatags, + }); + + // Reset isSearch so that the userRequested term will be erased on next page load + this.metatags = null; + + if (docURI.startsWith("about:certerror") || docURI.startsWith("about:blocked")) { + this.browser.addEventListener("click", ErrorPageEventHandler, true); + let listener = function() { + this.browser.removeEventListener("click", ErrorPageEventHandler, true); + this.browser.removeEventListener("pagehide", listener, true); + }.bind(this); + + this.browser.addEventListener("pagehide", listener, true); + } + + if (AppConstants.NIGHTLY_BUILD || AppConstants.MOZ_ANDROID_ACTIVITY_STREAM) { + WebsiteMetadata.parseAsynchronously(this.browser.contentDocument); + } + + break; + } + + case "DOMFormHasPassword": { + LoginManagerContent.onDOMFormHasPassword(aEvent, + this.browser.contentWindow); + + // Send logins for this hostname to Java. + let hostname = aEvent.target.baseURIObject.prePath; + let foundLogins = Services.logins.findLogins({}, hostname, "", ""); + if (foundLogins.length > 0) { + let displayHost = IdentityHandler.getEffectiveHost(); + let title = { text: displayHost, resource: hostname }; + let selectObj = { title: title, logins: foundLogins }; + Messaging.sendRequest({ type: "Doorhanger:Logins", data: selectObj }); + } + break; + } + + case "DOMInputPasswordAdded": { + LoginManagerContent.onDOMInputPasswordAdded(aEvent, + this.browser.contentWindow); + } + + case "DOMMetaAdded": + let target = aEvent.originalTarget; + let browser = BrowserApp.getBrowserForDocument(target.ownerDocument); + + switch (target.name) { + case "msapplication-TileImage": + this.addMetadata("tileImage", browser.currentURI.resolve(target.content), this.METADATA_GOOD_MATCH); + break; + case "msapplication-TileColor": + this.addMetadata("tileColor", target.content, this.METADATA_GOOD_MATCH); + break; + } + + break; + + case "DOMLinkAdded": + case "DOMLinkChanged": { + let jsonMessage = null; + let target = aEvent.originalTarget; + if (!target.href || target.disabled) + return; + + // Ignore on frames and other documents + if (target.ownerDocument != this.browser.contentDocument) + return; + + // Sanitize rel link + let list = this.sanitizeRelString(target.rel); + if (list.indexOf("[icon]") != -1) { + jsonMessage = this.makeFaviconMessage(target); + } else if (list.indexOf("[apple-touch-icon]") != -1 || + list.indexOf("[apple-touch-icon-precomposed]") != -1) { + jsonMessage = this.makeFaviconMessage(target); + jsonMessage['type'] = 'Link:Touchicon'; + this.addMetadata("touchIconList", jsonMessage.href, jsonMessage.size); + } else if (list.indexOf("[alternate]") != -1 && aEvent.type == "DOMLinkAdded") { + let type = target.type.toLowerCase().replace(/^\s+|\s*(?:;.*)?$/g, ""); + let isFeed = (type == "application/rss+xml" || type == "application/atom+xml"); + + if (!isFeed) + return; + + jsonMessage = this.makeFeedMessage(target, type); + } else if (list.indexOf("[search]") != -1 && aEvent.type == "DOMLinkAdded") { + this.sendOpenSearchMessage(target); + } + if (!jsonMessage) + return; + + Messaging.sendRequest(jsonMessage); + break; + } + + case "DOMTitleChanged": { + if (!aEvent.isTrusted) + return; + + // ignore on frames and other documents + if (aEvent.originalTarget != this.browser.contentDocument) + return; + + Messaging.sendRequest({ + type: "DOMTitleChanged", + tabID: this.id, + title: truncate(aEvent.target.title, MAX_TITLE_LENGTH) + }); + break; + } + + case "TabPreZombify": { + if (!this.playingAudio) { + return; + } + // Fall through to the DOMAudioPlayback events, so the + // audio playback indicator gets reset upon zombification. + } + case "DOMAudioPlaybackStarted": + case "DOMAudioPlaybackStopped": { + if (!Services.prefs.getBoolPref("browser.tabs.showAudioPlayingIcon") || + !aEvent.isTrusted) { + return; + } + + let browser = aEvent.originalTarget; + if (browser != this.browser) { + return; + } + + this.playingAudio = aEvent.type === "DOMAudioPlaybackStarted"; + + Messaging.sendRequest({ + type: "Tab:AudioPlayingChange", + tabID: this.id, + isAudioPlaying: this.playingAudio + }); + return; + } + + case "DOMWindowClose": { + if (!aEvent.isTrusted) + return; + + // Find the relevant tab, and close it from Java + if (this.browser.contentWindow == aEvent.target) { + aEvent.preventDefault(); + + Messaging.sendRequest({ + type: "Tab:Close", + tabID: this.id + }); + } + break; + } + + case "DOMWillOpenModalDialog": { + if (!aEvent.isTrusted) + return; + + // We're about to open a modal dialog, make sure the opening + // tab is brought to the front. + let tab = BrowserApp.getTabForWindow(aEvent.target.top); + BrowserApp.selectTab(tab); + break; + } + + case "DOMAutoComplete": + case "blur": { + LoginManagerContent.onUsernameInput(aEvent); + break; + } + + case "PluginBindingAttached": { + PluginHelper.handlePluginBindingAttached(this, aEvent); + break; + } + + case "VideoBindingAttached": { + CastingApps.handleVideoBindingAttached(this, aEvent); + break; + } + + case "VideoBindingCast": { + CastingApps.handleVideoBindingCast(this, aEvent); + break; + } + + case "MozApplicationManifest": { + OfflineApps.offlineAppRequested(aEvent.originalTarget.defaultView); + break; + } + + case "pageshow": { + LoginManagerContent.onPageShow(aEvent, this.browser.contentWindow); + + // The rest of this only handles pageshow for the top-level document. + if (aEvent.originalTarget.defaultView != this.browser.contentWindow) + return; + + let target = aEvent.originalTarget; + let docURI = target.documentURI; + if (!docURI.startsWith("about:neterror") && !this.isSearch) { + // If this wasn't an error page and the user isn't search, don't retain the typed entry + this.userRequested = ""; + } + + Messaging.sendRequest({ + type: "Content:PageShow", + tabID: this.id, + userRequested: this.userRequested, + fromCache: Tabs.useCache + }); + + this.isSearch = false; + + if (!aEvent.persisted && Services.prefs.getBoolPref("browser.ui.linkify.phone")) { + if (!this._linkifier) + this._linkifier = new Linkifier(); + this._linkifier.linkifyNumbers(this.browser.contentWindow.document); + } + + // Update page actions for helper apps. + let uri = this.browser.currentURI; + if (BrowserApp.selectedTab == this) { + if (ExternalApps.shouldCheckUri(uri)) { + ExternalApps.updatePageAction(uri, this.browser.contentDocument); + } else { + ExternalApps.clearPageAction(); + } + } + } + } + }, + + onStateChange: function(aWebProgress, aRequest, aStateFlags, aStatus) { + let contentWin = aWebProgress.DOMWindow; + if (contentWin != contentWin.top) + return; + + // Filter optimization: Only really send NETWORK state changes to Java listener + if (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) { + if (AppConstants.NIGHTLY_BUILD && (aStateFlags & Ci.nsIWebProgressListener.STATE_START)) { + Profiler.AddMarker("Load start: " + aRequest.QueryInterface(Ci.nsIChannel).originalURI.spec); + } else if (AppConstants.NIGHTLY_BUILD && (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) && !aWebProgress.isLoadingDocument) { + Profiler.AddMarker("Load stop: " + aRequest.QueryInterface(Ci.nsIChannel).originalURI.spec); + } + + if ((aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) && aWebProgress.isLoadingDocument) { + // We may receive a document stop event while a document is still loading + // (such as when doing URI fixup). Don't notify Java UI in these cases. + return; + } + + // Clear page-specific opensearch engines and feeds for a new request. + if (aStateFlags & Ci.nsIWebProgressListener.STATE_START && aRequest && aWebProgress.isTopLevel) { + this.browser.engines = null; + this.browser.feeds = null; + } + + // true if the page loaded successfully (i.e., no 404s or other errors) + let success = false; + let uri = ""; + try { + // Remember original URI for UA changes on redirected pages + this.originalURI = aRequest.QueryInterface(Components.interfaces.nsIChannel).originalURI; + + if (this.originalURI != null) + uri = this.originalURI.spec; + } catch (e) { } + try { + success = aRequest.QueryInterface(Components.interfaces.nsIHttpChannel).requestSucceeded; + } catch (e) { + // If the request does not handle the nsIHttpChannel interface, use nsIRequest's success + // status. Used for local files. See bug 948849. + success = aRequest.status == 0; + } + + // Check to see if we restoring the content from a previous presentation (session) + // since there should be no real network activity + let restoring = (aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) > 0; + + let message = { + type: "Content:StateChange", + tabID: this.id, + uri: truncate(uri, MAX_URI_LENGTH), + state: aStateFlags, + restoring: restoring, + success: success + }; + Messaging.sendRequest(message); + } + }, + + onLocationChange: function(aWebProgress, aRequest, aLocationURI, aFlags) { + let contentWin = aWebProgress.DOMWindow; + + // Browser webapps may load content inside iframes that can not reach across the app/frame boundary + // i.e. even though the page is loaded in an iframe window.top != webapp + // Make cure this window is a top level tab before moving on. + if (BrowserApp.getBrowserForWindow(contentWin) == null) + return; + + this._hostChanged = true; + + let fixedURI = aLocationURI; + try { + fixedURI = URIFixup.createExposableURI(aLocationURI); + } catch (ex) { } + + // In restricted profiles, we refuse to let you open various urls. + if (!ParentalControls.isAllowed(ParentalControls.BROWSE, fixedURI)) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + + this.browser.docShell.displayLoadError(Cr.NS_ERROR_UNKNOWN_PROTOCOL, fixedURI, null); + } + + let contentType = contentWin.document.contentType; + + // If fixedURI matches browser.lastURI, we assume this isn't a real location + // change but rather a spurious addition like a wyciwyg URI prefix. See Bug 747883. + // Note that we have to ensure fixedURI is not the same as aLocationURI so we + // don't false-positive page reloads as spurious additions. + let sameDocument = (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) != 0 || + ((this.browser.lastURI != null) && fixedURI.equals(this.browser.lastURI) && !fixedURI.equals(aLocationURI)); + this.browser.lastURI = fixedURI; + + // Let the reader logic know about same document changes because we won't get a DOMContentLoaded + // or pageshow event, but we'll still want to update the reader view button to account for this change. + // This mirrors the desktop logic in TabsProgressListener. + if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { + this.browser.messageManager.sendAsyncMessage("Reader:PushState", {isArticle: this.browser.isArticle}); + } + + // Reset state of click-to-play plugin notifications. + clearTimeout(this.pluginDoorhangerTimeout); + this.pluginDoorhangerTimeout = null; + this.shouldShowPluginDoorhanger = true; + this.clickToPlayPluginsActivated = false; + + let documentURI = contentWin.document.documentURIObject.spec; + + // If reader mode, get the base domain for the original url. + let strippedURI = this._stripAboutReaderURL(documentURI); + + // Borrowed from desktop Firefox: http://hg.mozilla.org/mozilla-central/annotate/72835344333f/browser/base/content/urlbarBindings.xml#l236 + let matchedURL = strippedURI.match(/^((?:[a-z]+:\/\/)?(?:[^\/]+@)?)(.+?)(?::\d+)?(?:\/|$)/); + let baseDomain = ""; + if (matchedURL) { + var domain = ""; + [, , domain] = matchedURL; + + try { + baseDomain = Services.eTLD.getBaseDomainFromHost(domain); + if (!domain.endsWith(baseDomain)) { + // getBaseDomainFromHost converts its resultant to ACE. + let IDNService = Cc["@mozilla.org/network/idn-service;1"].getService(Ci.nsIIDNService); + baseDomain = IDNService.convertACEtoUTF8(baseDomain); + } + } catch (e) {} + } + + // If we are navigating to a new location with a different host, + // clear any URL origin that might have been pinned to this tab. + let ss = Cc["@mozilla.org/browser/sessionstore;1"].getService(Ci.nsISessionStore); + let appOrigin = ss.getTabValue(this, "appOrigin"); + if (appOrigin) { + let originHost = ""; + try { + originHost = Services.io.newURI(appOrigin, null, null).host; + } catch (e if (e.result == Cr.NS_ERROR_FAILURE)) { + // NS_ERROR_FAILURE can be thrown by nsIURI.host if the URI scheme does not possess a host - in this case + // we just act as if we have an empty host. + } + if (originHost != aLocationURI.host) { + // Note: going 'back' will not make this tab pinned again + ss.deleteTabValue(this, "appOrigin"); + } + } + + // Update the page actions URI for helper apps. + if (BrowserApp.selectedTab == this) { + ExternalApps.updatePageActionUri(fixedURI); + } + + let webNav = contentWin.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + + let message = { + type: "Content:LocationChange", + tabID: this.id, + uri: truncate(fixedURI.spec, MAX_URI_LENGTH), + userRequested: this.userRequested || "", + baseDomain: baseDomain, + contentType: (contentType ? contentType : ""), + sameDocument: sameDocument, + + historyIndex: webNav.sessionHistory.index, + historySize: webNav.sessionHistory.count, + canGoBack: webNav.canGoBack, + canGoForward: webNav.canGoForward, + }; + + Messaging.sendRequest(message); + + if (!sameDocument) { + // XXX This code assumes that this is the earliest hook we have at which + // browser.contentDocument is changed to the new document we're loading + this.contentDocumentIsDisplayed = false; + this.hasTouchListener = false; + Services.obs.notifyObservers(this.browser, "Session:NotifyLocationChange", null); + } + }, + + _stripAboutReaderURL: function (url) { + return ReaderMode.getOriginalUrl(url) || url; + }, + + // Properties used to cache security state used to update the UI + _state: null, + _hostChanged: false, // onLocationChange will flip this bit + + onSecurityChange: function(aWebProgress, aRequest, aState) { + // Don't need to do anything if the data we use to update the UI hasn't changed + if (this._state == aState && !this._hostChanged) + return; + + this._state = aState; + this._hostChanged = false; + + let identity = IdentityHandler.checkIdentity(aState, this.browser); + + let message = { + type: "Content:SecurityChange", + tabID: this.id, + identity: identity + }; + + Messaging.sendRequest(message); + }, + + onProgressChange: function(aWebProgress, aRequest, aCurSelfProgress, aMaxSelfProgress, aCurTotalProgress, aMaxTotalProgress) { + // Note: aWebProgess and aRequest will be NULL since we are filtering webprogress + // notifications using nsBrowserStatusFilter. + }, + + onStatusChange: function(aBrowser, aWebProgress, aRequest, aStatus, aMessage) { + // Note: aWebProgess and aRequest will be NULL since we are filtering webprogress + // notifications using nsBrowserStatusFilter. + }, + + _getGeckoZoom: function() { + let res = {}; + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + cwu.getResolution(res); + let zoom = res.value * window.devicePixelRatio; + return zoom; + }, + + saveSessionZoom: function(aZoom) { + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + cwu.setResolutionAndScaleTo(aZoom / window.devicePixelRatio); + }, + + restoredSessionZoom: function() { + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + + if (this._restoreZoom && cwu.isResolutionSet) { + return this._getGeckoZoom(); + } + return null; + }, + + _updateZoomFromHistoryEvent: function(aHistoryEventName) { + // Restore zoom only when moving in session history, not for new page loads. + this._restoreZoom = aHistoryEventName !== "New"; + }, + + OnHistoryNewEntry: function(aUri) { + this._updateZoomFromHistoryEvent("New"); + }, + + OnHistoryGoBack: function(aUri) { + this._updateZoomFromHistoryEvent("Back"); + return true; + }, + + OnHistoryGoForward: function(aUri) { + this._updateZoomFromHistoryEvent("Forward"); + return true; + }, + + OnHistoryReload: function(aUri, aFlags) { + // we don't do anything with this, so don't propagate it + // for now anyway + return true; + }, + + OnHistoryGotoIndex: function(aIndex, aUri) { + this._updateZoomFromHistoryEvent("Goto"); + return true; + }, + + OnHistoryPurge: function(aNumEntries) { + this._updateZoomFromHistoryEvent("Purge"); + return true; + }, + + OnHistoryReplaceEntry: function(aIndex) { + // we don't do anything with this, so don't propogate it + // for now anyway. + }, + + ShouldNotifyMediaPlaybackChange: function(activeState) { + // If the media is active, we would check it's duration, because we don't + // want to show the media control interface for the short sound which + // duration is smaller than the threshold. The basic unit is second. + // Note : the streaming format's duration is infinite. + if (activeState === "inactive") { + return true; + } + + const mediaDurationThreshold = 1.0; + + let audioElements = this.browser.contentDocument.getElementsByTagName("audio"); + for each (let audio in audioElements) { + if (!audio.paused && audio.duration < mediaDurationThreshold) { + return false; + } + } + + let videoElements = this.browser.contentDocument.getElementsByTagName("video"); + for each (let video in videoElements) { + if (!video.paused && video.duration < mediaDurationThreshold) { + return false; + } + } + + return true; + }, + + observe: function(aSubject, aTopic, aData) { + switch (aTopic) { + case "before-first-paint": + // Is it on the top level? + let contentDocument = aSubject; + if (contentDocument == this.browser.contentDocument) { + if (BrowserApp.selectedTab == this) { + BrowserApp.contentDocumentChanged(); + } + this.contentDocumentIsDisplayed = true; + + if (contentDocument instanceof Ci.nsIImageDocument) { + contentDocument.shrinkToFit(); + } + } + break; + + case "media-playback": + case "media-playback-resumed": + if (!aSubject) { + return; + } + + let winId = aSubject.QueryInterface(Ci.nsISupportsPRUint64).data; + if (this.browser.outerWindowID != winId) { + return; + } + + if (!this.ShouldNotifyMediaPlaybackChange(aData)) { + return; + } + + let status; + if (aTopic == "media-playback") { + status = (aData === "inactive") ? "end" : "start"; + } else if (aTopic == "media-playback-resumed") { + status = "resume"; + } + + Messaging.sendRequest({ + type: "Tab:MediaPlaybackChange", + tabID: this.id, + status: status + }); + break; + } + }, + + // nsIBrowserTab + get window() { + if (!this.browser) + return null; + return this.browser.contentWindow; + }, + + get scale() { + return this._zoom; + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISHistoryListener, + Ci.nsIObserver, + Ci.nsISupportsWeakReference, + Ci.nsIBrowserTab + ]) +}; + +var BrowserEventHandler = { + init: function init() { + this._clickInZoomedView = false; + Services.obs.addObserver(this, "Gesture:SingleTap", false); + Services.obs.addObserver(this, "Gesture:ClickInZoomedView", false); + + BrowserApp.deck.addEventListener("touchend", this, true); + + BrowserApp.deck.addEventListener("DOMUpdatePageReport", PopupBlockerObserver.onUpdatePageReport, false); + BrowserApp.deck.addEventListener("MozMouseHittest", this, true); + BrowserApp.deck.addEventListener("OpenMediaWithExternalApp", this, true); + + InitLater(() => BrowserApp.deck.addEventListener("click", InputWidgetHelper, true)); + InitLater(() => BrowserApp.deck.addEventListener("click", SelectHelper, true)); + + // ReaderViews support backPress listeners. + Messaging.addListener(() => { + return Reader.onBackPress(BrowserApp.selectedTab.id); + }, "Browser:OnBackPressed"); + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case 'touchend': + if (this._inCluster) { + aEvent.preventDefault(); + } + break; + case 'MozMouseHittest': + this._handleRetargetedTouchStart(aEvent); + break; + case 'OpenMediaWithExternalApp': { + let mediaSrc = aEvent.target.currentSrc || aEvent.target.src; + let uuid = uuidgen.generateUUID().toString(); + Services.androidBridge.handleGeckoMessage({ + type: "Video:Play", + uri: mediaSrc, + uuid: uuid + }); + break; + } + } + }, + + _handleRetargetedTouchStart: function(aEvent) { + // we should only get this called just after a new touchstart with a single + // touch point. + if (!BrowserApp.isBrowserContentDocumentDisplayed() || aEvent.defaultPrevented) { + return; + } + + let target = aEvent.target; + if (!target) { + return; + } + + this._inCluster = aEvent.hitCluster; + if (this._inCluster) { + return; // No highlight for a cluster of links + } + + let uri = this._getLinkURI(target); + if (uri) { + try { + Services.io.QueryInterface(Ci.nsISpeculativeConnect).speculativeConnect(uri, null); + } catch (e) {} + } + this._doTapHighlight(target); + }, + + _getLinkURI: function(aElement) { + if (aElement.nodeType == Ci.nsIDOMNode.ELEMENT_NODE && + ((aElement instanceof Ci.nsIDOMHTMLAnchorElement && aElement.href) || + (aElement instanceof Ci.nsIDOMHTMLAreaElement && aElement.href))) { + try { + return Services.io.newURI(aElement.href, null, null); + } catch (e) {} + } + return null; + }, + + observe: function(aSubject, aTopic, aData) { + // the remaining events are all dependent on the browser content document being the + // same as the browser displayed document. if they are not the same, we should ignore + // the event. + if (BrowserApp.isBrowserContentDocumentDisplayed()) { + this.handleUserEvent(aTopic, aData); + } + }, + + handleUserEvent: function(aTopic, aData) { + switch (aTopic) { + + case "Gesture:ClickInZoomedView": + this._clickInZoomedView = true; + break; + + case "Gesture:SingleTap": { + let focusedElement = BrowserApp.getFocusedInput(BrowserApp.selectedBrowser); + let data = JSON.parse(aData); + let {x, y} = data; + + if (this._inCluster && this._clickInZoomedView != true) { + // If there is a focused element, the display of the zoomed view won't remove the focus. + // In this case, the form assistant linked to the focused element will never be closed. + // To avoid this situation, the focus is moved and the form assistant is closed. + if (focusedElement) { + try { + Services.focus.moveFocus(BrowserApp.selectedBrowser.contentWindow, null, Services.focus.MOVEFOCUS_ROOT, 0); + } catch(e) { + Cu.reportError(e); + } + Messaging.sendRequest({ type: "FormAssist:Hide" }); + } + this._clusterClicked(x, y); + } else { + if (this._clickInZoomedView != true) { + this._closeZoomedView(); + } + } + this._clickInZoomedView = false; + this._cancelTapHighlight(); + break; + } + + default: + dump('BrowserEventHandler.handleUserEvent: unexpected topic "' + aTopic + '"'); + break; + } + }, + + _closeZoomedView: function() { + Messaging.sendRequest({ + type: "Gesture:CloseZoomedView" + }); + }, + + _clusterClicked: function(aX, aY) { + Messaging.sendRequest({ + type: "Gesture:clusteredLinksClicked", + clickPosition: { + x: aX, + y: aY + } + }); + }, + + _highlightElement: null, + + _doTapHighlight: function _doTapHighlight(aElement) { + this._highlightElement = aElement; + }, + + _cancelTapHighlight: function _cancelTapHighlight() { + if (!this._highlightElement) + return; + + this._highlightElement = null; + } +}; + +const ElementTouchHelper = { + 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 cwu = document.defaultView.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + let scrollX = {}, scrollY = {}; + cwu.getScrollXY(false, scrollX, scrollY); + + let r = aElement.getBoundingClientRect(); + + // step out of iframes and frames, offsetting scroll values + for (let frame = aElement.ownerDocument.defaultView; frame.frameElement && frame != content; 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.value += rect.left + parseInt(left); + scrollY.value += rect.top + parseInt(top); + } + + return {x: r.left + scrollX.value, + y: r.top + scrollY.value, + w: r.width, + h: r.height }; + } +}; + +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 + try { + // Add a new SSL exception for this URL + let uri = Services.io.newURI(errorDoc.location.href, null, null); + let sslExceptions = new SSLExceptions(); + + if (target == perm) + sslExceptions.addPermanentException(uri, errorDoc.defaultView); + else + sslExceptions.addTemporaryException(uri, errorDoc.defaultView); + } catch (e) { + dump("Failed to set cert exception: " + e + "\n"); + } + errorDoc.location.reload(); + } else if (target == errorDoc.getElementById("getMeOutOfHereButton")) { + errorDoc.location = "about:home"; + } + } else if (errorDoc.documentURI.startsWith("about:blocked")) { + // The event came from a button on a malware/phishing block page + // First check whether it's malware, phishing or unwanted, so that we + // can use the right strings/links + let bucketName = ""; + let sendTelemetry = false; + if (errorDoc.documentURI.includes("e=malwareBlocked")) { + sendTelemetry = true; + bucketName = "WARNING_MALWARE_PAGE_"; + } else if (errorDoc.documentURI.includes("e=deceptiveBlocked")) { + sendTelemetry = true; + bucketName = "WARNING_PHISHING_PAGE_"; + } else if (errorDoc.documentURI.includes("e=unwantedBlocked")) { + sendTelemetry = true; + bucketName = "WARNING_UNWANTED_PAGE_"; + } + let nsISecTel = Ci.nsISecurityUITelemetry; + let isIframe = (errorDoc.defaultView.parent === errorDoc.defaultView); + bucketName += isIframe ? "TOP_" : "FRAME_"; + + let formatter = Cc["@mozilla.org/toolkit/URLFormatterService;1"].getService(Ci.nsIURLFormatter); + + if (target == errorDoc.getElementById("getMeOutButton")) { + if (sendTelemetry) { + Telemetry.addData("SECURITY_UI", nsISecTel[bucketName + "GET_ME_OUT_OF_HERE"]); + } + errorDoc.location = "about:home"; + } else if (target == errorDoc.getElementById("reportButton")) { + // We log even if malware/phishing info URL couldn't be found: + // the measurement is for how many users clicked the WHY BLOCKED button + if (sendTelemetry) { + Telemetry.addData("SECURITY_UI", nsISecTel[bucketName + "WHY_BLOCKED"]); + } + + // This is the "Why is this site blocked" button. We redirect + // to the generic page describing phishing/malware protection. + let url = Services.urlFormatter.formatURLPref("app.support.baseURL"); + BrowserApp.selectedBrowser.loadURI(url + "phishing-malware"); + } else if (target == errorDoc.getElementById("ignoreWarningButton") && + Services.prefs.getBoolPref("browser.safebrowsing.allowOverride")) { + if (sendTelemetry) { + Telemetry.addData("SECURITY_UI", nsISecTel[bucketName + "IGNORE_WARNING"]); + } + + // Allow users to override and continue through to the site, + let webNav = BrowserApp.selectedBrowser.docShell.QueryInterface(Ci.nsIWebNavigation); + let location = BrowserApp.selectedBrowser.contentWindow.location; + webNav.loadURI(location, Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CLASSIFIER, null, null, null); + + // ....but add a notify bar as a reminder, so that they don't lose + // track after, e.g., tab switching. + NativeWindow.doorhanger.show(Strings.browser.GetStringFromName("safeBrowsingDoorhanger"), "safebrowsing-warning", [], BrowserApp.selectedTab.id); + } + } + break; + } + } + } +}; + +var FormAssistant = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIFormSubmitObserver]), + + // Used to keep track of the element that corresponds to the current + // autocomplete suggestions + _currentInputElement: null, + + // The value of the currently focused input + _currentInputValue: null, + + // Whether we're in the middle of an autocomplete + _doingAutocomplete: false, + + // Keep track of whether or not an invalid form has been submitted + _invalidSubmit: false, + + init: function() { + Services.obs.addObserver(this, "FormAssist:AutoComplete", false); + Services.obs.addObserver(this, "FormAssist:Hidden", false); + Services.obs.addObserver(this, "FormAssist:Remove", false); + Services.obs.addObserver(this, "invalidformsubmit", false); + Services.obs.addObserver(this, "PanZoom:StateChange", false); + + // We need to use a capturing listener for focus events + BrowserApp.deck.addEventListener("focus", this, true); + BrowserApp.deck.addEventListener("blur", this, true); + BrowserApp.deck.addEventListener("click", this, true); + BrowserApp.deck.addEventListener("input", this, false); + BrowserApp.deck.addEventListener("pageshow", this, false); + }, + + 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; + if (aData == "NOTHING") { + // only look for input elements, not contentEditable or multiline text areas + let focused = BrowserApp.getFocusedInput(BrowserApp.selectedBrowser, true); + if (!focused) + break; + + if (this._showValidationMessage(focused)) + break; + let checkResultsClick = hasResults => { + if (!hasResults) { + this._hideFormAssistPopup(); + } + }; + this._showAutoCompleteSuggestions(focused, checkResultsClick); + } else { + // temporarily hide the form assist popup while we're panning or zooming the page + this._hideFormAssistPopup(); + } + break; + case "FormAssist:AutoComplete": + if (!this._currentInputElement) + break; + + let editableElement = this._currentInputElement.QueryInterface(Ci.nsIDOMNSEditableElement); + + 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 { + let imeEditor = editableElement.editor.QueryInterface(Ci.nsIEditorIMESupport); + if (imeEditor.composing) + imeEditor.forceCompositionEnd(); + } catch (e) {} + + editableElement.setUserInput(aData); + this._currentInputValue = aData; + + let event = this._currentInputElement.ownerDocument.createEvent("Events"); + event.initEvent("DOMAutoComplete", true, true); + this._currentInputElement.dispatchEvent(event); + + this._doingAutocomplete = false; + + break; + + case "FormAssist:Hidden": + this._currentInputElement = null; + break; + + case "FormAssist:Remove": + if (!this._currentInputElement) { + break; + } + + FormHistory.update({ + op: "remove", + fieldname: this._currentInputElement.name, + value: aData + }); + break; + } + }, + + notifyInvalidSubmit: function notifyInvalidSubmit(aFormElement, aInvalidElements) { + if (!aInvalidElements.length) + return; + + // Ignore this notificaiton if the current tab doesn't contain the invalid element + let currentElement = aInvalidElements.queryElementAt(0, Ci.nsISupports); + if (BrowserApp.selectedBrowser.contentDocument != + currentElement.ownerDocument.defaultView.top.document) + return; + + this._invalidSubmit = true; + + // Our focus listener will show the element's validation message + currentElement.focus(); + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "focus": { + let currentElement = aEvent.target; + + // Only show a validation message on focus. + this._showValidationMessage(currentElement); + break; + } + + case "blur": { + this._currentInputValue = null; + break; + } + + case "click": { + let currentElement = aEvent.target; + + // 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._showValidationMessage(currentElement)) + break; + + let checkResultsClick = hasResults => { + if (!hasResults) { + this._hideFormAssistPopup(); + } + }; + + 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 !== BrowserApp.getFocusedInput(BrowserApp.selectedBrowser) || + this._doingAutocomplete || + currentElement.value === this._currentInputValue) { + break; + } + + this._currentInputValue = currentElement.value; + + // Since we can only show one popup at a time, prioritze autocomplete + // suggestions over a form validation message + let checkResultsInput = hasResults => { + if (hasResults) + return; + + if (this._showValidationMessage(currentElement)) + return; + + // If we're not showing autocomplete suggestions, hide the form assist popup + this._hideFormAssistPopup(); + }; + + this._showAutoCompleteSuggestions(currentElement, checkResultsInput); + break; + } + + // Reset invalid submit state on each pageshow + case "pageshow": { + if (!this._invalidSubmit) + return; + + let selectedBrowser = BrowserApp.selectedBrowser; + if (selectedBrowser) { + let selectedDocument = selectedBrowser.contentDocument; + let target = aEvent.originalTarget; + if (target == selectedDocument || target.ownerDocument == selectedDocument) + this._invalidSubmit = false; + } + break; + } + } + }, + + // We only want to show autocomplete suggestions for certain elements + _isAutoComplete: function _isAutoComplete(aElement) { + if (!(aElement instanceof HTMLInputElement) || aElement.readOnly || aElement.disabled || + (aElement.getAttribute("type") == "password") || + (aElement.hasAttribute("autocomplete") && + aElement.getAttribute("autocomplete").toLowerCase() == "off")) + return false; + + return true; + }, + + // Retrieves autocomplete suggestions for an element from the form autocomplete service. + // aCallback(array_of_suggestions) is called when results are available. + _getAutoCompleteSuggestions: function _getAutoCompleteSuggestions(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); + }, + + /** + * (Copied from mobile/xul/chrome/content/forms.js) + * 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 _getListSuggestions(aElement) { + if (!(aElement instanceof 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 _showAutoCompleteSuggestions(aElement, aCallback) { + if (!this._isAutoComplete(aElement)) { + aCallback(false); + return; + } + if (this._isDisabledElement(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) { + aCallback(false); + return; + } + + Messaging.sendRequest({ + type: "FormAssist:AutoComplete", + suggestions: suggestions, + rect: ElementTouchHelper.getBoundingContentRect(aElement), + isEmpty: isEmpty, + }); + + // Keep track of input element so we can fill it in if the user + // selects an autocomplete suggestion + this._currentInputElement = aElement; + 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 _isValidateable(aElement) { + if (!this._invalidSubmit || + !aElement.validationMessage || + !(aElement instanceof HTMLInputElement || + aElement instanceof HTMLTextAreaElement || + aElement instanceof HTMLSelectElement || + aElement instanceof HTMLButtonElement)) + return false; + + return true; + }, + + // 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 _sendValidationMessage(aElement) { + if (!this._isValidateable(aElement)) + return false; + + Messaging.sendRequest({ + type: "FormAssist:ValidationMessage", + validationMessage: aElement.validationMessage, + rect: ElementTouchHelper.getBoundingContentRect(aElement) + }); + + return true; + }, + + _hideFormAssistPopup: function _hideFormAssistPopup() { + Messaging.sendRequest({ type: "FormAssist:Hide" }); + }, + + _isDisabledElement : function(aElement) { + let currentElement = aElement; + while (currentElement) { + if(currentElement.disabled) + return true; + + currentElement = currentElement.parentElement; + } + return false; + } +}; + +var XPInstallObserver = { + init: function() { + Services.obs.addObserver(this, "addon-install-origin-blocked", false); + Services.obs.addObserver(this, "addon-install-disabled", false); + Services.obs.addObserver(this, "addon-install-blocked", false); + Services.obs.addObserver(this, "addon-install-started", false); + Services.obs.addObserver(this, "xpi-signature-changed", false); + Services.obs.addObserver(this, "browser-delayed-startup-finished", false); + + AddonManager.addInstallListener(this); + }, + + observe: function(aSubject, aTopic, aData) { + let installInfo, tab, host; + if (aSubject && aSubject instanceof Ci.amIWebInstallInfo) { + installInfo = aSubject; + tab = BrowserApp.getTabForBrowser(installInfo.browser); + if (installInfo.originatingURI) { + host = installInfo.originatingURI.host; + } + } + + let strings = Strings.browser; + let brandShortName = Strings.brand.GetStringFromName("brandShortName"); + + switch (aTopic) { + case "addon-install-started": + Snackbars.show(strings.GetStringFromName("alertAddonsDownloading"), Snackbars.LENGTH_LONG); + break; + case "addon-install-disabled": { + if (!tab) + return; + + let enabled = true; + try { + enabled = Services.prefs.getBoolPref("xpinstall.enabled"); + } catch (e) {} + + let buttons, message, callback; + if (!enabled) { + message = strings.GetStringFromName("xpinstallDisabledMessageLocked"); + buttons = [strings.GetStringFromName("unsignedAddonsDisabled.dismiss")]; + callback: (data) => {}; + } else { + message = strings.formatStringFromName("xpinstallDisabledMessage2", [brandShortName, host], 2); + buttons = [ + strings.GetStringFromName("xpinstallDisabledButton"), + strings.GetStringFromName("unsignedAddonsDisabled.dismiss") + ]; + callback: (data) => { + if (data.button === 1) { + Services.prefs.setBoolPref("xpinstall.enabled", true) + } + }; + } + + new Prompt({ + title: Strings.browser.GetStringFromName("addonError.titleError"), + message: message, + buttons: buttons + }).show(callback); + break; + } + case "addon-install-blocked": { + if (!tab) + return; + + let message; + if (host) { + // We have a host which asked for the install. + message = strings.formatStringFromName("xpinstallPromptWarning2", [brandShortName, host], 2); + } else { + // Without a host we address the add-on as the initiator of the install. + let addon = null; + if (installInfo.installs.length > 0) { + addon = installInfo.installs[0].name; + } + if (addon) { + // We have an addon name, show the regular message. + message = strings.formatStringFromName("xpinstallPromptWarningLocal", [brandShortName, addon], 2); + } else { + // We don't have an addon name, show an alternative message. + message = strings.formatStringFromName("xpinstallPromptWarningDirect", [brandShortName], 1); + } + } + + let buttons = [ + strings.GetStringFromName("xpinstallPromptAllowButton"), + strings.GetStringFromName("unsignedAddonsDisabled.dismiss") + ]; + new Prompt({ + title: Strings.browser.GetStringFromName("addonError.titleBlocked"), + message: message, + buttons: buttons + }).show((data) => { + if (data.button === 0) { + // Kick off the install + installInfo.install(); + } + }); + break; + } + case "addon-install-origin-blocked": { + if (!tab) + return; + + new Prompt({ + title: Strings.browser.GetStringFromName("addonError.titleBlocked"), + message: strings.formatStringFromName("xpinstallPromptWarningDirect", [brandShortName], 1), + buttons: [strings.GetStringFromName("unsignedAddonsDisabled.dismiss")] + }).show((data) => {}); + break; + } + case "xpi-signature-changed": { + if (JSON.parse(aData).disabled.length) { + this._notifyUnsignedAddonsDisabled(); + } + break; + } + case "browser-delayed-startup-finished": { + let disabledAddons = AddonManager.getStartupChanges(AddonManager.STARTUP_CHANGE_DISABLED); + for (let id of disabledAddons) { + if (AddonManager.getAddonByID(id).signedState <= AddonManager.SIGNEDSTATE_MISSING) { + this._notifyUnsignedAddonsDisabled(); + break; + } + } + break; + } + } + }, + + _notifyUnsignedAddonsDisabled: function() { + new Prompt({ + window: window, + title: Strings.browser.GetStringFromName("unsignedAddonsDisabled.title"), + message: Strings.browser.GetStringFromName("unsignedAddonsDisabled.message"), + buttons: [ + Strings.browser.GetStringFromName("unsignedAddonsDisabled.viewAddons"), + Strings.browser.GetStringFromName("unsignedAddonsDisabled.dismiss") + ] + }).show((data) => { + if (data.button === 0) { + // TODO: Open about:addons to show only unsigned add-ons? + BrowserApp.selectOrAddTab("about:addons", { parentId: BrowserApp.selectedTab.id }); + } + }); + }, + + onInstallEnded: function(aInstall, aAddon) { + // Don't create a notification for distribution add-ons. + if (Distribution.pendingAddonInstalls.has(aInstall)) { + Distribution.pendingAddonInstalls.delete(aInstall); + return; + } + + let needsRestart = false; + if (aInstall.existingAddon && (aInstall.existingAddon.pendingOperations & AddonManager.PENDING_UPGRADE)) + needsRestart = true; + else if (aAddon.pendingOperations & AddonManager.PENDING_INSTALL) + needsRestart = true; + + if (needsRestart) { + this.showRestartPrompt(); + } else { + // Display completion message for new installs or updates not done Automatically + if (!aInstall.existingAddon || !AddonManager.shouldAutoUpdate(aInstall.existingAddon)) { + let message = Strings.browser.GetStringFromName("alertAddonsInstalledNoRestart.message"); + Snackbars.show(message, Snackbars.LENGTH_LONG, { + action: { + label: Strings.browser.GetStringFromName("alertAddonsInstalledNoRestart.action2"), + callback: () => { + UITelemetry.addEvent("show.1", "toast", null, "addons"); + BrowserApp.selectOrAddTab("about:addons", { parentId: BrowserApp.selectedTab.id }); + }, + } + }); + } + } + }, + + onInstallFailed: function(aInstall) { + this._showErrorMessage(aInstall); + }, + + onDownloadProgress: function(aInstall) {}, + + onDownloadFailed: function(aInstall) { + this._showErrorMessage(aInstall); + }, + + onDownloadCancelled: function(aInstall) {}, + + _showErrorMessage: function(aInstall) { + // Don't create a notification for distribution add-ons. + if (Distribution.pendingAddonInstalls.has(aInstall)) { + Cu.reportError("Error installing distribution add-on: " + aInstall.addon.id); + Distribution.pendingAddonInstalls.delete(aInstall); + return; + } + + let host = (aInstall.originatingURI instanceof Ci.nsIStandardURL) && aInstall.originatingURI.host; + if (!host) { + host = (aInstall.sourceURI instanceof Ci.nsIStandardURL) && aInstall.sourceURI.host; + } + + let error = (host || aInstall.error == 0) ? "addonError" : "addonLocalError"; + if (aInstall.error < 0) { + error += aInstall.error; + } else if (aInstall.addon && aInstall.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) { + error += "Blocklisted"; + } else { + error += "Incompatible"; + } + + let msg = Strings.browser.GetStringFromName(error); + // TODO: formatStringFromName + msg = msg.replace("#1", aInstall.name); + if (host) { + msg = msg.replace("#2", host); + } + msg = msg.replace("#3", Strings.brand.GetStringFromName("brandShortName")); + msg = msg.replace("#4", Services.appinfo.version); + + if (aInstall.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) { + new Prompt({ + window: window, + title: Strings.browser.GetStringFromName("addonError.titleBlocked"), + message: msg, + buttons: [Strings.browser.GetStringFromName("addonError.learnMore")] + }).show((data) => { + if (data.button === 0) { + let url = Services.urlFormatter.formatURLPref("app.support.baseURL") + "unsigned-addons"; + BrowserApp.addTab(url, { parentId: BrowserApp.selectedTab.id }); + } + }); + } else { + Services.prompt.alert(null, Strings.browser.GetStringFromName("addonError.titleError"), msg); + } + }, + + showRestartPrompt: function() { + let buttons = [{ + label: Strings.browser.GetStringFromName("notificationRestart.button"), + callback: function() { + // Notify all windows that an application quit has been requested + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", "restart"); + + // If nothing aborted, quit the app + if (cancelQuit.data == false) { + Services.obs.notifyObservers(null, "quit-application-proceeding", null); + let appStartup = Cc["@mozilla.org/toolkit/app-startup;1"].getService(Ci.nsIAppStartup); + appStartup.quit(Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit); + } + }, + positive: true + }]; + + let message = Strings.browser.GetStringFromName("notificationRestart.normal"); + NativeWindow.doorhanger.show(message, "addon-app-restart", buttons, BrowserApp.selectedTab.id, { persistence: -1 }); + }, + + hideRestartPrompt: function() { + NativeWindow.doorhanger.hide("addon-app-restart", BrowserApp.selectedTab.id); + } +}; + +var ViewportHandler = { + init: function init() { + Services.obs.addObserver(this, "Window:Resize", false); + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic == "Window:Resize" && aData) { + let scrollChange = JSON.parse(aData); + let windowUtils = window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + windowUtils.setNextPaintSyncId(scrollChange.id); + } + } +}; + +/** + * Handler for blocked popups, triggered by DOMUpdatePageReport events in browser.xml + */ +var PopupBlockerObserver = { + onUpdatePageReport: function onUpdatePageReport(aEvent) { + let browser = BrowserApp.selectedBrowser; + if (aEvent.originalTarget != browser) + return; + + if (!browser.pageReport) + return; + + let result = Services.perms.testExactPermission(BrowserApp.selectedBrowser.currentURI, "popup"); + if (result == Ci.nsIPermissionManager.DENY_ACTION) + return; + + // Only show the notification again if we've not already shown it. Since + // notifications are per-browser, we don't need to worry about re-adding + // it. + if (!browser.pageReport.reported) { + if (Services.prefs.getBoolPref("privacy.popups.showBrowserMessage")) { + let brandShortName = Strings.brand.GetStringFromName("brandShortName"); + let popupCount = browser.pageReport.length; + + let strings = Strings.browser; + let message = PluralForm.get(popupCount, strings.GetStringFromName("popup.message")) + .replace("#1", brandShortName) + .replace("#2", popupCount); + + let buttons = [ + { + label: strings.GetStringFromName("popup.dontShow"), + callback: function(aChecked) { + if (aChecked) + PopupBlockerObserver.allowPopupsForSite(false); + } + }, + { + label: strings.GetStringFromName("popup.show"), + callback: function(aChecked) { + // Set permission before opening popup windows + if (aChecked) + PopupBlockerObserver.allowPopupsForSite(true); + + PopupBlockerObserver.showPopupsForSite(); + }, + positive: true + } + ]; + + let options = { checkbox: Strings.browser.GetStringFromName("popup.dontAskAgain") }; + NativeWindow.doorhanger.show(message, "popup-blocked", buttons, null, options); + } + // Record the fact that we've reported this blocked popup, so we don't + // show it again. + browser.pageReport.reported = true; + } + }, + + allowPopupsForSite: function allowPopupsForSite(aAllow) { + let currentURI = BrowserApp.selectedBrowser.currentURI; + Services.perms.add(currentURI, "popup", aAllow + ? Ci.nsIPermissionManager.ALLOW_ACTION + : Ci.nsIPermissionManager.DENY_ACTION); + dump("Allowing popups for: " + currentURI); + }, + + showPopupsForSite: function showPopupsForSite() { + let uri = BrowserApp.selectedBrowser.currentURI; + let pageReport = BrowserApp.selectedBrowser.pageReport; + if (pageReport) { + for (let i = 0; i < pageReport.length; ++i) { + let popupURIspec = pageReport[i].popupWindowURIspec; + + // Sometimes the popup URI that we get back from the pageReport + // isn't useful (for instance, netscape.com's popup URI ends up + // being "http://www.netscape.com", which isn't really the URI of + // the popup they're trying to show). This isn't going to be + // useful to the user, so we won't create a menu item for it. + if (popupURIspec == "" || popupURIspec == "about:blank" || popupURIspec == uri.spec) + continue; + + let popupFeatures = pageReport[i].popupWindowFeatures; + let popupName = pageReport[i].popupWindowName; + + let parent = BrowserApp.selectedTab; + let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser); + BrowserApp.addTab(popupURIspec, { parentId: parent.id, isPrivate: isPrivate }); + } + } + } +}; + + +var IndexedDB = { + _permissionsPrompt: "indexedDB-permissions-prompt", + _permissionsResponse: "indexedDB-permissions-response", + + init: function IndexedDB_init() { + Services.obs.addObserver(this, this._permissionsPrompt, false); + }, + + observe: function IndexedDB_observe(subject, topic, data) { + if (topic != this._permissionsPrompt) { + throw new Error("Unexpected topic!"); + } + + let requestor = subject.QueryInterface(Ci.nsIInterfaceRequestor); + + let browser = requestor.getInterface(Ci.nsIDOMNode); + let tab = BrowserApp.getTabForBrowser(browser); + if (!tab) + return; + + let host = browser.currentURI.asciiHost; + + let strings = Strings.browser; + + let message, responseTopic; + if (topic == this._permissionsPrompt) { + message = strings.formatStringFromName("offlineApps.ask", [host], 1); + responseTopic = this._permissionsResponse; + } + + const firstTimeoutDuration = 300000; // 5 minutes + + let timeoutId; + + let notificationID = responseTopic + host; + let observer = requestor.getInterface(Ci.nsIObserver); + + // This will be set to the result of PopupNotifications.show() below, or to + // the result of PopupNotifications.getNotification() if this is a + // quotaCancel notification. + let notification; + + function timeoutNotification() { + // Remove the notification. + NativeWindow.doorhanger.hide(notificationID, tab.id); + + // Clear all of our timeout stuff. We may be called directly, not just + // when the timeout actually elapses. + clearTimeout(timeoutId); + + // And tell the page that the popup timed out. + observer.observe(null, responseTopic, Ci.nsIPermissionManager.UNKNOWN_ACTION); + } + + let buttons = [ + { + label: strings.GetStringFromName("offlineApps.dontAllow2"), + callback: function(aChecked) { + clearTimeout(timeoutId); + let action = aChecked ? Ci.nsIPermissionManager.DENY_ACTION : Ci.nsIPermissionManager.UNKNOWN_ACTION; + observer.observe(null, responseTopic, action); + } + }, + { + label: strings.GetStringFromName("offlineApps.allow"), + callback: function() { + clearTimeout(timeoutId); + observer.observe(null, responseTopic, Ci.nsIPermissionManager.ALLOW_ACTION); + }, + positive: true + }]; + + let options = { checkbox: Strings.browser.GetStringFromName("offlineApps.dontAskAgain") }; + NativeWindow.doorhanger.show(message, notificationID, buttons, tab.id, options); + + // Set the timeoutId after the popup has been created, and use the long + // timeout value. If the user doesn't notice the popup after this amount of + // time then it is most likely not visible and we want to alert the page. + timeoutId = setTimeout(timeoutNotification, firstTimeoutDuration); + } +}; + +var CharacterEncoding = { + _charsets: [], + + init: function init() { + Services.obs.addObserver(this, "CharEncoding:Get", false); + Services.obs.addObserver(this, "CharEncoding:Set", false); + InitLater(() => this.sendState()); + }, + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "CharEncoding:Get": + this.getEncoding(); + break; + case "CharEncoding:Set": + this.setEncoding(aData); + break; + } + }, + + sendState: function sendState() { + let showCharEncoding = "false"; + try { + showCharEncoding = Services.prefs.getComplexValue("browser.menu.showCharacterEncoding", Ci.nsIPrefLocalizedString).data; + } catch (e) { /* Optional */ } + + Messaging.sendRequest({ + type: "CharEncoding:State", + visible: showCharEncoding + }); + }, + + getEncoding: function getEncoding() { + function infoToCharset(info) { + return { code: info.value, title: info.label }; + } + + if (!this._charsets.length) { + let data = CharsetMenu.getData(); + + // In the desktop UI, the pinned charsets are shown above the rest. + let pinnedCharsets = data.pinnedCharsets.map(infoToCharset); + let otherCharsets = data.otherCharsets.map(infoToCharset) + + this._charsets = pinnedCharsets.concat(otherCharsets); + } + + // Look for the index of the selected charset. Default to -1 if the + // doc charset isn't found in the list of available charsets. + let docCharset = BrowserApp.selectedBrowser.contentDocument.characterSet; + let selected = -1; + let charsetCount = this._charsets.length; + + for (let i = 0; i < charsetCount; i++) { + if (this._charsets[i].code === docCharset) { + selected = i; + break; + } + } + + Messaging.sendRequest({ + type: "CharEncoding:Data", + charsets: this._charsets, + selected: selected + }); + }, + + setEncoding: function setEncoding(aEncoding) { + let browser = BrowserApp.selectedBrowser; + browser.docShell.gatherCharsetMenuTelemetry(); + browser.docShell.charset = aEncoding; + browser.reload(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE); + } +}; + +var IdentityHandler = { + // No trusted identity information. No site identity icon is shown. + IDENTITY_MODE_UNKNOWN: "unknown", + + // Domain-Validation SSL CA-signed domain verification (DV). + IDENTITY_MODE_IDENTIFIED: "identified", + + // Extended-Validation SSL CA-signed identity information (EV). A more rigorous validation process. + IDENTITY_MODE_VERIFIED: "verified", + + // Part of the product's UI (built in about: pages) + IDENTITY_MODE_CHROMEUI: "chromeUI", + + // The following mixed content modes are only used if "security.mixed_content.block_active_content" + // is enabled. Our Java frontend coalesces them into one indicator. + + // No mixed content information. No mixed content icon is shown. + MIXED_MODE_UNKNOWN: "unknown", + + // Blocked active mixed content. + MIXED_MODE_CONTENT_BLOCKED: "blocked", + + // Loaded active mixed content. + MIXED_MODE_CONTENT_LOADED: "loaded", + + // The following tracking content modes are only used if tracking protection + // is enabled. Our Java frontend coalesces them into one indicator. + + // No tracking content information. No tracking content icon is shown. + TRACKING_MODE_UNKNOWN: "unknown", + + // Blocked active tracking content. Shield icon is shown, with a popup option to load content. + TRACKING_MODE_CONTENT_BLOCKED: "tracking_content_blocked", + + // Loaded active tracking content. Yellow triangle icon is shown. + TRACKING_MODE_CONTENT_LOADED: "tracking_content_loaded", + + // Cache the most recent SSLStatus and Location seen in getIdentityStrings + _lastStatus : null, + _lastLocation : null, + + /** + * Helper to parse out the important parts of _lastStatus (of the SSL cert in + * particular) for use in constructing identity UI strings + */ + getIdentityData : function() { + let result = {}; + let status = this._lastStatus.QueryInterface(Components.interfaces.nsISSLStatus); + let cert = status.serverCert; + + // Human readable name of Subject + result.subjectOrg = cert.organization; + + // SubjectName fields, broken up for individual access + if (cert.subjectName) { + result.subjectNameFields = {}; + cert.subjectName.split(",").forEach(function(v) { + let field = v.split("="); + this[field[0]] = field[1]; + }, result.subjectNameFields); + + // Call out city, state, and country specifically + result.city = result.subjectNameFields.L; + result.state = result.subjectNameFields.ST; + result.country = result.subjectNameFields.C; + } + + // Human readable name of Certificate Authority + result.caOrg = cert.issuerOrganization || cert.issuerCommonName; + result.cert = cert; + + return result; + }, + + /** + * Determines the identity mode corresponding to the icon we show in the urlbar. + */ + getIdentityMode: function getIdentityMode(aState, uri) { + if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) { + return this.IDENTITY_MODE_VERIFIED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_IS_SECURE) { + return this.IDENTITY_MODE_IDENTIFIED; + } + + // We also allow "about:" by allowing the selector to be empty (i.e. '(|.....|...|...)' + let whitelist = /^about:($|about|accounts|addons|buildconfig|cache|config|crashes|devices|downloads|fennec|firefox|feedback|healthreport|home|license|logins|logo|memory|mozilla|networking|plugins|privatebrowsing|rights|serviceworkers|support|telemetry|webrtc)($|\?)/i; + if (uri.schemeIs("about") && whitelist.test(uri.spec)) { + return this.IDENTITY_MODE_CHROMEUI; + } + + return this.IDENTITY_MODE_UNKNOWN; + }, + + getMixedDisplayMode: function getMixedDisplayMode(aState) { + if (aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT) { + return this.MIXED_MODE_CONTENT_LOADED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT) { + return this.MIXED_MODE_CONTENT_BLOCKED; + } + + return this.MIXED_MODE_UNKNOWN; + }, + + getMixedActiveMode: function getActiveDisplayMode(aState) { + // Only show an indicator for loaded mixed content if the pref to block it is enabled + if ((aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT) && + !Services.prefs.getBoolPref("security.mixed_content.block_active_content")) { + return this.MIXED_MODE_CONTENT_LOADED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT) { + return this.MIXED_MODE_CONTENT_BLOCKED; + } + + return this.MIXED_MODE_UNKNOWN; + }, + + getTrackingMode: function getTrackingMode(aState, aBrowser) { + if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT) { + this.shieldHistogramAdd(aBrowser, 2); + return this.TRACKING_MODE_CONTENT_BLOCKED; + } + + // Only show an indicator for loaded tracking content if the pref to block it is enabled + let tpEnabled = Services.prefs.getBoolPref("privacy.trackingprotection.enabled") || + (Services.prefs.getBoolPref("privacy.trackingprotection.pbmode.enabled") && + PrivateBrowsingUtils.isBrowserPrivate(aBrowser)); + + if ((aState & Ci.nsIWebProgressListener.STATE_LOADED_TRACKING_CONTENT) && tpEnabled) { + this.shieldHistogramAdd(aBrowser, 1); + return this.TRACKING_MODE_CONTENT_LOADED; + } + + this.shieldHistogramAdd(aBrowser, 0); + return this.TRACKING_MODE_UNKNOWN; + }, + + shieldHistogramAdd: function(browser, value) { + if (PrivateBrowsingUtils.isBrowserPrivate(browser)) { + return; + } + Telemetry.addData("TRACKING_PROTECTION_SHIELD", value); + }, + + /** + * Determine the identity of the page being displayed by examining its SSL cert + * (if available). Return the data needed to update the UI. + */ + checkIdentity: function checkIdentity(aState, aBrowser) { + this._lastStatus = aBrowser.securityUI + .QueryInterface(Components.interfaces.nsISSLStatusProvider) + .SSLStatus; + + // Don't pass in the actual location object, since it can cause us to + // hold on to the window object too long. Just pass in the fields we + // care about. (bug 424829) + let locationObj = {}; + try { + let location = aBrowser.contentWindow.location; + locationObj.host = location.host; + locationObj.hostname = location.hostname; + locationObj.port = location.port; + locationObj.origin = location.origin; + } catch (ex) { + // Can sometimes throw if the URL being visited has no host/hostname, + // e.g. about:blank. The _state for these pages means we won't need these + // properties anyways, though. + } + this._lastLocation = locationObj; + + let uri = aBrowser.currentURI; + try { + uri = Services.uriFixup.createExposableURI(uri); + } catch (e) {} + + let identityMode = this.getIdentityMode(aState, uri); + let mixedDisplay = this.getMixedDisplayMode(aState); + let mixedActive = this.getMixedActiveMode(aState); + let trackingMode = this.getTrackingMode(aState, aBrowser); + let result = { + origin: locationObj.origin, + mode: { + identity: identityMode, + mixed_display: mixedDisplay, + mixed_active: mixedActive, + tracking: trackingMode + } + }; + + // Don't show identity data for pages with an unknown identity or if any + // mixed content is loaded (mixed display content is loaded by default). + // We also return for CHROMEUI pages since they don't have any certificate + // information to load either. result.secure specifically refers to connection + // security, which is irrelevant for about: pages, as they're loaded locally. + if (identityMode == this.IDENTITY_MODE_UNKNOWN || + identityMode == this.IDENTITY_MODE_CHROMEUI || + aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN) { + result.secure = false; + return result; + } + + result.secure = true; + + result.host = this.getEffectiveHost(); + + let iData = this.getIdentityData(); + result.verifier = Strings.browser.formatStringFromName("identity.identified.verifier", [iData.caOrg], 1); + + // If the cert is identified, then we can populate the results with credentials + if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) { + result.owner = iData.subjectOrg; + + // Build an appropriate supplemental block out of whatever location data we have + let supplemental = ""; + if (iData.city) { + supplemental += iData.city + "\n"; + } + if (iData.state && iData.country) { + supplemental += Strings.browser.formatStringFromName("identity.identified.state_and_country", [iData.state, iData.country], 2); + result.country = iData.country; + } else if (iData.state) { // State only + supplemental += iData.state; + } else if (iData.country) { // Country only + supplemental += iData.country; + result.country = iData.country; + } + result.supplemental = supplemental; + + return result; + } + + // Cache the override service the first time we need to check it + if (!this._overrideService) + this._overrideService = Cc["@mozilla.org/security/certoverride;1"].getService(Ci.nsICertOverrideService); + + // Check whether this site is a security exception. XPConnect does the right + // thing here in terms of converting _lastLocation.port from string to int, but + // the overrideService doesn't like undefined ports, so make sure we have + // something in the default case (bug 432241). + // .hostname can return an empty string in some exceptional cases - + // hasMatchingOverride does not handle that, so avoid calling it. + // Updating the tooltip value in those cases isn't critical. + // FIXME: Fixing bug 646690 would probably makes this check unnecessary + if (this._lastLocation.hostname && + this._overrideService.hasMatchingOverride(this._lastLocation.hostname, + (this._lastLocation.port || 443), + iData.cert, {}, {})) + result.verifier = Strings.browser.GetStringFromName("identity.identified.verified_by_you"); + + return result; + }, + + /** + * Attempt to provide proper IDN treatment for host names + */ + getEffectiveHost: function getEffectiveHost() { + if (!this._IDNService) + this._IDNService = Cc["@mozilla.org/network/idn-service;1"] + .getService(Ci.nsIIDNService); + try { + return this._IDNService.convertToDisplayIDN(this._uri.host, {}); + } catch (e) { + // If something goes wrong (e.g. hostname is an IP address) just fail back + // to the full domain. + return this._lastLocation.hostname; + } + } +}; + +var SearchEngines = { + _contextMenuId: null, + PREF_SUGGEST_ENABLED: "browser.search.suggest.enabled", + PREF_SUGGEST_PROMPTED: "browser.search.suggest.prompted", + + // Shared preference key used for search activity default engine. + PREF_SEARCH_ACTIVITY_ENGINE_KEY: "search.engines.defaultname", + + init: function init() { + Services.obs.addObserver(this, "SearchEngines:Add", false); + Services.obs.addObserver(this, "SearchEngines:GetVisible", false); + Services.obs.addObserver(this, "SearchEngines:Remove", false); + Services.obs.addObserver(this, "SearchEngines:RestoreDefaults", false); + Services.obs.addObserver(this, "SearchEngines:SetDefault", false); + Services.obs.addObserver(this, "browser-search-engine-modified", false); + }, + + // Fetch list of search engines. all ? All engines : Visible engines only. + _handleSearchEnginesGetVisible: function _handleSearchEnginesGetVisible(rv, all) { + if (!Components.isSuccessCode(rv)) { + Cu.reportError("Could not initialize search service, bailing out."); + return; + } + + let engineData = Services.search.getVisibleEngines({}); + + // Our Java UI assumes that the default engine is the first item in the array, + // so we need to make sure that's the case. + if (engineData[0] !== Services.search.defaultEngine) { + engineData = engineData.filter(engine => engine !== Services.search.defaultEngine); + engineData.unshift(Services.search.defaultEngine); + } + + let searchEngines = engineData.map(function (engine) { + return { + name: engine.name, + identifier: engine.identifier, + iconURI: (engine.iconURI ? engine.iconURI.spec : null), + hidden: engine.hidden + }; + }); + + let suggestTemplate = null; + let suggestEngine = null; + + // Check to see if the default engine supports search suggestions. We only need to check + // the default engine because we only show suggestions for the default engine in the UI. + let engine = Services.search.defaultEngine; + if (engine.supportsResponseType("application/x-suggestions+json")) { + suggestEngine = engine.name; + suggestTemplate = engine.getSubmission("__searchTerms__", "application/x-suggestions+json").uri.spec; + } + + // By convention, the currently configured default engine is at position zero in searchEngines. + Messaging.sendRequest({ + type: "SearchEngines:Data", + searchEngines: searchEngines, + suggest: { + engine: suggestEngine, + template: suggestTemplate, + enabled: Services.prefs.getBoolPref(this.PREF_SUGGEST_ENABLED), + prompted: Services.prefs.getBoolPref(this.PREF_SUGGEST_PROMPTED) + } + }); + + // Send a speculative connection to the default engine. + Services.search.defaultEngine.speculativeConnect({window: window}); + }, + + // Helper method to extract the engine name from a JSON. Simplifies the observe function. + _extractEngineFromJSON: function _extractEngineFromJSON(aData) { + let data = JSON.parse(aData); + return Services.search.getEngineByName(data.engine); + }, + + observe: function observe(aSubject, aTopic, aData) { + let engine; + switch(aTopic) { + case "SearchEngines:Add": + this.displaySearchEnginesList(aData); + break; + case "SearchEngines:GetVisible": + Services.search.init(this._handleSearchEnginesGetVisible.bind(this)); + break; + case "SearchEngines:Remove": + // Make sure the engine isn't hidden before removing it, to make sure it's + // visible if the user later re-adds it (works around bug 341833) + engine = this._extractEngineFromJSON(aData); + engine.hidden = false; + Services.search.removeEngine(engine); + break; + case "SearchEngines:RestoreDefaults": + // Un-hides all default engines. + Services.search.restoreDefaultEngines(); + break; + case "SearchEngines:SetDefault": + engine = this._extractEngineFromJSON(aData); + // Move the new default search engine to the top of the search engine list. + Services.search.moveEngine(engine, 0); + Services.search.defaultEngine = engine; + break; + case "browser-search-engine-modified": + if (aData == "engine-default") { + this._setSearchActivityDefaultPref(aSubject.QueryInterface(Ci.nsISearchEngine)); + } + break; + default: + dump("Unexpected message type observed: " + aTopic); + break; + } + }, + + migrateSearchActivityDefaultPref: function migrateSearchActivityDefaultPref() { + Services.search.init(() => this._setSearchActivityDefaultPref(Services.search.defaultEngine)); + }, + + // Updates the search activity pref when the default engine changes. + _setSearchActivityDefaultPref: function _setSearchActivityDefaultPref(engine) { + SharedPreferences.forApp().setCharPref(this.PREF_SEARCH_ACTIVITY_ENGINE_KEY, engine.name); + }, + + // Display context menu listing names of the search engines available to be added. + displaySearchEnginesList: function displaySearchEnginesList(aData) { + let data = JSON.parse(aData); + let tab = BrowserApp.getTabForId(data.tabId); + + if (!tab) + return; + + let browser = tab.browser; + let engines = browser.engines; + + let p = new Prompt({ + window: browser.contentWindow + }).setSingleChoiceItems(engines.map(function(e) { + return { label: e.title }; + })).show((function(data) { + if (data.button == -1) + return; + + this.addOpenSearchEngine(engines[data.button]); + engines.splice(data.button, 1); + + if (engines.length < 1) { + // Broadcast message that there are no more add-able search engines. + let newEngineMessage = { + type: "Link:OpenSearch", + tabID: tab.id, + visible: false + }; + + Messaging.sendRequest(newEngineMessage); + } + }).bind(this)); + }, + + addOpenSearchEngine: function addOpenSearchEngine(engine) { + Services.search.addEngine(engine.url, Ci.nsISearchEngine.DATA_XML, engine.iconURL, false, { + onSuccess: function() { + // Display a toast confirming addition of new search engine. + Snackbars.show(Strings.browser.formatStringFromName("alertSearchEngineAddedToast", [engine.title], 1), Snackbars.LENGTH_LONG); + }, + + onError: function(aCode) { + let errorMessage; + if (aCode == 2) { + // Engine is a duplicate. + errorMessage = "alertSearchEngineDuplicateToast"; + + } else { + // Unknown failure. Display general error message. + errorMessage = "alertSearchEngineErrorToast"; + } + + Snackbars.show(Strings.browser.formatStringFromName(errorMessage, [engine.title], 1), Snackbars.LENGTH_LONG); + } + }); + }, + + /** + * Build and return an array of sorted form data / Query Parameters + * for an element in a submission form. + * + * @param element + * A valid submission element of a form. + */ + _getSortedFormData: function(element) { + let formData = []; + + for (let formElement of element.form.elements) { + if (!formElement.type) { + continue; + } + + // Make this text field a generic search parameter. + if (element == formElement) { + formData.push({ name: formElement.name, value: "{searchTerms}" }); + continue; + } + + // Add other form elements as parameters. + switch (formElement.type.toLowerCase()) { + case "checkbox": + case "radio": + if (!formElement.checked) { + break; + } + case "text": + case "hidden": + case "textarea": + formData.push({ name: escape(formElement.name), value: escape(formElement.value) }); + break; + + case "select-one": { + for (let option of formElement.options) { + if (option.selected) { + formData.push({ name: escape(formElement.name), value: escape(formElement.value) }); + break; + } + } + } + } + }; + + // Return valid, pre-sorted queryParams. + return formData.filter(a => a.name && a.value).sort((a, b) => { + // nsIBrowserSearchService.hasEngineWithURL() ensures sort, but this helps. + if (a.name > b.name) { + return 1; + } + if (b.name > a.name) { + return -1; + } + + if (a.value > b.value) { + return 1; + } + if (b.value > a.value) { + return -1; + } + + return 0; + }); + }, + + /** + * Check if any search engines already handle an EngineURL of type + * URLTYPE_SEARCH_HTML, matching this request-method, formURL, and queryParams. + */ + visibleEngineExists: function(element) { + let formData = this._getSortedFormData(element); + + let form = element.form; + let method = form.method.toUpperCase(); + + let charset = element.ownerDocument.characterSet; + let docURI = Services.io.newURI(element.ownerDocument.URL, charset, null); + let formURL = Services.io.newURI(form.getAttribute("action"), charset, docURI).spec; + + return Services.search.hasEngineWithURL(method, formURL, formData); + }, + + /** + * Adds a new search engine to the BrowserSearchService, based on its provided element. Prompts for an engine + * name, and appends a simple version-number in case of collision with an existing name. + * + * @return callback to handle success value. Currently used for ActionBarHandler.js and UI updates. + */ + addEngine: function addEngine(aElement, resultCallback) { + let form = aElement.form; + let charset = aElement.ownerDocument.characterSet; + let docURI = Services.io.newURI(aElement.ownerDocument.URL, charset, null); + let formURL = Services.io.newURI(form.getAttribute("action"), charset, docURI).spec; + let method = form.method.toUpperCase(); + let formData = this._getSortedFormData(aElement); + + // prompt user for name of search engine + let promptTitle = Strings.browser.GetStringFromName("contextmenu.addSearchEngine3"); + let title = { value: (aElement.ownerDocument.title || docURI.host) }; + if (!Services.prompt.prompt(null, promptTitle, null, title, null, {})) { + if (resultCallback) { + resultCallback(false); + }; + return; + } + + // fetch the favicon for this page + let dbFile = FileUtils.getFile("ProfD", ["browser.db"]); + let mDBConn = Services.storage.openDatabase(dbFile); + let stmts = []; + stmts[0] = mDBConn.createStatement("SELECT favicon FROM history_with_favicons WHERE url = ?"); + stmts[0].bindByIndex(0, docURI.spec); + let favicon = null; + + Services.search.init(function addEngine_cb(rv) { + if (!Components.isSuccessCode(rv)) { + Cu.reportError("Could not initialize search service, bailing out."); + if (resultCallback) { + resultCallback(false); + }; + return; + } + + mDBConn.executeAsync(stmts, stmts.length, { + handleResult: function (results) { + let bytes = results.getNextRow().getResultByName("favicon"); + if (bytes && bytes.length) { + favicon = "data:image/x-icon;base64," + btoa(String.fromCharCode.apply(null, bytes)); + } + }, + handleCompletion: function (reason) { + // if there's already an engine with this name, add a number to + // make the name unique (e.g., "Google" becomes "Google 2") + let name = title.value; + for (let i = 2; Services.search.getEngineByName(name); i++) + name = title.value + " " + i; + + Services.search.addEngineWithDetails(name, favicon, null, null, method, formURL); + Snackbars.show(Strings.browser.formatStringFromName("alertSearchEngineAddedToast", [name], 1), Snackbars.LENGTH_LONG); + + let engine = Services.search.getEngineByName(name); + engine.wrappedJSObject._queryCharset = charset; + formData.forEach(param => { engine.addParam(param.name, param.value, null); }); + + if (resultCallback) { + return resultCallback(true); + }; + } + }); + }); + } +}; + +var ActivityObserver = { + init: function ao_init() { + Services.obs.addObserver(this, "application-background", false); + Services.obs.addObserver(this, "application-foreground", false); + }, + + observe: function ao_observe(aSubject, aTopic, aData) { + let isForeground = false; + let tab = BrowserApp.selectedTab; + + UITelemetry.addEvent("show.1", "system", null, aTopic); + + switch (aTopic) { + case "application-background" : + let doc = (tab ? tab.browser.contentDocument : null); + if (doc && doc.fullscreenElement) { + doc.exitFullscreen(); + } + isForeground = false; + break; + case "application-foreground" : + isForeground = true; + break; + } + + if (tab && tab.getActive() != isForeground) { + tab.setActive(isForeground); + } + } +}; + +var Telemetry = { + addData: function addData(aHistogramId, aValue) { + let histogram = Services.telemetry.getHistogramById(aHistogramId); + histogram.add(aValue); + }, +}; + +var Experiments = { + // Enable malware download protection (bug 936041) + MALWARE_DOWNLOAD_PROTECTION: "malware-download-protection", + + // Try to load pages from disk cache when network is offline (bug 935190) + OFFLINE_CACHE: "offline-cache", + + init() { + Messaging.sendRequestForResult({ + type: "Experiments:GetActive" + }).then(experiments => { + let names = JSON.parse(experiments); + for (let name of names) { + switch (name) { + case this.MALWARE_DOWNLOAD_PROTECTION: { + // Apply experiment preferences on the default branch. This allows + // us to avoid migrating user prefs when experiments are enabled/disabled, + // and it also allows users to override these prefs in about:config. + let defaults = Services.prefs.getDefaultBranch(null); + defaults.setBoolPref("browser.safebrowsing.downloads.enabled", true); + defaults.setBoolPref("browser.safebrowsing.downloads.remote.enabled", true); + continue; + } + + case this.OFFLINE_CACHE: { + let defaults = Services.prefs.getDefaultBranch(null); + defaults.setBoolPref("browser.tabs.useCache", true); + continue; + } + } + } + }); + }, + + setOverride(name, isEnabled) { + Messaging.sendRequest({ + type: "Experiments:SetOverride", + name: name, + isEnabled: isEnabled + }); + }, + + clearOverride(name) { + Messaging.sendRequest({ + type: "Experiments:ClearOverride", + name: name + }); + } +}; + +var ExternalApps = { + _contextMenuId: null, + + // extend _getLink to pickup html5 media links. + _getMediaLink: function(aElement) { + let uri = NativeWindow.contextmenus._getLink(aElement); + if (uri == null && aElement.nodeType == Ci.nsIDOMNode.ELEMENT_NODE && (aElement instanceof Ci.nsIDOMHTMLMediaElement)) { + try { + let mediaSrc = aElement.currentSrc || aElement.src; + uri = ContentAreaUtils.makeURI(mediaSrc, null, null); + } catch (e) {} + } + return uri; + }, + + init: function helper_init() { + this._contextMenuId = NativeWindow.contextmenus.add(function(aElement) { + let uri = null; + var node = aElement; + while (node && !uri) { + uri = ExternalApps._getMediaLink(node); + node = node.parentNode; + } + let apps = []; + if (uri) + apps = HelperApps.getAppsForUri(uri); + + return apps.length == 1 ? Strings.browser.formatStringFromName("helperapps.openWithApp2", [apps[0].name], 1) : + Strings.browser.GetStringFromName("helperapps.openWithList2"); + }, this.filter, this.openExternal); + }, + + filter: { + matches: function(aElement) { + let uri = ExternalApps._getMediaLink(aElement); + let apps = []; + if (uri) { + apps = HelperApps.getAppsForUri(uri); + } + return apps.length > 0; + } + }, + + openExternal: function(aElement) { + if (aElement.pause) { + aElement.pause(); + } + let uri = ExternalApps._getMediaLink(aElement); + HelperApps.launchUri(uri); + }, + + shouldCheckUri: function(uri) { + if (!(uri.schemeIs("http") || uri.schemeIs("https") || uri.schemeIs("file"))) { + return false; + } + + return true; + }, + + updatePageAction: function updatePageAction(uri, contentDocument) { + HelperApps.getAppsForUri(uri, { filterHttp: true }, (apps) => { + this.clearPageAction(); + if (apps.length > 0) + this._setUriForPageAction(uri, apps, contentDocument); + }); + }, + + updatePageActionUri: function updatePageActionUri(uri) { + this._pageActionUri = uri; + }, + + _getMediaContentElement(contentDocument) { + if (!contentDocument.contentType.startsWith("video/") && + !contentDocument.contentType.startsWith("audio/")) { + return null; + } + + let element = contentDocument.activeElement; + + if (element instanceof HTMLBodyElement) { + element = element.firstChild; + } + + if (element instanceof HTMLMediaElement) { + return element; + } + + return null; + }, + + _setUriForPageAction: function setUriForPageAction(uri, apps, contentDocument) { + this.updatePageActionUri(uri); + + // If the pageaction is already added, simply update the URI to be launched when 'onclick' is triggered. + if (this._pageActionId != undefined) + return; + + let mediaElement = this._getMediaContentElement(contentDocument); + + this._pageActionId = PageActions.add({ + title: Strings.browser.GetStringFromName("openInApp.pageAction"), + icon: "drawable://icon_openinapp", + + clickCallback: () => { + UITelemetry.addEvent("launch.1", "pageaction", null, "helper"); + + let wasPlaying = mediaElement && !mediaElement.paused && !mediaElement.ended; + if (wasPlaying) { + mediaElement.pause(); + } + + if (apps.length > 1) { + // Use the HelperApps prompt here to filter out any Http handlers + HelperApps.prompt(apps, { + title: Strings.browser.GetStringFromName("openInApp.pageAction"), + buttons: [ + Strings.browser.GetStringFromName("openInApp.ok"), + Strings.browser.GetStringFromName("openInApp.cancel") + ] + }, (result) => { + if (result.button != 0) { + if (wasPlaying) { + mediaElement.play(); + } + + return; + } + apps[result.icongrid0].launch(this._pageActionUri); + }); + } else { + apps[0].launch(this._pageActionUri); + } + } + }); + }, + + clearPageAction: function clearPageAction() { + if(!this._pageActionId) + return; + + PageActions.remove(this._pageActionId); + delete this._pageActionId; + }, +}; + +var Distribution = { + // File used to store campaign data + _file: null, + + _preferencesJSON: null, + + init: function dc_init() { + Services.obs.addObserver(this, "Distribution:Changed", false); + Services.obs.addObserver(this, "Distribution:Set", false); + Services.obs.addObserver(this, "prefservice:after-app-defaults", false); + Services.obs.addObserver(this, "Campaign:Set", false); + + // Look for file outside the APK: + // /data/data/org.mozilla.xxx/distribution.json + this._file = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + this._file.append("distribution.json"); + this.readJSON(this._file, this.update); + }, + + observe: function dc_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "Distribution:Changed": + // Re-init the search service. + try { + Services.search._asyncReInit(); + } catch (e) { + console.log("Unable to reinit search service."); + } + // Fall through. + + case "Distribution:Set": + if (aData) { + try { + this._preferencesJSON = JSON.parse(aData); + } catch (e) { + console.log("Invalid distribution JSON."); + } + } + // Reload the default prefs so we can observe "prefservice:after-app-defaults" + Services.prefs.QueryInterface(Ci.nsIObserver).observe(null, "reload-default-prefs", null); + this.installDistroAddons(); + break; + + case "prefservice:after-app-defaults": + this.getPrefs(); + break; + + case "Campaign:Set": { + // Update the prefs for this session + try { + this.update(JSON.parse(aData)); + } catch (ex) { + Cu.reportError("Distribution: Could not parse JSON: " + ex); + return; + } + + // Asynchronously copy the data to the file. + let array = new TextEncoder().encode(aData); + OS.File.writeAtomic(this._file.path, array, { tmpPath: this._file.path + ".tmp" }); + break; + } + } + }, + + update: function dc_update(aData) { + // Force the distribution preferences on the default branch + let defaults = Services.prefs.getDefaultBranch(null); + defaults.setCharPref("distribution.id", aData.id); + defaults.setCharPref("distribution.version", aData.version); + }, + + getPrefs: function dc_getPrefs() { + if (this._preferencesJSON) { + this.applyPrefs(this._preferencesJSON); + this._preferencesJSON = null; + return; + } + + // Get the distribution directory, and bail if it doesn't exist. + let file = FileUtils.getDir("XREAppDist", [], false); + if (!file.exists()) + return; + + file.append("preferences.json"); + this.readJSON(file, this.applyPrefs); + }, + + applyPrefs: function dc_applyPrefs(aData) { + // Check for required Global preferences + let global = aData["Global"]; + if (!(global && global["id"] && global["version"] && global["about"])) { + Cu.reportError("Distribution: missing or incomplete Global preferences"); + return; + } + + // Force the distribution preferences on the default branch + let defaults = Services.prefs.getDefaultBranch(null); + defaults.setCharPref("distribution.id", global["id"]); + defaults.setCharPref("distribution.version", global["version"]); + + let locale = BrowserApp.getUALocalePref(); + let aboutString = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); + aboutString.data = global["about." + locale] || global["about"]; + defaults.setComplexValue("distribution.about", Ci.nsISupportsString, aboutString); + + let prefs = aData["Preferences"]; + for (let key in prefs) { + try { + let value = prefs[key]; + switch (typeof value) { + case "boolean": + defaults.setBoolPref(key, value); + break; + case "number": + defaults.setIntPref(key, value); + break; + case "string": + case "undefined": + defaults.setCharPref(key, value); + break; + } + } catch (e) { /* ignore bad prefs and move on */ } + } + + // Apply a lightweight theme if necessary + if (prefs && prefs["lightweightThemes.selectedThemeID"]) { + Services.obs.notifyObservers(null, "lightweight-theme-apply", ""); + } + + let localizedString = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(Ci.nsIPrefLocalizedString); + let localizeablePrefs = aData["LocalizablePreferences"]; + for (let key in localizeablePrefs) { + try { + let value = localizeablePrefs[key]; + value = value.replace(/%LOCALE%/g, locale); + localizedString.data = "data:text/plain," + key + "=" + value; + defaults.setComplexValue(key, Ci.nsIPrefLocalizedString, localizedString); + } catch (e) { /* ignore bad prefs and move on */ } + } + + let localizeablePrefsOverrides = aData["LocalizablePreferences." + locale]; + for (let key in localizeablePrefsOverrides) { + try { + let value = localizeablePrefsOverrides[key]; + localizedString.data = "data:text/plain," + key + "=" + value; + defaults.setComplexValue(key, Ci.nsIPrefLocalizedString, localizedString); + } catch (e) { /* ignore bad prefs and move on */ } + } + + Messaging.sendRequest({ type: "Distribution:Set:OK" }); + }, + + // aFile is an nsIFile + // aCallback takes the parsed JSON object as a parameter + readJSON: function dc_readJSON(aFile, aCallback) { + Task.spawn(function() { + let bytes = yield OS.File.read(aFile.path); + let raw = new TextDecoder().decode(bytes) || ""; + + try { + aCallback(JSON.parse(raw)); + } catch (e) { + Cu.reportError("Distribution: Could not parse JSON: " + e); + } + }).then(null, function onError(reason) { + if (!(reason instanceof OS.File.Error && reason.becauseNoSuchFile)) { + Cu.reportError("Distribution: Could not read from " + aFile.leafName + " file"); + } + }); + }, + + // Track pending installs so we can avoid showing notifications for them. + pendingAddonInstalls: new Set(), + + installDistroAddons: Task.async(function* () { + const PREF_ADDONS_INSTALLED = "distribution.addonsInstalled"; + try { + let installed = Services.prefs.getBoolPref(PREF_ADDONS_INSTALLED); + if (installed) { + return; + } + } catch (e) { + Services.prefs.setBoolPref(PREF_ADDONS_INSTALLED, true); + } + + let distroPath; + try { + distroPath = FileUtils.getDir("XREAppDist", ["extensions"]).path; + + let info = yield OS.File.stat(distroPath); + if (!info.isDir) { + return; + } + } catch (e) { + return; + } + + let it = new OS.File.DirectoryIterator(distroPath); + try { + yield it.forEach(entry => { + // Only support extensions that are zipped in .xpi files. + if (entry.isDir || !entry.name.endsWith(".xpi")) { + dump("Ignoring distribution add-on that isn't an XPI: " + entry.path); + return; + } + + new Promise((resolve, reject) => { + AddonManager.getInstallForFile(new FileUtils.File(entry.path), resolve); + }).then(install => { + let id = entry.name.substring(0, entry.name.length - 4); + if (install.addon.id !== id) { + Cu.reportError("File entry " + entry.path + " contains an add-on with an incorrect ID"); + return; + } + this.pendingAddonInstalls.add(install); + install.install(); + }).catch(e => { + Cu.reportError("Error installing distribution add-on: " + entry.path + ": " + e); + }); + }); + } finally { + it.close(); + } + }) +}; + +var Tabs = { + _enableTabExpiration: false, + _useCache: false, + _domains: new Set(), + + init: function() { + // On low-memory platforms, always allow tab expiration. On high-mem + // platforms, allow it to be turned on once we hit a low-mem situation. + if (BrowserApp.isOnLowMemoryPlatform) { + this._enableTabExpiration = true; + } else { + Services.obs.addObserver(this, "memory-pressure", false); + } + + // Watch for opportunities to pre-connect to high probability targets. + Services.obs.addObserver(this, "Session:Prefetch", false); + + // Track the network connection so we can efficiently use the cache + // for possible offline rendering. + Services.obs.addObserver(this, "network:link-status-changed", false); + let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService); + this.useCache = !network.isLinkUp; + + BrowserApp.deck.addEventListener("pageshow", this, false); + BrowserApp.deck.addEventListener("TabOpen", this, false); + }, + + observe: function(aSubject, aTopic, aData) { + switch (aTopic) { + case "memory-pressure": + if (aData != "heap-minimize") { + // We received a low-memory related notification. This will enable + // expirations. + this._enableTabExpiration = true; + Services.obs.removeObserver(this, "memory-pressure"); + } else { + // Use "heap-minimize" as a trigger to expire the most stale tab. + this.expireLruTab(); + } + break; + case "Session:Prefetch": + if (aData) { + try { + let uri = Services.io.newURI(aData, null, null); + if (uri && !this._domains.has(uri.host)) { + Services.io.QueryInterface(Ci.nsISpeculativeConnect).speculativeConnect(uri, null); + this._domains.add(uri.host); + } + } catch (e) {} + } + break; + case "network:link-status-changed": + if (["down", "unknown", "up"].indexOf(aData) == -1) { + return; + } + this.useCache = (aData === "down"); + break; + } + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "pageshow": + // Clear the domain cache whenever a page is loaded into any browser. + this._domains.clear(); + + break; + case "TabOpen": + // Use opening a new tab as a trigger to expire the most stale tab. + this.expireLruTab(); + break; + } + }, + + // Manage the most-recently-used list of tabs. Each tab has a timestamp + // associated with it that indicates when it was last touched. + expireLruTab: function() { + if (!this._enableTabExpiration) { + return false; + } + let expireTimeMs = Services.prefs.getIntPref("browser.tabs.expireTime") * 1000; + if (expireTimeMs < 0) { + // This behaviour is disabled. + return false; + } + let tabs = BrowserApp.tabs; + let selected = BrowserApp.selectedTab; + let lruTab = null; + // Find the least recently used non-zombie tab. + for (let i = 0; i < tabs.length; i++) { + if (tabs[i] == selected || + tabs[i].browser.__SS_restore || + tabs[i].playingAudio) { + // This tab is selected, is already a zombie, or is currently playing + // audio, skip it. + continue; + } + if (lruTab == null || tabs[i].lastTouchedAt < lruTab.lastTouchedAt) { + lruTab = tabs[i]; + } + } + // If the tab was last touched more than browser.tabs.expireTime seconds ago, + // zombify it. + if (lruTab) { + if (Date.now() - lruTab.lastTouchedAt > expireTimeMs) { + MemoryObserver.zombify(lruTab); + return true; + } + } + return false; + }, + + get useCache() { + if (!Services.prefs.getBoolPref("browser.tabs.useCache")) { + return false; + } + return this._useCache; + }, + + set useCache(aUseCache) { + if (!Services.prefs.getBoolPref("browser.tabs.useCache")) { + return; + } + + if (this._useCache == aUseCache) { + return; + } + + BrowserApp.tabs.forEach(function(tab) { + if (tab.browser && tab.browser.docShell) { + if (aUseCache) { + tab.browser.docShell.defaultLoadFlags |= Ci.nsIRequest.LOAD_FROM_CACHE; + } else { + tab.browser.docShell.defaultLoadFlags &= ~Ci.nsIRequest.LOAD_FROM_CACHE; + } + } + }); + this._useCache = aUseCache; + }, + + // For debugging + dump: function(aPrefix) { + let tabs = BrowserApp.tabs; + for (let i = 0; i < tabs.length; i++) { + dump(aPrefix + " | " + "Tab [" + tabs[i].browser.contentWindow.location.href + "]: lastTouchedAt:" + tabs[i].lastTouchedAt + ", zombie:" + tabs[i].browser.__SS_restore); + } + }, +}; + +function ContextMenuItem(args) { + this.id = uuidgen.generateUUID().toString(); + this.args = args; +} + +ContextMenuItem.prototype = { + get order() { + return this.args.order || 0; + }, + + matches: function(elt, x, y) { + return this.args.selector.matches(elt, x, y); + }, + + callback: function(elt) { + this.args.callback(elt); + }, + + addVal: function(name, elt, defaultValue) { + if (!(name in this.args)) + return defaultValue; + + if (typeof this.args[name] == "function") + return this.args[name](elt); + + return this.args[name]; + }, + + getValue: function(elt) { + return { + id: this.id, + label: this.addVal("label", elt), + showAsActions: this.addVal("showAsActions", elt), + icon: this.addVal("icon", elt), + isGroup: this.addVal("isGroup", elt, false), + inGroup: this.addVal("inGroup", elt, false), + disabled: this.addVal("disabled", elt, false), + selected: this.addVal("selected", elt, false), + isParent: this.addVal("isParent", elt, false), + }; + } +} + +function HTMLContextMenuItem(elt, target) { + ContextMenuItem.call(this, { }); + + this.menuElementRef = Cu.getWeakReference(elt); + this.targetElementRef = Cu.getWeakReference(target); +} + +HTMLContextMenuItem.prototype = Object.create(ContextMenuItem.prototype, { + order: { + value: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER + }, + + matches: { + value: function(target) { + let t = this.targetElementRef.get(); + return t === target; + }, + }, + + callback: { + value: function(target) { + let elt = this.menuElementRef.get(); + if (!elt) { + return; + } + + // If this is a menu item, show a new context menu with the submenu in it + if (elt instanceof Ci.nsIDOMHTMLMenuElement) { + try { + NativeWindow.contextmenus.menus = {}; + + let elt = this.menuElementRef.get(); + let target = this.targetElementRef.get(); + if (!elt) { + return; + } + + var items = NativeWindow.contextmenus._getHTMLContextMenuItemsForMenu(elt, target); + // This menu will always only have one context, but we still make sure its the "right" one. + var context = NativeWindow.contextmenus._getContextType(target); + if (items.length > 0) { + NativeWindow.contextmenus._addMenuItems(items, context); + } + + } catch(ex) { + Cu.reportError(ex); + } + } else { + // otherwise just click the menu item + elt.click(); + } + }, + }, + + getValue: { + value: function(target) { + let elt = this.menuElementRef.get(); + if (!elt) { + return null; + } + + if (elt.hasAttribute("hidden")) { + return null; + } + + return { + id: this.id, + icon: elt.icon, + label: elt.label, + disabled: elt.disabled, + menu: elt instanceof Ci.nsIDOMHTMLMenuElement + }; + } + }, +}); + |