Bug 922694 - Part 3: FHR changes for distribution and locale. r=mcomella
authorRichard Newman <rnewman@mozilla.com>
Wed, 16 Oct 2013 18:56:27 -0700
changeset 165821 6fe8a2c4841ed5d32593ab69e55529fbbda068c8
parent 165820 e0bcb897d3bc1d882e04b518ea16663bd5c6cc29
child 165822 74b504d3e20a3488589a65492b9b3d8ceb7c5faa
push id428
push userbbajaj@mozilla.com
push dateTue, 28 Jan 2014 00:16:25 +0000
treeherdermozilla-release@cd72a7ff3a75 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmcomella
bugs922694
milestone27.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 922694 - Part 3: FHR changes for distribution and locale. r=mcomella
mobile/android/base/android-services-files.mk
mobile/android/base/background/healthreport/Environment.java
mobile/android/base/background/healthreport/EnvironmentBuilder.java
mobile/android/base/background/healthreport/EnvironmentV1.java
mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
mobile/android/base/background/healthreport/HealthReportGenerator.java
mobile/android/base/background/healthreport/ProfileInformationCache.java
mobile/android/services/java-sources.mn
mobile/android/tests/background/junit3/src/healthreport/MockDatabaseEnvironment.java
mobile/android/tests/background/junit3/src/healthreport/TestHealthReportGenerator.java
mobile/android/tests/background/junit3/src/healthreport/TestHealthReportProvider.java
--- a/mobile/android/base/android-services-files.mk
+++ b/mobile/android/base/android-services-files.mk
@@ -35,16 +35,17 @@ SYNC_JAVA_FILES := \
   background/common/log/writers/StringLogWriter.java \
   background/common/log/writers/TagLogWriter.java \
   background/common/log/writers/ThreadLocalTagLogWriter.java \
   background/datareporting/TelemetryRecorder.java \
   background/db/CursorDumper.java \
   background/db/Tab.java \
   background/healthreport/Environment.java \
   background/healthreport/EnvironmentBuilder.java \
+  background/healthreport/EnvironmentV1.java \
   background/healthreport/HealthReportBroadcastReceiver.java \
   background/healthreport/HealthReportBroadcastService.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 \
--- a/mobile/android/base/background/healthreport/Environment.java
+++ b/mobile/android/base/background/healthreport/Environment.java
@@ -1,274 +1,49 @@
 /* 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.UnsupportedEncodingException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.Iterator;
-import java.util.SortedSet;
-
-import org.json.JSONException;
-import org.json.JSONObject;
-import org.mozilla.apache.commons.codec.binary.Base64;
-import org.mozilla.gecko.background.common.log.Logger;
-
 /**
  * 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 {
-  private static final String LOG_TAG = "GeckoEnvironment";
-
-  public static int VERSION = 1;
-
-  protected final Class<? extends EnvironmentAppender> appenderClass;
-
-  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.
+public abstract class Environment extends EnvironmentV1 {
+  // Version 2 adds osLocale, appLocale, acceptLangSet, and distribution.
+  public static final int CURRENT_VERSION = 2;
 
-  // 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 JSONObject addons = null;
-
-  // org.mozilla.addons.counts.
-  public int extensionCount;
-  public int pluginCount;
-  public int themeCount;
+  public String osLocale;                // The Android OS "Locale" value.
+  public String appLocale;
+  public int acceptLangSet;
+  public String distribution;            // ID + version. Typically empty.
 
   public Environment() {
     this(Environment.HashAppender.class);
   }
 
   public Environment(Class<? extends EnvironmentAppender> appenderClass) {
-    this.appenderClass = appenderClass;
-  }
-
-  public JSONObject getNonIgnoredAddons() {
-    if (addons == null) {
-      return null;
-    }
-    JSONObject out = new JSONObject();
-    @SuppressWarnings("unchecked")
-    Iterator<String> keys = addons.keys();
-    while (keys.hasNext()) {
-      try {
-        final String key = keys.next();
-        final Object obj = addons.get(key);
-        if (obj != null && obj instanceof JSONObject && ((JSONObject) obj).optBoolean("ignore", false)) {
-          continue;
-        }
-        out.put(key, obj);
-      } catch (JSONException ex) {
-        // Do nothing.
-      }
-    }
-    return out;
-  }
-
-  /**
-   * We break out this interface in order to allow for testing -- pass in your
-   * own appender that just records strings, for example.
-   */
-  public static abstract class EnvironmentAppender {
-    public abstract void append(String s);
-    public abstract void append(int v);
-  }
-
-  public static class HashAppender extends EnvironmentAppender {
-    final MessageDigest hasher;
-
-    public HashAppender() throws NoSuchAlgorithmException {
-      // Note to the security minded reader: we deliberately use SHA-1 here, not
-      // a stronger hash. These identifiers don't strictly need a cryptographic
-      // hash function, because there is negligible value in attacking the hash.
-      // We use SHA-1 because it's *shorter* -- the exact same reason that Git
-      // chose SHA-1.
-      hasher = MessageDigest.getInstance("SHA-1");
-    }
-
-    @Override
-    public void append(String s) {
-      try {
-        hasher.update(((s == null) ? "null" : s).getBytes("UTF-8"));
-      } catch (UnsupportedEncodingException e) {
-        // This can never occur. Thanks, Java.
-      }
-    }
-
-    @Override
-    public void append(int profileCreation) {
-      append(Integer.toString(profileCreation, 10));
-    }
-
-    @Override
-    public String toString() {
-      // We *could* use ASCII85… but the savings would be negated by the
-      // inclusion of JSON-unsafe characters like double-quote.
-      return new Base64(-1, null, false).encodeAsString(hasher.digest());
-    }
+    super(appenderClass);
+    version = CURRENT_VERSION;
   }
 
