mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java

Wed, 31 Dec 2014 07:22:50 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 07:22:50 +0100
branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
permissions
-rw-r--r--

Correct previous dual key logic pending first delivery installment.

     1 /* This Source Code Form is subject to the terms of the Mozilla Public
     2  * License, v. 2.0. If a copy of the MPL was not distributed with this
     3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 package org.mozilla.gecko.background.healthreport;
     7 import java.io.File;
     8 import java.util.Collection;
     9 import java.util.HashMap;
    10 import java.util.concurrent.ConcurrentHashMap;
    11 import java.util.concurrent.Executor;
    12 import java.util.concurrent.Executors;
    14 import org.json.JSONObject;
    15 import org.mozilla.gecko.background.common.DateUtils;
    16 import org.mozilla.gecko.background.common.log.Logger;
    17 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
    19 import android.content.ContentValues;
    20 import android.content.Context;
    21 import android.content.ContextWrapper;
    22 import android.database.Cursor;
    23 import android.database.SQLException;
    24 import android.database.sqlite.SQLiteConstraintException;
    25 import android.database.sqlite.SQLiteDatabase;
    26 import android.database.sqlite.SQLiteOpenHelper;
    27 import android.os.Build;
    28 import android.util.SparseArray;
    30 /**
    31  * <code>HealthReportDatabaseStorage</code> provides an interface on top of
    32  * SQLite storage for Health Report data. It exposes methods for management of
    33  * environments, measurements, fields, and values, and a cursor-based API for
    34  * querying.
    35  *
    36  * Health Report data is structured as follows.
    37  *
    38  * Records are primarily broken down by date, at day granularity. Each day's data
    39  * is then split according to environment. An environment is a collection of
    40  * constant attributes, such as version and processor; if one of these attributes
    41  * changes, a new environment becomes active.
    42  *
    43  * Environments are identified by a stable hash of their attributes.
    44  *
    45  * The database includes a persistent numeric identifier for each environment. Create
    46  * or fetch this identifier via:
    47  *
    48  * <pre>
    49  *  final Environment environment = storage.getEnvironment();
    50  *  // Init the environment now.
    51  *  String envHash = environment.getHash();
    52  *  int env = environment.register();
    53  * </pre>
    54  *
    55  * You can safely cache this environment identifier for the life of the database.
    56  *
    57  * Orthogonal to environments are measurements. Each measurement is a named and
    58  * versioned scope for a collection of fields. It is assumed that if a measurement
    59  * with the given name and version is known to the database, then all of its fields
    60  * are also known; if you change the collection of fields in the measurement, the
    61  * measurement's version must be incremented, too.
    62  *
    63  * As with environments, measurements have an internal numeric identifier.
    64  *
    65  * Calling code should initialize its measurements as follows:
    66  *
    67  * <pre>
    68  *   public static class FooFieldsV1 implements MeasurementFields {
    69  *     {@literal @}Override
    70  *     public Iterable<String> getFields() {
    71  *       ArrayList<String> fields = new ArrayList<String>();
    72  *       fields.add("bar");
    73  *       fields.add("baz");
    74  *       return fields;
    75  *     }
    76  *   }
    77  *
    78  *   storage.beginInitialization();
    79  *
    80  *   try {
    81  *     storage.ensureMeasurementInitialized("org.mozilla.fooProvider.fooMeasurement",
    82  *                                          1, new FooFieldsV1());
    83  *     storage.finishInitialization();
    84  *   } catch (Exception e) {
    85  *     storage.abortInitialization();
    86  *   }
    87  * </pre>
    88  *
    89  * Measurements have fields. Fields can conceptually be divided into "daily last"
    90  * (we only care about the last value), "daily counter" (increments per day),
    91  * "daily discrete" (multiple records per day). Simply call the correct method for each.
    92  *
    93  * To do so you need a field ID, to avoid constant costly string lookups. You can get
    94  * this value from storage:
    95  *
    96  * <pre>
    97  *   Field field = storage.getField("org.mozilla.fooProvider.fooMeasurement", 1, "bar");
    98  *   int fieldID = field.getID();
    99  * </pre>
   100  *
   101  * This lookup is cached, and so is relatively inexpensive.
   102  *
   103  * You can then do something like the following:
   104  *
   105  * <pre>
   106  *   storage.recordDailyLast(storage.getDay(), env, fieldID, "last value");
   107  * </pre>
   108  *
   109  * or equivalently for numeric values, discrete or counters, etc.
   110  *
   111  * To retrieve values, use {@link #getRawEventsSince(long)}.
   112  *
   113  * For safety, perform operations on the storage executor thread:
   114  *
   115  * <pre>
   116  *   storage.enqueueOperation(runnable);
   117  * </pre>
   118  */
   119 public class HealthReportDatabaseStorage implements HealthReportStorage {
   121   private static final String WHERE_DATE_AND_ENV_AND_FIELD = "date = ? AND env = ? AND field = ?";
   123   public static final String[] COLUMNS_HASH = new String[] {"hash"};
   124   public static final String[] COLUMNS_DATE_ENV_FIELD_VALUE = new String[] {"date", "env", "field", "value"};
   125   public static final String[] COLUMNS_DATE_ENVSTR_M_MV_F_VALUE = new String[] {
   126     "date", "environment", "measurement_name", "measurement_version",
   127     "field_name", "field_flags", "value"
   128   };
   130   private static final String[] COLUMNS_ENVIRONMENT_DETAILS = new String[] {
   131       "id", "version", "hash",
   132       "profileCreation", "cpuCount", "memoryMB",
   134       "isBlocklistEnabled", "isTelemetryEnabled", "extensionCount",
   135       "pluginCount", "themeCount",
   137       "architecture", "sysName", "sysVersion", "vendor", "appName", "appID",
   138       "appVersion", "appBuildID", "platformVersion", "platformBuildID", "os",
   139       "xpcomabi", "updateChannel",
   141       "distribution", "osLocale", "appLocale", "acceptLangSet",
   143       // Joined to the add-ons table.
   144       "addonsBody"
   145   };
   147   public static final String[] COLUMNS_MEASUREMENT_DETAILS = new String[] {"id", "name", "version"};
   148   public static final String[] COLUMNS_MEASUREMENT_AND_FIELD_DETAILS =
   149       new String[] {"measurement_name", "measurement_id", "measurement_version",
   150                     "field_name", "field_id", "field_flags"};
   152   private static final String[] COLUMNS_VALUE = new String[] {"value"};
   153   private static final String[] COLUMNS_ID = new String[] {"id"};
   155   private static final String EVENTS_TEXTUAL = "events_textual";
   156   private static final String EVENTS_INTEGER = "events_integer";
   158   protected static final String DB_NAME = "health.db";
   160   private static final String LOG_TAG = "HealthReportStorage";
   162   private final Executor executor = Executors.newSingleThreadExecutor();
   164   @Override
   165   public void enqueueOperation(Runnable runnable) {
   166     executor.execute(runnable);
   167   }
   169   public HealthReportDatabaseStorage(final Context context,
   170                                      final File profileDirectory) {
   171     this.helper = new HealthReportSQLiteOpenHelper(context, profileDirectory,
   172                                                    DB_NAME);
   173     executor.execute(new Runnable() {
   174       @Override
   175       public void run() {
   176         Logger.setThreadLogTag(HealthReportConstants.GLOBAL_LOG_TAG);
   177         Logger.debug(LOG_TAG, "Creating HealthReportDatabaseStorage.");
   178       }
   179     });
   180   }
   182   @Override
   183   public void close() {
   184     this.helper.close();
   185     this.fields.clear();
   186     this.envs.clear();
   187     this.measurementVersions.clear();
   188   }
   190   protected final HealthReportSQLiteOpenHelper helper;
   192   public static class HealthReportSQLiteOpenHelper extends SQLiteOpenHelper {
   193     public static final int CURRENT_VERSION = 6;
   194     public static final String LOG_TAG = "HealthReportSQL";
   196     /**
   197      * A little helper to avoid SQLiteOpenHelper misbehaving on Android 2.1.
   198      * Partly cribbed from
   199      * <http://stackoverflow.com/questions/5332328/sqliteopenhelper-problem-with-fully-qualified-db-path-name>.
   200      */
   201     public static class AbsolutePathContext extends ContextWrapper {
   202       private final File parent;
   204       public AbsolutePathContext(Context base, File parent) {
   205         super(base);
   206         this.parent = parent;
   207       }
   209       @Override
   210       public File getDatabasePath(String name) {
   211         return new File(getAbsolutePath(parent, name));
   212       }
   214       // Won't be called after API v11, but we can't override the version that
   215       // *is* called and still support v8.
   216       // Instead we check the version code in the HealthReportSQLiteOpenHelper
   217       // constructor, and only use this workaround if we need to.
   218       @Override
   219       public SQLiteDatabase openOrCreateDatabase(String name,
   220                                                  int mode,
   221                                                  SQLiteDatabase.CursorFactory factory) {
   222         final File path = getDatabasePath(name);
   223         Logger.pii(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath());
   224         return SQLiteDatabase.openOrCreateDatabase(path, null);
   225       }
   226     }
   228     public static String getAbsolutePath(File parent, String name) {
   229       return parent.getAbsolutePath() + File.separator + name;
   230     }
   232     public static boolean CAN_USE_ABSOLUTE_DB_PATH = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO);
   233     public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name) {
   234       this(context, profileDirectory, name, CURRENT_VERSION);
   235     }
   237     // For testing DBs of different versions.
   238     public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name, int version) {
   239       super(
   240           (CAN_USE_ABSOLUTE_DB_PATH ? context : new AbsolutePathContext(context, profileDirectory)),
   241           (CAN_USE_ABSOLUTE_DB_PATH ? getAbsolutePath(profileDirectory, name) : name),
   242           null,
   243           version);
   245       if (CAN_USE_ABSOLUTE_DB_PATH) {
   246         Logger.pii(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name));
   247       }
   248     }
   250     @Override
   251     public void onCreate(SQLiteDatabase db) {
   252       db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
   253                  "                     body TEXT, " +
   254                  "                     UNIQUE (body) " +
   255                  ")");
   257       // N.B., hash collisions can occur across versions. In that case, the system
   258       // is likely to persist the original environment version.
   259       db.execSQL("CREATE TABLE environments (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
   260                  "                           version INTEGER, " +
   261                  "                           hash TEXT, " +
   262                  "                           profileCreation INTEGER, " +
   263                  "                           cpuCount        INTEGER, " +
   264                  "                           memoryMB        INTEGER, " +
   265                  "                           isBlocklistEnabled INTEGER, " +
   266                  "                           isTelemetryEnabled INTEGER, " +
   267                  "                           extensionCount     INTEGER, " +
   268                  "                           pluginCount        INTEGER, " +
   269                  "                           themeCount         INTEGER, " +
   270                  "                           architecture    TEXT, " +
   271                  "                           sysName         TEXT, " +
   272                  "                           sysVersion      TEXT, " +
   273                  "                           vendor          TEXT, " +
   274                  "                           appName         TEXT, " +
   275                  "                           appID           TEXT, " +
   276                  "                           appVersion      TEXT, " +
   277                  "                           appBuildID      TEXT, " +
   278                  "                           platformVersion TEXT, " +
   279                  "                           platformBuildID TEXT, " +
   280                  "                           os              TEXT, " +
   281                  "                           xpcomabi        TEXT, " +
   282                  "                           updateChannel   TEXT, " +
   284                  "                           distribution    TEXT, " +
   285                  "                           osLocale        TEXT, " +
   286                  "                           appLocale       TEXT, " +
   287                  "                           acceptLangSet   INTEGER, " +
   289                  "                           addonsID        INTEGER, " +
   290                  "                           FOREIGN KEY (addonsID) REFERENCES addons(id) ON DELETE RESTRICT, " +
   291                  "                           UNIQUE (hash) " +
   292                  ")");
   294       db.execSQL("CREATE TABLE measurements (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
   295                  "                           name TEXT, " +
   296                  "                           version INTEGER, " +
   297                  "                           UNIQUE (name, version) " +
   298                  ")");
   300       db.execSQL("CREATE TABLE fields (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
   301                  "                     measurement INTEGER, " +
   302                  "                     name TEXT, " +
   303                  "                     flags INTEGER, " +
   304                  "                     FOREIGN KEY (measurement) REFERENCES measurements(id) ON DELETE CASCADE, " +
   305                  "                     UNIQUE (measurement, name)" +
   306                  ")");
   308       db.execSQL("CREATE TABLE " + EVENTS_INTEGER + "(" +
   309                  "                 date  INTEGER, " +
   310                  "                 env   INTEGER, " +
   311                  "                 field INTEGER, " +
   312                  "                 value INTEGER, " +
   313                  "                 FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " +
   314                  "                 FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" +
   315                  ")");
   317       db.execSQL("CREATE TABLE " + EVENTS_TEXTUAL + "(" +
   318                  "                 date  INTEGER, " +
   319                  "                 env   INTEGER, " +
   320                  "                 field INTEGER, " +
   321                  "                 value TEXT, " +
   322                  "                 FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " +
   323                  "                 FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" +
   324                  ")");
   326       db.execSQL("CREATE INDEX idx_events_integer_date_env_field ON events_integer (date, env, field)");
   327       db.execSQL("CREATE INDEX idx_events_textual_date_env_field ON events_textual (date, env, field)");
   329       db.execSQL("CREATE VIEW events AS " +
   330                  "SELECT date, env, field, value FROM " + EVENTS_INTEGER + " " +
   331                  "UNION ALL " +
   332                  "SELECT date, env, field, value FROM " + EVENTS_TEXTUAL);
   334       db.execSQL("CREATE VIEW named_events AS " +
   335                  "SELECT date, " +
   336                  "       environments.hash AS environment, " +
   337                  "       measurements.name AS measurement_name, " +
   338                  "       measurements.version AS measurement_version, " +
   339                  "       fields.name AS field_name, " +
   340                  "       fields.flags AS field_flags, " +
   341                  "       value FROM " +
   342                  "events JOIN environments ON events.env = environments.id " +
   343                  "       JOIN fields ON events.field = fields.id " +
   344                  "       JOIN measurements ON fields.measurement = measurements.id");
   346       db.execSQL("CREATE VIEW named_fields AS " +
   347                  "SELECT measurements.name AS measurement_name, " +
   348                  "       measurements.id AS measurement_id, " +
   349                  "       measurements.version AS measurement_version, " +
   350                  "       fields.name AS field_name, " +
   351                  "       fields.id AS field_id, " +
   352                  "       fields.flags AS field_flags " +
   353                  "FROM fields JOIN measurements ON fields.measurement = measurements.id");
   355       db.execSQL("CREATE VIEW current_measurements AS " +
   356                  "SELECT name, MAX(version) AS version FROM measurements GROUP BY name");
   358       createAddonsEnvironmentsView(db);
   359     }
   361     @Override
   362     public void onOpen(SQLiteDatabase db) {
   363       if (!db.isReadOnly()) {
   364         db.execSQL("PRAGMA foreign_keys=ON;");
   365       }
   366     }
   368     private void createAddonsEnvironmentsView(SQLiteDatabase db) {
   369       db.execSQL("CREATE VIEW environments_with_addons AS " +
   370           "SELECT e.id AS id, " +
   371           "       e.version AS version, " +
   372           "       e.hash AS hash, " +
   373           "       e.profileCreation AS profileCreation, " +
   374           "       e.cpuCount AS cpuCount, " +
   375           "       e.memoryMB AS memoryMB, " +
   376           "       e.isBlocklistEnabled AS isBlocklistEnabled, " +
   377           "       e.isTelemetryEnabled AS isTelemetryEnabled, " +
   378           "       e.extensionCount AS extensionCount, " +
   379           "       e.pluginCount AS pluginCount, " +
   380           "       e.themeCount AS themeCount, " +
   381           "       e.architecture AS architecture, " +
   382           "       e.sysName AS sysName, " +
   383           "       e.sysVersion AS sysVersion, " +
   384           "       e.vendor AS vendor, " +
   385           "       e.appName AS appName, " +
   386           "       e.appID AS appID, " +
   387           "       e.appVersion AS appVersion, " +
   388           "       e.appBuildID AS appBuildID, " +
   389           "       e.platformVersion AS platformVersion, " +
   390           "       e.platformBuildID AS platformBuildID, " +
   391           "       e.os AS os, " +
   392           "       e.xpcomabi AS xpcomabi, " +
   393           "       e.updateChannel AS updateChannel, " +
   394           "       e.distribution AS distribution, " +
   395           "       e.osLocale AS osLocale, " +
   396           "       e.appLocale AS appLocale, " +
   397           "       e.acceptLangSet AS acceptLangSet, " +
   398           "       addons.body AS addonsBody " +
   399           "FROM environments AS e, addons " +
   400           "WHERE e.addonsID = addons.id");
   401     }
   403     private void upgradeDatabaseFrom2To3(SQLiteDatabase db) {
   404       db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
   405                  "                     body TEXT, " +
   406                  "                     UNIQUE (body) " +
   407                  ")");
   409       db.execSQL("ALTER TABLE environments ADD COLUMN addonsID INTEGER REFERENCES addons(id) ON DELETE RESTRICT");
   411       createAddonsEnvironmentsView(db);
   412     }
   414     private void upgradeDatabaseFrom3To4(SQLiteDatabase db) {
   415       // Update search measurements to use a different type.
   416       db.execSQL("UPDATE OR IGNORE fields SET flags = " + Field.TYPE_COUNTED_STRING_DISCRETE +
   417                  " WHERE measurement IN (SELECT id FROM measurements WHERE name = 'org.mozilla.searches.counts')");
   418     }
   420     private void upgradeDatabaseFrom4to5(SQLiteDatabase db) {
   421       // Delete NULL in addons.body, which appeared as a result of Bug 886156. Note that the
   422       // foreign key constraint, "ON DELETE RESTRICT", may be violated, but since onOpen() is
   423       // called after this method, foreign keys are not yet enabled and constraints can be broken.
   424       db.delete("addons", "body IS NULL", null);
   426       // Purge any data inconsistent with foreign key references (which may have appeared before
   427       // foreign keys were enabled in Bug 900289).
   428       db.delete("fields", "measurement NOT IN (SELECT id FROM measurements)", null);
   429       db.delete("environments", "addonsID NOT IN (SELECT id from addons)", null);
   430       db.delete(EVENTS_INTEGER, "env NOT IN (SELECT id FROM environments)", null);
   431       db.delete(EVENTS_TEXTUAL, "env NOT IN (SELECT id FROM environments)", null);
   432       db.delete(EVENTS_INTEGER, "field NOT IN (SELECT id FROM fields)", null);
   433       db.delete(EVENTS_TEXTUAL, "field NOT IN (SELECT id FROM fields)", null);
   434     }
   436     private void upgradeDatabaseFrom5to6(SQLiteDatabase db) {
   437       db.execSQL("DROP VIEW environments_with_addons");
   439       // Add version to environment (default to 1).
   440       db.execSQL("ALTER TABLE environments ADD COLUMN version INTEGER DEFAULT 1");
   442       // Add fields to environment (default to empty string).
   443       db.execSQL("ALTER TABLE environments ADD COLUMN distribution TEXT DEFAULT ''");
   444       db.execSQL("ALTER TABLE environments ADD COLUMN osLocale TEXT DEFAULT ''");
   445       db.execSQL("ALTER TABLE environments ADD COLUMN appLocale TEXT DEFAULT ''");
   446       db.execSQL("ALTER TABLE environments ADD COLUMN acceptLangSet INTEGER DEFAULT 0");
   448       // Recreate view.
   449       createAddonsEnvironmentsView(db);
   450     }
   452     @Override
   453     public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
   454       if (oldVersion >= newVersion) {
   455         return;
   456       }
   458       Logger.info(LOG_TAG, "onUpgrade: from " + oldVersion + " to " + newVersion + ".");
   459       try {
   460         switch (oldVersion) {
   461         case 2:
   462           upgradeDatabaseFrom2To3(db);
   463         case 3:
   464           upgradeDatabaseFrom3To4(db);
   465         case 4:
   466           upgradeDatabaseFrom4to5(db);
   467         case 5:
   468           upgradeDatabaseFrom5to6(db);
   469         }
   470       } catch (Exception e) {
   471         Logger.error(LOG_TAG, "Failure in onUpgrade.", e);
   472         throw new RuntimeException(e);
   473       }
   474    }
   476     public void deleteEverything() {
   477       final SQLiteDatabase db = this.getWritableDatabase();
   479       Logger.info(LOG_TAG, "Deleting everything.");
   480       db.beginTransaction();
   481       try {
   482         // Cascade will clear the rest.
   483         db.delete("measurements", null, null);
   484         db.delete("environments", null, null);
   485         db.delete("addons", null, null);
   486         db.setTransactionSuccessful();
   487         Logger.info(LOG_TAG, "Deletion successful.");
   488       } finally {
   489         db.endTransaction();
   490       }
   491     }
   492   }
   494   public class DatabaseField extends Field {
   495     public DatabaseField(String mName, int mVersion, String fieldName) {
   496       this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, UNKNOWN_TYPE_OR_FIELD_ID);
   497     }
   499     public DatabaseField(String mName, int mVersion, String fieldName, int flags) {
   500       this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, flags);
   501     }
   503     public DatabaseField(String mName, int mVersion, String fieldName, int fieldID, int flags) {
   504       super(mName, mVersion, fieldName, flags);
   505       this.fieldID = fieldID;
   506     }
   508     private void loadFlags() {
   509       if (this.flags == UNKNOWN_TYPE_OR_FIELD_ID) {
   510         if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
   511           this.getID();
   512         }
   513         this.flags = integerQuery("fields", "flags", "id = ?", new String[] { Integer.toString(this.fieldID, 10) }, -1);
   514       }
   515     }
   517     @Override
   518     public synchronized boolean isIntegerField() {
   519       loadFlags();
   520       return super.isIntegerField();
   521     }
   523     @Override
   524     public synchronized boolean isStringField() {
   525       loadFlags();
   526       return super.isStringField();
   527     }
   529     @Override
   530     public synchronized boolean isDiscreteField() {
   531       loadFlags();
   532       return super.isDiscreteField();
   533     }
   535     @Override
   536     public synchronized int getID() throws IllegalStateException {
   537       if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
   538         this.fieldID = integerQuery("named_fields", "field_id",
   539                                     "measurement_name = ? AND measurement_version = ? AND field_name = ?",
   540                                     new String[] {measurementName, measurementVersion, fieldName},
   541                                     UNKNOWN_TYPE_OR_FIELD_ID);
   542         if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
   543           throw new IllegalStateException("No field with name " + fieldName +
   544                                           " (" + measurementName + ", " + measurementVersion + ")");
   545         }
   546       }
   547       return this.fieldID;
   548     }
   549   }
   551   // `envs` and `fields` look similar, but they are touched differently and
   552   // store differently stable kinds of data, hence type difference.
   553   // Note that we don't pre-populate the environment cache. We'll typically only
   554   // handle one per session.
   555   //
   556   // protected for testing purposes only.
   557   protected final ConcurrentHashMap<String, Integer> envs = new ConcurrentHashMap<String, Integer>();
   559   /**
   560    * An {@link Environment} that knows how to persist to and from our database.
   561    */
   562   public static class DatabaseEnvironment extends Environment {
   563     protected final HealthReportDatabaseStorage storage;
   565     @Override
   566     public int register() {
   567       final String h = getHash();
   568       if (storage.envs.containsKey(h)) {
   569         this.id = storage.envs.get(h);
   570         return this.id;
   571       }
   573       // Otherwise, add data and hash to the DB.
   574       ContentValues v = new ContentValues();
   575       v.put("version", version);
   576       v.put("hash", h);
   577       v.put("profileCreation", profileCreation);
   578       v.put("cpuCount", cpuCount);
   579       v.put("memoryMB", memoryMB);
   580       v.put("isBlocklistEnabled", isBlocklistEnabled);
   581       v.put("isTelemetryEnabled", isTelemetryEnabled);
   582       v.put("extensionCount", extensionCount);
   583       v.put("pluginCount", pluginCount);
   584       v.put("themeCount", themeCount);
   585       v.put("architecture", architecture);
   586       v.put("sysName", sysName);
   587       v.put("sysVersion", sysVersion);
   588       v.put("vendor", vendor);
   589       v.put("appName", appName);
   590       v.put("appID", appID);
   591       v.put("appVersion", appVersion);
   592       v.put("appBuildID", appBuildID);
   593       v.put("platformVersion", platformVersion);
   594       v.put("platformBuildID", platformBuildID);
   595       v.put("os", os);
   596       v.put("xpcomabi", xpcomabi);
   597       v.put("updateChannel", updateChannel);
   598       v.put("distribution", distribution);
   599       v.put("osLocale", osLocale);
   600       v.put("appLocale", appLocale);
   601       v.put("acceptLangSet", acceptLangSet);
   603       final SQLiteDatabase db = storage.helper.getWritableDatabase();
   605       // If we're not already, we want all of our inserts to be in a transaction.
   606       boolean newTransaction = !db.inTransaction();
   608       // Insert, with a little error handling to populate the cache in case of
   609       // omission and consequent collision.
   610       //
   611       // We would like to hang a trigger off a view here, and just use that for
   612       // inserts. But triggers don't seem to correctly update the last inserted
   613       // ID, so Android's insertOrThrow method returns -1.
   614       //
   615       // Instead, we go without the trigger, simply running the inserts ourselves.
   616       //
   617       // insertWithOnConflict doesn't work as documented: <http://stackoverflow.com/questions/11328877/android-sqllite-on-conflict-ignore-is-ignored-in-ics/11424150>.
   618       // So we do this the hard way.
   619       // We presume that almost every get will hit the cache (except for the first, obviously), so we
   620       // bias here towards inserts for the environments.
   621       // For add-ons we assume that different environments will share add-ons, so we query first.
   623       final String addonsJSON = getNormalizedAddonsJSON();
   624       if (newTransaction) {
   625         db.beginTransaction();
   626       }
   628       try {
   629         int addonsID = ensureAddons(db, addonsJSON);
   630         v.put("addonsID", addonsID);
   632         try {
   633           int inserted = (int) db.insertOrThrow("environments", null, v);
   634           Logger.debug(LOG_TAG, "Inserted ID: " + inserted + " for hash " + h);
   635           if (inserted == -1) {
   636             throw new SQLException("Insert returned -1!");
   637           }
   638           this.id = inserted;
   639           storage.envs.put(h, this.id);
   640           if (newTransaction) {
   641             db.setTransactionSuccessful();
   642           }
   643           return inserted;
   644         } catch (SQLException e) {
   645           // The inserter should take care of updating `envs`. But if it
   646           // doesn't...
   647           Cursor c = db.query("environments", COLUMNS_ID, "hash = ?",
   648                               new String[] { h }, null, null, null);
   649           try {
   650             if (!c.moveToFirst()) {
   651               throw e;
   652             }
   653             this.id = (int) c.getLong(0);
   654             Logger.debug(LOG_TAG, "Found " + this.id + " for hash " + h);
   655             storage.envs.put(h, this.id);
   656             if (newTransaction) {
   657               db.setTransactionSuccessful();
   658             }
   659             return this.id;
   660           } finally {
   661             c.close();
   662           }
   663         }
   664       } finally {
   665         if (newTransaction) {
   666           db.endTransaction();
   667         }
   668       }
   669     }
   671     protected static int ensureAddons(SQLiteDatabase db, String json) {
   672       Cursor c = db.query("addons", COLUMNS_ID, "body = ?",
   673                           new String[] { (json == null) ? "null" : json }, null, null, null);
   674       try {
   675         if (c.moveToFirst()) {
   676           return c.getInt(0);
   677         }
   678         ContentValues values = new ContentValues();
   679         values.put("body", json);
   680         return (int) db.insert("addons", null, values);
   681       } finally {
   682         c.close();
   683       }
   684     }
   686     public void init(ContentValues v) {
   687       version         = v.containsKey("version") ? v.getAsInteger("version") : Environment.CURRENT_VERSION;
   688       profileCreation = v.getAsInteger("profileCreation");
   689       cpuCount        = v.getAsInteger("cpuCount");
   690       memoryMB        = v.getAsInteger("memoryMB");
   692       isBlocklistEnabled = v.getAsInteger("isBlocklistEnabled");
   693       isTelemetryEnabled = v.getAsInteger("isTelemetryEnabled");
   694       extensionCount     = v.getAsInteger("extensionCount");
   695       pluginCount        = v.getAsInteger("pluginCount");
   696       themeCount         = v.getAsInteger("themeCount");
   698       architecture    = v.getAsString("architecture");
   699       sysName         = v.getAsString("sysName");
   700       sysVersion      = v.getAsString("sysVersion");
   701       vendor          = v.getAsString("vendor");
   702       appName         = v.getAsString("appName");
   703       appID           = v.getAsString("appID");
   704       appVersion      = v.getAsString("appVersion");
   705       appBuildID      = v.getAsString("appBuildID");
   706       platformVersion = v.getAsString("platformVersion");
   707       platformBuildID = v.getAsString("platformBuildID");
   708       os              = v.getAsString("os");
   709       xpcomabi        = v.getAsString("xpcomabi");
   710       updateChannel   = v.getAsString("updateChannel");
   712       distribution    = v.getAsString("distribution");
   713       osLocale        = v.getAsString("osLocale");
   714       appLocale       = v.getAsString("appLocale");
   715       acceptLangSet   = v.getAsInteger("acceptLangSet");
   717       try {
   718         setJSONForAddons(v.getAsString("addonsBody"));
   719       } catch (Exception e) {
   720         // Nothing we can do.
   721       }
   723       this.hash = null;
   724       this.id = -1;
   725     }
   727     /**
   728      * Fill ourselves with data from the DB, then advance the cursor.
   729      *
   730      * @param cursor a {@link Cursor} pointing at a record to load.
   731      * @return true if the cursor was successfully advanced.
   732      */
   733     public boolean init(Cursor cursor) {
   734       int i = 0;
   735       this.id         = cursor.getInt(i++);
   736       this.version    = cursor.getInt(i++);
   737       this.hash       = cursor.getString(i++);
   739       profileCreation = cursor.getInt(i++);
   740       cpuCount        = cursor.getInt(i++);
   741       memoryMB        = cursor.getInt(i++);
   743       isBlocklistEnabled = cursor.getInt(i++);
   744       isTelemetryEnabled = cursor.getInt(i++);
   745       extensionCount     = cursor.getInt(i++);
   746       pluginCount        = cursor.getInt(i++);
   747       themeCount         = cursor.getInt(i++);
   749       architecture    = cursor.getString(i++);
   750       sysName         = cursor.getString(i++);
   751       sysVersion      = cursor.getString(i++);
   752       vendor          = cursor.getString(i++);
   753       appName         = cursor.getString(i++);
   754       appID           = cursor.getString(i++);
   755       appVersion      = cursor.getString(i++);
   756       appBuildID      = cursor.getString(i++);
   757       platformVersion = cursor.getString(i++);
   758       platformBuildID = cursor.getString(i++);
   759       os              = cursor.getString(i++);
   760       xpcomabi        = cursor.getString(i++);
   761       updateChannel   = cursor.getString(i++);
   763       distribution    = cursor.getString(i++);
   764       osLocale        = cursor.getString(i++);
   765       appLocale       = cursor.getString(i++);
   766       acceptLangSet   = cursor.getInt(i++);
   768       try {
   769         setJSONForAddons(cursor.getBlob(i++));
   770       } catch (Exception e) {
   771         // Nothing we can do.
   772       }
   774       return cursor.moveToNext();
   775     }
   777     public DatabaseEnvironment(HealthReportDatabaseStorage storage, Class<? extends EnvironmentAppender> appender) {
   778       super(appender);
   779       this.storage = storage;
   780     }
   782     public DatabaseEnvironment(HealthReportDatabaseStorage storage) {
   783       this.storage = storage;
   784     }
   785   }
   787   /**
   788    * Factory method. Returns a new {@link Environment} that callers can
   789    * populate and then register.
   790    */
   791   @Override
   792   public DatabaseEnvironment getEnvironment() {
   793     return new DatabaseEnvironment(this);
   794   }
   796   @Override
   797   public SparseArray<Environment> getEnvironmentRecordsByID() {
   798     final SQLiteDatabase db = this.helper.getReadableDatabase();
   799     Cursor c = db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, null, null, null, null, null);
   800     try {
   801       SparseArray<Environment> results = new SparseArray<Environment>();
   802       if (!c.moveToFirst()) {
   803         return results;
   804       }
   806       DatabaseEnvironment e = getEnvironment();
   807       while (e.init(c)) {
   808         results.put(e.id, e);
   809         e = getEnvironment();
   810       }
   811       results.put(e.id, e);
   812       return results;
   813     } finally {
   814       c.close();
   815     }
   816   }
   818   /**
   819    * Reverse lookup for an env. Only really used for tests: document generation
   820    * fetches all environments at once, and insertion only uses the integer key
   821    * that's returned during insertion.
   822    *
   823    * @param id
   824    *          the identifier for the environment.
   825    * @return a cursor over its details.
   826    */
   827   @Override
   828   public Cursor getEnvironmentRecordForID(int id) {
   829     final SQLiteDatabase db = this.helper.getReadableDatabase();
   830     return db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, "id = " + id, null, null, null, null);
   831   }
   833   @Override
   834   public SparseArray<String> getEnvironmentHashesByID() {
   835     final SQLiteDatabase db = this.helper.getReadableDatabase();
   836     Cursor c = db.query("environments", new String[] {"id", "hash"}, null, null, null, null, null);
   837     try {
   838       SparseArray<String> results = new SparseArray<String>();
   839       if (!c.moveToFirst()) {
   840         return results;
   841       }
   843       while (!c.isAfterLast()) {
   844         results.put(c.getInt(0), c.getString(1));
   845         c.moveToNext();
   846       }
   847       return results;
   848     } finally {
   849       c.close();
   850     }
   851   }
   853   /**
   854    * Cache the lookup from measurement and field specifier to field instance.
   855    * This allows us to memoize the field ID lookup, too.
   856    */
   857   private HashMap<String, Field> fields = new HashMap<String, Field>();
   858   private boolean fieldsCacheUpdated = false;
   860   private void invalidateFieldsCache() {
   861     synchronized (this.fields) {
   862       fieldsCacheUpdated = false;
   863     }
   864   }
   866   private String getFieldKey(String mName, int mVersion, String fieldName) {
   867     return mVersion + "." + mName + "/" + fieldName;
   868   }
   870   @Override
   871   public Field getField(String mName, int mVersion, String fieldName) {
   872     final String key = getFieldKey(mName, mVersion, fieldName);
   873     synchronized (fields) {
   874       if (fields.containsKey(key)) {
   875         return fields.get(key);
   876       }
   877       Field f = new DatabaseField(mName, mVersion, fieldName);
   878       fields.put(key, f);
   879       return f;
   880     }
   881   }
   883   private void populateFieldCache() {
   884     synchronized (fields) {
   885       if (fieldsCacheUpdated) {
   886         return;
   887       }
   889       fields.clear();
   890       Cursor c = getFieldVersions();
   891       try {
   892         if (!c.moveToFirst()) {
   893           return;
   894         }
   895         do {
   896           // We don't use the measurement ID here, so column 1 is unused.
   897           final String mName = c.getString(0);
   898           final int mVersion = c.getInt(2);
   899           final String fieldName = c.getString(3);
   900           final int fieldID = c.getInt(4);
   901           final int flags = c.getInt(5);
   902           final String key = getFieldKey(mName, mVersion, fieldName);
   904           Field f = new DatabaseField(mName, mVersion, fieldName, fieldID, flags);
   905           fields.put(key, f);
   906         } while (c.moveToNext());
   907         fieldsCacheUpdated = true;
   908       } finally {
   909         c.close();
   910       }
   911     }
   912   }
   914   /**
   915    * Return mappings from field ID to Field instance. Do so by looking in the DB.
   916    */
   917   @Override
   918   public SparseArray<Field> getFieldsByID() {
   919     final SparseArray<Field> out = new SparseArray<Field>();
   920     synchronized (fields) {
   921       populateFieldCache();
   922       Collection<Field> values = fields.values();
   923       for (Field field : values) {
   924         // Cache is up-to-date at this point, so we don't need to hit the DB.
   925         out.put(field.getID(), field);
   926       }
   927     }
   928     return out;
   929   }
   931   private final HashMap<String, Integer> measurementVersions = new HashMap<String, Integer>();
   933   private void populateMeasurementVersionsCache(SQLiteDatabase db) {
   934     HashMap<String, Integer> results = getIntegers(db, "current_measurements", "name", "version");
   935     if (results == null) {
   936       measurementVersions.clear();
   937       return;
   938     }
   939     synchronized (measurementVersions) {
   940       measurementVersions.clear();
   941       measurementVersions.putAll(results);
   942     }
   943   }
   945   /**
   946    * Return the version of the measurement for which the DB is currently configured, or
   947    * 0 if unknown.
   948    * @param measurement String measurement identifier.
   949    * @return Current version.
   950    */
   951   private int getMeasurementVersion(String measurement) {
   952     synchronized (measurementVersions) {
   953       if (measurementVersions.containsKey(measurement)) {
   954         return measurementVersions.get(measurement);
   955       }
   957       // This should never be necessary, unless the measurement does not exist.
   958       int value = integerQuery("measurements", "version", "name = ?", new String[] {measurement}, 0);
   959       measurementVersions.put(measurement, value);
   960       return value;
   961     }
   962   }
   964   /**
   965    * Inform the storage layer that fields for the given measurement have been updated
   966    * to this version.
   967    *
   968    * This should be one of the final calls in a configuration transaction.
   969    * Always call this inside a transaction.
   970    */
   971   private void notifyMeasurementVersionUpdated(String measurement, int version) {
   972     Logger.info(LOG_TAG, "Measurement " + measurement + " now at " + version);
   974     final SQLiteDatabase db = this.helper.getWritableDatabase();
   975     final ContentValues values = new ContentValues();
   976     values.put("name", measurement);
   977     values.put("version", version);
   979     synchronized (measurementVersions) {
   980       measurementVersions.put(measurement, version);
   981     }
   983     db.insertWithOnConflict("measurements", null, values, SQLiteDatabase.CONFLICT_IGNORE);
   984   }
   986   /**
   987    * Call in a transaction.
   988    * This method could race with other accesses, but (a) it's within a transaction,
   989    * (b) configuration should be single-threaded, (c) we initialize the cache up-front.
   990    */
   991   @Override
   992   public void ensureMeasurementInitialized(String measurement, int version, MeasurementFields fields) {
   993     final int currentVersion = getMeasurementVersion(measurement);
   994     Logger.info(LOG_TAG, "Initializing measurement " + measurement + " to " +
   995                          version + " (current " + currentVersion + ")");
   997     if (currentVersion == version) {
   998       Logger.info(LOG_TAG, "Measurement " + measurement + " already at v" + version);
   999       return;
  1002     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1003     if (!db.inTransaction()) {
  1004       Logger.warn(LOG_TAG, "ensureMeasurementInitialized should be called within a transaction.");
  1007     final ContentValues mv = new ContentValues();
  1008     mv.put("name", measurement);
  1009     mv.put("version", version);
  1011     final int measurementID = (int) db.insert("measurements", null, mv);
  1013     final ContentValues v = new ContentValues();
  1014     v.put("measurement", measurementID);
  1015     for (FieldSpec field : fields.getFields()) {
  1016       v.put("name", field.name);
  1017       v.put("flags", field.type);
  1018       Logger.debug(LOG_TAG, "M: " + measurementID + " F: " + field.name + " (" + field.type + ")");
  1019       db.insert("fields", null, v);
  1022     notifyMeasurementVersionUpdated(measurement, version);
  1024     // Let's be easy for now.
  1025     invalidateFieldsCache();
  1028   /**
  1029    * Return a cursor over the measurements and fields in the DB.
  1030    * Columns are {@link HealthReportDatabaseStorage#COLUMNS_MEASUREMENT_AND_FIELD_DETAILS}.
  1031    */
  1032   @Override
  1033   public Cursor getFieldVersions() {
  1034     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1035     return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS,
  1036                     null, null, null, null, "measurement_name, measurement_version, field_name");
  1039   @Override
  1040   public Cursor getFieldVersions(String measurement, int measurementVersion) {
  1041     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1042     return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS,
  1043                     "measurement_name = ? AND measurement_version = ?",
  1044                     new String[] {measurement, Integer.toString(measurementVersion)},
  1045                     null, null, "field_name");
  1048   @Override
  1049   public Cursor getMeasurementVersions() {
  1050     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1051     return db.query("measurements", COLUMNS_MEASUREMENT_DETAILS,
  1052                     null, null, null, null, "name, version");
  1055   /**
  1056    * A thin wrapper around the database transactional semantics. Clients can
  1057    * use this to more efficiently ensure that measurements are initialized.
  1059    * Note that caches are also initialized here.
  1060    */
  1061   public void beginInitialization() {
  1062     SQLiteDatabase db = this.helper.getWritableDatabase();
  1063     db.beginTransaction();
  1064     populateMeasurementVersionsCache(db);
  1067   public void finishInitialization() {
  1068     SQLiteDatabase db = this.helper.getWritableDatabase();
  1069     db.setTransactionSuccessful();
  1070     db.endTransaction();
  1073   public void abortInitialization() {
  1074     this.helper.getWritableDatabase().endTransaction();
  1077   protected int getIntFromQuery(final String sql, final String[] selectionArgs) {
  1078     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1079     final Cursor c = db.rawQuery(sql, selectionArgs);
  1080     try {
  1081       if (!c.moveToFirst()) {
  1082         throw new IllegalStateException("Cursor is empty.");
  1084       return c.getInt(0);
  1085     } finally {
  1086       c.close();
  1090   @Override
  1091   public int getDay(long time) {
  1092     return DateUtils.getDay(time);
  1095   @Override
  1096   public int getDay() {
  1097     return this.getDay(System.currentTimeMillis());
  1100   private void recordDailyLast(int env, int day, int field, Object value, String table) {
  1101     if (env == -1) {
  1102       Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
  1103       return;
  1106     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1108     final String envString = Integer.toString(env);
  1109     final String fieldIDString = Integer.toString(field, 10);
  1110     final String dayString = Integer.toString(day, 10);
  1112     // Java, your capacity for abstraction leaves me wanting.
  1113     final ContentValues v = new ContentValues();
  1114     putValue(v, value);
  1116     // If we used a separate table, such that we could have a
  1117     // UNIQUE(env, field, day) constraint for daily-last values, then we could
  1118     // use INSERT OR REPLACE.
  1119     final int updated = db.update(table, v, WHERE_DATE_AND_ENV_AND_FIELD,
  1120                                   new String[] {dayString, envString, fieldIDString});
  1121     if (0 == updated) {
  1122       v.put("env", env);
  1123       v.put("field", field);
  1124       v.put("date", day);
  1125       try {
  1126         db.insertOrThrow(table, null, v);
  1127       } catch (SQLiteConstraintException e) {
  1128         throw new IllegalStateException("Event did not reference existing an environment or field.", e);
  1133   @Override
  1134   public void recordDailyLast(int env, int day, int field, JSONObject value) {
  1135     this.recordDailyLast(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
  1138   @Override
  1139   public void recordDailyLast(int env, int day, int field, String value) {
  1140     this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL);
  1143   @Override
  1144   public void recordDailyLast(int env, int day, int field, int value) {
  1145     this.recordDailyLast(env, day, field, Integer.valueOf(value), EVENTS_INTEGER);
  1148   private void recordDailyDiscrete(int env, int day, int field, Object value, String table) {
  1149     if (env == -1) {
  1150       Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
  1151       return;
  1154     final ContentValues v = new ContentValues();
  1155     v.put("env", env);
  1156     v.put("field", field);
  1157     v.put("date", day);
  1159     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1160     putValue(v, value);
  1162     // Using SQLiteDatabase.insertOrThrow throws SQLiteConstraintException we cannot catch for
  1163     // unknown reasons (bug 961526 comment 13). We believe these are thrown because we attempt to
  1164     // record events using environment IDs removed from the database by the prune service. We
  1165     // invalidate the currentEnvironment ID after pruning, preventing further propagation,
  1166     // however, any event recording waiting for the prune service to complete on the background
  1167     // thread may carry an invalid ID: we expect an insertion failure and drop these events here.
  1168     final long res = db.insert(table, null, v);
  1169     if (res == -1) {
  1170       Logger.error(LOG_TAG, "Unable to record daily discrete event. Ignoring.");
  1174   @Override
  1175   public void recordDailyDiscrete(int env, int day, int field, JSONObject value) {
  1176     this.recordDailyDiscrete(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
  1179   @Override
  1180   public void recordDailyDiscrete(int env, int day, int field, String value) {
  1181     this.recordDailyDiscrete(env, day, field, value, EVENTS_TEXTUAL);
  1184   @Override
  1185   public void recordDailyDiscrete(int env, int day, int field, int value) {
  1186     this.recordDailyDiscrete(env, day, field, value, EVENTS_INTEGER);
  1189   /**
  1190    * Increment the specified field value by the specified amount. Counts start
  1191    * at zero.
  1193    * Note that this method can misbehave or throw if not executed within a
  1194    * transaction, because correct behavior involves querying then
  1195    * insert-or-update, and a race condition can otherwise occur.
  1197    * @param env the environment ID
  1198    * @param day the current day, in days since epoch
  1199    * @param field the field ID
  1200    * @param by how much to increment the counter.
  1201    */
  1202   @Override
  1203   public void incrementDailyCount(int env, int day, int field, int by) {
  1204     if (env == -1) {
  1205       Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
  1206       return;
  1209     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1210     final String envString = Integer.toString(env);
  1211     final String fieldIDString = Integer.toString(field, 10);
  1212     final String dayString = Integer.toString(day, 10);
  1214     // Can't run a complex UPDATE and get the number of changed rows, so we'll
  1215     // do this the hard way.
  1216     // This relies on being called within a transaction.
  1217     final String[] args = new String[] {dayString, envString, fieldIDString};
  1218     final Cursor c = db.query(EVENTS_INTEGER,
  1219                               COLUMNS_VALUE,
  1220                               WHERE_DATE_AND_ENV_AND_FIELD,
  1221                               args, null, null, null, "1");
  1223     boolean present = false;
  1224     try {
  1225       present = c.moveToFirst();
  1226     } finally {
  1227       c.close();
  1230     if (present) {
  1231       // It's an int, so safe to concatenate. Avoids us having to mess with args.
  1232       db.execSQL("UPDATE " + EVENTS_INTEGER + " SET value = value + " + by + " WHERE " +
  1233                  WHERE_DATE_AND_ENV_AND_FIELD,
  1234                  args);
  1235     } else {
  1236       final ContentValues v = new ContentValues();
  1237       v.put("env", env);
  1238       v.put("value", by);
  1239       v.put("field", field);
  1240       v.put("date", day);
  1241       try {
  1242         db.insertOrThrow(EVENTS_INTEGER, null, v);
  1243       } catch (SQLiteConstraintException e) {
  1244         throw new IllegalStateException("Event did not reference existing an environment or field.", e);
  1249   @Override
  1250   public void incrementDailyCount(int env, int day, int field) {
  1251     this.incrementDailyCount(env, day, field, 1);
  1254   /**
  1255    * Are there events recorded on or after <code>time</code>?
  1257    * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
  1258    * @return true if such events exist, false otherwise.
  1259    */
  1260   @Override
  1261   public boolean hasEventSince(long time) {
  1262     final int start = this.getDay(time);
  1263     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1264     final String dayString = Integer.toString(start, 10);
  1265     Cursor cur = db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
  1266         "date >= ?", new String[] {dayString}, null, null, null, "1");
  1267     if (cur == null) {
  1268       // Something is horribly wrong; let the caller who actually reads the
  1269       // events deal with it.
  1270       return true;
  1272     try {
  1273       return cur.getCount() > 0;
  1274     } finally {
  1275       cur.close();
  1279   /**
  1280    * Returns a cursor over field events in the database. The results will be
  1281    * strictly ordered first by date, then by environment, and finally by field.
  1283    * Each row includes columns in {@link #COLUMNS_DATE_ENV_FIELD_VALUE}:
  1284    * "date", "env", "field", "value".
  1286    * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
  1287    * @return a cursor. The caller is responsible for closing this.
  1288    */
  1289   @Override
  1290   public Cursor getRawEventsSince(long time) {
  1291     final int start = this.getDay(time);
  1292     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1293     final String dayString = Integer.toString(start, 10);
  1294     return db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
  1295                     "date >= ?", new String[] {dayString}, null, null, "date, env, field");
  1298   /**
  1299    * Returns a cursor over field events in the database. The results will be
  1300    * strictly ordered first by date, then by environment, and finally by field.
  1302    * Each row includes columns in {@link #COLUMNS_DATE_ENVSTR_M_MV_F_VALUE}:
  1303    * "date", "environment" (as a String), "measurement_name", "measurement_version",
  1304    * "field_name", "field_flags", "value".
  1306    * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
  1307    * @return a cursor. The caller is responsible for closing this.
  1308    */
  1309   @Override
  1310   public Cursor getEventsSince(long time) {
  1311     final int start = this.getDay(time);
  1312     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1313     final String dayString = Integer.toString(start, 10);
  1314     return db.query("named_events", COLUMNS_DATE_ENVSTR_M_MV_F_VALUE,
  1315                     "date >= ?", new String[] {dayString}, null, null,
  1316                     "date, environment, measurement_name, measurement_version, field_name");
  1319   public int getEventCount() {
  1320     return getRowCount("events");
  1323   public int getEnvironmentCount() {
  1324     return getRowCount("environments");
  1327   private int getRowCount(String table) {
  1328     // table should be parameterized, but SQL throws a compilation error if the table in unknown
  1329     // in advance.
  1330     return getIntFromQuery("SELECT COUNT(*) from " + table, null);
  1333   /**
  1334    * Deletes all environments, addons, and events from the database before the given time. If this
  1335    * data does not have recorded dates but are no longer referenced by other fields, they are also
  1336    * removed (with exception to the current environment).
  1338    * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
  1339    * @param curEnv The ID of the current environment.
  1340    * @return The number of environments and addon entries deleted.
  1341    */
  1342   public int deleteDataBefore(final long time, final int curEnv) {
  1343     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1344     db.beginTransaction();
  1345     int numRowsDeleted = 0;
  1346     try {
  1347       numRowsDeleted += deleteEnvAndEventsBefore(db, time, curEnv);
  1348       numRowsDeleted += deleteOrphanedAddons(db);
  1349       db.setTransactionSuccessful();
  1350     } finally {
  1351       db.endTransaction();
  1353     return numRowsDeleted;
  1356   /**
  1357    * Deletes environments and their referring events recorded before the given time. Environments
  1358    * referenced by no events are deleted, except for the current environment.
  1360    * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
  1361    * @param curEnv The ID of the current environment.
  1362    * @return The number of environments (not events) deleted.
  1363    */
  1364   protected int deleteEnvAndEventsBefore(final long time, final int curEnv) {
  1365     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1366     return deleteEnvAndEventsBefore(db, time, curEnv);
  1369   // Called internally only to ensure the same db instance is used.
  1370   protected int deleteEnvAndEventsBefore(final SQLiteDatabase db, final long time, final int curEnv) {
  1371     // Delete environments only referenced by events occuring before the given time. Cascade
  1372     // delete these events.
  1373     String whereClause =
  1374         "(SELECT COUNT(*) FROM events WHERE date >= ? " +
  1375         "    AND events.env = environments.id) = 0 " +
  1376         "AND id IN (SELECT DISTINCT env FROM events WHERE date < ?)";
  1377     final int day = this.getDay(time);
  1378     final String dayString = Integer.toString(day, 10);
  1379     String[] whereArgs = new String[] {dayString, dayString};
  1381     int numEnvDeleted = 0;
  1382     db.beginTransaction();
  1383     try {
  1384       numEnvDeleted += db.delete("environments", whereClause, whereArgs);
  1385       numEnvDeleted += deleteOrphanedEnv(db, curEnv);
  1386       // We can't get the number of events deleted through cascading deletions so we do not record
  1387       // the number of events deleted here.
  1388       deleteEventsBefore(db, dayString);
  1389       db.setTransactionSuccessful();
  1390     } finally {
  1391       db.endTransaction();
  1393     return numEnvDeleted;
  1396   /**
  1397    * Deletes environments not referenced by any events except for the given current environment.
  1398    */
  1399   protected int deleteOrphanedEnv(final int curEnv) {
  1400     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1401     return deleteOrphanedEnv(db, curEnv);
  1404   // Called internally only to ensure the same db instance is used.
  1405   @SuppressWarnings("static-method")
  1406   protected int deleteOrphanedEnv(final SQLiteDatabase db, final int curEnv) {
  1407     final String whereClause =
  1408         "id != ? AND " +
  1409         "id NOT IN (SELECT env FROM events)";
  1410     final String[] whereArgs = new String[] {Integer.toString(curEnv)};
  1411     return db.delete("environments", whereClause, whereArgs);
  1414   protected int deleteEventsBefore(final String dayString) {
  1415     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1416     return deleteEventsBefore(db, dayString);
  1419   // Called internally only to ensure the same db instance is used.
  1420   @SuppressWarnings("static-method")
  1421   protected int deleteEventsBefore(final SQLiteDatabase db, final String dayString) {
  1422     final String whereClause = "date < ?";
  1423     final String[] whereArgs = new String[] {dayString};
  1424     int numEventsDeleted = 0;
  1425     db.beginTransaction();
  1426     try {
  1427       numEventsDeleted += db.delete("events_integer", whereClause, whereArgs);
  1428       numEventsDeleted += db.delete("events_textual", whereClause, whereArgs);
  1429       db.setTransactionSuccessful();
  1430     } finally {
  1431       db.endTransaction();
  1433     return numEventsDeleted;
  1436   /**
  1437    * Deletes addons not referenced by any environments.
  1438    */
  1439   protected int deleteOrphanedAddons() {
  1440     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1441     return deleteOrphanedAddons(db);
  1444   // Called internally only to ensure the same db instance is used.
  1445   @SuppressWarnings("static-method")
  1446   protected int deleteOrphanedAddons(final SQLiteDatabase db) {
  1447     final String whereClause = "id NOT IN (SELECT addonsID FROM environments)";
  1448     return db.delete("addons", whereClause, null);
  1451   /**
  1452    * Retrieve a mapping from a table. Keys should be unique; only one key-value
  1453    * pair will be returned for each key.
  1454    */
  1455   private static HashMap<String, Integer> getIntegers(SQLiteDatabase db, String table, String columnA, String columnB) {
  1456     Cursor c = db.query(table, new String[] {columnA, columnB}, null, null, null, null, null);
  1457     try {
  1458       if (!c.moveToFirst()) {
  1459         return null;
  1462       HashMap<String, Integer> results = new HashMap<String, Integer>();
  1463       while (!c.isAfterLast()) {
  1464         results.put(c.getString(0), c.getInt(1));
  1465         c.moveToNext();
  1467       return results;
  1468     } finally {
  1469       c.close();
  1473   /**
  1474    * Retrieve a single value from a mapping table.
  1475    */
  1476   private int integerQuery(String table, String column, String where, String[] args, int defaultValue) {
  1477     final SQLiteDatabase db = this.helper.getReadableDatabase();
  1478     Cursor c = db.query(table, new String[] {column}, where, args, null, null, column + " DESC", "1");
  1479     try {
  1480       if (!c.moveToFirst()) {
  1481         return defaultValue;
  1483       return c.getInt(0);
  1484     } finally {
  1485       c.close();
  1489   /**
  1490    * Helper to allow us to avoid excessive code duplication.
  1492    * @param v
  1493    *          the destination <code>ContentValues</code>.
  1494    * @param value
  1495    *          either a <code>String</code> or an <code>Integer</code>. No type
  1496    *          checking is performed.
  1497    */
  1498   private static final void putValue(final ContentValues v, Object value) {
  1499     if (value instanceof String) {
  1500       v.put("value", (String) value);
  1501     } else {
  1502       v.put("value", (Integer) value);
  1506   @Override
  1507   public void deleteEverything() {
  1508     this.helper.deleteEverything();
  1511   @Override
  1512   public void deleteEnvironments() {
  1513     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1514     db.beginTransaction();
  1515     try {
  1516       // Cascade will clear the rest.
  1517       db.delete("environments", null, null);
  1518       db.setTransactionSuccessful();
  1519     } finally {
  1520       db.endTransaction();
  1524   @Override
  1525   public void deleteMeasurements() {
  1526     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1527     db.beginTransaction();
  1528     try {
  1529       // Cascade will clear the rest.
  1530       db.delete("measurements", null, null);
  1532       // Clear measurements and fields cache, because some of their IDs are now invalid.
  1533       invalidateFieldsCache(); // Let it repopulate on its own.
  1534       populateMeasurementVersionsCache(db); // Performed at Storage init so repopulate now.
  1536       db.setTransactionSuccessful();
  1537     } finally {
  1538       db.endTransaction();
  1542   /**
  1543    * Prunes the given number of least-recently used environments. Note that orphaned environments
  1544    * are not removed and the environment cache is cleared.
  1545    */
  1546   public void pruneEnvironments(final int numToPrune) {
  1547     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1548     db.beginTransaction();
  1549     try {
  1550       db.delete("environments",
  1551           "id in (SELECT env " +
  1552           "       FROM events " +
  1553           "       GROUP BY env " +
  1554           "       ORDER BY MAX(date), env " +
  1555           "       LIMIT " + numToPrune + ")",
  1556           null);
  1557       db.setTransactionSuccessful();
  1559       // Clear environment cache, because some of their IDs are now invalid.
  1560       this.envs.clear();
  1561     } finally {
  1562       db.endTransaction();
  1566   /**
  1567    * Prunes up to a maximum of the given number of the oldest events. While it is more correct to
  1568    * prune the exact given amount, there is no unique identifier among events so we cannot be so
  1569    * precise. Instead, we prune on date, deleting all events up to the day before our count of
  1570    * events reaches the given maximum. Note that this technicality means this method cannot be
  1571    * used to delete all events.
  1572    */
  1573   public void pruneEvents(final int maxNumToPrune) {
  1574     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1576     final Cursor c = db.rawQuery(
  1577         "SELECT MAX(date) " +
  1578         "FROM (SELECT date " +
  1579         "      FROM events " +
  1580         "      ORDER BY date " +
  1581         "      LIMIT " + maxNumToPrune + ")",
  1582         null);
  1583     long pruneDate = -1;
  1584     try {
  1585       if (!c.moveToFirst()) {
  1586         Logger.debug(LOG_TAG, "No max date found in events: table is likely empty. Not pruning " +
  1587             "events.");
  1588         return;
  1590       pruneDate = c.getLong(0);
  1591     } finally {
  1592       c.close();
  1595     final String selection = "date < " + pruneDate;
  1596     db.beginTransaction();
  1597     try {
  1598       db.delete(EVENTS_INTEGER, selection, null);
  1599       db.delete(EVENTS_TEXTUAL, selection, null);
  1600       db.setTransactionSuccessful();
  1601     } finally {
  1602       db.endTransaction();
  1606   public void vacuum() {
  1607     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1608     db.execSQL("vacuum");
  1611   /**
  1612    * Disables auto_vacuuming. Changes may only take effect after a "vacuum" command.
  1613    */
  1614   public void disableAutoVacuuming() {
  1615     final SQLiteDatabase db = this.helper.getWritableDatabase();
  1616     db.execSQL("PRAGMA auto_vacuum=0");

mercurial