browser/components/loop/modules/MozLoopService.jsm
author Matthew Noorenberghe <mozilla@noorenberghe.ca>
Mon, 08 Jun 2015 16:18:00 -0700
changeset 247723 84d422afe66ddeb2206a77d0748c17ba50dd77a0
parent 245059 bc4fc60d575935934f8acc4b504518a907541650
child 248478 56378c101d300fbdaa8ca570d89394b82e675e26
permissions -rw-r--r--
Bug 1172141 - Use the maxLogLevelPref ConsoleAPI option for Loop and UITour to make logging preferences live. r=bgrins

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

// Invalid auth token as per
// https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
const INVALID_AUTH_TOKEN = 110;

const LOOP_SESSION_TYPE = {
  GUEST: 1,
  FXA: 2
};

/***
 * Values that we segment 2-way media connection length telemetry probes
 * into.
 *
 * @type {{SHORTER_THAN_10S: Number, BETWEEN_10S_AND_30S: Number,
 *   BETWEEN_30S_AND_5M: Number, MORE_THAN_5M: Number}}
 */
const TWO_WAY_MEDIA_CONN_LENGTH = {
  SHORTER_THAN_10S: 0,
  BETWEEN_10S_AND_30S: 1,
  BETWEEN_30S_AND_5M: 2,
  MORE_THAN_5M: 3
};

/**
 * Values that we segment sharing state change telemetry probes into.
 *
 * @type {{WINDOW_ENABLED: Number, WINDOW_DISABLED: Number,
 *   BROWSER_ENABLED: Number, BROWSER_DISABLED: Number}}
 */
const SHARING_STATE_CHANGE = {
  WINDOW_ENABLED: 0,
  WINDOW_DISABLED: 1,
  BROWSER_ENABLED: 2,
  BROWSER_DISABLED: 3
};

// See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
const PREF_LOG_LEVEL = "loop.debug.loglevel";

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/osfile.jsm", this);
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");

Cu.importGlobalProperties(["URL"]);

this.EXPORTED_SYMBOLS = ["MozLoopService", "LOOP_SESSION_TYPE",
  "TWO_WAY_MEDIA_CONN_LENGTH", "SHARING_STATE_CHANGE"];

XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI",
  "resource:///modules/loop/MozLoopAPI.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "convertToRTCStatsReport",
  "resource://gre/modules/media/RTCStatsReport.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "loopUtils",
  "resource:///modules/loop/utils.js", "utils");
XPCOMUtils.defineLazyModuleGetter(this, "loopCrypto",
  "resource:///modules/loop/crypto.js", "LoopCrypto");

XPCOMUtils.defineLazyModuleGetter(this, "Chat", "resource:///modules/Chat.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
                                  "resource://services-common/utils.js");

XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
                                  "resource://services-crypto/utils.js");

XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient",
                                  "resource://gre/modules/FxAccountsProfileClient.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "HawkClient",
                                  "resource://services-common/hawkclient.js");

XPCOMUtils.defineLazyModuleGetter(this, "deriveHawkCredentials",
                                  "resource://services-common/hawkrequest.js");

XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
                                  "resource:///modules/loop/LoopContacts.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                  "resource:///modules/loop/LoopStorage.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "LoopCalls",
                                  "resource:///modules/loop/LoopCalls.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "LoopRooms",
                                  "resource:///modules/loop/LoopRooms.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "roomsPushNotification",
                                  "resource:///modules/loop/LoopRooms.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
                                  "resource:///modules/loop/MozLoopPushHandler.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "UITour",
                                  "resource:///modules/UITour.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
                                   "@mozilla.org/uuid-generator;1",
                                   "nsIUUIDGenerator");

XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
                                   "@mozilla.org/network/dns-service;1",
                                   "nsIDNSService");

XPCOMUtils.defineLazyServiceGetter(this, "gWM",
                                   "@mozilla.org/appshell/window-mediator;1",
                                   "nsIWindowMediator");

// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
XPCOMUtils.defineLazyGetter(this, "log", () => {
  let ConsoleAPI = Cu.import("resource://gre/modules/devtools/Console.jsm", {}).ConsoleAPI;
  let consoleOptions = {
    maxLogLevelPref: PREF_LOG_LEVEL,
    prefix: "Loop"
  };
  return new ConsoleAPI(consoleOptions);
});

function setJSONPref(aName, aValue) {
  let value = !!aValue ? JSON.stringify(aValue) : "";
  Services.prefs.setCharPref(aName, value);
}

function getJSONPref(aName) {
  let value = Services.prefs.getCharPref(aName);
  return !!value ? JSON.parse(value) : null;
}

let gHawkClient = null;
let gLocalizedStrings = new Map();
let gFxAEnabled = true;
let gFxAOAuthClientPromise = null;
let gFxAOAuthClient = null;
let gErrors = new Map();
let gLastWindowId = 0;
let gConversationWindowData = new Map();

/**
 * Internal helper methods and state
 *
 * The registration is a two-part process. First we need to connect to
 * and register with the push server. Then we need to take the result of that
 * and register with the Loop server.
 */
