diff -r 000000000000 -r 6474c204b198 mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java Wed Dec 31 06:09:35 2014 +0100
@@ -0,0 +1,1618 @@
+/* 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/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.background.common.DateUtils;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteConstraintException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.os.Build;
+import android.util.SparseArray;
+
+/**
+ * HealthReportDatabaseStorage
provides an interface on top of
+ * SQLite storage for Health Report data. It exposes methods for management of
+ * environments, measurements, fields, and values, and a cursor-based API for
+ * querying.
+ *
+ * Health Report data is structured as follows.
+ *
+ * Records are primarily broken down by date, at day granularity. Each day's data
+ * is then split according to environment. An environment is a collection of
+ * constant attributes, such as version and processor; if one of these attributes
+ * changes, a new environment becomes active.
+ *
+ * Environments are identified by a stable hash of their attributes.
+ *
+ * The database includes a persistent numeric identifier for each environment. Create
+ * or fetch this identifier via:
+ *
+ *
+ * final Environment environment = storage.getEnvironment();
+ * // Init the environment now.
+ * String envHash = environment.getHash();
+ * int env = environment.register();
+ *
+ *
+ * You can safely cache this environment identifier for the life of the database.
+ *
+ * Orthogonal to environments are measurements. Each measurement is a named and
+ * versioned scope for a collection of fields. It is assumed that if a measurement
+ * with the given name and version is known to the database, then all of its fields
+ * are also known; if you change the collection of fields in the measurement, the
+ * measurement's version must be incremented, too.
+ *
+ * As with environments, measurements have an internal numeric identifier.
+ *
+ * Calling code should initialize its measurements as follows:
+ *
+ *
+ * public static class FooFieldsV1 implements MeasurementFields {
+ * {@literal @}Override
+ * public Iterable getFields() {
+ * ArrayList fields = new ArrayList();
+ * fields.add("bar");
+ * fields.add("baz");
+ * return fields;
+ * }
+ * }
+ *
+ * storage.beginInitialization();
+ *
+ * try {
+ * storage.ensureMeasurementInitialized("org.mozilla.fooProvider.fooMeasurement",
+ * 1, new FooFieldsV1());
+ * storage.finishInitialization();
+ * } catch (Exception e) {
+ * storage.abortInitialization();
+ * }
+ *
+ *
+ * Measurements have fields. Fields can conceptually be divided into "daily last"
+ * (we only care about the last value), "daily counter" (increments per day),
+ * "daily discrete" (multiple records per day). Simply call the correct method for each.
+ *
+ * To do so you need a field ID, to avoid constant costly string lookups. You can get
+ * this value from storage:
+ *
+ *
+ * Field field = storage.getField("org.mozilla.fooProvider.fooMeasurement", 1, "bar");
+ * int fieldID = field.getID();
+ *
+ *
+ * This lookup is cached, and so is relatively inexpensive.
+ *
+ * You can then do something like the following:
+ *
+ *
+ * storage.recordDailyLast(storage.getDay(), env, fieldID, "last value");
+ *
+ *
+ * or equivalently for numeric values, discrete or counters, etc.
+ *
+ * To retrieve values, use {@link #getRawEventsSince(long)}.
+ *
+ * For safety, perform operations on the storage executor thread:
+ *
+ *
+ * storage.enqueueOperation(runnable);
+ *
+ */
+public class HealthReportDatabaseStorage implements HealthReportStorage {
+
+ private static final String WHERE_DATE_AND_ENV_AND_FIELD = "date = ? AND env = ? AND field = ?";
+
+ public static final String[] COLUMNS_HASH = new String[] {"hash"};
+ public static final String[] COLUMNS_DATE_ENV_FIELD_VALUE = new String[] {"date", "env", "field", "value"};
+ public static final String[] COLUMNS_DATE_ENVSTR_M_MV_F_VALUE = new String[] {
+ "date", "environment", "measurement_name", "measurement_version",
+ "field_name", "field_flags", "value"
+ };
+
+ private static final String[] COLUMNS_ENVIRONMENT_DETAILS = new String[] {
+ "id", "version", "hash",
+ "profileCreation", "cpuCount", "memoryMB",
+
+ "isBlocklistEnabled", "isTelemetryEnabled", "extensionCount",
+ "pluginCount", "themeCount",
+
+ "architecture", "sysName", "sysVersion", "vendor", "appName", "appID",
+ "appVersion", "appBuildID", "platformVersion", "platformBuildID", "os",
+ "xpcomabi", "updateChannel",
+
+ "distribution", "osLocale", "appLocale", "acceptLangSet",
+
+ // Joined to the add-ons table.
+ "addonsBody"
+ };
+
+ public static final String[] COLUMNS_MEASUREMENT_DETAILS = new String[] {"id", "name", "version"};
+ public static final String[] COLUMNS_MEASUREMENT_AND_FIELD_DETAILS =
+ new String[] {"measurement_name", "measurement_id", "measurement_version",
+ "field_name", "field_id", "field_flags"};
+
+ private static final String[] COLUMNS_VALUE = new String[] {"value"};
+ private static final String[] COLUMNS_ID = new String[] {"id"};
+
+ private static final String EVENTS_TEXTUAL = "events_textual";
+ private static final String EVENTS_INTEGER = "events_integer";
+
+ protected static final String DB_NAME = "health.db";
+
+ private static final String LOG_TAG = "HealthReportStorage";
+
+ private final Executor executor = Executors.newSingleThreadExecutor();
+
+ @Override
+ public void enqueueOperation(Runnable runnable) {
+ executor.execute(runnable);
+ }
+
+ public HealthReportDatabaseStorage(final Context context,
+ final File profileDirectory) {
+ this.helper = new HealthReportSQLiteOpenHelper(context, profileDirectory,
+ DB_NAME);
+ executor.execute(new Runnable() {
+ @Override
+ public void run() {
+ Logger.setThreadLogTag(HealthReportConstants.GLOBAL_LOG_TAG);
+ Logger.debug(LOG_TAG, "Creating HealthReportDatabaseStorage.");
+ }
+ });
+ }
+
+ @Override
+ public void close() {
+ this.helper.close();
+ this.fields.clear();
+ this.envs.clear();
+ this.measurementVersions.clear();
+ }
+
+ protected final HealthReportSQLiteOpenHelper helper;
+
+ public static class HealthReportSQLiteOpenHelper extends SQLiteOpenHelper {
+ public static final int CURRENT_VERSION = 6;
+ public static final String LOG_TAG = "HealthReportSQL";
+
+ /**
+ * A little helper to avoid SQLiteOpenHelper misbehaving on Android 2.1.
+ * Partly cribbed from
+ * .
+ */
+ public static class AbsolutePathContext extends ContextWrapper {
+ private final File parent;
+
+ public AbsolutePathContext(Context base, File parent) {
+ super(base);
+ this.parent = parent;
+ }
+
+ @Override
+ public File getDatabasePath(String name) {
+ return new File(getAbsolutePath(parent, name));
+ }
+
+ // Won't be called after API v11, but we can't override the version that
+ // *is* called and still support v8.
+ // Instead we check the version code in the HealthReportSQLiteOpenHelper
+ // constructor, and only use this workaround if we need to.
+ @Override
+ public SQLiteDatabase openOrCreateDatabase(String name,
+ int mode,
+ SQLiteDatabase.CursorFactory factory) {
+ final File path = getDatabasePath(name);
+ Logger.pii(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath());
+ return SQLiteDatabase.openOrCreateDatabase(path, null);
+ }
+ }
+
+ public static String getAbsolutePath(File parent, String name) {
+ return parent.getAbsolutePath() + File.separator + name;
+ }
+
+ public static boolean CAN_USE_ABSOLUTE_DB_PATH = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO);
+ public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name) {
+ this(context, profileDirectory, name, CURRENT_VERSION);
+ }
+
+ // For testing DBs of different versions.
+ public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name, int version) {
+ super(
+ (CAN_USE_ABSOLUTE_DB_PATH ? context : new AbsolutePathContext(context, profileDirectory)),
+ (CAN_USE_ABSOLUTE_DB_PATH ? getAbsolutePath(profileDirectory, name) : name),
+ null,
+ version);
+
+ if (CAN_USE_ABSOLUTE_DB_PATH) {
+ Logger.pii(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name));
+ }
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ " body TEXT, " +
+ " UNIQUE (body) " +
+ ")");
+
+ // N.B., hash collisions can occur across versions. In that case, the system
+ // is likely to persist the original environment version.
+ db.execSQL("CREATE TABLE environments (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ " version INTEGER, " +
+ " hash TEXT, " +
+ " profileCreation INTEGER, " +
+ " cpuCount INTEGER, " +
+ " memoryMB INTEGER, " +
+ " isBlocklistEnabled INTEGER, " +
+ " isTelemetryEnabled INTEGER, " +
+ " extensionCount INTEGER, " +
+ " pluginCount INTEGER, " +
+ " themeCount INTEGER, " +
+ " architecture TEXT, " +
+ " sysName TEXT, " +
+ " sysVersion TEXT, " +
+ " vendor TEXT, " +
+ " appName TEXT, " +
+ " appID TEXT, " +
+ " appVersion TEXT, " +
+ " appBuildID TEXT, " +
+ " platformVersion TEXT, " +
+ " platformBuildID TEXT, " +
+ " os TEXT, " +
+ " xpcomabi TEXT, " +
+ " updateChannel TEXT, " +
+
+ " distribution TEXT, " +
+ " osLocale TEXT, " +
+ " appLocale TEXT, " +
+ " acceptLangSet INTEGER, " +
+
+ " addonsID INTEGER, " +
+ " FOREIGN KEY (addonsID) REFERENCES addons(id) ON DELETE RESTRICT, " +
+ " UNIQUE (hash) " +
+ ")");
+
+ db.execSQL("CREATE TABLE measurements (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ " name TEXT, " +
+ " version INTEGER, " +
+ " UNIQUE (name, version) " +
+ ")");
+
+ db.execSQL("CREATE TABLE fields (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ " measurement INTEGER, " +
+ " name TEXT, " +
+ " flags INTEGER, " +
+ " FOREIGN KEY (measurement) REFERENCES measurements(id) ON DELETE CASCADE, " +
+ " UNIQUE (measurement, name)" +
+ ")");
+
+ db.execSQL("CREATE TABLE " + EVENTS_INTEGER + "(" +
+ " date INTEGER, " +
+ " env INTEGER, " +
+ " field INTEGER, " +
+ " value INTEGER, " +
+ " FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " +
+ " FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" +
+ ")");
+
+ db.execSQL("CREATE TABLE " + EVENTS_TEXTUAL + "(" +
+ " date INTEGER, " +
+ " env INTEGER, " +
+ " field INTEGER, " +
+ " value TEXT, " +
+ " FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " +
+ " FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" +
+ ")");
+
+ db.execSQL("CREATE INDEX idx_events_integer_date_env_field ON events_integer (date, env, field)");
+ db.execSQL("CREATE INDEX idx_events_textual_date_env_field ON events_textual (date, env, field)");
+
+ db.execSQL("CREATE VIEW events AS " +
+ "SELECT date, env, field, value FROM " + EVENTS_INTEGER + " " +
+ "UNION ALL " +
+ "SELECT date, env, field, value FROM " + EVENTS_TEXTUAL);
+
+ db.execSQL("CREATE VIEW named_events AS " +
+ "SELECT date, " +
+ " environments.hash AS environment, " +
+ " measurements.name AS measurement_name, " +
+ " measurements.version AS measurement_version, " +
+ " fields.name AS field_name, " +
+ " fields.flags AS field_flags, " +
+ " value FROM " +
+ "events JOIN environments ON events.env = environments.id " +
+ " JOIN fields ON events.field = fields.id " +
+ " JOIN measurements ON fields.measurement = measurements.id");
+
+ db.execSQL("CREATE VIEW named_fields AS " +
+ "SELECT measurements.name AS measurement_name, " +
+ " measurements.id AS measurement_id, " +
+ " measurements.version AS measurement_version, " +
+ " fields.name AS field_name, " +
+ " fields.id AS field_id, " +
+ " fields.flags AS field_flags " +
+ "FROM fields JOIN measurements ON fields.measurement = measurements.id");
+
+ db.execSQL("CREATE VIEW current_measurements AS " +
+ "SELECT name, MAX(version) AS version FROM measurements GROUP BY name");
+
+ createAddonsEnvironmentsView(db);
+ }
+
+ @Override
+ public void onOpen(SQLiteDatabase db) {
+ if (!db.isReadOnly()) {
+ db.execSQL("PRAGMA foreign_keys=ON;");
+ }
+ }
+
+ private void createAddonsEnvironmentsView(SQLiteDatabase db) {
+ db.execSQL("CREATE VIEW environments_with_addons AS " +
+ "SELECT e.id AS id, " +
+ " e.version AS version, " +
+ " e.hash AS hash, " +
+ " e.profileCreation AS profileCreation, " +
+ " e.cpuCount AS cpuCount, " +
+ " e.memoryMB AS memoryMB, " +
+ " e.isBlocklistEnabled AS isBlocklistEnabled, " +
+ " e.isTelemetryEnabled AS isTelemetryEnabled, " +
+ " e.extensionCount AS extensionCount, " +
+ " e.pluginCount AS pluginCount, " +
+ " e.themeCount AS themeCount, " +
+ " e.architecture AS architecture, " +
+ " e.sysName AS sysName, " +
+ " e.sysVersion AS sysVersion, " +
+ " e.vendor AS vendor, " +
+ " e.appName AS appName, " +
+ " e.appID AS appID, " +
+ " e.appVersion AS appVersion, " +
+ " e.appBuildID AS appBuildID, " +
+ " e.platformVersion AS platformVersion, " +
+ " e.platformBuildID AS platformBuildID, " +
+ " e.os AS os, " +
+ " e.xpcomabi AS xpcomabi, " +
+ " e.updateChannel AS updateChannel, " +
+ " e.distribution AS distribution, " +
+ " e.osLocale AS osLocale, " +
+ " e.appLocale AS appLocale, " +
+ " e.acceptLangSet AS acceptLangSet, " +
+ " addons.body AS addonsBody " +
+ "FROM environments AS e, addons " +
+ "WHERE e.addonsID = addons.id");
+ }
+
+ private void upgradeDatabaseFrom2To3(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+ " body TEXT, " +
+ " UNIQUE (body) " +
+ ")");
+
+ db.execSQL("ALTER TABLE environments ADD COLUMN addonsID INTEGER REFERENCES addons(id) ON DELETE RESTRICT");
+
+ createAddonsEnvironmentsView(db);
+ }
+
+ private void upgradeDatabaseFrom3To4(SQLiteDatabase db) {
+ // Update search measurements to use a different type.
+ db.execSQL("UPDATE OR IGNORE fields SET flags = " + Field.TYPE_COUNTED_STRING_DISCRETE +
+ " WHERE measurement IN (SELECT id FROM measurements WHERE name = 'org.mozilla.searches.counts')");
+ }
+
+ private void upgradeDatabaseFrom4to5(SQLiteDatabase db) {
+ // Delete NULL in addons.body, which appeared as a result of Bug 886156. Note that the
+ // foreign key constraint, "ON DELETE RESTRICT", may be violated, but since onOpen() is
+ // called after this method, foreign keys are not yet enabled and constraints can be broken.
+ db.delete("addons", "body IS NULL", null);
+
+ // Purge any data inconsistent with foreign key references (which may have appeared before
+ // foreign keys were enabled in Bug 900289).
+ db.delete("fields", "measurement NOT IN (SELECT id FROM measurements)", null);
+ db.delete("environments", "addonsID NOT IN (SELECT id from addons)", null);
+ db.delete(EVENTS_INTEGER, "env NOT IN (SELECT id FROM environments)", null);
+ db.delete(EVENTS_TEXTUAL, "env NOT IN (SELECT id FROM environments)", null);
+ db.delete(EVENTS_INTEGER, "field NOT IN (SELECT id FROM fields)", null);
+ db.delete(EVENTS_TEXTUAL, "field NOT IN (SELECT id FROM fields)", null);
+ }
+
+ private void upgradeDatabaseFrom5to6(SQLiteDatabase db) {
+ db.execSQL("DROP VIEW environments_with_addons");
+
+ // Add version to environment (default to 1).
+ db.execSQL("ALTER TABLE environments ADD COLUMN version INTEGER DEFAULT 1");
+
+ // Add fields to environment (default to empty string).
+ db.execSQL("ALTER TABLE environments ADD COLUMN distribution TEXT DEFAULT ''");
+ db.execSQL("ALTER TABLE environments ADD COLUMN osLocale TEXT DEFAULT ''");
+ db.execSQL("ALTER TABLE environments ADD COLUMN appLocale TEXT DEFAULT ''");
+ db.execSQL("ALTER TABLE environments ADD COLUMN acceptLangSet INTEGER DEFAULT 0");
+
+ // Recreate view.
+ createAddonsEnvironmentsView(db);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ if (oldVersion >= newVersion) {
+ return;
+ }
+
+ Logger.info(LOG_TAG, "onUpgrade: from " + oldVersion + " to " + newVersion + ".");
+ try {
+ switch (oldVersion) {
+ case 2:
+ upgradeDatabaseFrom2To3(db);
+ case 3:
+ upgradeDatabaseFrom3To4(db);
+ case 4:
+ upgradeDatabaseFrom4to5(db);
+ case 5:
+ upgradeDatabaseFrom5to6(db);
+ }
+ } catch (Exception e) {
+ Logger.error(LOG_TAG, "Failure in onUpgrade.", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public void deleteEverything() {
+ final SQLiteDatabase db = this.getWritableDatabase();
+
+ Logger.info(LOG_TAG, "Deleting everything.");
+ db.beginTransaction();
+ try {
+ // Cascade will clear the rest.
+ db.delete("measurements", null, null);
+ db.delete("environments", null, null);
+ db.delete("addons", null, null);
+ db.setTransactionSuccessful();
+ Logger.info(LOG_TAG, "Deletion successful.");
+ } finally {
+ db.endTransaction();
+ }
+ }
+ }
+
+ public class DatabaseField extends Field {
+ public DatabaseField(String mName, int mVersion, String fieldName) {
+ this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, UNKNOWN_TYPE_OR_FIELD_ID);
+ }
+
+ public DatabaseField(String mName, int mVersion, String fieldName, int flags) {
+ this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, flags);
+ }
+
+ public DatabaseField(String mName, int mVersion, String fieldName, int fieldID, int flags) {
+ super(mName, mVersion, fieldName, flags);
+ this.fieldID = fieldID;
+ }
+
+ private void loadFlags() {
+ if (this.flags == UNKNOWN_TYPE_OR_FIELD_ID) {
+ if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
+ this.getID();
+ }
+ this.flags = integerQuery("fields", "flags", "id = ?", new String[] { Integer.toString(this.fieldID, 10) }, -1);
+ }
+ }
+
+ @Override
+ public synchronized boolean isIntegerField() {
+ loadFlags();
+ return super.isIntegerField();
+ }
+
+ @Override
+ public synchronized boolean isStringField() {
+ loadFlags();
+ return super.isStringField();
+ }
+
+ @Override
+ public synchronized boolean isDiscreteField() {
+ loadFlags();
+ return super.isDiscreteField();
+ }
+
+ @Override
+ public synchronized int getID() throws IllegalStateException {
+ if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
+ this.fieldID = integerQuery("named_fields", "field_id",
+ "measurement_name = ? AND measurement_version = ? AND field_name = ?",
+ new String[] {measurementName, measurementVersion, fieldName},
+ UNKNOWN_TYPE_OR_FIELD_ID);
+ if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
+ throw new IllegalStateException("No field with name " + fieldName +
+ " (" + measurementName + ", " + measurementVersion + ")");
+ }
+ }
+ return this.fieldID;
+ }
+ }
+
+ // `envs` and `fields` look similar, but they are touched differently and
+ // store differently stable kinds of data, hence type difference.
+ // Note that we don't pre-populate the environment cache. We'll typically only
+ // handle one per session.
+ //
+ // protected for testing purposes only.
+ protected final ConcurrentHashMap envs = new ConcurrentHashMap();
+
+ /**
+ * An {@link Environment} that knows how to persist to and from our database.
+ */
+ public static class DatabaseEnvironment extends Environment {
+ protected final HealthReportDatabaseStorage storage;
+
+ @Override
+ public int register() {
+ final String h = getHash();
+ if (storage.envs.containsKey(h)) {
+ this.id = storage.envs.get(h);
+ return this.id;
+ }
+
+ // Otherwise, add data and hash to the DB.
+ ContentValues v = new ContentValues();
+ v.put("version", version);
+ v.put("hash", h);
+ v.put("profileCreation", profileCreation);
+ v.put("cpuCount", cpuCount);
+ v.put("memoryMB", memoryMB);
+ v.put("isBlocklistEnabled", isBlocklistEnabled);
+ v.put("isTelemetryEnabled", isTelemetryEnabled);
+ v.put("extensionCount", extensionCount);
+ v.put("pluginCount", pluginCount);
+ v.put("themeCount", themeCount);
+ v.put("architecture", architecture);
+ v.put("sysName", sysName);
+ v.put("sysVersion", sysVersion);
+ v.put("vendor", vendor);
+ v.put("appName", appName);
+ v.put("appID", appID);
+ v.put("appVersion", appVersion);
+ v.put("appBuildID", appBuildID);
+ v.put("platformVersion", platformVersion);
+ v.put("platformBuildID", platformBuildID);
+ v.put("os", os);
+ v.put("xpcomabi", xpcomabi);
+ v.put("updateChannel", updateChannel);
+ v.put("distribution", distribution);
+ v.put("osLocale", osLocale);
+ v.put("appLocale", appLocale);
+ v.put("acceptLangSet", acceptLangSet);
+
+ final SQLiteDatabase db = storage.helper.getWritableDatabase();
+
+ // If we're not already, we want all of our inserts to be in a transaction.
+ boolean newTransaction = !db.inTransaction();
+
+ // Insert, with a little error handling to populate the cache in case of
+ // omission and consequent collision.
+ //
+ // We would like to hang a trigger off a view here, and just use that for
+ // inserts. But triggers don't seem to correctly update the last inserted
+ // ID, so Android's insertOrThrow method returns -1.
+ //
+ // Instead, we go without the trigger, simply running the inserts ourselves.
+ //
+ // insertWithOnConflict doesn't work as documented: .
+ // So we do this the hard way.
+ // We presume that almost every get will hit the cache (except for the first, obviously), so we
+ // bias here towards inserts for the environments.
+ // For add-ons we assume that different environments will share add-ons, so we query first.
+
+ final String addonsJSON = getNormalizedAddonsJSON();
+ if (newTransaction) {
+ db.beginTransaction();
+ }
+
+ try {
+ int addonsID = ensureAddons(db, addonsJSON);
+ v.put("addonsID", addonsID);
+
+ try {
+ int inserted = (int) db.insertOrThrow("environments", null, v);
+ Logger.debug(LOG_TAG, "Inserted ID: " + inserted + " for hash " + h);
+ if (inserted == -1) {
+ throw new SQLException("Insert returned -1!");
+ }
+ this.id = inserted;
+ storage.envs.put(h, this.id);
+ if (newTransaction) {
+ db.setTransactionSuccessful();
+ }
+ return inserted;
+ } catch (SQLException e) {
+ // The inserter should take care of updating `envs`. But if it
+ // doesn't...
+ Cursor c = db.query("environments", COLUMNS_ID, "hash = ?",
+ new String[] { h }, null, null, null);
+ try {
+ if (!c.moveToFirst()) {
+ throw e;
+ }
+ this.id = (int) c.getLong(0);
+ Logger.debug(LOG_TAG, "Found " + this.id + " for hash " + h);
+ storage.envs.put(h, this.id);
+ if (newTransaction) {
+ db.setTransactionSuccessful();
+ }
+ return this.id;
+ } finally {
+ c.close();
+ }
+ }
+ } finally {
+ if (newTransaction) {
+ db.endTransaction();
+ }
+ }
+ }
+
+ protected static int ensureAddons(SQLiteDatabase db, String json) {
+ Cursor c = db.query("addons", COLUMNS_ID, "body = ?",
+ new String[] { (json == null) ? "null" : json }, null, null, null);
+ try {
+ if (c.moveToFirst()) {
+ return c.getInt(0);
+ }
+ ContentValues values = new ContentValues();
+ values.put("body", json);
+ return (int) db.insert("addons", null, values);
+ } finally {
+ c.close();
+ }
+ }
+
+ public void init(ContentValues v) {
+ version = v.containsKey("version") ? v.getAsInteger("version") : Environment.CURRENT_VERSION;
+ profileCreation = v.getAsInteger("profileCreation");
+ cpuCount = v.getAsInteger("cpuCount");
+ memoryMB = v.getAsInteger("memoryMB");
+
+ isBlocklistEnabled = v.getAsInteger("isBlocklistEnabled");
+ isTelemetryEnabled = v.getAsInteger("isTelemetryEnabled");
+ extensionCount = v.getAsInteger("extensionCount");
+ pluginCount = v.getAsInteger("pluginCount");
+ themeCount = v.getAsInteger("themeCount");
+
+ architecture = v.getAsString("architecture");
+ sysName = v.getAsString("sysName");
+ sysVersion = v.getAsString("sysVersion");
+ vendor = v.getAsString("vendor");
+ appName = v.getAsString("appName");
+ appID = v.getAsString("appID");
+ appVersion = v.getAsString("appVersion");
+ appBuildID = v.getAsString("appBuildID");
+ platformVersion = v.getAsString("platformVersion");
+ platformBuildID = v.getAsString("platformBuildID");
+ os = v.getAsString("os");
+ xpcomabi = v.getAsString("xpcomabi");
+ updateChannel = v.getAsString("updateChannel");
+
+ distribution = v.getAsString("distribution");
+ osLocale = v.getAsString("osLocale");
+ appLocale = v.getAsString("appLocale");
+ acceptLangSet = v.getAsInteger("acceptLangSet");
+
+ try {
+ setJSONForAddons(v.getAsString("addonsBody"));
+ } catch (Exception e) {
+ // Nothing we can do.
+ }
+
+ this.hash = null;
+ this.id = -1;
+ }
+
+ /**
+ * Fill ourselves with data from the DB, then advance the cursor.
+ *
+ * @param cursor a {@link Cursor} pointing at a record to load.
+ * @return true if the cursor was successfully advanced.
+ */
+ public boolean init(Cursor cursor) {
+ int i = 0;
+ this.id = cursor.getInt(i++);
+ this.version = cursor.getInt(i++);
+ this.hash = cursor.getString(i++);
+
+ profileCreation = cursor.getInt(i++);
+ cpuCount = cursor.getInt(i++);
+ memoryMB = cursor.getInt(i++);
+
+ isBlocklistEnabled = cursor.getInt(i++);
+ isTelemetryEnabled = cursor.getInt(i++);
+ extensionCount = cursor.getInt(i++);
+ pluginCount = cursor.getInt(i++);
+ themeCount = cursor.getInt(i++);
+
+ architecture = cursor.getString(i++);
+ sysName = cursor.getString(i++);
+ sysVersion = cursor.getString(i++);
+ vendor = cursor.getString(i++);
+ appName = cursor.getString(i++);
+ appID = cursor.getString(i++);
+ appVersion = cursor.getString(i++);
+ appBuildID = cursor.getString(i++);
+ platformVersion = cursor.getString(i++);
+ platformBuildID = cursor.getString(i++);
+ os = cursor.getString(i++);
+ xpcomabi = cursor.getString(i++);
+ updateChannel = cursor.getString(i++);
+
+ distribution = cursor.getString(i++);
+ osLocale = cursor.getString(i++);
+ appLocale = cursor.getString(i++);
+ acceptLangSet = cursor.getInt(i++);
+
+ try {
+ setJSONForAddons(cursor.getBlob(i++));
+ } catch (Exception e) {
+ // Nothing we can do.
+ }
+
+ return cursor.moveToNext();
+ }
+
+ public DatabaseEnvironment(HealthReportDatabaseStorage storage, Class extends EnvironmentAppender> appender) {
+ super(appender);
+ this.storage = storage;
+ }
+
+ public DatabaseEnvironment(HealthReportDatabaseStorage storage) {
+ this.storage = storage;
+ }
+ }
+
+ /**
+ * Factory method. Returns a new {@link Environment} that callers can
+ * populate and then register.
+ */
+ @Override
+ public DatabaseEnvironment getEnvironment() {
+ return new DatabaseEnvironment(this);
+ }
+
+ @Override
+ public SparseArray getEnvironmentRecordsByID() {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ Cursor c = db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, null, null, null, null, null);
+ try {
+ SparseArray results = new SparseArray();
+ if (!c.moveToFirst()) {
+ return results;
+ }
+
+ DatabaseEnvironment e = getEnvironment();
+ while (e.init(c)) {
+ results.put(e.id, e);
+ e = getEnvironment();
+ }
+ results.put(e.id, e);
+ return results;
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Reverse lookup for an env. Only really used for tests: document generation
+ * fetches all environments at once, and insertion only uses the integer key
+ * that's returned during insertion.
+ *
+ * @param id
+ * the identifier for the environment.
+ * @return a cursor over its details.
+ */
+ @Override
+ public Cursor getEnvironmentRecordForID(int id) {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ return db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, "id = " + id, null, null, null, null);
+ }
+
+ @Override
+ public SparseArray getEnvironmentHashesByID() {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ Cursor c = db.query("environments", new String[] {"id", "hash"}, null, null, null, null, null);
+ try {
+ SparseArray results = new SparseArray();
+ if (!c.moveToFirst()) {
+ return results;
+ }
+
+ while (!c.isAfterLast()) {
+ results.put(c.getInt(0), c.getString(1));
+ c.moveToNext();
+ }
+ return results;
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Cache the lookup from measurement and field specifier to field instance.
+ * This allows us to memoize the field ID lookup, too.
+ */
+ private HashMap fields = new HashMap();
+ private boolean fieldsCacheUpdated = false;
+
+ private void invalidateFieldsCache() {
+ synchronized (this.fields) {
+ fieldsCacheUpdated = false;
+ }
+ }
+
+ private String getFieldKey(String mName, int mVersion, String fieldName) {
+ return mVersion + "." + mName + "/" + fieldName;
+ }
+
+ @Override
+ public Field getField(String mName, int mVersion, String fieldName) {
+ final String key = getFieldKey(mName, mVersion, fieldName);
+ synchronized (fields) {
+ if (fields.containsKey(key)) {
+ return fields.get(key);
+ }
+ Field f = new DatabaseField(mName, mVersion, fieldName);
+ fields.put(key, f);
+ return f;
+ }
+ }
+
+ private void populateFieldCache() {
+ synchronized (fields) {
+ if (fieldsCacheUpdated) {
+ return;
+ }
+
+ fields.clear();
+ Cursor c = getFieldVersions();
+ try {
+ if (!c.moveToFirst()) {
+ return;
+ }
+ do {
+ // We don't use the measurement ID here, so column 1 is unused.
+ final String mName = c.getString(0);
+ final int mVersion = c.getInt(2);
+ final String fieldName = c.getString(3);
+ final int fieldID = c.getInt(4);
+ final int flags = c.getInt(5);
+ final String key = getFieldKey(mName, mVersion, fieldName);
+
+ Field f = new DatabaseField(mName, mVersion, fieldName, fieldID, flags);
+ fields.put(key, f);
+ } while (c.moveToNext());
+ fieldsCacheUpdated = true;
+ } finally {
+ c.close();
+ }
+ }
+ }
+
+ /**
+ * Return mappings from field ID to Field instance. Do so by looking in the DB.
+ */
+ @Override
+ public SparseArray getFieldsByID() {
+ final SparseArray out = new SparseArray();
+ synchronized (fields) {
+ populateFieldCache();
+ Collection values = fields.values();
+ for (Field field : values) {
+ // Cache is up-to-date at this point, so we don't need to hit the DB.
+ out.put(field.getID(), field);
+ }
+ }
+ return out;
+ }
+
+ private final HashMap measurementVersions = new HashMap();
+
+ private void populateMeasurementVersionsCache(SQLiteDatabase db) {
+ HashMap results = getIntegers(db, "current_measurements", "name", "version");
+ if (results == null) {
+ measurementVersions.clear();
+ return;
+ }
+ synchronized (measurementVersions) {
+ measurementVersions.clear();
+ measurementVersions.putAll(results);
+ }
+ }
+
+ /**
+ * Return the version of the measurement for which the DB is currently configured, or
+ * 0 if unknown.
+ * @param measurement String measurement identifier.
+ * @return Current version.
+ */
+ private int getMeasurementVersion(String measurement) {
+ synchronized (measurementVersions) {
+ if (measurementVersions.containsKey(measurement)) {
+ return measurementVersions.get(measurement);
+ }
+
+ // This should never be necessary, unless the measurement does not exist.
+ int value = integerQuery("measurements", "version", "name = ?", new String[] {measurement}, 0);
+ measurementVersions.put(measurement, value);
+ return value;
+ }
+ }
+
+ /**
+ * Inform the storage layer that fields for the given measurement have been updated
+ * to this version.
+ *
+ * This should be one of the final calls in a configuration transaction.
+ * Always call this inside a transaction.
+ */
+ private void notifyMeasurementVersionUpdated(String measurement, int version) {
+ Logger.info(LOG_TAG, "Measurement " + measurement + " now at " + version);
+
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ final ContentValues values = new ContentValues();
+ values.put("name", measurement);
+ values.put("version", version);
+
+ synchronized (measurementVersions) {
+ measurementVersions.put(measurement, version);
+ }
+
+ db.insertWithOnConflict("measurements", null, values, SQLiteDatabase.CONFLICT_IGNORE);
+ }
+
+ /**
+ * Call in a transaction.
+ * This method could race with other accesses, but (a) it's within a transaction,
+ * (b) configuration should be single-threaded, (c) we initialize the cache up-front.
+ */
+ @Override
+ public void ensureMeasurementInitialized(String measurement, int version, MeasurementFields fields) {
+ final int currentVersion = getMeasurementVersion(measurement);
+ Logger.info(LOG_TAG, "Initializing measurement " + measurement + " to " +
+ version + " (current " + currentVersion + ")");
+
+ if (currentVersion == version) {
+ Logger.info(LOG_TAG, "Measurement " + measurement + " already at v" + version);
+ return;
+ }
+
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ if (!db.inTransaction()) {
+ Logger.warn(LOG_TAG, "ensureMeasurementInitialized should be called within a transaction.");
+ }
+
+ final ContentValues mv = new ContentValues();
+ mv.put("name", measurement);
+ mv.put("version", version);
+
+ final int measurementID = (int) db.insert("measurements", null, mv);
+
+ final ContentValues v = new ContentValues();
+ v.put("measurement", measurementID);
+ for (FieldSpec field : fields.getFields()) {
+ v.put("name", field.name);
+ v.put("flags", field.type);
+ Logger.debug(LOG_TAG, "M: " + measurementID + " F: " + field.name + " (" + field.type + ")");
+ db.insert("fields", null, v);
+ }
+
+ notifyMeasurementVersionUpdated(measurement, version);
+
+ // Let's be easy for now.
+ invalidateFieldsCache();
+ }
+
+ /**
+ * Return a cursor over the measurements and fields in the DB.
+ * Columns are {@link HealthReportDatabaseStorage#COLUMNS_MEASUREMENT_AND_FIELD_DETAILS}.
+ */
+ @Override
+ public Cursor getFieldVersions() {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS,
+ null, null, null, null, "measurement_name, measurement_version, field_name");
+ }
+
+ @Override
+ public Cursor getFieldVersions(String measurement, int measurementVersion) {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS,
+ "measurement_name = ? AND measurement_version = ?",
+ new String[] {measurement, Integer.toString(measurementVersion)},
+ null, null, "field_name");
+ }
+
+ @Override
+ public Cursor getMeasurementVersions() {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ return db.query("measurements", COLUMNS_MEASUREMENT_DETAILS,
+ null, null, null, null, "name, version");
+ }
+
+ /**
+ * A thin wrapper around the database transactional semantics. Clients can
+ * use this to more efficiently ensure that measurements are initialized.
+ *
+ * Note that caches are also initialized here.
+ */
+ public void beginInitialization() {
+ SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.beginTransaction();
+ populateMeasurementVersionsCache(db);
+ }
+
+ public void finishInitialization() {
+ SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ public void abortInitialization() {
+ this.helper.getWritableDatabase().endTransaction();
+ }
+
+ protected int getIntFromQuery(final String sql, final String[] selectionArgs) {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ final Cursor c = db.rawQuery(sql, selectionArgs);
+ try {
+ if (!c.moveToFirst()) {
+ throw new IllegalStateException("Cursor is empty.");
+ }
+ return c.getInt(0);
+ } finally {
+ c.close();
+ }
+ }
+
+ @Override
+ public int getDay(long time) {
+ return DateUtils.getDay(time);
+ }
+
+ @Override
+ public int getDay() {
+ return this.getDay(System.currentTimeMillis());
+ }
+
+ private void recordDailyLast(int env, int day, int field, Object value, String table) {
+ if (env == -1) {
+ Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
+ return;
+ }
+
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+
+ final String envString = Integer.toString(env);
+ final String fieldIDString = Integer.toString(field, 10);
+ final String dayString = Integer.toString(day, 10);
+
+ // Java, your capacity for abstraction leaves me wanting.
+ final ContentValues v = new ContentValues();
+ putValue(v, value);
+
+ // If we used a separate table, such that we could have a
+ // UNIQUE(env, field, day) constraint for daily-last values, then we could
+ // use INSERT OR REPLACE.
+ final int updated = db.update(table, v, WHERE_DATE_AND_ENV_AND_FIELD,
+ new String[] {dayString, envString, fieldIDString});
+ if (0 == updated) {
+ v.put("env", env);
+ v.put("field", field);
+ v.put("date", day);
+ try {
+ db.insertOrThrow(table, null, v);
+ } catch (SQLiteConstraintException e) {
+ throw new IllegalStateException("Event did not reference existing an environment or field.", e);
+ }
+ }
+ }
+
+ @Override
+ public void recordDailyLast(int env, int day, int field, JSONObject value) {
+ this.recordDailyLast(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
+ }
+
+ @Override
+ public void recordDailyLast(int env, int day, int field, String value) {
+ this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL);
+ }
+
+ @Override
+ public void recordDailyLast(int env, int day, int field, int value) {
+ this.recordDailyLast(env, day, field, Integer.valueOf(value), EVENTS_INTEGER);
+ }
+
+ private void recordDailyDiscrete(int env, int day, int field, Object value, String table) {
+ if (env == -1) {
+ Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
+ return;
+ }
+
+ final ContentValues v = new ContentValues();
+ v.put("env", env);
+ v.put("field", field);
+ v.put("date", day);
+
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ putValue(v, value);
+
+ // Using SQLiteDatabase.insertOrThrow throws SQLiteConstraintException we cannot catch for
+ // unknown reasons (bug 961526 comment 13). We believe these are thrown because we attempt to
+ // record events using environment IDs removed from the database by the prune service. We
+ // invalidate the currentEnvironment ID after pruning, preventing further propagation,
+ // however, any event recording waiting for the prune service to complete on the background
+ // thread may carry an invalid ID: we expect an insertion failure and drop these events here.
+ final long res = db.insert(table, null, v);
+ if (res == -1) {
+ Logger.error(LOG_TAG, "Unable to record daily discrete event. Ignoring.");
+ }
+ }
+
+ @Override
+ public void recordDailyDiscrete(int env, int day, int field, JSONObject value) {
+ this.recordDailyDiscrete(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
+ }
+
+ @Override
+ public void recordDailyDiscrete(int env, int day, int field, String value) {
+ this.recordDailyDiscrete(env, day, field, value, EVENTS_TEXTUAL);
+ }
+
+ @Override
+ public void recordDailyDiscrete(int env, int day, int field, int value) {
+ this.recordDailyDiscrete(env, day, field, value, EVENTS_INTEGER);
+ }
+
+ /**
+ * Increment the specified field value by the specified amount. Counts start
+ * at zero.
+ *
+ * Note that this method can misbehave or throw if not executed within a
+ * transaction, because correct behavior involves querying then
+ * insert-or-update, and a race condition can otherwise occur.
+ *
+ * @param env the environment ID
+ * @param day the current day, in days since epoch
+ * @param field the field ID
+ * @param by how much to increment the counter.
+ */
+ @Override
+ public void incrementDailyCount(int env, int day, int field, int by) {
+ if (env == -1) {
+ Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
+ return;
+ }
+
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ final String envString = Integer.toString(env);
+ final String fieldIDString = Integer.toString(field, 10);
+ final String dayString = Integer.toString(day, 10);
+
+ // Can't run a complex UPDATE and get the number of changed rows, so we'll
+ // do this the hard way.
+ // This relies on being called within a transaction.
+ final String[] args = new String[] {dayString, envString, fieldIDString};
+ final Cursor c = db.query(EVENTS_INTEGER,
+ COLUMNS_VALUE,
+ WHERE_DATE_AND_ENV_AND_FIELD,
+ args, null, null, null, "1");
+
+ boolean present = false;
+ try {
+ present = c.moveToFirst();
+ } finally {
+ c.close();
+ }
+
+ if (present) {
+ // It's an int, so safe to concatenate. Avoids us having to mess with args.
+ db.execSQL("UPDATE " + EVENTS_INTEGER + " SET value = value + " + by + " WHERE " +
+ WHERE_DATE_AND_ENV_AND_FIELD,
+ args);
+ } else {
+ final ContentValues v = new ContentValues();
+ v.put("env", env);
+ v.put("value", by);
+ v.put("field", field);
+ v.put("date", day);
+ try {
+ db.insertOrThrow(EVENTS_INTEGER, null, v);
+ } catch (SQLiteConstraintException e) {
+ throw new IllegalStateException("Event did not reference existing an environment or field.", e);
+ }
+ }
+ }
+
+ @Override
+ public void incrementDailyCount(int env, int day, int field) {
+ this.incrementDailyCount(env, day, field, 1);
+ }
+
+ /**
+ * Are there events recorded on or after time
?
+ *
+ * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
+ * @return true if such events exist, false otherwise.
+ */
+ @Override
+ public boolean hasEventSince(long time) {
+ final int start = this.getDay(time);
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ final String dayString = Integer.toString(start, 10);
+ Cursor cur = db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
+ "date >= ?", new String[] {dayString}, null, null, null, "1");
+ if (cur == null) {
+ // Something is horribly wrong; let the caller who actually reads the
+ // events deal with it.
+ return true;
+ }
+ try {
+ return cur.getCount() > 0;
+ } finally {
+ cur.close();
+ }
+ }
+
+ /**
+ * Returns a cursor over field events in the database. The results will be
+ * strictly ordered first by date, then by environment, and finally by field.
+ *
+ * Each row includes columns in {@link #COLUMNS_DATE_ENV_FIELD_VALUE}:
+ * "date", "env", "field", "value".
+ *
+ * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
+ * @return a cursor. The caller is responsible for closing this.
+ */
+ @Override
+ public Cursor getRawEventsSince(long time) {
+ final int start = this.getDay(time);
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ final String dayString = Integer.toString(start, 10);
+ return db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
+ "date >= ?", new String[] {dayString}, null, null, "date, env, field");
+ }
+
+ /**
+ * Returns a cursor over field events in the database. The results will be
+ * strictly ordered first by date, then by environment, and finally by field.
+ *
+ * Each row includes columns in {@link #COLUMNS_DATE_ENVSTR_M_MV_F_VALUE}:
+ * "date", "environment" (as a String), "measurement_name", "measurement_version",
+ * "field_name", "field_flags", "value".
+ *
+ * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
+ * @return a cursor. The caller is responsible for closing this.
+ */
+ @Override
+ public Cursor getEventsSince(long time) {
+ final int start = this.getDay(time);
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ final String dayString = Integer.toString(start, 10);
+ return db.query("named_events", COLUMNS_DATE_ENVSTR_M_MV_F_VALUE,
+ "date >= ?", new String[] {dayString}, null, null,
+ "date, environment, measurement_name, measurement_version, field_name");
+ }
+
+ public int getEventCount() {
+ return getRowCount("events");
+ }
+
+ public int getEnvironmentCount() {
+ return getRowCount("environments");
+ }
+
+ private int getRowCount(String table) {
+ // table should be parameterized, but SQL throws a compilation error if the table in unknown
+ // in advance.
+ return getIntFromQuery("SELECT COUNT(*) from " + table, null);
+ }
+
+ /**
+ * Deletes all environments, addons, and events from the database before the given time. If this
+ * data does not have recorded dates but are no longer referenced by other fields, they are also
+ * removed (with exception to the current environment).
+ *
+ * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
+ * @param curEnv The ID of the current environment.
+ * @return The number of environments and addon entries deleted.
+ */
+ public int deleteDataBefore(final long time, final int curEnv) {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.beginTransaction();
+ int numRowsDeleted = 0;
+ try {
+ numRowsDeleted += deleteEnvAndEventsBefore(db, time, curEnv);
+ numRowsDeleted += deleteOrphanedAddons(db);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return numRowsDeleted;
+ }
+
+ /**
+ * Deletes environments and their referring events recorded before the given time. Environments
+ * referenced by no events are deleted, except for the current environment.
+ *
+ * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
+ * @param curEnv The ID of the current environment.
+ * @return The number of environments (not events) deleted.
+ */
+ protected int deleteEnvAndEventsBefore(final long time, final int curEnv) {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ return deleteEnvAndEventsBefore(db, time, curEnv);
+ }
+
+ // Called internally only to ensure the same db instance is used.
+ protected int deleteEnvAndEventsBefore(final SQLiteDatabase db, final long time, final int curEnv) {
+ // Delete environments only referenced by events occuring before the given time. Cascade
+ // delete these events.
+ String whereClause =
+ "(SELECT COUNT(*) FROM events WHERE date >= ? " +
+ " AND events.env = environments.id) = 0 " +
+ "AND id IN (SELECT DISTINCT env FROM events WHERE date < ?)";
+ final int day = this.getDay(time);
+ final String dayString = Integer.toString(day, 10);
+ String[] whereArgs = new String[] {dayString, dayString};
+
+ int numEnvDeleted = 0;
+ db.beginTransaction();
+ try {
+ numEnvDeleted += db.delete("environments", whereClause, whereArgs);
+ numEnvDeleted += deleteOrphanedEnv(db, curEnv);
+ // We can't get the number of events deleted through cascading deletions so we do not record
+ // the number of events deleted here.
+ deleteEventsBefore(db, dayString);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return numEnvDeleted;
+ }
+
+ /**
+ * Deletes environments not referenced by any events except for the given current environment.
+ */
+ protected int deleteOrphanedEnv(final int curEnv) {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ return deleteOrphanedEnv(db, curEnv);
+ }
+
+ // Called internally only to ensure the same db instance is used.
+ @SuppressWarnings("static-method")
+ protected int deleteOrphanedEnv(final SQLiteDatabase db, final int curEnv) {
+ final String whereClause =
+ "id != ? AND " +
+ "id NOT IN (SELECT env FROM events)";
+ final String[] whereArgs = new String[] {Integer.toString(curEnv)};
+ return db.delete("environments", whereClause, whereArgs);
+ }
+
+ protected int deleteEventsBefore(final String dayString) {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ return deleteEventsBefore(db, dayString);
+ }
+
+ // Called internally only to ensure the same db instance is used.
+ @SuppressWarnings("static-method")
+ protected int deleteEventsBefore(final SQLiteDatabase db, final String dayString) {
+ final String whereClause = "date < ?";
+ final String[] whereArgs = new String[] {dayString};
+ int numEventsDeleted = 0;
+ db.beginTransaction();
+ try {
+ numEventsDeleted += db.delete("events_integer", whereClause, whereArgs);
+ numEventsDeleted += db.delete("events_textual", whereClause, whereArgs);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return numEventsDeleted;
+ }
+
+ /**
+ * Deletes addons not referenced by any environments.
+ */
+ protected int deleteOrphanedAddons() {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ return deleteOrphanedAddons(db);
+ }
+
+ // Called internally only to ensure the same db instance is used.
+ @SuppressWarnings("static-method")
+ protected int deleteOrphanedAddons(final SQLiteDatabase db) {
+ final String whereClause = "id NOT IN (SELECT addonsID FROM environments)";
+ return db.delete("addons", whereClause, null);
+ }
+
+ /**
+ * Retrieve a mapping from a table. Keys should be unique; only one key-value
+ * pair will be returned for each key.
+ */
+ private static HashMap getIntegers(SQLiteDatabase db, String table, String columnA, String columnB) {
+ Cursor c = db.query(table, new String[] {columnA, columnB}, null, null, null, null, null);
+ try {
+ if (!c.moveToFirst()) {
+ return null;
+ }
+
+ HashMap results = new HashMap();
+ while (!c.isAfterLast()) {
+ results.put(c.getString(0), c.getInt(1));
+ c.moveToNext();
+ }
+ return results;
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Retrieve a single value from a mapping table.
+ */
+ private int integerQuery(String table, String column, String where, String[] args, int defaultValue) {
+ final SQLiteDatabase db = this.helper.getReadableDatabase();
+ Cursor c = db.query(table, new String[] {column}, where, args, null, null, column + " DESC", "1");
+ try {
+ if (!c.moveToFirst()) {
+ return defaultValue;
+ }
+ return c.getInt(0);
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Helper to allow us to avoid excessive code duplication.
+ *
+ * @param v
+ * the destination ContentValues
.
+ * @param value
+ * either a String
or an Integer
. No type
+ * checking is performed.
+ */
+ private static final void putValue(final ContentValues v, Object value) {
+ if (value instanceof String) {
+ v.put("value", (String) value);
+ } else {
+ v.put("value", (Integer) value);
+ }
+ }
+
+ @Override
+ public void deleteEverything() {
+ this.helper.deleteEverything();
+ }
+
+ @Override
+ public void deleteEnvironments() {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.beginTransaction();
+ try {
+ // Cascade will clear the rest.
+ db.delete("environments", null, null);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ @Override
+ public void deleteMeasurements() {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.beginTransaction();
+ try {
+ // Cascade will clear the rest.
+ db.delete("measurements", null, null);
+
+ // Clear measurements and fields cache, because some of their IDs are now invalid.
+ invalidateFieldsCache(); // Let it repopulate on its own.
+ populateMeasurementVersionsCache(db); // Performed at Storage init so repopulate now.
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Prunes the given number of least-recently used environments. Note that orphaned environments
+ * are not removed and the environment cache is cleared.
+ */
+ public void pruneEnvironments(final int numToPrune) {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.beginTransaction();
+ try {
+ db.delete("environments",
+ "id in (SELECT env " +
+ " FROM events " +
+ " GROUP BY env " +
+ " ORDER BY MAX(date), env " +
+ " LIMIT " + numToPrune + ")",
+ null);
+ db.setTransactionSuccessful();
+
+ // Clear environment cache, because some of their IDs are now invalid.
+ this.envs.clear();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Prunes up to a maximum of the given number of the oldest events. While it is more correct to
+ * prune the exact given amount, there is no unique identifier among events so we cannot be so
+ * precise. Instead, we prune on date, deleting all events up to the day before our count of
+ * events reaches the given maximum. Note that this technicality means this method cannot be
+ * used to delete all events.
+ */
+ public void pruneEvents(final int maxNumToPrune) {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+
+ final Cursor c = db.rawQuery(
+ "SELECT MAX(date) " +
+ "FROM (SELECT date " +
+ " FROM events " +
+ " ORDER BY date " +
+ " LIMIT " + maxNumToPrune + ")",
+ null);
+ long pruneDate = -1;
+ try {
+ if (!c.moveToFirst()) {
+ Logger.debug(LOG_TAG, "No max date found in events: table is likely empty. Not pruning " +
+ "events.");
+ return;
+ }
+ pruneDate = c.getLong(0);
+ } finally {
+ c.close();
+ }
+
+ final String selection = "date < " + pruneDate;
+ db.beginTransaction();
+ try {
+ db.delete(EVENTS_INTEGER, selection, null);
+ db.delete(EVENTS_TEXTUAL, selection, null);
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ public void vacuum() {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.execSQL("vacuum");
+ }
+
+ /**
+ * Disables auto_vacuuming. Changes may only take effect after a "vacuum" command.
+ */
+ public void disableAutoVacuuming() {
+ final SQLiteDatabase db = this.helper.getWritableDatabase();
+ db.execSQL("PRAGMA auto_vacuum=0");
+ }
+}