-  /**
-   * Compute the stable hash of the configured environment.
-   *
-   * @return the hash in base34, or null if there was a problem.
-   */
-  public String getHash() {
-    // It's never unset, so we only care about partial reads. volatile is enough.
-    if (hash != null) {
-      return hash;
-    }
-
-    EnvironmentAppender appender;
-    try {
-      appender = appenderClass.newInstance();
-    } catch (InstantiationException ex) {
-      // Should never happen, but...
-      Logger.warn(LOG_TAG,  "Could not compute hash.", ex);
-      return null;
-    } catch (IllegalAccessException ex) {
-      // Should never happen, but...
-      Logger.warn(LOG_TAG,  "Could not compute hash.", ex);
-      return null;
-    }
-
-    appender.append(profileCreation);
-    appender.append(cpuCount);
-    appender.append(memoryMB);
-    appender.append(architecture);
-    appender.append(sysName);
-    appender.append(sysVersion);
-    appender.append(vendor);
-    appender.append(appName);
-    appender.append(appID);
-    appender.append(appVersion);
-    appender.append(appBuildID);
-    appender.append(platformVersion);
-    appender.append(platformBuildID);
-    appender.append(os);
-    appender.append(xpcomabi);
-    appender.append(updateChannel);
-    appender.append(isBlocklistEnabled);
-    appender.append(isTelemetryEnabled);
-    appender.append(extensionCount);
-    appender.append(pluginCount);
-    appender.append(themeCount);
-
-    // We need sorted values.
-    if (addons != null) {
-      appendSortedAddons(getNonIgnoredAddons(), appender);
-    }
-
-    return hash = appender.toString();
-  }
-
-  /**
-   * Take a collection of add-on descriptors, appending a consistent string
-   * to the provided builder.
-   */
-  public static void appendSortedAddons(JSONObject addons,
-                                        final EnvironmentAppender builder) {
-    final SortedSet<String> keys = HealthReportUtils.sortedKeySet(addons);
+  @Override
+  protected void appendHash(EnvironmentAppender appender) {
+    super.appendHash(appender);
 
-    // For each add-on, produce a consistent, sorted mapping of its descriptor.
-    for (String key : keys) {
-      try {
-        JSONObject addon = addons.getJSONObject(key);
-
-        // Now produce the output for this add-on.
-        builder.append(key);
-        builder.append("={");
-
-        for (String addonKey : HealthReportUtils.sortedKeySet(addon)) {
-          builder.append(addonKey);
-          builder.append("==");
-          try {
-            builder.append(addon.get(addonKey).toString());
-          } catch (JSONException e) {
-            builder.append("_e_");
-          }
-        }
-
-        builder.append("}");
-      } catch (Exception e) {
-        // Muffle.
-        Logger.warn(LOG_TAG, "Invalid add-on for ID " + key);
-      }
-    }
-  }
-
-  public void setJSONForAddons(byte[] json) throws Exception {
-    setJSONForAddons(new String(json, "UTF-8"));
+    // v2.
+    appender.append(osLocale);
+    appender.append(appLocale);
+    appender.append(acceptLangSet);
+    appender.append(distribution);
   }
-
-  public void setJSONForAddons(String json) throws Exception {
-    if (json == null || "null".equals(json)) {
-      addons = null;
-      return;
-    }
-    addons = new JSONObject(json);
-  }
-
-  public void setJSONForAddons(JSONObject json) {
-    addons = json;
-  }
-
-  /**
-   * Includes ignored add-ons.
-   */
-  public String getNormalizedAddonsJSON() {
-    // We trust that our input will already be normalized. If that assumption
-    // is invalidated, then we'll be sorry.
-    return (addons == null) ? "null" : addons.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();
 }
--- a/mobile/android/base/background/healthreport/EnvironmentBuilder.java
+++ b/mobile/android/base/background/healthreport/EnvironmentBuilder.java
@@ -53,17 +53,23 @@ public class EnvironmentBuilder {
       Logger.error(LOG_TAG, "ContentProvider not a HealthReportProvider!", ex);
       throw ex;
     }
   }
 
   public static interface ProfileInformationProvider {
     public boolean isBlocklistEnabled();
     public boolean isTelemetryEnabled();
+    public boolean isAcceptLangUserSet();
     public long getProfileCreationTime();
+
+    public String getDistributionString();
+    public String getOSLocale();
+    public String getAppLocale();
+
     public JSONObject getAddonsJSON();
   }
 
   protected static void populateEnvironment(Environment e,
                                             ProfileInformationProvider info) {
     e.cpuCount = SysInfo.getCPUCount();
     e.memoryMB = SysInfo.getMemSize();
 
@@ -119,16 +125,22 @@ public class EnvironmentBuilder {
           Logger.debug(LOG_TAG, "Unknown add-on type: " + type);
         }
       } catch (Exception ex) {
         Logger.warn(LOG_TAG, "Failed to process add-on " + key, ex);
       }
     }
 
     e.addons = addons;