let MozLoopServiceInternal = {
  conversationContexts: new Map(),
  pushURLs: new Map(),

  mocks: {
    pushHandler: undefined
  },

  /**
   * The current deferreds for the registration processes. This is set if in progress
   * or the registration was successful. This is null if a registration attempt was
   * unsuccessful.
   */
  deferredRegistrations: new Map(),

  get pushHandler() {
    return this.mocks.pushHandler || MozLoopPushHandler;
  },

  // The uri of the Loop server.
  get loopServerUri() {
    return Services.prefs.getCharPref("loop.server");
  },

  /**
   * The initial delay for push registration. This ensures we don't start
   * kicking off straight after browser startup, just a few seconds later.
   */
  get initialRegistrationDelayMilliseconds() {
    try {
      // Let a pref override this for developer & testing use.
      return Services.prefs.getIntPref("loop.initialDelay");
    } catch (x) {
      // Default to 5 seconds
      return 5000;
    }
  },

  /**
   * Retrieves MozLoopService Firefox Accounts OAuth token.
   *
   * @return {Object} OAuth token
   */
  get fxAOAuthTokenData() {
    return getJSONPref("loop.fxa_oauth.tokendata");
  },

  /**
   * Sets MozLoopService Firefox Accounts OAuth token.
   * If the tokenData is being cleared, will also clear the
   * profile since the profile is dependent on the token data.
   *
   * @param {Object} aTokenData OAuth token
   */
  set fxAOAuthTokenData(aTokenData) {
    setJSONPref("loop.fxa_oauth.tokendata", aTokenData);
    if (!aTokenData) {
      this.fxAOAuthProfile = null;
    }
  },

  /**
   * Sets MozLoopService Firefox Accounts Profile data.
   *
   * @param {Object} aProfileData Profile data
   */
  set fxAOAuthProfile(aProfileData) {
    setJSONPref("loop.fxa_oauth.profile", aProfileData);
    this.notifyStatusChanged(aProfileData ? "login" : undefined);
  },

  /**
   * Retrieves MozLoopService "do not disturb" pref value.
   *
   * @return {Boolean} aFlag
   */
  get doNotDisturb() {
    return Services.prefs.getBoolPref("loop.do_not_disturb");
  },

  /**
   * Sets MozLoopService "do not disturb" pref value.
   *
   * @param {Boolean} aFlag
   */
  set doNotDisturb(aFlag) {
    Services.prefs.setBoolPref("loop.do_not_disturb", Boolean(aFlag));
    this.notifyStatusChanged();
  },

  notifyStatusChanged: function(aReason = null) {
    log.debug("notifyStatusChanged with reason:", aReason);
    let profile = MozLoopService.userProfile;
    LoopStorage.switchDatabase(profile && profile.uid);
    LoopRooms.maybeRefresh(profile && profile.uid);
    Services.obs.notifyObservers(null, "loop-status-changed", aReason);
  },

  /**
   * Record an error and notify interested UI with the relevant user-facing strings attached.
   *
   * @param {String} errorType a key to identify the type of error. Only one
   *                           error of a type will be saved at a time. This value may be used to
   *                           determine user-facing (aka. friendly) strings.
   * @param {Object} error     an object describing the error in the format from Hawk errors
   * @param {Function} [actionCallback] an object describing the label and callback function for error
   *                                    bar's button e.g. to retry.
   */
  setError: function(errorType, error, actionCallback = null) {
    log.debug("setError", errorType, error);
    log.trace();
    let messageString, detailsString, detailsButtonLabelString, detailsButtonCallback;
    const NETWORK_ERRORS = [
      Cr.NS_ERROR_CONNECTION_REFUSED,
      Cr.NS_ERROR_NET_INTERRUPT,
      Cr.NS_ERROR_NET_RESET,
      Cr.NS_ERROR_NET_TIMEOUT,
      Cr.NS_ERROR_OFFLINE,
      Cr.NS_ERROR_PROXY_CONNECTION_REFUSED,
      Cr.NS_ERROR_UNKNOWN_HOST,
      Cr.NS_ERROR_UNKNOWN_PROXY_HOST
    ];

    if (error.code === null && error.errno === null &&
        error.error instanceof Ci.nsIException &&
        NETWORK_ERRORS.indexOf(error.error.result) != -1) {
      // Network error. Override errorType so we can easily clear it on the next succesful request.
      errorType = "network";
      messageString = "could_not_connect";
      detailsString = "check_internet_connection";
      detailsButtonLabelString = "retry_button";
    } else if (errorType == "profile" && error.code >= 500 && error.code < 600) {
      messageString = "problem_accessing_account";
    } else if (error.code == 401) {
      if (errorType == "login") {
        messageString = "could_not_authenticate"; // XXX: Bug 1076377
        detailsString = "password_changed_question";
        detailsButtonLabelString = "retry_button";
        detailsButtonCallback = () => MozLoopService.logInToFxA();
      } else {
        messageString = "session_expired_error_description";
      }
    } else if (error.code >= 500 && error.code < 600) {
      messageString = "service_not_available";
      detailsString = "try_again_later";
      detailsButtonLabelString = "retry_button";
    } else {
      messageString = "generic_failure_title";
    }

    error.friendlyMessage = this.localizedStrings.get(messageString);

    // Default to the generic "retry_button" text even though the button won't be shown if
    // error.friendlyDetails is null.
    error.friendlyDetailsButtonLabel = detailsButtonLabelString ?
                                         this.localizedStrings.get(detailsButtonLabelString) :
                                         this.localizedStrings.get("retry_button");

    error.friendlyDetailsButtonCallback = actionCallback || detailsButtonCallback || null;

    if (detailsString) {
      error.friendlyDetails = this.localizedStrings.get(detailsString);
    } else if (error.friendlyDetailsButtonCallback) {
      // If we have a retry callback but no details use the generic try again string.
      error.friendlyDetails = this.localizedStrings.get("generic_failure_no_reason2");
    } else {
      error.friendlyDetails = null;
    }

    gErrors.set(errorType, error);
    this.notifyStatusChanged();
  },

  clearError: function(errorType) {
    if (gErrors.has(errorType)) {
      gErrors.delete(errorType);
      this.notifyStatusChanged();
    }
  },

  get errors() {
    return gErrors;
  },

  /**
   * Create a notification channel between the LoopServer and this client
   * via a PushServer. Once created, any subsequent changes in the pushURL
   * assigned by the PushServer will be communicated to the LoopServer.
   * with the Loop server. It will return early if already registered.
   *
   * @param {String} channelID Unique identifier for the notification channel
   *                 registered with the PushServer.
   * @param {LOOP_SESSION_TYPE} sessionType
   * @param {String} serviceType Either 'calls' or 'rooms'.
   * @param {Function} onNotification Callback function that will be associated
   *                   with this channel from the PushServer.
   * @returns {Promise} A promise that is resolved with no params on completion, or
   *                    rejected with an error code or string.
   */
  createNotificationChannel: function(channelID, sessionType, serviceType, onNotification) {
    log.debug("createNotificationChannel", channelID, sessionType, serviceType);
    // Wrap the push notification registration callback in a Promise.
    return new Promise((resolve, reject) => {
      let onRegistered = (error, pushURL, channelID) => {
        log.debug("createNotificationChannel onRegistered:", error, pushURL, channelID);
        if (error) {
          reject(Error(error));
        } else {
          resolve(this.registerWithLoopServer(sessionType, serviceType, pushURL));
        }
      };

      this.pushHandler.register(channelID, onRegistered, onNotification);
    });
  },

  /**
   * Starts registration of Loop with the PushServer and the LoopServer.
   * Successful PushServer registration will automatically trigger the registration
   * of the PushURL returned by the PushServer with the LoopServer. If the registration
   * chain has already been set up, this function will simply resolve.
   *
   * @param {LOOP_SESSION_TYPE} sessionType
   * @returns {Promise} a promise that is resolved with no params on completion, or
   *          rejected with an error code or string.
   */
  promiseRegisteredWithServers: function(sessionType = LOOP_SESSION_TYPE.GUEST) {
    if (sessionType !== LOOP_SESSION_TYPE.GUEST && sessionType !== LOOP_SESSION_TYPE.FXA) {
      return Promise.reject(new Error("promiseRegisteredWithServers: Invalid sessionType"));
    }

    if (this.deferredRegistrations.has(sessionType)) {
      log.debug("promiseRegisteredWithServers: registration already completed or in progress:",
                sessionType);
      return this.deferredRegistrations.get(sessionType);
    }

    let options = this.mocks.webSocket ? { mockWebSocket: this.mocks.webSocket } : {};
    this.pushHandler.initialize(options); // This can be called more than once.

    let regPromise;
    if (sessionType == LOOP_SESSION_TYPE.GUEST) {
      regPromise = this.createNotificationChannel(
        MozLoopService.channelIDs.roomsGuest, sessionType, "rooms",
        roomsPushNotification);
    } else {
      regPromise = this.createNotificationChannel(
        MozLoopService.channelIDs.callsFxA, sessionType, "calls",
        LoopCalls.onNotification).then(() => {
          return this.createNotificationChannel(
            MozLoopService.channelIDs.roomsFxA, sessionType, "rooms",
            roomsPushNotification);
        });
    }

    log.debug("assigning to deferredRegistrations for sessionType:", sessionType);
    this.deferredRegistrations.set(sessionType, regPromise);

    // Do not return the new Promise generated by this catch() invocation.
    // This will be called along with any other onReject function attached to regPromise.
    regPromise.catch((error) => {
      log.error("Failed to register with Loop server with sessionType ", sessionType, error);
      this.deferredRegistrations.delete(sessionType);
      log.debug("Cleared deferredRegistration for sessionType:", sessionType);
    });

    return regPromise;
  },

  /**
   * Registers with the Loop server either as a guest or a FxA user.
   *
   * @private
   * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
   * @param {String} serviceType: "rooms" or "calls"
   * @param {Boolean} [retry=true] Whether to retry if authentication fails.
   * @return {Promise} resolves to pushURL or rejects with an Error
   */
  registerWithLoopServer: function(sessionType, serviceType, pushURL, retry = true) {
    log.debug("registerWithLoopServer with sessionType:", sessionType, serviceType, retry);
    if (!pushURL || !sessionType || !serviceType) {
      return Promise.reject(new Error("Invalid or missing parameters for registerWithLoopServer"));
    }

    let pushURLs = this.pushURLs.get(sessionType);

    // Create a blank URL record set if none exists for this sessionType.
    if (!pushURLs) {
      pushURLs = { calls: undefined, rooms: undefined };
      this.pushURLs.set(sessionType, pushURLs);
    }

    if (pushURLs[serviceType] == pushURL) {
      return Promise.resolve(pushURL);
    }

    let newURLs = {calls: pushURLs.calls,
                   rooms: pushURLs.rooms};
    newURLs[serviceType] = pushURL;

    return this.hawkRequestInternal(sessionType, "/registration", "POST",
                                    { simplePushURLs: newURLs }).then(
      (response) => {
        // If this failed we got an invalid token.
        if (!this.storeSessionToken(sessionType, response.headers)) {
          throw new Error("session-token-wrong-size");
        }

        // Record the new push URL
        pushURLs[serviceType] = pushURL;
        log.debug("Successfully registered with server for sessionType", sessionType);
        this.clearError("registration");
        return pushURL;
      }, (error) => {
        // There's other errors than invalid auth token, but we should only do the reset
        // as a last resort.
        if (error.code === 401) {
          // Authorization failed, invalid token, we need to try again with a new token.
          // XXX (pkerr) - Why is there a retry here? This will not clear up a hawk session
          // token problem at this level.
          if (retry) {
            return this.registerWithLoopServer(sessionType, serviceType, pushURL, false);
          }
        }

        log.error("Failed to register with the loop server. Error: ", error);
        throw error;
      }
    );
  },

  /**
   * Unregisters from the Loop server either as a guest or a FxA user.
   *
   * This is normally only wanted for FxA users as we normally want to keep the
   * guest session with the device.
   *
   * NOTE: It is the responsibiliy of the caller the clear the session token
   * after all of the notification classes: calls and rooms, for either
   * Guest or FxA have been unregistered with the LoopServer.
   *
   * @param {LOOP_SESSION_TYPE} sessionType The type of session e.g. guest or FxA
   * @return {Promise} resolving when the unregistration request finishes
   */
  unregisterFromLoopServer: function(sessionType) {
    let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(sessionType));
    if (prefType == Services.prefs.PREF_INVALID) {
      log.debug("already unregistered from LoopServer", sessionType);
      return Promise.resolve("already unregistered");
    }

    let error,
        pushURLs = this.pushURLs.get(sessionType),
        callsPushURL = pushURLs ? pushURLs.calls : null,
        roomsPushURL = pushURLs ? pushURLs.rooms : null;
    this.pushURLs.delete(sessionType);

    let unregister = (sessionType, pushURL) => {
      if (!pushURL) {
        return Promise.resolve("no pushURL of this type to unregister");
      }

      let unregisterURL = "/registration?simplePushURL=" + encodeURIComponent(pushURL);
      return this.hawkRequestInternal(sessionType, unregisterURL, "DELETE").then(
        () => {
          log.debug("Successfully unregistered from server for sessionType = ", sessionType);
          return "unregistered sessionType " + sessionType;
        },
        error => {
          if (error.code === 401) {
            // Authorization failed, invalid token. This is fine since it may mean we already logged out.
            log.debug("already unregistered - invalid token", sessionType);
            return "already unregistered, sessionType = " + sessionType;
          }

          log.error("Failed to unregister with the loop server. Error: ", error);
          throw error;
        });
    };

    return Promise.all([unregister(sessionType, callsPushURL), unregister(sessionType, roomsPushURL)]);
  },

  /**
   * Performs a hawk based request to the loop server - there is no pre-registration
   * for this request, if this is required, use hawkRequest.
   *
   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
   *                                        This is one of the LOOP_SESSION_TYPE members.
   * @param {String} path The path to make the request to.
   * @param {String} method The request method, e.g. 'POST', 'GET'.
   * @param {Object} payloadObj An object which is converted to JSON and
   *                            transmitted with the request.
   * @param {Boolean} [retryOn401=true] Whether to retry if authentication fails.
   * @returns {Promise}
   *        Returns a promise that resolves to the response of the API call,
   *        or is rejected with an error.  If the server response can be parsed
   *        as JSON and contains an 'error' property, the promise will be
   *        rejected with this JSON-parsed response.
   */
  hawkRequestInternal: function(sessionType, path, method, payloadObj, retryOn401 = true) {
    log.debug("hawkRequestInternal: ", sessionType, path, method);
    if (!gHawkClient) {
      gHawkClient = new HawkClient(this.loopServerUri);
    }

    let sessionToken, credentials;
    try {
      sessionToken = Services.prefs.getCharPref(this.getSessionTokenPrefName(sessionType));
    } catch (x) {
      // It is ok for this not to exist, we'll default to sending no-creds
    }

    if (sessionToken) {
      // true = use a hex key, as required by the server (see bug 1032738).
      credentials = deriveHawkCredentials(sessionToken, "sessionToken",
                                          2 * 32, true);
    }

    if (payloadObj) {
      // Note: we must copy the object rather than mutate it, to avoid
      // mutating the values of the object passed in.
      let newPayloadObj = {};
      for (let property of Object.getOwnPropertyNames(payloadObj)) {
        if (typeof payloadObj[property] == "string") {
          newPayloadObj[property] = CommonUtils.encodeUTF8(payloadObj[property]);
        } else {
          newPayloadObj[property] = payloadObj[property];
        }
      }
      payloadObj = newPayloadObj;
    }

    let handle401Error = (error) => {
      if (sessionType === LOOP_SESSION_TYPE.FXA) {
        return MozLoopService.logOutFromFxA().then(() => {
          // Set a user-visible error after logOutFromFxA clears existing ones.
          this.setError("login", error);
          throw error;
        });
      }
      this.setError("registration", error);
      throw error;
    };

    return gHawkClient.request(path, method, credentials, payloadObj).then(
      (result) => {
        this.clearError("network");
        return result;
      },
      (error) => {
      if (error.code && error.code == 401) {
        this.clearSessionToken(sessionType);
        if (retryOn401 && sessionType === LOOP_SESSION_TYPE.GUEST) {
          log.info("401 and INVALID_AUTH_TOKEN - retry registration");
          return this.registerWithLoopServer(sessionType, false).then(
            () => {
              return this.hawkRequestInternal(sessionType, path, method, payloadObj, false);
            },
            () => {
              return handle401Error(error); //Process the original error that triggered the retry.
            }
          );
        }
        return handle401Error(error);
      }
      throw error;
    });
  },

  /**
   * Performs a hawk based request to the loop server, registering if necessary.
   *
   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
   *                                        This is one of the LOOP_SESSION_TYPE members.
   * @param {String} path The path to make the request to.
   * @param {String} method The request method, e.g. 'POST', 'GET'.
   * @param {Object} payloadObj An object which is converted to JSON and
   *                            transmitted with the request.
   * @returns {Promise}
   *        Returns a promise that resolves to the response of the API call,
   *        or is rejected with an error.  If the server response can be parsed
   *        as JSON and contains an 'error' property, the promise will be
   *        rejected with this JSON-parsed response.
   */
  hawkRequest: function(sessionType, path, method, payloadObj) {
    log.debug("hawkRequest: " + path, sessionType);
    return new Promise((resolve, reject) => {
      MozLoopService.promiseRegisteredWithServers(sessionType).then(() => {
        this.hawkRequestInternal(sessionType, path, method, payloadObj).then(resolve, reject);
      }, err => {
        reject(err);
      }).catch(reject);
    });
  },

  /**
   * Generic hawkRequest onError handler for the hawkRequest promise.
   *
   * @param {Object} error - error reporting object
   *
   */

  _hawkRequestError: function(error) {
    log.error("Loop hawkRequest error:", error);
    throw error;
  },

  getSessionTokenPrefName: function(sessionType) {
    let suffix;
    switch (sessionType) {
      case LOOP_SESSION_TYPE.GUEST:
        suffix = "";
        break;
      case LOOP_SESSION_TYPE.FXA:
        suffix = ".fxa";
        break;
      default:
        throw new Error("Unknown LOOP_SESSION_TYPE");
    }
    return "loop.hawk-session-token" + suffix;
  },

  /**
   * Used to store a session token from a request if it exists in the headers.
   *
   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
   *                                        One of the LOOP_SESSION_TYPE members.
   * @param {Object} headers The request headers, which may include a
   *                         "hawk-session-token" to be saved.
   * @return true on success or no token, false on failure.
   */
  storeSessionToken: function(sessionType, headers) {
    let sessionToken = headers["hawk-session-token"];
    if (sessionToken) {
      // XXX should do more validation here
      if (sessionToken.length === 64) {
        Services.prefs.setCharPref(this.getSessionTokenPrefName(sessionType), sessionToken);
        log.debug("Stored a hawk session token for sessionType", sessionType);
      } else {
        // XXX Bubble the precise details up to the UI somehow (bug 1013248).
        log.warn("Loop server sent an invalid session token");
        return false;
      }
    }
    return true;
  },

  /**
   * Clear the loop session token so we don't use it for Hawk Requests anymore.
   *
   * This should normally be used after unregistering with the server so it can
   * clean up session state first.
   *
   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
   *                                        One of the LOOP_SESSION_TYPE members.
   */
  clearSessionToken: function(sessionType) {
    Services.prefs.clearUserPref(this.getSessionTokenPrefName(sessionType));
    log.debug("Cleared hawk session token for sessionType", sessionType);
  },

  /**
   * A getter to obtain and store the strings for loop. This is structured
   * for use by l10n.js.
   *
   * @returns {Map} a map of element ids with localized string values
   */
  get localizedStrings() {
    if (gLocalizedStrings.size) {
      return gLocalizedStrings;
    }

    let stringBundle =
      Services.strings.createBundle("chrome://browser/locale/loop/loop.properties");

    let enumerator = stringBundle.getSimpleEnumeration();
    while (enumerator.hasMoreElements()) {
      let string = enumerator.getNext().QueryInterface(Ci.nsIPropertyElement);
      gLocalizedStrings.set(string.key, string.value);
    }
    // Supply the strings from the branding bundle on a per-need basis.
    let brandBundle =
      Services.strings.createBundle("chrome://branding/locale/brand.properties");
    // Unfortunately the `brandShortName` string is used by Loop with a lowercase 'N'.
    gLocalizedStrings.set("brandShortname", brandBundle.GetStringFromName("brandShortName"));

    return gLocalizedStrings;
  },

  /**
   * Saves loop logs to the saved-telemetry-pings folder.
   *
   * @param {Object} pc The peerConnection in question.
   */
  stageForTelemetryUpload: function(window, pc) {
    window.WebrtcGlobalInformation.getAllStats(allStats => {
      let internalFormat = allStats.reports[0]; // filtered on pc.id
      window.WebrtcGlobalInformation.getLogging("", logs => {
        let report = convertToRTCStatsReport(internalFormat);
        let logStr = "";
        logs.forEach(s => { logStr += s + "\n"; });

        // We have stats and logs.

        // Create worker job. ping = saved telemetry ping file header + payload
        //
        // Prepare payload according to https://wiki.mozilla.org/Loop/Telemetry

        let ai = Services.appinfo;
        let uuid = uuidgen.generateUUID().toString();
        uuid = uuid.substr(1, uuid.length - 2); // remove uuid curly braces

        let directory = OS.Path.join(OS.Constants.Path.profileDir,
                                     "saved-telemetry-pings");
        let job = {
          directory: directory,
          filename: uuid + ".json",
          ping: {
            reason: "loop",
            slug: uuid,
            payload: {
              ver: 1,
              info: {
                appUpdateChannel: ai.defaultUpdateChannel,
                appBuildID: ai.appBuildID,
                appName: ai.name,
                appVersion: ai.version,
                reason: "loop",
                OS: ai.OS,
                version: Services.sysinfo.getProperty("version")
              },
              report: "ice failure",
              connectionstate: pc.iceConnectionState,
              stats: report,
              localSdp: internalFormat.localSdp,
              remoteSdp: internalFormat.remoteSdp,
              log: logStr
            }
          }
        };

        // Send job to worker to do log sanitation, transcoding and saving to
        // disk for pickup by telemetry on next startup, which then uploads it.

        let worker = new ChromeWorker("MozLoopWorker.js");
        worker.onmessage = function(e) {
          log.info(e.data.ok ?
            "Successfully staged loop report for telemetry upload." :
            ("Failed to stage loop report. Error: " + e.data.fail));
        };
        worker.postMessage(job);
      });
    }, pc.id);
  },

  getChatWindowID: function(conversationWindowData) {
    // Try getting a window ID that can (re-)identify this conversation, or resort
    // to a globally unique one as a last resort.
    // XXX We can clean this up once rooms and direct contact calling are the only
    //     two modes left.
    let windowId = ("contact" in conversationWindowData) ?
                   conversationWindowData.contact._guid || gLastWindowId++ :
                   conversationWindowData.roomToken || conversationWindowData.callId ||
                   gLastWindowId++;
    return windowId.toString();
  },

  getChatURL: function(chatWindowId) {
    return "about:loopconversation#" + chatWindowId;
  },

  /**
   * Opens the chat window
   *
   * @param {Object} conversationWindowData The data to be obtained by the
   *                                        window when it opens.
   * @returns {Number} The id of the window, null if a window could not
   *                   be opened.
   */
  openChatWindow: function(conversationWindowData) {
    // So I guess the origin is the loop server!?
    let origin = this.loopServerUri;
    let windowId = this.getChatWindowID(conversationWindowData);

    gConversationWindowData.set(windowId, conversationWindowData);

    let url = this.getChatURL(windowId);

    let callback = chatbox => {
      // We need to use DOMContentLoaded as otherwise the injection will happen
      // in about:blank and then get lost.
      // Sadly we can't use chatbox.promiseChatLoaded() as promise chaining
      // involves event loop spins, which means it might be too late.
      // Have we already done it?
      if (chatbox.contentWindow.navigator.mozLoop) {
        return;
      }

      chatbox.setAttribute("dark", true);
      chatbox.setAttribute("large", true);

      chatbox.addEventListener("DOMContentLoaded", function loaded(event) {
        if (event.target != chatbox.contentDocument) {
          return;
        }
        chatbox.removeEventListener("DOMContentLoaded", loaded, true);

        let window = chatbox.contentWindow;

        function socialFrameChanged(eventName) {
          UITour.availableTargetsCache.clear();
          UITour.notify(eventName);
        }

        window.addEventListener("socialFrameHide", socialFrameChanged.bind(null, "Loop:ChatWindowHidden"));
        window.addEventListener("socialFrameShow", socialFrameChanged.bind(null, "Loop:ChatWindowShown"));
        window.addEventListener("socialFrameDetached", socialFrameChanged.bind(null, "Loop:ChatWindowDetached"));
        window.addEventListener("unload", socialFrameChanged.bind(null, "Loop:ChatWindowClosed"));

        injectLoopAPI(window);

        let ourID = window.QueryInterface(Ci.nsIInterfaceRequestor)
            .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;

        let onPCLifecycleChange = (pc, winID, type) => {
          if (winID != ourID) {
            return;
          }

          // Chat Window Id, this is different that the internal winId
          let windowId = window.location.hash.slice(1);
          var context = this.conversationContexts.get(windowId);
          var exists = pc.id.match(/session=(\S+)/);
          if (context && !exists) {
            // Not ideal but insert our data amidst existing data like this:
            // - 000 (id=00 url=http)
            // + 000 (session=000 call=000 id=00 url=http)
            var pair = pc.id.split("(");  //)
            if (pair.length == 2) {
              pc.id = pair[0] + "(session=" + context.sessionId +
                  (context.callId ? " call=" + context.callId : "") + " " + pair[1]; //)
            }
          }

          if (type == "iceconnectionstatechange") {
            switch(pc.iceConnectionState) {
              case "failed":
              case "disconnected":
                if (Services.telemetry.canRecordExtended) {
                  this.stageForTelemetryUpload(window, pc);
                }
                break;
            }
          }
        };

        let pc_static = new window.mozRTCPeerConnectionStatic();
        pc_static.registerPeerConnectionLifecycleCallback(onPCLifecycleChange);

        UITour.notify("Loop:ChatWindowOpened");
      }.bind(this), true);
    };

    if (!Chat.open(null, origin, "", url, undefined, undefined, callback)) {
      return null;
    }
    return windowId;
  },

  /**
   * Fetch Firefox Accounts (FxA) OAuth parameters from the Loop Server.
   *
   * @return {Promise} resolved with the body of the hawk request for OAuth parameters.
   */
  promiseFxAOAuthParameters: function() {
    const SESSION_TYPE = LOOP_SESSION_TYPE.FXA;
    return this.hawkRequestInternal(SESSION_TYPE, "/fxa-oauth/params", "POST").then(response => {
      if (!this.storeSessionToken(SESSION_TYPE, response.headers)) {
        throw new Error("Invalid FxA hawk token returned");
      }
      let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(SESSION_TYPE));
      if (prefType == Services.prefs.PREF_INVALID) {
        throw new Error("No FxA hawk token returned and we don't have one saved");
      }

      return JSON.parse(response.body);
    },
    error => { this._hawkRequestError(error); });
  },

  /**
   * Get the OAuth client constructed with Loop OAauth parameters.
   *
   * @param {Boolean} forceReAuth Set to true to force the user to reauthenticate.
   * @return {Promise}
   */
  promiseFxAOAuthClient: Task.async(function* (forceReAuth) {
    // We must make sure to have only a single client otherwise they will have different states and
    // multiple channels. This would happen if the user clicks the Login button more than once.
    if (gFxAOAuthClientPromise) {
      return gFxAOAuthClientPromise;
    }

    gFxAOAuthClientPromise = this.promiseFxAOAuthParameters().then(
      parameters => {
        // Add the fact that we want keys to the parameters.
        parameters.keys = true;
        if (forceReAuth) {
          parameters.action = "force_auth";
          parameters.email = MozLoopService.userProfile.email;
        }

        try {
          gFxAOAuthClient = new FxAccountsOAuthClient({
            parameters: parameters
          });
        } catch (ex) {
          gFxAOAuthClientPromise = null;
          throw ex;
        }
        return gFxAOAuthClient;
      },
      error => {
        gFxAOAuthClientPromise = null;
        throw error;
      }
    );

    return gFxAOAuthClientPromise;
  }),

  /**
   * Get the OAuth client and do the authorization web flow to get an OAuth code.
   *
   * @param {Boolean} forceReAuth Set to true to force the user to reauthenticate.
   * @return {Promise}
   */
  promiseFxAOAuthAuthorization: function(forceReAuth) {
    let deferred = Promise.defer();
    this.promiseFxAOAuthClient(forceReAuth).then(
      client => {
        client.onComplete = this._fxAOAuthComplete.bind(this, deferred);
        client.onError = this._fxAOAuthError.bind(this, deferred);
        client.launchWebFlow();
      },
      error => {
        log.error(error);
        deferred.reject(error);
      }
    );
    return deferred.promise;
  },

  /**
   * Get the OAuth token using the OAuth code and state.
   *
   * The caller should approperiately handle 4xx errors (which should lead to a logout)
   * and 5xx or connectivity issues with messaging to try again later.
   *
   * @param {String} code
   * @param {String} state
   *
   * @return {Promise} resolving with OAuth token data.
   */
  promiseFxAOAuthToken: function(code, state) {
    if (!code || !state) {
      throw new Error("promiseFxAOAuthToken: code and state are required.");
    }

    let payload = {
      code: code,
      state: state
    };
    return this.hawkRequestInternal(LOOP_SESSION_TYPE.FXA, "/fxa-oauth/token", "POST", payload).then(response => {
      return JSON.parse(response.body);
    },
    error => { this._hawkRequestError(error); });
  },

  /**
   * Called once gFxAOAuthClient fires onComplete.
   *
   * @param {Deferred} deferred used to resolve the gFxAOAuthClientPromise
   * @param {Object} result (with code and state)
   */
  _fxAOAuthComplete: function(deferred, result, keys) {
    if (keys.kBr) {
      Services.prefs.setCharPref("loop.key.fxa", keys.kBr.k);
    }
    gFxAOAuthClientPromise = null;
    // Note: The state was already verified in FxAccountsOAuthClient.
    deferred.resolve(result);
  },

  /**
   * Called if gFxAOAuthClient fires onError.
   *
   * @param {Deferred} deferred used to reject the gFxAOAuthClientPromise
   * @param {Object} error object returned by FxAOAuthClient
   */
  _fxAOAuthError: function(deferred, err) {
    gFxAOAuthClientPromise = null;
    deferred.reject(err);
  }
};
Object.freeze(MozLoopServiceInternal);


