Bug 858742 - Part 1: Firefox Health Report storage for Android. r=nalexander, a=lsblakk
authorRichard Newman <rnewman@mozilla.com>
Wed, 22 May 2013 10:23:29 -0700
changeset 142912 6ec42b44b114803dc12de5f4bdd727219a6d4b7e
parent 142911 27bcde94e2eeeac709ce50a86df550cf38afa9db
child 142913 b4716fe1ccbe83300ddcb78e7c1368c287126062
push id2579
push userakeybl@mozilla.com
push dateMon, 24 Jun 2013 18:52:47 +0000
treeherdermozilla-beta@b69b7de8a05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, lsblakk
bugs858742
milestone23.0a2
Bug 858742 - Part 1: Firefox Health Report storage for Android. r=nalexander, a=lsblakk
mobile/android/base/android-services-files.mk
mobile/android/base/background/healthreport/Environment.java
mobile/android/base/background/healthreport/HealthReportConstants.java.in
mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
mobile/android/base/background/healthreport/HealthReportDatabases.java
mobile/android/base/background/healthreport/HealthReportGenerator.java
mobile/android/base/background/healthreport/HealthReportProvider.java
mobile/android/base/background/healthreport/HealthReportStorage.java
mobile/android/base/background/healthreport/HealthReportUtils.java
mobile/android/services/java-sources.mn
mobile/android/services/manifests/HealthReportAndroidManifest_activities.xml.in
mobile/android/services/preprocess-sources.mn
--- a/mobile/android/base/android-services-files.mk
+++ b/mobile/android/base/android-services-files.mk
@@ -2,16 +2,17 @@
 # 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/.
 
 # These files are managed in the android-sync repo. Do not modify directly, or your changes will be lost.
 SYNC_PP_JAVA_FILES := \
   background/common/GlobalConstants.java \
   sync/SyncConstants.java \
   background/announcements/AnnouncementsConstants.java \
+  background/healthreport/HealthReportConstants.java \
   $(NULL)
 
 SYNC_JAVA_FILES := \
   background/announcements/Announcement.java \
   background/announcements/AnnouncementPresenter.java \
   background/announcements/AnnouncementsBroadcastReceiver.java \
   background/announcements/AnnouncementsBroadcastService.java \
   background/announcements/AnnouncementsFetchDelegate.java \
@@ -31,16 +32,23 @@ SYNC_JAVA_FILES := \
   background/common/log/writers/LogWriter.java \
   background/common/log/writers/PrintLogWriter.java \
   background/common/log/writers/SimpleTagLogWriter.java \
   background/common/log/writers/StringLogWriter.java \
   background/common/log/writers/TagLogWriter.java \
   background/common/log/writers/ThreadLocalTagLogWriter.java \
   background/db/CursorDumper.java \
   background/db/Tab.java \