+
+    // v2 environment fields.
+    e.distribution = info.getDistributionString();
+    e.osLocale = info.getOSLocale();
+    e.appLocale = info.getAppLocale();
+    e.acceptLangSet = info.isAcceptLangUserSet() ? 1 : 0;
   }
 
   /**
    * Returns an {@link Environment} not linked to a storage instance, but
    * populated with current field values.
    *
    * @param info a source of profile data
    * @return the new {@link Environment}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/EnvironmentV1.java
@@ -0,0 +1,267 @@
+/* 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.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Iterator;
+import java.util.SortedSet;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.apache.commons.codec.binary.Base64;
+import org.mozilla.gecko.background.common.log.Logger;
+
+public abstract class EnvironmentV1 {
+  private static final String LOG_TAG = "GeckoEnvironment";
+  private static final int VERSION = 1;
+
+  protected final Class<? extends EnvironmentAppender> appenderClass;
+
+  protected volatile String hash = null;
+  protected volatile int id = -1;
+
+  public int version = VERSION;
+
+  // 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.
+  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;
+
+  // org.mozilla.addons.active.
+  public JSONObject addons = null;
+
+  // org.mozilla.addons.counts.
+  public int extensionCount;
+  public int pluginCount;
+  public int themeCount;
+
+  /**
+   * We break out this interface in order to allow for testing -- pass in your
+   * own appender that just records strings, for example.
+   */
+  public static abstract class EnvironmentAppender {
+    public abstract void append(String s);
+    public abstract void append(int v);
+  }
+
+  public static class HashAppender extends EnvironmentAppender {
+    final MessageDigest hasher;
+
+    public HashAppender() throws NoSuchAlgorithmException {
+      // Note to the security-minded reader: we deliberately use SHA-1 here, not
+      // a stronger hash. These identifiers don't strictly need a cryptographic
+      // hash function, because there is negligible value in attacking the hash.
+      // We use SHA-1 because it's *shorter* -- the exact same reason that Git
+      // chose SHA-1.
+      hasher = MessageDigest.getInstance("SHA-1");
+    }
+
+    @Override
+    public void append(String s) {
+      try {
+        hasher.update(((s == null) ? "null" : s).getBytes("UTF-8"));
+      } catch (UnsupportedEncodingException e) {
+        // This can never occur. Thanks, Java.
+      }
+    }
+
+    @Override
+    public void append(int profileCreation) {
+      append(Integer.toString(profileCreation, 10));
+    }
+
+    @Override
+    public String toString() {
+      // We *could* use ASCII85… but the savings would be negated by the
+      // inclusion of JSON-unsafe characters like double-quote.
+      return new Base64(-1, null, false).encodeAsString(hasher.digest());
+    }
+  }
+
+  /**
+   * 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();
+
+  protected EnvironmentAppender getAppender() {
+    EnvironmentAppender appender = null;
+    try {
+      appender = appenderClass.newInstance();
+    } catch (InstantiationException ex) {
+      // Should never happen, but...
+      Logger.warn(LOG_TAG,  "Could not compute hash.", ex);
+    } catch (IllegalAccessException ex) {
+      // Should never happen, but...
+      Logger.warn(LOG_TAG,  "Could not compute hash.", ex);
+    }
+    return appender;
+  }
+
+  protected void appendHash(EnvironmentAppender appender) {
+    appender.append(profileCreation);
+    appender.append(cpuCount);
+    appender.append(memoryMB);
+    appender.append(architecture);
+    appender.append(sysName);
+    appender.append(sysVersion);
+    appender.append(vendor);
+    appender.append(appName);
+    appender.append(appID);
+    appender.append(appVersion);
+    appender.append(appBuildID);
+    appender.append(platformVersion);
+    appender.append(platformBuildID);
+    appender.append(os);
+    appender.append(xpcomabi);
+    appender.append(updateChannel);
+    appender.append(isBlocklistEnabled);
+    appender.append(isTelemetryEnabled);
+    appender.append(extensionCount);
+    appender.append(pluginCount);
+    appender.append(themeCount);
+
+    // We need sorted values.
+    if (addons != null) {
+      appendSortedAddons(getNonIgnoredAddons(), appender);
+    }
+  }
+
+  /**
+   * Compute the stable hash of the configured environment.
+   *
+   * @return the hash in base34, or null if there was a problem.
+   */
+  public String getHash() {
+    // It's never unset, so we only care about partial reads. volatile is enough.
+    if (hash != null) {
+      return hash;
+    }
+
+    EnvironmentAppender appender = getAppender();
+    if (appender == null) {
+      return null;
+    }
+
+    appendHash(appender);
+    return hash = appender.toString();
+  }
+
+  public EnvironmentV1(Class<? extends EnvironmentAppender> appenderClass) {
+    super();
+    this.appenderClass = appenderClass;
+  }
+
+  public JSONObject getNonIgnoredAddons() {
+    if (addons == null) {
+      return null;
+    }
+    JSONObject out = new JSONObject();
+    @SuppressWarnings("unchecked")
+    Iterator<String> keys = addons.keys();
+    while (keys.hasNext()) {
+      try {
+        final String key = keys.next();
+        final Object obj = addons.get(key);
+        if (obj != null &&
+            obj instanceof JSONObject &&
+            ((JSONObject) obj).optBoolean("ignore", false)) {
+          continue;
+        }
+        out.put(key, obj);
+      } catch (JSONException ex) {
+        // Do nothing.
+      }
+    }
+    return out;
+  }
+
+  /**
+   * Take a collection of add-on descriptors, appending a consistent string
+   * to the provided builder.
+   */
+  public static void appendSortedAddons(JSONObject addons, final EnvironmentAppender builder) {
+    final SortedSet<String> keys = HealthReportUtils.sortedKeySet(addons);
+
+    // For each add-on, produce a consistent, sorted mapping of its descriptor.
+    for (String key : keys) {
+      try {
+        JSONObject addon = addons.getJSONObject(key);
+
+        // Now produce the output for this add-on.
+        builder.append(key);
+        builder.append("={");
+
+        for (String addonKey : HealthReportUtils.sortedKeySet(addon)) {
+          builder.append(addonKey);
+          builder.append("==");
+          try {
+            builder.append(addon.get(addonKey).toString());
+          } catch (JSONException e) {
+            builder.append("_e_");
+          }
+        }
+
+        builder.append("}");
+      } catch (Exception e) {
+        // Muffle.
+        Logger.warn(LOG_TAG, "Invalid add-on for ID " + key);
+      }
+    }
+  }
+
+  public void setJSONForAddons(byte[] json) throws Exception {
+    setJSONForAddons(new String(json, "UTF-8"));
+  }
+
+  public void setJSONForAddons(String json) throws Exception {
+    if (json == null || "null".equals(json)) {
+      addons = null;
+      return;
+    }
+    addons = new JSONObject(json);
+  }
+
+  public void setJSONForAddons(JSONObject json) {
+    addons = json;
+  }
+
+  /**
+   * Includes ignored add-ons.
+   */
+  public String getNormalizedAddonsJSON() {
+    // We trust that our input will already be normalized. If that assumption
+    // is invalidated, then we'll be sorry.
+    return (addons == null) ? "null" : addons.toString();
+  }
+}
--- a/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
+++ b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
@@ -123,26 +123,28 @@ public class HealthReportDatabaseStorage
   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",
+      "id", "version", "hash",
       "profileCreation", "cpuCount", "memoryMB",
 
       "isBlocklistEnabled", "isTelemetryEnabled", "extensionCount",
       "pluginCount", "themeCount",
 
       "architecture", "sysName", "sysVersion", "vendor", "appName", "appID",
       "appVersion", "appBuildID", "platformVersion", "platformBuildID", "os",
       "xpcomabi", "updateChannel",
 
+      "distribution", "osLocale", "appLocale", "acceptLangSet",
+
       // Joined to the add-ons table.
       "addonsBody"
   };
 
   public static final String[] COLUMNS_MEASUREMENT_DETAILS = new String[] {"id", "name", "version"};
   public static final String[] COLUMNS_MEASUREMENT_AND_FIELD_DETAILS =
       new String[] {"measurement_name", "measurement_id", "measurement_version",
                     "field_name", "field_id", "field_flags"};
@@ -183,17 +185,17 @@ public class HealthReportDatabaseStorage
     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 = 5;
