1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/modules/HomeProvider.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,382 @@ 1.4 +// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- 1.5 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this file, 1.7 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.8 + 1.9 +"use strict"; 1.10 + 1.11 +this.EXPORTED_SYMBOLS = [ "HomeProvider" ]; 1.12 + 1.13 +const { utils: Cu, classes: Cc, interfaces: Ci } = Components; 1.14 + 1.15 +Cu.import("resource://gre/modules/Messaging.jsm"); 1.16 +Cu.import("resource://gre/modules/osfile.jsm"); 1.17 +Cu.import("resource://gre/modules/Promise.jsm"); 1.18 +Cu.import("resource://gre/modules/Services.jsm"); 1.19 +Cu.import("resource://gre/modules/Sqlite.jsm"); 1.20 +Cu.import("resource://gre/modules/Task.jsm"); 1.21 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.22 + 1.23 +/* 1.24 + * SCHEMA_VERSION history: 1.25 + * 1: Create HomeProvider (bug 942288) 1.26 + * 2: Add filter column to items table (bug 942295/975841) 1.27 + */ 1.28 +const SCHEMA_VERSION = 2; 1.29 + 1.30 +XPCOMUtils.defineLazyGetter(this, "DB_PATH", function() { 1.31 + return OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite"); 1.32 +}); 1.33 + 1.34 +const PREF_STORAGE_LAST_SYNC_TIME_PREFIX = "home.storage.lastSyncTime."; 1.35 +const PREF_SYNC_UPDATE_MODE = "home.sync.updateMode"; 1.36 +const PREF_SYNC_CHECK_INTERVAL_SECS = "home.sync.checkIntervalSecs"; 1.37 + 1.38 +XPCOMUtils.defineLazyGetter(this, "gSyncCheckIntervalSecs", function() { 1.39 + return Services.prefs.getIntPref(PREF_SYNC_CHECK_INTERVAL_SECS); 1.40 +}); 1.41 + 1.42 +XPCOMUtils.defineLazyServiceGetter(this, 1.43 + "gUpdateTimerManager", "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager"); 1.44 + 1.45 +/** 1.46 + * All SQL statements should be defined here. 1.47 + */ 1.48 +const SQL = { 1.49 + createItemsTable: 1.50 + "CREATE TABLE items (" + 1.51 + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + 1.52 + "dataset_id TEXT NOT NULL, " + 1.53 + "url TEXT," + 1.54 + "title TEXT," + 1.55 + "description TEXT," + 1.56 + "image_url TEXT," + 1.57 + "filter TEXT," + 1.58 + "created INTEGER" + 1.59 + ")", 1.60 + 1.61 + dropItemsTable: 1.62 + "DROP TABLE items", 1.63 + 1.64 + insertItem: 1.65 + "INSERT INTO items (dataset_id, url, title, description, image_url, filter, created) " + 1.66 + "VALUES (:dataset_id, :url, :title, :description, :image_url, :filter, :created)", 1.67 + 1.68 + deleteFromDataset: 1.69 + "DELETE FROM items WHERE dataset_id = :dataset_id" 1.70 +} 1.71 + 1.72 +/** 1.73 + * Technically this function checks to see if the user is on a local network, 1.74 + * but we express this as "wifi" to the user. 1.75 + */ 1.76 +function isUsingWifi() { 1.77 + let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService); 1.78 + return (network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_WIFI || network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET); 1.79 +} 1.80 + 1.81 +function getNowInSeconds() { 1.82 + return Math.round(Date.now() / 1000); 1.83 +} 1.84 + 1.85 +function getLastSyncPrefName(datasetId) { 1.86 + return PREF_STORAGE_LAST_SYNC_TIME_PREFIX + datasetId; 1.87 +} 1.88 + 1.89 +// Whether or not we've registered an update timer. 1.90 +var gTimerRegistered = false; 1.91 + 1.92 +// Map of datasetId -> { interval: <integer>, callback: <function> } 1.93 +var gSyncCallbacks = {}; 1.94 + 1.95 +/** 1.96 + * nsITimerCallback implementation. Checks to see if it's time to sync any registered datasets. 1.97 + * 1.98 + * @param timer The timer which has expired. 1.99 + */ 1.100 +function syncTimerCallback(timer) { 1.101 + for (let datasetId in gSyncCallbacks) { 1.102 + let lastSyncTime = 0; 1.103 + try { 1.104 + lastSyncTime = Services.prefs.getIntPref(getLastSyncPrefName(datasetId)); 1.105 + } catch(e) { } 1.106 + 1.107 + let now = getNowInSeconds(); 1.108 + let { interval: interval, callback: callback } = gSyncCallbacks[datasetId]; 1.109 + 1.110 + if (lastSyncTime < now - interval) { 1.111 + let success = HomeProvider.requestSync(datasetId, callback); 1.112 + if (success) { 1.113 + Services.prefs.setIntPref(getLastSyncPrefName(datasetId), now); 1.114 + } 1.115 + } 1.116 + } 1.117 +} 1.118 + 1.119 +this.HomeStorage = function(datasetId) { 1.120 + this.datasetId = datasetId; 1.121 +}; 1.122 + 1.123 +this.ValidationError = function(message) { 1.124 + this.name = "ValidationError"; 1.125 + this.message = message; 1.126 +}; 1.127 +ValidationError.prototype = new Error(); 1.128 +ValidationError.prototype.constructor = ValidationError; 1.129 + 1.130 +this.HomeProvider = Object.freeze({ 1.131 + ValidationError: ValidationError, 1.132 + 1.133 + /** 1.134 + * Returns a storage associated with a given dataset identifer. 1.135 + * 1.136 + * @param datasetId 1.137 + * (string) Unique identifier for the dataset. 1.138 + * 1.139 + * @return HomeStorage 1.140 + */ 1.141 + getStorage: function(datasetId) { 1.142 + return new HomeStorage(datasetId); 1.143 + }, 1.144 + 1.145 + /** 1.146 + * Checks to see if it's an appropriate time to sync. 1.147 + * 1.148 + * @param datasetId Unique identifier for the dataset to sync. 1.149 + * @param callback Function to call when it's time to sync, called with datasetId as a parameter. 1.150 + * 1.151 + * @return boolean Whether or not we were able to sync. 1.152 + */ 1.153 + requestSync: function(datasetId, callback) { 1.154 + // Make sure it's a good time to sync. 1.155 + if ((Services.prefs.getIntPref(PREF_SYNC_UPDATE_MODE) === 1) && !isUsingWifi()) { 1.156 + Cu.reportError("HomeProvider: Failed to sync because device is not on a local network"); 1.157 + return false; 1.158 + } 1.159 + 1.160 + callback(datasetId); 1.161 + return true; 1.162 + }, 1.163 + 1.164 + /** 1.165 + * Specifies that a sync should be requested for the given dataset and update interval. 1.166 + * 1.167 + * @param datasetId Unique identifier for the dataset to sync. 1.168 + * @param interval Update interval in seconds. By default, this is throttled to 3600 seconds (1 hour). 1.169 + * @param callback Function to call when it's time to sync, called with datasetId as a parameter. 1.170 + */ 1.171 + addPeriodicSync: function(datasetId, interval, callback) { 1.172 + // Warn developers if they're expecting more frequent notifications that we allow. 1.173 + if (interval < gSyncCheckIntervalSecs) { 1.174 + Cu.reportError("HomeProvider: Warning for dataset " + datasetId + 1.175 + " : Sync notifications are throttled to " + gSyncCheckIntervalSecs + " seconds"); 1.176 + } 1.177 + 1.178 + gSyncCallbacks[datasetId] = { 1.179 + interval: interval, 1.180 + callback: callback 1.181 + }; 1.182 + 1.183 + if (!gTimerRegistered) { 1.184 + gUpdateTimerManager.registerTimer("home-provider-sync-timer", syncTimerCallback, gSyncCheckIntervalSecs); 1.185 + gTimerRegistered = true; 1.186 + } 1.187 + }, 1.188 + 1.189 + /** 1.190 + * Removes a periodic sync timer. 1.191 + * 1.192 + * @param datasetId Dataset to sync. 1.193 + */ 1.194 + removePeriodicSync: function(datasetId) { 1.195 + delete gSyncCallbacks[datasetId]; 1.196 + Services.prefs.clearUserPref(getLastSyncPrefName(datasetId)); 1.197 + // You can't unregister a update timer, so we don't try to do that. 1.198 + } 1.199 +}); 1.200 + 1.201 +var gDatabaseEnsured = false; 1.202 + 1.203 +/** 1.204 + * Creates the database schema. 1.205 + */ 1.206 +function createDatabase(db) { 1.207 + return Task.spawn(function create_database_task() { 1.208 + yield db.execute(SQL.createItemsTable); 1.209 + }); 1.210 +} 1.211 + 1.212 +/** 1.213 + * Migrates the database schema to a new version. 1.214 + */ 1.215 +function upgradeDatabase(db, oldVersion, newVersion) { 1.216 + return Task.spawn(function upgrade_database_task() { 1.217 + for (let v = oldVersion + 1; v <= newVersion; v++) { 1.218 + switch(v) { 1.219 + case 2: 1.220 + // Recreate the items table discarding any 1.221 + // existing data. 1.222 + yield db.execute(SQL.dropItemsTable); 1.223 + yield db.execute(SQL.createItemsTable); 1.224 + break; 1.225 + } 1.226 + } 1.227 + }); 1.228 +} 1.229 + 1.230 +/** 1.231 + * Opens a database connection and makes sure that the database schema version 1.232 + * is correct, performing migrations if necessary. Consumers should be sure 1.233 + * to close any database connections they open. 1.234 + * 1.235 + * @return Promise 1.236 + * @resolves Handle on an opened SQLite database. 1.237 + */ 1.238 +function getDatabaseConnection() { 1.239 + return Task.spawn(function get_database_connection_task() { 1.240 + let db = yield Sqlite.openConnection({ path: DB_PATH }); 1.241 + if (gDatabaseEnsured) { 1.242 + throw new Task.Result(db); 1.243 + } 1.244 + 1.245 + try { 1.246 + // Check to see if we need to perform any migrations. 1.247 + let dbVersion = parseInt(yield db.getSchemaVersion()); 1.248 + 1.249 + // getSchemaVersion() returns a 0 int if the schema 1.250 + // version is undefined. 1.251 + if (dbVersion === 0) { 1.252 + yield createDatabase(db); 1.253 + } else if (dbVersion < SCHEMA_VERSION) { 1.254 + yield upgradeDatabase(db, dbVersion, SCHEMA_VERSION); 1.255 + } 1.256 + 1.257 + yield db.setSchemaVersion(SCHEMA_VERSION); 1.258 + } catch(e) { 1.259 + // Close the DB connection before passing the exception to the consumer. 1.260 + yield db.close(); 1.261 + throw e; 1.262 + } 1.263 + 1.264 + gDatabaseEnsured = true; 1.265 + throw new Task.Result(db); 1.266 + }); 1.267 +} 1.268 + 1.269 +/** 1.270 + * Validates an item to be saved to the DB. 1.271 + * 1.272 + * @param item 1.273 + * (object) item object to be validated. 1.274 + */ 1.275 +function validateItem(datasetId, item) { 1.276 + if (!item.url) { 1.277 + throw new ValidationError('HomeStorage: All rows must have an URL: datasetId = ' + 1.278 + datasetId); 1.279 + } 1.280 + 1.281 + if (!item.image_url && !item.title && !item.description) { 1.282 + throw new ValidationError('HomeStorage: All rows must have at least an image URL, ' + 1.283 + 'or a title or a description: datasetId = ' + datasetId); 1.284 + } 1.285 +} 1.286 + 1.287 +var gRefreshTimers = {}; 1.288 + 1.289 +/** 1.290 + * Sends a message to Java to refresh the given dataset. Delays sending 1.291 + * messages to avoid successive refreshes, which can result in flashing views. 1.292 + */ 1.293 +function refreshDataset(datasetId) { 1.294 + // Bail if there's already a refresh timer waiting to fire 1.295 + if (gRefreshTimers[datasetId]) { 1.296 + return; 1.297 + } 1.298 + 1.299 + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 1.300 + timer.initWithCallback(function(timer) { 1.301 + delete gRefreshTimers[datasetId]; 1.302 + 1.303 + sendMessageToJava({ 1.304 + type: "HomePanels:RefreshDataset", 1.305 + datasetId: datasetId 1.306 + }); 1.307 + }, 100, Ci.nsITimer.TYPE_ONE_SHOT); 1.308 + 1.309 + gRefreshTimers[datasetId] = timer; 1.310 +} 1.311 + 1.312 +HomeStorage.prototype = { 1.313 + /** 1.314 + * Saves data rows to the DB. 1.315 + * 1.316 + * @param data 1.317 + * An array of JS objects represnting row items to save. 1.318 + * Each object may have the following properties: 1.319 + * - url (string) 1.320 + * - title (string) 1.321 + * - description (string) 1.322 + * - image_url (string) 1.323 + * - filter (string) 1.324 + * @param options 1.325 + * A JS object holding additional cofiguration properties. 1.326 + * The following properties are currently supported: 1.327 + * - replace (boolean): Whether or not to replace existing items. 1.328 + * 1.329 + * @return Promise 1.330 + * @resolves When the operation has completed. 1.331 + */ 1.332 + save: function(data, options) { 1.333 + return Task.spawn(function save_task() { 1.334 + let db = yield getDatabaseConnection(); 1.335 + try { 1.336 + yield db.executeTransaction(function save_transaction() { 1.337 + if (options && options.replace) { 1.338 + yield db.executeCached(SQL.deleteFromDataset, { dataset_id: this.datasetId }); 1.339 + } 1.340 + 1.341 + // Insert data into DB. 1.342 + for (let item of data) { 1.343 + validateItem(this.datasetId, item); 1.344 + 1.345 + // XXX: Directly pass item as params? More validation for item? 1.346 + let params = { 1.347 + dataset_id: this.datasetId, 1.348 + url: item.url, 1.349 + title: item.title, 1.350 + description: item.description, 1.351 + image_url: item.image_url, 1.352 + filter: item.filter, 1.353 + created: Date.now() 1.354 + }; 1.355 + yield db.executeCached(SQL.insertItem, params); 1.356 + } 1.357 + }.bind(this)); 1.358 + } finally { 1.359 + yield db.close(); 1.360 + } 1.361 + 1.362 + refreshDataset(this.datasetId); 1.363 + }.bind(this)); 1.364 + }, 1.365 + 1.366 + /** 1.367 + * Deletes all rows associated with this storage. 1.368 + * 1.369 + * @return Promise 1.370 + * @resolves When the operation has completed. 1.371 + */ 1.372 + deleteAll: function() { 1.373 + return Task.spawn(function delete_all_task() { 1.374 + let db = yield getDatabaseConnection(); 1.375 + try { 1.376 + let params = { dataset_id: this.datasetId }; 1.377 + yield db.executeCached(SQL.deleteFromDataset, params); 1.378 + } finally { 1.379 + yield db.close(); 1.380 + } 1.381 + 1.382 + refreshDataset(this.datasetId); 1.383 + }.bind(this)); 1.384 + } 1.385 +};