diff -r 000000000000 -r 6474c204b198 mobile/android/chrome/content/browser.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mobile/android/chrome/content/browser.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,8558 @@ +#filter substitution +// -*- 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"; + +let Cc = Components.classes; +let Ci = Components.interfaces; +let Cu = Components.utils; +let Cr = Components.results; + +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/FileUtils.jsm"); +Cu.import("resource://gre/modules/JNI.jsm"); +Cu.import('resource://gre/modules/Payment.jsm'); +Cu.import("resource://gre/modules/NotificationDB.jsm"); +Cu.import("resource://gre/modules/SpatialNavigation.jsm"); +Cu.import("resource://gre/modules/UITelemetry.jsm"); + +#ifdef ACCESSIBILITY +Cu.import("resource://gre/modules/accessibility/AccessFu.jsm"); +#endif + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "sendMessageToJava", + "resource://gre/modules/Messaging.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "DebuggerServer", + "resource://gre/modules/devtools/dbg-server.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UserAgentOverrides", + "resource://gre/modules/UserAgentOverrides.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerContent", + "resource://gre/modules/LoginManagerContent.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +#ifdef MOZ_SAFE_BROWSING +XPCOMUtils.defineLazyModuleGetter(this, "SafeBrowsing", + "resource://gre/modules/SafeBrowsing.jsm"); +#endif + +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.defineLazyModuleGetter(this, "SimpleServiceDiscovery", + "resource://gre/modules/SimpleServiceDiscovery.jsm"); + +#ifdef NIGHTLY_BUILD +XPCOMUtils.defineLazyModuleGetter(this, "ShumwayUtils", + "resource://shumway/ShumwayUtils.jsm"); +#endif + +#ifdef MOZ_ANDROID_SYNTHAPKS +XPCOMUtils.defineLazyModuleGetter(this, "WebappManager", + "resource://gre/modules/WebappManager.jsm"); +#endif + +XPCOMUtils.defineLazyModuleGetter(this, "CharsetMenu", + "resource://gre/modules/CharsetMenu.jsm"); + +// Lazily-loaded browser scripts: +[ + ["SelectHelper", "chrome://browser/content/SelectHelper.js"], + ["InputWidgetHelper", "chrome://browser/content/InputWidgetHelper.js"], + ["AboutReader", "chrome://browser/content/aboutReader.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"], + ["ZoomHelper", "chrome://browser/content/ZoomHelper.js"], + ["CastingApps", "chrome://browser/content/CastingApps.js"], +].forEach(function (aScript) { + let [name, script] = aScript; + XPCOMUtils.defineLazyGetter(window, name, function() { + let sandbox = {}; + Services.scriptloader.loadSubScript(script, sandbox); + return sandbox[name]; + }); +}); + +[ +#ifdef MOZ_WEBRTC + ["WebrtcUI", ["getUserMedia:request", "recording-device-events"], "chrome://browser/content/WebrtcUI.js"], +#endif + ["MemoryObserver", ["memory-pressure", "Memory:Dump"], "chrome://browser/content/MemoryObserver.js"], + ["ConsoleAPI", ["console-api-log-event"], "chrome://browser/content/ConsoleAPI.js"], + ["FindHelper", ["FindInPage:Find", "FindInPage:Prev", "FindInPage:Next", "FindInPage:Closed", "Tab:Selected"], "chrome://browser/content/FindHelper.js"], + ["PermissionsHelper", ["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"], + ["SelectionHandler", ["TextSelection:Get"], "chrome://browser/content/SelectionHandler.js"], +].forEach(function (aScript) { + let [name, notifications, script] = aScript; + XPCOMUtils.defineLazyGetter(window, name, function() { + let sandbox = {}; + Services.scriptloader.loadSubScript(script, sandbox); + return sandbox[name]; + }); + notifications.forEach(function (aNotification) { + Services.obs.addObserver(function(s, t, d) { + window[name].observe(s, t, d) + }, aNotification, false); + }); +}); + +// 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); + notifications.forEach(notification => { + Services.obs.addObserver((s,t,d) => { + this[name].observe(s,t,d) + }, notification, false); + }); +}); + +XPCOMUtils.defineLazyServiceGetter(this, "Haptic", + "@mozilla.org/widget/hapticfeedback;1", "nsIHapticFeedback"); + +XPCOMUtils.defineLazyServiceGetter(this, "DOMUtils", + "@mozilla.org/inspector/dom-utils;1", "inIDOMUtils"); + +XPCOMUtils.defineLazyServiceGetter(window, "URIFixup", + "@mozilla.org/docshell/urifixup;1", "nsIURIFixup"); + +#ifdef MOZ_WEBRTC +XPCOMUtils.defineLazyServiceGetter(this, "MediaManagerService", + "@mozilla.org/mediaManagerService;1", "nsIMediaManagerService"); +#endif + +const kStateActive = 0x00000001; // :active pseudoclass for elements + +const kXLinkNamespace = "http://www.w3.org/1999/xlink"; + +const kDefaultCSSViewportWidth = 980; +const kDefaultCSSViewportHeight = 480; + +const kViewportRemeasureThrottle = 500; + +const kDoNotTrackPrefState = Object.freeze({ + NO_PREF: "0", + DISALLOW_TRACKING: "1", + ALLOW_TRACKING: "2", +}); + +function dump(a) { + Services.console.logStringMessage(a); +} + +function doChangeMaxLineBoxWidth(aWidth) { + gReflowPending = null; + let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + let docShell = webNav.QueryInterface(Ci.nsIDocShell); + let docViewer = docShell.contentViewer.QueryInterface(Ci.nsIMarkupDocumentViewer); + + let range = null; + if (BrowserApp.selectedTab._mReflozPoint) { + range = BrowserApp.selectedTab._mReflozPoint.range; + } + + try { + docViewer.pausePainting(); + docViewer.changeMaxLineBoxWidth(aWidth); + + if (range) { + ZoomHelper.zoomInAndSnapToRange(range); + } else { + // In this case, we actually didn't zoom into a specific range. It + // probably happened from a page load reflow-on-zoom event, so we + // need to make sure painting is re-enabled. + BrowserApp.selectedTab.clearReflowOnZoomPendingActions(); + } + } finally { + docViewer.resumePainting(); + } +} + +function fuzzyEquals(a, b) { + return (Math.abs(a - b) < 1e-6); +} + +/** + * Convert a font size to CSS pixels (px) from twentieiths-of-a-point + * (twips). + */ +function convertFromTwipsToPx(aSize) { + return aSize/240 * 16.0; +} + +#ifdef MOZ_CRASHREPORTER +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "CrashReporter", + "@mozilla.org/xre/app-info;1", "nsICrashReporter"); +#endif + +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"); + +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 = {}; +[ + ["brand", "chrome://branding/locale/brand.properties"], + ["browser", "chrome://browser/locale/browser.properties"] +].forEach(function (aStringBundle) { + let [name, bundle] = aStringBundle; + XPCOMUtils.defineLazyGetter(Strings, name, function() { + return Services.strings.createBundle(bundle); + }); +}); + +const kFormHelperModeDisabled = 0; +const kFormHelperModeEnabled = 1; +const kFormHelperModeDynamic = 2; // disabled on tablets + +var BrowserApp = { + _tabs: [], + _selectedTab: null, + _prefObservers: [], + isGuest: false, + + 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."); + + this.deck = document.getElementById("browsers"); + this.deck.addEventListener("DOMContentLoaded", function BrowserApp_delayedStartup() { + try { + BrowserApp.deck.removeEventListener("DOMContentLoaded", BrowserApp_delayedStartup, false); + Services.obs.notifyObservers(window, "browser-delayed-startup-finished", ""); + sendMessageToJava({ type: "Gecko:DelayedStartup" }); + } catch(ex) { console.log(ex); } + }, false); + + BrowserEventHandler.init(); + ViewportHandler.init(); + + Services.androidBridge.browserApp = this; + + 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:ShowHistory", false); + Services.obs.addObserver(this, "Session:Forward", 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, "Preferences:Set", 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, "Viewport:Change", false); + Services.obs.addObserver(this, "Viewport:Flush", false); + Services.obs.addObserver(this, "Viewport:FixedMarginsChanged", false); + Services.obs.addObserver(this, "Passwords:Init", false); + Services.obs.addObserver(this, "FormHistory:Init", false); + Services.obs.addObserver(this, "gather-telemetry", false); + Services.obs.addObserver(this, "keyword-search", false); +#ifdef MOZ_ANDROID_SYNTHAPKS + Services.obs.addObserver(this, "webapps-runtime-install", false); + Services.obs.addObserver(this, "webapps-runtime-install-package", false); + Services.obs.addObserver(this, "webapps-ask-install", false); + Services.obs.addObserver(this, "webapps-launch", false); + Services.obs.addObserver(this, "webapps-uninstall", false); + Services.obs.addObserver(this, "Webapps:AutoInstall", false); + Services.obs.addObserver(this, "Webapps:Load", false); + Services.obs.addObserver(this, "Webapps:AutoUninstall", false); +#endif + Services.obs.addObserver(this, "sessionstore-state-purge-complete", false); + + function showFullScreenWarning() { + NativeWindow.toast.show(Strings.browser.GetStringFromName("alertFullScreenToast"), "short"); + } + + window.addEventListener("fullscreen", function() { + sendMessageToJava({ + type: window.fullScreen ? "ToggleChrome:Show" : "ToggleChrome:Hide" + }); + }, false); + + window.addEventListener("mozfullscreenchange", function() { + sendMessageToJava({ + type: document.mozFullScreen ? "DOMFullScreen:Start" : "DOMFullScreen:Stop" + }); + + if (document.mozFullScreen) + showFullScreenWarning(); + }, false); + + // When a restricted key is pressed in DOM full-screen mode, we should display + // the "Press ESC to exit" warning message. + window.addEventListener("MozShowFullScreenWarning", showFullScreenWarning, true); + + NativeWindow.init(); + LightWeightThemeWebInstaller.init(); + Downloads.init(); + FormAssistant.init(); + IndexedDB.init(); + HealthReportStatusListener.init(); + XPInstallObserver.init(); + CharacterEncoding.init(); + ActivityObserver.init(); +#ifdef MOZ_ANDROID_SYNTHAPKS + // TODO: replace with Android implementation of WebappOSUtils.isLaunchable. + Cu.import("resource://gre/modules/Webapps.jsm"); + DOMApplicationRegistry.allAppsLaunchable = true; +#else + WebappsUI.init(); +#endif + RemoteDebugger.init(); + Reader.init(); + UserAgentOverrides.init(); + DesktopUserAgent.init(); + CastingApps.init(); + Distribution.init(); + Tabs.init(); +#ifdef ACCESSIBILITY + AccessFu.attach(window); +#endif +#ifdef NIGHTLY_BUILD + ShumwayUtils.init(); +#endif + + // Init LoginManager + Cc["@mozilla.org/login-manager;1"].getService(Ci.nsILoginManager); + + let url = null; + let pinned = false; + if ("arguments" in window) { + if (window.arguments[0]) + url = window.arguments[0]; + if (window.arguments[1]) + gScreenWidth = window.arguments[1]; + if (window.arguments[2]) + gScreenHeight = window.arguments[2]; + if (window.arguments[3]) + pinned = window.arguments[3]; + if (window.arguments[4]) + this.isGuest = window.arguments[4]; + } + + if (pinned) { + this._initRuntime(this._startupStatus, url, aUrl => this.addTab(aUrl)); + } else { + SearchEngines.init(); + this.initContextMenu(); + } + // 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 + ExternalApps.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(); + + // Store the low-precision buffer pref + this.gUseLowPrecision = Services.prefs.getBoolPref("layers.low-precision-buffer"); + + // notify java that gecko has loaded + sendMessageToJava({ type: "Gecko:Ready" }); + +#ifdef MOZ_SAFE_BROWSING + // Bug 778855 - Perf regression if we do this here. To be addressed in bug 779008. + setTimeout(function() { SafeBrowsing.init(); }, 5000); +#endif + }, + + get _startupStatus() { + delete this._startupStatus; + + let savedMilestone = null; + try { + savedMilestone = Services.prefs.getCharPref("browser.startup.homepage_override.mstone"); + } catch (e) { + } +#expand let ourMilestone = "__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); + sendMessageToJava({ type: "Locale:Set", locale: locale }); + }, + + _initRuntime: function(status, url, callback) { + let sandbox = {}; + Services.scriptloader.loadSubScript("chrome://browser/content/WebappRT.js", sandbox); + window.WebappRT = sandbox.WebappRT; + WebappRT.init(status, url, callback); + }, + + initContextMenu: function ba_initContextMenu() { + // TODO: These should eventually move into more appropriate classes + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("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); + 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); + NativeWindow.toast.show(label, "short"); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.openInPrivateTab"), + NativeWindow.contextmenus.linkOpenableContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_open_private_tab"); + UITelemetry.addEvent("loadurl.1", "contextmenu", null); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + ContentAreaUtils.urlSecurityCheck(url, aTarget.ownerDocument.nodePrincipal); + 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); + NativeWindow.toast.show(label, "short"); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("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(Strings.browser.GetStringFromName("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(Strings.browser.GetStringFromName("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: Strings.browser.GetStringFromName("contextmenu.shareLink"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, // Show above HTML5 menu items + selector: NativeWindow.contextmenus._disableInGuest(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) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_link"); + } + }); + + NativeWindow.contextmenus.add({ + label: Strings.browser.GetStringFromName("contextmenu.shareEmailAddress"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, + selector: NativeWindow.contextmenus._disableInGuest(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) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_email"); + } + }); + + NativeWindow.contextmenus.add({ + label: Strings.browser.GetStringFromName("contextmenu.sharePhoneNumber"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, + selector: NativeWindow.contextmenus._disableInGuest(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) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_phone"); + } + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.addToContacts"), + NativeWindow.contextmenus._disableInGuest(NativeWindow.contextmenus.emailLinkContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_contact_email"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + sendMessageToJava({ + type: "Contact:Add", + email: url + }); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.addToContacts"), + NativeWindow.contextmenus._disableInGuest(NativeWindow.contextmenus.phoneNumberLinkContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_contact_phone"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + sendMessageToJava({ + type: "Contact:Add", + phone: url + }); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.bookmarkLink"), + NativeWindow.contextmenus._disableInGuest(NativeWindow.contextmenus.linkBookmarkableContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_bookmark"); + + let url = NativeWindow.contextmenus._getLinkURL(aTarget); + let title = aTarget.textContent || aTarget.title || url; + sendMessageToJava({ + type: "Bookmark:Insert", + url: url, + title: title + }); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.playMedia"), + NativeWindow.contextmenus.mediaContext("media-paused"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_play"); + aTarget.play(); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.pauseMedia"), + NativeWindow.contextmenus.mediaContext("media-playing"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_pause"); + aTarget.pause(); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("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: Strings.browser.GetStringFromName("contextmenu.shareMedia"), + order: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER - 1, + selector: NativeWindow.contextmenus._disableInGuest(NativeWindow.contextmenus.SelectorContext("video")), + 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) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_share_media"); + } + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.fullScreen"), + NativeWindow.contextmenus.SelectorContext("video:not(:-moz-full-screen)"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_fullscreen"); + aTarget.mozRequestFullScreen(); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.mute"), + NativeWindow.contextmenus.mediaContext("media-unmuted"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_mute"); + aTarget.muted = true; + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.unmute"), + NativeWindow.contextmenus.mediaContext("media-muted"), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_unmute"); + aTarget.muted = false; + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("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: Strings.browser.GetStringFromName("contextmenu.shareImage"), + selector: NativeWindow.contextmenus._disableInGuest(NativeWindow.contextmenus.imageSaveableContext), + 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.characterSet); + 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(Strings.browser.GetStringFromName("contextmenu.saveImage"), + NativeWindow.contextmenus.imageSaveableContext, + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_save_image"); + + ContentAreaUtils.saveImageURL(aTarget.currentURI.spec, null, "SaveImageTitle", + false, true, aTarget.ownerDocument.documentURIObject, + aTarget.ownerDocument); + }); + + NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.setImageAs"), + NativeWindow.contextmenus._disableInGuest(NativeWindow.contextmenus.imageSaveableContext), + function(aTarget) { + UITelemetry.addEvent("action.1", "contextmenu", null, "web_background_image"); + + let src = aTarget.src; + sendMessageToJava({ + 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"); + + 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); + }); + }, + + onAppUpdated: function() { + // initialize the form history and passwords databases on upgrades + Services.obs.notifyObservers(null, "FormHistory:Init", ""); + Services.obs.notifyObservers(null, "Passwords:Init", ""); + + // 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"); + } + }, + + shutdown: function shutdown() { + NativeWindow.uninit(); + LightWeightThemeWebInstaller.uninit(); + FormAssistant.uninit(); + IndexedDB.uninit(); + ViewportHandler.uninit(); + XPInstallObserver.uninit(); + HealthReportStatusListener.uninit(); + CharacterEncoding.uninit(); + SearchEngines.uninit(); +#ifndef MOZ_ANDROID_SYNTHAPKS + WebappsUI.uninit(); +#endif + RemoteDebugger.uninit(); + Reader.uninit(); + UserAgentOverrides.uninit(); + DesktopUserAgent.uninit(); + ExternalApps.uninit(); + CastingApps.uninit(); + Distribution.uninit(); + Tabs.uninit(); + }, + + // 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()) + 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(); + }, + + get tabs() { + return this._tabs; + }, + + get selectedTab() { + return this._selectedTab; + }, + + set selectedTab(aTab) { + if (this._selectedTab == aTab) + return; + + if (this._selectedTab) { + this._selectedTab.setActive(false); + } + + this._selectedTab = aTab; + if (!aTab) + return; + + aTab.setActive(true); + aTab.setResolution(aTab._zoom, 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 ("userSearch" in aParams) tab.userSearch = aParams.userSearch; + } + + try { + aBrowser.loadURIWithFlags(aURI, flags, referrerURI, charset, postData); + } catch(e) { + if (tab) { + let message = { + type: "Content:LoadError", + tabID: tab.id + }; + sendMessageToJava(message); + dump("Handled load error: " + e) + } + } + }, + + addTab: function addTab(aURI, aParams) { + aParams = aParams || {}; + + let newTab = new Tab(aURI, aParams); + 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 + }; + sendMessageToJava(message); + }, + +#ifdef MOZ_ANDROID_SYNTHAPKS + _loadWebapp: function(aMessage) { + + this._initRuntime(this._startupStatus, aMessage.url, aUrl => { + this.manifestUrl = aMessage.url; + this.addTab(aUrl, { title: aMessage.name }); + }); + }, +#endif + + // Calling this will update the state in BrowserApp after a tab has been + // closed in the Java UI. + _handleTabClosed: function _handleTabClosed(aTab) { + if (aTab == this.selectedTab) + this.selectedTab = null; + + let evt = document.createEvent("UIEvents"); + evt.initUIEvent("TabClose", true, false, window, null); + aTab.browser.dispatchEvent(evt); + + aTab.destroy(); + this._tabs.splice(this._tabs.indexOf(aTab), 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 message = { + type: "Tab:Select", + tabID: aTab.id + }; + sendMessageToJava(message); + }, + + /** + * Gets an open tab with the given URL. + * + * @param aURL URL to look for + * @return the tab with the given URL, or null if no such tab exists + */ + getTabWithURL: function getTabWithURL(aURL) { + let uri = Services.io.newURI(aURL, null, null); + for (let i = 0; i < this._tabs.length; ++i) { + let tab = this._tabs[i]; + 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 + */ + selectOrOpenTab: function selectOrOpenTab(aURL) { + let tab = this.getTabWithURL(aURL); + if (tab == null) { + this.addTab(aURL); + } else { + this.selectTab(tab); + } + }, + + // This method updates the state in BrowserApp after a tab has been selected + // in the Java UI. + _handleTabSelected: function _handleTabSelected(aTab) { + this.selectedTab = aTab; + + let evt = document.createEvent("UIEvents"); + evt.initUIEvent("TabSelect", true, false, window, null); + aTab.browser.dispatchEvent(evt); + }, + + quit: function quit() { + // Figure out if there's at least one other browser window around. + let lastBrowser = true; + let e = Services.wm.getEnumerator("navigator:browser"); + while (e.hasMoreElements() && lastBrowser) { + let win = e.getNext(); + if (!win.closed && win != window) + lastBrowser = false; + } + + if (lastBrowser) { + // Let everyone know we are closing the last browser window + let closingCanceled = Cc["@mozilla.org/supports-PRBool;1"].createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(closingCanceled, "browser-lastwindow-close-requested", null); + if (closingCanceled.data) + return; + + Services.obs.notifyObservers(null, "browser-lastwindow-close-granted", null); + } + + window.QueryInterface(Ci.nsIDOMChromeWindow).minimize(); + window.close(); + }, + + saveAsPDF: function saveAsPDF(aBrowser) { + // Create the final destination file location + let fileName = ContentAreaUtils.getDefaultFileName(aBrowser.contentTitle, aBrowser.currentURI, null, null); + fileName = fileName.trim() + ".pdf"; + + let dm = Cc["@mozilla.org/download-manager;1"].getService(Ci.nsIDownloadManager); + let downloadsDir = dm.defaultDownloadsDirectory; + + let file = downloadsDir.clone(); + file.append(fileName); + file.createUnique(file.NORMAL_FILE_TYPE, parseInt("666", 8)); + + let printSettings = Cc["@mozilla.org/gfx/printsettings-service;1"].getService(Ci.nsIPrintSettingsService).newPrintSettings; + printSettings.printSilent = true; + printSettings.showPrintProgress = false; + printSettings.printBGImages = true; + printSettings.printBGColors = true; + printSettings.printToFile = true; + printSettings.toFileName = file.path; + printSettings.printFrameType = Ci.nsIPrintSettings.kFramesAsIs; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + + //XXX we probably need a preference here, the header can be useful + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + + // Create a valid mimeInfo for the PDF + let ms = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + let mimeInfo = ms.getFromTypeAndExtension("application/pdf", "pdf"); + + let webBrowserPrint = aBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserPrint); + + let cancelable = { + cancel: function (aReason) { + webBrowserPrint.cancel(); + } + } + let isPrivate = PrivateBrowsingUtils.isWindowPrivate(aBrowser.contentWindow); + let download = dm.addDownload(Ci.nsIDownloadManager.DOWNLOAD_TYPE_DOWNLOAD, + aBrowser.currentURI, + Services.io.newFileURI(file), "", mimeInfo, + Date.now() * 1000, null, cancelable, isPrivate); + + webBrowserPrint.print(printSettings, download); + }, + + notifyPrefObservers: function(aPref) { + this._prefObservers[aPref].forEach(function(aRequestId) { + this.getPreferences(aRequestId, [aPref], 1); + }, this); + }, + + handlePreferencesRequest: function handlePreferencesRequest(aRequestId, + aPrefNames, + aListen) { + + let prefs = []; + + for (let prefName of aPrefNames) { + let pref = { + name: prefName, + type: "", + value: null + }; + + if (aListen) { + if (this._prefObservers[prefName]) + this._prefObservers[prefName].push(aRequestId); + else + this._prefObservers[prefName] = [ aRequestId ]; + Services.prefs.addObserver(prefName, this, false); + } + + // These pref names are not "real" pref names. + // They are used in the setting menu, + // and these are passed when initializing the setting menu. + switch (prefName) { + // The plugin pref is actually two separate prefs, so + // we need to handle it differently + case "plugin.enable": + pref.type = "string";// Use a string type for java's ListPreference + pref.value = PluginHelper.getPluginPreference(); + prefs.push(pref); + continue; + // Handle master password + case "privacy.masterpassword.enabled": + pref.type = "bool"; + pref.value = MasterPassword.enabled; + prefs.push(pref); + continue; + // Handle do-not-track preference + case "privacy.donottrackheader": + pref.type = "string"; + + let enableDNT = Services.prefs.getBoolPref("privacy.donottrackheader.enabled"); + if (!enableDNT) { + pref.value = kDoNotTrackPrefState.NO_PREF; + } else { + let dntState = Services.prefs.getIntPref("privacy.donottrackheader.value"); + pref.value = (dntState === 0) ? kDoNotTrackPrefState.ALLOW_TRACKING : + kDoNotTrackPrefState.DISALLOW_TRACKING; + } + + prefs.push(pref); + continue; +#ifdef MOZ_CRASHREPORTER + // Crash reporter submit pref must be fetched from nsICrashReporter service. + case "datareporting.crashreporter.submitEnabled": + pref.type = "bool"; + pref.value = CrashReporter.submitReports; + prefs.push(pref); + continue; +#endif + } + + try { + switch (Services.prefs.getPrefType(prefName)) { + case Ci.nsIPrefBranch.PREF_BOOL: + pref.type = "bool"; + pref.value = Services.prefs.getBoolPref(prefName); + break; + case Ci.nsIPrefBranch.PREF_INT: + pref.type = "int"; + pref.value = Services.prefs.getIntPref(prefName); + break; + case Ci.nsIPrefBranch.PREF_STRING: + default: + pref.type = "string"; + try { + // Try in case it's a localized string (will throw an exception if not) + pref.value = Services.prefs.getComplexValue(prefName, Ci.nsIPrefLocalizedString).data; + } catch (e) { + pref.value = Services.prefs.getCharPref(prefName); + } + break; + } + } catch (e) { + dump("Error reading pref [" + prefName + "]: " + e); + // preference does not exist; do not send it + continue; + } + + // Some Gecko preferences use integers or strings to reference + // state instead of directly representing the value. + // Since the Java UI uses the type to determine which ui elements + // to show and how to handle them, we need to normalize these + // preferences to the correct type. + switch (prefName) { + // (string) index for determining which multiple choice value to display. + case "browser.chrome.titlebarMode": + case "network.cookie.cookieBehavior": + case "font.size.inflation.minTwips": + case "home.sync.updateMode": + pref.type = "string"; + pref.value = pref.value.toString(); + break; + } + + prefs.push(pref); + } + + sendMessageToJava({ + type: "Preferences:Data", + requestId: aRequestId, // opaque request identifier, can be any string/int/whatever + preferences: prefs + }); + }, + + setPreferences: function setPreferences(aPref) { + let json = JSON.parse(aPref); + + switch (json.name) { + // The plugin pref is actually two separate prefs, so + // we need to handle it differently + case "plugin.enable": + PluginHelper.setPluginPreference(json.value); + return; + + // MasterPassword pref is not real, we just need take action and leave + case "privacy.masterpassword.enabled": + if (MasterPassword.enabled) + MasterPassword.removePassword(json.value); + else + MasterPassword.setPassword(json.value); + return; + + // "privacy.donottrackheader" is not "real" pref name, it's used in the setting menu. + case "privacy.donottrackheader": + switch (json.value) { + // Don't tell anything about tracking me + case kDoNotTrackPrefState.NO_PREF: + Services.prefs.setBoolPref("privacy.donottrackheader.enabled", false); + Services.prefs.clearUserPref("privacy.donottrackheader.value"); + break; + // Accept tracking me + case kDoNotTrackPrefState.ALLOW_TRACKING: + Services.prefs.setBoolPref("privacy.donottrackheader.enabled", true); + Services.prefs.setIntPref("privacy.donottrackheader.value", 0); + break; + // Not accept tracking me + case kDoNotTrackPrefState.DISALLOW_TRACKING: + Services.prefs.setBoolPref("privacy.donottrackheader.enabled", true); + Services.prefs.setIntPref("privacy.donottrackheader.value", 1); + break; + } + return; + + // Enabling or disabling suggestions will prevent future prompts + case SearchEngines.PREF_SUGGEST_ENABLED: + Services.prefs.setBoolPref(SearchEngines.PREF_SUGGEST_PROMPTED, true); + break; + +#ifdef MOZ_CRASHREPORTER + // Crash reporter preference is in a service; set and return. + case "datareporting.crashreporter.submitEnabled": + CrashReporter.submitReports = json.value; + return; +#endif + // When sending to Java, we normalized special preferences that use + // integers and strings to represent booleans. Here, we convert them back + // to their actual types so we can store them. + case "browser.chrome.titlebarMode": + case "network.cookie.cookieBehavior": + case "font.size.inflation.minTwips": + case "home.sync.updateMode": + json.type = "int"; + json.value = parseInt(json.value); + break; + } + + switch (json.type) { + case "bool": + Services.prefs.setBoolPref(json.name, json.value); + break; + case "int": + Services.prefs.setIntPref(json.name, json.value); + break; + default: { + let pref = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(Ci.nsIPrefLocalizedString); + pref.data = json.value; + Services.prefs.setComplexValue(json.name, Ci.nsISupportsString, pref); + break; + } + } + }, + + sanitize: function (aItems) { + let json = JSON.parse(aItems); + let success = true; + + for (let key in json) { + if (!json[key]) + continue; + + try { + switch (key) { + case "cookies_sessions": + Sanitizer.clearItem("cookies"); + Sanitizer.clearItem("sessions"); + break; + default: + Sanitizer.clearItem(key); + } + } catch (e) { + dump("sanitize error: " + e); + success = false; + } + } + + sendMessageToJava({ + type: "Sanitize:Finished", + success: success + }); + }, + + 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)) + 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 focused = this.getFocusedInput(aBrowser); + + if (focused) { + let shouldZoom = Services.prefs.getBoolPref("formhelper.autozoom"); + if (formHelperMode == kFormHelperModeDynamic && this.isTablet) + shouldZoom = false; + // ZoomHelper.zoomToElement will handle not sending any message if this input is already mostly filling the screen + ZoomHelper.zoomToElement(focused, -1, false, + aAllowZoom && shouldZoom && !ViewportHandler.getViewportMetadata(aBrowser.contentWindow).isSpecified); + } + }, + + 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:Reload": { + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY | Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + + // Check to see if this is a message to enable/disable mixed content blocking. + if (aData) { + let allowMixedContent = JSON.parse(aData).allowMixedContent; + if (allowMixedContent) { + // Set a flag to disable mixed content blocking. + flags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_MIXED_CONTENT; + } else { + // Set mixedContentChannel to null to re-enable mixed content blocking. + let docShell = browser.webNavigation.QueryInterface(Ci.nsIDocShell); + docShell.mixedContentChannel = null; + } + } + + // 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 "Session:ShowHistory": { + let data = JSON.parse(aData); + this.showHistory(data.fromIndex, data.toIndex, data.selIndex); + break; + } + + case "Tab:Load": { + let data = JSON.parse(aData); + + // Pass LOAD_FLAGS_DISALLOW_INHERIT_OWNER to prevent any loads from + // inheriting the currently loaded document's principal. + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP | + Ci.nsIWebNavigation.LOAD_FLAGS_FIXUP_SCHEME_TYPOS; + if (data.userEntered) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_OWNER; + } + + 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) + }; + + let url = data.url; + if (data.engine) { + let engine = Services.search.getEngineByName(data.engine); + if (engine) { + params.userSearch = url; + let submission = engine.getSubmission(url); + url = submission.uri.spec; + params.postData = submission.postData; + } + } + + 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": + this._handleTabClosed(this.getTabForId(parseInt(aData))); + 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.userSearch = aData; + + let engine = aSubject.QueryInterface(Ci.nsISearchEngine); + sendMessageToJava({ + type: "Search:Keyword", + identifier: engine.identifier, + name: engine.name, + }); + break; + + case "Browser:Quit": + this.quit(); + break; + + case "SaveAs:PDF": + this.saveAsPDF(browser); + break; + + case "Preferences:Set": + this.setPreferences(aData); + 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(aData); + break; + + case "FullScreen:Exit": + browser.contentDocument.mozCancelFullScreen(); + break; + + case "Viewport:Change": + if (this.isBrowserContentDocumentDisplayed()) + this.selectedTab.setViewport(JSON.parse(aData)); + break; + + case "Viewport:Flush": + this.contentDocumentChanged(); + break; + + case "Passwords:Init": { + let storage = Cc["@mozilla.org/login-manager/storage/mozStorage;1"]. + getService(Ci.nsILoginManagerStorage); + storage.init(); + 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 "sessionstore-state-purge-complete": + sendMessageToJava({ type: "Session:StatePurged" }); + break; + + case "gather-telemetry": + sendMessageToJava({ type: "Telemetry:Gather" }); + break; + + case "Viewport:FixedMarginsChanged": + gViewportMargins = JSON.parse(aData); + this.selectedTab.updateViewportSize(gScreenWidth); + break; + + case "nsPref:changed": + this.notifyPrefObservers(aData); + break; + +#ifdef MOZ_ANDROID_SYNTHAPKS + case "webapps-runtime-install": + WebappManager.install(JSON.parse(aData), aSubject); + break; + + case "webapps-runtime-install-package": + WebappManager.installPackage(JSON.parse(aData), aSubject); + break; + + case "webapps-ask-install": + WebappManager.askInstall(JSON.parse(aData)); + break; + + case "webapps-launch": { + WebappManager.launch(JSON.parse(aData)); + break; + } + + case "webapps-uninstall": { + WebappManager.uninstall(JSON.parse(aData)); + break; + } + + case "Webapps:AutoInstall": + WebappManager.autoInstall(JSON.parse(aData)); + break; + + case "Webapps:Load": + this._loadWebapp(JSON.parse(aData)); + break; + + case "Webapps:AutoUninstall": + WebappManager.autoUninstall(JSON.parse(aData)); + break; +#endif + + case "Locale:Changed": + // 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); + + // TODO: do we need to be more nuanced here -- e.g., checking for the + // OS locale -- or should it always be false on Fennec? + Services.prefs.setBoolPref("intl.locale.matchOS", false); + Services.prefs.setCharPref("general.useragent.locale", aData); + break; + + default: + dump('BrowserApp.observe: unexpected topic "' + aTopic + '"\n'); + break; + + } + }, + + get defaultBrowserWidth() { + delete this.defaultBrowserWidth; + let width = Services.prefs.getIntPref("browser.viewport.desktopWidth"); + return this.defaultBrowserWidth = width; + }, + + // nsIAndroidBrowserApp + getBrowserTab: function(tabId) { + return this.getTabForId(tabId); + }, + + getUITelemetryObserver: function() { + return UITelemetry; + }, + + getPreferences: function getPreferences(requestId, prefNames, count) { + this.handlePreferencesRequest(requestId, prefNames, false); + }, + + observePreferences: function observePreferences(requestId, prefNames, count) { + this.handlePreferencesRequest(requestId, prefNames, true); + }, + + removePreferenceObservers: function removePreferenceObservers(aRequestId) { + let newPrefObservers = []; + for (let prefName in this._prefObservers) { + let requestIds = this._prefObservers[prefName]; + // Remove the requestID from the preference handlers + let i = requestIds.indexOf(aRequestId); + if (i >= 0) { + requestIds.splice(i, 1); + } + + // If there are no more request IDs, remove the observer + if (requestIds.length == 0) { + Services.prefs.removeObserver(prefName, this); + } else { + newPrefObservers[prefName] = requestIds; + } + } + this._prefObservers = newPrefObservers; + }, + + // This method will print a list from fromIndex to toIndex, optionally + // selecting selIndex(if fromIndex<=selIndex<=toIndex) + showHistory: function(fromIndex, toIndex, selIndex) { + let browser = this.selectedBrowser; + let hist = browser.sessionHistory; + let listitems = []; + for (let i = toIndex; i >= fromIndex; i--) { + let entry = hist.getEntryAtIndex(i, false); + let item = { + label: entry.title || entry.URI.spec, + selected: (i == selIndex) + }; + listitems.push(item); + } + + let p = new Prompt({ + window: browser.contentWindow + }).setSingleChoiceItems(listitems).show(function(data) { + let selected = data.button; + if (selected == -1) + return; + + browser.gotoIndex(toIndex-selected); + }); + }, +}; + +var NativeWindow = { + init: function() { + Services.obs.addObserver(this, "Menu:Clicked", false); + Services.obs.addObserver(this, "PageActions:Clicked", false); + Services.obs.addObserver(this, "PageActions:LongClicked", false); + Services.obs.addObserver(this, "Doorhanger:Reply", false); + Services.obs.addObserver(this, "Toast:Click", false); + Services.obs.addObserver(this, "Toast:Hidden", false); + this.contextmenus.init(); + }, + + uninit: function() { + Services.obs.removeObserver(this, "Menu:Clicked"); + Services.obs.removeObserver(this, "PageActions:Clicked"); + Services.obs.removeObserver(this, "PageActions:LongClicked"); + Services.obs.removeObserver(this, "Doorhanger:Reply"); + Services.obs.removeObserver(this, "Toast:Click", false); + Services.obs.removeObserver(this, "Toast:Hidden", false); + this.contextmenus.uninit(); + }, + + loadDex: function(zipFile, implClass) { + sendMessageToJava({ + type: "Dex:Load", + zipfile: zipFile, + impl: implClass || "Main" + }); + }, + + unloadDex: function(zipFile) { + sendMessageToJava({ + type: "Dex:Unload", + zipfile: zipFile + }); + }, + + toast: { + _callbacks: {}, + show: function(aMessage, aDuration, aOptions) { + let msg = { + type: "Toast:Show", + message: aMessage, + duration: aDuration + }; + + if (aOptions && aOptions.button) { + msg.button = { + label: aOptions.button.label, + id: uuidgen.generateUUID().toString(), + // If the caller specified a button, make sure we convert any chrome urls + // to jar:jar urls so that the frontend can show them + icon: aOptions.button.icon ? resolveGeckoURI(aOptions.button.icon) : null, + }; + this._callbacks[msg.button.id] = aOptions.button.callback; + } + + sendMessageToJava(msg); + } + }, + + pageactions: { + _items: { }, + add: function(aOptions) { + let id = uuidgen.generateUUID().toString(); + sendMessageToJava({ + type: "PageActions:Add", + id: id, + title: aOptions.title, + icon: resolveGeckoURI(aOptions.icon), + important: "important" in aOptions ? aOptions.important : false + }); + this._items[id] = { + clickCallback: aOptions.clickCallback, + longClickCallback: aOptions.longClickCallback + }; + return id; + }, + remove: function(id) { + sendMessageToJava({ + type: "PageActions:Remove", + id: id + }); + delete this._items[id]; + } + }, + + menu: { + _callbacks: [], + _menuId: 1, + toolsMenuID: -1, + add: function() { + let options; + if (arguments.length == 1) { + options = arguments[0]; + } else if (arguments.length == 3) { + options = { + name: arguments[0], + icon: arguments[1], + callback: arguments[2] + }; + } else { + throw "Incorrect number of parameters"; + } + + options.type = "Menu:Add"; + options.id = this._menuId; + + sendMessageToJava(options); + this._callbacks[this._menuId] = options.callback; + this._menuId++; + return this._menuId - 1; + }, + + remove: function(aId) { + sendMessageToJava({ type: "Menu:Remove", id: aId }); + }, + + update: function(aId, aOptions) { + if (!aOptions) + return; + + sendMessageToJava({ + 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. + */ + show: function(aMessage, aValue, aButtons, aTabID, aOptions) { + if (aButtons == null) { + aButtons = []; + } + + 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 || {} + }; + sendMessageToJava(json); + }, + + hide: function(aValue, aTabID) { + sendMessageToJava({ + 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 == "PageActions:Clicked") { + if (this.pageactions._items[aData].clickCallback) + this.pageactions._items[aData].clickCallback(); + } else if (aTopic == "PageActions:LongClicked") { + if (this.pageactions._items[aData].longClickCallback) + this.pageactions._items[aData].longClickCallback(); + } else if (aTopic == "Toast:Click") { + if (this.toast._callbacks[aData]) { + this.toast._callbacks[aData](); + delete this.toast._callbacks[aData]; + } + } else if (aTopic == "Toast:Hidden") { + if (this.toast._callbacks[aData]) + delete this.toast._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() { + Services.obs.addObserver(this, "Gesture:LongPress", false); + }, + + uninit: function() { + Services.obs.removeObserver(this, "Gesture:LongPress"); + }, + + 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]; + }, + + SelectorContext: function(aSelector) { + return { + matches: function(aElt) { + if (aElt.mozMatchesSelector) + return aElt.mozMatchesSelector(aSelector); + return false; + } + }; + }, + + linkOpenableNonPrivateContext: { + matches: function linkOpenableNonPrivateContextMatches(aElement) { + let doc = aElement.ownerDocument; + if (!doc || PrivateBrowsingUtils.isWindowPrivate(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) { + return (aElement instanceof Ci.nsIImageLoadingContent && aElement.currentURI); + } + }, + + imageSaveableContext: { + matches: function imageSaveableContextMatches(aElement) { + 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; + } + }, + + mediaSaveableContext: { + matches: function mediaSaveableContextMatches(aElement) { + return (aElement instanceof HTMLVideoElement || + aElement instanceof HTMLAudioElement); + } + }, + + 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; + } + }; + }, + + /* 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 node + * Parameters: + * menu - The 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 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 + */ + _sendToContent: function(x, y) { + let target = BrowserEventHandler._highlightElement || ElementTouchHelper.elementFromPoint(x, y); + if (!target) + target = ElementTouchHelper.anyElementFromPoint(x, y); + + if (!target) + return; + + this._target = target; + + Services.obs.notifyObservers(null, "before-build-contextmenu", ""); + this._buildMenu(x, y); + + // only send the contextmenu event to content if we are planning to show a context menu (i.e. not on every long tap) + if (this.shouldShow()) { + let event = target.ownerDocument.createEvent("MouseEvent"); + event.initMouseEvent("contextmenu", true, true, target.defaultView, + 0, x, y, x, y, false, false, false, false, + 0, null); + target.ownerDocument.defaultView.addEventListener("contextmenu", this, false); + target.dispatchEvent(event); + } else { + this.menus = null; + Services.obs.notifyObservers({target: target, x: x, y: y}, "context-menu-not-shown", ""); + + if (SelectionHandler.canSelect(target)) { + if (!SelectionHandler.startSelection(target, { + mode: SelectionHandler.SELECT_AT_POINT, + x: x, + y: y + })) { + SelectionHandler.attachCaret(target); + } + } + } + }, + + // 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) { + return node.currentURI.spec; + } else if (node instanceof Ci.nsIDOMHTMLMediaElement) { + return (node.currentSrc || node.src); + } + + 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; + } + }, + + // Actually shows the native context menu by passing a list of context menu items to + // show to the Java. + _show: function(aEvent) { + let popupNode = this._target; + this._target = null; + if (aEvent.defaultPrevented || !popupNode) { + return; + } + this._innerShow(popupNode, aEvent.clientX, aEvent.clientY); + }, + + // 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; + } + }, + + // Called when the contextmenu is done propagating to content. If the event wasn't cancelled, will show a contextmenu. + handleEvent: function(aEvent) { + BrowserEventHandler._cancelTapHighlight(); + aEvent.target.ownerDocument.defaultView.removeEventListener("contextmenu", this, false); + this._show(aEvent); + }, + + // Called when a long press is observed in the native Java frontend. Will start the process of generating/showing a contextmenu. + observe: function(aSubject, aTopic, aData) { + let data = JSON.parse(aData); + // content gets first crack at cancelling context menus + this._sendToContent(data.x, data.y); + }, + + // 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; + }, + + _disableInGuest: function _disableInGuest(selector) { + return { + matches: function _disableInGuestMatches(aElement, aX, aY) { + if (BrowserApp.isGuest) + return false; + return selector.matches(aElement, aX, aY); + } + }; + }, + + _getLinkURL: function ch_getLinkURL(aLink) { + let href = aLink.href; + if (href) + return href; + + 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); + }, + + _shareStringWithDefault: function(aSharedString, aTitle) { + let sharing = Cc["@mozilla.org/uriloader/external-sharing-app-service;1"].getService(Ci.nsIExternalSharingAppService); + sharing.shareWithDefault(aSharedString, "text/plain", aTitle); + }, + + _stripScheme: function(aString) { + let index = aString.indexOf(":"); + return aString.slice(index + 1); + } + } +}; + +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); + }, + + uninit: function() { + BrowserApp.deck.addEventListener("InstallBrowserTheme", this, false, true); + BrowserApp.deck.addEventListener("PreviewBrowserTheme", this, false, true); + BrowserApp.deck.addEventListener("ResetBrowserThemePreview", this, false, true); + }, + + 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; + }, + + _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); + } + }]; + + 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) { + let pm = Services.perms; + + let uri = node.ownerDocument.documentURIObject; + 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, + + 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; [a-zA-Z]+/, "X11; Linux x86_64") + .replace(/Gecko\/[0-9\.]+/, "Gecko/20100101"); + }, + + uninit: function ua_uninit() { + Services.obs.removeObserver(this, "DesktopMode:Change"); + }, + + onRequest: function(channel, defaultUA) { + let channelWindow = this._getWindowForRequest(channel); + let tab = BrowserApp.getTabForWindow(channelWindow); + if (tab == null) + return null; + + return this.getUserAgentForTab(tab); + }, + + 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 != null) + tab.reloadWithMode(args.desktopMode); + } + } +}; + + +function nsBrowserAccess() { +} + +nsBrowserAccess.prototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIBrowserDOMWindow]), + + _getBrowser: function _getBrowser(aURI, aOpener, aWhere, aContext) { + let isExternal = (aContext == 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) { + switch (aContext) { + case Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL: + aWhere = Services.prefs.getIntPref("browser.link.open_external"); + break; + default: // OPEN_NEW or an illegal value + 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; + } + } + } + + 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.isWindowPrivate(parent.browser.contentWindow); + } + } + + // 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, + 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, aContext) { + let browser = this._getBrowser(aURI, aOpener, aWhere, aContext); + return browser ? browser.contentWindow : null; + }, + + openURIInFrame: function browser_openURIInFrame(aURI, aOpener, aWhere, aContext) { + let browser = this._getBrowser(aURI, aOpener, aWhere, aContext); + return browser ? browser.QueryInterface(Ci.nsIFrameLoaderOwner) : null; + }, + + isTabContentWindow: function(aWindow) { + return BrowserApp.getBrowserForWindow(aWindow) != null; + }, + + get contentWindow() { + return BrowserApp.selectedBrowser.contentWindow; + } +}; + + +// track the last known screen size so that new tabs +// get created with the right size rather than being 1x1 +let gScreenWidth = 1; +let gScreenHeight = 1; +let gReflowPending = null; + +// The margins that should be applied to the viewport for fixed position +// children. This is used to avoid browser chrome permanently obscuring +// fixed position content, and also to make sure window-sized pages take +// into account said browser chrome. +let gViewportMargins = { top: 0, right: 0, bottom: 0, left: 0}; + +function Tab(aURL, aParams) { + this.browser = null; + this.id = 0; + this.lastTouchedAt = Date.now(); + this._zoom = 1.0; + this._drawZoom = 1.0; + this._restoreZoom = false; + this._fixedMarginLeft = 0; + this._fixedMarginTop = 0; + this._fixedMarginRight = 0; + this._fixedMarginBottom = 0; + this._readerEnabled = false; + this._readerActive = false; + this.userScrollPos = { x: 0, y: 0 }; + this.viewportExcludesHorizontalMargins = true; + this.viewportExcludesVerticalMargins = true; + this.viewportMeasureCallback = null; + this.lastPageSizeAfterViewportRemeasure = { width: 0, height: 0 }; + this.contentDocumentIsDisplayed = true; + this.pluginDoorhangerTimeout = null; + this.shouldShowPluginDoorhanger = true; + this.clickToPlayPluginsActivated = false; + this.desktopMode = false; + this.originalURI = null; + this.savedArticle = null; + this.hasTouchListener = false; + this.browserWidth = 0; + this.browserHeight = 0; + + this.create(aURL, aParams); +} + +Tab.prototype = { + create: function(aURL, aParams) { + if (this.browser) + return; + + aParams = aParams || {}; + + this.browser = document.createElement("browser"); + this.browser.setAttribute("type", "content-targetable"); + this.setBrowserSize(kDefaultCSSViewportWidth, kDefaultCSSViewportHeight); + + // 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; + + 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.setIsApp(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) { + this.browser.docShell.QueryInterface(Ci.nsILoadContext).usePrivateBrowsing = true; + } + + this.browser.stop(); + + let frameLoader = this.browser.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader; + frameLoader.renderMode = Ci.nsIFrameLoader.RENDER_MODE_ASYNC_SCROLL; + + // 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 jni = new JNI(); + let cls = jni.findClass("org/mozilla/gecko/Tabs"); + let method = jni.getStaticMethodID(cls, "getNextTabId", "()I"); + this.id = jni.callStaticIntMethod(cls, method); + jni.close(); + } + + this.desktopMode = ("desktopMode" in aParams) ? aParams.desktopMode : false; + + let message = { + type: "Tab:Added", + tabID: this.id, + uri: uri, + parentId: ("parentId" in aParams) ? aParams.parentId : -1, + external: ("external" in aParams) ? aParams.external : false, + selected: ("selected" in aParams) ? aParams.selected : true, + title: title, + delayLoad: aParams.delayLoad || false, + desktopMode: this.desktopMode, + isPrivate: isPrivate, + stub: stub + }; + sendMessageToJava(message); + + this.overscrollController = new OverscrollController(this); + } + + this.browser.contentWindow.controllers.insertControllerAt(0, this.overscrollController); + + let flags = Ci.nsIWebProgress.NOTIFY_STATE_ALL | + Ci.nsIWebProgress.NOTIFY_LOCATION | + Ci.nsIWebProgress.NOTIFY_SECURITY; + this.browser.addProgressListener(this, flags); + this.browser.sessionHistory.addSHistoryListener(this); + + this.browser.addEventListener("DOMContentLoaded", this, true); + this.browser.addEventListener("DOMFormHasPassword", this, true); + this.browser.addEventListener("DOMLinkAdded", this, true); + this.browser.addEventListener("DOMTitleChanged", 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("scroll", this, true); + this.browser.addEventListener("MozScrolledAreaChanged", this, true); + this.browser.addEventListener("pageshow", this, true); + this.browser.addEventListener("MozApplicationManifest", 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, "after-viewport-change", false); + Services.prefs.addObserver("browser.ui.zoom.force-user-scalable", this, false); + + if (aParams.delayLoad) { + // If this is a zombie tab, attach restore data so the tab will be + // restored when selected + this.browser.__SS_data = { + entries: [{ + url: aURL, + title: title + }], + index: 1 + }; + 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.userSearch = "userSearch" in aParams ? aParams.userSearch : ""; + + try { + this.browser.loadURIWithFlags(aURL, flags, referrerURI, charset, postData); + } catch(e) { + let message = { + type: "Content:LoadError", + tabID: this.id + }; + sendMessageToJava(message); + dump("Handled load error: " + e); + } + } + }, + + /** + * Retrieves the font size in twips for a given element. + */ + getInflatedFontSizeFor: function(aElement) { + // GetComputedStyle should always give us CSS pixels for a font size. + let fontSizeStr = this.window.getComputedStyle(aElement)['fontSize']; + let fontSize = fontSizeStr.slice(0, -2); + return aElement.fontSizeInflation * fontSize; + }, + + /** + * This returns the zoom necessary to match the font size of an element to + * the minimum font size specified by the browser.zoom.reflowOnZoom.minFontSizeTwips + * preference. + */ + getZoomToMinFontSize: function(aElement) { + // We only use the font.size.inflation.minTwips preference because this is + // the only one that is controlled by the user-interface in the 'Settings' + // menu. Thus, if font.size.inflation.emPerLine is changed, this does not + // effect reflow-on-zoom. + let minFontSize = convertFromTwipsToPx(Services.prefs.getIntPref("font.size.inflation.minTwips")); + return minFontSize / this.getInflatedFontSizeFor(aElement); + }, + + clearReflowOnZoomPendingActions: function() { + // Reflow was completed, so now re-enable painting. + let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + let docShell = webNav.QueryInterface(Ci.nsIDocShell); + let docViewer = docShell.contentViewer.QueryInterface(Ci.nsIMarkupDocumentViewer); + docViewer.resumePainting(); + + BrowserApp.selectedTab._mReflozPositioned = false; + }, + + /** + * Reflow on zoom consists of a few different sub-operations: + * + * 1. When a double-tap event is seen, we verify that the correct preferences + * are enabled and perform the pre-position handling calculation. We also + * signal that reflow-on-zoom should be performed at this time, and pause + * painting. + * 2. During the next call to setViewport(), which is in the Tab prototype, + * we detect that a call to changeMaxLineBoxWidth should be performed. If + * we're zooming out, then the max line box width should be reset at this + * time. Otherwise, we call performReflowOnZoom. + * 2a. PerformReflowOnZoom() and resetMaxLineBoxWidth() schedule a call to + * doChangeMaxLineBoxWidth, based on a timeout specified in preferences. + * 3. doChangeMaxLineBoxWidth changes the line box width (which also + * schedules a reflow event), and then calls ZoomHelper.zoomInAndSnapToRange. + * 4. ZoomHelper.zoomInAndSnapToRange performs the positioning of reflow-on-zoom + * and then re-enables painting. + * + * Some of the events happen synchronously, while others happen asynchronously. + * The following is a rough sketch of the progression of events: + * + * double tap event seen -> onDoubleTap() -> ... asynchronous ... + * -> setViewport() -> performReflowOnZoom() -> ... asynchronous ... + * -> doChangeMaxLineBoxWidth() -> ZoomHelper.zoomInAndSnapToRange() + * -> ... asynchronous ... -> setViewport() -> Observe('after-viewport-change') + * -> resumePainting() + */ + performReflowOnZoom: function(aViewport) { + let zoom = this._drawZoom ? this._drawZoom : aViewport.zoom; + + let viewportWidth = gScreenWidth / zoom; + let reflozTimeout = Services.prefs.getIntPref("browser.zoom.reflowZoom.reflowTimeout"); + + if (gReflowPending) { + clearTimeout(gReflowPending); + } + + // We add in a bit of fudge just so that the end characters + // don't accidentally get clipped. 15px is an arbitrary choice. + gReflowPending = setTimeout(doChangeMaxLineBoxWidth, + reflozTimeout, + viewportWidth - 15); + }, + + /** + * Reloads the tab with the desktop mode setting. + */ + reloadWithMode: function (aDesktopMode) { + // Set desktop mode for tab and send change to Java + if (this.desktopMode != aDesktopMode) { + this.desktopMode = aDesktopMode; + sendMessageToJava({ + 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; + 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.contentWindow.controllers.removeController(this.overscrollController); + + this.browser.removeProgressListener(this); + this.browser.sessionHistory.removeSHistoryListener(this); + + this.browser.removeEventListener("DOMContentLoaded", this, true); + this.browser.removeEventListener("DOMFormHasPassword", this, true); + this.browser.removeEventListener("DOMLinkAdded", this, true); + this.browser.removeEventListener("DOMTitleChanged", 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("scroll", this, true); + this.browser.removeEventListener("MozScrolledAreaChanged", this, true); + this.browser.removeEventListener("pageshow", this, true); + this.browser.removeEventListener("MozApplicationManifest", 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, "after-viewport-change"); + Services.prefs.removeObserver("browser.ui.zoom.force-user-scalable", this); + + // 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.savedArticle = 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); + } else { + this.browser.setAttribute("type", "content-targetable"); + this.browser.docShellIsActive = false; + } + }, + + getActive: function getActive() { + return this.browser.docShellIsActive; + }, + + setDisplayPort: function(aDisplayPort) { + let zoom = this._zoom; + let resolution = aDisplayPort.resolution; + if (zoom <= 0 || resolution <= 0) + return; + + // "zoom" is the user-visible zoom of the "this" tab + // "resolution" is the zoom at which we wish gecko to render "this" tab at + // these two may be different if we are, for example, trying to render a + // large area of the page at low resolution because the user is panning real + // fast. + // The gecko scroll position is in CSS pixels. The display port rect + // values (aDisplayPort), however, are in CSS pixels multiplied by the desired + // rendering resolution. Therefore care must be taken when doing math with + // these sets of values, to ensure that they are normalized to the same coordinate + // space first. + + let element = this.browser.contentDocument.documentElement; + if (!element) + return; + + // we should never be drawing background tabs at resolutions other than the user- + // visible zoom. for foreground tabs, however, if we are drawing at some other + // resolution, we need to set the resolution as specified. + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + if (BrowserApp.selectedTab == this) { + if (resolution != this._drawZoom) { + this._drawZoom = resolution; + cwu.setResolution(resolution / window.devicePixelRatio, resolution / window.devicePixelRatio); + } + } else if (!fuzzyEquals(resolution, zoom)) { + dump("Warning: setDisplayPort resolution did not match zoom for background tab! (" + resolution + " != " + zoom + ")"); + } + + // Finally, we set the display port, taking care to convert everything into the CSS-pixel + // coordinate space, because that is what the function accepts. Also we have to fudge the + // displayport somewhat to make sure it gets through all the conversions gecko will do on it + // without deforming too much. See https://bugzilla.mozilla.org/show_bug.cgi?id=737510#c10 + // for details on what these operations are. + let geckoScrollX = this.browser.contentWindow.scrollX; + let geckoScrollY = this.browser.contentWindow.scrollY; + aDisplayPort = this._dirtiestHackEverToWorkAroundGeckoRounding(aDisplayPort, geckoScrollX, geckoScrollY); + + let displayPort = { + x: (aDisplayPort.left / resolution) - geckoScrollX, + y: (aDisplayPort.top / resolution) - geckoScrollY, + width: (aDisplayPort.right - aDisplayPort.left) / resolution, + height: (aDisplayPort.bottom - aDisplayPort.top) / resolution + }; + + if (this._oldDisplayPort == null || + !fuzzyEquals(displayPort.x, this._oldDisplayPort.x) || + !fuzzyEquals(displayPort.y, this._oldDisplayPort.y) || + !fuzzyEquals(displayPort.width, this._oldDisplayPort.width) || + !fuzzyEquals(displayPort.height, this._oldDisplayPort.height)) { + if (BrowserApp.gUseLowPrecision) { + // Set the display-port to be 4x the size of the critical display-port, + // on each dimension, giving us a 0.25x lower precision buffer around the + // critical display-port. Spare area is *not* redistributed to the other + // axis, as display-list building and invalidation cost scales with the + // size of the display-port. + let pageRect = cwu.getRootBounds(); + let pageXMost = pageRect.right - geckoScrollX; + let pageYMost = pageRect.bottom - geckoScrollY; + + let dpW = Math.min(pageRect.right - pageRect.left, displayPort.width * 4); + let dpH = Math.min(pageRect.bottom - pageRect.top, displayPort.height * 4); + + let dpX = Math.min(Math.max(displayPort.x - displayPort.width * 1.5, + pageRect.left - geckoScrollX), pageXMost - dpW); + let dpY = Math.min(Math.max(displayPort.y - displayPort.height * 1.5, + pageRect.top - geckoScrollY), pageYMost - dpH); + cwu.setDisplayPortForElement(dpX, dpY, dpW, dpH, element, 0); + cwu.setCriticalDisplayPortForElement(displayPort.x, displayPort.y, + displayPort.width, displayPort.height, + element); + } else { + cwu.setDisplayPortForElement(displayPort.x, displayPort.y, + displayPort.width, displayPort.height, + element, 0); + } + } + + this._oldDisplayPort = displayPort; + }, + + /* + * Yes, this is ugly. But it's currently the safest way to account for the rounding errors that occur + * when we pump the displayport coordinates through gecko and they pop out in the compositor. + * + * In general, the values are converted from page-relative device pixels to viewport-relative app units, + * and then back to page-relative device pixels (now as ints). The first half of this is only slightly + * lossy, but it's enough to throw off the numbers a little. Because of this, when gecko calls + * ScaleToOutsidePixels to generate the final rect, the rect may get expanded more than it should, + * ending up a pixel larger than it started off. This is undesirable in general, but specifically + * bad for tiling, because it means we means we end up painting one line of pixels from a tile, + * causing an otherwise unnecessary upload of the whole tile. + * + * In order to counteract the rounding error, this code simulates the conversions that will happen + * to the display port, and calculates whether or not that final ScaleToOutsidePixels is actually + * expanding the rect more than it should. If so, it determines how much rounding error was introduced + * up until that point, and adjusts the original values to compensate for that rounding error. + */ + _dirtiestHackEverToWorkAroundGeckoRounding: function(aDisplayPort, aGeckoScrollX, aGeckoScrollY) { + const APP_UNITS_PER_CSS_PIXEL = 60.0; + const EXTRA_FUDGE = 0.04; + + let resolution = aDisplayPort.resolution; + + // Some helper functions that simulate conversion processes in gecko + + function cssPixelsToAppUnits(aVal) { + return Math.floor((aVal * APP_UNITS_PER_CSS_PIXEL) + 0.5); + } + + function appUnitsToDevicePixels(aVal) { + return aVal / APP_UNITS_PER_CSS_PIXEL * resolution; + } + + function devicePixelsToAppUnits(aVal) { + return cssPixelsToAppUnits(aVal / resolution); + } + + // Stash our original (desired) displayport width and height away, we need it + // later and we might modify the displayport in between. + let originalWidth = aDisplayPort.right - aDisplayPort.left; + let originalHeight = aDisplayPort.bottom - aDisplayPort.top; + + // This is the first conversion the displayport goes through, going from page-relative + // device pixels to viewport-relative app units. + let appUnitDisplayPort = { + x: cssPixelsToAppUnits((aDisplayPort.left / resolution) - aGeckoScrollX), + y: cssPixelsToAppUnits((aDisplayPort.top / resolution) - aGeckoScrollY), + w: cssPixelsToAppUnits((aDisplayPort.right - aDisplayPort.left) / resolution), + h: cssPixelsToAppUnits((aDisplayPort.bottom - aDisplayPort.top) / resolution) + }; + + // This is the translation gecko applies when converting back from viewport-relative + // device pixels to page-relative device pixels. + let geckoTransformX = -Math.floor((-aGeckoScrollX * resolution) + 0.5); + let geckoTransformY = -Math.floor((-aGeckoScrollY * resolution) + 0.5); + + // The final "left" value as calculated in gecko is: + // left = geckoTransformX + Math.floor(appUnitsToDevicePixels(appUnitDisplayPort.x)) + // In a perfect world, this value would be identical to aDisplayPort.left, which is what + // we started with. However, this may not be the case if the value being floored has accumulated + // enough error to drop below what it should be. + // For example, assume geckoTransformX is 0, and aDisplayPort.left is 4, but + // appUnitsToDevicePixels(appUnitsToDevicePixels.x) comes out as 3.9 because of rounding error. + // That's bad, because the -0.1 error has caused it to floor to 3 instead of 4. (If it had errored + // the other way and come out as 4.1, there's no problem). In this example, we need to increase the + // "left" value by some amount so that the 3.9 actually comes out as >= 4, and it gets floored into + // the expected value of 4. The delta values calculated below calculate that error amount (e.g. -0.1). + let errorLeft = (geckoTransformX + appUnitsToDevicePixels(appUnitDisplayPort.x)) - aDisplayPort.left; + let errorTop = (geckoTransformY + appUnitsToDevicePixels(appUnitDisplayPort.y)) - aDisplayPort.top; + + // If the error was negative, that means it will floor incorrectly, so we need to bump up the + // original aDisplayPort.left and/or aDisplayPort.top values. The amount we bump it up by is + // the error amount (increased by a small fudge factor to ensure it's sufficient), converted + // backwards through the conversion process. + if (errorLeft < 0) { + aDisplayPort.left += appUnitsToDevicePixels(devicePixelsToAppUnits(EXTRA_FUDGE - errorLeft)); + // After we modify the left value, we need to re-simulate some values to take that into account + appUnitDisplayPort.x = cssPixelsToAppUnits((aDisplayPort.left / resolution) - aGeckoScrollX); + appUnitDisplayPort.w = cssPixelsToAppUnits((aDisplayPort.right - aDisplayPort.left) / resolution); + } + if (errorTop < 0) { + aDisplayPort.top += appUnitsToDevicePixels(devicePixelsToAppUnits(EXTRA_FUDGE - errorTop)); + // After we modify the top value, we need to re-simulate some values to take that into account + appUnitDisplayPort.y = cssPixelsToAppUnits((aDisplayPort.top / resolution) - aGeckoScrollY); + appUnitDisplayPort.h = cssPixelsToAppUnits((aDisplayPort.bottom - aDisplayPort.top) / resolution); + } + + // At this point, the aDisplayPort.left and aDisplayPort.top values have been corrected to account + // for the error in conversion such that they end up where we want them. Now we need to also do the + // same for the right/bottom values so that the width/height end up where we want them. + + // This is the final conversion that the displayport goes through before gecko spits it back to + // us. Note that the width/height calculates are of the form "ceil(transform(right)) - floor(transform(left))" + let scaledOutDevicePixels = { + x: Math.floor(appUnitsToDevicePixels(appUnitDisplayPort.x)), + y: Math.floor(appUnitsToDevicePixels(appUnitDisplayPort.y)), + w: Math.ceil(appUnitsToDevicePixels(appUnitDisplayPort.x + appUnitDisplayPort.w)) - Math.floor(appUnitsToDevicePixels(appUnitDisplayPort.x)), + h: Math.ceil(appUnitsToDevicePixels(appUnitDisplayPort.y + appUnitDisplayPort.h)) - Math.floor(appUnitsToDevicePixels(appUnitDisplayPort.y)) + }; + + // The final "width" value as calculated in gecko is scaledOutDevicePixels.w. + // In a perfect world, this would equal originalWidth. However, things are not perfect, and as before, + // we need to calculate how much rounding error has been introduced. In this case the rounding error is causing + // the Math.ceil call above to ceiling to the wrong final value. For example, 4 gets converted 4.1 and gets + // ceiling'd to 5; in this case the error is 0.1. + let errorRight = (appUnitsToDevicePixels(appUnitDisplayPort.x + appUnitDisplayPort.w) - scaledOutDevicePixels.x) - originalWidth; + let errorBottom = (appUnitsToDevicePixels(appUnitDisplayPort.y + appUnitDisplayPort.h) - scaledOutDevicePixels.y) - originalHeight; + + // If the error was positive, that means it will ceiling incorrectly, so we need to bump down the + // original aDisplayPort.right and/or aDisplayPort.bottom. Again, we back-convert the error amount + // with a small fudge factor to figure out how much to adjust the original values. + if (errorRight > 0) aDisplayPort.right -= appUnitsToDevicePixels(devicePixelsToAppUnits(errorRight + EXTRA_FUDGE)); + if (errorBottom > 0) aDisplayPort.bottom -= appUnitsToDevicePixels(devicePixelsToAppUnits(errorBottom + EXTRA_FUDGE)); + + // Et voila! + return aDisplayPort; + }, + + setScrollClampingSize: function(zoom) { + let viewportWidth = gScreenWidth / zoom; + let viewportHeight = gScreenHeight / zoom; + let screenWidth = gScreenWidth; + let screenHeight = gScreenHeight; + + // Shrink the viewport appropriately if the margins are excluded + if (this.viewportExcludesVerticalMargins) { + screenHeight = gScreenHeight - gViewportMargins.top - gViewportMargins.bottom; + viewportHeight = screenHeight / zoom; + } + if (this.viewportExcludesHorizontalMargins) { + screenWidth = gScreenWidth - gViewportMargins.left - gViewportMargins.right; + viewportWidth = screenWidth / zoom; + } + + // Make sure the aspect ratio of the screen is maintained when setting + // the clamping scroll-port size. + let factor = Math.min(viewportWidth / screenWidth, + viewportHeight / screenHeight); + let scrollPortWidth = screenWidth * factor; + let scrollPortHeight = screenHeight * factor; + + let win = this.browser.contentWindow; + win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils). + setScrollPositionClampingScrollPortSize(scrollPortWidth, scrollPortHeight); + }, + + setViewport: function(aViewport) { + // Transform coordinates based on zoom + let x = aViewport.x / aViewport.zoom; + let y = aViewport.y / aViewport.zoom; + + this.setScrollClampingSize(aViewport.zoom); + + // Adjust the max line box width to be no more than the viewport width, but + // only if the reflow-on-zoom preference is enabled. + let isZooming = !fuzzyEquals(aViewport.zoom, this._zoom); + + let docViewer = null; + + if (isZooming && + BrowserEventHandler.mReflozPref && + BrowserApp.selectedTab._mReflozPoint && + BrowserApp.selectedTab.probablyNeedRefloz) { + let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + let docShell = webNav.QueryInterface(Ci.nsIDocShell); + docViewer = docShell.contentViewer.QueryInterface(Ci.nsIMarkupDocumentViewer); + docViewer.pausePainting(); + + BrowserApp.selectedTab.performReflowOnZoom(aViewport); + BrowserApp.selectedTab.probablyNeedRefloz = false; + } + + let win = this.browser.contentWindow; + win.scrollTo(x, y); + this.saveSessionZoom(aViewport.zoom); + + this.userScrollPos.x = win.scrollX; + this.userScrollPos.y = win.scrollY; + this.setResolution(aViewport.zoom, false); + + if (aViewport.displayPort) + this.setDisplayPort(aViewport.displayPort); + + // Store fixed margins for later retrieval in getViewport. + this._fixedMarginLeft = aViewport.fixedMarginLeft; + this._fixedMarginTop = aViewport.fixedMarginTop; + this._fixedMarginRight = aViewport.fixedMarginRight; + this._fixedMarginBottom = aViewport.fixedMarginBottom; + + let dwi = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + dwi.setContentDocumentFixedPositionMargins( + aViewport.fixedMarginTop / aViewport.zoom, + aViewport.fixedMarginRight / aViewport.zoom, + aViewport.fixedMarginBottom / aViewport.zoom, + aViewport.fixedMarginLeft / aViewport.zoom); + + Services.obs.notifyObservers(null, "after-viewport-change", ""); + if (docViewer) { + docViewer.resumePainting(); + } + }, + + setResolution: function(aZoom, aForce) { + // Set zoom level + if (aForce || !fuzzyEquals(aZoom, this._zoom)) { + this._zoom = aZoom; + if (BrowserApp.selectedTab == this) { + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + this._drawZoom = aZoom; + cwu.setResolution(aZoom / window.devicePixelRatio, aZoom / window.devicePixelRatio); + } + } + }, + + getPageSize: function(aDocument, aDefaultWidth, aDefaultHeight) { + let body = aDocument.body || { scrollWidth: aDefaultWidth, scrollHeight: aDefaultHeight }; + let html = aDocument.documentElement || { scrollWidth: aDefaultWidth, scrollHeight: aDefaultHeight }; + return [Math.max(body.scrollWidth, html.scrollWidth), + Math.max(body.scrollHeight, html.scrollHeight)]; + }, + + getViewport: function() { + let screenW = gScreenWidth - gViewportMargins.left - gViewportMargins.right; + let screenH = gScreenHeight - gViewportMargins.top - gViewportMargins.bottom; + let zoom = this.restoredSessionZoom() || this._zoom; + + let viewport = { + width: screenW, + height: screenH, + cssWidth: screenW / zoom, + cssHeight: screenH / zoom, + pageLeft: 0, + pageTop: 0, + pageRight: screenW, + pageBottom: screenH, + // We make up matching css page dimensions + cssPageLeft: 0, + cssPageTop: 0, + cssPageRight: screenW / zoom, + cssPageBottom: screenH / zoom, + fixedMarginLeft: this._fixedMarginLeft, + fixedMarginTop: this._fixedMarginTop, + fixedMarginRight: this._fixedMarginRight, + fixedMarginBottom: this._fixedMarginBottom, + zoom: zoom, + }; + + // Set the viewport offset to current scroll offset + viewport.cssX = this.browser.contentWindow.scrollX || 0; + viewport.cssY = this.browser.contentWindow.scrollY || 0; + + // Transform coordinates based on zoom + viewport.x = Math.round(viewport.cssX * viewport.zoom); + viewport.y = Math.round(viewport.cssY * viewport.zoom); + + let doc = this.browser.contentDocument; + if (doc != null) { + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + let cssPageRect = cwu.getRootBounds(); + + /* + * Avoid sending page sizes of less than screen size before we hit DOMContentLoaded, because + * this causes the page size to jump around wildly during page load. After the page is loaded, + * send updates regardless of page size; we'll zoom to fit the content as needed. + * + * In the check below, we floor the viewport size because there might be slight rounding errors + * introduced in the CSS page size due to the conversion to and from app units in Gecko. The + * error should be no more than one app unit so doing the floor is overkill, but safe in the + * sense that the extra page size updates that get sent as a result will be mostly harmless. + */ + let pageLargerThanScreen = (cssPageRect.width >= Math.floor(viewport.cssWidth)) + && (cssPageRect.height >= Math.floor(viewport.cssHeight)); + if (doc.readyState === 'complete' || pageLargerThanScreen) { + viewport.cssPageLeft = cssPageRect.left; + viewport.cssPageTop = cssPageRect.top; + viewport.cssPageRight = cssPageRect.right; + viewport.cssPageBottom = cssPageRect.bottom; + /* Transform the page width and height based on the zoom factor. */ + viewport.pageLeft = (viewport.cssPageLeft * viewport.zoom); + viewport.pageTop = (viewport.cssPageTop * viewport.zoom); + viewport.pageRight = (viewport.cssPageRight * viewport.zoom); + viewport.pageBottom = (viewport.cssPageBottom * viewport.zoom); + } + } + + return viewport; + }, + + sendViewportUpdate: function(aPageSizeUpdate) { + let viewport = this.getViewport(); + let displayPort = Services.androidBridge.getDisplayPort(aPageSizeUpdate, BrowserApp.isBrowserContentDocumentDisplayed(), this.id, viewport); + if (displayPort != null) + this.setDisplayPort(displayPort); + }, + + updateViewportForPageSize: function() { + let hasHorizontalMargins = gViewportMargins.left != 0 || gViewportMargins.right != 0; + let hasVerticalMargins = gViewportMargins.top != 0 || gViewportMargins.bottom != 0; + + if (!hasHorizontalMargins && !hasVerticalMargins) { + // If there are no margins, then we don't need to do any remeasuring + return; + } + + // If the page size has changed so that it might or might not fit on the + // screen with the margins included, run updateViewportSize to resize the + // browser accordingly. + // A page will receive the smaller viewport when its page size fits + // within the screen size, so remeasure when the page size remains within + // the threshold of screen + margins, in case it's sizing itself relative + // to the viewport. + let viewport = this.getViewport(); + let pageWidth = viewport.pageRight - viewport.pageLeft; + let pageHeight = viewport.pageBottom - viewport.pageTop; + let remeasureNeeded = false; + + if (hasHorizontalMargins) { + let viewportShouldExcludeHorizontalMargins = (pageWidth <= gScreenWidth - 0.5); + if (viewportShouldExcludeHorizontalMargins != this.viewportExcludesHorizontalMargins) { + remeasureNeeded = true; + } + } + if (hasVerticalMargins) { + let viewportShouldExcludeVerticalMargins = (pageHeight <= gScreenHeight - 0.5); + if (viewportShouldExcludeVerticalMargins != this.viewportExcludesVerticalMargins) { + remeasureNeeded = true; + } + } + + if (remeasureNeeded) { + if (!this.viewportMeasureCallback) { + this.viewportMeasureCallback = setTimeout(function() { + this.viewportMeasureCallback = null; + + // Re-fetch the viewport as it may have changed between setting the timeout + // and running this callback + let viewport = this.getViewport(); + let pageWidth = viewport.pageRight - viewport.pageLeft; + let pageHeight = viewport.pageBottom - viewport.pageTop; + + if (Math.abs(pageWidth - this.lastPageSizeAfterViewportRemeasure.width) >= 0.5 || + Math.abs(pageHeight - this.lastPageSizeAfterViewportRemeasure.height) >= 0.5) { + this.updateViewportSize(gScreenWidth); + } + }.bind(this), kViewportRemeasureThrottle); + } + } else if (this.viewportMeasureCallback) { + // If the page changed size twice since we last measured the viewport and + // the latest size change reveals we don't need to remeasure, cancel any + // pending remeasure. + clearTimeout(this.viewportMeasureCallback); + this.viewportMeasureCallback = null; + } + }, + + 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")) + errorType = "neterror"; + + sendMessageToJava({ + type: "DOMContentLoaded", + tabID: this.id, + bgColor: backgroundColor, + errorType: errorType + }); + + // 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: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 (docURI.startsWith("about:reader")) { + // During browser restart / recovery, duplicate "DOMContentLoaded" messages are received here + // For the visible tab ... where more than one tab is being reloaded, the inital "DOMContentLoaded" + // Message can be received before the document body is available ... so we avoid instantiating an + // AboutReader object, expecting that an eventual valid message will follow. + let contentDocument = this.browser.contentDocument; + if (contentDocument.body) { + new AboutReader(contentDocument, this.browser.contentWindow); + } + } + + break; + } + + case "DOMFormHasPassword": { + LoginManagerContent.onFormPassword(aEvent); + break; + } + + case "DOMLinkAdded": { + let target = aEvent.originalTarget; + if (!target.href || target.disabled) + return; + + // Ignore on frames and other documents + if (target.ownerDocument != this.browser.contentDocument) + return; + + // Sanitize the rel string + let list = []; + if (target.rel) { + list = target.rel.toLowerCase().split(/\s+/); + let hash = {}; + list.forEach(function(value) { hash[value] = true; }); + list = []; + for (let rel in hash) + list.push("[" + rel + "]"); + } + + if (list.indexOf("[icon]") != -1) { + // 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 (target.hasAttribute("sizes")) { + let sizes = target.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)); + }); + } + } + + let json = { + type: "Link:Favicon", + tabID: this.id, + href: resolveGeckoURI(target.href), + charset: target.ownerDocument.characterSet, + title: target.title, + rel: list.join(" "), + size: maxSize + }; + sendMessageToJava(json); + } else if (list.indexOf("[alternate]") != -1) { + let type = target.type.toLowerCase().replace(/^\s+|\s*(?:;.*)?$/g, ""); + let isFeed = (type == "application/rss+xml" || type == "application/atom+xml"); + + if (!isFeed) + return; + + try { + // urlSecurityCeck will throw if things are not OK + ContentAreaUtils.urlSecurityCheck(target.href, target.ownerDocument.nodePrincipal, Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL); + + if (!this.browser.feeds) + this.browser.feeds = []; + this.browser.feeds.push({ href: target.href, title: target.title, type: type }); + + let json = { + type: "Link:Feed", + tabID: this.id + }; + sendMessageToJava(json); + } catch (e) {} + } else if (list.indexOf("[search]" != -1)) { + let type = target.type && target.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 && target.title && /^(?:https?|ftp):/i.test(target.href)) { + 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 == target.title; + })) { + // This engine is already present, do nothing. + return; + } + + if (this.browser.engines) { + // This engine has already been handled, do nothing. + if (this.browser.engines.some(function(e) { + return e.url == target.href; + })) { + return; + } + } else { + this.browser.engines = []; + } + + // Get favicon. + let iconURL = target.ownerDocument.documentURIObject.prePath + "/favicon.ico"; + + let newEngine = { + title: target.title, + url: target.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; + + // Broadcast message that this tab contains search engines that should be visible. + let newEngineMessage = { + type: "Link:OpenSearch", + tabID: this.id, + visible: true + }; + + sendMessageToJava(newEngineMessage); + } + } + break; + } + + case "DOMTitleChanged": { + if (!aEvent.isTrusted) + return; + + // ignore on frames and other documents + if (aEvent.originalTarget != this.browser.contentDocument) + return; + + sendMessageToJava({ + type: "DOMTitleChanged", + tabID: this.id, + title: aEvent.target.title.substring(0, 255) + }); + break; + } + + case "DOMWindowClose": { + if (!aEvent.isTrusted) + return; + + // Find the relevant tab, and close it from Java + if (this.browser.contentWindow == aEvent.target) { + aEvent.preventDefault(); + + sendMessageToJava({ + 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 "scroll": { + let win = this.browser.contentWindow; + if (this.userScrollPos.x != win.scrollX || this.userScrollPos.y != win.scrollY) { + this.sendViewportUpdate(); + } + break; + } + + case "MozScrolledAreaChanged": { + // This event is only fired for root scroll frames, and only when the + // scrolled area has actually changed, so no need to check for that. + // Just make sure it's the event for the correct root scroll frame. + if (aEvent.originalTarget != this.browser.contentDocument) + return; + + this.sendViewportUpdate(true); + this.updateViewportForPageSize(); + 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": { + // only send pageshow for the top-level document + if (aEvent.originalTarget.defaultView != this.browser.contentWindow) + return; + + sendMessageToJava({ + type: "Content:PageShow", + tabID: this.id + }); + + 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); + } else { + ExternalApps.clearPageAction(); + } + } + + if (!Reader.isEnabledForParseOnLoad) + return; + + // Once document is fully loaded, parse it + Reader.parseDocumentFromTab(this.id, function (article) { + // Do nothing if there's no article or the page in this tab has + // changed + let tabURL = uri.specIgnoringRef; + if (article == null || (article.url != tabURL)) { + // Don't clear the article for about:reader pages since we want to + // use the article from the previous page + if (!tabURL.startsWith("about:reader")) { + this.savedArticle = null; + this.readerEnabled = false; + this.readerActive = false; + } else { + this.readerActive = true; + } + return; + } + + this.savedArticle = article; + + sendMessageToJava({ + type: "Content:ReaderEnabled", + tabID: this.id + }); + + if(this.readerActive) + this.readerActive = false; + + if(!this.readerEnabled) + this.readerEnabled = true; + }.bind(this)); + } + } + }, + + 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 ((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: uri, + state: aStateFlags, + restoring: restoring, + success: success + }; + sendMessageToJava(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) { } + + 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; + + // Reset state of click-to-play plugin notifications. + clearTimeout(this.pluginDoorhangerTimeout); + this.pluginDoorhangerTimeout = null; + this.shouldShowPluginDoorhanger = true; + this.clickToPlayPluginsActivated = false; + // Borrowed from desktop Firefox: http://mxr.mozilla.org/mozilla-central/source/browser/base/content/urlbarBindings.xml#174 + let documentURI = contentWin.document.documentURIObject.spec + let matchedURL = documentURI.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) {} + } + + // Update the page actions URI for helper apps. + if (BrowserApp.selectedTab == this) { + ExternalApps.updatePageActionUri(fixedURI); + } + + let message = { + type: "Content:LocationChange", + tabID: this.id, + uri: fixedURI.spec, + userSearch: this.userSearch || "", + baseDomain: baseDomain, + contentType: (contentType ? contentType : ""), + sameDocument: sameDocument + }; + + sendMessageToJava(message); + + // The search term is only valid for this location change event, so reset it here. + this.userSearch = ""; + + 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; + } else { + this.sendViewportUpdate(); + } + }, + + // 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 + }; + + sendMessageToJava(message); + }, + + onProgressChange: function(aWebProgress, aRequest, aCurSelfProgress, aMaxSelfProgress, aCurTotalProgress, aMaxTotalProgress) { + }, + + onStatusChange: function(aBrowser, aWebProgress, aRequest, aStatus, aMessage) { + }, + + _sendHistoryEvent: function(aMessage, aParams) { + let message = { + type: "SessionHistory:" + aMessage, + tabID: this.id, + }; + + // Restore zoom only when moving in session history, not for new page loads. + this._restoreZoom = aMessage != "New"; + + if (aParams) { + if ("url" in aParams) + message.url = aParams.url; + if ("index" in aParams) + message.index = aParams.index; + if ("numEntries" in aParams) + message.numEntries = aParams.numEntries; + } + + sendMessageToJava(message); + }, + + _getGeckoZoom: function() { + let res = {x: {}, y: {}}; + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + cwu.getResolution(res.x, res.y); + let zoom = res.x.value * window.devicePixelRatio; + return zoom; + }, + + saveSessionZoom: function(aZoom) { + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + cwu.setResolution(aZoom / window.devicePixelRatio, 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; + }, + + OnHistoryNewEntry: function(aUri) { + this._sendHistoryEvent("New", { url: aUri.spec }); + }, + + OnHistoryGoBack: function(aUri) { + this._sendHistoryEvent("Back"); + return true; + }, + + OnHistoryGoForward: function(aUri) { + this._sendHistoryEvent("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._sendHistoryEvent("Goto", { index: aIndex }); + return true; + }, + + OnHistoryPurge: function(aNumEntries) { + this._sendHistoryEvent("Purge", { numEntries: aNumEntries }); + return true; + }, + + OnHistoryReplaceEntry: function(aIndex) { + // we don't do anything with this, so don't propogate it + // for now anyway. + }, + + get metadata() { + return ViewportHandler.getMetadataForDocument(this.browser.contentDocument); + }, + + /** Update viewport when the metadata changes. */ + updateViewportMetadata: function updateViewportMetadata(aMetadata, aInitialLoad) { + if (Services.prefs.getBoolPref("browser.ui.zoom.force-user-scalable")) { + aMetadata.allowZoom = true; + aMetadata.allowDoubleTapZoom = true; + aMetadata.minZoom = aMetadata.maxZoom = NaN; + } + + let scaleRatio = window.devicePixelRatio; + + if (aMetadata.defaultZoom > 0) + aMetadata.defaultZoom *= scaleRatio; + if (aMetadata.minZoom > 0) + aMetadata.minZoom *= scaleRatio; + if (aMetadata.maxZoom > 0) + aMetadata.maxZoom *= scaleRatio; + + aMetadata.isRTL = this.browser.contentDocument.documentElement.dir == "rtl"; + + ViewportHandler.setMetadataForDocument(this.browser.contentDocument, aMetadata); + this.sendViewportMetadata(); + + this.updateViewportSize(gScreenWidth, aInitialLoad); + }, + + /** Update viewport when the metadata or the window size changes. */ + updateViewportSize: function updateViewportSize(aOldScreenWidth, aInitialLoad) { + // When this function gets called on window resize, we must execute + // this.sendViewportUpdate() so that refreshDisplayPort is called. + // Ensure that when making changes to this function that code path + // is not accidentally removed (the call to sendViewportUpdate() is + // at the very end). + + if (this.viewportMeasureCallback) { + clearTimeout(this.viewportMeasureCallback); + this.viewportMeasureCallback = null; + } + + let browser = this.browser; + if (!browser) + return; + + let screenW = gScreenWidth - gViewportMargins.left - gViewportMargins.right; + let screenH = gScreenHeight - gViewportMargins.top - gViewportMargins.bottom; + let viewportW, viewportH; + + let metadata = this.metadata; + if (metadata.autoSize) { + viewportW = screenW / window.devicePixelRatio; + viewportH = screenH / window.devicePixelRatio; + } else { + viewportW = metadata.width; + viewportH = metadata.height; + + // If (scale * width) < device-width, increase the width (bug 561413). + let maxInitialZoom = metadata.defaultZoom || metadata.maxZoom; + if (maxInitialZoom && viewportW) { + viewportW = Math.max(viewportW, screenW / maxInitialZoom); + } + + let validW = viewportW > 0; + let validH = viewportH > 0; + + if (!validW) + viewportW = validH ? (viewportH * (screenW / screenH)) : BrowserApp.defaultBrowserWidth; + if (!validH) + viewportH = viewportW * (screenH / screenW); + } + + // Make sure the viewport height is not shorter than the window when + // the page is zoomed out to show its full width. Note that before + // we set the viewport width, the "full width" of the page isn't properly + // defined, so that's why we have to call setBrowserSize twice - once + // to set the width, and the second time to figure out the height based + // on the layout at that width. + let oldBrowserWidth = this.browserWidth; + this.setBrowserSize(viewportW, viewportH); + + // This change to the zoom accounts for all types of changes I can conceive: + // 1. screen size changes, CSS viewport does not (pages with no meta viewport + // or a fixed size viewport) + // 2. screen size changes, CSS viewport also does (pages with a device-width + // viewport) + // 3. screen size remains constant, but CSS viewport changes (meta viewport + // tag is added or removed) + // 4. neither screen size nor CSS viewport changes + // + // In all of these cases, we maintain how much actual content is visible + // within the screen width. Note that "actual content" may be different + // with respect to CSS pixels because of the CSS viewport size changing. + let zoom = this.restoredSessionZoom() || metadata.defaultZoom; + if (!zoom || !aInitialLoad) { + let zoomScale = (screenW * oldBrowserWidth) / (aOldScreenWidth * viewportW); + zoom = this.clampZoom(this._zoom * zoomScale); + } + this.setResolution(zoom, false); + this.setScrollClampingSize(zoom); + + // if this page has not been painted yet, then this must be getting run + // because a meta-viewport element was added (via the DOMMetaAdded handler). + // in this case, we should not do anything that forces a reflow (see bug 759678) + // such as requesting the page size or sending a viewport update. this code + // will get run again in the before-first-paint handler and that point we + // will run though all of it. the reason we even bother executing up to this + // point on the DOMMetaAdded handler is so that scripts that use window.innerWidth + // before they are painted have a correct value (bug 771575). + if (!this.contentDocumentIsDisplayed) { + return; + } + + this.viewportExcludesHorizontalMargins = true; + this.viewportExcludesVerticalMargins = true; + let minScale = 1.0; + if (this.browser.contentDocument) { + // this may get run during a Viewport:Change message while the document + // has not yet loaded, so need to guard against a null document. + let [pageWidth, pageHeight] = this.getPageSize(this.browser.contentDocument, viewportW, viewportH); + + // In the situation the page size equals or exceeds the screen size, + // lengthen the viewport on the corresponding axis to include the margins. + // The '- 0.5' is to account for rounding errors. + if (pageWidth * this._zoom > gScreenWidth - 0.5) { + screenW = gScreenWidth; + this.viewportExcludesHorizontalMargins = false; + } + if (pageHeight * this._zoom > gScreenHeight - 0.5) { + screenH = gScreenHeight; + this.viewportExcludesVerticalMargins = false; + } + + minScale = screenW / pageWidth; + } + minScale = this.clampZoom(minScale); + viewportH = Math.max(viewportH, screenH / minScale); + + // In general we want to keep calls to setBrowserSize and setScrollClampingSize + // together because setBrowserSize could mark the viewport size as dirty, creating + // a pending resize event for content. If that resize gets dispatched (which happens + // on the next reflow) without setScrollClampingSize having being called, then + // content might be exposed to incorrect innerWidth/innerHeight values. + this.setBrowserSize(viewportW, viewportH); + this.setScrollClampingSize(zoom); + + // Avoid having the scroll position jump around after device rotation. + let win = this.browser.contentWindow; + this.userScrollPos.x = win.scrollX; + this.userScrollPos.y = win.scrollY; + + this.sendViewportUpdate(); + + if (metadata.allowZoom && !Services.prefs.getBoolPref("browser.ui.zoom.force-user-scalable")) { + // If the CSS viewport is narrower than the screen (i.e. width <= device-width) + // then we disable double-tap-to-zoom behaviour. + var oldAllowDoubleTapZoom = metadata.allowDoubleTapZoom; + var newAllowDoubleTapZoom = (!metadata.isSpecified) || (viewportW > screenW / window.devicePixelRatio); + if (oldAllowDoubleTapZoom !== newAllowDoubleTapZoom) { + metadata.allowDoubleTapZoom = newAllowDoubleTapZoom; + this.sendViewportMetadata(); + } + } + + // Store the page size that was used to calculate the viewport so that we + // can verify it's changed when we consider remeasuring in updateViewportForPageSize + let viewport = this.getViewport(); + this.lastPageSizeAfterViewportRemeasure = { + width: viewport.pageRight - viewport.pageLeft, + height: viewport.pageBottom - viewport.pageTop + }; + }, + + sendViewportMetadata: function sendViewportMetadata() { + let metadata = this.metadata; + sendMessageToJava({ + type: "Tab:ViewportMetadata", + allowZoom: metadata.allowZoom, + allowDoubleTapZoom: metadata.allowDoubleTapZoom, + defaultZoom: metadata.defaultZoom || window.devicePixelRatio, + minZoom: metadata.minZoom || 0, + maxZoom: metadata.maxZoom || 0, + isRTL: metadata.isRTL, + tabID: this.id + }); + }, + + setBrowserSize: function(aWidth, aHeight) { + if (fuzzyEquals(this.browserWidth, aWidth) && fuzzyEquals(this.browserHeight, aHeight)) { + return; + } + + this.browserWidth = aWidth; + this.browserHeight = aHeight; + + if (!this.browser.contentWindow) + return; + let cwu = this.browser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + cwu.setCSSViewport(aWidth, aHeight); + }, + + /** Takes a scale and restricts it based on this tab's zoom limits. */ + clampZoom: function clampZoom(aZoom) { + let zoom = ViewportHandler.clamp(aZoom, kViewportMinScale, kViewportMaxScale); + + let md = this.metadata; + if (!md.allowZoom) + return md.defaultZoom || zoom; + + if (md && md.minZoom) + zoom = Math.max(zoom, md.minZoom); + if (md && md.maxZoom) + zoom = Math.min(zoom, md.maxZoom); + return zoom; + }, + + 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; + + // reset CSS viewport and zoom to default on new page, and then calculate + // them properly using the actual metadata from the page. note that the + // updateMetadata call takes into account the existing CSS viewport size + // and zoom when calculating the new ones, so we need to reset these + // things here before calling updateMetadata. + this.setBrowserSize(kDefaultCSSViewportWidth, kDefaultCSSViewportHeight); + let zoom = this.restoredSessionZoom() || gScreenWidth / this.browserWidth; + this.setResolution(zoom, true); + ViewportHandler.updateMetadata(this, true); + + // Note that if we draw without a display-port, things can go wrong. By the + // time we execute this, it's almost certain a display-port has been set via + // the MozScrolledAreaChanged event. If that didn't happen, the updateMetadata + // call above does so at the end of the updateViewportSize function. As long + // as that is happening, we don't need to do it again here. + + if (!this.restoredSessionZoom() && contentDocument.mozSyntheticDocument) { + // for images, scale to fit width. this needs to happen *after* the call + // to updateMetadata above, because that call sets the CSS viewport which + // will affect the page size (i.e. contentDocument.body.scroll*) that we + // use in this calculation. also we call sendViewportUpdate after changing + // the resolution so that the display port gets recalculated appropriately. + let fitZoom = Math.min(gScreenWidth / contentDocument.body.scrollWidth, + gScreenHeight / contentDocument.body.scrollHeight); + this.setResolution(fitZoom, false); + this.sendViewportUpdate(); + } + } + + // If the reflow-text-on-page-load pref is enabled, and reflow-on-zoom + // is enabled, and our defaultZoom level is set, then we need to get + // the default zoom and reflow the text according to the defaultZoom + // level. + let rzEnabled = BrowserEventHandler.mReflozPref; + let rzPl = Services.prefs.getBoolPref("browser.zoom.reflowZoom.reflowTextOnPageLoad"); + + if (rzEnabled && rzPl) { + // Retrieve the viewport width and adjust the max line box width + // accordingly. + let vp = BrowserApp.selectedTab.getViewport(); + BrowserApp.selectedTab.performReflowOnZoom(vp); + } + break; + case "after-viewport-change": + if (BrowserApp.selectedTab._mReflozPositioned) { + BrowserApp.selectedTab.clearReflowOnZoomPendingActions(); + } + break; + case "nsPref:changed": + if (aData == "browser.ui.zoom.force-user-scalable") + ViewportHandler.updateMetadata(this, false); + break; + } + }, + + set readerEnabled(isReaderEnabled) { + this._readerEnabled = isReaderEnabled; + if (this.getActive()) + Reader.updatePageAction(this); + }, + + get readerEnabled() { + return this._readerEnabled; + }, + + set readerActive(isReaderActive) { + this._readerActive = isReaderActive; + if (this.getActive()) + Reader.updatePageAction(this); + }, + + get readerActive() { + return this._readerActive; + }, + + // 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() { + Services.obs.addObserver(this, "Gesture:SingleTap", false); + Services.obs.addObserver(this, "Gesture:CancelTouch", false); + Services.obs.addObserver(this, "Gesture:DoubleTap", false); + Services.obs.addObserver(this, "Gesture:Scroll", false); + Services.obs.addObserver(this, "dom-touch-listener-added", false); + + BrowserApp.deck.addEventListener("DOMUpdatePageReport", PopupBlockerObserver.onUpdatePageReport, false); + BrowserApp.deck.addEventListener("touchstart", this, true); + BrowserApp.deck.addEventListener("click", InputWidgetHelper, true); + BrowserApp.deck.addEventListener("click", SelectHelper, true); + + SpatialNavigation.init(BrowserApp.deck, null); + + document.addEventListener("MozMagnifyGesture", this, true); + + Services.prefs.addObserver("browser.zoom.reflowOnZoom", this, false); + this.updateReflozPref(); + }, + + resetMaxLineBoxWidth: function() { + BrowserApp.selectedTab.probablyNeedRefloz = false; + + if (gReflowPending) { + clearTimeout(gReflowPending); + } + + let reflozTimeout = Services.prefs.getIntPref("browser.zoom.reflowZoom.reflowTimeout"); + gReflowPending = setTimeout(doChangeMaxLineBoxWidth, + reflozTimeout, 0); + }, + + updateReflozPref: function() { + this.mReflozPref = Services.prefs.getBoolPref("browser.zoom.reflowOnZoom"); + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case 'touchstart': + this._handleTouchStart(aEvent); + break; + case 'MozMagnifyGesture': + this.observe(this, aEvent.type, + JSON.stringify({x: aEvent.screenX, y: aEvent.screenY, + zoomDelta: aEvent.delta})); + break; + } + }, + + _handleTouchStart: function(aEvent) { + if (!BrowserApp.isBrowserContentDocumentDisplayed() || aEvent.touches.length > 1 || aEvent.defaultPrevented) + return; + + let closest = aEvent.target; + + if (closest) { + // If we've pressed a scrollable element, let Java know that we may + // want to override the scroll behaviour (for document sub-frames) + this._scrollableElement = this._findScrollableElement(closest, true); + this._firstScrollEvent = true; + + if (this._scrollableElement != null) { + // Discard if it's the top-level scrollable, we let Java handle this + // The top-level scrollable is the body in quirks mode and the html element + // in standards mode + let doc = BrowserApp.selectedBrowser.contentDocument; + let rootScrollable = (doc.compatMode === "BackCompat" ? doc.body : doc.documentElement); + if (this._scrollableElement != rootScrollable) { + sendMessageToJava({ type: "Panning:Override" }); + } + } + } + + if (!ElementTouchHelper.isElementClickable(closest, null, false)) + closest = ElementTouchHelper.elementFromPoint(aEvent.changedTouches[0].screenX, + aEvent.changedTouches[0].screenY); + if (!closest) + closest = aEvent.target; + + if (closest) { + let uri = this._getLinkURI(closest); + if (uri) { + try { + Services.io.QueryInterface(Ci.nsISpeculativeConnect).speculativeConnect(uri, null); + } catch (e) {} + } + this._doTapHighlight(closest); + } + }, + + _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) { + if (aTopic == "dom-touch-listener-added") { + let tab = BrowserApp.getTabForWindow(aSubject.top); + if (!tab || tab.hasTouchListener) + return; + + tab.hasTouchListener = true; + sendMessageToJava({ + type: "Tab:HasTouchListener", + tabID: tab.id + }); + return; + } else if (aTopic == "nsPref:changed") { + if (aData == "browser.zoom.reflowOnZoom") { + this.updateReflozPref(); + } + return; + } + + // 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:Scroll": { + // If we've lost our scrollable element, return. Don't cancel the + // override, as we probably don't want Java to handle panning until the + // user releases their finger. + if (this._scrollableElement == null) + return; + + // If this is the first scroll event and we can't scroll in the direction + // the user wanted, and neither can any non-root sub-frame, cancel the + // override so that Java can handle panning the main document. + let data = JSON.parse(aData); + + // round the scroll amounts because they come in as floats and might be + // subject to minor rounding errors because of zoom values. I've seen values + // like 0.99 come in here and get truncated to 0; this avoids that problem. + let zoom = BrowserApp.selectedTab._zoom; + let x = Math.round(data.x / zoom); + let y = Math.round(data.y / zoom); + + if (this._firstScrollEvent) { + while (this._scrollableElement != null && + !this._elementCanScroll(this._scrollableElement, x, y)) + this._scrollableElement = this._findScrollableElement(this._scrollableElement, false); + + let doc = BrowserApp.selectedBrowser.contentDocument; + if (this._scrollableElement == null || + this._scrollableElement == doc.documentElement) { + sendMessageToJava({ type: "Panning:CancelOverride" }); + return; + } + + this._firstScrollEvent = false; + } + + // Scroll the scrollable element + if (this._elementCanScroll(this._scrollableElement, x, y)) { + this._scrollElementBy(this._scrollableElement, x, y); + sendMessageToJava({ type: "Gesture:ScrollAck", scrolled: true }); + SelectionHandler.subdocumentScrolled(this._scrollableElement); + } else { + sendMessageToJava({ type: "Gesture:ScrollAck", scrolled: false }); + } + + break; + } + + case "Gesture:CancelTouch": + this._cancelTapHighlight(); + break; + + case "Gesture:SingleTap": { + let element = this._highlightElement; + if (element) { + try { + let data = JSON.parse(aData); + let [x, y] = [data.x, data.y]; + if (ElementTouchHelper.isElementClickable(element)) { + [x, y] = this._moveClickPoint(element, x, y); + } + + // Was the element already focused before it was clicked? + let isFocused = (element == BrowserApp.getFocusedInput(BrowserApp.selectedBrowser)); + + this._sendMouseEvent("mousemove", element, x, y); + this._sendMouseEvent("mousedown", element, x, y); + this._sendMouseEvent("mouseup", element, x, y); + + // If the element was previously focused, show the caret attached to it. + if (isFocused) + SelectionHandler.attachCaret(element); + + // scrollToFocusedInput does its own checks to find out if an element should be zoomed into + BrowserApp.scrollToFocusedInput(BrowserApp.selectedBrowser); + } catch(e) { + Cu.reportError(e); + } + } + this._cancelTapHighlight(); + break; + } + + case"Gesture:DoubleTap": + this._cancelTapHighlight(); + this.onDoubleTap(aData); + break; + + case "MozMagnifyGesture": + this.onPinchFinish(aData); + break; + + default: + dump('BrowserEventHandler.handleUserEvent: unexpected topic "' + aTopic + '"'); + break; + } + }, + + onDoubleTap: function(aData) { + let data = JSON.parse(aData); + let element = ElementTouchHelper.anyElementFromPoint(data.x, data.y); + + // We only want to do this if reflow-on-zoom is enabled, we don't already + // have a reflow-on-zoom event pending, and the element upon which the user + // double-tapped isn't of a type we want to avoid reflow-on-zoom. + if (BrowserEventHandler.mReflozPref && + !BrowserApp.selectedTab._mReflozPoint && + !this._shouldSuppressReflowOnZoom(element)) { + + // See comment above performReflowOnZoom() for a detailed description of + // the events happening in the reflow-on-zoom operation. + let data = JSON.parse(aData); + let zoomPointX = data.x; + let zoomPointY = data.y; + + BrowserApp.selectedTab._mReflozPoint = { x: zoomPointX, y: zoomPointY, + range: BrowserApp.selectedBrowser.contentDocument.caretPositionFromPoint(zoomPointX, zoomPointY) }; + + // Before we perform a reflow on zoom, let's disable painting. + let webNav = BrowserApp.selectedTab.window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation); + let docShell = webNav.QueryInterface(Ci.nsIDocShell); + let docViewer = docShell.contentViewer.QueryInterface(Ci.nsIMarkupDocumentViewer); + docViewer.pausePainting(); + + BrowserApp.selectedTab.probablyNeedRefloz = true; + } + + if (!element) { + ZoomHelper.zoomOut(); + return; + } + + while (element && !this._shouldZoomToElement(element)) + element = element.parentNode; + + if (!element) { + ZoomHelper.zoomOut(); + } else { + ZoomHelper.zoomToElement(element, data.y); + } + }, + + /** + * Determine if reflow-on-zoom functionality should be suppressed, given a + * particular element. Double-tapping on the following elements suppresses + * reflow-on-zoom: + * + *