// -*- 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 = [ "HomeProvider" ]; const { utils: Cu, classes: Cc, interfaces: Ci } = Components; Cu.import("resource://gre/modules/Messaging.jsm"); Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/Sqlite.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /* * SCHEMA_VERSION history: * 1: Create HomeProvider (bug 942288) * 2: Add filter column to items table (bug 942295/975841) * 3: Add background_color and background_url columns (bug 1157539) */ const SCHEMA_VERSION = 3; // The maximum number of items you can attempt to save at once. const MAX_SAVE_COUNT = 100; XPCOMUtils.defineLazyGetter(this, "DB_PATH", function() { return OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite"); }); const PREF_STORAGE_LAST_SYNC_TIME_PREFIX = "home.storage.lastSyncTime."; const PREF_SYNC_UPDATE_MODE = "home.sync.updateMode"; const PREF_SYNC_CHECK_INTERVAL_SECS = "home.sync.checkIntervalSecs"; XPCOMUtils.defineLazyGetter(this, "gSyncCheckIntervalSecs", function() { return Services.prefs.getIntPref(PREF_SYNC_CHECK_INTERVAL_SECS); }); XPCOMUtils.defineLazyServiceGetter(this, "gUpdateTimerManager", "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager"); /** * All SQL statements should be defined here. */ const SQL = { createItemsTable: "CREATE TABLE items (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + "dataset_id TEXT NOT NULL, " + "url TEXT," + "title TEXT," + "description TEXT," + "image_url TEXT," + "background_color TEXT," + "background_url TEXT," + "filter TEXT," + "created INTEGER" + ")", dropItemsTable: "DROP TABLE items", insertItem: "INSERT INTO items (dataset_id, url, title, description, image_url, background_color, background_url, filter, created) " + "VALUES (:dataset_id, :url, :title, :description, :image_url, :background_color, :background_url, :filter, :created)", deleteFromDataset: "DELETE FROM items WHERE dataset_id = :dataset_id", addColumnBackgroundColor: "ALTER TABLE items ADD COLUMN background_color TEXT", addColumnBackgroundUrl: "ALTER TABLE items ADD COLUMN background_url TEXT", } /** * Technically this function checks to see if the user is on a local network, * but we express this as "wifi" to the user. */ function isUsingWifi() { let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService); return (network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_WIFI || network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET); } function getNowInSeconds() { return Math.round(Date.now() / 1000); } function getLastSyncPrefName(datasetId) { return PREF_STORAGE_LAST_SYNC_TIME_PREFIX + datasetId; } // Whether or not we've registered an update timer. var gTimerRegistered = false; // Map of datasetId -> { interval: , callback: } var gSyncCallbacks = {}; /** * nsITimerCallback implementation. Checks to see if it's time to sync any registered datasets. * * @param timer The timer which has expired. */ function syncTimerCallback(timer) { for (let datasetId in gSyncCallbacks) { let lastSyncTime = 0; try { lastSyncTime = Services.prefs.getIntPref(getLastSyncPrefName(datasetId)); } catch(e) { } let now = getNowInSeconds(); let { interval: interval, callback: callback } = gSyncCallbacks[datasetId]; if (lastSyncTime < now - interval) { let success = HomeProvider.requestSync(datasetId, callback); if (success) { Services.prefs.setIntPref(getLastSyncPrefName(datasetId), now); } } } } this.HomeStorage = function(datasetId) { this.datasetId = datasetId; }; this.ValidationError = function(message) { this.name = "ValidationError"; this.message = message; }; ValidationError.prototype = new Error(); ValidationError.prototype.constructor = ValidationError; this.HomeProvider = Object.freeze({ ValidationError: ValidationError, /** * Returns a storage associated with a given dataset identifer. * * @param datasetId * (string) Unique identifier for the dataset. * * @return HomeStorage */ getStorage: function(datasetId) { return new HomeStorage(datasetId); }, /** * Checks to see if it's an appropriate time to sync. * * @param datasetId Unique identifier for the dataset to sync. * @param callback Function to call when it's time to sync, called with datasetId as a parameter. * * @return boolean Whether or not we were able to sync. */ requestSync: function(datasetId, callback) { // Make sure it's a good time to sync. if ((Services.prefs.getIntPref(PREF_SYNC_UPDATE_MODE) === 1) && !isUsingWifi()) { Cu.reportError("HomeProvider: Failed to sync because device is not on a local network"); return false; } callback(datasetId); return true; }, /** * Specifies that a sync should be requested for the given dataset and update interval. * * @param datasetId Unique identifier for the dataset to sync. * @param interval Update interval in seconds. By default, this is throttled to 3600 seconds (1 hour). * @param callback Function to call when it's time to sync, called with datasetId as a parameter. */ addPeriodicSync: function(datasetId, interval, callback) { // Warn developers if they're expecting more frequent notifications that we allow. if (interval < gSyncCheckIntervalSecs) { Cu.reportError("HomeProvider: Warning for dataset " + datasetId + " : Sync notifications are throttled to " + gSyncCheckIntervalSecs + " seconds"); } gSyncCallbacks[datasetId] = { interval: interval, callback: callback }; if (!gTimerRegistered) { gUpdateTimerManager.registerTimer("home-provider-sync-timer", syncTimerCallback, gSyncCheckIntervalSecs); gTimerRegistered = true; } }, /** * Removes a periodic sync timer. * * @param datasetId Dataset to sync. */ removePeriodicSync: function(datasetId) { delete gSyncCallbacks[datasetId]; Services.prefs.clearUserPref(getLastSyncPrefName(datasetId)); // You can't unregister a update timer, so we don't try to do that. } }); var gDatabaseEnsured = false; /** * Creates the database schema. */ function createDatabase(db) { return Task.spawn(function create_database_task() { yield db.execute(SQL.createItemsTable); }); } /** * Migrates the database schema to a new version. */ function upgradeDatabase(db, oldVersion, newVersion) { return Task.spawn(function upgrade_database_task() { switch (oldVersion) { case 1: // Migration from v1 to latest: // Recreate the items table discarding any // existing data. yield db.execute(SQL.dropItemsTable); yield db.execute(SQL.createItemsTable); break; case 2: // Migration from v2 to latest: // Add new columns: background_color, background_url yield db.execute(SQL.addColumnBackgroundColor); yield db.execute(SQL.addColumnBackgroundUrl); break; } }); } /** * Opens a database connection and makes sure that the database schema version * is correct, performing migrations if necessary. Consumers should be sure * to close any database connections they open. * * @return Promise * @resolves Handle on an opened SQLite database. */ function getDatabaseConnection() { return Task.spawn(function get_database_connection_task() { let db = yield Sqlite.openConnection({ path: DB_PATH }); if (gDatabaseEnsured) { throw new Task.Result(db); } try { // Check to see if we need to perform any migrations. let dbVersion = parseInt(yield db.getSchemaVersion()); // getSchemaVersion() returns a 0 int if the schema // version is undefined. if (dbVersion === 0) { yield createDatabase(db); } else if (dbVersion < SCHEMA_VERSION) { yield upgradeDatabase(db, dbVersion, SCHEMA_VERSION); } yield db.setSchemaVersion(SCHEMA_VERSION); } catch(e) { // Close the DB connection before passing the exception to the consumer. yield db.close(); throw e; } gDatabaseEnsured = true; throw new Task.Result(db); }); } /** * Validates an item to be saved to the DB. * * @param item * (object) item object to be validated. */ function validateItem(datasetId, item) { if (!item.url) { throw new ValidationError('HomeStorage: All rows must have an URL: datasetId = ' + datasetId); } if (!item.image_url && !item.title && !item.description) { throw new ValidationError('HomeStorage: All rows must have at least an image URL, ' + 'or a title or a description: datasetId = ' + datasetId); } } var gRefreshTimers = {}; /** * Sends a message to Java to refresh the given dataset. Delays sending * messages to avoid successive refreshes, which can result in flashing views. */ function refreshDataset(datasetId) { // Bail if there's already a refresh timer waiting to fire if (gRefreshTimers[datasetId]) { return; } let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); timer.initWithCallback(function(timer) { delete gRefreshTimers[datasetId]; Messaging.sendRequest({ type: "HomePanels:RefreshDataset", datasetId: datasetId }); }, 100, Ci.nsITimer.TYPE_ONE_SHOT); gRefreshTimers[datasetId] = timer; } HomeStorage.prototype = { /** * Saves data rows to the DB. * * @param data * An array of JS objects represnting row items to save. * Each object may have the following properties: * - url (string) * - title (string) * - description (string) * - image_url (string) * - filter (string) * @param options * A JS object holding additional cofiguration properties. * The following properties are currently supported: * - replace (boolean): Whether or not to replace existing items. * * @return Promise * @resolves When the operation has completed. */ save: function(data, options) { if (data && data.length > MAX_SAVE_COUNT) { throw "save failed for dataset = " + this.datasetId + ": you cannot save more than " + MAX_SAVE_COUNT + " items at once"; } return Task.spawn(function save_task() { let db = yield getDatabaseConnection(); try { yield db.executeTransaction(function save_transaction() { if (options && options.replace) { yield db.executeCached(SQL.deleteFromDataset, { dataset_id: this.datasetId }); } // Insert data into DB. for (let item of data) { validateItem(this.datasetId, item); // XXX: Directly pass item as params? More validation for item? let params = { dataset_id: this.datasetId, url: item.url, title: item.title, description: item.description, image_url: item.image_url, background_color: item.background_color, background_url: item.background_url, filter: item.filter, created: Date.now() }; yield db.executeCached(SQL.insertItem, params); } }.bind(this)); } finally { yield db.close(); } refreshDataset(this.datasetId); }.bind(this)); }, /** * Deletes all rows associated with this storage. * * @return Promise * @resolves When the operation has completed. */ deleteAll: function() { return Task.spawn(function delete_all_task() { let db = yield getDatabaseConnection(); try { let params = { dataset_id: this.datasetId }; yield db.executeCached(SQL.deleteFromDataset, params); } finally { yield db.close(); } refreshDataset(this.datasetId); }.bind(this)); } };