/* 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.goanna.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.goanna.background.common.DateUtils; import org.mozilla.goanna.background.common.log.Logger; import org.mozilla.goanna.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.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", // v3. "hasHardwareKeyboard", "uiMode", "uiType", "screenLayout", "screenXInMM", "screenYInMM" }; 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 = 7; 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 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(context, getAbsolutePath(profileDirectory, name), null, version); 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, " + " hasHardwareKeyboard INTEGER, " + " uiMode INTEGER, " + " uiType TEXT, " + " screenLayout INTEGER, " + " screenXInMM INTEGER, " + " screenYInMM 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, " + " e.hasHardwareKeyboard AS hasHardwareKeyboard, " + " e.uiMode AS uiMode, " + " e.uiType AS uiType, " + " e.screenLayout AS screenLayout, " + " e.screenXInMM AS screenXInMM, " + " e.screenYInMM AS screenYInMM " + "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"); // No need to create view: we're just going to upgrade again. } 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 IF EXISTS 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"); // No need to recreate view -- we're just going to upgrade to v7 next. } private void upgradeDatabaseFrom6to7(SQLiteDatabase db) { db.execSQL("DROP VIEW IF EXISTS environments_with_addons"); // Add fields to environment (default to empty string and 0). db.execSQL("ALTER TABLE environments ADD COLUMN hasHardwareKeyboard INTEGER DEFAULT 0"); db.execSQL("ALTER TABLE environments ADD COLUMN uiMode INTEGER DEFAULT 0"); db.execSQL("ALTER TABLE environments ADD COLUMN uiType TEXT DEFAULT ''"); db.execSQL("ALTER TABLE environments ADD COLUMN screenLayout INTEGER DEFAULT 0"); db.execSQL("ALTER TABLE environments ADD COLUMN screenXInMM INTEGER DEFAULT 0"); db.execSQL("ALTER TABLE environments ADD COLUMN screenYInMM 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); case 6: upgradeDatabaseFrom6to7(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); v.put("hasHardwareKeyboard", hasHardwareKeyboard ? 1 : 0); v.put("uiMode", uiMode); v.put("uiType", uiType.toString()); v.put("screenLayout", screenLayout); v.put("screenXInMM", screenXInMM); v.put("screenYInMM", screenYInMM); 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; Logger.debug(LOG_TAG, "Initializing environment with version " + 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. } if (version >= 3) { hasHardwareKeyboard = v.getAsInteger("hasHardwareKeyboard") != 0; uiMode = v.getAsInteger("uiMode"); uiType = UIType.fromLabel(v.getAsString("uiType")); screenLayout = v.getAsInteger("screenLayout"); screenXInMM = v.getAsInteger("screenXInMM"); screenYInMM = v.getAsInteger("screenYInMM"); } 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. } if (this.version >= 3) { hasHardwareKeyboard = cursor.getInt(i++) != 0; uiMode = cursor.getInt(i++); uiType = UIType.fromLabel(cursor.getString(i++)); screenLayout = cursor.getInt(i++); screenXInMM = cursor.getInt(i++); screenYInMM = cursor.getInt(i++); } return cursor.moveToNext(); } public DatabaseEnvironment(HealthReportDatabaseStorage storage, Class 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 final 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, 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"); } @Override public int getEventCount() { return getRowCount("events"); } @Override 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. */ @Override 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. */ @Override 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. */ @Override 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"); } }