+    public static final int CURRENT_VERSION = 6;
     public static final String LOG_TAG = "HealthReportSQL";
 
     /**
      * A little helper to avoid SQLiteOpenHelper misbehaving on Android 2.1.
      * Partly cribbed from
      * <http://stackoverflow.com/questions/5332328/sqliteopenhelper-problem-with-fully-qualified-db-path-name>.
      */
     public static class AbsolutePathContext extends ContextWrapper {
@@ -247,17 +249,20 @@ public class HealthReportDatabaseStorage
 
     @Override
     public void onCreate(SQLiteDatabase db) {
       db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                  "                     body TEXT, " +
                  "                     UNIQUE (body) " +
                  ")");
 
+      // N.B., hash collisions can occur across versions. In that case, the system
+      // is likely to persist the original environment version.
       db.execSQL("CREATE TABLE environments (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
+                 "                           version INTEGER, " +
                  "                           hash TEXT, " +
                  "                           profileCreation INTEGER, " +
                  "                           cpuCount        INTEGER, " +
                  "                           memoryMB        INTEGER, " +
                  "                           isBlocklistEnabled INTEGER, " +
                  "                           isTelemetryEnabled INTEGER, " +
                  "                           extensionCount     INTEGER, " +
                  "                           pluginCount        INTEGER, " +
@@ -270,16 +275,22 @@ public class HealthReportDatabaseStorage
                  "                           appID           TEXT, " +
                  "                           appVersion      TEXT, " +
                  "                           appBuildID      TEXT, " +
                  "                           platformVersion TEXT, " +
                  "                           platformBuildID TEXT, " +
                  "                           os              TEXT, " +
                  "                           xpcomabi        TEXT, " +
                  "                           updateChannel   TEXT, " +
+
+                 "                           distribution    TEXT, " +
+                 "                           osLocale        TEXT, " +
+                 "                           appLocale       TEXT, " +
+                 "                           acceptLangSet   INTEGER, " +
+
                  "                           addonsID        INTEGER, " +
                  "                           FOREIGN KEY (addonsID) REFERENCES addons(id) ON DELETE RESTRICT, " +
                  "                           UNIQUE (hash) " +
                  ")");
 
       db.execSQL("CREATE TABLE measurements (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                  "                           name TEXT, " +
                  "                           version INTEGER, " +
@@ -352,16 +363,17 @@ public class HealthReportDatabaseStorage
       if (!db.isReadOnly()) {
         db.execSQL("PRAGMA foreign_keys=ON;");
       }
     }
 
     private void createAddonsEnvironmentsView(SQLiteDatabase db) {
       db.execSQL("CREATE VIEW environments_with_addons AS " +
           "SELECT e.id AS id, " +
+          "       e.version AS version, " +
           "       e.hash AS hash, " +
           "       e.profileCreation AS profileCreation, " +
           "       e.cpuCount AS cpuCount, " +
           "       e.memoryMB AS memoryMB, " +
           "       e.isBlocklistEnabled AS isBlocklistEnabled, " +
           "       e.isTelemetryEnabled AS isTelemetryEnabled, " +
           "       e.extensionCount AS extensionCount, " +
           "       e.pluginCount AS pluginCount, " +
@@ -374,16 +386,20 @@ public class HealthReportDatabaseStorage
           "       e.appID AS appID, " +
           "       e.appVersion AS appVersion, " +
           "       e.appBuildID AS appBuildID, " +
           "       e.platformVersion AS platformVersion, " +
           "       e.platformBuildID AS platformBuildID, " +
           "       e.os AS os, " +
           "       e.xpcomabi AS xpcomabi, " +
           "       e.updateChannel AS updateChannel, " +
+          "       e.distribution AS distribution, " +
+          "       e.osLocale AS osLocale, " +
+          "       e.appLocale AS appLocale, " +
+          "       e.acceptLangSet AS acceptLangSet, " +
           "       addons.body AS addonsBody " +
           "FROM environments AS e, addons " +
           "WHERE e.addonsID = addons.id");
     }
 
     private void upgradeDatabaseFrom2To3(SQLiteDatabase db) {
       db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
                  "                     body TEXT, " +
@@ -412,31 +428,49 @@ public class HealthReportDatabaseStorage
       db.delete("fields", "measurement NOT IN (SELECT id FROM measurements)", null);
       db.delete("environments", "addonsID NOT IN (SELECT id from addons)", null);
       db.delete(EVENTS_INTEGER, "env NOT IN (SELECT id FROM environments)", null);
       db.delete(EVENTS_TEXTUAL, "env NOT IN (SELECT id FROM environments)", null);
       db.delete(EVENTS_INTEGER, "field NOT IN (SELECT id FROM fields)", null);
       db.delete(EVENTS_TEXTUAL, "field NOT IN (SELECT id FROM fields)", null);
     }
 
+    private void upgradeDatabaseFrom5to6(SQLiteDatabase db) {
+      db.execSQL("DROP VIEW environments_with_addons");
+
+      // Add version to environment (default to 1).
+      db.execSQL("ALTER TABLE environments ADD COLUMN version INTEGER DEFAULT 1");
+
+      // Add fields to environment (default to empty string).
+      db.execSQL("ALTER TABLE environments ADD COLUMN distribution TEXT DEFAULT ''");
+      db.execSQL("ALTER TABLE environments ADD COLUMN osLocale TEXT DEFAULT ''");
+      db.execSQL("ALTER TABLE environments ADD COLUMN appLocale TEXT DEFAULT ''");
+      db.execSQL("ALTER TABLE environments ADD COLUMN acceptLangSet INTEGER DEFAULT 0");
+
+      // Recreate view.
+      createAddonsEnvironmentsView(db);
+    }
+
     @Override
     public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
       if (oldVersion >= newVersion) {
         return;
       }
 
       Logger.info(LOG_TAG, "onUpgrade: from " + oldVersion + " to " + newVersion + ".");
       try {
         switch (oldVersion) {
         case 2:
           upgradeDatabaseFrom2To3(db);
         case 3:
           upgradeDatabaseFrom3To4(db);
         case 4:
           upgradeDatabaseFrom4to5(db);
+        case 5:
+          upgradeDatabaseFrom5to6(db);
         }
       } catch (Exception e) {
         Logger.error(LOG_TAG, "Failure in onUpgrade.", e);
         throw new RuntimeException(e);
       }
    }
 
     public void deleteEverything() {
@@ -531,16 +565,17 @@ public class HealthReportDatabaseStorage
       final String h = getHash();
       if (storage.envs.containsKey(h)) {
         this.id = storage.envs.get(h);
         return this.id;
       }
 
       // Otherwise, add data and hash to the DB.
       ContentValues v = new ContentValues();
+      v.put("version", version);
       v.put("hash", h);
       v.put("profileCreation", profileCreation);
       v.put("cpuCount", cpuCount);
       v.put("memoryMB", memoryMB);
       v.put("isBlocklistEnabled", isBlocklistEnabled);
       v.put("isTelemetryEnabled", isTelemetryEnabled);
       v.put("extensionCount", extensionCount);
       v.put("pluginCount", pluginCount);
@@ -553,16 +588,20 @@ public class HealthReportDatabaseStorage
       v.put("appID", appID);
       v.put("appVersion", appVersion);
       v.put("appBuildID", appBuildID);
       v.put("platformVersion", platformVersion);
       v.put("platformBuildID", platformBuildID);
       v.put("os", os);
       v.put("xpcomabi", xpcomabi);
       v.put("updateChannel", updateChannel);
+      v.put("distribution", distribution);
+      v.put("osLocale", osLocale);
+      v.put("appLocale", appLocale);
+      v.put("acceptLangSet", acceptLangSet);
 
       final SQLiteDatabase db = storage.helper.getWritableDatabase();
 
       // If we're not already, we want all of our inserts to be in a transaction.
       boolean newTransaction = !db.inTransaction();
 
       // Insert, with a little error handling to populate the cache in case of
       // omission and consequent collision.
@@ -638,16 +677,17 @@ public class HealthReportDatabaseStorage
         values.put("body", json);
         return (int) db.insert("addons", null, values);
       } finally {
         c.close();
       }
     }
 
     public void init(ContentValues v) {
+      version         = v.containsKey("version") ? v.getAsInteger("version") : Environment.CURRENT_VERSION;
       profileCreation = v.getAsInteger("profileCreation");
       cpuCount        = v.getAsInteger("cpuCount");
       memoryMB        = v.getAsInteger("memoryMB");
 
       isBlocklistEnabled = v.getAsInteger("isBlocklistEnabled");
       isTelemetryEnabled = v.getAsInteger("isTelemetryEnabled");
       extensionCount     = v.getAsInteger("extensionCount");
       pluginCount        = v.getAsInteger("pluginCount");
@@ -662,16 +702,21 @@ public class HealthReportDatabaseStorage
       appVersion      = v.getAsString("appVersion");
       appBuildID      = v.getAsString("appBuildID");
       platformVersion = v.getAsString("platformVersion");
       platformBuildID = v.getAsString("platformBuildID");
       os              = v.getAsString("os");
       xpcomabi        = v.getAsString("xpcomabi");
       updateChannel   = v.getAsString("updateChannel");
 
+      distribution    = v.getAsString("distribution");
+      osLocale        = v.getAsString("osLocale");
+      appLocale       = v.getAsString("appLocale");
+      acceptLangSet   = v.getAsInteger("acceptLangSet");
+
       try {
         setJSONForAddons(v.getAsString("addonsBody"));
       } catch (Exception e) {
         // Nothing we can do.
       }
 
       this.hash = null;
       this.id = -1;
@@ -681,16 +726,17 @@ public class HealthReportDatabaseStorage
      * Fill ourselves with data from the DB, then advance the cursor.
      *
      * @param cursor a {@link Cursor} pointing at a record to load.
      * @return true if the cursor was successfully advanced.
      */
     public boolean init(Cursor cursor) {
       int i = 0;
       this.id         = cursor.getInt(i++);
+      this.version    = cursor.getInt(i++);
       this.hash       = cursor.getString(i++);
 
       profileCreation = cursor.getInt(i++);
       cpuCount        = cursor.getInt(i++);
       memoryMB        = cursor.getInt(i++);
 
       isBlocklistEnabled = cursor.getInt(i++);
       isTelemetryEnabled = cursor.getInt(i++);
@@ -707,16 +753,21 @@ public class HealthReportDatabaseStorage
       appVersion      = cursor.getString(i++);
       appBuildID      = cursor.getString(i++);
       platformVersion = cursor.getString(i++);
       platformBuildID = cursor.getString(i++);
       os              = cursor.getString(i++);
       xpcomabi        = cursor.getString(i++);
       updateChannel   = cursor.getString(i++);
 
+      distribution    = cursor.getString(i++);
+      osLocale        = cursor.getString(i++);
+      appLocale       = cursor.getString(i++);
+      acceptLangSet   = cursor.getInt(i++);
+
       try {
         setJSONForAddons(cursor.getBlob(i++));
       } catch (Exception e) {
         // Nothing we can do.
       }
 
       return cursor.moveToNext();
     }
@@ -1334,30 +1385,32 @@ public class HealthReportDatabaseStorage
    * Deletes environments not referenced by any events except for the given current environment.
    */
   protected int deleteOrphanedEnv(final int curEnv) {
     final SQLiteDatabase db = this.helper.getWritableDatabase();
     return deleteOrphanedEnv(db, curEnv);
   }
 
   // Called internally only to ensure the same db instance is used.
+  @SuppressWarnings("static-method")
   protected int deleteOrphanedEnv(final SQLiteDatabase db, final int curEnv) {
     final String whereClause =
         "id != ? AND " +
         "id NOT IN (SELECT env FROM events)";
     final String[] whereArgs = new String[] {Integer.toString(curEnv)};
     return db.delete("environments", whereClause, whereArgs);
   }
 
   protected int deleteEventsBefore(final String dayString) {
     final SQLiteDatabase db = this.helper.getWritableDatabase();
     return deleteEventsBefore(db, dayString);
   }
 
   // Called internally only to ensure the same db instance is used.
+  @SuppressWarnings("static-method")
   protected int deleteEventsBefore(final SQLiteDatabase db, final String dayString) {
     final String whereClause = "date < ?";
     final String[] whereArgs = new String[] {dayString};
     int numEventsDeleted = 0;
     db.beginTransaction();
     try {
       numEventsDeleted += db.delete("events_integer", whereClause, whereArgs);
       numEventsDeleted += db.delete("events_textual", whereClause, whereArgs);
@@ -1372,16 +1425,17 @@ public class HealthReportDatabaseStorage
    * Deletes addons not referenced by any environments.
    */
   protected int deleteOrphanedAddons() {
     final SQLiteDatabase db = this.helper.getWritableDatabase();
     return deleteOrphanedAddons(db);
   }
 
   // Called internally only to ensure the same db instance is used.
+  @SuppressWarnings("static-method")
   protected int deleteOrphanedAddons(final SQLiteDatabase db) {
     final String whereClause = "id NOT IN (SELECT addonsID FROM environments)";
     return db.delete("addons", whereClause, null);
   }
 
   /**
    * Retrieve a mapping from a table. Keys should be unique; only one key-value
    * pair will be returned for each key.
--- a/mobile/android/base/background/healthreport/HealthReportGenerator.java
+++ b/mobile/android/base/background/healthreport/HealthReportGenerator.java
@@ -383,32 +383,125 @@ public class HealthReportGenerator {
     }
     if (current != null && changes == 0) {
       return null;
     }
     gecko.put("_v", 1);
     return gecko;
   }
 
+  // Null-safe string comparison.
+  private static boolean stringsDiffer(final String a, final String b) {
+    if (a == null) {
+      return b != null;
+    }
+    return !a.equals(b);
+  }
+
   private static JSONObject getAppInfo(Environment e, Environment current) throws JSONException {
     JSONObject appinfo = new JSONObject();
-    int changes = 0;
+
+    Logger.debug(LOG_TAG, "Generating appinfo for v" + e.version + " env " + e.hash);
+
+    // Is the environment in question newer than the diff target, or is
+    // there no diff target?
+    final boolean outdated = current == null ||
+                             e.version > current.version;
+
+    // Is the environment in question a different version (lower or higher),
+    // or is there no diff target?
+    final boolean differ = outdated || current.version > e.version;
+
+    // Always produce an output object if there's a version mismatch or this
+    // isn't a diff. Otherwise, track as we go if there's any difference.
+    boolean changed = differ;
+
+    switch (e.version) {
+    // There's a straightforward correspondence between environment versions
+    // and appinfo versions.
+    case 2:
+      appinfo.put("_v", 3);
+      break;
+    case 1:
+      appinfo.put("_v", 2);
+      break;
+    default:
+      Logger.warn(LOG_TAG, "Unknown environment version: " + e.version);
+      return appinfo;
+    }
+
+    switch (e.version) {
+    case 2:
+      if (populateAppInfoV2(appinfo, e, current, outdated)) {
+        changed = true;
+      }
+      // Fall through.
+
+    case 1:
+      // There is no older version than v1, so don't check outdated.
+      if (populateAppInfoV1(e, current, appinfo)) {
+        changed = true;
+      }
+    }
+
+    if (!changed) {
+      return null;
+    }
+
+    return appinfo;
+  }
+
+  private static boolean populateAppInfoV1(Environment e,
+                                           Environment current,
+                                           JSONObject appinfo)
+    throws JSONException {
+    boolean changes = false;
     if (current == null || current.isBlocklistEnabled != e.isBlocklistEnabled) {
       appinfo.put("isBlocklistEnabled", e.isBlocklistEnabled);
-      changes++;
+      changes = true;
     }
+
     if (current == null || current.isTelemetryEnabled != e.isTelemetryEnabled) {
       appinfo.put("isTelemetryEnabled", e.isTelemetryEnabled);
-      changes++;
+      changes = true;
+    }
+
+    return changes;
+  }
+
+  private static boolean populateAppInfoV2(JSONObject appinfo,
+                                           Environment e,
+                                           Environment current,
+                                           final boolean outdated)
+    throws JSONException {
+    boolean changes = false;
+    if (outdated ||
+        stringsDiffer(current.osLocale, e.osLocale)) {
+      appinfo.put("osLocale", e.osLocale);
+      changes = true;
     }
-    if (current != null && changes == 0) {
-      return null;
+
+    if (outdated ||
+        stringsDiffer(current.appLocale, e.appLocale)) {
+      appinfo.put("appLocale", e.appLocale);
+      changes = true;
     }
-    appinfo.put("_v", 2);
-    return appinfo;
+
+    if (outdated ||
+        stringsDiffer(current.distribution, e.distribution)) {
+      appinfo.put("distribution", e.distribution);
+      changes = true;
+    }
+
+    if (outdated ||
+        current.acceptLangSet != e.acceptLangSet) {
+      appinfo.put("acceptLangIsUserSet", e.acceptLangSet);
+      changes = true;
+    }
+    return changes;
   }
 
   private static JSONObject getAddonCounts(Environment e, Environment current) throws JSONException {
     JSONObject counts = new JSONObject();
     int changes = 0;
     if (current == null || current.extensionCount != e.extensionCount) {
       counts.put("extension", e.extensionCount);
       changes++;
--- a/mobile/android/base/background/healthreport/ProfileInformationCache.java
+++ b/mobile/android/base/background/healthreport/ProfileInformationCache.java
@@ -5,16 +5,17 @@
 package org.mozilla.gecko.background.healthreport;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStreamWriter;
 import java.nio.charset.Charset;
+import java.util.Locale;
 import java.util.Scanner;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.healthreport.EnvironmentBuilder.ProfileInformationProvider;
 
 /**
@@ -27,27 +28,50 @@ public class ProfileInformationCache imp
   private static final String LOG_TAG = "GeckoProfileInfo";
   private static final String CACHE_FILE = "profile_info_cache.json";
 
   /*
    * FORMAT_VERSION history:
    *   -: No version number; implicit v1.
    *   1: Add versioning (Bug 878670).
    *   2: Bump to regenerate add-on set after landing Bug 900694 (Bug 901622).
+   *   3: Add distribution, osLocale, appLocale.
    */
-  public static final int FORMAT_VERSION = 2;
+  public static final int FORMAT_VERSION = 3;
 
   protected boolean initialized = false;
   protected boolean needsWrite = false;
 
   protected final File file;
 
   private volatile boolean blocklistEnabled = true;
   private volatile boolean telemetryEnabled = false;
+  private volatile boolean isAcceptLangUserSet = false;
+
   private volatile long profileCreationTime = 0;
+  private volatile String distribution = "";
+
+  // There are really four kinds of locale in play:
+  //
+  // * The OS
+  // * The Android environment of the app (setDefault)
+  // * The Gecko locale
+  // * The requested content locale (Accept-Language).
+  //
+  // We track only the first two, assuming that the Gecko locale will typically
+  // be the same as the app locale.
+  //
+  // The app locale is fetched from the PIC because it can be modified at
+  // runtime -- it won't necessarily be what Locale.getDefaultLocale() returns
+  // in a fresh non-browser profile.
+  //
+  // We also track the OS locale here for the same reason -- we need to store
+  // the default (OS) value before the locale-switching code takes effect!
+  private volatile String osLocale = "";
+  private volatile String appLocale = "";
 
   private volatile JSONObject addons = null;
 
   public ProfileInformationCache(String profilePath) {
     file = new File(profilePath + File.separator + CACHE_FILE);
     Logger.pii(LOG_TAG, "Using " + file.getAbsolutePath() + " for profile information cache.");
   }
 
@@ -57,17 +81,21 @@ public class ProfileInformationCache imp
   }
 
   public JSONObject toJSON() {
     JSONObject object = new JSONObject();
     try {
       object.put("version", FORMAT_VERSION);
       object.put("blocklist", blocklistEnabled);
       object.put("telemetry", telemetryEnabled);
+      object.put("isAcceptLangUserSet", isAcceptLangUserSet);
       object.put("profileCreated", profileCreationTime);
+      object.put("osLocale", osLocale);
+      object.put("appLocale", appLocale);
+      object.put("distribution", distribution);
       object.put("addons", addons);
     } catch (JSONException e) {
       // There isn't much we can do about this.
       // Let's just quietly muffle.
       return null;
     }
     return object;
   }
@@ -81,18 +109,22 @@ public class ProfileInformationCache imp
    * @return false if there's a version mismatch or an error, true on success.
    */
   private boolean fromJSON(JSONObject object) throws JSONException {
     int version = object.optInt("version", 1);
     switch (version) {
     case FORMAT_VERSION:
       blocklistEnabled = object.getBoolean("blocklist");
       telemetryEnabled = object.getBoolean("telemetry");
+      isAcceptLangUserSet = object.getBoolean("isAcceptLangUserSet");
       profileCreationTime = object.getLong("profileCreated");
       addons = object.getJSONObject("addons");
+      distribution = object.getString("distribution");
+      osLocale = object.getString("osLocale");
+      appLocale = object.getString("appLocale");
       return true;
     default:
       Logger.warn(LOG_TAG, "Unable to restore from version " + version + " PIC file: expecting " + FORMAT_VERSION);
       return false;
     }
   }
 
   protected JSONObject readFromFile() throws FileNotFoundException, JSONException {
@@ -202,60 +234,141 @@ public class ProfileInformationCache imp
 
   public void setTelemetryEnabled(boolean value) {
     Logger.debug(LOG_TAG, "Setting telemetry enabled: " + value);
     telemetryEnabled = value;
     needsWrite = true;
   }
 
   @Override
+  public boolean isAcceptLangUserSet() {
+    ensureInitialized();
+    return isAcceptLangUserSet;
+  }
+
+  public void setAcceptLangUserSet(boolean value) {
+    Logger.debug(LOG_TAG, "Setting accept-lang as user-set: " + value);
+    isAcceptLangUserSet = value;
+    needsWrite = true;
+  }
+
+  @Override
   public long getProfileCreationTime() {
     ensureInitialized();
     return profileCreationTime;
   }
 
   public void setProfileCreationTime(long value) {
     Logger.debug(LOG_TAG, "Setting profile creation time: " + value);
     profileCreationTime = value;
     needsWrite = true;
   }
 
   @Override
+  public String getDistributionString() {
+    ensureInitialized();
+    return distribution;
+  }
+
+  /**
+   * Ensure that your arguments are non-null.
+   */
+  public void setDistributionString(String distributionID, String distributionVersion) {
+    Logger.debug(LOG_TAG, "Setting distribution: " + distributionID + ", " + distributionVersion);
+    distribution = distributionID + ":" + distributionVersion;
+    needsWrite = true;
+  }
+
+  @Override
+  public String getAppLocale() {
+    ensureInitialized();
+    return appLocale;
+  }
+
+  public void setAppLocale(String value) {
+    if (value.equalsIgnoreCase(appLocale)) {
+      return;
+    }
+    Logger.debug(LOG_TAG, "Setting app locale: " + value);
+    appLocale = value.toLowerCase(Locale.US);
+    needsWrite = true;
+  }
+
+  @Override
+  public String getOSLocale() {
+    ensureInitialized();
+    return osLocale;
+  }
+
+  public void setOSLocale(String value) {
+    if (value.equalsIgnoreCase(osLocale)) {
+      return;
+    }
+    Logger.debug(LOG_TAG, "Setting OS locale: " + value);
+    osLocale = value.toLowerCase(Locale.US);
+    needsWrite = true;
+  }
+
+  /**
+   * Update the PIC, if necessary, to match the current locale environment.
+   *
+   * @return true if the PIC needed to be updated.
+   */
+  public boolean updateLocales(String osLocale, String appLocale) {
+    if (this.osLocale.equalsIgnoreCase(osLocale) &&
+        (appLocale == null || this.appLocale.equalsIgnoreCase(appLocale))) {
+      return false;
+    }
+    this.setOSLocale(osLocale);
+    if (appLocale != null) {
+      this.setAppLocale(appLocale);
+    }
+    return true;
+  }
+
+  @Override
   public JSONObject getAddonsJSON() {
+    ensureInitialized();
     return addons;
   }
 
   public void updateJSONForAddon(String id, String json) throws Exception {
     addons.put(id, new JSONObject(json));
+    needsWrite = true;
   }
 
   public void removeAddon(String id) {
-    addons.remove(id);
+    if (null != addons.remove(id)) {
+      needsWrite = true;
+    }
   }
 
   /**
    * Will throw if you haven't done a full update at least once.
    */
   public void updateJSONForAddon(String id, JSONObject json) {
     if (addons == null) {
       throw new IllegalStateException("Cannot incrementally update add-ons without first initializing.");
     }
     try {
       addons.put(id, json);
+      needsWrite = true;
     } catch (Exception e) {
       // Why would this happen?
       Logger.warn(LOG_TAG, "Unexpected failure updating JSON for add-on.", e);
     }
   }
 
   /**
    * Update the cached set of add-ons. Throws on invalid input.
    *
    * @param json a valid add-ons JSON string.
    */
   public void setJSONForAddons(String json) throws Exception {
     addons = new JSONObject(json);
+    needsWrite = true;
   }
 
   public void setJSONForAddons(JSONObject json) {
     addons = json;
+    needsWrite = true;
   }
 }
--- a/mobile/android/services/java-sources.mn
+++ b/mobile/android/services/java-sources.mn
@@ -22,16 +22,17 @@ background/common/log/writers/SimpleTagL
 background/common/log/writers/StringLogWriter.java
 background/common/log/writers/TagLogWriter.java
 background/common/log/writers/ThreadLocalTagLogWriter.java
 background/datareporting/TelemetryRecorder.java
 background/db/CursorDumper.java
 background/db/Tab.java
 background/healthreport/Environment.java
 background/healthreport/EnvironmentBuilder.java
+background/healthreport/EnvironmentV1.java
 background/healthreport/HealthReportBroadcastReceiver.java
 background/healthreport/HealthReportBroadcastService.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
--- a/mobile/android/tests/background/junit3/src/healthreport/MockDatabaseEnvironment.java
+++ b/mobile/android/tests/background/junit3/src/healthreport/MockDatabaseEnvironment.java
@@ -33,36 +33,44 @@ public class MockDatabaseEnvironment ext
     }
 
     @Override
     public String toString() {
       return appended.toString();
     }
   }
 
