Bug 1201335 - Display notification when a new device is added to Sync account. r?markh draft
authorEdouard Oger <eoger@fastmail.com>
Wed, 04 Jan 2017 19:21:36 -0500
changeset 456368 2d08a54df6b0aa90b9fc238f0062053fac1e8460
parent 456196 f13abb8ba9f366c9f32a3146245adf642528becd
child 541204 75f9c40fce3f69a0f7daf1a72717a7fe06f9f391
push id40473
push userbmo:eoger@fastmail.com
push dateThu, 05 Jan 2017 13:51:24 +0000
reviewersmarkh
bugs1201335
milestone53.0a1
Bug 1201335 - Display notification when a new device is added to Sync account. r?markh MozReview-Commit-ID: D03prgIdL1M
browser/app/profile/firefox.js
browser/components/nsBrowserGlue.js
browser/locales/en-US/chrome/browser/accounts.properties
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsPush.js
services/fxaccounts/moz.build
services/fxaccounts/tests/browser/browser.ini
services/fxaccounts/tests/browser/browser_device_connected.js
services/fxaccounts/tests/browser/head.js
services/fxaccounts/tests/xpcshell/test_push_service.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1294,16 +1294,19 @@ pref("identity.fxaccounts.remote.webchan
 // discovery is enabled.
 pref("identity.fxaccounts.contextParam", "fx_desktop_v3");
 
 // The URL we take the user to when they opt to "manage" their Firefox Account.
 // Note that this will always need to be in the same TLD as the
 // "identity.fxaccounts.remote.signup.uri" pref.
 pref("identity.fxaccounts.settings.uri", "https://accounts.firefox.com/settings?service=sync&context=fx_desktop_v3");
 
+// The URL of the FxA device manager page
+pref("identity.fxaccounts.settings.devices.uri", "https://accounts.firefox.com/settings/clients?service=sync&context=fx_desktop_v3");
+
 // The remote URL of the FxA Profile Server
 pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1");
 
 // The remote URL of the FxA OAuth Server
 pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1");
 
 // Whether we display profile images in the UI or not.
 pref("identity.fxaccounts.profile_image.enabled", true);
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -246,16 +246,19 @@ BrowserGlue.prototype = {
         }
         break;
       case "weave:service:ready":
         this._setSyncAutoconnectDelay();
         break;
       case "fxaccounts:onverified":
         this._showSyncStartedDoorhanger();
         break;
+      case "fxaccounts:device_connected":
+        this._onDeviceConnected(data);
+        break;
       case "fxaccounts:device_disconnected":
         this._onDeviceDisconnected();
         break;
       case "weave:engine:clients:display-uris":
         this._onDisplaySyncURIs(subject);
         break;
       case "session-save":
         this._setPrefToSaveSession(true);
@@ -403,16 +406,17 @@ BrowserGlue.prototype = {
     os.addObserver(this, "quit-application-requested", false);
     os.addObserver(this, "quit-application-granted", false);
     if (OBSERVE_LASTWINDOW_CLOSE_TOPICS) {
       os.addObserver(this, "browser-lastwindow-close-requested", false);
       os.addObserver(this, "browser-lastwindow-close-granted", false);
     }
     os.addObserver(this, "weave:service:ready", false);
     os.addObserver(this, "fxaccounts:onverified", false);
+    os.addObserver(this, "fxaccounts:device_connected", false);
     os.addObserver(this, "fxaccounts:device_disconnected", false);
     os.addObserver(this, "weave:engine:clients:display-uris", false);
     os.addObserver(this, "session-save", false);
     os.addObserver(this, "places-init-complete", false);
     this._isPlacesInitObserver = true;
     os.addObserver(this, "places-database-locked", false);
     this._isPlacesLockedObserver = true;
     os.addObserver(this, "distribution-customization-complete", false);
@@ -455,16 +459,17 @@ BrowserGlue.prototype = {
     os.removeObserver(this, "quit-application-granted");
     os.removeObserver(this, "restart-in-safe-mode");
     if (OBSERVE_LASTWINDOW_CLOSE_TOPICS) {
       os.removeObserver(this, "browser-lastwindow-close-requested");
       os.removeObserver(this, "browser-lastwindow-close-granted");
     }
     os.removeObserver(this, "weave:service:ready");
     os.removeObserver(this, "fxaccounts:onverified");
+    os.removeObserver(this, "fxaccounts:device_connected");
     os.removeObserver(this, "fxaccounts:device_disconnected");
     os.removeObserver(this, "weave:engine:clients:display-uris");
     os.removeObserver(this, "session-save");
     if (this._bookmarksBackupIdleTime) {
       this._idleService.removeIdleObserver(this, this._bookmarksBackupIdleTime);
       delete this._bookmarksBackupIdleTime;
     }
     if (this._isPlacesInitObserver)
@@ -2325,16 +2330,42 @@ BrowserGlue.prototype = {
         imageURL = "chrome://branding/content/icon64.png";
       }
       AlertsService.showAlertNotification(imageURL, title, body, true, null, clickCallback);
     } catch (ex) {
       Cu.reportError("Error displaying tab(s) received by Sync: " + ex);
     }
   },
 
+  _onDeviceConnected(deviceName) {
+    let accountsBundle = Services.strings.createBundle(
+      "chrome://browser/locale/accounts.properties"
+    );
+    let title = accountsBundle.GetStringFromName("deviceConnectedTitle");
+    let body = accountsBundle.formatStringFromName("deviceConnectedBody", [deviceName], 1);
+    let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.devices.uri");
+
+    function clickCallback(subject, topic, data) {
+      if (topic != "alertclickcallback")
+        return;
+      let win = RecentWindow.getMostRecentBrowserWindow({private: false});
+      if (!win) {
+        Services.appShell.hiddenDOMWindow.open(url);
+      } else {
+        win.gBrowser.addTab(url);
+      }
+    }
+
+    try {
+      AlertsService.showAlertNotification(null, title, body, true, url, clickCallback);
+    } catch (ex) {
+      Cu.reportError("Error notifying of a new Sync device: " + ex);
+    }
+  },
+
   _onDeviceDisconnected() {
     let bundle = Services.strings.createBundle("chrome://browser/locale/accounts.properties");
     let title = bundle.GetStringFromName("deviceDisconnectedNotification.title");
     let body = bundle.GetStringFromName("deviceDisconnectedNotification.body");
 
     let clickCallback = (subject, topic, data) => {
       if (topic != "alertclickcallback")
         return;
--- a/browser/locales/en-US/chrome/browser/accounts.properties
+++ b/browser/locales/en-US/chrome/browser/accounts.properties
@@ -19,16 +19,21 @@ verifyDescription = Verify %S
 # These strings are shown in a desktop notification after the
 # user requests we resend a verification email.
 verificationSentTitle = Verification Sent
 # LOCALIZATION NOTE (verificationSentBody) - %S = Email address of user's Firefox Account
 verificationSentBody = A verification link has been sent to %S.
 verificationNotSentTitle = Unable to Send Verification
 verificationNotSentBody = We are unable to send a verification mail at this time, please try again later.
 
+# LOCALIZATION NOTE (deviceConnectedTitle, deviceConnectedBody)
+# These strings are used in a notification shown when a new device joins the Sync account.
+deviceConnectedTitle = Firefox Sync
+deviceConnectedBody = This computer is now syncing with %S.
+
 # LOCALIZATION NOTE (syncStartNotification.title, syncStartNotification.body)
 # These strings are used in a notification shown after Sync is connected.
 syncStartNotification.title = Sync enabled
 # %S is brandShortName
 syncStartNotification.body2 = %S will begin syncing momentarily.
 
 # LOCALIZATION NOTE (deviceDisconnectedNotification.title, deviceDisconnectedNotification.body)
 # These strings are used in a notification shown after Sync was disconnected remotely.
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -84,16 +84,17 @@ exports.KEY_LIFETIME       = 1000 * 3600
 exports.POLL_SESSION       = 1000 * 60 * 20;   // 20 minutes
 
 // Observer notifications.
 exports.ONLOGIN_NOTIFICATION = "fxaccounts:onlogin";
 exports.ONVERIFIED_NOTIFICATION = "fxaccounts:onverified";
 exports.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
 // Internal to services/fxaccounts only
 exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update";
+exports.ON_DEVICE_CONNECTED_NOTIFICATION = "fxaccounts:device_connected";
 exports.ON_DEVICE_DISCONNECTED_NOTIFICATION = "fxaccounts:device_disconnected";
 exports.ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed";
 exports.ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset";
 exports.ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed";
 
 exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
 
 exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange";
--- a/services/fxaccounts/FxAccountsPush.js
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -159,16 +159,19 @@ FxAccountsPushService.prototype = {
     if (!message.data) {
       // Use the empty signal to check the verification state of the account right away
       this.log.debug("empty push message - checking account status");
       return this.fxAccounts.checkVerificationStatus();
     }
     let payload = message.data.json();
     this.log.debug(`push command: ${payload.command}`);
     switch (payload.command) {
+      case ON_DEVICE_CONNECTED_NOTIFICATION:
+        Services.obs.notifyObservers(null, ON_DEVICE_CONNECTED_NOTIFICATION, payload.data.deviceName);
+        break;
       case ON_DEVICE_DISCONNECTED_NOTIFICATION:
         return this.fxAccounts.handleDeviceDisconnection(payload.data.id);
         break;
       case ON_PASSWORD_CHANGED_NOTIFICATION:
       case ON_PASSWORD_RESET_NOTIFICATION:
         return this._onPasswordChanged();
         break;
       case ON_COLLECTION_CHANGED_NOTIFICATION:
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -3,16 +3,18 @@
 # 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/.
 
 DIRS += ['interfaces']
 
 MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
 
+BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini']
+
 XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
 
 EXTRA_COMPONENTS += [
   'FxAccountsComponents.manifest',
   'FxAccountsPush.js',
 ]
 
 EXTRA_JS_MODULES += [
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/browser/browser.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+support-files =
+  head.js
+[browser_device_connected.js]
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/browser/browser_device_connected.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const { MockRegistrar } =
+  Cu.import("resource://testing-common/MockRegistrar.jsm", {});
+
+const StubAlertsService = {
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIAlertsService, Ci.nsISupports]),
+
+  showAlertNotification(image, title, text, clickable, cookie, clickCallback) {
+    // We can't simulate a click on the alert popup,
+    // so instead we call the click listener ourselves directly
+    clickCallback.observe.call(clickCallback, null, "alertclickcallback", null);
+  }
+}
+
+add_task(function*() {
+  gBrowser.selectedBrowser.loadURI("about:robots");
+  yield waitForDocLoadComplete();
+
+  MockRegistrar.register("@mozilla.org/alerts-service;1", StubAlertsService);
+  Preferences.set("identity.fxaccounts.settings.devices.uri", "http://localhost/devices");
+
+  let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+  Services.obs.notifyObservers(null, "fxaccounts:device_connected", "My phone");
+
+  let tab = yield waitForTabPromise;
+  Assert.ok("Tab successfully opened");
+
+  let expectedURI = Preferences.get("identity.fxaccounts.settings.devices.uri",
+                                    "prefundefined");
+  Assert.equal(tab.linkedBrowser.currentURI.spec, expectedURI);
+
+  yield BrowserTestUtils.removeTab(tab);
+});
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/browser/head.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Waits for the next load to complete in any browser or the given browser.
+ * If a <tabbrowser> is given it waits for a load in any of its browsers.
+ *
+ * @return promise
+ */
+function waitForDocLoadComplete(aBrowser = gBrowser) {
+  return new Promise(resolve => {
+    let listener = {
+      onStateChange(webProgress, req, flags, status) {
+        let docStop = Ci.nsIWebProgressListener.STATE_IS_NETWORK |
+                      Ci.nsIWebProgressListener.STATE_STOP;
+        info("Saw state " + flags.toString(16) + " and status " + status.toString(16));
+
+        // When a load needs to be retargetted to a new process it is cancelled
+        // with NS_BINDING_ABORTED so ignore that case
+        if ((flags & docStop) == docStop && status != Cr.NS_BINDING_ABORTED) {
+          aBrowser.removeProgressListener(this);
+          waitForDocLoadComplete.listeners.delete(this);
+
+          let chan = req.QueryInterface(Ci.nsIChannel);
+          info("Browser loaded " + chan.originalURI.spec);
+          resolve();
+        }
+      },
+      QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
+                                             Ci.nsISupportsWeakReference])
+    };
+    aBrowser.addProgressListener(listener);
+    waitForDocLoadComplete.listeners.add(listener);
+    info("Waiting for browser load");
+  });
+}
+
+// Keep a set of progress listeners for waitForDocLoadComplete() to make sure
+// they're not GC'ed before we saw the page load.
+waitForDocLoadComplete.listeners = new Set();
+registerCleanupFunction(() => waitForDocLoadComplete.listeners.clear());
--- a/services/fxaccounts/tests/xpcshell/test_push_service.js
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -138,16 +138,44 @@ add_test(function observePushTopicVerify
   let pushService = new FxAccountsPushService({
     pushService: mockPushService,
     fxAccounts: customAccounts,
   });
 
   pushService.observe(emptyMsg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
 });
 
+add_test(function observePushTopicDeviceConnected() {
+  let msg = {
+    data: {
+      json: () => ({
+        command: ON_DEVICE_CONNECTED_NOTIFICATION,
+        data: {
+          deviceName: "My phone"
+        }
+      })
+    },
+    QueryInterface: function() {
+      return this;
+    }
+  };
+  let obs = (subject, topic, data) => {
+    Services.obs.removeObserver(obs, topic);
+    run_next_test();
+  };
+  Services.obs.addObserver(obs, ON_DEVICE_CONNECTED_NOTIFICATION, false);
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    fxAccounts: mockFxAccounts,
+  });
+
+  pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
 add_test(function observePushTopicDeviceDisconnected() {
   const deviceId = "bogusid";
   let msg = {
     data: {
       json: () => ({
         command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
         data: {
           id: deviceId