diff --git a/browser/base/content/newtab/newTab.css b/browser/base/content/newtab/newTab.css
index ba4c8376610..1b7966fd72d 100644
--- a/browser/base/content/newtab/newTab.css
+++ b/browser/base/content/newtab/newTab.css
@@ -353,7 +353,7 @@ input[type=button] {
}
@media not all and (max-resolution: 1dppx) {
- #newtab-search-icon.magnifier {
+ #newtab-search-logo.magnifier {
background-image: url("chrome://browser/skin/magnifier@2x.png");
}
}
diff --git a/browser/base/content/urlbarBindings.xml b/browser/base/content/urlbarBindings.xml
index 9cfc8b92e16..b628aea36b1 100644
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -1040,6 +1040,8 @@
[currentEngine.name], 1);
document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine-name")
.setAttribute("value", headerText);
+ document.getAnonymousElementByAttribute(this, "anonid", "searchbar-engine")
+ .engine = currentEngine;
// Update the 'Search for with:" header.
let headerSearchText =
@@ -1198,11 +1200,13 @@
return; // ignore right clicks.
let button = event.originalTarget;
- if (button.localName != "button" || !button.engine)
+ let engine = button.engine || button.parentNode.engine;
+
+ if (!engine)
return;
let searchbar = document.getElementById("searchbar");
- searchbar.handleSearchCommand(event, button.engine);
+ searchbar.handleSearchCommand(event, engine);
]]>
{
+ return parseInt(v, 10) == 100;
+ });
yield promiseTabLoadEvent(tab1, "about:home");
+ yield attributeChangePromise;
is(parseInt(zoomResetButton.label, 10), 100, "Default zoom is 100% for about:home");
yield promiseTabHistoryNavigation(-1, function() {
return parseInt(zoomResetButton.label, 10) == 110;
diff --git a/browser/components/customizableui/test/head.js b/browser/components/customizableui/test/head.js
index 9dfe6bc3eca..e331f2e497c 100644
--- a/browser/components/customizableui/test/head.js
+++ b/browser/components/customizableui/test/head.js
@@ -461,6 +461,34 @@ function promiseTabHistoryNavigation(aDirection = -1, aConditionFn) {
return deferred.promise;
}
+/**
+ * Wait for an attribute on a node to change
+ *
+ * @param aNode Node on which the mutation is expected
+ * @param aAttribute The attribute we're interested in
+ * @param aFilterFn A function to check if the new value is what we want.
+ * @return {Promise} resolved when the requisite mutation shows up.
+ */
+function promiseAttributeMutation(aNode, aAttribute, aFilterFn) {
+ return new Promise((resolve, reject) => {
+ info("waiting for mutation of attribute '" + aAttribute + "'.");
+ let obs = new MutationObserver((mutations) => {
+ for (let mut of mutations) {
+ let attr = mut.attributeName;
+ let newValue = mut.target.getAttribute(attr);
+ if (aFilterFn(newValue)) {
+ ok(true, "mutation occurred: attribute '" + attr + "' changed to '" + newValue + "' from '" + mut.oldValue + "'.");
+ obs.disconnect();
+ resolve();
+ } else {
+ info("Ignoring mutation that produced value " + newValue + " because of filter.");
+ }
+ }
+ });
+ obs.observe(aNode, {attributeFilter: [aAttribute]});
+ });
+}
+
function popupShown(aPopup) {
return promisePopupEvent(aPopup, "shown");
}
diff --git a/browser/components/loop/content/conversation.html b/browser/components/loop/content/conversation.html
index 9e2255f799b..28a43eb62c5 100644
--- a/browser/components/loop/content/conversation.html
+++ b/browser/components/loop/content/conversation.html
@@ -37,6 +37,8 @@
+
+
diff --git a/browser/components/loop/content/js/panel.js b/browser/components/loop/content/js/panel.js
index ad1400331bd..ac0e7293b81 100644
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -730,6 +730,15 @@ loop.panel = (function(_, mozL10n) {
this.stopListening(this.props.store);
},
+ componentWillUpdate: function(nextProps, nextState) {
+ // If we've just created a room, close the panel - the store will open
+ // the room.
+ if (this.state.pendingCreation &&
+ !nextState.pendingCreation && !nextState.error) {
+ this.closeWindow();
+ }
+ },
+
_onStoreStateChanged: function() {
this.setState(this.props.store.getStoreState());
},
@@ -747,8 +756,6 @@ loop.panel = (function(_, mozL10n) {
},
handleCreateButtonClick: function() {
- this.closeWindow();
-
this.props.dispatcher.dispatch(new sharedActions.CreateRoom({
nameTemplate: mozL10n.get("rooms_default_room_name_template"),
roomOwner: this.props.userDisplayName
@@ -1003,7 +1010,8 @@ loop.panel = (function(_, mozL10n) {
var notifications = new sharedModels.NotificationCollection();
var dispatcher = new loop.Dispatcher();
var roomStore = new loop.store.RoomStore(dispatcher, {
- mozLoop: navigator.mozLoop
+ mozLoop: navigator.mozLoop,
+ notifications: notifications
});
React.renderComponent(PanelView({
diff --git a/browser/components/loop/content/js/panel.jsx b/browser/components/loop/content/js/panel.jsx
index 864d86adf58..1847458806c 100644
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -730,6 +730,15 @@ loop.panel = (function(_, mozL10n) {
this.stopListening(this.props.store);
},
+ componentWillUpdate: function(nextProps, nextState) {
+ // If we've just created a room, close the panel - the store will open
+ // the room.
+ if (this.state.pendingCreation &&
+ !nextState.pendingCreation && !nextState.error) {
+ this.closeWindow();
+ }
+ },
+
_onStoreStateChanged: function() {
this.setState(this.props.store.getStoreState());
},
@@ -747,8 +756,6 @@ loop.panel = (function(_, mozL10n) {
},
handleCreateButtonClick: function() {
- this.closeWindow();
-
this.props.dispatcher.dispatch(new sharedActions.CreateRoom({
nameTemplate: mozL10n.get("rooms_default_room_name_template"),
roomOwner: this.props.userDisplayName
@@ -1003,7 +1010,8 @@ loop.panel = (function(_, mozL10n) {
var notifications = new sharedModels.NotificationCollection();
var dispatcher = new loop.Dispatcher();
var roomStore = new loop.store.RoomStore(dispatcher, {
- mozLoop: navigator.mozLoop
+ mozLoop: navigator.mozLoop,
+ notifications: notifications
});
React.renderComponent(
+
+
diff --git a/browser/components/loop/content/shared/js/actions.js b/browser/components/loop/content/shared/js/actions.js
index 22d2ad8e49c..6b1f95ee55d 100644
--- a/browser/components/loop/content/shared/js/actions.js
+++ b/browser/components/loop/content/shared/js/actions.js
@@ -197,12 +197,23 @@ loop.shared.actions = (function() {
roomOwner: String
}),
+ /**
+ * When a room has been created.
+ * XXX: should move to some roomActions module - refs bug 1079284
+ */
+ CreatedRoom: Action.define("createdRoom", {
+ roomToken: String
+ }),
+
/**
* Rooms creation error.
* XXX: should move to some roomActions module - refs bug 1079284
*/
CreateRoomError: Action.define("createRoomError", {
- error: Error
+ // There's two types of error possible - one thrown by our code (and Error)
+ // and the other is an Object about the error codes from the server as
+ // returned by the Hawk request.
+ error: Object
}),
/**
@@ -218,7 +229,10 @@ loop.shared.actions = (function() {
* XXX: should move to some roomActions module - refs bug 1079284
*/
DeleteRoomError: Action.define("deleteRoomError", {
- error: Error
+ // There's two types of error possible - one thrown by our code (and Error)
+ // and the other is an Object about the error codes from the server as
+ // returned by the Hawk request.
+ error: Object
}),
/**
diff --git a/browser/components/loop/content/shared/js/activeRoomStore.js b/browser/components/loop/content/shared/js/activeRoomStore.js
index df909a16436..05713606a4c 100644
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -21,31 +21,7 @@ loop.store.ActiveRoomStore = (function() {
ROOM_FULL: 202
};
- var ROOM_STATES = loop.store.ROOM_STATES = {
- // The initial state of the room
- INIT: "room-init",
- // The store is gathering the room data
- GATHER: "room-gather",
- // The store has got the room data
- READY: "room-ready",
- // Obtaining media from the user
- MEDIA_WAIT: "room-media-wait",
- // The room is known to be joined on the loop-server
- JOINED: "room-joined",
- // The room is connected to the sdk server.
- SESSION_CONNECTED: "room-session-connected",
- // There are participants in the room.
- HAS_PARTICIPANTS: "room-has-participants",
- // There was an issue with the room
- FAILED: "room-failed",
- // The room is full
- FULL: "room-full",
- // The room conversation has ended
- ENDED: "room-ended",
- // The window is closing
- CLOSING: "room-closing"
- };
-
+ var ROOM_STATES = loop.store.ROOM_STATES;
/**
* Active room store.
*
diff --git a/browser/components/loop/content/shared/js/fxOSActiveRoomStore.js b/browser/components/loop/content/shared/js/fxOSActiveRoomStore.js
new file mode 100644
index 00000000000..553025fae8c
--- /dev/null
+++ b/browser/components/loop/content/shared/js/fxOSActiveRoomStore.js
@@ -0,0 +1,164 @@
+/* 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/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.store = loop.store || {};
+
+loop.store.FxOSActiveRoomStore = (function() {
+ "use strict";
+ var sharedActions = loop.shared.actions;
+ var ROOM_STATES = loop.store.ROOM_STATES;
+
+ var FxOSActiveRoomStore = loop.store.createStore({
+ actions: [
+ "fetchServerData"
+ ],
+
+ initialize: function(options) {
+ if (!options.mozLoop) {
+ throw new Error("Missing option mozLoop");
+ }
+ this._mozLoop = options.mozLoop;
+ },
+
+ /**
+ * Returns initial state data for this active room.
+ */
+ getInitialStoreState: function() {
+ return {
+ roomState: ROOM_STATES.INIT,
+ audioMuted: false,
+ videoMuted: false,
+ failureReason: undefined
+ };
+ },
+
+ /**
+ * Registers the actions with the dispatcher that this store is interested
+ * in after the initial setup has been performed.
+ */
+ _registerPostSetupActions: function() {
+ this.dispatcher.register(this, [
+ "joinRoom"
+ ]);
+ },
+
+ /**
+ * Execute fetchServerData event action from the dispatcher. Although
+ * this is to fetch the server data - for rooms on the standalone client,
+ * we don't actually need to get any data. Therefore we just save the
+ * data that is given to us for when the user chooses to join the room.
+ *
+ * @param {sharedActions.FetchServerData} actionData
+ */
+ fetchServerData: function(actionData) {
+ if (actionData.windowType !== "room") {
+ // Nothing for us to do here, leave it to other stores.
+ return;
+ }
+
+ this._registerPostSetupActions();
+
+ this.setStoreState({
+ roomToken: actionData.token,
+ roomState: ROOM_STATES.READY
+ });
+
+ },
+
+ /**
+ * Handles the action to join to a room.
+ */
+ joinRoom: function() {
+ // Reset the failure reason if necessary.
+ if (this.getStoreState().failureReason) {
+ this.setStoreState({failureReason: undefined});
+ }
+
+ this._setupOutgoingRoom(true);
+ },
+
+ /**
+ * Sets up an outgoing room. It will try launching the activity to let the
+ * FirefoxOS loop app handle the call. If the activity fails:
+ * - if installApp is true, then it'll try to install the FirefoxOS loop
+ * app.
+ * - if installApp is false, then it'll just log and error and fail.
+ *
+ * @param {boolean} installApp
+ */
+ _setupOutgoingRoom: function(installApp) {
+ var request = new MozActivity({
+ name: "room-call",
+ data: {
+ type: "loop/rToken",
+ token: this.getStoreState("roomToken")
+ }
+ });
+
+ request.onsuccess = function() {};
+
+ request.onerror = (function(event) {
+ if (!installApp) {
+ // This really should not happen ever.
+ console.error(
+ "Unexpected activity launch error after the app has been installed");
+ return;
+ }
+ if (event.target.error.name !== "NO_PROVIDER") {
+ console.error ("Unexpected " + event.target.error.name);
+ return;
+ }
+ // We need to install the FxOS app.
+ this.setStoreState({
+ marketplaceSrc: loop.config.marketplaceUrl,
+ onMarketplaceMessage: this._onMarketplaceMessage.bind(this)
+ });
+ }).bind(this);
+ },
+
+ /**
+ * This method will handle events generated on the marketplace frame. It
+ * will launch the FirefoxOS loop app installation, and receive the result
+ * of the installation.
+ *
+ * @param {DOMEvent} event
+ */
+ _onMarketplaceMessage: function(event) {
+ var message = event.data;
+ switch (message.name) {
+ case "loaded":
+ var marketplace = window.document.getElementById("marketplace");
+ // Once we have it loaded, we request the installation of the FxOS
+ // Loop client app. We will be receiving the result of this action
+ // via postMessage from the child iframe.
+ marketplace.contentWindow.postMessage({
+ "name": "install-package",
+ "data": {
+ "product": {
+ "name": loop.config.fxosApp.name,
+ "manifest_url": loop.config.fxosApp.manifestUrl,
+ "is_packaged": true
+ }
+ }
+ }, "*");
+ break;
+ case "install-package":
+ window.removeEventListener("message", this.onMarketplaceMessage);
+ if (message.error) {
+ console.error(message.error.error);
+ return;
+ }
+ // We installed the FxOS app, so we can continue with the call
+ // process.
+ this._setupOutgoingRoom(false);
+ break;
+ }
+ }
+ });
+
+ return FxOSActiveRoomStore;
+})();
diff --git a/browser/components/loop/content/shared/js/roomStates.js b/browser/components/loop/content/shared/js/roomStates.js
new file mode 100644
index 00000000000..439046161e7
--- /dev/null
+++ b/browser/components/loop/content/shared/js/roomStates.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global loop:true */
+
+var loop = loop || {};
+loop.store = loop.store || {};
+
+loop.store.ROOM_STATES = {
+ // The initial state of the room
+ INIT: "room-init",
+ // The store is gathering the room data
+ GATHER: "room-gather",
+ // The store has got the room data
+ READY: "room-ready",
+ // Obtaining media from the user
+ MEDIA_WAIT: "room-media-wait",
+ // The room is known to be joined on the loop-server
+ JOINED: "room-joined",
+ // The room is connected to the sdk server.
+ SESSION_CONNECTED: "room-session-connected",
+ // There are participants in the room.
+ HAS_PARTICIPANTS: "room-has-participants",
+ // There was an issue with the room
+ FAILED: "room-failed",
+ // The room is full
+ FULL: "room-full",
+ // The room conversation has ended
+ ENDED: "room-ended",
+ // The window is closing
+ CLOSING: "room-closing"
+};
diff --git a/browser/components/loop/content/shared/js/roomStore.js b/browser/components/loop/content/shared/js/roomStore.js
index ca05036a299..c9b23c607c6 100644
--- a/browser/components/loop/content/shared/js/roomStore.js
+++ b/browser/components/loop/content/shared/js/roomStore.js
@@ -7,7 +7,7 @@
var loop = loop || {};
loop.store = loop.store || {};
-(function() {
+(function(mozL10n) {
"use strict";
/**
@@ -67,6 +67,8 @@ loop.store = loop.store || {};
* - {mozLoop} mozLoop The MozLoop API object.
* - {ActiveRoomStore} activeRoomStore An optional substore for active room
* state.
+ * - {Notifications} notifications An optional notifications item that is
+ * required if create actions are to be used
*/
loop.store.RoomStore = loop.store.createStore({
/**
@@ -89,6 +91,7 @@ loop.store = loop.store || {};
*/
actions: [
"createRoom",
+ "createdRoom",
"createRoomError",
"copyRoomUrl",
"deleteRoom",
@@ -107,6 +110,7 @@ loop.store = loop.store || {};
throw new Error("Missing option mozLoop");
}
this._mozLoop = options.mozLoop;
+ this._notifications = options.notifications;
if (options.activeRoomStore) {
this.activeRoomStore = options.activeRoomStore;
@@ -257,7 +261,10 @@ loop.store = loop.store || {};
* @param {sharedActions.CreateRoom} actionData The new room information.
*/
createRoom: function(actionData) {
- this.setStoreState({pendingCreation: true});
+ this.setStoreState({
+ pendingCreation: true,
+ error: null,
+ });
var roomCreationData = {
roomName: this._generateNewRoomName(actionData.nameTemplate),
@@ -266,19 +273,32 @@ loop.store = loop.store || {};
expiresIn: this.defaultExpiresIn
};
+ this._notifications.remove("create-room-error");
+
this._mozLoop.rooms.create(roomCreationData, function(err, createdRoom) {
- this.setStoreState({pendingCreation: false});
if (err) {
this.dispatchAction(new sharedActions.CreateRoomError({error: err}));
return;
}
- // Opens the newly created room
- this.dispatchAction(new sharedActions.OpenRoom({
+
+ this.dispatchAction(new sharedActions.CreatedRoom({
roomToken: createdRoom.roomToken
}));
}.bind(this));
},
+ /**
+ * Executed when a room has been created
+ */
+ createdRoom: function(actionData) {
+ this.setStoreState({pendingCreation: false});
+
+ // Opens the newly created room
+ this.dispatchAction(new sharedActions.OpenRoom({
+ roomToken: actionData.roomToken
+ }));
+ },
+
/**
* Executed when a room creation error occurs.
*
@@ -289,6 +309,13 @@ loop.store = loop.store || {};
error: actionData.error,
pendingCreation: false
});
+
+ // XXX Needs a more descriptive error - bug 1109151.
+ this._notifications.set({
+ id: "create-room-error",
+ level: "error",
+ message: mozL10n.get("generic_failure_title")
+ });
},
/**
@@ -406,4 +433,4 @@ loop.store = loop.store || {};
this.setStoreState({error: actionData.error});
}
});
-})();
+})(document.mozL10n || navigator.mozL10n);
diff --git a/browser/components/loop/jar.mn b/browser/components/loop/jar.mn
index 01425e94c65..2b4bf87d181 100644
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -66,22 +66,24 @@ browser.jar:
content/browser/loop/shared/img/telefonica@2x.png (content/shared/img/telefonica@2x.png)
# Shared scripts
- content/browser/loop/shared/js/actions.js (content/shared/js/actions.js)
- content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
- content/browser/loop/shared/js/store.js (content/shared/js/store.js)
- content/browser/loop/shared/js/roomStore.js (content/shared/js/roomStore.js)
- content/browser/loop/shared/js/activeRoomStore.js (content/shared/js/activeRoomStore.js)
- content/browser/loop/shared/js/feedbackStore.js (content/shared/js/feedbackStore.js)
- content/browser/loop/shared/js/dispatcher.js (content/shared/js/dispatcher.js)
- content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
- content/browser/loop/shared/js/models.js (content/shared/js/models.js)
- content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js)
- content/browser/loop/shared/js/otSdkDriver.js (content/shared/js/otSdkDriver.js)
- content/browser/loop/shared/js/views.js (content/shared/js/views.js)
- content/browser/loop/shared/js/feedbackViews.js (content/shared/js/feedbackViews.js)
- content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
- content/browser/loop/shared/js/validate.js (content/shared/js/validate.js)
- content/browser/loop/shared/js/websocket.js (content/shared/js/websocket.js)
+ content/browser/loop/shared/js/actions.js (content/shared/js/actions.js)
+ content/browser/loop/shared/js/conversationStore.js (content/shared/js/conversationStore.js)
+ content/browser/loop/shared/js/store.js (content/shared/js/store.js)
+ content/browser/loop/shared/js/roomStore.js (content/shared/js/roomStore.js)
+ content/browser/loop/shared/js/roomStates.js (content/shared/js/roomStates.js)
+ content/browser/loop/shared/js/fxOSActiveRoomStore.js (content/shared/js/fxOSActiveRoomStore.js)
+ content/browser/loop/shared/js/activeRoomStore.js (content/shared/js/activeRoomStore.js)
+ content/browser/loop/shared/js/feedbackStore.js (content/shared/js/feedbackStore.js)
+ content/browser/loop/shared/js/dispatcher.js (content/shared/js/dispatcher.js)
+ content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
+ content/browser/loop/shared/js/models.js (content/shared/js/models.js)
+ content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js)
+ content/browser/loop/shared/js/otSdkDriver.js (content/shared/js/otSdkDriver.js)
+ content/browser/loop/shared/js/views.js (content/shared/js/views.js)
+ content/browser/loop/shared/js/feedbackViews.js (content/shared/js/feedbackViews.js)
+ content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
+ content/browser/loop/shared/js/validate.js (content/shared/js/validate.js)
+ content/browser/loop/shared/js/websocket.js (content/shared/js/websocket.js)
# Shared libs
#ifdef DEBUG
diff --git a/browser/components/loop/standalone/Makefile b/browser/components/loop/standalone/Makefile
index 8071f7c54aa..87c177f34f1 100644
--- a/browser/components/loop/standalone/Makefile
+++ b/browser/components/loop/standalone/Makefile
@@ -83,6 +83,7 @@ config:
@echo "loop.config.learnMoreUrl = '`echo $(LOOP_PRODUCT_HOMEPAGE_URL)`';" >> content/config.js
@echo "loop.config.fxosApp = loop.config.fxosApp || {};" >> content/config.js
@echo "loop.config.fxosApp.name = 'Loop';" >> content/config.js
+ @echo "loop.config.fxosApp.rooms = true;" >> content/config.js
@echo "loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';" >> content/config.js
@echo "loop.config.roomsSupportUrl = 'https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc';" >> content/config.js
@echo "loop.config.guestSupportUrl = 'https://support.mozilla.org/kb/respond-firefox-hello-invitation-guest-mode';" >> content/config.js
diff --git a/browser/components/loop/standalone/content/index.html b/browser/components/loop/standalone/content/index.html
index c9f8aaf0746..9768200cb3e 100644
--- a/browser/components/loop/standalone/content/index.html
+++ b/browser/components/loop/standalone/content/index.html
@@ -98,12 +98,15 @@
+
+
+
diff --git a/browser/components/loop/standalone/content/js/fxOSMarketplace.js b/browser/components/loop/standalone/content/js/fxOSMarketplace.js
new file mode 100644
index 00000000000..c84b0e52bff
--- /dev/null
+++ b/browser/components/loop/standalone/content/js/fxOSMarketplace.js
@@ -0,0 +1,37 @@
+/** @jsx React.DOM */
+
+var loop = loop || {};
+loop.fxOSMarketplaceViews = (function() {
+ "use strict";
+
+ /**
+ * The Firefox Marketplace exposes a web page that contains a postMesssage
+ * based API that wraps a small set of functionality from the WebApps API
+ * that allow us to request the installation of apps given their manifest
+ * URL. We will be embedding the content of this web page within an hidden
+ * iframe in case that we need to request the installation of the FxOS Loop
+ * client.
+ */
+ var FxOSHiddenMarketplaceView = React.createClass({displayName: 'FxOSHiddenMarketplaceView',
+ render: function() {
+ return React.DOM.iframe({id: "marketplace", src: this.props.marketplaceSrc, hidden: true});
+ },
+
+ componentDidUpdate: function() {
+ // This happens only once when we change the 'src' property of the iframe.
+ if (this.props.onMarketplaceMessage) {
+ // The reason for listening on the global window instead of on the
+ // iframe content window is because the Marketplace is doing a
+ // window.top.postMessage.
+ // XXX Bug 1097703: This should be changed to an action when the old
+ // style URLs go away.
+ window.addEventListener("message", this.props.onMarketplaceMessage);
+ }
+ }
+ });
+
+ return {
+ FxOSHiddenMarketplaceView: FxOSHiddenMarketplaceView
+ };
+
+})();
diff --git a/browser/components/loop/standalone/content/js/fxOSMarketplace.jsx b/browser/components/loop/standalone/content/js/fxOSMarketplace.jsx
new file mode 100644
index 00000000000..2fd8d9f2a55
--- /dev/null
+++ b/browser/components/loop/standalone/content/js/fxOSMarketplace.jsx
@@ -0,0 +1,37 @@
+/** @jsx React.DOM */
+
+var loop = loop || {};
+loop.fxOSMarketplaceViews = (function() {
+ "use strict";
+
+ /**
+ * The Firefox Marketplace exposes a web page that contains a postMesssage
+ * based API that wraps a small set of functionality from the WebApps API
+ * that allow us to request the installation of apps given their manifest
+ * URL. We will be embedding the content of this web page within an hidden
+ * iframe in case that we need to request the installation of the FxOS Loop
+ * client.
+ */
+ var FxOSHiddenMarketplaceView = React.createClass({
+ render: function() {
+ return ;
+ },
+
+ componentDidUpdate: function() {
+ // This happens only once when we change the 'src' property of the iframe.
+ if (this.props.onMarketplaceMessage) {
+ // The reason for listening on the global window instead of on the
+ // iframe content window is because the Marketplace is doing a
+ // window.top.postMessage.
+ // XXX Bug 1097703: This should be changed to an action when the old
+ // style URLs go away.
+ window.addEventListener("message", this.props.onMarketplaceMessage);
+ }
+ }
+ });
+
+ return {
+ FxOSHiddenMarketplaceView: FxOSHiddenMarketplaceView
+ };
+
+})();
diff --git a/browser/components/loop/standalone/content/js/standaloneRoomViews.js b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
index e61dd960121..96527bcfa19 100644
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.js
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.js
@@ -20,8 +20,10 @@ loop.standaloneRoomViews = (function(mozL10n) {
var StandaloneRoomInfoArea = React.createClass({displayName: 'StandaloneRoomInfoArea',
propTypes: {
helper: React.PropTypes.instanceOf(loop.shared.utils.Helper).isRequired,
- activeRoomStore:
- React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired,
+ activeRoomStore: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
+ React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
+ ]).isRequired,
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
},
@@ -189,8 +191,10 @@ loop.standaloneRoomViews = (function(mozL10n) {
],
propTypes: {
- activeRoomStore:
- React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired,
+ activeRoomStore: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
+ React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
+ ]).isRequired,
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
@@ -379,6 +383,9 @@ loop.standaloneRoomViews = (function(mozL10n) {
enableHangup: this._roomIsActive()})
)
),
+ loop.fxOSMarketplaceViews.FxOSHiddenMarketplaceView({
+ marketplaceSrc: this.state.marketplaceSrc,
+ onMarketplaceMessage: this.state.onMarketplaceMessage}),
StandaloneRoomFooter(null)
)
);
diff --git a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
index 98db359c5fa..5146fb6000e 100644
--- a/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
+++ b/browser/components/loop/standalone/content/js/standaloneRoomViews.jsx
@@ -20,8 +20,10 @@ loop.standaloneRoomViews = (function(mozL10n) {
var StandaloneRoomInfoArea = React.createClass({
propTypes: {
helper: React.PropTypes.instanceOf(loop.shared.utils.Helper).isRequired,
- activeRoomStore:
- React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired,
+ activeRoomStore: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
+ React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
+ ]).isRequired,
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired
},
@@ -189,8 +191,10 @@ loop.standaloneRoomViews = (function(mozL10n) {
],
propTypes: {
- activeRoomStore:
- React.PropTypes.instanceOf(loop.store.ActiveRoomStore).isRequired,
+ activeRoomStore: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
+ React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
+ ]).isRequired,
feedbackStore:
React.PropTypes.instanceOf(loop.store.FeedbackStore).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
@@ -379,6 +383,9 @@ loop.standaloneRoomViews = (function(mozL10n) {
enableHangup={this._roomIsActive()} />
+
);
diff --git a/browser/components/loop/standalone/content/js/webapp.js b/browser/components/loop/standalone/content/js/webapp.js
index 7c64237e414..ff0b9baf444 100644
--- a/browser/components/loop/standalone/content/js/webapp.js
+++ b/browser/components/loop/standalone/content/js/webapp.js
@@ -127,32 +127,11 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
- /**
- * The Firefox Marketplace exposes a web page that contains a postMesssage
- * based API that wraps a small set of functionality from the WebApps API
- * that allow us to request the installation of apps given their manifest
- * URL. We will be embedding the content of this web page within an hidden
- * iframe in case that we need to request the installation of the FxOS Loop
- * client.
- */
- var FxOSHiddenMarketplace = React.createClass({displayName: 'FxOSHiddenMarketplace',
- render: function() {
- return React.DOM.iframe({id: "marketplace", src: this.props.marketplaceSrc, hidden: true});
- },
-
- componentDidUpdate: function() {
- // This happens only once when we change the 'src' property of the iframe.
- if (this.props.onMarketplaceMessage) {
- // The reason for listening on the global window instead of on the
- // iframe content window is because the Marketplace is doing a
- // window.top.postMessage.
- window.addEventListener("message", this.props.onMarketplaceMessage);
- }
- }
- });
-
var FxOSConversationModel = Backbone.Model.extend({
- setupOutgoingCall: function() {
+ setupOutgoingCall: function(selectedCallType) {
+ if (selectedCallType) {
+ this.set("selectedCallType", selectedCallType);
+ }
// The FxOS Loop client exposes a "loop-call" activity. If we get the
// activity onerror callback it means that there is no "loop-call"
// activity handler available and so no FxOS Loop client installed.
@@ -162,7 +141,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
type: "loop/token",
token: this.get("loopToken"),
callerId: this.get("callerId"),
- callType: this.get("callType")
+ video: this.get("selectedCallType") === "audio-video"
}
});
@@ -565,7 +544,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
dangerouslySetInnerHTML: {__html: tosHTML}})
),
- FxOSHiddenMarketplace({
+ loop.fxOSMarketplaceViews.FxOSHiddenMarketplaceView({
marketplaceSrc: this.state.marketplaceSrc,
onMarketplaceMessage: this.state.onMarketplaceMessage}),
@@ -959,8 +938,10 @@ loop.webapp = (function($, _, OT, mozL10n) {
// XXX New types for flux style
standaloneAppStore: React.PropTypes.instanceOf(
loop.store.StandaloneAppStore).isRequired,
- activeRoomStore: React.PropTypes.instanceOf(
- loop.store.ActiveRoomStore).isRequired,
+ activeRoomStore: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
+ React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
+ ]).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
@@ -1036,14 +1017,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
// Older non-flux based items.
var notifications = new sharedModels.NotificationCollection();
- var conversation
- if (helper.isFirefoxOS(navigator.userAgent)) {
- conversation = new FxOSConversationModel();
- } else {
- conversation = new sharedModels.ConversationModel({}, {
- sdk: OT
- });
- }
var feedbackApiClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
@@ -1061,6 +1034,29 @@ loop.webapp = (function($, _, OT, mozL10n) {
dispatcher: dispatcher,
sdk: OT
});
+ var conversation;
+ var activeRoomStore;
+ if (helper.isFirefoxOS(navigator.userAgent)) {
+ if (loop.config.fxosApp) {
+ conversation = new FxOSConversationModel();
+ if (loop.config.fxosApp.rooms) {
+ activeRoomStore = new loop.store.FxOSActiveRoomStore(dispatcher, {
+ mozLoop: standaloneMozLoop
+ });
+ }
+ }
+ }
+
+ conversation = conversation ||
+ new sharedModels.ConversationModel({}, {
+ sdk: OT
+ });
+ activeRoomStore = activeRoomStore ||
+ new loop.store.ActiveRoomStore(dispatcher, {
+ mozLoop: standaloneMozLoop,
+ sdkDriver: sdkDriver
+ });
+
var feedbackClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
product: loop.config.feedbackProductName,
@@ -1075,10 +1071,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
helper: helper,
sdk: OT
});
- var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
- mozLoop: standaloneMozLoop,
- sdkDriver: sdkDriver
- });
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
diff --git a/browser/components/loop/standalone/content/js/webapp.jsx b/browser/components/loop/standalone/content/js/webapp.jsx
index 24a77a9710d..148df860040 100644
--- a/browser/components/loop/standalone/content/js/webapp.jsx
+++ b/browser/components/loop/standalone/content/js/webapp.jsx
@@ -127,32 +127,11 @@ loop.webapp = (function($, _, OT, mozL10n) {
}
});
- /**
- * The Firefox Marketplace exposes a web page that contains a postMesssage
- * based API that wraps a small set of functionality from the WebApps API
- * that allow us to request the installation of apps given their manifest
- * URL. We will be embedding the content of this web page within an hidden
- * iframe in case that we need to request the installation of the FxOS Loop
- * client.
- */
- var FxOSHiddenMarketplace = React.createClass({
- render: function() {
- return ;
- },
-
- componentDidUpdate: function() {
- // This happens only once when we change the 'src' property of the iframe.
- if (this.props.onMarketplaceMessage) {
- // The reason for listening on the global window instead of on the
- // iframe content window is because the Marketplace is doing a
- // window.top.postMessage.
- window.addEventListener("message", this.props.onMarketplaceMessage);
- }
- }
- });
-
var FxOSConversationModel = Backbone.Model.extend({
- setupOutgoingCall: function() {
+ setupOutgoingCall: function(selectedCallType) {
+ if (selectedCallType) {
+ this.set("selectedCallType", selectedCallType);
+ }
// The FxOS Loop client exposes a "loop-call" activity. If we get the
// activity onerror callback it means that there is no "loop-call"
// activity handler available and so no FxOS Loop client installed.
@@ -162,7 +141,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
type: "loop/token",
token: this.get("loopToken"),
callerId: this.get("callerId"),
- callType: this.get("callType")
+ video: this.get("selectedCallType") === "audio-video"
}
});
@@ -565,7 +544,7 @@ loop.webapp = (function($, _, OT, mozL10n) {
dangerouslySetInnerHTML={{__html: tosHTML}}>
-
@@ -959,8 +938,10 @@ loop.webapp = (function($, _, OT, mozL10n) {
// XXX New types for flux style
standaloneAppStore: React.PropTypes.instanceOf(
loop.store.StandaloneAppStore).isRequired,
- activeRoomStore: React.PropTypes.instanceOf(
- loop.store.ActiveRoomStore).isRequired,
+ activeRoomStore: React.PropTypes.oneOfType([
+ React.PropTypes.instanceOf(loop.store.ActiveRoomStore),
+ React.PropTypes.instanceOf(loop.store.FxOSActiveRoomStore)
+ ]).isRequired,
dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
feedbackStore: React.PropTypes.instanceOf(loop.store.FeedbackStore)
},
@@ -1036,14 +1017,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
// Older non-flux based items.
var notifications = new sharedModels.NotificationCollection();
- var conversation
- if (helper.isFirefoxOS(navigator.userAgent)) {
- conversation = new FxOSConversationModel();
- } else {
- conversation = new sharedModels.ConversationModel({}, {
- sdk: OT
- });
- }
var feedbackApiClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
@@ -1061,6 +1034,29 @@ loop.webapp = (function($, _, OT, mozL10n) {
dispatcher: dispatcher,
sdk: OT
});
+ var conversation;
+ var activeRoomStore;
+ if (helper.isFirefoxOS(navigator.userAgent)) {
+ if (loop.config.fxosApp) {
+ conversation = new FxOSConversationModel();
+ if (loop.config.fxosApp.rooms) {
+ activeRoomStore = new loop.store.FxOSActiveRoomStore(dispatcher, {
+ mozLoop: standaloneMozLoop
+ });
+ }
+ }
+ }
+
+ conversation = conversation ||
+ new sharedModels.ConversationModel({}, {
+ sdk: OT
+ });
+ activeRoomStore = activeRoomStore ||
+ new loop.store.ActiveRoomStore(dispatcher, {
+ mozLoop: standaloneMozLoop,
+ sdkDriver: sdkDriver
+ });
+
var feedbackClient = new loop.FeedbackAPIClient(
loop.config.feedbackApiUrl, {
product: loop.config.feedbackProductName,
@@ -1075,10 +1071,6 @@ loop.webapp = (function($, _, OT, mozL10n) {
helper: helper,
sdk: OT
});
- var activeRoomStore = new loop.store.ActiveRoomStore(dispatcher, {
- mozLoop: standaloneMozLoop,
- sdkDriver: sdkDriver
- });
var feedbackStore = new loop.store.FeedbackStore(dispatcher, {
feedbackClient: feedbackClient
});
diff --git a/browser/components/loop/standalone/server.js b/browser/components/loop/standalone/server.js
index 99f94919257..830fed17c1a 100644
--- a/browser/components/loop/standalone/server.js
+++ b/browser/components/loop/standalone/server.js
@@ -30,6 +30,7 @@ function getConfigFile(req, res) {
"loop.config.legalWebsiteUrl = 'https://www.mozilla.org/about/legal/terms/firefox-hello/';",
"loop.config.fxosApp = loop.config.fxosApp || {};",
"loop.config.fxosApp.name = 'Loop';",
+ "loop.config.fxosApp.rooms = true;",
"loop.config.fxosApp.manifestUrl = 'http://fake-market.herokuapp.com/apps/packagedApp/manifest.webapp';",
"loop.config.roomsSupportUrl = 'https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc';",
"loop.config.guestSupportUrl = 'https://support.mozilla.org/kb/respond-firefox-hello-invitation-guest-mode';",
diff --git a/browser/components/loop/test/desktop-local/index.html b/browser/components/loop/test/desktop-local/index.html
index 73b4a25a5ed..3b6889c59e6 100644
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -52,6 +52,8 @@
+
+
diff --git a/browser/components/loop/test/desktop-local/panel_test.js b/browser/components/loop/test/desktop-local/panel_test.js
index 0608694cc4b..ba4cc303c21 100644
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -946,11 +946,15 @@ describe("loop.panel", function() {
}));
});
- it("should close the panel when 'Start a Conversation' is clicked",
+ it("should close the panel once a room is created and there is no error",
function() {
var view = createTestComponent();
- TestUtils.Simulate.click(view.getDOMNode().querySelector("button"));
+ roomStore.setStoreState({pendingCreation: true});
+
+ sinon.assert.notCalled(fakeWindow.close);
+
+ roomStore.setStoreState({pendingCreation: false});
sinon.assert.calledOnce(fakeWindow.close);
});
diff --git a/browser/components/loop/test/shared/fxOSActiveRoomStore_test.js b/browser/components/loop/test/shared/fxOSActiveRoomStore_test.js
new file mode 100644
index 00000000000..43f297f560a
--- /dev/null
+++ b/browser/components/loop/test/shared/fxOSActiveRoomStore_test.js
@@ -0,0 +1,212 @@
+/* global chai, loop */
+
+var expect = chai.expect;
+var sharedActions = loop.shared.actions;
+
+describe("loop.store.FxOSActiveRoomStore", function () {
+ "use strict";
+
+ var ROOM_STATES = loop.store.ROOM_STATES;
+
+ var sandbox;
+ var dispatcher;
+ var fakeMozLoop;
+ var store;
+
+ beforeEach(function() {
+ sandbox = sinon.sandbox.create();
+ sandbox.useFakeTimers();
+
+ dispatcher = new loop.Dispatcher();
+ sandbox.stub(dispatcher, "dispatch");
+
+ fakeMozLoop = {
+ setLoopPref: sandbox.stub(),
+ rooms: {
+ join: sinon.stub()
+ }
+ };
+
+ store = new loop.store.FxOSActiveRoomStore(dispatcher, {
+ mozLoop: fakeMozLoop
+ });
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ describe("#FxOSActiveRoomStore - constructor", function() {
+ it("should throw an error if mozLoop is missing", function() {
+ expect(function() {
+ new loop.store.FxOSActiveRoomStore(dispatcher);
+ }).to.Throw(/mozLoop/);
+ });
+ });
+
+ describe("#FxOSActiveRoomStore - fetchServerData", function() {
+ it("should save the token", function() {
+ store.fetchServerData(new sharedActions.FetchServerData({
+ windowType: "room",
+ token: "fakeToken"
+ }));
+
+ expect(store.getStoreState().roomToken).eql("fakeToken");
+ });
+
+ it("should set the state to `READY`", function() {
+ store.fetchServerData(new sharedActions.FetchServerData({
+ windowType: "room",
+ token: "fakeToken"
+ }));
+
+ expect(store.getStoreState().roomState).eql(ROOM_STATES.READY);
+ });
+ });
+
+ describe("#FxOSActiveRoomStore - setupOutgoingRoom", function() {
+ var realMozActivity;
+ var _activityDetail;
+ var _onerror;
+
+ function fireError(errorName) {
+ _onerror({
+ target: {
+ error: {
+ name: errorName
+ }
+ }
+ });
+ }
+
+ before(function() {
+ realMozActivity = window.MozActivity;
+
+ window.MozActivity = function(activityDetail) {
+ _activityDetail = activityDetail;
+ return {
+ set onerror(cbk) {
+ _onerror = cbk;
+ }
+ };
+ };
+ });
+
+ after(function() {
+ window.MozActivity = realMozActivity;
+ });
+
+ beforeEach(function() {
+ sandbox.stub(console, "error");
+ _activityDetail = undefined;
+ _onerror = undefined;
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ it("should reset failureReason", function() {
+ store.setStoreState({failureReason: "Test"});
+
+ store.joinRoom();
+
+ expect(store.getStoreState().failureReason).eql(undefined);
+ });
+
+ it("should create an activity", function() {
+ store.setStoreState({
+ roomToken: "fakeToken",
+ token: "fakeToken"
+ });
+
+ expect(_activityDetail).to.not.exist;
+ store.joinRoom();
+ expect(_activityDetail).to.exist;
+ expect(_activityDetail).eql({
+ name: "room-call",
+ data: {
+ type: "loop/rToken",
+ token: "fakeToken"
+ }
+ });
+ });
+
+ it("should change the store state when the activity fail with a " +
+ "NO_PROVIDER error", function() {
+
+ loop.config = {
+ marketplaceUrl: "http://market/"
+ };
+ store._setupOutgoingRoom(true);
+ fireError("NO_PROVIDER");
+ expect(store.getStoreState().marketplaceSrc).eql(
+ loop.config.marketplaceUrl
+ );
+ });
+
+ it("should log an error when the activity fail with a error different " +
+ "from NO_PROVIDER", function() {
+ loop.config = {
+ marketplaceUrl: "http://market/"
+ };
+ store._setupOutgoingRoom(true);
+ fireError("whatever");
+ sinon.assert.calledOnce(console.error);
+ sinon.assert.calledWith(console.error, "Unexpected whatever");
+ });
+
+ it("should log an error and exist if an activity error is received when " +
+ "the parameter is false ", function() {
+ loop.config = {
+ marketplaceUrl: "http://market/"
+ };
+ store._setupOutgoingRoom(false);
+ fireError("whatever");
+ sinon.assert.calledOnce(console.error);
+ sinon.assert.calledWith(console.error,
+ "Unexpected activity launch error after the app has been installed");
+ });
+ });
+
+ describe("#FxOSActiveRoomStore - _onMarketplaceMessage", function() {
+ var setupOutgoingRoom;
+
+ beforeEach(function() {
+ sandbox.stub(console, "error");
+ setupOutgoingRoom = sandbox.stub(store, "_setupOutgoingRoom");
+ });
+
+ afterEach(function() {
+ setupOutgoingRoom.restore();
+ });
+
+ it("We should call trigger a FxOS outgoing call if we get " +
+ "install-package message without error", function() {
+
+ sinon.assert.notCalled(setupOutgoingRoom);
+ store._onMarketplaceMessage({
+ data: {
+ name: "install-package"
+ }
+ });
+ sinon.assert.calledOnce(setupOutgoingRoom);
+ });
+
+ it("We should log an error if we get install-package message with an " +
+ "error", function() {
+
+ sinon.assert.notCalled(setupOutgoingRoom);
+ store._onMarketplaceMessage({
+ data: {
+ name: "install-package",
+ error: { error: "whatever error" }
+ }
+ });
+ sinon.assert.notCalled(setupOutgoingRoom);
+ sinon.assert.calledOnce(console.error);
+ sinon.assert.calledWith(console.error, "whatever error");
+ });
+ });
+});
+
diff --git a/browser/components/loop/test/shared/index.html b/browser/components/loop/test/shared/index.html
index 7cfe3c38f50..079104e37dc 100644
--- a/browser/components/loop/test/shared/index.html
+++ b/browser/components/loop/test/shared/index.html
@@ -50,6 +50,8 @@
+
+
@@ -67,6 +69,7 @@
+
diff --git a/browser/components/loop/test/shared/roomStore_test.js b/browser/components/loop/test/shared/roomStore_test.js
index 64d5f686d86..e59339de6fb 100644
--- a/browser/components/loop/test/shared/roomStore_test.js
+++ b/browser/components/loop/test/shared/roomStore_test.js
@@ -64,7 +64,7 @@ describe("loop.store.RoomStore", function () {
});
describe("constructed", function() {
- var fakeMozLoop, store;
+ var fakeMozLoop, fakeNotifications, store;
var defaultStoreState = {
error: undefined,
@@ -86,7 +86,14 @@ describe("loop.store.RoomStore", function () {
on: sandbox.stub()
}
};
- store = new loop.store.RoomStore(dispatcher, {mozLoop: fakeMozLoop});
+ fakeNotifications = {
+ set: sinon.stub(),
+ remove: sinon.stub()
+ };
+ store = new loop.store.RoomStore(dispatcher, {
+ mozLoop: fakeMozLoop,
+ notifications: fakeNotifications
+ });
store.setStoreState(defaultStoreState);
});
@@ -153,7 +160,7 @@ describe("loop.store.RoomStore", function () {
expect(store.getStoreState().rooms).to.have.length.of(0);
});
- })
+ });
});
describe("#findNextAvailableRoomNumber", function() {
@@ -203,9 +210,20 @@ describe("loop.store.RoomStore", function () {
};
beforeEach(function() {
+ sandbox.stub(dispatcher, "dispatch");
store.setStoreState({pendingCreation: false, rooms: []});
});
+ it("should clear any existing room errors", function() {
+ sandbox.stub(fakeMozLoop.rooms, "create");
+
+ store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
+
+ sinon.assert.calledOnce(fakeNotifications.remove);
+ sinon.assert.calledWithExactly(fakeNotifications.remove,
+ "create-room-error");
+ });
+
it("should request creation of a new room", function() {
sandbox.stub(fakeMozLoop.rooms, "create");
@@ -219,17 +237,6 @@ describe("loop.store.RoomStore", function () {
});
});
- it("should store any creation encountered error", function() {
- var err = new Error("fake");
- sandbox.stub(fakeMozLoop.rooms, "create", function(data, cb) {
- cb(err);
- });
-
- store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
-
- expect(store.getStoreState().error).eql(err);
- });
-
it("should switch the pendingCreation state flag to true", function() {
sandbox.stub(fakeMozLoop.rooms, "create");
@@ -238,33 +245,91 @@ describe("loop.store.RoomStore", function () {
expect(store.getStoreState().pendingCreation).eql(true);
});
- it("should switch the pendingCreation state flag to false once the " +
- "operation is done", function() {
- sandbox.stub(fakeMozLoop.rooms, "create", function(data, cb) {
- cb(null, {roomToken: "fakeToken"});
- });
-
- store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
-
- expect(store.getStoreState().pendingCreation).eql(false);
- });
-
- it("should dispatch an OpenRoom action once the operation is done",
+ it("should dispatch a CreatedRoom action once the operation is done",
function() {
- var dispatch = sandbox.stub(dispatcher, "dispatch");
sandbox.stub(fakeMozLoop.rooms, "create", function(data, cb) {
cb(null, {roomToken: "fakeToken"});
});
store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
- sinon.assert.calledOnce(dispatch);
- sinon.assert.calledWithExactly(dispatch, new sharedActions.OpenRoom({
+ sinon.assert.calledOnce(dispatcher.dispatch);
+ sinon.assert.calledWithExactly(dispatcher.dispatch,
+ new sharedActions.CreatedRoom({
+ roomToken: "fakeToken"
+ }));
+ });
+
+ it("should dispatch a CreateRoomError action if the operation fails",
+ function() {
+ var err = new Error("fake");
+ sandbox.stub(fakeMozLoop.rooms, "create", function(data, cb) {
+ cb(err);
+ });
+
+ store.createRoom(new sharedActions.CreateRoom(fakeRoomCreationData));
+
+ sinon.assert.calledOnce(dispatcher.dispatch);
+ sinon.assert.calledWithExactly(dispatcher.dispatch,
+ new sharedActions.CreateRoomError({
+ error: err
+ }));
+ });
+ });
+
+ describe("#createdRoom", function() {
+ beforeEach(function() {
+ sandbox.stub(dispatcher, "dispatch");
+ });
+
+ it("should switch the pendingCreation state flag to false", function() {
+ store.setStoreState({pendingCreation:true});
+
+ store.createdRoom(new sharedActions.CreatedRoom({
+ roomToken: "fakeToken"
+ }));
+
+ expect(store.getStoreState().pendingCreation).eql(false);
+ });
+
+ it("should dispatch an OpenRoom action once the operation is done",
+ function() {
+ store.createdRoom(new sharedActions.CreatedRoom({
roomToken: "fakeToken"
}));
+
+ sinon.assert.calledOnce(dispatcher.dispatch);
+ sinon.assert.calledWithExactly(dispatcher.dispatch,
+ new sharedActions.OpenRoom({
+ roomToken: "fakeToken"
+ }));
});
});
+ describe("#createRoomError", function() {
+ it("should switch the pendingCreation state flag to false", function() {
+ store.setStoreState({pendingCreation:true});
+
+ store.createRoomError({
+ error: new Error("fake")
+ });
+
+ expect(store.getStoreState().pendingCreation).eql(false);
+ });
+
+ it("should set a notification", function() {
+ store.createRoomError({
+ error: new Error("fake")
+ });
+
+ sinon.assert.calledOnce(fakeNotifications.set);
+ sinon.assert.calledWithMatch(fakeNotifications.set, {
+ id: "create-room-error",
+ level: "error"
+ });
+ });
+ });
+
describe("#copyRoomUrl", function() {
it("should copy the room URL", function() {
var copyString = sandbox.stub(fakeMozLoop, "copyString");
diff --git a/browser/components/loop/test/standalone/index.html b/browser/components/loop/test/standalone/index.html
index d5f8d48b63e..50abf311da4 100644
--- a/browser/components/loop/test/standalone/index.html
+++ b/browser/components/loop/test/standalone/index.html
@@ -48,6 +48,8 @@
+
+
@@ -56,6 +58,7 @@
+
diff --git a/browser/components/loop/test/standalone/standaloneRoomViews_test.js b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
index d93b0ceb7ca..3d40c90b781 100644
--- a/browser/components/loop/test/standalone/standaloneRoomViews_test.js
+++ b/browser/components/loop/test/standalone/standaloneRoomViews_test.js
@@ -332,6 +332,26 @@ describe("loop.standaloneRoomViews", function() {
.not.eql(null);
});
});
+
+ describe("Marketplace hidden iframe", function() {
+
+ it("should set src when the store state change",
+ function(done) {
+
+ var marketplace = view.getDOMNode().querySelector("#marketplace");
+ expect(marketplace.src).to.be.equal("");
+
+ activeRoomStore.setStoreState({
+ marketplaceSrc: "http://market/",
+ onMarketplaceMessage: function () {}
+ });
+
+ view.forceUpdate(function() {
+ expect(marketplace.src).to.be.equal("http://market/");
+ done();
+ });
+ });
+ });
});
});
});
diff --git a/browser/components/loop/test/standalone/webapp_test.js b/browser/components/loop/test/standalone/webapp_test.js
index 018f7ad0009..24168a023f5 100644
--- a/browser/components/loop/test/standalone/webapp_test.js
+++ b/browser/components/loop/test/standalone/webapp_test.js
@@ -1169,8 +1169,7 @@ describe("loop.webapp", function() {
before(function() {
model = new loop.webapp.FxOSConversationModel({
loopToken: "fakeToken",
- callerId: "callerId",
- callType: "callType"
+ callerId: "callerId"
});
realMozActivity = window.MozActivity;
@@ -1214,13 +1213,44 @@ describe("loop.webapp", function() {
beforeEach(function() {
trigger = sandbox.stub(model, "trigger");
+ _activityProps = undefined;
});
afterEach(function() {
trigger.restore();
});
- it("Activity properties", function() {
+ it("Activity properties with video call", function() {
+ expect(_activityProps).to.not.exist;
+ model.setupOutgoingCall("audio-video");
+ expect(_activityProps).to.exist;
+ expect(_activityProps).eql({
+ name: "loop-call",
+ data: {
+ type: "loop/token",
+ token: "fakeToken",
+ callerId: "callerId",
+ video: true
+ }
+ });
+ });
+
+ it("Activity properties with audio call", function() {
+ expect(_activityProps).to.not.exist;
+ model.setupOutgoingCall("audio");
+ expect(_activityProps).to.exist;
+ expect(_activityProps).eql({
+ name: "loop-call",
+ data: {
+ type: "loop/token",
+ token: "fakeToken",
+ callerId: "callerId",
+ video: false
+ }
+ });
+ });
+
+ it("Activity properties by default", function() {
expect(_activityProps).to.not.exist;
model.setupOutgoingCall();
expect(_activityProps).to.exist;
@@ -1230,7 +1260,7 @@ describe("loop.webapp", function() {
type: "loop/token",
token: "fakeToken",
callerId: "callerId",
- callType: "callType"
+ video: false
}
});
});
diff --git a/browser/components/loop/ui/index.html b/browser/components/loop/ui/index.html
index f5822eaff53..38f3cbb5b14 100644
--- a/browser/components/loop/ui/index.html
+++ b/browser/components/loop/ui/index.html
@@ -44,12 +44,15 @@
+
+
+
diff --git a/layout/inspector/inDOMUtils.cpp b/layout/inspector/inDOMUtils.cpp
index 687b071760b..11e959a5b24 100644
--- a/layout/inspector/inDOMUtils.cpp
+++ b/layout/inspector/inDOMUtils.cpp
@@ -867,6 +867,11 @@ inDOMUtils::CssPropertyIsValid(const nsAString& aPropertyName,
return NS_OK;
}
+ if (propertyID == eCSSPropertyExtra_variable) {
+ *_retval = true;
+ return NS_OK;
+ }
+
// Get a parser, parse the property.
nsCSSParser parser;
*_retval = parser.IsValueValidForProperty(propertyID, aPropertyValue);
diff --git a/layout/inspector/tests/test_css_property_is_valid.html b/layout/inspector/tests/test_css_property_is_valid.html
index cde687ae035..21f00bf1848 100644
--- a/layout/inspector/tests/test_css_property_is_valid.html
+++ b/layout/inspector/tests/test_css_property_is_valid.html
@@ -74,6 +74,11 @@
property: "content",
value: "\"hello\"",
expected: true
+ },
+ {
+ property: "color",
+ value: "var(--some-kind-of-green)",
+ expected: true
}
];
diff --git a/mobile/android/chrome/content/browser.js b/mobile/android/chrome/content/browser.js
index 535adc80b0d..fcf35f8f673 100644
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -349,6 +349,8 @@ var BrowserApp = {
#endif
#ifdef NIGHTLY_BUILD
WebcompatReporter.init();
+ Telemetry.addData("TRACKING_PROTECTION_ENABLED",
+ Services.prefs.getBoolPref("privacy.trackingprotection.enabled"));
#endif
} catch(ex) { console.log(ex); }
}, false);
@@ -6788,15 +6790,18 @@ var IdentityHandler = {
getTrackingMode: function getTrackingMode(aState) {
if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT) {
+ Telemetry.addData("TRACKING_PROTECTION_SHIELD", 2);
return this.TRACKING_MODE_CONTENT_BLOCKED;
}
// Only show an indicator for loaded tracking content if the pref to block it is enabled
if ((aState & Ci.nsIWebProgressListener.STATE_LOADED_TRACKING_CONTENT) &&
Services.prefs.getBoolPref("privacy.trackingprotection.enabled")) {
+ Telemetry.addData("TRACKING_PROTECTION_SHIELD", 1);
return this.TRACKING_MODE_CONTENT_LOADED;
}
+ Telemetry.addData("TRACKING_PROTECTION_SHIELD", 0);
return this.TRACKING_MODE_UNKNOWN;
},
diff --git a/toolkit/components/jsdownloads/src/DownloadCore.jsm b/toolkit/components/jsdownloads/src/DownloadCore.jsm
index 16dc216d114..bc80b171f4c 100644
--- a/toolkit/components/jsdownloads/src/DownloadCore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm
@@ -254,6 +254,11 @@ this.Download.prototype = {
* Indicates whether, at this time, there is any partially downloaded data
* that can be used when restarting a failed or canceled download.
*
+ * Even if the download has partial data on disk, hasPartialData will be false
+ * if that data cannot be used to restart the download. In order to determine
+ * if a part file is being used which contains partial data the
+ * Download.target.partFilePath should be checked.
+ *
* This property is relevant while the download is in progress, and also if it
* failed or has been canceled. If the download has been completed
* successfully, this property is always false.
@@ -263,6 +268,13 @@ this.Download.prototype = {
*/
hasPartialData: false,
+ /**
+ * Indicates whether, at this time, there is any data that has been blocked.
+ * Since reputation blocking takes place after the download has fully
+ * completed a value of true also indicates 100% of the data is present.
+ */
+ hasBlockedData: false,
+
/**
* This can be set to a function that is called after other properties change.
*/
@@ -353,11 +365,18 @@ this.Download.prototype = {
message: "Cannot start after finalization."}));
}
+ if (this.error && this.error.becauseBlockedByReputationCheck) {
+ return Promise.reject(new DownloadError({
+ message: "Cannot start after being blocked " +
+ "by a reputation check."}));
+ }
+
// Initialize all the status properties for a new or restarted download.
this.stopped = false;
this.canceled = false;
this.error = null;
this.hasProgress = false;
+ this.hasBlockedData = false;
this.progress = 0;
this.totalBytes = 0;
this.currentBytes = 0;
@@ -448,20 +467,16 @@ this.Download.prototype = {
yield this.saver.execute(DS_setProgressBytes.bind(this),
DS_setProperties.bind(this));
- // Check for application reputation, which requires the entire file to
- // be downloaded. After that, check for the last time if the download
- // has been canceled. Both cases require the target file to be deleted,
- // thus we process both in the same block of code.
- if ((yield DownloadIntegration.shouldBlockForReputationCheck(this)) ||
- this._promiseCanceled) {
+ // Check for the last time if the download has been canceled.
+ if (this._promiseCanceled) {
try {
yield OS.File.remove(this.target.path);
} catch (ex) {
Cu.reportError(ex);
}
- // If this is actually a cancellation, this exception will be changed
- // in the catch block below.
- throw new DownloadError({ becauseBlockedByReputationCheck: true });
+
+ // Cancellation exceptions will be changed in the catch block below.
+ throw new DownloadError();
}
// Update the status properties for a successful download.
@@ -513,24 +528,7 @@ this.Download.prototype = {
this.speed = 0;
this._notifyChange();
if (this.succeeded) {
- yield DownloadIntegration.downloadDone(this);
-
- this._deferSucceeded.resolve();
-
- if (this.launchWhenSucceeded) {
- this.launch().then(null, Cu.reportError);
-
- // Always schedule files to be deleted at the end of the private browsing
- // mode, regardless of the value of the pref.
- if (this.source.isPrivate) {
- gExternalAppLauncher.deleteTemporaryPrivateFileWhenPossible(
- new FileUtils.File(this.target.path));
- } else if (Services.prefs.getBoolPref(
- "browser.helperApps.deleteTempFileOnExit")) {
- gExternalAppLauncher.deleteTemporaryFileOnExit(
- new FileUtils.File(this.target.path));
- }
- }
+ yield this._succeed();
}
}
}
@@ -541,6 +539,133 @@ this.Download.prototype = {
return currentAttempt;
},
+ /**
+ * Perform the actions necessary when a Download succeeds.
+ *
+ * @return {Promise}
+ * @resolves When the steps to take after success have completed.
+ * @rejects JavaScript exception if any of the operations failed.
+ */
+ _succeed: Task.async(function* () {
+ yield DownloadIntegration.downloadDone(this);
+
+ this._deferSucceeded.resolve();
+
+ if (this.launchWhenSucceeded) {
+ this.launch().then(null, Cu.reportError);
+
+ // Always schedule files to be deleted at the end of the private browsing
+ // mode, regardless of the value of the pref.
+ if (this.source.isPrivate) {
+ gExternalAppLauncher.deleteTemporaryPrivateFileWhenPossible(
+ new FileUtils.File(this.target.path));
+ } else if (Services.prefs.getBoolPref(
+ "browser.helperApps.deleteTempFileOnExit")) {
+ gExternalAppLauncher.deleteTemporaryFileOnExit(
+ new FileUtils.File(this.target.path));
+ }
+ }
+ }),
+
+ /**
+ * When a request to unblock the download is received, contains a promise
+ * that will be resolved when the unblock request is completed. This property
+ * will then continue to hold the promise indefinitely.
+ */
+ _promiseUnblock: null,
+
+ /**
+ * When a request to confirm the block of the download is received, contains
+ * a promise that will be resolved when cleaning up the download has
+ * completed. This property will then continue to hold the promise
+ * indefinitely.
+ */
+ _promiseConfirmBlock: null,
+
+ /**
+ * Unblocks a download which had been blocked by reputation.
+ *
+ * The file will be moved out of quarantine and the download will be
+ * marked as succeeded.
+ *
+ * @return {Promise}
+ * @resolves When the Download has been unblocked and succeeded.
+ * @rejects JavaScript exception if any of the operations failed.
+ */
+ unblock: function() {
+ if (this._promiseUnblock) {
+ return this._promiseUnblock;
+ }
+
+ if (this._promiseConfirmBlock) {
+ return Promise.reject(new Error(
+ "Download block has been confirmed, cannot unblock."));
+ }
+
+ if (!this.hasBlockedData) {
+ return Promise.reject(new Error(
+ "unblock may only be called on Downloads with blocked data."));
+ }
+
+ this._promiseUnblock = Task.spawn(function* () {
+ try {
+ yield OS.File.move(this.target.partFilePath, this.target.path);
+ } catch (ex) {
+ yield this.refresh();
+ this._promiseUnblock = null;
+ throw ex;
+ }
+
+ this.succeeded = true;
+ this.hasBlockedData = false;
+ this._notifyChange();
+ yield this._succeed();
+ }.bind(this));
+
+ return this._promiseUnblock;
+ },
+
+ /**
+ * Confirms that a blocked download should be cleaned up.
+ *
+ * If a download was blocked but retained on disk this method can be used
+ * to remove the file.
+ *
+ * @return {Promise}
+ * @resolves When the Download's data has been removed.
+ * @rejects JavaScript exception if any of the operations failed.
+ */
+ confirmBlock: function() {
+ if (this._promiseConfirmBlock) {
+ return this._promiseConfirmBlock;
+ }
+
+ if (this._promiseUnblock) {
+ return Promise.reject(new Error(
+ "Download is being unblocked, cannot confirmBlock."));
+ }
+
+ if (!this.hasBlockedData) {
+ return Promise.reject(new Error(
+ "confirmBlock may only be called on Downloads with blocked data."));
+ }
+
+ this._promiseConfirmBlock = Task.spawn(function* () {
+ try {
+ yield OS.File.remove(this.target.partFilePath);
+ } catch (ex) {
+ yield this.refresh();
+ this._promiseConfirmBlock = null;
+ throw ex;
+ }
+
+ this.hasBlockedData = false;
+ this._notifyChange();
+ }.bind(this));
+
+ return this._promiseConfirmBlock;
+ },
+
/*
* Launches the file after download has completed. This can open
* the file with the default application for the target MIME type
@@ -772,21 +897,34 @@ this.Download.prototype = {
}
// Update the current progress from disk if we retained partial data.
- if (this.hasPartialData && this.target.partFilePath) {
- let stat = yield OS.File.stat(this.target.partFilePath);
+ if ((this.hasPartialData || this.hasBlockedData) &&
+ this.target.partFilePath) {
- // Ignore the result if the state has changed meanwhile.
- if (!this.stopped || this._finalized) {
- return;
+ try {
+ let stat = yield OS.File.stat(this.target.partFilePath);
+
+ // Ignore the result if the state has changed meanwhile.
+ if (!this.stopped || this._finalized) {
+ return;
+ }
+
+ // Update the bytes transferred and the related progress properties.
+ this.currentBytes = stat.size;
+ if (this.totalBytes > 0) {
+ this.hasProgress = true;
+ this.progress = Math.floor(this.currentBytes /
+ this.totalBytes * 100);
+ }
+ } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+ // Ignore the result if the state has changed meanwhile.
+ if (!this.stopped || this._finalized) {
+ return;
+ }
+
+ this.hasBlockedData = false;
+ this.hasPartialData = false;
}
- // Update the bytes transferred and the related progress properties.
- this.currentBytes = stat.size;
- if (this.totalBytes > 0) {
- this.hasProgress = true;
- this.progress = Math.floor(this.currentBytes /
- this.totalBytes * 100);
- }
this._notifyChange();
}
}.bind(this)).then(null, Cu.reportError);
@@ -984,6 +1122,7 @@ const kPlainSerializableDownloadProperties = [
"canceled",
"totalBytes",
"hasPartialData",
+ "hasBlockedData",
"tryToKeepPartialData",
"launcherPath",
"launchWhenSucceeded",
@@ -1818,11 +1957,6 @@ this.DownloadCopySaver.prototype = {
// background file saver that the operation can finish. If the
// data transfer failed, the saver has been already stopped.
if (Components.isSuccessCode(aStatusCode)) {
- if (partFilePath) {
- // Move to the final target if we were using a part file.
- backgroundFileSaver.setTarget(
- new FileUtils.File(targetPath), false);
- }
backgroundFileSaver.finish(Cr.NS_OK);
}
}
@@ -1855,6 +1989,8 @@ this.DownloadCopySaver.prototype = {
// We will wait on this promise in case no error occurred while setting
// up the chain of objects for the download.
yield deferSaveComplete.promise;
+
+ yield this._checkReputationAndMove();
} catch (ex) {
// Ensure we always remove the placeholder for the final target file on
// failure, independently of which code path failed. In some cases, the
@@ -1876,6 +2012,47 @@ this.DownloadCopySaver.prototype = {
}.bind(this));
},
+ /**
+ * Perform the reputation check and cleanup the downloaded data if required.
+ * If the download passes the reputation check and is using a part file we
+ * will move it to the target path since reputation checking is the final
+ * step in the saver.
+ *
+ * @return {Promise}
+ * @resolves When the reputation check and cleanup is complete.
+ * @rejects DownloadError if the download should be blocked.
+ */
+ _checkReputationAndMove: Task.async(function* () {
+ let download = this.download;
+ let targetPath = this.download.target.path;
+ let partFilePath = this.download.target.partFilePath;
+
+ if (yield DownloadIntegration.shouldBlockForReputationCheck(download)) {
+ download.progress = 100;
+ download.hasPartialData = false;
+
+ // We will remove the potentially dangerous file if instructed by
+ // DownloadIntegration. We will always remove the file when the
+ // download did not use a partial file path, meaning it
+ // currently has its final filename.
+ if (!DownloadIntegration.shouldKeepBlockedData() || !partFilePath) {
+ try {
+ yield OS.File.remove(partFilePath || targetPath);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ } else {
+ download.hasBlockedData = true;
+ }
+
+ throw new DownloadError({ becauseBlockedByReputationCheck: true });
+ }
+
+ if (partFilePath) {
+ yield OS.File.move(partFilePath, targetPath);
+ }
+ }),
+
/**
* Implements "DownloadSaver.cancel".
*/
@@ -2171,13 +2348,12 @@ this.DownloadLegacySaver.prototype = {
// to its final target path when the download succeeds. In this case,
// an empty ".part" file is created even if no data was received from
// the source.
- if (this.download.target.partFilePath) {
- yield OS.File.move(this.download.target.partFilePath,
- this.download.target.path);
- } else {
- // The download implementation may not have created the target file if
- // no data was received from the source. In this case, ensure that an
- // empty file is created as expected.
+ //
+ // When no ".part" file path is provided the download implementation may
+ // not have created the target file (if no data was received from the
+ // source). In this case, ensure that an empty file is created as
+ // expected.
+ if (!this.download.target.partFilePath) {
try {
// This atomic operation is more efficient than an existence check.
let file = yield OS.File.open(this.download.target.path,
@@ -2185,6 +2361,9 @@ this.DownloadLegacySaver.prototype = {
yield file.close();
} catch (ex if ex instanceof OS.File.Error && ex.becauseExists) { }
}
+
+ yield this._checkReputationAndMove();
+
} catch (ex) {
// Ensure we always remove the final target file on failure,
// independently of which code path failed. In some cases, the
@@ -2217,6 +2396,10 @@ this.DownloadLegacySaver.prototype = {
}.bind(this));
},
+ _checkReputationAndMove: function () {
+ return DownloadCopySaver.prototype._checkReputationAndMove.call(this);
+ },
+
/**
* Implements "DownloadSaver.cancel".
*/
diff --git a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
index dbefa74c21a..a30e8a6d07f 100644
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -152,6 +152,7 @@ this.DownloadIntegration = {
dontCheckApplicationReputation: true,
#endif
shouldBlockInTestForApplicationReputation: false,
+ shouldKeepBlockedDataInTest: false,
dontOpenFileAndFolder: false,
downloadDoneCalled: false,
_deferTestOpenFile: null,
@@ -174,6 +175,30 @@ this.DownloadIntegration = {
return (this._testMode = mode);
},
+ /**
+ * Returns whether data for blocked downloads should be kept on disk.
+ * Implementations which support unblocking downloads may return true to
+ * keep the blocked download on disk until its fate is decided.
+ *
+ * If a download is blocked and the partial data is kept the Download's
+ * 'hasBlockedData' property will be true. In this state Download.unblock()
+ * or Download.confirmBlock() may be used to either unblock the download or
+ * remove the downloaded data respectively.
+ *
+ * Even if shouldKeepBlockedData returns true, if the download did not use a
+ * partFile the blocked data will be removed - preventing the complete
+ * download from existing on disk with its final filename.
+ *
+ * @return boolean True if data should be kept.
+ */
+ shouldKeepBlockedData: function() {
+ if (this.shouldBlockInTestForApplicationReputation) {
+ return this.shouldKeepBlockedDataInTest;
+ }
+
+ return false;
+ },
+
/**
* Performs initialization of the list of persistent downloads, before its
* first use by the host application. This function may be called only once
diff --git a/toolkit/components/jsdownloads/test/unit/common_test_Download.js b/toolkit/components/jsdownloads/test/unit/common_test_Download.js
index 6820e5334e9..1ae0b6c0d91 100644
--- a/toolkit/components/jsdownloads/test/unit/common_test_Download.js
+++ b/toolkit/components/jsdownloads/test/unit/common_test_Download.js
@@ -1547,42 +1547,231 @@ add_task(function test_getSha256Hash()
}
});
+
/**
- * Checks that application reputation blocks the download and the target file
- * does not exist.
+ * Create a download which will be reputation blocked.
+ *
+ * @param options
+ * {
+ * keepPartialData: bool,
+ * keepBlockedData: bool,
+ * }
+ * @return {Promise}
+ * @resolves The reputation blocked download.
+ * @rejects JavaScript exception.
*/
-add_task(function test_blocked_applicationReputation()
-{
+let promiseBlockedDownload = Task.async(function* (options) {
function cleanup() {
DownloadIntegration.shouldBlockInTestForApplicationReputation = false;
+ DownloadIntegration.shouldKeepBlockedDataInTest = false;
}
do_register_cleanup(cleanup);
+
+ let {keepPartialData, keepBlockedData} = options;
DownloadIntegration.shouldBlockInTestForApplicationReputation = true;
+ DownloadIntegration.shouldKeepBlockedDataInTest = keepBlockedData;
let download;
+
try {
- if (!gUseLegacySaver) {
- // When testing DownloadCopySaver, we want to check that the promise
- // returned by the "start" method is rejected.
+ if (keepPartialData) {
+ download = yield promiseStartDownload_tryToKeepPartialData();
+ continueResponses();
+ } else if (gUseLegacySaver) {
+ download = yield promiseStartLegacyDownload();
+ } else {
download = yield promiseNewDownload();
yield download.start();
- } else {
- // When testing DownloadLegacySaver, we cannot be sure whether we are
- // testing the promise returned by the "start" method or we are testing
- // the "error" property checked by promiseDownloadStopped. This happens
- // because we don't have control over when the download is started.
- download = yield promiseStartLegacyDownload();
- yield promiseDownloadStopped(download);
+ do_throw("The download should have blocked.");
}
+
+ yield promiseDownloadStopped(download);
do_throw("The download should have blocked.");
} catch (ex if ex instanceof Downloads.Error && ex.becauseBlocked) {
do_check_true(ex.becauseBlockedByReputationCheck);
do_check_true(download.error.becauseBlockedByReputationCheck);
}
+ do_check_true(download.stopped);
+ do_check_false(download.succeeded);
+ do_check_false(yield OS.File.exists(download.target.path));
+
+ cleanup();
+ return download;
+});
+
+/**
+ * Checks that application reputation blocks the download and the target file
+ * does not exist.
+ */
+add_task(function test_blocked_applicationReputation()
+{
+ let download = yield promiseBlockedDownload({
+ keepPartialData: false,
+ keepBlockedData: false,
+ });
+
// Now that the download is blocked, the target file should not exist.
do_check_false(yield OS.File.exists(download.target.path));
- cleanup();
+
+ // There should also be no blocked data in this case
+ do_check_false(download.hasBlockedData);
+});
+
+/**
+ * Checks that application reputation blocks the download but maintains the
+ * blocked data, which will be deleted when the block is confirmed.
+ */
+add_task(function test_blocked_applicationReputation_confirmBlock()
+{
+ let download = yield promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ do_check_true(download.hasBlockedData);
+ do_check_true(yield OS.File.exists(download.target.partFilePath));
+
+ yield download.confirmBlock();
+
+ // After confirming the block the download should be in a failed state and
+ // have no downloaded data left on disk.
+ do_check_true(download.stopped);
+ do_check_false(download.succeeded);
+ do_check_false(download.hasBlockedData);
+ do_check_false(yield OS.File.exists(download.target.partFilePath));
+ do_check_false(yield OS.File.exists(download.target.path));
+});
+
+/**
+ * Checks that application reputation blocks the download but maintains the
+ * blocked data, which will be used to complete the download when unblocking.
+ */
+add_task(function test_blocked_applicationReputation_unblock()
+{
+ let download = yield promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ do_check_true(download.hasBlockedData);
+ do_check_true(yield OS.File.exists(download.target.partFilePath));
+
+ yield download.unblock();
+
+ // After unblocking the download should have succeeded and be
+ // present at the final path.
+ do_check_true(download.stopped);
+ do_check_true(download.succeeded);
+ do_check_false(download.hasBlockedData);
+ do_check_false(yield OS.File.exists(download.target.partFilePath));
+ do_check_true(yield OS.File.exists(download.target.path));
+
+ // The only indication the download was previously blocked is the
+ // existence of the error, so we make sure it's still set.
+ do_check_true(download.error instanceof Downloads.Error);
+ do_check_true(download.error.becauseBlocked);
+ do_check_true(download.error.becauseBlockedByReputationCheck);
+});
+
+/**
+ * Check that calling cancel on a blocked download will not cause errors
+ */
+add_task(function test_blocked_applicationReputation_cancel()
+{
+ let download = yield promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ // This call should succeed on a blocked download.
+ yield download.cancel();
+
+ // Calling cancel should not have changed the current state, the download
+ // should still be blocked.
+ do_check_true(download.error.becauseBlockedByReputationCheck);
+ do_check_true(download.stopped);
+ do_check_false(download.succeeded);
+ do_check_true(download.hasBlockedData);
+});
+
+/**
+ * Checks that unblock and confirmBlock cannot race on a blocked download
+ */
+add_task(function test_blocked_applicationReputation_decisionRace()
+{
+ let download = yield promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ let unblockPromise = download.unblock();
+ let confirmBlockPromise = download.confirmBlock();
+
+ yield confirmBlockPromise.then(() => {
+ do_throw("confirmBlock should have failed.");
+ }, () => {});
+
+ yield unblockPromise;
+
+ // After unblocking the download should have succeeded and be
+ // present at the final path.
+ do_check_true(download.stopped);
+ do_check_true(download.succeeded);
+ do_check_false(download.hasBlockedData);
+ do_check_false(yield OS.File.exists(download.target.partFilePath));
+ do_check_true(yield OS.File.exists(download.target.path));
+
+ download = yield promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ confirmBlockPromise = download.confirmBlock();
+ unblockPromise = download.unblock();
+
+ yield unblockPromise.then(() => {
+ do_throw("unblock should have failed.");
+ }, () => {});
+
+ yield confirmBlockPromise;
+
+ // After confirming the block the download should be in a failed state and
+ // have no downloaded data left on disk.
+ do_check_true(download.stopped);
+ do_check_false(download.succeeded);
+ do_check_false(download.hasBlockedData);
+ do_check_false(yield OS.File.exists(download.target.partFilePath));
+ do_check_false(yield OS.File.exists(download.target.path));
+});
+
+/**
+ * Checks that unblocking a blocked download fails if the blocked data has been
+ * removed.
+ */
+add_task(function test_blocked_applicationReputation_unblock()
+{
+ let download = yield promiseBlockedDownload({
+ keepPartialData: true,
+ keepBlockedData: true,
+ });
+
+ do_check_true(download.hasBlockedData);
+ do_check_true(yield OS.File.exists(download.target.partFilePath));
+
+ // Remove the blocked data without telling the download.
+ yield OS.File.remove(download.target.partFilePath);
+
+ let unblockPromise = download.unblock();
+ yield unblockPromise.then(() => {
+ do_throw("unblock should have failed.");
+ }, () => {});
+
+ // Even though unblocking failed the download state should have been updated
+ // to reflect the lack of blocked data.
+ do_check_false(download.hasBlockedData);
+ do_check_true(download.stopped);
+ do_check_false(download.succeeded);
});
/**