+  background/healthreport/Environment.java \
+  background/healthreport/HealthReportDatabases.java \
+  background/healthreport/HealthReportDatabaseStorage.java \
+  background/healthreport/HealthReportGenerator.java \
+  background/healthreport/HealthReportProvider.java \
+  background/healthreport/HealthReportStorage.java \
+  background/healthreport/HealthReportUtils.java \
   sync/AlreadySyncingException.java \
   sync/CollectionKeys.java \
   sync/CommandProcessor.java \
   sync/CommandRunner.java \
   sync/config/AccountPickler.java \
   sync/config/activities/SelectEnginesActivity.java \
   sync/config/ClientRecordTerminator.java \
   sync/config/ConfigurationMigrator.java \
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/Environment.java
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import java.util.ArrayList;
+
+/**
+ * This captures all of the details that define an 'environment' for FHR's purposes.
+ * Whenever this format changes, it'll be changing with a build ID, so no migration
+ * of values is needed.
+ *
+ * Unless you remove the build descriptors from the set, of course.
+ *
+ * Or store these in a database.
+ *
+ * Instances of this class should be considered "effectively immutable": control their
+ * scope such that clear creation/sharing boundaries exist. Once you've populated and
+ * registered an <code>Environment</code>, don't do so again; start from scratch.
+ *
+ */
+public abstract class Environment {
+  public static int VERSION = 1;
+
+  protected volatile String hash = null;
+  protected volatile int id = -1;
+
+  // org.mozilla.profile.age.
+  public int profileCreation;
+
+  // org.mozilla.sysinfo.sysinfo.
+  public int cpuCount;
+  public int memoryMB;
+  public String architecture;
+  public String sysName;
+  public String sysVersion;      // Kernel.
+
+  // geckoAppInfo. Not sure if we can/should provide this on Android.
+  public String vendor;
+  public String appName;
+  public String appID;
+  public String appVersion;
+  public String appBuildID;
+  public String platformVersion;
+  public String platformBuildID;
+  public String os;
+  public String xpcomabi;
+  public String updateChannel;
+
+  // appInfo.
+  public int isBlocklistEnabled;
+  public int isTelemetryEnabled;
+  // public int isDefaultBrowser;        // This is meaningless on Android.
+
+  // org.mozilla.addons.active.
+  public final ArrayList<String> addons = new ArrayList<String>();
+
+  // org.mozilla.addons.counts.
+  public int extensionCount;
+  public int pluginCount;
+  public int themeCount;
+
+  public String getHash() {
+    // It's never unset, so we only care about partial reads. volatile is enough.
+    if (hash != null) {
+      return hash;
+    }
+
+    StringBuilder b = new StringBuilder();
+    b.append(profileCreation);
+    b.append(cpuCount);
+    b.append(memoryMB);
+    b.append(architecture);
+    b.append(sysName);
+    b.append(sysVersion);
+    b.append(vendor);
+    b.append(appName);
+    b.append(appID);
+    b.append(appVersion);
+    b.append(appBuildID);
+    b.append(platformVersion);
+    b.append(platformBuildID);
+    b.append(os);
+    b.append(xpcomabi);
+    b.append(updateChannel);
+    b.append(isBlocklistEnabled);
+    b.append(isTelemetryEnabled);
+    b.append(extensionCount);
+    b.append(pluginCount);
+    b.append(themeCount);
+
+    for (String addon : addons) {
+      b.append(addon);
+    }
+
+    return hash = HealthReportUtils.getEnvironmentHash(b.toString());
+  }
+
+  /**
+   * Ensure that the {@link Environment} has been registered with its
+   * storage layer, and can be used to annotate events.
+   *
+   * It's safe to call this method more than once, and each time you'll
+   * get the same ID.
+   *
+   * @return the integer ID to use in subsequent DB insertions.
+   */
+  public abstract int register();
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportConstants.java.in
@@ -0,0 +1,18 @@
+#filter substitution
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+public class HealthReportConstants {
+  public static final String HEALTH_AUTHORITY = "@ANDROID_PACKAGE_NAME@.health";
+  public static final String GLOBAL_LOG_TAG = "GeckoHealth";
+  public static final int MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+  /**
+   * The earliest allowable value for the last ping time, corresponding to May 2nd 2013.
+   * Used for sanity checks.
+   */
+  public static final long EARLIEST_LAST_PING = 1367500000000L;
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
@@ -0,0 +1,1094 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.os.Build;
+import android.util.SparseArray;
+
+/**
+ * <code>HealthReportDatabaseStorage</code> 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:
+ *
+ * <pre>
+ *  final Environment environment = storage.getEnvironment();
+ *  // Init the environment now.
+ *  String envHash = environment.getHash();
+ *  int env = environment.register();
+ * </pre>
+ *
+ * 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:
+ *
+ * <pre>
+ *   public static class FooFieldsV1 implements MeasurementFields {
+ *     {@literal @}Override
+ *     public Iterable<String> getFields() {
+ *       ArrayList<String> fields = new ArrayList<String>();
+ *       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();
+ *   }
+ * </pre>
+ *
+ * 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:
+ *
+ * <pre>
+ *   Field field = storage.getField("org.mozilla.fooProvider.fooMeasurement", 1, "bar");
+ *   int fieldID = field.getID();
+ * </pre>
+ *
+ * This lookup is cached, and so is relatively inexpensive.
+ *
+ * You can then do something like the following:
+ *
+ * <pre>
+ *   storage.recordDailyLast(storage.getDay(), env, fieldID, "last value");
+ * </pre>
+ *
+ * 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:
+ *
+ * <pre>
+ *   storage.enqueueOperation(runnable);
+ * </pre>
+ */
+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", "hash",
+      "profileCreation", "cpuCount", "memoryMB",
+
+      "isBlocklistEnabled", "isTelemetryEnabled", "extensionCount",
+      "pluginCount", "themeCount",
+
+      "architecture", "sysName", "sysVersion", "vendor", "appName", "appID",
+      "appVersion", "appBuildID", "platformVersion", "platformBuildID", "os",
+      "xpcomabi", "updateChannel"
+  };
+
+  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[] ENVIRONMENT_RECORD_COLUMNS = null;
+
+  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 = 2;
+    public static final String LOG_TAG = "HealthReportSQL";
+
+    /**
+     * A little helper to avoid SQLiteOpenHelper misbehaving on Android 2.1.
+     * Partly cribbed from
+     * <http://stackoverflow.com/questions/5332328/sqliteopenhelper-problem-with-fully-qualified-db-path-name>.
+     */
+    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.info(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath());
+        return SQLiteDatabase.openOrCreateDatabase(path, null);
+      }
+    }
+
+    public static String getAbsolutePath(File parent, String name) {
+      return parent.getAbsolutePath() + File.separator + name;
+    }
+
+    public static boolean CAN_USE_ABSOLUTE_DB_PATH = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO);
+    public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name) {
+      super(
+          (CAN_USE_ABSOLUTE_DB_PATH ? context : new AbsolutePathContext(context, profileDirectory)),
+          (CAN_USE_ABSOLUTE_DB_PATH ? getAbsolutePath(profileDirectory, name) : name),
+          null,
+          CURRENT_VERSION);
+
+      if (CAN_USE_ABSOLUTE_DB_PATH) {
+        Logger.info(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name));
+      }
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+      db.beginTransaction();
+      try {
+        db.execSQL("CREATE TABLE environments (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                   "                           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, " +
+                   "                           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");
+
+        db.setTransactionSuccessful();
+      } finally {
+        db.endTransaction();
+      }
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+      Logger.info(LOG_TAG, "onUpgrade: from " + oldVersion + " to " + newVersion + ".");
+    }
+
+    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.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},
+                                    -1);
+        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.
+  private final ConcurrentHashMap<String, Integer> envs = new ConcurrentHashMap<String, Integer>();
+
+  /**
+   * 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)) {
+        return storage.envs.get(h);
+      }
+
+      // Otherwise, add data and hash to the DB.
+      ContentValues v = new ContentValues();
+      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);
+
+      final SQLiteDatabase db = storage.helper.getWritableDatabase();
+
+      // Insert, with a little error handling to populate the cache in case of
+      // omission and consequent collision.
+      //
+      // insertWithOnConflict doesn't work as documented: <http://stackoverflow.com/questions/11328877/android-sqllite-on-conflict-ignore-is-ignored-in-ics/11424150>.
+      // 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.
+      try {
+        this.id = (int) db.insertOrThrow("environments", null, v);
+        storage.envs.put(h, this.id);
+        return this.id;
+      } 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[] {hash}, null, null, null);
+        try {
+          if (!c.moveToFirst()) {
+            throw e;
+          }
+          this.id = (int) c.getLong(0);
+          storage.envs.put(hash, this.id);
+          return this.id;
+        } finally {
+          c.close();
+        }
+      }
+    }
+
+    public void init(ContentValues v) {
+      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");
+
+      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.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++);
+
+      return cursor.moveToNext();
+    }
+
+    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<Environment> getEnvironmentRecordsByID() {
+    final SQLiteDatabase db = this.helper.getReadableDatabase();
+    Cursor c = db.query("environments", COLUMNS_ENVIRONMENT_DETAILS, null, null, null, null, null);
+    try {
+      SparseArray<Environment> results = new SparseArray<Environment>();
+      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", ENVIRONMENT_RECORD_COLUMNS, "id = " + id, null, null, null, null);
+  }
+
+  @Override
+  public SparseArray<String> getEnvironmentHashesByID() {
+    final SQLiteDatabase db = this.helper.getReadableDatabase();
+    Cursor c = db.query("environments", new String[] {"id", "hash"}, null, null, null, null, null);
+    try {
+      SparseArray<String> results = new SparseArray<String>();
+      if (!c.moveToFirst()) {
+        return results;
+      }
+
+      while (!c.isAfterLast()) {
+        results.put(c.getInt(0), c.getString(1));
+      }
+      return results;
+    } finally {
+      c.close();
+    }
+  }
+
+  /**
+   * Cache the lookup from measurement and field specifier to field instance.
+   * This allows us to memoize the field ID lookup, too.
+   */
+  private HashMap<String, Field> fields = new HashMap<String, Field>();
+  private boolean 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<Field> getFieldsByID() {
+    final SparseArray<Field> out = new SparseArray<Field>();
+    synchronized (fields) {
+      populateFieldCache();
+      Collection<Field> 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<String, Integer> measurementVersions = new HashMap<String, Integer>();
+
+  private void populateMeasurementVersionsCache(SQLiteDatabase db) {
+    HashMap<String, Integer> 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.info(LOG_TAG, "M: " + measurementID + " F: " + field.name + " (" + field.type + ")");
+      db.insert("fields", null, v);
+    }
+
+    notifyMeasurementVersionUpdated(measurement, version);
+
+    // Let's be easy for now.
+    synchronized (fields) {
+      fieldsCacheUpdated = false;
+    }
+  }
+
+  /**
+   * 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();
+  }
+
+  @Override
+  public int getDay(long time) {
+    return HealthReportUtils.getDay(time);
+  }
+
+  @Override
+  public int getDay() {
+    return this.getDay(System.currentTimeMillis());
+  }
+
+  private void recordDailyLast(int env, int day, int field, Object value, String table) {
+    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);
+      db.insert(table, null, v);
+    }
+  }
+
+  @Override
+  public void recordDailyLast(int env, int day, int field, String value) {
+    this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL);
+  }
+
+  @Override
+  public void recordDailyLast(int env, int day, int field, int value) {
+    this.recordDailyLast(env, day, field, Integer.valueOf(value), EVENTS_INTEGER);
+  }
+
+  private void recordDailyDiscrete(int env, int day, int field, Object value, String table) {
+    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);
+    db.insert(table, null, v);
+  }
+
+  @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) {
+    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);
+      db.insert(EVENTS_INTEGER, null, v);
+    }
+  }
+
+  @Override
+  public void incrementDailyCount(int env, int day, int field) {
+    this.incrementDailyCount(env, day, field, 1);
+  }
+
+  /**
+   * 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");
+  }
+
+  /**
+   * Retrieve a mapping from a table. Keys should be unique; only one key-value
+   * pair will be returned for each key.
+   */
+  private static HashMap<String, Integer> 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<String, Integer> results = new HashMap<String, Integer>();
+      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 <code>ContentValues</code>.
+   * @param value
+   *          either a <code>String</code> or an <code>Integer</code>. 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);
+      db.setTransactionSuccessful();
+    } finally {
+      db.endTransaction();
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportDatabases.java
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import java.io.File;
+import java.util.HashMap;
+
+import org.mozilla.gecko.background.common.log.Logger;
+
+import android.content.Context;
+
+/**
+ * Manages a set of per-profile Health Report storage helpers.
+ */
+public class HealthReportDatabases {
+  private static final String LOG_TAG = "HealthReportDatabases";
+
+  private Context context;
+  private final HashMap<File, HealthReportDatabaseStorage> storages = new HashMap<File, HealthReportDatabaseStorage>();
+
+
+  public HealthReportDatabases(final Context context) {
+    this.context = context;
+  }
+
+  public synchronized HealthReportDatabaseStorage getDatabaseHelperForProfile(final File profileDir) {
+    if (profileDir == null) {
+      throw new IllegalArgumentException("No profile provided.");
+    }
+
+    if (this.storages.containsKey(profileDir)) {
+      return this.storages.get(profileDir);
+    }
+
+    final HealthReportDatabaseStorage helper;
+    helper = new HealthReportDatabaseStorage(this.context, profileDir);
+    this.storages.put(profileDir, helper);
+    return helper;
+  }
+
+  public synchronized void closeDatabaseHelpers() {
+    for (HealthReportDatabaseStorage helper : storages.values()) {
+      try {
+        helper.close();
+      } catch (Exception e) {
+        Logger.warn(LOG_TAG, "Failed to close database helper.", e);
+      }
+    }
+    storages.clear();
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportGenerator.java
@@ -0,0 +1,354 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
+
+import android.database.Cursor;
+import android.util.SparseArray;
+
+public class HealthReportGenerator {
+  private static final int PAYLOAD_VERSION = 3;
+
+  private final HealthReportStorage storage;
+
+  public HealthReportGenerator(HealthReportStorage storage) {
+    this.storage = storage;
+  }
+
+  @SuppressWarnings("static-method")
+  protected long now() {
+    return System.currentTimeMillis();
+  }
+
+  /**
+   * The document consists of:
+   *
+   * * Basic metadata: last ping time, current ping time, version.
+   * * A map of environments: 'current' and others named by hash. 'current' is fully specified,
+   *   and others are deltas from current.
+   * * A 'data' object. This includes 'last' and 'days'.
+   *   'days' is a map from date strings to {hash: {measurement: {_v: version, fields...}}}.
+   */
+  @SuppressWarnings("unchecked")
+  public JSONObject generateDocument(long since, long lastPingTime, Environment currentEnvironment) {
+    // We want to map field IDs to some strings as we go.
+    SparseArray<Environment> envs = storage.getEnvironmentRecordsByID();
+
+    JSONObject document = new JSONObject();
+    if (lastPingTime >= HealthReportConstants.EARLIEST_LAST_PING) {
+      document.put("lastPingDate", HealthReportUtils.getDateString(lastPingTime));
+    }
+
+    document.put("thisPingDate", HealthReportUtils.getDateString(now()));
+    document.put("version", PAYLOAD_VERSION);
+
+    document.put("environments", getEnvironmentsJSON(currentEnvironment, envs));
+    document.put("data", getDataJSON(currentEnvironment, envs, since));
+
+    return document;
+  }
+
+  @SuppressWarnings("unchecked")
+  protected JSONObject getDataJSON(Environment currentEnvironment,
+                                   SparseArray<Environment> envs, long since) {
+    SparseArray<Field> fields = storage.getFieldsByID();
+
+    JSONObject days = getDaysJSON(currentEnvironment, envs, fields, since);
+
+    JSONObject last = new JSONObject();
+
+    JSONObject data = new JSONObject();
+    data.put("days", days);
+    data.put("last", last);
+    return data;
+  }
+
+  @SuppressWarnings("unchecked")
+  protected JSONObject getDaysJSON(Environment currentEnvironment, SparseArray<Environment> envs, SparseArray<Field> fields, long since) {
+    JSONObject days = new JSONObject();
+    Cursor cursor = storage.getRawEventsSince(since);
+    try {
+      if (!cursor.moveToNext()) {
+        return days;
+      }
+
+      // A classic walking partition.
+      // Columns are "date", "env", "field", "value".
+      // Note that we care about the type (integer, string) and kind
+      // (last/counter, discrete) of each field.
+      // Each field will be accessed once for each date/env pair, so
+      // Field memoizes these facts.
+      // We also care about which measurement contains each field.
+      int lastDate  = -1;
+      int lastEnv   = -1;
+      JSONObject dateObject = null;
+      JSONObject envObject = null;
+
+      while (!cursor.isAfterLast()) {
+        int cDate  = cursor.getInt(0);
+        int cEnv   = cursor.getInt(1);
+        int cField = cursor.getInt(2);
+
+        boolean dateChanged = cDate != lastDate;
+        boolean envChanged = cEnv != lastEnv;
+
+        if (dateChanged) {
+          if (dateObject != null) {
+            days.put(HealthReportUtils.getDateStringForDay(lastDate), dateObject);
+          }
+          dateObject = new JSONObject();
+          lastDate = cDate;
+        }
+
+        if (dateChanged || envChanged) {
+          envObject = new JSONObject();
+          dateObject.put(envs.get(cEnv).hash, envObject);
+          lastEnv = cEnv;
+        }
+
+        final Field field = fields.get(cField);
+        JSONObject measurement = (JSONObject) envObject.get(field.measurementName);
+        if (measurement == null) {
+          // We will never have more than one measurement version within a
+          // single environment -- to do so involves changing the build ID. And
+          // even if we did, we have no way to represent it. So just build the
+          // output object once.
+          measurement = new JSONObject();
+          measurement.put("_v", field.measurementVersion);
+          envObject.put(field.measurementName, measurement);
+        }
+        if (field.isDiscreteField()) {
+          JSONArray discrete = (JSONArray) measurement.get(field.fieldName);
+          if (discrete == null) {
+            discrete = new JSONArray();
+            measurement.put(field.fieldName, discrete);
+          }
+          if (field.isStringField()) {
+            discrete.add(cursor.getString(3));
+          } else if (field.isIntegerField()) {
+            discrete.add(cursor.getLong(3));
+          } else {
+            // Uh oh!
+          }
+        } else {
+          if (field.isStringField()) {
+            measurement.put(field.fieldName, cursor.getString(3));
+          } else {
+            measurement.put(field.fieldName, cursor.getLong(3));
+          }
+        }
+
+        cursor.moveToNext();
+        continue;
+      }
+      days.put(HealthReportUtils.getDateStringForDay(lastDate), dateObject);
+    } finally {
+      cursor.close();
+    }
+    return days;
+  }
+
+  @SuppressWarnings("unchecked")
+  protected JSONObject getEnvironmentsJSON(Environment currentEnvironment,
+                                           SparseArray<Environment> envs) {
+    JSONObject environments = new JSONObject();
+
+    // Always do this, even if it hasn't recorded anything in the DB.
+    environments.put("current", jsonify(currentEnvironment, null));
+
+    String currentHash = currentEnvironment.getHash();
+    for (int i = 0; i < envs.size(); i++) {
+      Environment e = envs.valueAt(i);
+      if (currentHash.equals(e.getHash())) {
+        continue;
+      }
+      environments.put(e.getHash(), jsonify(e, currentEnvironment));
+    }
+    return environments;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject jsonify(Environment e, Environment current) {
+    JSONObject age = getProfileAge(e, current);
+    JSONObject sysinfo = getSysInfo(e, current);
+    JSONObject gecko = getGeckoInfo(e, current);
+    JSONObject appinfo = getAppInfo(e, current);
+    JSONObject counts = getAddonCounts(e, current);
+
+    JSONObject out = new JSONObject();
+    if (age != null)
+      out.put("org.mozilla.profile.age", age);
+    if (sysinfo != null)
+      out.put("org.mozilla.sysinfo.sysinfo", sysinfo);
+    if (gecko != null)
+      out.put("geckoAppInfo", gecko);
+    if (appinfo != null)
+      out.put("org.mozilla.appInfo.appinfo", appinfo);
+    if (counts != null)
+      out.put("org.mozilla.addons.counts", counts);
+
+    JSONObject active = getActiveAddons(e, current);
+    if (active != null)
+      out.put("org.mozilla.addons.active", active);
+
+    if (current == null) {
+      out.put("hash", e.getHash());
+    }
+    return out;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject getProfileAge(Environment e, Environment current) {
+    JSONObject age = new JSONObject();
+    int changes = 0;
+    if (current == null || current.profileCreation != e.profileCreation) {
+      age.put("profileCreation", e.profileCreation);
+      changes++;
+    }
+    if (current != null && changes == 0) {
+      return null;
+    }
+    age.put("_v", 1);
+    return age;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject getSysInfo(Environment e, Environment current) {
+    JSONObject sysinfo = new JSONObject();
+    int changes = 0;
+    if (current == null || current.cpuCount != e.cpuCount) {
+      sysinfo.put("cpuCount", e.cpuCount);
+      changes++;
+    }
+    if (current == null || current.memoryMB != e.memoryMB) {
+      sysinfo.put("memoryMB", e.memoryMB);
+      changes++;
+    }
+    if (current == null || !current.architecture.equals(e.architecture)) {
+      sysinfo.put("architecture", e.architecture);
+      changes++;
+    }
+    if (current == null || !current.sysName.equals(e.sysName)) {
+      sysinfo.put("name", e.sysName);
+      changes++;
+    }
+    if (current == null || !current.sysVersion.equals(e.sysVersion)) {
+      sysinfo.put("version", e.sysVersion);
+      changes++;
+    }
+    if (current != null && changes == 0) {
+      return null;
+    }
+    sysinfo.put("_v", 1);
+    return sysinfo;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject getGeckoInfo(Environment e, Environment current) {
+    JSONObject gecko = new JSONObject();
+    int changes = 0;
+    if (current == null || !current.vendor.equals(e.vendor)) {
+      gecko.put("vendor", e.vendor);
+      changes++;
+    }
+    if (current == null || !current.appName.equals(e.appName)) {
+      gecko.put("name", e.appName);
+      changes++;
+    }
+    if (current == null || !current.appID.equals(e.appID)) {
+      gecko.put("id", e.appID);
+      changes++;
+    }
+    if (current == null || !current.appVersion.equals(e.appVersion)) {
+      gecko.put("version", e.appVersion);
+      changes++;
+    }
+    if (current == null || !current.appBuildID.equals(e.appBuildID)) {
+      gecko.put("appBuildID", e.appBuildID);
+      changes++;
+    }
+    if (current == null || !current.platformVersion.equals(e.platformVersion)) {
+      gecko.put("platformVersion", e.platformVersion);
+      changes++;
+    }
+    if (current == null || !current.platformBuildID.equals(e.platformBuildID)) {
+      gecko.put("platformBuildID", e.platformBuildID);
+      changes++;
+    }
+    if (current == null || !current.os.equals(e.os)) {
+      gecko.put("os", e.os);
+      changes++;
+    }
+    if (current == null || !current.xpcomabi.equals(e.xpcomabi)) {
+      gecko.put("xpcomabi", e.xpcomabi);
+      changes++;
+    }
+    if (current == null || !current.updateChannel.equals(e.updateChannel)) {
+      gecko.put("updateChannel", e.updateChannel);
+      changes++;
+    }
+    if (current != null && changes == 0) {
+      return null;
+    }
+    gecko.put("_v", 1);
+    return gecko;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject getAppInfo(Environment e, Environment current) {
+    JSONObject appinfo = new JSONObject();
+    int changes = 0;
+    if (current == null || current.isBlocklistEnabled != e.isBlocklistEnabled) {
+      appinfo.put("isBlocklistEnabled", e.isBlocklistEnabled);
+      changes++;
+    }
+    if (current == null || current.isTelemetryEnabled != e.isTelemetryEnabled) {
+      appinfo.put("isTelemetryEnabled", e.isTelemetryEnabled);
+      changes++;
+    }
+    if (current != null && changes == 0) {
+      return null;
+    }
+    appinfo.put("_v", 2);
+    return appinfo;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject getAddonCounts(Environment e, Environment current) {
+    JSONObject counts = new JSONObject();
+    int changes = 0;
+    if (current == null || current.extensionCount != e.extensionCount) {
+      counts.put("extension", e.extensionCount);
+      changes++;
+    }
+    if (current == null || current.pluginCount != e.pluginCount) {
+      counts.put("plugin", e.pluginCount);
+      changes++;
+    }
+    if (current == null || current.themeCount != e.themeCount) {
+      counts.put("theme", e.themeCount);
+      changes++;
+    }
+    if (current != null && changes == 0) {
+      return null;
+    }
+    counts.put("_v", 1);
+    return counts;
+  }
+
+  @SuppressWarnings("unchecked")
+  private JSONObject getActiveAddons(Environment e, Environment current) {
+    JSONObject active = new JSONObject();
+    int changes = 0;
+    if (current != null && changes == 0) {
+      return null;
+    }
+    active.put("_v", 1);
+    return active;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportProvider.java
@@ -0,0 +1,298 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map.Entry;
+
+import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage.DatabaseEnvironment;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
+import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.net.Uri;
+
+/**
+ * This is a {@link ContentProvider} wrapper around a database-backed Health
+ * Report storage layer.
+ *
+ * It stores environments, fields, and measurements, and events which refer to
+ * each of these by integer ID.
+ *
+ * Insert = daily discrete.
+ * content://org.mozilla.gecko.health/events/env/measurement/v/field
+ *
+ * Update = daily last or daily counter
+ * content://org.mozilla.gecko.health/events/env/measurement/v/field/counter
+ * content://org.mozilla.gecko.health/events/env/measurement/v/field/last
+ *
+ * Delete = drop today's row
+ * content://org.mozilla.gecko.health/events/env/measurement/v/field/
+ *
+ * Query, of course: content://org.mozilla.gecko.health/events/?since
+ *
+ * Each operation accepts an optional `time` query parameter, formatted as
+ * milliseconds since epoch. If omitted, it defaults to the current time.
+ *
+ * Each operation also accepts mandatory `profilePath` and `env` arguments.
+ *
+ * TODO: document measurements.
+ */
+public class HealthReportProvider extends ContentProvider {
+  private HealthReportDatabases databases;
+  private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+  public static final String HEALTH_AUTHORITY = HealthReportConstants.HEALTH_AUTHORITY;
+
+  // URI matches.
+  private static final int ENVIRONMENTS_ROOT    = 10;
+  private static final int EVENTS_ROOT          = 11;
+  private static final int EVENTS_RAW_ROOT      = 12;
+  private static final int FIELDS_ROOT          = 13;
+  private static final int MEASUREMENTS_ROOT    = 14;
+
+  private static final int EVENTS_FIELD_GENERIC = 20;
+  private static final int EVENTS_FIELD_COUNTER = 21;
+  private static final int EVENTS_FIELD_LAST    = 22;
+
+  private static final int ENVIRONMENT_DETAILS  = 30;
+  private static final int FIELDS_MEASUREMENT   = 31;
+
+  static {
+    uriMatcher.addURI(HEALTH_AUTHORITY, "environments/", ENVIRONMENTS_ROOT);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "events/", EVENTS_ROOT);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "rawevents/", EVENTS_RAW_ROOT);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "fields/", FIELDS_ROOT);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "measurements/", MEASUREMENTS_ROOT);
+
+    uriMatcher.addURI(HEALTH_AUTHORITY, "events/#/*/#/*", EVENTS_FIELD_GENERIC);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "events/#/*/#/*/counter", EVENTS_FIELD_COUNTER);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "events/#/*/#/*/last", EVENTS_FIELD_LAST);
+
+    uriMatcher.addURI(HEALTH_AUTHORITY, "environments/#", ENVIRONMENT_DETAILS);
+    uriMatcher.addURI(HEALTH_AUTHORITY, "fields/*/#", FIELDS_MEASUREMENT);
+  }
+
+  /**
+   * So we can bypass the ContentProvider layer.
+   */
+  public HealthReportDatabaseStorage getProfileStorage(final String profilePath) {
+    if (profilePath == null) {
+      throw new IllegalArgumentException("profilePath must be provided.");
+    }
+    return databases.getDatabaseHelperForProfile(new File(profilePath));
+  }
+
+  private HealthReportDatabaseStorage getProfileStorageForUri(Uri uri) {
+    final String profilePath = uri.getQueryParameter("profilePath");
+    return getProfileStorage(profilePath);
+  }
+
+  @Override
+  public void onLowMemory() {
+    super.onLowMemory();
+    databases.closeDatabaseHelpers();
+  }
+
+  @Override
+  public String getType(Uri uri) {
+    return null;
+  }
+
+  @Override
+  public boolean onCreate() {
+    databases = new HealthReportDatabases(getContext());
+    return true;
+  }
+
+  @Override
+  public Uri insert(Uri uri, ContentValues values) {
+    int match = uriMatcher.match(uri);
+    HealthReportDatabaseStorage storage = getProfileStorageForUri(uri);
+    switch (match) {
+    case FIELDS_MEASUREMENT:
+      // The keys of this ContentValues are field names.
+      List<String> pathSegments = uri.getPathSegments();
+      String measurement = pathSegments.get(1);
+      int v = Integer.parseInt(pathSegments.get(2));
+      storage.ensureMeasurementInitialized(measurement, v, getFieldSpecs(values));
+      return uri;
+
+    case ENVIRONMENTS_ROOT:
+      DatabaseEnvironment environment = storage.getEnvironment();
+      environment.init(values);
+      return ContentUris.withAppendedId(uri, environment.register());
+
+    case EVENTS_FIELD_GENERIC:
+      long time = getTimeFromUri(uri);
+      int day = storage.getDay(time);
+      int env = getEnvironmentFromUri(uri);
+      Field field = getFieldFromUri(storage, uri);
+
+      if (!values.containsKey("value")) {
+        throw new IllegalArgumentException("Must provide ContentValues including 'value' key.");
+      }
+
+      Object object = values.get("value");
+      if (object instanceof Integer ||
+          object instanceof Long) {
+        storage.recordDailyDiscrete(env, day, field.getID(), ((Integer) object).intValue());
+      } else if (object instanceof String) {
+        storage.recordDailyDiscrete(env, day, field.getID(), (String) object);
+      } else {
+        storage.recordDailyDiscrete(env, day, field.getID(), object.toString());
+      }
+
+      // TODO: eventually we might want to return something more useful than
+      // the input URI.
+      return uri;
+    default:
+      throw new IllegalArgumentException("Unknown insert URI");
+    }
+  }
+
+  @Override
+  public int update(Uri uri, ContentValues values, String selection,
+                    String[] selectionArgs) {
+
+    int match = uriMatcher.match(uri);
+    if (match != EVENTS_FIELD_COUNTER &&
+        match != EVENTS_FIELD_LAST) {
+      throw new IllegalArgumentException("Must provide operation for update.");
+    }
+
+    HealthReportStorage storage = getProfileStorageForUri(uri);
+    long time = getTimeFromUri(uri);
+    int day = storage.getDay(time);
+    int env = getEnvironmentFromUri(uri);
+    Field field = getFieldFromUri(storage, uri);
+
+    switch (match) {
+    case EVENTS_FIELD_COUNTER:
+      int by = values.containsKey("value") ? values.getAsInteger("value") : 1;
+      storage.incrementDailyCount(env, day, field.getID(), by);
+      return 1;
+
+    case EVENTS_FIELD_LAST:
+      Object object = values.get("value");
+      if (object instanceof Integer ||
+          object instanceof Long) {
+        storage.recordDailyLast(env, day, field.getID(), ((Integer) object).intValue());
+      } else if (object instanceof String) {
+        storage.recordDailyLast(env, day, field.getID(), (String) object);
+      } else {
+        storage.recordDailyLast(env, day, field.getID(), object.toString());
+      }
+      return 1;
+
+    default:
+        // javac's flow control analysis sucks.
+        return 0;
+    }
+  }
+
+  @Override
+  public int delete(Uri uri, String selection, String[] selectionArgs) {
+    int match = uriMatcher.match(uri);
+    HealthReportStorage storage = getProfileStorageForUri(uri);
+    switch (match) {
+    case MEASUREMENTS_ROOT:
+      storage.deleteMeasurements();
+      return 1;
+    case ENVIRONMENTS_ROOT:
+      storage.deleteEnvironments();
+      return 1;
+    default:
+      throw new IllegalArgumentException();
+    }
+
+    // TODO: more
+  }
+
+  @Override
+  public Cursor query(Uri uri, String[] projection, String selection,
+                      String[] selectionArgs, String sortOrder) {
+    int match = uriMatcher.match(uri);
+
+    HealthReportStorage storage = getProfileStorageForUri(uri);
+    switch (match) {
+    case EVENTS_ROOT:
+      return storage.getEventsSince(getTimeFromUri(uri));
+    case EVENTS_RAW_ROOT:
+      return storage.getRawEventsSince(getTimeFromUri(uri));
+    case MEASUREMENTS_ROOT:
+      return storage.getMeasurementVersions();
+    case FIELDS_ROOT:
+      return storage.getFieldVersions();
+    }
+    List<String> pathSegments = uri.getPathSegments();
+    switch (match) {
+    case ENVIRONMENT_DETAILS:
+      return storage.getEnvironmentRecordForID(Integer.parseInt(pathSegments.get(1), 10));
+    case FIELDS_MEASUREMENT:
+      String measurement = pathSegments.get(1);
+      int v = Integer.parseInt(pathSegments.get(2));
+      return storage.getFieldVersions(measurement, v);
+    default:
+    return null;
+    }
+  }
+
+  private static long getTimeFromUri(final Uri uri) {
+    String t = uri.getQueryParameter("time");
+    if (t == null) {
+      return System.currentTimeMillis();
+    } else {
+      return Long.parseLong(t, 10);
+    }
+  }
+
+  private static int getEnvironmentFromUri(final Uri uri) {
+    return Integer.parseInt(uri.getPathSegments().get(1), 10);
+  }
+
+  /**
+   * Assumes a URI structured like:
+   *
+   * <code>content://org.mozilla.gecko.health/events/env/measurement/v/field</code>
+   *
+   * @param uri a URI formatted as expected.
+   * @return a {@link Field} instance.
+   */
+  private static Field getFieldFromUri(HealthReportStorage storage, final Uri uri) {
+    String measurement;
+    String field;
+    int measurementVersion;
+
+    List<String> pathSegments = uri.getPathSegments();
+    measurement = pathSegments.get(2);
+    measurementVersion = Integer.parseInt(pathSegments.get(3), 10);
+    field = pathSegments.get(4);
+
+    return storage.getField(measurement, measurementVersion, field);
+  }
+
+  private MeasurementFields getFieldSpecs(ContentValues values) {
+    final ArrayList<FieldSpec> specs = new ArrayList<FieldSpec>(values.size());
+    for (Entry<String, Object> entry : values.valueSet()) {
+      specs.add(new FieldSpec(entry.getKey(), ((Integer) entry.getValue()).intValue()));
+    }
+
+    return new MeasurementFields() {
+      @Override
+      public Iterable<FieldSpec> getFields() {
+        return specs;
+      }
+    };
+  }
+
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportStorage.java
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import android.database.Cursor;
+import android.util.SparseArray;
+
+/**
+ * Abstraction over storage for Firefox Health Report on Android.
+ */
+public interface HealthReportStorage {
+  // Right now we only care about the name of the field.
+  public interface MeasurementFields {
+    public class FieldSpec {
+      public final String name;
+      public final int type;
+      public FieldSpec(String name, int type) {
+        this.name = name;
+        this.type = type;
+      }
+    }
+    Iterable<FieldSpec> getFields();
+  }
+
+  public abstract class Field {
+    protected static final int UNKNOWN_TYPE_OR_FIELD_ID = -1;
+
+    protected static final int FLAG_INTEGER  = 1 << 0;
+    protected static final int FLAG_STRING   = 1 << 1;
+
+    protected static final int FLAG_DISCRETE = 1 << 8;
+    protected static final int FLAG_LAST     = 1 << 9;
+    protected static final int FLAG_COUNTER  = 1 << 10;
+
+    public static final int TYPE_INTEGER_DISCRETE = FLAG_INTEGER | FLAG_DISCRETE;
+    public static final int TYPE_INTEGER_LAST     = FLAG_INTEGER | FLAG_LAST;
+    public static final int TYPE_INTEGER_COUNTER  = FLAG_INTEGER | FLAG_COUNTER;
+
+    public static final int TYPE_STRING_DISCRETE  = FLAG_STRING | FLAG_DISCRETE;
+    public static final int TYPE_STRING_LAST      = FLAG_STRING | FLAG_LAST;
+
+    protected int fieldID = UNKNOWN_TYPE_OR_FIELD_ID;
+    protected int flags;
+
+    protected final String measurementName;
+    protected final String measurementVersion;
+    protected final String fieldName;
+
+    public Field(String mName, int mVersion, String fieldName, int type) {
+      this.measurementName = mName;
+      this.measurementVersion = Integer.toString(mVersion, 10);
+      this.fieldName = fieldName;
+      this.flags = type;
+    }
+
+    /**
+     * @return the ID for this <code>Field</code>
+     * @throws IllegalStateException if this field is not found in storage
+     */
+    public abstract int getID() throws IllegalStateException;
+
+    public boolean isIntegerField() {
+      return (this.flags & FLAG_INTEGER) > 0;
+    }
+
+    public boolean isStringField() {
+      return (this.flags & FLAG_STRING) > 0;
+    }
+
+    public boolean isDiscreteField() {
+      return (this.flags & FLAG_DISCRETE) > 0;
+    }
+  }
+
+  /**
+   * Close open storage handles and otherwise finish up.
+   */
+  public void close();
+
+  /**
+   * Return the day integer corresponding to the provided time.
+   *
+   * @param time
+   *          milliseconds since Unix epoch.
+   * @return an integer day.
+   */
+  public int getDay(long time);
+
+  /**
+   * Return the day integer corresponding to the current time.
+   *
+   * @return an integer day.
+   */
+  public int getDay();
+
+  /**
+   * Return a new {@link Environment}, suitable for being populated, hashed, and
+   * registered.
+   *
+   * @return a new {@link Environment} instance.
+   */
+  public Environment getEnvironment();
+
+  /**
+   * @return a mapping from environment IDs to hashes, suitable for use in
+   *         payload generation.
+   */
+  public SparseArray<String> getEnvironmentHashesByID();
+
+  /**
+   * @return a mapping from environment IDs to registered {@link Environment}
+   *         records, suitable for use in payload generation.
+   */
+  public SparseArray<Environment> getEnvironmentRecordsByID();
+
+  /**
+   * @param id
+   *          the environment ID, as returned by {@link Environment#register()}.
+   * @return a cursor for the record.
+   */
+  public Cursor getEnvironmentRecordForID(int id);
+
+  /**
+   * @param measurement
+   *          the name of a measurement, such as "org.mozilla.appInfo.appInfo".
+   * @param measurementVersion
+   *          the version of a measurement, such as '3'.
+   * @param fieldName
+   *          the name of a field, such as "platformVersion".
+   *
+   * @return a {@link Field} instance corresponding to the provided values.
+   */
+  public Field getField(String measurement, int measurementVersion,
+                        String fieldName);
+
+  /**
+   * @return a mapping from field IDs to {@link Field} instances, suitable for
+   *         use in payload generation.
+   */
+  public SparseArray<Field> getFieldsByID();
+
+  public void recordDailyLast(int env, int day, int field, String value);
+  public void recordDailyLast(int env, int day, int field, int value);
+  public void recordDailyDiscrete(int env, int day, int field, String value);
+  public void recordDailyDiscrete(int env, int day, int field, int value);
+  public void incrementDailyCount(int env, int day, int field, int by);
+  public void incrementDailyCount(int env, int day, int field);
+
+  /**
+   * Obtain a cursor over events that were recorded since <code>time</code>.
+   * This cursor exposes 'raw' events, with integer identifiers for values.
+   */
+  public Cursor getRawEventsSince(long time);
+
+  /**
+   * Obtain a cursor over events that were recorded since <code>time</code>.
+   *
+   * This cursor exposes 'friendly' events, with string names and full
+   * measurement metadata.
+   */
+  public Cursor getEventsSince(long time);
+
+  /**
+   * Ensure that a measurement and all of its fields are registered with the DB.
+   * No fields will be processed if the measurement exists with the specified
+   * version.
+   *
+   * @param measurement
+   *          a measurement name, such as "org.mozila.appInfo.appInfo".
+   * @param version
+   *          a version number, such as '3'.
+   * @param fields
+   *          a {@link MeasurementFields} instance, consisting of a collection
+   *          of field names.
+   */
+  public void ensureMeasurementInitialized(String measurement,
+                                           int version,
+                                           MeasurementFields fields);
+  public Cursor getMeasurementVersions();
+  public Cursor getFieldVersions();
+  public Cursor getFieldVersions(String measurement, int measurementVersion);
+
+  public void deleteEverything();
+  public void deleteEnvironments();
+  public void deleteMeasurements();
+
+  public void enqueueOperation(Runnable runnable);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/HealthReportUtils.java
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.healthreport;
+
+import java.text.SimpleDateFormat;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.mozilla.apache.commons.codec.digest.DigestUtils;
+
+import android.content.ContentUris;
+import android.net.Uri;
+
+public class HealthReportUtils {
+  public static int getDay(final long time) {
+    return (int) Math.floor(time / HealthReportConstants.MILLISECONDS_PER_DAY);
+  }
+
+  public static String getEnvironmentHash(final String input) {
+    return DigestUtils.shaHex(input);
+  }
+
+  public static String getDateStringForDay(long day) {
+    return getDateString(HealthReportConstants.MILLISECONDS_PER_DAY * day);
+  }
+
+  public static String getDateString(long time) {
+    final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
+    format.setTimeZone(TimeZone.getTimeZone("UTC"));
+    return format.format(time);
+  }
+
+  /**
+   * Take an environment URI (one that identifies an environment) and produce an
+   * event URI.
+   *
+   * That this is needed is tragic.
+   *
+   * @param environmentURI
+   *          the {@link Uri} returned by an environment operation.
+   * @return a {@link Uri} to which insertions can be dispatched.
+   */
+  public static Uri getEventURI(Uri environmentURI) {
+    return environmentURI.buildUpon().path("/events/" + ContentUris.parseId(environmentURI) + "/").build();
+  }
+}
--- a/mobile/android/services/java-sources.mn
+++ b/mobile/android/services/java-sources.mn
@@ -19,16 +19,23 @@ background/common/log/writers/LevelFilte
 background/common/log/writers/LogWriter.java
 background/common/log/writers/PrintLogWriter.java
 background/common/log/writers/SimpleTagLogWriter.java
 background/common/log/writers/StringLogWriter.java
 background/common/log/writers/TagLogWriter.java
 background/common/log/writers/ThreadLocalTagLogWriter.java
 background/db/CursorDumper.java
 background/db/Tab.java
+background/healthreport/Environment.java
+background/healthreport/HealthReportDatabases.java
+background/healthreport/HealthReportDatabaseStorage.java
+background/healthreport/HealthReportGenerator.java
+background/healthreport/HealthReportProvider.java
+background/healthreport/HealthReportStorage.java
+background/healthreport/HealthReportUtils.java
 sync/AlreadySyncingException.java
 sync/CollectionKeys.java
 sync/CommandProcessor.java
 sync/CommandRunner.java
 sync/config/AccountPickler.java
 sync/config/activities/SelectEnginesActivity.java
 sync/config/ClientRecordTerminator.java
 sync/config/ConfigurationMigrator.java
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/manifests/HealthReportAndroidManifest_activities.xml.in
@@ -0,0 +1,4 @@
+        <provider android:name="org.mozilla.gecko.background.healthreport.HealthReportProvider"
+                  android:authorities="@ANDROID_PACKAGE_NAME@.health"
+                  android:permission="@ANDROID_PACKAGE_NAME@.permissions.HEALTH_PROVIDER">
+        </provider>
--- a/mobile/android/services/preprocess-sources.mn
+++ b/mobile/android/services/preprocess-sources.mn
@@ -1,3 +1,4 @@
 background/common/GlobalConstants.java
 sync/SyncConstants.java
 background/announcements/AnnouncementsConstants.java
+background/healthreport/HealthReportConstants.java