-  public MockDatabaseEnvironment mockInit(String version) {
+  public MockDatabaseEnvironment mockInit(String appVersion) {
     profileCreation = 1234;
     cpuCount        = 2;
     memoryMB        = 512;
 
     isBlocklistEnabled = 1;
     isTelemetryEnabled = 1;
     extensionCount     = 0;
     pluginCount        = 0;
     themeCount         = 0;
 
     architecture    = "";
     sysName         = "";
     sysVersion      = "";
     vendor          = "";
     appName         = "";
     appID           = "";
-    appVersion      = version;
+    this.appVersion = appVersion;
     appBuildID      = "";
     platformVersion = "";
     platformBuildID = "";
     os              = "";
     xpcomabi        = "";
     updateChannel   = "";
 
+    // v2 fields.
+    distribution  = "";
+    appLocale     = "";
+    osLocale      = "";
+    acceptLangSet = 0;
+
+    version       = Environment.CURRENT_VERSION;
+
     return this;
   }
 }
--- a/mobile/android/tests/background/junit3/src/healthreport/TestHealthReportGenerator.java
+++ b/mobile/android/tests/background/junit3/src/healthreport/TestHealthReportGenerator.java
@@ -10,16 +10,20 @@ import java.util.Iterator;
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.background.common.DateUtils;
 import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
 import org.mozilla.gecko.background.helpers.FakeProfileTestCase;
 
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.util.SparseArray;
+
 public class TestHealthReportGenerator extends FakeProfileTestCase {
   @SuppressWarnings("static-method")
   public void testOptObject() throws JSONException {
     JSONObject o = new JSONObject();
     o.put("foo", JSONObject.NULL);
     assertEquals(null, o.optJSONObject("foo"));
   }
 
@@ -52,19 +56,24 @@ public class TestHealthReportGenerator e
     assertEquals(1, foo.getInt("b"));
     assertEquals(1, foo.getInt("c"));
     assertFalse(foo.has("d"));
     assertEquals(1, bar.getInt("a"));
     assertEquals(1, bar.getInt("d"));
     assertFalse(bar.has("b"));
   }
 