let gInitializeTimerFunc = (deferredInitialization) => {
  // Kick off the push notification service into registering after a timeout.
  // This ensures we're not doing too much straight after the browser's finished
  // starting up.

  setTimeout(MozLoopService.delayedInitialize.bind(MozLoopService, deferredInitialization),
             MozLoopServiceInternal.initialRegistrationDelayMilliseconds);
};

let gServiceInitialized = false;

/**
 * Public API
 */
this.MozLoopService = {
  _DNSService: gDNSService,
  _activeScreenShares: [],

  get channelIDs() {
    // Channel ids that will be registered with the PushServer for notifications
    return {
      callsFxA: "25389583-921f-4169-a426-a4673658944b",
      roomsFxA: "6add272a-d316-477c-8335-f00f73dfde71",
      roomsGuest: "19d3f799-a8f3-4328-9822-b7cd02765832"
    };
  },

  /**
   * Used to override the initalize timer function for test purposes.
   */
  set initializeTimerFunc(value) {
    gInitializeTimerFunc = value;
  },

  /**
   * Used to reset if the service has been initialized or not - for test
   * purposes.
   */
  resetServiceInitialized: function() {
    gServiceInitialized = false;
  },

  get roomsParticipantsCount() {
    return LoopRooms.participantsCount;
  },

  /**
   * Initialized the loop service, and starts registration with the
   * push and loop servers.
   *
   * Note: this returns a promise for unit test purposes.
   *
   * @return {Promise}
   */
  initialize: Task.async(function*() {
    // Ensure we don't setup things like listeners more than once.
    if (gServiceInitialized) {
      return Promise.resolve();
    }

    gServiceInitialized = true;

    // Do this here, rather than immediately after definition, so that we can
    // stub out API functions for unit testing
    Object.freeze(this);

    // Don't do anything if loop is not enabled.
    if (!Services.prefs.getBoolPref("loop.enabled")) {
      return Promise.reject(new Error("loop is not enabled"));
    }

    if (Services.prefs.getPrefType("loop.fxa.enabled") == Services.prefs.PREF_BOOL) {
      gFxAEnabled = Services.prefs.getBoolPref("loop.fxa.enabled");
      if (!gFxAEnabled) {
        yield this.logOutFromFxA();
      }
    }

    // The Loop toolbar button should change icon when the room participant count
    // changes from 0 to something.
    const onRoomsChange = (e) => {
      // Pass the event name as notification reason for better logging.
      MozLoopServiceInternal.notifyStatusChanged("room-" + e);
    };
    LoopRooms.on("add", onRoomsChange);
    LoopRooms.on("update", onRoomsChange);
    LoopRooms.on("delete", onRoomsChange);
    LoopRooms.on("joined", (e, room, participant) => {
      // Don't alert if we're in the doNotDisturb mode, or the participant
      // is the owner - the content code deals with the rest of the sounds.
      if (MozLoopServiceInternal.doNotDisturb || participant.owner) {
        return;
      }

      let window = gWM.getMostRecentWindow("navigator:browser");
      if (window) {
        window.LoopUI.showNotification({
          sound: "room-joined",
          // Fallback to the brand short name if the roomName isn't available.
          title: room.roomName || MozLoopServiceInternal.localizedStrings.get("clientShortname2"),
          message: MozLoopServiceInternal.localizedStrings.get("rooms_room_joined_label"),
          selectTab: "rooms"
        });
      }
    });

    LoopRooms.on("joined", this.maybeResumeTourOnRoomJoined.bind(this));

    // If there's no guest room created and the user hasn't
    // previously authenticated then skip registration.
    if (!LoopRooms.getGuestCreatedRoom() &&
        !MozLoopServiceInternal.fxAOAuthTokenData) {
      return Promise.resolve("registration not needed");
    }

    let deferredInitialization = Promise.defer();
    gInitializeTimerFunc(deferredInitialization);

    return deferredInitialization.promise;
  }),

  /**
   * Maybe resume the tour (re-opening the tab, if necessary) if someone else joins
   * a room of ours and it's currently open.
   */
  maybeResumeTourOnRoomJoined: function(e, room, participant) {
    let isOwnerInRoom = false;
    let isOtherInRoom = false;

    if (!this.getLoopPref("gettingStarted.resumeOnFirstJoin")) {
      return;
    }

    if (!room.participants) {
      return;
    }

    // The particpant that joined isn't necessarily included in room.participants (depending on
    // when the broadcast happens) so concatenate.
    for (let participant of room.participants.concat(participant)) {
      if (participant.owner) {
        isOwnerInRoom = true;
      } else {
        isOtherInRoom = true;
      }
    }

    if (!isOwnerInRoom || !isOtherInRoom) {
      return;
    }

    // Check that the room chatbox is still actually open using its URL
    let chatboxesForRoom = [...Chat.chatboxes].filter(chatbox => {
      return chatbox.src == MozLoopServiceInternal.getChatURL(room.roomToken);
    });

    if (!chatboxesForRoom.length) {
      log.warn("Tried to resume the tour from a join when the chatbox was closed", room);
      return;
    }

    this.resumeTour("open");
  },

  /**
   * The core of the initialization work that happens once the browser is ready
   * (after a timer when called during startup).
   *
   * Can be called more than once (e.g. if the initial setup fails at some phase).
   * @param {Deferred} deferredInitialization
   */
  delayedInitialize: Task.async(function*(deferredInitialization) {
    log.debug("delayedInitialize");
    // Set or clear an error depending on how deferredInitialization gets resolved.
    // We do this first so that it can handle the early returns below.
    let completedPromise = deferredInitialization.promise.then(result => {
      MozLoopServiceInternal.clearError("initialization");
      return result;
    },
    error => {
      // If we get a non-object then setError was already called for a different error type.
      if (typeof error == "object") {
        MozLoopServiceInternal.setError("initialization", error, () => MozLoopService.delayedInitialize(Promise.defer()));
      }
    });

    try {
      if (LoopRooms.getGuestCreatedRoom()) {
        yield this.promiseRegisteredWithServers(LOOP_SESSION_TYPE.GUEST);
      } else {
        log.debug("delayedInitialize: Guest Room hasn't been created so not registering as a guest");
      }
    } catch (ex) {
      log.debug("MozLoopService: Failure of guest registration", ex);
      deferredInitialization.reject(ex);
      yield completedPromise;
      return;
    }

    if (!MozLoopServiceInternal.fxAOAuthTokenData) {
      log.debug("delayedInitialize: Initialized without an already logged-in account");
      deferredInitialization.resolve("initialized without FxA status");
      yield completedPromise;
      return;
    }

    log.debug("MozLoopService: Initializing with already logged-in account");
    MozLoopServiceInternal.promiseRegisteredWithServers(LOOP_SESSION_TYPE.FXA).then(() => {
      deferredInitialization.resolve("initialized to logged-in status");
    }, error => {
      log.debug("MozLoopService: error logging in using cached auth token");
      let retryFunc = () => MozLoopServiceInternal.promiseRegisteredWithServers(LOOP_SESSION_TYPE.FXA);
      MozLoopServiceInternal.setError("login", error, retryFunc);
      deferredInitialization.reject("error logging in using cached auth token");
    });
    yield completedPromise;
  }),

  /**
   * Opens the chat window
   *
   * @param {Object} conversationWindowData The data to be obtained by the
   *                                        window when it opens.
   * @returns {Number} The id of the window.
   */
  openChatWindow: function(conversationWindowData) {
    return MozLoopServiceInternal.openChatWindow(conversationWindowData);
  },

  /**
   * @see MozLoopServiceInternal.promiseRegisteredWithServers
   */
  promiseRegisteredWithServers: function(sessionType = LOOP_SESSION_TYPE.GUEST) {
    return MozLoopServiceInternal.promiseRegisteredWithServers(sessionType);
  },

  /**
   * Returns the strings for the specified element. Designed for use with l10n.js.
   *
   * @param {key} The element id to get strings for.
   * @return {String} A JSON string containing the localized attribute/value pairs
   *                  for the element.
   */
  getStrings: function(key) {
    var stringData = MozLoopServiceInternal.localizedStrings;
    if (!stringData.has(key)) {
      log.error("No string found for key: ", key);
      return "";
    }

    return JSON.stringify({ textContent: stringData.get(key) });
  },

  /**
   * Returns a new GUID (UUID) in curly braces format.
   */
  generateUUID: function() {
    return uuidgen.generateUUID().toString();
  },

  /**
   * Retrieves MozLoopService "do not disturb" value.
   *
   * @return {Boolean}
   */
  get doNotDisturb() {
    return MozLoopServiceInternal.doNotDisturb;
  },

  /**
   * Sets MozLoopService "do not disturb" value.
   *
   * @param {Boolean} aFlag
   */
  set doNotDisturb(aFlag) {
    MozLoopServiceInternal.doNotDisturb = aFlag;
  },

  get fxAEnabled() {
    return gFxAEnabled;
  },

  /**
   * Gets the user profile, but only if there is
   * tokenData present. Without tokenData, the
   * profile is meaningless.
   *
   * @return {Object}
   */
  get userProfile() {
    return getJSONPref("loop.fxa_oauth.tokendata") &&
           getJSONPref("loop.fxa_oauth.profile");
  },

  /**
   * Gets the encryption key for this profile.
   */
  promiseProfileEncryptionKey: function() {
    return new Promise((resolve, reject) => {
      if (this.userProfile) {
        // We're an FxA user.
        if (Services.prefs.prefHasUserValue("loop.key.fxa")) {
          resolve(MozLoopService.getLoopPref("key.fxa"));
          return;
        }

        // This should generally never happen, but its not really possible
        // for us to force reauth from here in a sensible way for the user.
        // So we'll just have to flag it the best we can.
        reject(new Error("No FxA key available"));
        return;
      }

      // XXX Temporarily save in preferences until we've got some
      // extra storage (bug 1152761).
      if (!Services.prefs.prefHasUserValue("loop.key")) {
        // Get a new value.
        loopCrypto.generateKey().then(key => {
          Services.prefs.setCharPref("loop.key", key);
          resolve(key);
        }).catch(function(error) {
          MozLoopService.log.error(error);
          reject(error);
        });
        return;
      }

      resolve(MozLoopService.getLoopPref("key"));
    });
  },

  /**
   * Returns true if this profile has an encryption key. For guest profiles
   * this is always true, since we can generate a new one if needed. For FxA
   * profiles, we need to check the preference.
   *
   * @return {Boolean} True if the profile has an encryption key.
   */
  get hasEncryptionKey() {
    return !this.userProfile ||
      Services.prefs.prefHasUserValue("loop.key.fxa");
  },

  get errors() {
    return MozLoopServiceInternal.errors;
  },

  get log() {
    return log;
  },

  /**
   * Returns the current locale
   *
   * @return {String} The code of the current locale.
   */
  get locale() {
    try {
      return Services.prefs.getComplexValue("general.useragent.locale",
        Ci.nsISupportsString).data;
    } catch (ex) {
      return "en-US";
    }
  },

  /**
   * Set any preference under "loop.".
   *
   * @param {String} prefSuffix The name of the pref without the preceding "loop."
   * @param {*} value The value to set.
   * @param {Enum} prefType Type of preference, defined at Ci.nsIPrefBranch. Optional.
   *
   * Any errors thrown by the Mozilla pref API are logged to the console.
   */
  setLoopPref: function(prefSuffix, value, prefType) {
    let prefName = "loop." + prefSuffix;
    try {
      if (!prefType) {
        prefType = Services.prefs.getPrefType(prefName);
      }
      switch (prefType) {
        case Ci.nsIPrefBranch.PREF_STRING:
          Services.prefs.setCharPref(prefName, value);
          break;
        case Ci.nsIPrefBranch.PREF_INT:
          Services.prefs.setIntPref(prefName, value);
          break;
        case Ci.nsIPrefBranch.PREF_BOOL:
          Services.prefs.setBoolPref(prefName, value);
          break;
        default:
          log.error("invalid preference type setting " + prefName);
          break;
      }
    } catch (ex) {
      log.error("setLoopPref had trouble setting " + prefName +
        "; exception: " + ex);
    }
  },

  /**
   * Return any preference under "loop.".
   *
   * @param {String} prefName The name of the pref without the preceding
   * "loop."
   * @param {Enum} prefType Type of preference, defined at Ci.nsIPrefBranch. Optional.
   *
   * Any errors thrown by the Mozilla pref API are logged to the console
   * and cause null to be returned. This includes the case of the preference
   * not being found.
   *
   * @return {*} on success, null on error
   */
  getLoopPref: function(prefSuffix, prefType) {
    let prefName = "loop." + prefSuffix;
    try {
      if (!prefType) {
        prefType = Services.prefs.getPrefType(prefName);
      } else if (prefType != Services.prefs.getPrefType(prefName)) {
        log.error("invalid type specified for preference");
        return null;
      }
      switch (prefType) {
        case Ci.nsIPrefBranch.PREF_STRING:
          return Services.prefs.getCharPref(prefName);
        case Ci.nsIPrefBranch.PREF_INT:
          return Services.prefs.getIntPref(prefName);
        case Ci.nsIPrefBranch.PREF_BOOL:
          return Services.prefs.getBoolPref(prefName);
        default:
          log.error("invalid preference type getting " + prefName);
          return null;
      }
    } catch (ex) {
      log.error("getLoopPref had trouble getting " + prefName +
        "; exception: " + ex);
      return null;
    }
  },

  /**
   * Start the FxA login flow using the OAuth client and params from the Loop server.
   *
   * The caller should be prepared to handle rejections related to network, server or login errors.
   *
   * @param {Boolean} forceReAuth Set to true to force the user to reauthenticate.
   * @return {Promise} that resolves when the FxA login flow is complete.
   */
  logInToFxA: function(forceReAuth) {
    log.debug("logInToFxA with fxAOAuthTokenData:", !!MozLoopServiceInternal.fxAOAuthTokenData);
    if (!forceReAuth && MozLoopServiceInternal.fxAOAuthTokenData) {
      return Promise.resolve(MozLoopServiceInternal.fxAOAuthTokenData);
    }
    return MozLoopServiceInternal.promiseFxAOAuthAuthorization(forceReAuth).then(response => {
      return MozLoopServiceInternal.promiseFxAOAuthToken(response.code, response.state);
    }).then(tokenData => {
      MozLoopServiceInternal.fxAOAuthTokenData = tokenData;
      return tokenData;
    }).then(tokenData => {
      return MozLoopServiceInternal.promiseRegisteredWithServers(LOOP_SESSION_TYPE.FXA).then(() => {
        MozLoopServiceInternal.clearError("login");
        MozLoopServiceInternal.clearError("profile");
        return MozLoopServiceInternal.fxAOAuthTokenData;
      });
    }).then(Task.async(function* fetchProfile(tokenData) {
      yield MozLoopService.fetchFxAProfile(tokenData);
      return tokenData;
    })).catch(error => {
      MozLoopServiceInternal.fxAOAuthTokenData = null;
      MozLoopServiceInternal.fxAOAuthProfile = null;
      MozLoopServiceInternal.deferredRegistrations.delete(LOOP_SESSION_TYPE.FXA);
      throw error;
    }).catch((error) => {
      MozLoopServiceInternal.setError("login", error,
                                      () => MozLoopService.logInToFxA());
      // Re-throw for testing
      throw error;
    });
  },

  /**
   * Logs the user out from FxA.
   *
   * Gracefully handles if the user is already logged out.
   *
   * @return {Promise} that resolves when the FxA logout flow is complete.
   */
  logOutFromFxA: Task.async(function*() {
    log.debug("logOutFromFxA");
    try {
      yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA);
    }
    catch (err) {
      throw err;
    }
    finally {
      MozLoopServiceInternal.clearSessionToken(LOOP_SESSION_TYPE.FXA);
      MozLoopServiceInternal.fxAOAuthTokenData = null;
      MozLoopServiceInternal.fxAOAuthProfile = null;
      MozLoopServiceInternal.deferredRegistrations.delete(LOOP_SESSION_TYPE.FXA);
      // Unregister with PushHandler so these push channels will not get re-registered
      // if the connection is re-established by the PushHandler.
      MozLoopServiceInternal.pushHandler.unregister(MozLoopService.channelIDs.callsFxA);
      MozLoopServiceInternal.pushHandler.unregister(MozLoopService.channelIDs.roomsFxA);

      // Reset the client since the initial promiseFxAOAuthParameters() call is
      // what creates a new session.
      gFxAOAuthClient = null;
      gFxAOAuthClientPromise = null;

      // clearError calls notifyStatusChanged so should be done last when the
      // state is clean.
      MozLoopServiceInternal.clearError("registration");
      MozLoopServiceInternal.clearError("login");
      MozLoopServiceInternal.clearError("profile");
    }
  }),

  /**
   * Fetch/update the FxA Profile for the logged in user.
   *
   * @return {Promise} resolving if the profile information was succesfully retrieved
   *                   rejecting if the profile information couldn't be retrieved.
   *                   A profile error is registered.
   **/
  fetchFxAProfile: function() {
    log.debug("fetchFxAProfile");
    let client = new FxAccountsProfileClient({
      serverURL: gFxAOAuthClient.parameters.profile_uri,
      token: MozLoopServiceInternal.fxAOAuthTokenData.access_token
    });
    return client.fetchProfile().then(result => {
      MozLoopServiceInternal.fxAOAuthProfile = result;
      MozLoopServiceInternal.clearError("profile");
    }, error => {
      log.error("Failed to retrieve profile", error, this.fetchFxAProfile.bind(this));
      MozLoopServiceInternal.setError("profile", error);
      MozLoopServiceInternal.fxAOAuthProfile = null;
      MozLoopServiceInternal.notifyStatusChanged();
    });
  },

  openFxASettings: Task.async(function* () {
    try {
      let fxAOAuthClient = yield MozLoopServiceInternal.promiseFxAOAuthClient();
      if (!fxAOAuthClient) {
        log.error("Could not get the OAuth client");
        return;
      }
      let url = new URL("/settings", fxAOAuthClient.parameters.content_uri);
      let win = Services.wm.getMostRecentWindow("navigator:browser");
      win.switchToTabHavingURI(url.toString(), true);
    } catch (ex) {
      log.error("Error opening FxA settings", ex);
    }
  }),

  /**
   * Gets the tour URL.
   *
   * @param {String} aSrc A string representing the entry point to begin the tour, optional.
   * @param {Object} aAdditionalParams An object with keys used as query parameter names
   */
  getTourURL: function(aSrc = null, aAdditionalParams = {}) {
    let urlStr = this.getLoopPref("gettingStarted.url");
    let url = new URL(Services.urlFormatter.formatURL(urlStr));
    for (let paramName in aAdditionalParams) {
      url.searchParams.set(paramName, aAdditionalParams[paramName]);
    }
    if (aSrc) {
      url.searchParams.set("utm_source", "firefox-browser");
      url.searchParams.set("utm_medium", "firefox-browser");
      url.searchParams.set("utm_campaign", aSrc);
    }

    // Find the most recent pageID that has the Loop prefix.
    let mostRecentLoopPageID = {id: null, lastSeen: null};
    for (let pageID of UITour.pageIDsForSession) {
      if (pageID[0] && pageID[0].startsWith("hello-tour_OpenPanel_") &&
          pageID[1] && pageID[1].lastSeen > mostRecentLoopPageID.lastSeen) {
        mostRecentLoopPageID.id = pageID[0];
        mostRecentLoopPageID.lastSeen = pageID[1].lastSeen;
      }
    }

    const PAGE_ID_EXPIRATION_MS = 60 * 60 * 1000;
    if (mostRecentLoopPageID.id &&
        mostRecentLoopPageID.lastSeen > Date.now() - PAGE_ID_EXPIRATION_MS) {
      url.searchParams.set("utm_content", mostRecentLoopPageID.id);
    }
    return url;
  },

  resumeTour: function(aIncomingConversationState) {
    if (!this.getLoopPref("gettingStarted.resumeOnFirstJoin")) {
      return;
    }

    let url = this.getTourURL("resume-with-conversation", {
      incomingConversation: aIncomingConversationState
    });

    let win = Services.wm.getMostRecentWindow("navigator:browser");

    this.setLoopPref("gettingStarted.resumeOnFirstJoin", false);

    // The query parameters of the url can vary but we always want to re-use a Loop tour tab that's
    // already open so we ignore the fragment and query string.
    let hadExistingTab = win.switchToTabHavingURI(url, true, {
      ignoreFragment: true,
      ignoreQueryString: true
    });

    // If the tab was already open, send an event instead of using the query
    // parameter above (that we don't replace on existing tabs to avoid a reload).
    if (hadExistingTab) {
      UITour.notify("Loop:IncomingConversation", {
        conversationOpen: aIncomingConversationState === "open"
      });
    }
  },

  /**
   * Opens the Getting Started tour in the browser.
   *
   * @param {String} [aSrc] A string representing the entry point to begin the tour, optional.
   */
  openGettingStartedTour: Task.async(function(aSrc = null) {
    try {
      let url = this.getTourURL(aSrc);
      let win = Services.wm.getMostRecentWindow("navigator:browser");
      win.switchToTabHavingURI(url, true, {
        ignoreFragment: true,
        replaceQueryString: true
      });
    } catch (ex) {
      log.error("Error opening Getting Started tour", ex);
    }
  }),

  /**
   * Opens a URL in a new tab in the browser.
   *
   * @param {String} url The new url to open
   */
  openURL: function(url) {
    let win = Services.wm.getMostRecentWindow("navigator:browser");
    win.openUILinkIn(url, "tab");
  },

  /**
   * Performs a hawk based request to the loop server.
   *
   * @param {LOOP_SESSION_TYPE} sessionType The type of session to use for the request.
   *                                        One of the LOOP_SESSION_TYPE members.
   * @param {String} path The path to make the request to.
   * @param {String} method The request method, e.g. 'POST', 'GET'.
   * @param {Object} payloadObj An object which is converted to JSON and
   *                            transmitted with the request.
   * @returns {Promise}
   *        Returns a promise that resolves to the response of the API call,
   *        or is rejected with an error.  If the server response can be parsed
   *        as JSON and contains an 'error' property, the promise will be
   *        rejected with this JSON-parsed response.
   */
  hawkRequest: function(sessionType, path, method, payloadObj) {
    return MozLoopServiceInternal.hawkRequest(sessionType, path, method, payloadObj).catch(
      error => { MozLoopServiceInternal._hawkRequestError(error); });
  },

  /**
   * Returns the window data for a specific conversation window id.
   *
   * This data will be relevant to the type of window, e.g. rooms or calls.
   * See LoopRooms or LoopCalls for more information.
   *
   * @param {String} conversationWindowId
   * @returns {Object} The window data or null if error.
   */
  getConversationWindowData: function(conversationWindowId) {
    if (gConversationWindowData.has(conversationWindowId)) {
      var conversationData = gConversationWindowData.get(conversationWindowId);
      gConversationWindowData.delete(conversationWindowId);
      return conversationData;
    }

    log.error("Window data was already fetched before. Possible race condition!");
    return null;
  },

  getConversationContext: function(winId) {
    return MozLoopServiceInternal.conversationContexts.get(winId);
  },

  addConversationContext: function(windowId, context) {
    MozLoopServiceInternal.conversationContexts.set(windowId, context);
  },

  /**
   * Used to record the screen sharing state for a window so that it can
   * be reflected on the toolbar button.
   *
   * @param {String} windowId The id of the conversation window the state
   *                          is being changed for.
   * @param {Boolean} active  Whether or not screen sharing is now active.
   */
  setScreenShareState: function(windowId, active) {
    if (active) {
      this._activeScreenShares.push(windowId);
    } else {
      var index = this._activeScreenShares.indexOf(windowId);
      if (index != -1) {
        this._activeScreenShares.splice(index, 1);
      }
    }

    MozLoopServiceInternal.notifyStatusChanged();
  },

  /**
   * Returns true if screen sharing is active in at least one window.
   */
  get screenShareActive() {
    return this._activeScreenShares.length > 0;
  }
};