mobile/android/modules/Home.jsm
author Gregory Szorc <gps@mozilla.com>
Mon, 27 Jun 2016 13:54:15 -0700
changeset 302727 2ab57664bf7c
parent 289490 2edb5ec1f8c2
permissions -rw-r--r--
NO BUG - Reference hg.mozilla.org/mozilla-unified instead of /firefox

DONTBUILD (NPOTB)

MozReview-Commit-ID: CnM3AEtRjVj
// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* 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";

this.EXPORTED_SYMBOLS = ["Home"];

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

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/SharedPreferences.jsm");
Cu.import("resource://gre/modules/Messaging.jsm");

// Keep this in sync with the constant defined in PanelAuthCache.java
const PREFS_PANEL_AUTH_PREFIX = "home_panels_auth_";

// Default weight for a banner message.
const DEFAULT_WEIGHT = 100;

// See bug 915424
function resolveGeckoURI(aURI) {
  if (!aURI)
    throw "Can't resolve an empty uri";

  if (aURI.startsWith("chrome://")) {
    let registry = Cc['@mozilla.org/chrome/chrome-registry;1'].getService(Ci["nsIChromeRegistry"]);
    return registry.convertChromeURL(Services.io.newURI(aURI, null, null)).spec;
  } else if (aURI.startsWith("resource://")) {
    let handler = Services.io.getProtocolHandler("resource").QueryInterface(Ci.nsIResProtocolHandler);
    return handler.resolveURI(Services.io.newURI(aURI, null, null));
  }
  return aURI;
}

function BannerMessage(options) {
  let uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
  this.id = uuidgen.generateUUID().toString();

  if ("text" in options && options.text != null)
    this.text = options.text;

  if ("icon" in options && options.icon != null)
    this.iconURI = resolveGeckoURI(options.icon);

  if ("onshown" in options && typeof options.onshown === "function")
    this.onshown = options.onshown;

  if ("onclick" in options && typeof options.onclick === "function")
    this.onclick = options.onclick;

  if ("ondismiss" in options && typeof options.ondismiss === "function")
    this.ondismiss = options.ondismiss;

  let weight = parseInt(options.weight, 10);
  this.weight = weight > 0 ? weight : DEFAULT_WEIGHT;
}

// We need this object to have access to the HomeBanner
// private members without leaking it outside Home.jsm.
var HomeBannerMessageHandlers;

var HomeBanner = (function () {
  // Whether there is a "HomeBanner:Get" request we couldn't fulfill.
  let _pendingRequest = false;

  // Functions used to handle messages sent from Java.
  HomeBannerMessageHandlers = {
    "HomeBanner:Get": function handleBannerGet(data) {
      if (Object.keys(_messages).length > 0) {
        _sendBannerData();
      } else {
        _pendingRequest = true;
      }
    }
  };

  // Holds the messages that will rotate through the banner.
  let _messages = {};

  // Choose a random message from the set of messages, biasing towards those with higher weight.
  // Weight logic copied from desktop snippets:
  // https://github.com/mozilla/snippets-service/blob/7d80edb8b1cddaed075275c2fc7cdf69a10f4003/snippets/base/templates/base/includes/snippet_js.html#L119
  let _sendBannerData = function() {
    let totalWeight = 0;
    for (let key in _messages) {
      let message = _messages[key];
      totalWeight += message.weight;
      message.totalWeight = totalWeight;
    }

    let threshold = Math.random() * totalWeight;
    for (let key in _messages) {
      let message = _messages[key];
      if (threshold < message.totalWeight) {
        Messaging.sendRequest({
          type: "HomeBanner:Data",
          id: message.id,
          text: message.text,
          iconURI: message.iconURI
        });
        return;
      }
    }
  };

  let _handleShown = function(id) {
    let message = _messages[id];
    if (message.onshown)
      message.onshown();
  };

  let _handleClick = function(id) {
    let message = _messages[id];
    if (message.onclick)
      message.onclick();
  };

  let _handleDismiss = function(id) {
    let message = _messages[id];
    if (message.ondismiss)
      message.ondismiss();
  };

  return Object.freeze({
    observe: function(subject, topic, data) {
      switch(topic) {
        case "HomeBanner:Shown":
          _handleShown(data);
          break;

        case "HomeBanner:Click":
          _handleClick(data);
          break;

        case "HomeBanner:Dismiss":
          _handleDismiss(data);
          break;
      }
    },

    /**
     * Adds a new banner message to the rotation.
     *
     * @return id Unique identifer for the message.
     */
    add: function(options) {
      let message = new BannerMessage(options);
      _messages[message.id] = message;

      // If this is the first message we're adding, add
      // observers to listen for requests from the Java UI.
      if (Object.keys(_messages).length == 1) {
        Services.obs.addObserver(this, "HomeBanner:Shown", false);
        Services.obs.addObserver(this, "HomeBanner:Click", false);
        Services.obs.addObserver(this, "HomeBanner:Dismiss", false);

        // Send a message to Java if there's a pending "HomeBanner:Get" request.
        if (_pendingRequest) {
          _pendingRequest = false;
          _sendBannerData();
        }
      }

      return message.id;
    },

    /**
     * Removes a banner message from the rotation.
     *
     * @param id The id of the message to remove.
     */
    remove: function(id) {
      if (!(id in _messages)) {
        throw "Home.banner: Can't remove message that doesn't exist: id = " + id;
      }

      delete _messages[id];

      // If there are no more messages, remove the observers.
      if (Object.keys(_messages).length == 0) {
        Services.obs.removeObserver(this, "HomeBanner:Shown");
        Services.obs.removeObserver(this, "HomeBanner:Click");
        Services.obs.removeObserver(this, "HomeBanner:Dismiss");
      }
    }
  });
})();