+  // We don't initialize the env in testHashing, so these are just the default
+  // values for the Java types, in order.
   private static final String EXPECTED_MOCK_BASE_HASH = "000nullnullnullnullnullnullnull"
                                                         + "nullnullnullnullnullnull00000";
 
+  // v2 fields.
+  private static final String EXPECTED_MOCK_BASE_HASH_SUFFIX = "null" + "null" + 0 + "null";
+
   public void testHashing() throws JSONException {
     MockHealthReportDatabaseStorage storage = new MockHealthReportDatabaseStorage(context, fakeProfileDirectory);
     MockDatabaseEnvironment env = new MockDatabaseEnvironment(storage, MockDatabaseEnvironment.MockEnvironmentAppender.class);
     env.addons = new JSONObject();
 
     String addonAHash = "{addonA}={appDisabled==falseforeignInstall==false"
         + "hasBinaryComponents==falseinstallDay==15269scope==1"
         + "type==extensionupdateDay==15602userDisabled==false"
@@ -91,20 +100,20 @@ public class TestHealthReportGenerator e
         "\"type\": \"extension\", " +
         "\"scope\": 1, " +
         "\"appDisabled\": false, " +
         "\"version\": \"1.10\", " +
         "\"updateDay\": 15602 " +
     "}");
     env.addons.put("{addonA}", addonA1);
 
