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