// We need this object to have access to the HomePanels
// private members without leaking it outside Home.jsm.
var HomePanelsMessageHandlers;

var HomePanels = (function () {
  // Functions used to handle messages sent from Java.
  HomePanelsMessageHandlers = {

    "HomePanels:Get": function handlePanelsGet(data) {
      data = JSON.parse(data);

      let requestId = data.requestId;
      let ids = data.ids || null;

      let panels = [];
      for (let id in _registeredPanels) {
        // Null ids means we want to fetch all available panels
        if (ids == null || ids.indexOf(id) >= 0) {
          try {
            panels.push(_generatePanel(id));
          } catch(e) {
            Cu.reportError("Home.panels: Invalid options, panel.id = " + id + ": " + e);
          }
        }
      }

      Messaging.sendRequest({
        type: "HomePanels:Data",
        panels: panels,
        requestId: requestId
      });
    },

    "HomePanels:Authenticate": function handlePanelsAuthenticate(id) {
      // Generate panel options to get auth handler.
      let options = _registeredPanels[id]();
      if (!options.auth) {
        throw "Home.panels: Invalid auth for panel.id = " + id;
      }
      if (!options.auth.authenticate || typeof options.auth.authenticate !== "function") {
        throw "Home.panels: Invalid auth authenticate function: panel.id = " + this.id;
      }
      options.auth.authenticate();
    },

    "HomePanels:RefreshView": function handlePanelsRefreshView(data) {
      data = JSON.parse(data);

      let options = _registeredPanels[data.panelId]();
      let view = options.views[data.viewIndex];

      if (!view) {
        throw "Home.panels: Invalid view for panel.id = " + data.panelId
            + ", view.index = " + data.viewIndex;
      }

      if (!view.onrefresh || typeof view.onrefresh !== "function") {
        throw "Home.panels: Invalid onrefresh for panel.id = " + data.panelId
            + ", view.index = " + data.viewIndex;
      }

      view.onrefresh();
    },

    "HomePanels:Installed": function handlePanelsInstalled(id) {
      _assertPanelExists(id);

      let options = _registeredPanels[id]();
      if (!options.oninstall) {
        return;
      }
      if (typeof options.oninstall !== "function") {
        throw "Home.panels: Invalid oninstall function: panel.id = " + this.id;
      }
      options.oninstall();
    },

    "HomePanels:Uninstalled": function handlePanelsUninstalled(id) {
      _assertPanelExists(id);

      let options = _registeredPanels[id]();
      if (!options.onuninstall) {
        return;
      }
      if (typeof options.onuninstall !== "function") {
        throw "Home.panels: Invalid onuninstall function: panel.id = " + this.id;
      }
      options.onuninstall();
    }
  };

  // Holds the current set of registered panels that can be
  // installed, updated, uninstalled, or unregistered. It maps
  // panel ids with the functions that dynamically generate
  // their respective panel options. This is used to retrieve
  // the current list of available panels in the system.
  // See HomePanels:Get handler.
  let _registeredPanels = {};

  // Valid layouts for a panel.
  let Layout = Object.freeze({
    FRAME: "frame"
  });

  // Valid types of views for a dataset.
  let View = Object.freeze({
    LIST: "list",
    GRID: "grid"
  });

  // Valid item types for a panel view.
  let Item = Object.freeze({
    ARTICLE: "article",
    IMAGE: "image",
    ICON: "icon"
  });

  // Valid item handlers for a panel view.
  let ItemHandler = Object.freeze({
    BROWSER: "browser",
    INTENT: "intent"
  });

  function Panel(id, options) {
    this.id = id;
    this.title = options.title;
    this.layout = options.layout;
    this.views = options.views;
    this.default = !!options.default;

    if (!this.id || !this.title) {
      throw "Home.panels: Can't create a home panel without an id and title!";
    }

    if (!this.layout) {
      // Use FRAME layout by default
      this.layout = Layout.FRAME;
    } else if (!_valueExists(Layout, this.layout)) {
      throw "Home.panels: Invalid layout for panel: panel.id = " + this.id + ", panel.layout =" + this.layout;
    }

    for (let view of this.views) {
      if (!_valueExists(View, view.type)) {
        throw "Home.panels: Invalid view type: panel.id = " + this.id + ", view.type = " + view.type;
      }

      if (!view.itemType) {
        if (view.type == View.LIST) {
          // Use ARTICLE item type by default in LIST views
          view.itemType = Item.ARTICLE;
        } else if (view.type == View.GRID) {
          // Use IMAGE item type by default in GRID views
          view.itemType = Item.IMAGE;
        }
      } else if (!_valueExists(Item, view.itemType)) {
        throw "Home.panels: Invalid item type: panel.id = " + this.id + ", view.itemType = " + view.itemType;
      }

      if (!view.itemHandler) {
        // Use BROWSER item handler by default
        view.itemHandler = ItemHandler.BROWSER;
      } else if (!_valueExists(ItemHandler, view.itemHandler)) {
        throw "Home.panels: Invalid item handler: panel.id = " + this.id + ", view.itemHandler = " + view.itemHandler;
      }

      if (!view.dataset) {
        throw "Home.panels: No dataset provided for view: panel.id = " + this.id + ", view.type = " + view.type;
      }

      if (view.onrefresh) {
        view.refreshEnabled = true;
      }
    }

    if (options.auth) {
      if (!options.auth.messageText) {
        throw "Home.panels: Invalid auth messageText: panel.id = " + this.id;
      }
      if (!options.auth.buttonText) {
        throw "Home.panels: Invalid auth buttonText: panel.id = " + this.id;
      }

      this.authConfig = {
        messageText: options.auth.messageText,
        buttonText: options.auth.buttonText
      };

      // Include optional image URL if it is specified.
      if (options.auth.imageUrl) {
        this.authConfig.imageUrl = options.auth.imageUrl;
      }
    }

    if (options.position >= 0) {
      this.position = options.position;
    }
  }

  let _generatePanel = function(id) {
    let options = _registeredPanels[id]();
    return new Panel(id, options);
  };

  // Helper function used to see if a value is in an object.
  let _valueExists = function(obj, value) {
    for (let key in obj) {
      if (obj[key] == value) {
        return true;
      }
    }
    return false;
  };

  let _assertPanelExists = function(id) {
    if (!(id in _registeredPanels)) {
      throw "Home.panels: Panel doesn't exist: id = " + id;
    }
  };

  return Object.freeze({
    Layout: Layout,
    View: View,
    Item: Item,
    ItemHandler: ItemHandler,

    register: function(id, optionsCallback) {
      // Bail if the panel already exists
      if (id in _registeredPanels) {
        throw "Home.panels: Panel already exists: id = " + id;
      }

      if (!optionsCallback || typeof optionsCallback !== "function") {
        throw "Home.panels: Panel callback must be a function: id = " + id;
      }

      _registeredPanels[id] = optionsCallback;
    },

    unregister: function(id) {
      _assertPanelExists(id);

      delete _registeredPanels[id];
    },

    install: function(id) {
      _assertPanelExists(id);

      Messaging.sendRequest({
        type: "HomePanels:Install",
        panel: _generatePanel(id)
      });
    },

    uninstall: function(id) {
      _assertPanelExists(id);

      Messaging.sendRequest({
        type: "HomePanels:Uninstall",
        id: id
      });
    },

    update: function(id) {
      _assertPanelExists(id);

      Messaging.sendRequest({
        type: "HomePanels:Update",
        panel: _generatePanel(id)
      });
    },

    setAuthenticated: function(id, isAuthenticated) {
      _assertPanelExists(id);

      let authKey = PREFS_PANEL_AUTH_PREFIX + id;
      let sharedPrefs = SharedPreferences.forProfile();
      sharedPrefs.setBoolPref(authKey, isAuthenticated);
    }
  });
})();

// Public API
this.Home = Object.freeze({
  banner: HomeBanner,
  panels: HomePanels,

  // Lazy notification observer registered in browser.js
  observe: function(subject, topic, data) {
    if (topic in HomeBannerMessageHandlers) {
      HomeBannerMessageHandlers[topic](data);
    } else if (topic in HomePanelsMessageHandlers) {
      HomePanelsMessageHandlers[topic](data);
    } else {
      Cu.reportError("Home.observe: message handler not found for topic: " + topic);
    }
  }
});