-    assertEquals(EXPECTED_MOCK_BASE_HASH + addonAHash, env.getHash());
+    assertEquals(EXPECTED_MOCK_BASE_HASH + addonAHash + EXPECTED_MOCK_BASE_HASH_SUFFIX, env.getHash());
 
     env.addons.put("{addonA}", addonA1rev);
-    assertEquals(EXPECTED_MOCK_BASE_HASH + addonAHash, env.getHash());
+    assertEquals(EXPECTED_MOCK_BASE_HASH + addonAHash + EXPECTED_MOCK_BASE_HASH_SUFFIX, env.getHash());
   }
 
   private void assertJSONDiff(JSONObject source, JSONObject diff) throws JSONException {
     assertEquals(source.get("a"), diff.get("a"));
     assertFalse(diff.has("b"));
     assertEquals(source.get("c"), diff.get("c"));
     JSONObject diffD = diff.getJSONObject("d");
     assertFalse(diffD.has("aa"));
@@ -401,9 +410,108 @@ public class TestHealthReportGenerator e
     assertEquals(JSONObject.NULL, discreteJSON.get(1));
     assertEquals("bar", discreteJSON.getJSONObject(2).getString("foo"));
   }
 
   @Override
   protected String getCacheSuffix() {
     return File.separator + "health-" + System.currentTimeMillis() + ".profile";
   }
