diff options
Diffstat (limited to 'dom/network/NetworkStatsDB.jsm')
-rw-r--r-- | dom/network/NetworkStatsDB.jsm | 1285 |
1 files changed, 1285 insertions, 0 deletions
diff --git a/dom/network/NetworkStatsDB.jsm b/dom/network/NetworkStatsDB.jsm new file mode 100644 index 000000000..aa74d40ad --- /dev/null +++ b/dom/network/NetworkStatsDB.jsm @@ -0,0 +1,1285 @@ +/* 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 = ['NetworkStatsDB']; + +const DEBUG = false; +function debug(s) { dump("-*- NetworkStatsDB: " + s + "\n"); } + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/IndexedDBHelper.jsm"); +Cu.importGlobalProperties(["indexedDB"]); + +XPCOMUtils.defineLazyServiceGetter(this, "appsService", + "@mozilla.org/AppsService;1", + "nsIAppsService"); + +const DB_NAME = "net_stats"; +const DB_VERSION = 9; +const DEPRECATED_STATS_STORE_NAME = + [ + "net_stats_v2", // existed only in DB version 2 + "net_stats", // existed in DB version 1 and 3 to 5 + "net_stats_store", // existed in DB version 6 to 8 + ]; +const STATS_STORE_NAME = "net_stats_store_v3"; // since DB version 9 +const ALARMS_STORE_NAME = "net_alarm"; + +// Constant defining the maximum values allowed per interface. If more, older +// will be erased. +const VALUES_MAX_LENGTH = 6 * 30; + +// Constant defining the rate of the samples. Daily. +const SAMPLE_RATE = 1000 * 60 * 60 * 24; + +this.NetworkStatsDB = function NetworkStatsDB() { + if (DEBUG) { + debug("Constructor"); + } + this.initDBHelper(DB_NAME, DB_VERSION, [STATS_STORE_NAME, ALARMS_STORE_NAME]); +} + +NetworkStatsDB.prototype = { + __proto__: IndexedDBHelper.prototype, + + dbNewTxn: function dbNewTxn(store_name, txn_type, callback, txnCb) { + function successCb(result) { + txnCb(null, result); + } + function errorCb(error) { + txnCb(error, null); + } + return this.newTxn(txn_type, store_name, callback, successCb, errorCb); + }, + + /** + * The onupgradeneeded handler of the IDBOpenDBRequest. + * This function is called in IndexedDBHelper open() method. + * + * @param {IDBTransaction} aTransaction + * {IDBDatabase} aDb + * {64-bit integer} aOldVersion The version number on local storage. + * {64-bit integer} aNewVersion The version number to be upgraded to. + * + * @note Be careful with the database upgrade pattern. + * Because IndexedDB operations are performed asynchronously, we must + * apply a recursive approach instead of an iterative approach while + * upgrading versions. + */ + upgradeSchema: function upgradeSchema(aTransaction, aDb, aOldVersion, aNewVersion) { + if (DEBUG) { + debug("upgrade schema from: " + aOldVersion + " to " + aNewVersion + " called!"); + } + let db = aDb; + let objectStore; + + // An array of upgrade functions for each version. + let upgradeSteps = [ + function upgrade0to1() { + if (DEBUG) debug("Upgrade 0 to 1: Create object stores and indexes."); + + // Create the initial database schema. + objectStore = db.createObjectStore(DEPRECATED_STATS_STORE_NAME[1], + { keyPath: ["connectionType", "timestamp"] }); + objectStore.createIndex("connectionType", "connectionType", { unique: false }); + objectStore.createIndex("timestamp", "timestamp", { unique: false }); + objectStore.createIndex("rxBytes", "rxBytes", { unique: false }); + objectStore.createIndex("txBytes", "txBytes", { unique: false }); + objectStore.createIndex("rxTotalBytes", "rxTotalBytes", { unique: false }); + objectStore.createIndex("txTotalBytes", "txTotalBytes", { unique: false }); + + upgradeNextVersion(); + }, + + function upgrade1to2() { + if (DEBUG) debug("Upgrade 1 to 2: Do nothing."); + upgradeNextVersion(); + }, + + function upgrade2to3() { + if (DEBUG) debug("Upgrade 2 to 3: Add keyPath appId to object store."); + + // In order to support per-app traffic data storage, the original + // objectStore needs to be replaced by a new objectStore with new + // key path ("appId") and new index ("appId"). + // Also, since now networks are identified by their + // [networkId, networkType] not just by their connectionType, + // to modify the keyPath is mandatory to delete the object store + // and create it again. Old data is going to be deleted because the + // networkId for each sample can not be set. + + // In version 1.2 objectStore name was 'net_stats_v2', to avoid errors when + // upgrading from 1.2 to 1.3 objectStore name should be checked. + let stores = db.objectStoreNames; + let deprecatedName = DEPRECATED_STATS_STORE_NAME[0]; + let storeName = DEPRECATED_STATS_STORE_NAME[1]; + if(stores.contains(deprecatedName)) { + // Delete the obsolete stats store. + db.deleteObjectStore(deprecatedName); + } else { + // Re-create stats object store without copying records. + db.deleteObjectStore(storeName); + } + + objectStore = db.createObjectStore(storeName, { keyPath: ["appId", "network", "timestamp"] }); + objectStore.createIndex("appId", "appId", { unique: false }); + objectStore.createIndex("network", "network", { unique: false }); + objectStore.createIndex("networkType", "networkType", { unique: false }); + objectStore.createIndex("timestamp", "timestamp", { unique: false }); + objectStore.createIndex("rxBytes", "rxBytes", { unique: false }); + objectStore.createIndex("txBytes", "txBytes", { unique: false }); + objectStore.createIndex("rxTotalBytes", "rxTotalBytes", { unique: false }); + objectStore.createIndex("txTotalBytes", "txTotalBytes", { unique: false }); + + upgradeNextVersion(); + }, + + function upgrade3to4() { + if (DEBUG) debug("Upgrade 3 to 4: Delete redundant indexes."); + + // Delete redundant indexes (leave "network" only). + objectStore = aTransaction.objectStore(DEPRECATED_STATS_STORE_NAME[1]); + if (objectStore.indexNames.contains("appId")) { + objectStore.deleteIndex("appId"); + } + if (objectStore.indexNames.contains("networkType")) { + objectStore.deleteIndex("networkType"); + } + if (objectStore.indexNames.contains("timestamp")) { + objectStore.deleteIndex("timestamp"); + } + if (objectStore.indexNames.contains("rxBytes")) { + objectStore.deleteIndex("rxBytes"); + } + if (objectStore.indexNames.contains("txBytes")) { + objectStore.deleteIndex("txBytes"); + } + if (objectStore.indexNames.contains("rxTotalBytes")) { + objectStore.deleteIndex("rxTotalBytes"); + } + if (objectStore.indexNames.contains("txTotalBytes")) { + objectStore.deleteIndex("txTotalBytes"); + } + + upgradeNextVersion(); + }, + + function upgrade4to5() { + if (DEBUG) debug("Upgrade 4 to 5: Create object store for alarms."); + + // In order to manage alarms, it is necessary to use a global counter + // (totalBytes) that will increase regardless of the system reboot. + objectStore = aTransaction.objectStore(DEPRECATED_STATS_STORE_NAME[1]); + + // Now, systemBytes will hold the old totalBytes and totalBytes will + // keep the increasing counter. |counters| will keep the track of + // accumulated values. + let counters = {}; + + objectStore.openCursor().onsuccess = function(event) { + let cursor = event.target.result; + if (!cursor){ + // upgrade4to5 completed now. + upgradeNextVersion(); + return; + } + + cursor.value.rxSystemBytes = cursor.value.rxTotalBytes; + cursor.value.txSystemBytes = cursor.value.txTotalBytes; + + if (cursor.value.appId == 0) { + let netId = cursor.value.network[0] + '' + cursor.value.network[1]; + if (!counters[netId]) { + counters[netId] = { + rxCounter: 0, + txCounter: 0, + lastRx: 0, + lastTx: 0 + }; + } + + let rxDiff = cursor.value.rxSystemBytes - counters[netId].lastRx; + let txDiff = cursor.value.txSystemBytes - counters[netId].lastTx; + if (rxDiff < 0 || txDiff < 0) { + // System reboot between samples, so take the current one. + rxDiff = cursor.value.rxSystemBytes; + txDiff = cursor.value.txSystemBytes; + } + + counters[netId].rxCounter += rxDiff; + counters[netId].txCounter += txDiff; + cursor.value.rxTotalBytes = counters[netId].rxCounter; + cursor.value.txTotalBytes = counters[netId].txCounter; + + counters[netId].lastRx = cursor.value.rxSystemBytes; + counters[netId].lastTx = cursor.value.txSystemBytes; + } else { + cursor.value.rxTotalBytes = cursor.value.rxSystemBytes; + cursor.value.txTotalBytes = cursor.value.txSystemBytes; + } + + cursor.update(cursor.value); + cursor.continue(); + }; + + // Create object store for alarms. + objectStore = db.createObjectStore(ALARMS_STORE_NAME, { keyPath: "id", autoIncrement: true }); + objectStore.createIndex("alarm", ['networkId','threshold'], { unique: false }); + objectStore.createIndex("manifestURL", "manifestURL", { unique: false }); + }, + + function upgrade5to6() { + if (DEBUG) debug("Upgrade 5 to 6: Add keyPath serviceType to object store."); + + // In contrast to "per-app" traffic data, "system-only" traffic data + // refers to data which can not be identified by any applications. + // To further support "system-only" data storage, the data can be + // saved by service type (e.g., Tethering, OTA). Thus it's needed to + // have a new key ("serviceType") for the ojectStore. + let newObjectStore; + let deprecatedName = DEPRECATED_STATS_STORE_NAME[1]; + newObjectStore = db.createObjectStore(DEPRECATED_STATS_STORE_NAME[2], + { keyPath: ["appId", "serviceType", "network", "timestamp"] }); + newObjectStore.createIndex("network", "network", { unique: false }); + + // Copy the data from the original objectStore to the new objectStore. + objectStore = aTransaction.objectStore(deprecatedName); + objectStore.openCursor().onsuccess = function(event) { + let cursor = event.target.result; + if (!cursor) { + db.deleteObjectStore(deprecatedName); + // upgrade5to6 completed now. + upgradeNextVersion(); + return; + } + + let newStats = cursor.value; + newStats.serviceType = ""; + newObjectStore.put(newStats); + cursor.continue(); + }; + }, + + function upgrade6to7() { + if (DEBUG) debug("Upgrade 6 to 7: Replace alarm threshold by relativeThreshold."); + + // Replace threshold attribute of alarm index by relativeThreshold in alarms DB. + // Now alarms are indexed by relativeThreshold, which is the threshold relative + // to current system stats. + let alarmsStore = aTransaction.objectStore(ALARMS_STORE_NAME); + + // Delete "alarm" index. + if (alarmsStore.indexNames.contains("alarm")) { + alarmsStore.deleteIndex("alarm"); + } + + // Create new "alarm" index. + alarmsStore.createIndex("alarm", ['networkId','relativeThreshold'], { unique: false }); + + // Populate new "alarm" index attributes. + alarmsStore.openCursor().onsuccess = function(event) { + let cursor = event.target.result; + if (!cursor) { + upgrade6to7_updateTotalBytes(); + return; + } + + cursor.value.relativeThreshold = cursor.value.threshold; + cursor.value.absoluteThreshold = cursor.value.threshold; + delete cursor.value.threshold; + + cursor.update(cursor.value); + cursor.continue(); + } + + function upgrade6to7_updateTotalBytes() { + if (DEBUG) debug("Upgrade 6 to 7: Update TotalBytes."); + // Previous versions save accumulative totalBytes, increasing although the system + // reboots or resets stats. But is necessary to reset the total counters when reset + // through 'clearInterfaceStats'. + let statsStore = aTransaction.objectStore(DEPRECATED_STATS_STORE_NAME[2]); + let networks = []; + + // Find networks stored in the database. + statsStore.index("network").openKeyCursor(null, "nextunique").onsuccess = function(event) { + let cursor = event.target.result; + + // Store each network into an array. + if (cursor) { + networks.push(cursor.key); + cursor.continue(); + return; + } + + // Start to deal with each network. + let pending = networks.length; + + if (pending === 0) { + // Found no records of network. upgrade6to7 completed now. + upgradeNextVersion(); + return; + } + + networks.forEach(function(network) { + let lowerFilter = [0, "", network, 0]; + let upperFilter = [0, "", network, ""]; + let range = IDBKeyRange.bound(lowerFilter, upperFilter, false, false); + + // Find number of samples for a given network. + statsStore.count(range).onsuccess = function(event) { + let recordCount = event.target.result; + + // If there are more samples than the max allowed, there is no way to know + // when does reset take place. + if (recordCount === 0 || recordCount >= VALUES_MAX_LENGTH) { + pending--; + if (pending === 0) { + upgradeNextVersion(); + } + return; + } + + let last = null; + // Reset detected if the first sample totalCounters are different than bytes + // counters. If so, the total counters should be recalculated. + statsStore.openCursor(range).onsuccess = function(event) { + let cursor = event.target.result; + if (!cursor) { + pending--; + if (pending === 0) { + upgradeNextVersion(); + } + return; + } + if (!last) { + if (cursor.value.rxTotalBytes == cursor.value.rxBytes && + cursor.value.txTotalBytes == cursor.value.txBytes) { + pending--; + if (pending === 0) { + upgradeNextVersion(); + } + return; + } + + cursor.value.rxTotalBytes = cursor.value.rxBytes; + cursor.value.txTotalBytes = cursor.value.txBytes; + cursor.update(cursor.value); + last = cursor.value; + cursor.continue(); + return; + } + + // Recalculate the total counter for last / current sample + cursor.value.rxTotalBytes = last.rxTotalBytes + cursor.value.rxBytes; + cursor.value.txTotalBytes = last.txTotalBytes + cursor.value.txBytes; + cursor.update(cursor.value); + last = cursor.value; + cursor.continue(); + } + } + }, this); // end of networks.forEach() + }; // end of statsStore.index("network").openKeyCursor().onsuccess callback + } // end of function upgrade6to7_updateTotalBytes + }, + + function upgrade7to8() { + if (DEBUG) debug("Upgrade 7 to 8: Create index serviceType."); + + // Create index for 'ServiceType' in order to make it retrievable. + let statsStore = aTransaction.objectStore(DEPRECATED_STATS_STORE_NAME[2]); + statsStore.createIndex("serviceType", "serviceType", { unique: false }); + + upgradeNextVersion(); + }, + + function upgrade8to9() { + if (DEBUG) debug("Upgrade 8 to 9: Add keyPath isInBrowser to " + + "network stats object store"); + + // Since B2G v2.0, there is no stand-alone browser app anymore. + // The browser app is a mozbrowser iframe element owned by system app. + // In order to separate traffic generated from system and browser, we + // have to add a new attribute |isInBrowser| as keyPath. + // Refer to bug 1070944 for more detail. + let newObjectStore; + let deprecatedName = DEPRECATED_STATS_STORE_NAME[2]; + newObjectStore = db.createObjectStore(STATS_STORE_NAME, + { keyPath: ["appId", "isInBrowser", "serviceType", + "network", "timestamp"] }); + newObjectStore.createIndex("network", "network", { unique: false }); + newObjectStore.createIndex("serviceType", "serviceType", { unique: false }); + + // Copy records from the current object store to the new one. + objectStore = aTransaction.objectStore(deprecatedName); + objectStore.openCursor().onsuccess = function (event) { + let cursor = event.target.result; + if (!cursor) { + db.deleteObjectStore(deprecatedName); + // upgrade8to9 completed now. + return; + } + let newStats = cursor.value; + // Augment records by adding the new isInBrowser attribute. + // Notes: + // 1. Key value cannot be boolean type. Use 1/0 instead of true/false. + // 2. Most traffic of system app should come from its browser iframe, + // thus assign isInBrowser as 1 for system app. + let manifestURL = appsService.getManifestURLByLocalId(newStats.appId); + if (manifestURL && manifestURL.search(/app:\/\/system\./) === 0) { + newStats.isInBrowser = 1; + } else { + newStats.isInBrowser = 0; + } + newObjectStore.put(newStats); + cursor.continue(); + }; + } + ]; + + let index = aOldVersion; + let outer = this; + + function upgradeNextVersion() { + if (index == aNewVersion) { + debug("Upgrade finished."); + return; + } + + try { + var i = index++; + if (DEBUG) debug("Upgrade step: " + i + "\n"); + upgradeSteps[i].call(outer); + } catch (ex) { + dump("Caught exception " + ex); + throw ex; + return; + } + } + + if (aNewVersion > upgradeSteps.length) { + debug("No migration steps for the new version!"); + aTransaction.abort(); + return; + } + + upgradeNextVersion(); + }, + + importData: function importData(aStats) { + let stats = { appId: aStats.appId, + isInBrowser: aStats.isInBrowser ? 1 : 0, + serviceType: aStats.serviceType, + network: [aStats.networkId, aStats.networkType], + timestamp: aStats.timestamp, + rxBytes: aStats.rxBytes, + txBytes: aStats.txBytes, + rxSystemBytes: aStats.rxSystemBytes, + txSystemBytes: aStats.txSystemBytes, + rxTotalBytes: aStats.rxTotalBytes, + txTotalBytes: aStats.txTotalBytes }; + + return stats; + }, + + exportData: function exportData(aStats) { + let stats = { appId: aStats.appId, + isInBrowser: aStats.isInBrowser ? true : false, + serviceType: aStats.serviceType, + networkId: aStats.network[0], + networkType: aStats.network[1], + timestamp: aStats.timestamp, + rxBytes: aStats.rxBytes, + txBytes: aStats.txBytes, + rxTotalBytes: aStats.rxTotalBytes, + txTotalBytes: aStats.txTotalBytes }; + + return stats; + }, + + normalizeDate: function normalizeDate(aDate) { + // Convert to UTC according to timezone and + // filter timestamp to get SAMPLE_RATE precission + let timestamp = aDate.getTime() - aDate.getTimezoneOffset() * 60 * 1000; + timestamp = Math.floor(timestamp / SAMPLE_RATE) * SAMPLE_RATE; + return timestamp; + }, + + saveStats: function saveStats(aStats, aResultCb) { + let isAccumulative = aStats.isAccumulative; + let timestamp = this.normalizeDate(aStats.date); + + let stats = { appId: aStats.appId, + isInBrowser: aStats.isInBrowser, + serviceType: aStats.serviceType, + networkId: aStats.networkId, + networkType: aStats.networkType, + timestamp: timestamp, + rxBytes: isAccumulative ? 0 : aStats.rxBytes, + txBytes: isAccumulative ? 0 : aStats.txBytes, + rxSystemBytes: isAccumulative ? aStats.rxBytes : 0, + txSystemBytes: isAccumulative ? aStats.txBytes : 0, + rxTotalBytes: isAccumulative ? aStats.rxBytes : 0, + txTotalBytes: isAccumulative ? aStats.txBytes : 0 }; + + stats = this.importData(stats); + + this.dbNewTxn(STATS_STORE_NAME, "readwrite", function(aTxn, aStore) { + if (DEBUG) { + debug("Filtered time: " + new Date(timestamp)); + debug("New stats: " + JSON.stringify(stats)); + } + + let lowerFilter = [stats.appId, stats.isInBrowser, stats.serviceType, + stats.network, 0]; + let upperFilter = [stats.appId, stats.isInBrowser, stats.serviceType, + stats.network, ""]; + let range = IDBKeyRange.bound(lowerFilter, upperFilter, false, false); + + let request = aStore.openCursor(range, 'prev'); + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (!cursor) { + // Empty, so save first element. + + if (!isAccumulative) { + this._saveStats(aTxn, aStore, stats); + return; + } + + // There could be a time delay between the point when the network + // interface comes up and the point when the database is initialized. + // In this short interval some traffic data are generated but are not + // registered by the first sample. + stats.rxBytes = stats.rxTotalBytes; + stats.txBytes = stats.txTotalBytes; + + // However, if the interface is not switched on after the database is + // initialized (dual sim use case) stats should be set to 0. + let req = aStore.index("network").openKeyCursor(null, "nextunique"); + req.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + if (cursor.key[1] == stats.network[1]) { + stats.rxBytes = 0; + stats.txBytes = 0; + this._saveStats(aTxn, aStore, stats); + return; + } + + cursor.continue(); + return; + } + + this._saveStats(aTxn, aStore, stats); + }.bind(this); + + return; + } + + // There are old samples + if (DEBUG) { + debug("Last value " + JSON.stringify(cursor.value)); + } + + // Remove stats previous to now - VALUE_MAX_LENGTH + this._removeOldStats(aTxn, aStore, stats.appId, stats.isInBrowser, + stats.serviceType, stats.network, stats.timestamp); + + // Process stats before save + this._processSamplesDiff(aTxn, aStore, cursor, stats, isAccumulative); + }.bind(this); + }.bind(this), aResultCb); + }, + + /* + * This function check that stats are saved in the database following the sample rate. + * In this way is easier to find elements when stats are requested. + */ + _processSamplesDiff: function _processSamplesDiff(aTxn, + aStore, + aLastSampleCursor, + aNewSample, + aIsAccumulative) { + let lastSample = aLastSampleCursor.value; + + // Get difference between last and new sample. + let diff = (aNewSample.timestamp - lastSample.timestamp) / SAMPLE_RATE; + if (diff % 1) { + // diff is decimal, so some error happened because samples are stored as a multiple + // of SAMPLE_RATE + aTxn.abort(); + throw new Error("Error processing samples"); + } + + if (DEBUG) { + debug("New: " + aNewSample.timestamp + " - Last: " + + lastSample.timestamp + " - diff: " + diff); + } + + // If the incoming data has a accumulation feature, the new + // |txBytes|/|rxBytes| is assigend by differnces between the new + // |txTotalBytes|/|rxTotalBytes| and the last |txTotalBytes|/|rxTotalBytes|. + // Else, if incoming data is non-accumulative, the |txBytes|/|rxBytes| + // is the new |txBytes|/|rxBytes|. + let rxDiff = 0; + let txDiff = 0; + if (aIsAccumulative) { + rxDiff = aNewSample.rxSystemBytes - lastSample.rxSystemBytes; + txDiff = aNewSample.txSystemBytes - lastSample.txSystemBytes; + if (rxDiff < 0 || txDiff < 0) { + rxDiff = aNewSample.rxSystemBytes; + txDiff = aNewSample.txSystemBytes; + } + aNewSample.rxBytes = rxDiff; + aNewSample.txBytes = txDiff; + + aNewSample.rxTotalBytes = lastSample.rxTotalBytes + rxDiff; + aNewSample.txTotalBytes = lastSample.txTotalBytes + txDiff; + } else { + rxDiff = aNewSample.rxBytes; + txDiff = aNewSample.txBytes; + } + + if (diff == 1) { + // New element. + + // If the incoming data is non-accumulative, the new + // |rxTotalBytes|/|txTotalBytes| needs to be updated by adding new + // |rxBytes|/|txBytes| to the last |rxTotalBytes|/|txTotalBytes|. + if (!aIsAccumulative) { + aNewSample.rxTotalBytes = aNewSample.rxBytes + lastSample.rxTotalBytes; + aNewSample.txTotalBytes = aNewSample.txBytes + lastSample.txTotalBytes; + } + + this._saveStats(aTxn, aStore, aNewSample); + return; + } + if (diff > 1) { + // Some samples lost. Device off during one or more samplerate periods. + // Time or timezone changed + // Add lost samples with 0 bytes and the actual one. + if (diff > VALUES_MAX_LENGTH) { + diff = VALUES_MAX_LENGTH; + } + + let data = []; + for (let i = diff - 2; i >= 0; i--) { + let time = aNewSample.timestamp - SAMPLE_RATE * (i + 1); + let sample = { appId: aNewSample.appId, + isInBrowser: aNewSample.isInBrowser, + serviceType: aNewSample.serviceType, + network: aNewSample.network, + timestamp: time, + rxBytes: 0, + txBytes: 0, + rxSystemBytes: lastSample.rxSystemBytes, + txSystemBytes: lastSample.txSystemBytes, + rxTotalBytes: lastSample.rxTotalBytes, + txTotalBytes: lastSample.txTotalBytes }; + + data.push(sample); + } + + data.push(aNewSample); + this._saveStats(aTxn, aStore, data); + return; + } + if (diff == 0 || diff < 0) { + // New element received before samplerate period. It means that device has + // been restarted (or clock / timezone change). + // Update element. If diff < 0, clock or timezone changed back. Place data + // in the last sample. + + // Old |rxTotalBytes|/|txTotalBytes| needs to get updated by adding the + // last |rxTotalBytes|/|txTotalBytes|. + lastSample.rxBytes += rxDiff; + lastSample.txBytes += txDiff; + lastSample.rxSystemBytes = aNewSample.rxSystemBytes; + lastSample.txSystemBytes = aNewSample.txSystemBytes; + lastSample.rxTotalBytes += rxDiff; + lastSample.txTotalBytes += txDiff; + + if (DEBUG) { + debug("Update: " + JSON.stringify(lastSample)); + } + let req = aLastSampleCursor.update(lastSample); + } + }, + + _saveStats: function _saveStats(aTxn, aStore, aNetworkStats) { + if (DEBUG) { + debug("_saveStats: " + JSON.stringify(aNetworkStats)); + } + + if (Array.isArray(aNetworkStats)) { + let len = aNetworkStats.length - 1; + for (let i = 0; i <= len; i++) { + aStore.put(aNetworkStats[i]); + } + } else { + aStore.put(aNetworkStats); + } + }, + + _removeOldStats: function _removeOldStats(aTxn, aStore, aAppId, aIsInBrowser, + aServiceType, aNetwork, aDate) { + // Callback function to remove old items when new ones are added. + let filterDate = aDate - (SAMPLE_RATE * VALUES_MAX_LENGTH - 1); + let lowerFilter = [aAppId, aIsInBrowser, aServiceType, aNetwork, 0]; + let upperFilter = [aAppId, aIsInBrowser, aServiceType, aNetwork, filterDate]; + let range = IDBKeyRange.bound(lowerFilter, upperFilter, false, false); + let lastSample = null; + let self = this; + + aStore.openCursor(range).onsuccess = function(event) { + var cursor = event.target.result; + if (cursor) { + lastSample = cursor.value; + cursor.delete(); + cursor.continue(); + return; + } + + // If all samples for a network are removed, an empty sample + // has to be saved to keep the totalBytes in order to compute + // future samples because system counters are not set to 0. + // Thus, if there are no samples left, the last sample removed + // will be saved again after setting its bytes to 0. + let request = aStore.index("network").openCursor(aNetwork); + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (!cursor && lastSample != null) { + let timestamp = new Date(); + timestamp = self.normalizeDate(timestamp); + lastSample.timestamp = timestamp; + lastSample.rxBytes = 0; + lastSample.txBytes = 0; + self._saveStats(aTxn, aStore, lastSample); + } + }; + }; + }, + + clearInterfaceStats: function clearInterfaceStats(aNetwork, aResultCb) { + let network = [aNetwork.network.id, aNetwork.network.type]; + let self = this; + + // Clear and save an empty sample to keep sync with system counters + this.dbNewTxn(STATS_STORE_NAME, "readwrite", function(aTxn, aStore) { + let sample = null; + let request = aStore.index("network").openCursor(network, "prev"); + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + if (!sample && cursor.value.appId == 0) { + sample = cursor.value; + } + + cursor.delete(); + cursor.continue(); + return; + } + + if (sample) { + let timestamp = new Date(); + timestamp = self.normalizeDate(timestamp); + sample.timestamp = timestamp; + sample.appId = 0; + sample.isInBrowser = 0; + sample.serviceType = ""; + sample.rxBytes = 0; + sample.txBytes = 0; + sample.rxTotalBytes = 0; + sample.txTotalBytes = 0; + + self._saveStats(aTxn, aStore, sample); + } + }; + }, this._resetAlarms.bind(this, aNetwork.networkId, aResultCb)); + }, + + clearStats: function clearStats(aNetworks, aResultCb) { + let index = 0; + let stats = []; + let self = this; + + let callback = function(aError, aResult) { + index++; + + if (!aError && index < aNetworks.length) { + self.clearInterfaceStats(aNetworks[index], callback); + return; + } + + aResultCb(aError, aResult); + }; + + if (!aNetworks[index]) { + aResultCb(null, true); + return; + } + this.clearInterfaceStats(aNetworks[index], callback); + }, + + getCurrentStats: function getCurrentStats(aNetwork, aDate, aResultCb) { + if (DEBUG) { + debug("Get current stats for " + JSON.stringify(aNetwork) + " since " + aDate); + } + + let network = [aNetwork.id, aNetwork.type]; + if (aDate) { + this._getCurrentStatsFromDate(network, aDate, aResultCb); + return; + } + + this._getCurrentStats(network, aResultCb); + }, + + _getCurrentStats: function _getCurrentStats(aNetwork, aResultCb) { + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(txn, store) { + let request = null; + let upperFilter = [0, 1, "", aNetwork, Date.now()]; + let range = IDBKeyRange.upperBound(upperFilter, false); + let result = { rxBytes: 0, txBytes: 0, + rxTotalBytes: 0, txTotalBytes: 0 }; + + request = store.openCursor(range, "prev"); + + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + result.rxBytes = result.rxTotalBytes = cursor.value.rxTotalBytes; + result.txBytes = result.txTotalBytes = cursor.value.txTotalBytes; + } + + txn.result = result; + }; + }.bind(this), aResultCb); + }, + + _getCurrentStatsFromDate: function _getCurrentStatsFromDate(aNetwork, aDate, aResultCb) { + aDate = new Date(aDate); + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(txn, store) { + let request = null; + let start = this.normalizeDate(aDate); + let upperFilter = [0, 1, "", aNetwork, Date.now()]; + let range = IDBKeyRange.upperBound(upperFilter, false); + let result = { rxBytes: 0, txBytes: 0, + rxTotalBytes: 0, txTotalBytes: 0 }; + + request = store.openCursor(range, "prev"); + + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + result.rxBytes = result.rxTotalBytes = cursor.value.rxTotalBytes; + result.txBytes = result.txTotalBytes = cursor.value.txTotalBytes; + } + + let timestamp = cursor.value.timestamp; + let range = IDBKeyRange.lowerBound(lowerFilter, false); + request = store.openCursor(range); + + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + if (cursor.value.timestamp == timestamp) { + // There is one sample only. + result.rxBytes = cursor.value.rxBytes; + result.txBytes = cursor.value.txBytes; + } else { + result.rxBytes -= cursor.value.rxTotalBytes; + result.txBytes -= cursor.value.txTotalBytes; + } + } + + txn.result = result; + }; + }; + }.bind(this), aResultCb); + }, + + find: function find(aResultCb, aAppId, aBrowsingTrafficOnly, aServiceType, + aNetwork, aStart, aEnd, aAppManifestURL) { + let offset = (new Date()).getTimezoneOffset() * 60 * 1000; + let start = this.normalizeDate(aStart); + let end = this.normalizeDate(aEnd); + + if (DEBUG) { + debug("Find samples for appId: " + aAppId + + " browsingTrafficOnly: " + aBrowsingTrafficOnly + + " serviceType: " + aServiceType + + " network: " + JSON.stringify(aNetwork) + " from " + start + + " until " + end); + debug("Start time: " + new Date(start)); + debug("End time: " + new Date(end)); + } + + // Find samples of browsing traffic (isInBrowser = 1) first since they are + // needed no matter browsingTrafficOnly is true or false. + // We have to make two queries to database because we cannot filter correct + // records by a single query that sets ranges for two keys (isInBrowser and + // timestamp). We think it is because the keyPath contains an array + // (network) so such query does not work. + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(aTxn, aStore) { + let network = [aNetwork.id, aNetwork.type]; + let lowerFilter = [aAppId, 1, aServiceType, network, start]; + let upperFilter = [aAppId, 1, aServiceType, network, end]; + let range = IDBKeyRange.bound(lowerFilter, upperFilter, false, false); + + let data = []; + + if (!aTxn.result) { + aTxn.result = {}; + } + aTxn.result.appManifestURL = aAppManifestURL; + aTxn.result.browsingTrafficOnly = aBrowsingTrafficOnly; + aTxn.result.serviceType = aServiceType; + aTxn.result.network = aNetwork; + aTxn.result.start = aStart; + aTxn.result.end = aEnd; + + let request = aStore.openCursor(range).onsuccess = function(event) { + var cursor = event.target.result; + if (cursor){ + // We use rxTotalBytes/txTotalBytes instead of rxBytes/txBytes for + // the first (oldest) sample. The rx/txTotalBytes fields record + // accumulative usage amount, which means even if old samples were + // expired and removed from the Database, we can still obtain the + // correct network usage. + if (data.length == 0) { + data.push({ rxBytes: cursor.value.rxTotalBytes, + txBytes: cursor.value.txTotalBytes, + date: new Date(cursor.value.timestamp + offset) }); + } else { + data.push({ rxBytes: cursor.value.rxBytes, + txBytes: cursor.value.txBytes, + date: new Date(cursor.value.timestamp + offset) }); + } + cursor.continue(); + return; + } + + if (aBrowsingTrafficOnly) { + this.fillResultSamples(start + offset, end + offset, data); + aTxn.result.data = data; + return; + } + + // Find samples of app traffic (isInBrowser = 0) as well if + // browsingTrafficOnly is false. + lowerFilter = [aAppId, 0, aServiceType, network, start]; + upperFilter = [aAppId, 0, aServiceType, network, end]; + range = IDBKeyRange.bound(lowerFilter, upperFilter, false, false); + request = aStore.openCursor(range).onsuccess = function(event) { + cursor = event.target.result; + if (cursor) { + var date = new Date(cursor.value.timestamp + offset); + var foundData = data.find(function (element, index, array) { + if (element.date.getTime() !== date.getTime()) { + return false; + } + return element; + }, date); + + if (foundData) { + foundData.rxBytes += cursor.value.rxBytes; + foundData.txBytes += cursor.value.txBytes; + } else { + // We use rxTotalBytes/txTotalBytes instead of rxBytes/txBytes + // for the first (oldest) sample. The rx/txTotalBytes fields + // record accumulative usage amount, which means even if old + // samples were expired and removed from the Database, we can + // still obtain the correct network usage. + if (data.length == 0) { + data.push({ rxBytes: cursor.value.rxTotalBytes, + txBytes: cursor.value.txTotalBytes, + date: new Date(cursor.value.timestamp + offset) }); + } else { + data.push({ rxBytes: cursor.value.rxBytes, + txBytes: cursor.value.txBytes, + date: new Date(cursor.value.timestamp + offset) }); + } + } + cursor.continue(); + return; + } + this.fillResultSamples(start + offset, end + offset, data); + aTxn.result.data = data; + }.bind(this); // openCursor(range).onsuccess() callback + }.bind(this); // openCursor(range).onsuccess() callback + }.bind(this), aResultCb); + }, + + /* + * Fill data array (samples from database) with empty samples to match + * requested start / end dates. + */ + fillResultSamples: function fillResultSamples(aStart, aEnd, aData) { + if (aData.length == 0) { + aData.push({ rxBytes: undefined, + txBytes: undefined, + date: new Date(aStart) }); + } + + while (aStart < aData[0].date.getTime()) { + aData.unshift({ rxBytes: undefined, + txBytes: undefined, + date: new Date(aData[0].date.getTime() - SAMPLE_RATE) }); + } + + while (aEnd > aData[aData.length - 1].date.getTime()) { + aData.push({ rxBytes: undefined, + txBytes: undefined, + date: new Date(aData[aData.length - 1].date.getTime() + SAMPLE_RATE) }); + } + }, + + getAvailableNetworks: function getAvailableNetworks(aResultCb) { + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(aTxn, aStore) { + if (!aTxn.result) { + aTxn.result = []; + } + + let request = aStore.index("network").openKeyCursor(null, "nextunique"); + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + aTxn.result.push({ id: cursor.key[0], + type: cursor.key[1] }); + cursor.continue(); + return; + } + }; + }, aResultCb); + }, + + isNetworkAvailable: function isNetworkAvailable(aNetwork, aResultCb) { + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(aTxn, aStore) { + if (!aTxn.result) { + aTxn.result = false; + } + + let network = [aNetwork.id, aNetwork.type]; + let request = aStore.index("network").openKeyCursor(IDBKeyRange.only(network)); + request.onsuccess = function onsuccess(event) { + if (event.target.result) { + aTxn.result = true; + } + }; + }, aResultCb); + }, + + getAvailableServiceTypes: function getAvailableServiceTypes(aResultCb) { + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(aTxn, aStore) { + if (!aTxn.result) { + aTxn.result = []; + } + + let request = aStore.index("serviceType").openKeyCursor(null, "nextunique"); + request.onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor && cursor.key != "") { + aTxn.result.push({ serviceType: cursor.key }); + cursor.continue(); + return; + } + }; + }, aResultCb); + }, + + get sampleRate () { + return SAMPLE_RATE; + }, + + get maxStorageSamples () { + return VALUES_MAX_LENGTH; + }, + + logAllRecords: function logAllRecords(aResultCb) { + this.dbNewTxn(STATS_STORE_NAME, "readonly", function(aTxn, aStore) { + aStore.mozGetAll().onsuccess = function onsuccess(event) { + aTxn.result = event.target.result; + }; + }, aResultCb); + }, + + alarmToRecord: function alarmToRecord(aAlarm) { + let record = { networkId: aAlarm.networkId, + absoluteThreshold: aAlarm.absoluteThreshold, + relativeThreshold: aAlarm.relativeThreshold, + startTime: aAlarm.startTime, + data: aAlarm.data, + manifestURL: aAlarm.manifestURL, + pageURL: aAlarm.pageURL }; + + if (aAlarm.id) { + record.id = aAlarm.id; + } + + return record; + }, + + recordToAlarm: function recordToalarm(aRecord) { + let alarm = { networkId: aRecord.networkId, + absoluteThreshold: aRecord.absoluteThreshold, + relativeThreshold: aRecord.relativeThreshold, + startTime: aRecord.startTime, + data: aRecord.data, + manifestURL: aRecord.manifestURL, + pageURL: aRecord.pageURL }; + + if (aRecord.id) { + alarm.id = aRecord.id; + } + + return alarm; + }, + + addAlarm: function addAlarm(aAlarm, aResultCb) { + this.dbNewTxn(ALARMS_STORE_NAME, "readwrite", function(txn, store) { + if (DEBUG) { + debug("Going to add " + JSON.stringify(aAlarm)); + } + + let record = this.alarmToRecord(aAlarm); + store.put(record).onsuccess = function setResult(aEvent) { + txn.result = aEvent.target.result; + if (DEBUG) { + debug("Request successful. New record ID: " + txn.result); + } + }; + }.bind(this), aResultCb); + }, + + getFirstAlarm: function getFirstAlarm(aNetworkId, aResultCb) { + let self = this; + + this.dbNewTxn(ALARMS_STORE_NAME, "readonly", function(txn, store) { + if (DEBUG) { + debug("Get first alarm for network " + aNetworkId); + } + + let lowerFilter = [aNetworkId, 0]; + let upperFilter = [aNetworkId, ""]; + let range = IDBKeyRange.bound(lowerFilter, upperFilter); + + store.index("alarm").openCursor(range).onsuccess = function onsuccess(event) { + let cursor = event.target.result; + txn.result = null; + if (cursor) { + txn.result = self.recordToAlarm(cursor.value); + } + }; + }, aResultCb); + }, + + removeAlarm: function removeAlarm(aAlarmId, aManifestURL, aResultCb) { + this.dbNewTxn(ALARMS_STORE_NAME, "readwrite", function(txn, store) { + if (DEBUG) { + debug("Remove alarm " + aAlarmId); + } + + store.get(aAlarmId).onsuccess = function onsuccess(event) { + let record = event.target.result; + txn.result = false; + if (!record || (aManifestURL && record.manifestURL != aManifestURL)) { + return; + } + + store.delete(aAlarmId); + txn.result = true; + } + }, aResultCb); + }, + + removeAlarms: function removeAlarms(aManifestURL, aResultCb) { + this.dbNewTxn(ALARMS_STORE_NAME, "readwrite", function(txn, store) { + if (DEBUG) { + debug("Remove alarms of " + aManifestURL); + } + + store.index("manifestURL").openCursor(aManifestURL) + .onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } + } + }, aResultCb); + }, + + updateAlarm: function updateAlarm(aAlarm, aResultCb) { + let self = this; + this.dbNewTxn(ALARMS_STORE_NAME, "readwrite", function(txn, store) { + if (DEBUG) { + debug("Update alarm " + aAlarm.id); + } + + let record = self.alarmToRecord(aAlarm); + store.openCursor(record.id).onsuccess = function onsuccess(event) { + let cursor = event.target.result; + txn.result = false; + if (cursor) { + cursor.update(record); + txn.result = true; + } + } + }, aResultCb); + }, + + getAlarms: function getAlarms(aNetworkId, aManifestURL, aResultCb) { + let self = this; + this.dbNewTxn(ALARMS_STORE_NAME, "readonly", function(txn, store) { + if (DEBUG) { + debug("Get alarms for " + aManifestURL); + } + + txn.result = []; + store.index("manifestURL").openCursor(aManifestURL) + .onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (!cursor) { + return; + } + + if (!aNetworkId || cursor.value.networkId == aNetworkId) { + txn.result.push(self.recordToAlarm(cursor.value)); + } + + cursor.continue(); + } + }, aResultCb); + }, + + _resetAlarms: function _resetAlarms(aNetworkId, aResultCb) { + this.dbNewTxn(ALARMS_STORE_NAME, "readwrite", function(txn, store) { + if (DEBUG) { + debug("Reset alarms for network " + aNetworkId); + } + + let lowerFilter = [aNetworkId, 0]; + let upperFilter = [aNetworkId, ""]; + let range = IDBKeyRange.bound(lowerFilter, upperFilter); + + store.index("alarm").openCursor(range).onsuccess = function onsuccess(event) { + let cursor = event.target.result; + if (cursor) { + if (cursor.value.startTime) { + cursor.value.relativeThreshold = cursor.value.threshold; + cursor.update(cursor.value); + } + cursor.continue(); + return; + } + }; + }, aResultCb); + } +}; |