+
+
+  public void testEnvironmentDiffing() throws JSONException {
+    // Manually insert a v1 environment.
+    final MockHealthReportDatabaseStorage storage = new MockHealthReportDatabaseStorage(context, fakeProfileDirectory);
+    final SQLiteDatabase db = storage.getDB();
+    storage.deleteEverything();
+    final MockDatabaseEnvironment v1env = storage.getEnvironment();
+    v1env.mockInit("27.0a1");
+    v1env.version = 1;
+    v1env.appLocale = "";
+    v1env.osLocale  = "";
+    v1env.distribution = "";
+    v1env.acceptLangSet = 0;
+    final int v1ID = v1env.register();
+
+    // Verify.
+    final String[] cols = new String[] {
+      "id", "version", "hash",
+      "osLocale", "acceptLangSet", "appLocale", "distribution"
+    };
+
+    final Cursor c1 = db.query("environments", cols, "id = " + v1ID, null, null, null, null);
+    String v1envHash;
+    try {
+      assertTrue(c1.moveToFirst());
+      assertEquals(1, c1.getCount());
+
+      assertEquals(v1ID, c1.getInt(0));
+      assertEquals(1,    c1.getInt(1));
+
+      v1envHash = c1.getString(2);
+      assertNotNull(v1envHash);
+      assertEquals("", c1.getString(3));
+      assertEquals(0,  c1.getInt(4));
+      assertEquals("", c1.getString(5));
+      assertEquals("", c1.getString(6));
+    } finally {
+      c1.close();
+    }
+
+    // Insert a v2 environment.
+    final MockDatabaseEnvironment v2env = storage.getEnvironment();
+    v2env.mockInit("27.0a1");
+    v2env.appLocale = v2env.osLocale = "en_us";
+    v2env.acceptLangSet = 1;
+
+    final int v2ID = v2env.register();
+    assertFalse(v1ID == v2ID);
+    final Cursor c2 = db.query("environments", cols, "id = " + v2ID, null, null, null, null);
+    String v2envHash;
+    try {
+      assertTrue(c2.moveToFirst());
+      assertEquals(1, c2.getCount());
+
+      assertEquals(v2ID, c2.getInt(0));
+      assertEquals(2,    c2.getInt(1));
+
+      v2envHash = c2.getString(2);
+      assertNotNull(v2envHash);
+      assertEquals("en_us", c2.getString(3));
+      assertEquals(1,       c2.getInt(4));
+      assertEquals("en_us", c2.getString(5));
+      assertEquals("",      c2.getString(6));
+    } finally {
+      c2.close();
+    }
+
+    assertFalse(v1envHash.equals(v2envHash));
+
+    // Now let's diff based on DB contents.
+    SparseArray<Environment> envs = storage.getEnvironmentRecordsByID();
+
+    JSONObject oldEnv = HealthReportGenerator.jsonify(envs.get(v1ID), null).getJSONObject("org.mozilla.appInfo.appinfo");
+    JSONObject newEnv = HealthReportGenerator.jsonify(envs.get(v2ID), null).getJSONObject("org.mozilla.appInfo.appinfo");
+
+    // Generate the new env as if the old were the current. This should rarely happen in practice.
+    // Fields supported by the new env but not the old will appear, even if the 'default' for the
+    // old implementation is equal to the new env's value.
+    JSONObject newVsOld = HealthReportGenerator.jsonify(envs.get(v2ID), envs.get(v1ID)).getJSONObject("org.mozilla.appInfo.appinfo");
+
+    // Generate the old env as if the new were the current. This is normal. Fields not supported by the old
+    // environment version should not appear in the output.
+    JSONObject oldVsNew = HealthReportGenerator.jsonify(envs.get(v1ID), envs.get(v2ID)).getJSONObject("org.mozilla.appInfo.appinfo");
+    assertEquals(2, oldEnv.getInt("_v"));
+    assertEquals(3, newEnv.getInt("_v"));
+    assertEquals(2, oldVsNew.getInt("_v"));
+    assertEquals(3, newVsOld.getInt("_v"));
+
+    assertFalse(oldVsNew.has("osLocale"));
+    assertFalse(oldVsNew.has("appLocale"));
+    assertFalse(oldVsNew.has("distribution"));
+    assertFalse(oldVsNew.has("acceptLangIsUserSet"));
+
+    assertTrue(newVsOld.has("osLocale"));
+    assertTrue(newVsOld.has("appLocale"));
+    assertTrue(newVsOld.has("distribution"));
+    assertTrue(newVsOld.has("acceptLangIsUserSet"));
+  }
 }
--- a/mobile/android/tests/background/junit3/src/healthreport/TestHealthReportProvider.java
+++ b/mobile/android/tests/background/junit3/src/healthreport/TestHealthReportProvider.java
@@ -141,17 +141,17 @@ public class TestHealthReportProvider ex
     ensureMeasurementCount(1);
     ensureFieldCount(4);
 
     final Uri envURI = resolver.insert(getCompleteUri("/environments/"), getTestEnvContentValues());
     String envHash = null;
     Cursor envCursor = resolver.query(envURI, null, null, null, null);
     try {
       assertTrue(envCursor.moveToFirst());
-      envHash = envCursor.getString(1);
+      envHash = envCursor.getString(2);      // id, version, hash, ...
     } finally {
       envCursor.close();
     }
 
     final Uri eventURI = HealthReportUtils.getEventURI(envURI);
 
     Uri discrete1 = eventURI.buildUpon().appendEncodedPath("testm1/1/discrete1").build();
     Uri counter1 = eventURI.buildUpon().appendEncodedPath("testm1/1/counter1/counter").build();
@@ -244,11 +244,18 @@ public class TestHealthReportProvider ex
     v.put("appID", "");
     v.put("appVersion", "");
     v.put("appBuildID", "");
     v.put("platformVersion", "");
     v.put("platformBuildID", "");
     v.put("os", "");
     v.put("xpcomabi", "");
     v.put("updateChannel", "");
+
+    // v2.
+    v.put("distribution", "");
+    v.put("osLocale", "en_us");
+    v.put("appLocale", "en_us");
+    v.put("acceptLangSet", 0);
+
     return v;
   }
 }