Bug 828654, Bug 840127 - Android service for submitting Firefox Health Report payloads. r=rnewman
authorNick Alexander <nalexander@mozilla.com>
Wed, 12 Jun 2013 16:17:47 -0700
changeset 146372 4ae3e1cede0a3495431e75f6ee7515e7a417926f
parent 146371 a4e3ffaf77430252e1f6961a702368b37da8f769
child 146373 0c890f39dfa6cd1ff1f0fabe7a3242bb8ab84230
push id2697
push userbbajaj@mozilla.com
push dateMon, 05 Aug 2013 18:49:53 +0000
treeherdermozilla-beta@dfec938c7b63 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs828654, 840127
milestone24.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 828654, Bug 840127 - Android service for submitting Firefox Health Report payloads. r=rnewman
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/GeckoApp.java
mobile/android/base/GeckoPreferences.java
mobile/android/base/android-services-files.mk
mobile/android/base/background/BackgroundService.java
mobile/android/base/background/announcements/AnnouncementsBroadcastService.java
mobile/android/base/background/bagheera/BagheeraClient.java
mobile/android/base/background/bagheera/BagheeraRequestDelegate.java
mobile/android/base/background/common/GlobalConstants.java.in
mobile/android/base/background/healthreport/HealthReportConstants.java.in
mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
mobile/android/base/background/healthreport/HealthReportStorage.java
mobile/android/base/background/healthreport/HealthReportUtils.java
mobile/android/base/background/healthreport/upload/AndroidSubmissionClient.java
mobile/android/base/background/healthreport/upload/HealthReportBroadcastReceiver.java
mobile/android/base/background/healthreport/upload/HealthReportBroadcastService.java
mobile/android/base/background/healthreport/upload/HealthReportUploadService.java
mobile/android/base/background/healthreport/upload/HealthReportUploadStartReceiver.java
mobile/android/base/background/healthreport/upload/SubmissionClient.java
mobile/android/base/background/healthreport/upload/SubmissionPolicy.java
mobile/android/base/sync/ExtendedJSONObject.java
mobile/android/services/java-sources.mn
mobile/android/services/manifests/AnnouncementsAndroidManifest_permissions.xml.in
mobile/android/services/manifests/AnnouncementsAndroidManifest_services.xml.in
mobile/android/services/manifests/HealthReportAndroidManifest_activities.xml.in
mobile/android/services/manifests/HealthReportAndroidManifest_permissions.xml.in
mobile/android/services/manifests/HealthReportAndroidManifest_services.xml.in
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -8,16 +8,17 @@
 #ifdef MOZ_ANDROID_SHARED_ID
       android:sharedUserId="@MOZ_ANDROID_SHARED_ID@"
 #endif
       >
     <uses-sdk android:minSdkVersion="8"
               android:targetSdkVersion="14"/>
 
 #include ../services/manifests/AnnouncementsAndroidManifest_permissions.xml.in
+#include ../services/manifests/HealthReportAndroidManifest_permissions.xml.in
 #include ../services/manifests/SyncAndroidManifest_permissions.xml.in
 
     <uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
     <uses-permission android:name="android.permission.INTERNET"/>
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -273,17 +274,19 @@
 
         <service
             android:exported="false"
             android:name="org.mozilla.gecko.NotificationService">
         </service>
 
 
 #include ../services/manifests/AnnouncementsAndroidManifest_services.xml.in
+#include ../services/manifests/HealthReportAndroidManifest_services.xml.in
 #include ../services/manifests/SyncAndroidManifest_services.xml.in
+
     </application>
 
     <permission android:name="@ANDROID_PACKAGE_NAME@.permissions.BROWSER_PROVIDER"
                 android:protectionLevel="signature"/>
 
     <permission android:name="@ANDROID_PACKAGE_NAME@.permissions.PASSWORD_PROVIDER"
                 android:protectionLevel="signature"/>
 
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -1519,20 +1519,22 @@ abstract public class GeckoApp
                 // no hurry in starting this.
                 checkMigrateSync();
 
                 // Record our launch time for the announcements service
                 // to use in assessing inactivity.
                 final Context context = GeckoApp.sAppContext;
                 AnnouncementsBroadcastService.recordLastLaunch(context);
 
-                // Kick off our background service to fetch product announcements.
-                // We do this by invoking the broadcast receiver, which uses the
-                // system alarm infrastructure to perform tasks at intervals.
+                // Kick off our background services that fetch product
+                // announcements and upload health reports.  We do this by
+                // invoking the broadcast receiver, which uses the system alarm
+                // infrastructure to perform tasks at intervals.
                 GeckoPreferences.broadcastAnnouncementsPref(context);
+                GeckoPreferences.broadcastHealthReportUploadPref(context);
 
                 /*
                   XXXX see bug 635342
                    We want to disable this code if possible.  It is about 145ms in runtime
                 SharedPreferences settings = getPreferences(Activity.MODE_PRIVATE);
                 String localeCode = settings.getString(getPackageName() + ".locale", "");
                 if (localeCode != null && localeCode.length() > 0)
                     GeckoAppShell.setSelectedLocale(localeCode);
--- a/mobile/android/base/GeckoPreferences.java
+++ b/mobile/android/base/GeckoPreferences.java
@@ -2,16 +2,17 @@
  * 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;
 
 import org.mozilla.gecko.background.announcements.AnnouncementsConstants;
 import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.GeckoPreferenceFragment;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import org.json.JSONArray;
 import org.json.JSONObject;
 
 import android.app.AlertDialog;
@@ -298,16 +299,27 @@ public class GeckoPreferences
                                            final String action,
                                            final String pref,
                                            final boolean value) {
         final Intent intent = new Intent(action);
         intent.setAction(action);
         intent.putExtra("pref", pref);
         intent.putExtra("branch", GeckoApp.PREFS_NAME);
         intent.putExtra("enabled", value);
+
+        // There is a race here, but GeckoProfile returns the default profile
+        // when Gecko is not explicitly running for a different profile.  In a
+        // multi-profile world, this will need to be updated (possibly to
+        // broadcast settings for all profiles).  See Bug 882182.
+        GeckoProfile profile = GeckoProfile.get(context);
+        if (profile != null) {
+            intent.putExtra("profileName", profile.getName());
+            intent.putExtra("profilePath", profile.getDir().getAbsolutePath());
+        }
+
         Log.d(LOGTAG, "Broadcast: " + action + ", " + pref + ", " + GeckoApp.PREFS_NAME + ", " + value);
         context.sendBroadcast(intent, GlobalConstants.PER_ANDROID_PACKAGE_PERMISSION);
     }
 
     /**
      * Broadcast the provided value as the value of the
      * <code>PREFS_ANNOUNCEMENTS_ENABLED</code> pref.
      */
@@ -323,16 +335,36 @@ public class GeckoPreferences
      * <code>PREFS_ANNOUNCEMENTS_ENABLED</code> pref.
      */
     public static void broadcastAnnouncementsPref(final Context context) {
         final boolean value = getBooleanPref(context, PREFS_ANNOUNCEMENTS_ENABLED, true);
         broadcastAnnouncementsPref(context, value);
     }
 
     /**
+     * Broadcast the provided value as the value of the
+     * <code>PREFS_HEALTHREPORT_UPLOAD_ENABLED</code> pref.
+     */
+    public static void broadcastHealthReportUploadPref(final Context context, final boolean value) {
+        broadcastPrefAction(context,
+                            HealthReportConstants.ACTION_HEALTHREPORT_UPLOAD_PREF,
+                            PREFS_HEALTHREPORT_UPLOAD_ENABLED,
+                            value);
+    }
+
+    /**
+     * Broadcast the current value of the
+     * <code>PREFS_HEALTHREPORT_UPLOAD_ENABLED</code> pref.
+     */
+    public static void broadcastHealthReportUploadPref(final Context context) {
+        final boolean value = getBooleanPref(context, PREFS_HEALTHREPORT_UPLOAD_ENABLED, true);
+        broadcastHealthReportUploadPref(context, value);
+    }
+
+    /**
      * Return the value of the named preference in the default preferences file.
      *
      * This corresponds to the storage that backs preferences.xml.
      * @param context a <code>Context</code>; the
      *                <code>PreferenceActivity</code> will suffice, but this
      *                method is intended to be called from other contexts
      *                within the application, not just this <code>Activity</code>.
      * @param name    the name of the preference to retrieve.
@@ -354,17 +386,21 @@ public class GeckoPreferences
             setCharEncodingState(((String) newValue).equals("true"));
         } else if (PREFS_ANNOUNCEMENTS_ENABLED.equals(prefName)) {
             // Send a broadcast intent to the product announcements service, either to start or
             // to stop the repeated background checks.
             broadcastAnnouncementsPref(GeckoAppShell.getContext(), ((Boolean) newValue).booleanValue());
         } else if (PREFS_UPDATER_AUTODOWNLOAD.equals(prefName)) {
             org.mozilla.gecko.updater.UpdateServiceHelper.registerForUpdates(GeckoAppShell.getContext(), (String) newValue);
         } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(prefName)) {
-            // Healthreport pref only lives in Android. Do not persist to Gecko.
+            // The healthreport pref only lives in Android, so we do not persist
+            // to Gecko, but we do broadcast intent to the health report
+            // background uploader service, which will start or stop the
+            // repeated background upload attempts.
+            broadcastHealthReportUploadPref(GeckoAppShell.getContext(), ((Boolean) newValue).booleanValue());
             return true;
         }
 
         if (!TextUtils.isEmpty(prefName)) {
             PrefsHelper.setPref(prefName, newValue);
         }
         if (preference instanceof ListPreference) {
             // We need to find the entry for the new value
--- a/mobile/android/base/android-services-files.mk
+++ b/mobile/android/base/android-services-files.mk
@@ -41,16 +41,23 @@ SYNC_JAVA_FILES := \
   background/healthreport/EnvironmentBuilder.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 \
   background/healthreport/ProfileInformationCache.java \
+  background/healthreport/upload/AndroidSubmissionClient.java \
+  background/healthreport/upload/HealthReportBroadcastReceiver.java \
+  background/healthreport/upload/HealthReportBroadcastService.java \
+  background/healthreport/upload/HealthReportUploadService.java \
+  background/healthreport/upload/HealthReportUploadStartReceiver.java \
+  background/healthreport/upload/SubmissionClient.java \
+  background/healthreport/upload/SubmissionPolicy.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 \
--- a/mobile/android/base/background/BackgroundService.java
+++ b/mobile/android/base/background/BackgroundService.java
@@ -1,14 +1,17 @@
 /* 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;
 
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
 import org.mozilla.gecko.background.common.log.Logger;
 
 import android.app.AlarmManager;
 import android.app.IntentService;
 import android.app.PendingIntent;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -72,9 +75,43 @@ public abstract class BackgroundService 
     final long firstEvent = System.currentTimeMillis();
     alarm.setInexactRepeating(AlarmManager.RTC, firstEvent, pollInterval, pendingIntent);
   }
 
   protected void cancelAlarm(PendingIntent pendingIntent) {
     final AlarmManager alarm = getAlarmManager();
     alarm.cancel(pendingIntent);
   }
+
+  /**
+   * To avoid tight coupling to Fennec, we use reflection to find
+   * <code>GeckoPreferences</code>, invoking the same code path that
+   * <code>GeckoApp</code> uses on startup to send the <i>other</i>
+   * notification to which we listen.
+   *
+   * Invoke this to handle one of the system intents to which we listen to
+   * launch our service without the browser being opened.
+   *
+   * All of this is neatly wrapped in <code>try…catch</code>, so this code
+   * will run safely without a Firefox build installed.
+   */
+  protected static void reflectContextToFennec(Context context, String className, String methodName) {
+    // Ask the browser to tell us the current state of the preference.
+    try {
+      Class<?> geckoPreferences = Class.forName(className);
+      Method broadcastSnippetsPref = geckoPreferences.getMethod(methodName, Context.class);
+      broadcastSnippetsPref.invoke(null, context);
+      return;
+    } catch (ClassNotFoundException e) {
+      Logger.error(LOG_TAG, "Class " + className + " not found!");
+      return;
+    } catch (NoSuchMethodException e) {
+      Logger.error(LOG_TAG, "Method " + className + "/" + methodName + " not found!");
+      return;
+    } catch (IllegalArgumentException e) {
+      Logger.error(LOG_TAG, "Got exception invoking " + methodName + ".");
+    } catch (IllegalAccessException e) {
+      Logger.error(LOG_TAG, "Got exception invoking " + methodName + ".");
+    } catch (InvocationTargetException e) {
+      Logger.error(LOG_TAG, "Got exception invoking " + methodName + ".");
+    }
+  }
 }
--- a/mobile/android/base/background/announcements/AnnouncementsBroadcastService.java
+++ b/mobile/android/base/background/announcements/AnnouncementsBroadcastService.java
@@ -1,17 +1,14 @@
 /* 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.announcements;
 
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-
 import org.mozilla.gecko.background.BackgroundService;
 import org.mozilla.gecko.background.common.GlobalConstants;
 import org.mozilla.gecko.background.common.log.Logger;
 
 import android.app.AlarmManager;
 import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
@@ -113,59 +110,27 @@ public class AnnouncementsBroadcastServi
 
     if (AnnouncementsConstants.ACTION_ANNOUNCEMENTS_PREF.equals(action)) {
       handlePrefIntent(intent);
       return;
     }
 
     if (Intent.ACTION_BOOT_COMPLETED.equals(action) ||
         Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE.equals(action)) {
-      handleSystemLifetimeIntent();
+      BackgroundService.reflectContextToFennec(this,
+          GlobalConstants.GECKO_PREFERENCES_CLASS,
+          GlobalConstants.GECKO_BROADCAST_ANNOUNCEMENTS_PREF_METHOD);
       return;
     }
 
     // Failure case.
     Logger.warn(LOG_TAG, "Unknown intent " + action);
   }
 
   /**
-   * Handle one of the system intents to which we listen to launch our service
-   * without the browser being opened.
-   *
-   * To avoid tight coupling to Fennec, we use reflection to find
-   * <code>GeckoPreferences</code>, invoking the same code path that
-   * <code>GeckoApp</code> uses on startup to send the <i>other</i>
-   * notification to which we listen.
-   *
-   * All of this is neatly wrapped in <code>try…catch</code>, so this code
-   * will run safely without a Firefox build installed.
-   */
-  protected void handleSystemLifetimeIntent() {
-    // Ask the browser to tell us the current state of the preference.
-    try {
-      Class<?> geckoPreferences = Class.forName(GlobalConstants.GECKO_PREFERENCES_CLASS);
-      Method broadcastSnippetsPref = geckoPreferences.getMethod(GlobalConstants.GECKO_BROADCAST_METHOD, Context.class);
-      broadcastSnippetsPref.invoke(null, this);
-      return;
-    } catch (ClassNotFoundException e) {
-      Logger.error(LOG_TAG, "Class " + GlobalConstants.GECKO_PREFERENCES_CLASS + " not found!");
-      return;
-    } catch (NoSuchMethodException e) {
-      Logger.error(LOG_TAG, "Method " + GlobalConstants.GECKO_PREFERENCES_CLASS + "/" + GlobalConstants.GECKO_BROADCAST_METHOD + " not found!");
-      return;
-    } catch (IllegalArgumentException e) {
-      Logger.error(LOG_TAG, "Got exception invoking " + GlobalConstants.GECKO_BROADCAST_METHOD + ".");
-    } catch (IllegalAccessException e) {
-      Logger.error(LOG_TAG, "Got exception invoking " + GlobalConstants.GECKO_BROADCAST_METHOD + ".");
-    } catch (InvocationTargetException e) {
-      Logger.error(LOG_TAG, "Got exception invoking " + GlobalConstants.GECKO_BROADCAST_METHOD + ".");
-    }
-  }
-
-  /**
    * Handle the intent sent by the browser when it wishes to notify us
    * of the value of the user preference. Look at the value and toggle the
    * alarm service accordingly.
    */
   protected void handlePrefIntent(Intent intent) {
     if (!intent.hasExtra("enabled")) {
       Logger.warn(LOG_TAG, "Got ANNOUNCEMENTS_PREF intent without enabled. Ignoring.");
       return;
--- a/mobile/android/base/background/bagheera/BagheeraClient.java
+++ b/mobile/android/base/background/bagheera/BagheeraClient.java
@@ -83,17 +83,17 @@ public class BagheeraClient {
     if (namespace == null) {
       throw new IllegalArgumentException("Must provide namespace.");
     }
     if (id == null) {
       throw new IllegalArgumentException("Must provide id.");
     }
 
     final BaseResource resource = makeResource(namespace, id);
-    resource.delegate = new BagheeraResourceDelegate(resource, delegate);
+    resource.delegate = new BagheeraResourceDelegate(resource, namespace, id, delegate);
     resource.delete();
   }
 
   /**
    * Upload a JSON document to a Bagheera server. The delegate's callbacks will
    * be invoked in tasks run by the client's executor.
    *
    * @param namespace
@@ -121,17 +121,17 @@ public class BagheeraClient {
     }
     if (payload == null) {
       throw new IllegalArgumentException("Must provide payload.");
     }
 
     final BaseResource resource = makeResource(namespace, id);
     final HttpEntity deflatedBody = DeflateHelper.deflateBody(payload);
 
-    resource.delegate = new BagheeraUploadResourceDelegate(resource, oldID, delegate);
+    resource.delegate = new BagheeraUploadResourceDelegate(resource, namespace, id, oldID, delegate);
     resource.post(deflatedBody);
   }
 
   public static boolean isValidURIComponent(final String in) {
     return URI_PATTERN.matcher(in).matches();
   }
 
   protected BaseResource makeResource(final String namespace, final String id) throws URISyntaxException {
@@ -145,25 +145,27 @@ public class BagheeraClient {
 
     final String uri = this.serverURI + PROTOCOL_VERSION + SUBMIT_PATH +
                        namespace + "/" + id;
     return new BaseResource(uri);
   }
 
   public class BagheeraResourceDelegate extends BaseResourceDelegate {
     private static final int DEFAULT_SOCKET_TIMEOUT_MSEC = 5 * 60 * 1000;       // Five minutes.
-    protected BagheeraRequestDelegate delegate;
-
-    public BagheeraResourceDelegate(Resource resource) {
-      super(resource);
-    }
+    protected final BagheeraRequestDelegate delegate;
+    protected final String namespace;
+    protected final String id;
 
     public BagheeraResourceDelegate(final Resource resource,
+                                    final String namespace,
+                                    final String id,
                                     final BagheeraRequestDelegate delegate) {
-      this(resource);
+      super(resource);
+      this.namespace = namespace;
+      this.id = id;
       this.delegate = delegate;
     }
 
     @Override
     public int socketTimeout() {
       return DEFAULT_SOCKET_TIMEOUT_MSEC;
     }
 
@@ -188,26 +190,26 @@ public class BagheeraClient {
         }
       });
     }
 
     protected void invokeHandleFailure(final int status, final HttpResponse response) {
       executor.execute(new Runnable() {
         @Override
         public void run() {
-          delegate.handleFailure(status, response);
+          delegate.handleFailure(status, namespace, response);
         }
       });
     }
 
     protected void invokeHandleSuccess(final int status, final HttpResponse response) {
       executor.execute(new Runnable() {
         @Override
         public void run() {
-          delegate.handleSuccess(status, response);
+          delegate.handleSuccess(status, namespace, id, response);
         }
       });
     }
 
     @Override
     public void handleHttpProtocolException(final ClientProtocolException e) {
       invokeHandleError(e);
     }
@@ -224,19 +226,21 @@ public class BagheeraClient {
   }
 
   public final class BagheeraUploadResourceDelegate extends BagheeraResourceDelegate {
     private static final String HEADER_OBSOLETE_DOCUMENT = "X-Obsolete-Document";
     private static final String COMPRESSED_CONTENT_TYPE = "application/json+zlib; charset=utf-8";
     protected String obsoleteDocumentID;
 
     public BagheeraUploadResourceDelegate(Resource resource,
-                                          String obsoleteDocumentID,
-                                          BagheeraRequestDelegate delegate) {
-      super(resource, delegate);
+        String namespace,
+        String id,
+        String obsoleteDocumentID,
+        BagheeraRequestDelegate delegate) {
+      super(resource, namespace, id, delegate);
       this.obsoleteDocumentID = obsoleteDocumentID;
     }
 
     @Override
     public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
       super.addHeaders(request, client);
       request.setHeader(HTTP.CONTENT_TYPE, COMPRESSED_CONTENT_TYPE);
       if (this.obsoleteDocumentID != null) {
--- a/mobile/android/base/background/bagheera/BagheeraRequestDelegate.java
+++ b/mobile/android/base/background/bagheera/BagheeraRequestDelegate.java
@@ -2,12 +2,12 @@
  * 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.bagheera;
 
 import ch.boye.httpclientandroidlib.HttpResponse;
 
 public interface BagheeraRequestDelegate {
-  void handleSuccess(int status, HttpResponse response);
+  void handleSuccess(int status, String namespace, String id, HttpResponse response);
   void handleError(Exception e);
-  void handleFailure(int status, HttpResponse response);
+  void handleFailure(int status, String namespace, HttpResponse response);
 }
--- a/mobile/android/base/background/common/GlobalConstants.java.in
+++ b/mobile/android/base/background/common/GlobalConstants.java.in
@@ -32,16 +32,18 @@ public class GlobalConstants {
   /**
    * Bug 800244: this signing-level permission protects broadcast intents that
    * should be received only by the Firefox versions with the given Android
    * package name.
    */
   public static final String PER_ANDROID_PACKAGE_PERMISSION = "@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE";
 
   public static final int SHARED_PREFERENCES_MODE = 0;
+
   // These are used to ask Fennec (via reflection) to send
   // us a pref notification. This avoids us having to guess
   // Fennec's prefs branch and pref name.
   // Eventually Fennec might listen to startup notifications and
   // do this automatically, but this will do for now. See Bug 800244.
   public static String GECKO_PREFERENCES_CLASS = "org.mozilla.gecko.GeckoPreferences";
-  public static String GECKO_BROADCAST_METHOD  = "broadcastAnnouncementsPref";
+  public static String GECKO_BROADCAST_ANNOUNCEMENTS_PREF_METHOD  = "broadcastAnnouncementsPref";
+  public static String GECKO_BROADCAST_HEALTHREPORT_UPLOAD_PREF_METHOD  = "broadcastHealthReportUploadPref";
 }
--- a/mobile/android/base/background/healthreport/HealthReportConstants.java.in
+++ b/mobile/android/base/background/healthreport/HealthReportConstants.java.in
@@ -3,16 +3,79 @@
  * 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;
+
+  public static final long MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+  public static final long MILLISECONDS_PER_SIX_MONTHS = 180 * MILLISECONDS_PER_DAY;
+
+  // Not `final` so we have the option to turn this on at runtime with a magic addon.
+  public static boolean UPLOAD_FEATURE_DISABLED = false;
+
+  // Android SharedPreferences branch where global (not per-profile) uploader
+  // settings are stored.
+  public static final String PREFS_BRANCH = "background";
+
+  // How frequently the submission policy is ticked over. This is how frequently our
+  // intent is scheduled to be called by the Android Alarm Manager, not how
+  // frequently we actually submit.
+  public static final String PREF_SUBMISSION_INTENT_INTERVAL_MSEC = "healthreport_submission_intent_interval_msec";
+  public static final long DEFAULT_SUBMISSION_INTENT_INTERVAL_MSEC = MILLISECONDS_PER_DAY / 24;
+
+  public static final String ACTION_HEALTHREPORT_UPLOAD_PREF = "@ANDROID_PACKAGE_NAME@.HEALTHREPORT_UPLOAD_PREF";
+
+  public static final String PREF_MINIMUM_TIME_BETWEEN_UPLOADS = "healthreport_time_between_uploads";
+  public static final long DEFAULT_MINIMUM_TIME_BETWEEN_UPLOADS = MILLISECONDS_PER_DAY;
+
+  public static final String PREF_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION = "healthreport_time_before_first_submission";
+  public static final long DEFAULT_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION = MILLISECONDS_PER_DAY;
+
+  public static final String PREF_MINIMUM_TIME_AFTER_FAILURE = "healthreport_time_after_failure";
+  public static final long DEFAULT_MINIMUM_TIME_AFTER_FAILURE = DEFAULT_SUBMISSION_INTENT_INTERVAL_MSEC;
+
+  public static final String PREF_MAXIMUM_FAILURES_PER_DAY = "healthreport_maximum_failures_per_day";
+  public static final long DEFAULT_MAXIMUM_FAILURES_PER_DAY = 2;
+
+  // Authoritative.
+  public static final String PREF_FIRST_RUN = "healthreport_first_run";
+  public static final String PREF_NEXT_SUBMISSION = "healthreport_next_submission";
+  public static final String PREF_CURRENT_DAY_FAILURE_COUNT = "healthreport_current_day_failure_count";
+
+  // Forensic.
+  public static final String PREF_LAST_UPLOAD_REQUESTED = "healthreport_last_upload_requested";
+  public static final String PREF_LAST_UPLOAD_SUCCEEDED = "healthreport_last_upload_succeeded";
+  public static final String PREF_LAST_UPLOAD_FAILED = "healthreport_last_upload_failed";
+
+  // Preferences for deleting obsolete documents.
+  public static final String PREF_MINIMUM_TIME_BETWEEN_DELETES = "healthreport_time_between_deletes";
+  public static final long DEFAULT_MINIMUM_TIME_BETWEEN_DELETES = DEFAULT_SUBMISSION_INTENT_INTERVAL_MSEC;
+
+  public static final String PREF_OBSOLETE_DOCUMENT_IDS_TO_DELETION_ATTEMPTS_REMAINING = "healthreport_obsolete_document_ids_to_deletions_remaining";
+
+  public static final String PREF_DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID = "healthreport_deletion_attempts_per_obsolete_document_id";
+  public static final long DEFAULT_DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID = 5;
+
+  // Forensic.
+  public static final String PREF_LAST_DELETE_REQUESTED = "healthreport_last_delete_requested";
+  public static final String PREF_LAST_DELETE_SUCCEEDED = "healthreport_last_delete_succeeded";
+  public static final String PREF_LAST_DELETE_FAILED = "healthreport_last_delete_failed";
+
+  // Preferences for upload client.
+  public static final String PREF_LAST_UPLOAD_LOCAL_TIME  = "healthreport_last_upload_local_time";
+  public static final String PREF_LAST_UPLOAD_DOCUMENT_ID  = "healthreport_last_upload_document_id";
+
+  public static final String PREF_DOCUMENT_SERVER_URI = "healthreport_document_server_uri";
+  public static final String DEFAULT_DOCUMENT_SERVER_URI = "https://fhr.data.mozilla.com/";
+
+  public static final String PREF_DOCUMENT_SERVER_NAMESPACE = "healthreport_document_server_namespace";
+  public static final String DEFAULT_DOCUMENT_SERVER_NAMESPACE = "metrics";
 }
--- a/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
+++ b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
@@ -1138,16 +1138,41 @@ public class HealthReportDatabaseStorage
   }
 
   @Override
   public void incrementDailyCount(int env, int day, int field) {
     this.incrementDailyCount(env, day, field, 1);
   }
 
   /**
+   * Are there events recorded on or after <code>time</code>?
+   *
+   * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
+   * @return true if such events exist, false otherwise.
+   */
+  @Override
+  public boolean hasEventSince(long time) {
+    final int start = this.getDay(time);
+    final SQLiteDatabase db = this.helper.getReadableDatabase();
+    final String dayString = Integer.toString(start, 10);
+    Cursor cur = db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
+        "date >= ?", new String[] {dayString}, null, null, null, "1");
+    if (cur == null) {
+      // Something is horribly wrong; let the caller who actually reads the
+      // events deal with it.
+      return true;
+    }
+    try {
+      return cur.getCount() > 0;
+    } finally {
+      cur.close();
+    }
+  }
+
+  /**
    * Returns a cursor over field events in the database. The results will be
    * strictly ordered first by date, then by environment, and finally by field.
    *
    * Each row includes columns in {@link #COLUMNS_DATE_ENV_FIELD_VALUE}:
    * "date", "env", "field", "value".
    *
    * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
    * @return a cursor. The caller is responsible for closing this.
--- a/mobile/android/base/background/healthreport/HealthReportStorage.java
+++ b/mobile/android/base/background/healthreport/HealthReportStorage.java
@@ -173,16 +173,21 @@ public interface HealthReportStorage {
   public void recordDailyLast(int env, int day, int field, int value);
   public void recordDailyDiscrete(int env, int day, int field, JSONObject 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);
 
   /**
+   * Return true if events exist that were recorded on or after <code>time</code>.
+   */
+  boolean hasEventSince(long time);
+
+  /**
    * 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>.
    *
--- a/mobile/android/base/background/healthreport/HealthReportUtils.java
+++ b/mobile/android/base/background/healthreport/HealthReportUtils.java
@@ -8,25 +8,30 @@ import java.text.SimpleDateFormat;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Locale;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.TimeZone;
 import java.util.TreeSet;
 
+import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
-import org.json.JSONArray;
 import org.mozilla.apache.commons.codec.digest.DigestUtils;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
 
 import android.content.ContentUris;
+import android.content.SharedPreferences;
 import android.net.Uri;
 
 public class HealthReportUtils {
+  public static final String LOG_TAG = HealthReportUtils.class.getSimpleName();
+
   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);
   }
 
@@ -138,9 +143,36 @@ public class HealthReportUtils {
       JSONObject counts = new JSONObject();
       counts.put(value, 1);
       o.put(key, counts);
       return;
     }
     JSONObject dest = o.getJSONObject(key);
     dest.put(value, dest.optInt(value, 0) + 1);
   }
+
+  public static ExtendedJSONObject getObsoleteIds(SharedPreferences sharedPrefs) {
+    String s = sharedPrefs.getString(HealthReportConstants.PREF_OBSOLETE_DOCUMENT_IDS_TO_DELETION_ATTEMPTS_REMAINING, null);
+    if (s == null) {
+      return new ExtendedJSONObject();
+    }
+    try {
+      return ExtendedJSONObject.parseJSONObject(s);
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Got exception getting obsolete ids.", e);
+      return new ExtendedJSONObject();
+    }
+  }
+
+  /**
+   * Write obsolete ids to disk.
+   *
+   * @param sharedPrefs to write to.
+   * @param ids to write.
+   * @return editor.
+   */
+  public static void setObsoleteIds(SharedPreferences sharedPrefs, ExtendedJSONObject ids) {
+    sharedPrefs
+      .edit()
+      .putString(HealthReportConstants.PREF_OBSOLETE_DOCUMENT_IDS_TO_DELETION_ATTEMPTS_REMAINING, ids.toString())
+      .commit();
+  }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/AndroidSubmissionClient.java
@@ -0,0 +1,201 @@
+/* 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.upload;
+
+import java.io.IOException;
+import java.util.UUID;
+
+import org.json.JSONObject;
+import org.mozilla.gecko.background.bagheera.BagheeraClient;
+import org.mozilla.gecko.background.bagheera.BagheeraRequestDelegate;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.EnvironmentBuilder;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage;
+import org.mozilla.gecko.background.healthreport.HealthReportGenerator;
+
+import android.content.ContentProviderClient;
+import android.content.Context;
+import android.content.SharedPreferences;
+import ch.boye.httpclientandroidlib.HttpResponse;
+
+public class AndroidSubmissionClient implements SubmissionClient {
+  protected static final String LOG_TAG = AndroidSubmissionClient.class.getSimpleName();
+
+  protected final Context context;
+  protected final SharedPreferences sharedPreferences;
+  protected final String profilePath;
+
+  public AndroidSubmissionClient(Context context, SharedPreferences sharedPreferences, String profilePath) {
+    this.context = context;
+    this.sharedPreferences = sharedPreferences;
+    this.profilePath = profilePath;
+  }
+
+  public SharedPreferences getSharedPreferences() {
+    return sharedPreferences;
+  }
+
+  public String getDocumentServerURI() {
+    return getSharedPreferences().getString(HealthReportConstants.PREF_DOCUMENT_SERVER_URI, HealthReportConstants.DEFAULT_DOCUMENT_SERVER_URI);
+  }
+
+  public String getDocumentServerNamespace() {
+    return getSharedPreferences().getString(HealthReportConstants.PREF_DOCUMENT_SERVER_NAMESPACE, HealthReportConstants.DEFAULT_DOCUMENT_SERVER_NAMESPACE);
+  }
+
+  public long getLastUploadLocalTime() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_LOCAL_TIME, 0L);
+  }
+
+  public String getLastUploadDocumentId() {
+    return getSharedPreferences().getString(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID, null);
+  }
+
+  public void setLastUploadLocalTimeAndDocumentId(long localTime, String id) {
+    getSharedPreferences().edit()
+      .putLong(HealthReportConstants.PREF_LAST_UPLOAD_LOCAL_TIME, localTime)
+      .putString(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID, id)
+      .commit();
+  }
+
+  protected void uploadPayload(String payload, BagheeraRequestDelegate uploadDelegate) {
+    final BagheeraClient client = new BagheeraClient(getDocumentServerURI());
+
+    final String id = UUID.randomUUID().toString();
+    final String lastId = getLastUploadDocumentId();
+
+    Logger.pii(LOG_TAG, "New health report has id " + id +
+        (lastId == null ? "." : " and obsoletes id " + lastId + "."));
+
+    try {
+      client.uploadJSONDocument(getDocumentServerNamespace(), id, payload, lastId, uploadDelegate);
+    } catch (Exception e) {
+      uploadDelegate.handleError(e);
+    }
+  }
+
+  @Override
+  public void upload(long localTime, Delegate delegate) {
+    // We abuse the life-cycle of an Android ContentProvider slightly by holding
+    // onto a ContentProviderClient while we generate a payload. This keeps our
+    // database storage alive, and may also allow us to share a database
+    // connection with a BrowserHealthRecorder from Fennec.  The ContentProvider
+    // owns all underlying Storage instances, so we don't need to explicitly
+    // close them.
+    ContentProviderClient client = EnvironmentBuilder.getContentProviderClient(context);
+    if (client == null) {
+      delegate.onHardFailure(localTime, null, "Could not fetch content provider client.", null);
+      return;
+    }
+
+    try {
+      // Storage instance is owned by HealthReportProvider, so we don't need to
+      // close it. It's worth noting that this call will fail if called
+      // out-of-process.
+      HealthReportDatabaseStorage storage = EnvironmentBuilder.getStorage(client, profilePath);
+      if (storage == null) {
+        delegate.onHardFailure(localTime, null, "No storage when generating report.", null);
+        return;
+      }
+
+      long since = localTime - HealthReportConstants.MILLISECONDS_PER_SIX_MONTHS;
+      long last = Math.max(getLastUploadLocalTime(), HealthReportConstants.EARLIEST_LAST_PING);
+
+      if (!storage.hasEventSince(last)) {
+        delegate.onHardFailure(localTime, null, "No new events in storage.", null);
+        return;
+      }
+
+      HealthReportGenerator generator = new HealthReportGenerator(storage);
+      JSONObject document = generator.generateDocument(since, last, profilePath);
+      if (document == null) {
+        delegate.onHardFailure(localTime, null, "Generator returned null document.", null);
+        return;
+      }
+
+      BagheeraRequestDelegate uploadDelegate = new RequestDelegate(delegate, localTime, true, null);
+      this.uploadPayload(document.toString(), uploadDelegate);
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Got exception generating document.", e);
+      delegate.onHardFailure(localTime, null, "Got exception uploading.", e);
+      return;
+    } finally {
+      client.release();
+    }
+  }
+
+  @Override
+  public void delete(final long localTime, final String id, Delegate delegate) {
+    final BagheeraClient client = new BagheeraClient(getDocumentServerURI());
+
+    Logger.pii(LOG_TAG, "Deleting health report with id " + id + ".");
+
+    BagheeraRequestDelegate deleteDelegate = new RequestDelegate(delegate, localTime, false, id);
+    try {
+      client.deleteDocument(getDocumentServerNamespace(), id, deleteDelegate);
+    } catch (Exception e) {
+      deleteDelegate.handleError(e);
+    }
+  }
+
+  protected class RequestDelegate implements BagheeraRequestDelegate {
+    protected final Delegate delegate;
+    protected final boolean isUpload;
+    protected final String methodString;
+    protected final long localTime;
+    protected final String id;
+
+    public RequestDelegate(Delegate delegate, long localTime, boolean isUpload, String id) {
+      this.delegate = delegate;
+      this.localTime = localTime;
+      this.isUpload = isUpload;
+      this.methodString = this.isUpload ? "upload" : "delete";
+      this.id = this.isUpload ? null : id; // id is known for deletions only.
+    }
+
+    @Override
+    public void handleSuccess(int status, String namespace, String id, HttpResponse response) {
+      if (isUpload) {
+        setLastUploadLocalTimeAndDocumentId(localTime, id);
+      }
+      Logger.debug(LOG_TAG, "Successful " + methodString + " at " + localTime + ".");
+      delegate.onSuccess(localTime, id);
+    }
+
+    /**
+     * Bagheera status codes:
+     *
+     * 403 Forbidden - Violated access restrictions. Most likely because of the method used.
+     * 413 Request Too Large - Request payload was larger than the configured maximum.
+     * 400 Bad Request - Returned if the POST/PUT failed validation in some manner.
+     * 404 Not Found - Returned if the URI path doesn't exist or if the URI was not in the proper format.
+     * 500 Server Error - General server error. Someone with access should look at the logs for more details.
+     */
+    @Override
+    public void handleFailure(int status, String namespace, HttpResponse response) {
+      Logger.debug(LOG_TAG, "Failed " + methodString + " at " + localTime + ".");
+      if (status >= 500) {
+        delegate.onSoftFailure(localTime, id, "Got status " + status + " from server.", null);
+        return;
+      }
+      // Things are either bad locally (bad payload format, too much data) or
+      // bad remotely (badly configured server, temporarily unavailable). Try
+      // again tomorrow.
+      delegate.onHardFailure(localTime, id, "Got status " + status + " from server.", null);
+    }
+
+    @Override
+    public void handleError(Exception e) {
+      Logger.debug(LOG_TAG, "Exception during " + methodString + " at " + localTime + ".", e);
+      if (e instanceof IOException) {
+        // Let's assume IO exceptions are Android dropping the network.
+        delegate.onSoftFailure(localTime, id, "Got exception during " + methodString + ".", e);
+        return;
+      }
+      delegate.onHardFailure(localTime, id, "Got exception during " + methodString + ".", e);
+    }
+  };
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/HealthReportBroadcastReceiver.java
@@ -0,0 +1,47 @@
+/* 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.upload;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Watch for notifications to start the Health Report background upload service.
+ *
+ * Some observations:
+ *
+ * From the Android documentation: "Also note that as of Android 3.0 the user
+ * needs to have started the application at least once before your application
+ * can receive android.intent.action.BOOT_COMPLETED events."
+ *
+ * We really do want to launch on BOOT_COMPLETED, since it's possible for a user
+ * to run Firefox, shut down the phone, then power it on again on the same day.
+ * We want to submit a health report in this case, even though they haven't
+ * launched Firefox since boot.
+ */
+public class HealthReportBroadcastReceiver extends BroadcastReceiver {
+  public static final String LOG_TAG = HealthReportBroadcastReceiver.class.getSimpleName();
+
+  /**
+   * Forward the intent to an IntentService to do background processing.
+   */
+  @Override
+  public void onReceive(Context context, Intent intent) {
+    if (HealthReportConstants.UPLOAD_FEATURE_DISABLED) {
+      Logger.debug(LOG_TAG, "Health report upload feature is compile-time disabled; not forwarding intent.");
+      return;
+    }
+
+    Logger.debug(LOG_TAG, "Health report upload feature is compile-time enabled; forwarding intent.");
+    Intent service = new Intent(context, HealthReportBroadcastService.class);
+    service.putExtras(intent);
+    service.setAction(intent.getAction());
+    context.startService(service);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/HealthReportBroadcastService.java
@@ -0,0 +1,177 @@
+/* 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.upload;
+
+import org.mozilla.gecko.background.BackgroundService;
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+import org.mozilla.gecko.background.healthreport.HealthReportUtils;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+/**
+ * A service which listens to broadcast intents from the system and from the
+ * browser, registering or unregistering the main
+ * {@link HealthReportUploadStartReceiver} with the {@link AlarmManager}.
+ */
+public class HealthReportBroadcastService extends BackgroundService {
+  public static final String LOG_TAG = HealthReportBroadcastService.class.getSimpleName();
+  public static final String WORKER_THREAD_NAME = LOG_TAG + "Worker";
+
+  public HealthReportBroadcastService() {
+    super(WORKER_THREAD_NAME);
+  }
+
+  protected SharedPreferences getSharedPreferences() {
+    return this.getSharedPreferences(HealthReportConstants.PREFS_BRANCH, GlobalConstants.SHARED_PREFERENCES_MODE);
+  }
+
+  public long getPollInterval() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_SUBMISSION_INTENT_INTERVAL_MSEC, HealthReportConstants.DEFAULT_SUBMISSION_INTENT_INTERVAL_MSEC);
+  }
+
+  public void setPollInterval(long interval) {
+    getSharedPreferences().edit().putLong(HealthReportConstants.PREF_SUBMISSION_INTENT_INTERVAL_MSEC, interval).commit();
+  }
+
+  public long getDeletionAttemptsPerObsoleteDocumentId() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID, HealthReportConstants.DEFAULT_DELETION_ATTEMPTS_PER_OBSOLETE_DOCUMENT_ID);
+  }
+
+  /**
+   * Set or cancel an alarm to submit data for a profile.
+   *
+   * @param context
+   *          Android context.
+   * @param profileName
+   *          to submit data for.
+   * @param profilePath
+   *          to submit data for.
+   * @param enabled
+   *          whether the user has enabled submitting health report data for
+   *          this profile.
+   * @param serviceEnabled
+   *          whether submitting should be scheduled. If the user turns off
+   *          submitting, <code>enabled</code> could be false but we could need
+   *          to delete so <code>serviceEnabled</code> could be true.
+   */
+  protected void toggleAlarm(final Context context, String profileName, String profilePath, boolean enabled, boolean serviceEnabled) {
+    Logger.info(LOG_TAG, (serviceEnabled ? "R" : "Unr") + "egistering health report start broadcast receiver.");
+
+    // PendingIntents are compared without reference to their extras. Therefore
+    // even though we pass the profile details to the action, different
+    // profiles will share the *same* pending intent. In a multi-profile future,
+    // this will need to be addressed.  See Bug 882182.
+    final Intent service = new Intent(context, HealthReportUploadStartReceiver.class);
+    service.setAction("upload"); // PendingIntents "lose" their extras if no action is set.
+    service.putExtra("uploadEnabled", enabled);
+    service.putExtra("profileName", profileName);
+    service.putExtra("profilePath", profilePath);
+    final PendingIntent pending = PendingIntent.getBroadcast(context, 0, service, PendingIntent.FLAG_CANCEL_CURRENT);
+
+    if (!serviceEnabled) {
+      cancelAlarm(pending);
+      return;
+    }
+
+    final long pollInterval = getPollInterval();
+    scheduleAlarm(pollInterval, pending);
+  }
+
+  @Override
+  protected void onHandleIntent(Intent intent) {
+    Logger.setThreadLogTag(HealthReportConstants.GLOBAL_LOG_TAG);
+
+    if (HealthReportConstants.UPLOAD_FEATURE_DISABLED) {
+      Logger.debug(LOG_TAG, "Health report upload feature is compile-time disabled; not handling intent.");
+      return;
+    }
+
+    final String action = intent.getAction();
+    Logger.debug(LOG_TAG, "Health report upload feature is compile-time enabled; handling intent with action " + action + ".");
+
+    if (HealthReportConstants.ACTION_HEALTHREPORT_UPLOAD_PREF.equals(action)) {
+      handlePrefIntent(intent);
+      return;
+    }
+
+    if (Intent.ACTION_BOOT_COMPLETED.equals(action) ||
+        Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE.equals(action)) {
+      BackgroundService.reflectContextToFennec(this,
+          GlobalConstants.GECKO_PREFERENCES_CLASS,
+          GlobalConstants.GECKO_BROADCAST_HEALTHREPORT_UPLOAD_PREF_METHOD);
+      return;
+    }
+
+    // Failure case.
+    Logger.warn(LOG_TAG, "Unknown intent " + action + ".");
+  }
+
+  /**
+   * Handle the intent sent by the browser when it wishes to notify us
+   * of the value of the user preference. Look at the value and toggle the
+   * alarm service accordingly.
+   */
+  protected void handlePrefIntent(Intent intent) {
+    if (!intent.hasExtra("enabled")) {
+      Logger.warn(LOG_TAG, "Got " + HealthReportConstants.ACTION_HEALTHREPORT_UPLOAD_PREF + " intent without enabled. Ignoring.");
+      return;
+    }
+
+    final boolean enabled = intent.getBooleanExtra("enabled", true);
+    Logger.debug(LOG_TAG, intent.getStringExtra("branch") + "/" +
+                          intent.getStringExtra("pref")   + " = " +
+                          (intent.hasExtra("enabled") ? enabled : ""));
+
+    String profileName = intent.getStringExtra("profileName");
+    String profilePath = intent.getStringExtra("profilePath");
+
+    if (profileName == null || profilePath == null) {
+      Logger.warn(LOG_TAG, "Got " + HealthReportConstants.ACTION_HEALTHREPORT_UPLOAD_PREF + " intent without profilePath or profileName. Ignoring.");
+      return;
+    }
+
+    Logger.pii(LOG_TAG, "Updating health report alarm for profile " + profileName + " at " + profilePath + ".");
+
+    final SharedPreferences sharedPrefs = getSharedPreferences();
+
+    ExtendedJSONObject obsoleteIds = HealthReportUtils.getObsoleteIds(getSharedPreferences());
+
+    if (!enabled) {
+      String lastId = sharedPrefs.getString(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID, null);
+      final Editor editor = sharedPrefs.edit();
+      editor.remove(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID);
+
+      if (lastId != null) {
+        try {
+          obsoleteIds.put(lastId, getDeletionAttemptsPerObsoleteDocumentId());
+        } catch (Exception e) {
+          Logger.warn(LOG_TAG, "Got exception updating obsolete ids JSON.", e);
+        }
+        HealthReportUtils.setObsoleteIds(getSharedPreferences(), obsoleteIds);
+        Logger.debug(LOG_TAG, "New health report to obsolete; scheduling deletion of " + obsoleteIds.size() + " documents.");
+      } else {
+        // Primarily intended for debugging and testing.
+        Logger.debug(LOG_TAG, "Health report upload disabled and no deletes to schedule: clearing prefs.");
+        editor.remove(HealthReportConstants.PREF_FIRST_RUN);
+        editor.remove(HealthReportConstants.PREF_NEXT_SUBMISSION);
+      }
+
+      editor.commit();
+    }
+
+    // The user can toggle us off or on, or we can have obsolete documents to
+    // remove.
+    final boolean serviceEnabled = (obsoleteIds.size() > 0) || enabled;
+    toggleAlarm(this, profileName, profilePath, enabled, serviceEnabled);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/HealthReportUploadService.java
@@ -0,0 +1,82 @@
+/* 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.upload;
+
+import org.mozilla.gecko.background.BackgroundService;
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.IBinder;
+
+/**
+ * A <code>Service</code> to manage and upload health report data.
+ *
+ * We extend <code>IntentService</code>, rather than just <code>Service</code>,
+ * because this gives us a worker thread to avoid main-thread networking.
+ *
+ * Yes, even though we're in an alarm-triggered service, it still counts as
+ * main-thread.
+ */
+public class HealthReportUploadService extends BackgroundService {
+  public static final String LOG_TAG = HealthReportUploadService.class.getSimpleName();
+  public static final String WORKER_THREAD_NAME = LOG_TAG + "Worker";
+
+  public HealthReportUploadService() {
+    super(WORKER_THREAD_NAME);
+  }
+
+  @Override
+  public IBinder onBind(Intent intent) {
+    return null;
+  }
+
+  protected SharedPreferences getSharedPreferences() {
+    return this.getSharedPreferences(HealthReportConstants.PREFS_BRANCH, GlobalConstants.SHARED_PREFERENCES_MODE);
+  }
+
+  @Override
+  public void onHandleIntent(Intent intent) {
+    Logger.setThreadLogTag(HealthReportConstants.GLOBAL_LOG_TAG);
+
+    if (HealthReportConstants.UPLOAD_FEATURE_DISABLED) {
+      Logger.debug(LOG_TAG, "Health report upload feature is compile-time disabled; not handling upload intent.");
+      return;
+    }
+
+    Logger.debug(LOG_TAG, "Health report upload feature is compile-time enabled; handling upload intent.");
+
+    String profileName = intent.getStringExtra("profileName");
+    String profilePath = intent.getStringExtra("profilePath");
+
+    if (profileName == null || profilePath == null) {
+      Logger.warn(LOG_TAG, "Got intent without profilePath or profileName. Ignoring.");
+      return;
+    }
+
+    if (!intent.hasExtra("uploadEnabled")) {
+      Logger.warn(LOG_TAG, "Got intent without uploadEnabled. Ignoring.");
+      return;
+    }
+    boolean uploadEnabled = intent.getBooleanExtra("uploadEnabled", false);
+
+    // Don't do anything if the device can't talk to the server.
+    if (!backgroundDataIsEnabled()) {
+      Logger.debug(LOG_TAG, "Background data is not enabled; skipping.");
+      return;
+    }
+
+    Logger.pii(LOG_TAG, "Ticking policy for profile " + profileName + " at " + profilePath + ".");
+
+    final SharedPreferences sharedPrefs = getSharedPreferences();
+    SubmissionClient client = new AndroidSubmissionClient(this, sharedPrefs, profilePath);
+    SubmissionPolicy policy = new SubmissionPolicy(sharedPrefs, client, uploadEnabled);
+
+    final long now = System.currentTimeMillis();
+    policy.tick(now);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/HealthReportUploadStartReceiver.java
@@ -0,0 +1,34 @@
+/* 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.upload;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Start the Health Report background upload service when instructed by the
+ * {@link android.app.AlarmManager}.
+ */
+public class HealthReportUploadStartReceiver extends BroadcastReceiver {
+  public static final String LOG_TAG = HealthReportUploadStartReceiver.class.getSimpleName();
+
+  @Override
+  public void onReceive(Context context, Intent intent) {
+    if (HealthReportConstants.UPLOAD_FEATURE_DISABLED) {
+      Logger.debug(LOG_TAG, "Health report upload feature is compile-time disabled; not starting background upload service.");
+      return;
+    }
+
+    Logger.debug(LOG_TAG, "Health report upload feature is compile-time enabled; starting background upload service.");
+    Intent service = new Intent(context, HealthReportUploadService.class);
+    service.setAction(intent.getAction());
+    service.putExtras(intent); // profileName, profilePath, uploadEnabled are in the extras.
+    context.startService(service);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/SubmissionClient.java
@@ -0,0 +1,40 @@
+/* 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.upload;
+
+public interface SubmissionClient {
+  public interface Delegate {
+    /**
+     * Called in the event of a temporary failure; we should try again soon.
+     *
+     * @param localTime milliseconds since the epoch.
+     * @param id if known; may be null.
+     * @param reason for failure.
+     * @param e if there was an exception; may be null.
+     */
+    public void onSoftFailure(long localTime, String id, String reason, Exception e);
+
+    /**
+     * Called in the event of a failure; we should try again, but not today.
+     *
+     * @param localTime milliseconds since the epoch.
+     * @param id if known; may be null.
+     * @param reason for failure.
+     * @param e if there was an exception; may be null.
+     */
+    public void onHardFailure(long localTime, String id, String reason, Exception e);
+
+    /**
+     * Success!
+     *
+     * @param localTime milliseconds since the epoch.
+     * @param id is always known; not null.
+     */
+    public void onSuccess(long localTime, String id);
+  }
+
+  public void upload(long localTime, Delegate delegate);
+  public void delete(long localTime, String id, Delegate delegate);
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/healthreport/upload/SubmissionPolicy.java
@@ -0,0 +1,445 @@
+/* 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.upload;
+
+import java.util.Collections;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.background.healthreport.HealthReportConstants;
+import org.mozilla.gecko.background.healthreport.HealthReportUtils;
+import org.mozilla.gecko.background.healthreport.upload.SubmissionClient.Delegate;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+import android.content.SharedPreferences;
+
+/**
+ * Manages scheduling of Firefox Health Report data submission.
+ *
+ * The rules of data submission are as follows:
+ *
+ * 1. Do not submit data more than once every 24 hours.
+ *
+ * 2. Try to submit as close to 24 hours apart as possible.
+ *
+ * 3. Do not submit too soon after application startup so as to not negatively
+ * impact performance at startup.
+ *
+ * 4. Before first ever data submission, the user should be notified about data
+ * collection practices.
+ *
+ * 5. User should have opportunity to react to this notification before data
+ * submission.
+ *
+ * 6. Display of notification without any explicit user action constitutes
+ * implicit consent after a certain duration of time.
+ *
+ * 7. If data submission fails, try at most 2 additional times before giving up
+ * on that day's submission.
+ *
+ * On Android, items 4, 5, and 6 are addressed by displaying an Android
+ * notification on first run.
+ */
+public class SubmissionPolicy {
+  public static final String LOG_TAG = SubmissionPolicy.class.getSimpleName();
+
+  protected final SharedPreferences sharedPreferences;
+  protected final SubmissionClient client;
+  protected final boolean uploadEnabled;
+
+  public SubmissionPolicy(final SharedPreferences sharedPreferences, final SubmissionClient client, boolean uploadEnabled) {
+    this.sharedPreferences = sharedPreferences;
+    this.client = client;
+    this.uploadEnabled = uploadEnabled;
+  }
+
+  /**
+   * Check what action must happen, advance counters and timestamps, and
+   * possibly spawn a request to the server.
+   *
+   * @param localTime now.
+   * @return true if a request was spawned; false otherwise.
+   */
+  public boolean tick(final long localTime) {
+    final long nextUpload = getNextSubmission();
+
+    // If the system clock were ever set to a time in the distant future,
+    // it's possible our next schedule date is far out as well. We know
+    // we shouldn't schedule for more than a day out, so we reset the next
+    // scheduled date appropriately. 3 days was chosen to match desktop's
+    // arbitrary choice.
+    if (nextUpload >= localTime + 3 * getMinimumTimeBetweenUploads()) {
+      Logger.warn(LOG_TAG, "Next upload scheduled far in the future; system clock reset? " + nextUpload + " > " + localTime);
+      // Things are strange, we want to start again but we don't want to stampede.
+      editor()
+        .setNextSubmission(localTime + getMinimumTimeBetweenUploads())
+        .commit();
+      return false;
+    }
+
+    // Don't upload unless an interval has elapsed.
+    if (localTime < nextUpload) {
+      Logger.debug(LOG_TAG, "We uploaded less than an interval ago; skipping. " + nextUpload + " > " + localTime);
+      return false;
+    }
+
+    ExtendedJSONObject ids = getObsoleteIds();
+    if (ids.size() > 0) {
+      // Deleting obsolete documents takes precedence over everything else. We
+      // try to delete aggressively, since the volume of deletes should be very
+      // low. But we don't want to send too many delete requests at the same
+      // time, so we process these one at a time. In the future (Bug 872756), we
+      // will be able to delete multiple documents with one request.
+      String obsoleteId;
+      try {
+        // We don't care what the order is, but let's make testing easier by
+        // being deterministic. Deleting in random order might avoid failing too
+        // many times in succession, but we expect only a single pending delete
+        // in practice.
+        obsoleteId = Collections.min(ids.keySet());
+      } catch (Exception e) {
+        Logger.warn(LOG_TAG, "Got exception picking obsolete id to delete.", e);
+        return false;
+      }
+      if (obsoleteId == null) {
+        Logger.error(LOG_TAG, "Next obsolete id to delete is null?");
+        return false;
+      }
+
+      Editor editor = editor();
+      editor.setLastDeleteRequested(localTime); // Write committed by delegate.
+      client.delete(localTime, obsoleteId, new DeleteDelegate(editor));
+      return true;
+    }
+
+    // If we delete all obsolete ids, we could fall through to this point, and
+    // we don't want to upload.
+    if (!uploadEnabled) {
+      return false;
+    }
+
+    long firstRun = getFirstRunLocalTime();
+    if (firstRun < 0) {
+      firstRun = localTime;
+      // Make sure we start clean and as soon as possible.
+      editor()
+        .setFirstRunLocalTime(firstRun)
+        .setNextSubmission(localTime + getMinimumTimeBeforeFirstSubmission())
+        .setCurrentDayFailureCount(0)
+        .commit();
+    }
+
+    if (localTime < firstRun + getMinimumTimeBeforeFirstSubmission()) {
+      Logger.info(LOG_TAG, "Need to wait " + getMinimumTimeBeforeFirstSubmission() + " before first upload.");
+      return false;
+    }
+
+    Editor editor = editor();
+    editor.setLastUploadRequested(localTime); // Write committed by delegate.
+    client.upload(localTime, new UploadDelegate(editor));
+    return true;
+  }
+
+  protected class UploadDelegate implements Delegate {
+    protected final Editor editor;
+
+    public UploadDelegate(Editor editor) {
+      this.editor = editor;
+    }
+
+    @Override
+    public void onSuccess(long localTime, String id) {
+      long next = localTime + getMinimumTimeBetweenUploads();
+      editor
+        .setNextSubmission(next)
+        .setLastUploadSucceeded(localTime)
+        .setCurrentDayFailureCount(0)
+        .commit();
+      if (Logger.LOG_PERSONAL_INFORMATION) {
+        Logger.pii(LOG_TAG, "Successful upload with id " + id + " reported at " + localTime + "; next upload at " + next + ".");
+      } else {
+        Logger.info(LOG_TAG, "Successful upload reported at " + localTime + "; next upload at " + next + ".");
+      }
+    }
+
+    @Override
+    public void onHardFailure(long localTime, String id, String reason, Exception e) {
+      long next = localTime + getMinimumTimeBetweenUploads();
+      editor
+        .setNextSubmission(next)
+        .setLastUploadFailed(localTime)
+        .setCurrentDayFailureCount(0)
+        .commit();
+      Logger.warn(LOG_TAG, "Hard failure reported at " + localTime + ": " + reason + " Next upload at " + next + ".", e);
+    }
+
+    @Override
+    public void onSoftFailure(long localTime, String id, String reason, Exception e) {
+      int failuresToday = getCurrentDayFailureCount();
+      Logger.warn(LOG_TAG, "Soft failure reported at " + localTime + ": " + reason + " Previously failed " + failuresToday + " today.");
+
+      if (failuresToday >= getMaximumFailuresPerDay()) {
+        onHardFailure(localTime, id, "Reached the limit of daily upload attempts.", null);
+        return;
+      }
+
+      long next = localTime + getMinimumTimeAfterFailure();
+      editor
+        .setNextSubmission(next)
+        .setLastUploadFailed(localTime)
+        .setCurrentDayFailureCount(failuresToday + 1)
+        .commit();
+      Logger.info(LOG_TAG, "Retrying upload at " + next + ".");
+    }
+  }
+
+  protected class DeleteDelegate implements Delegate {
+    protected final Editor editor;
+
+    public DeleteDelegate(Editor editor) {
+      this.editor = editor;
+    }
+
+    @Override
+    public void onSoftFailure(final long localTime, String id, String reason, Exception e) {
+      long next = localTime + getMinimumTimeBetweenDeletes();
+      decrementObsoleteIdAttempts(id);
+      editor
+        .setNextSubmission(next)
+        .setLastDeleteFailed(localTime)
+        .commit();
+
+      if (Logger.LOG_PERSONAL_INFORMATION) {
+        Logger.info(LOG_TAG, "Got soft failure at " + localTime + " deleting obsolete document with id " + id + ": " + reason + " Trying again later.");
+      } else {
+        Logger.info(LOG_TAG, "Got soft failure at " + localTime + " deleting obsolete document: " + reason + " Trying again later.");
+      }
+    }
+
+    @Override
+    public void onHardFailure(final long localTime, String id, String reason, Exception e) {
+      // We're never going to be able to delete this id, so don't keep trying.
+      long next = localTime + getMinimumTimeBetweenDeletes();
+      removeObsoleteId(id);
+      editor
+        .setNextSubmission(next)
+        .setLastDeleteFailed(localTime)
+        .commit();
+
+      if (Logger.LOG_PERSONAL_INFORMATION) {
+        Logger.warn(LOG_TAG, "Got hard failure at " + localTime + " deleting obsolete document with id " + id + ": " + reason + " Abandoning delete request.", e);
+      } else {
+        Logger.warn(LOG_TAG, "Got hard failure at " + localTime + " deleting obsolete document: " + reason + " Abandoning delete request.", e);
+      }
+    }
+
+    @Override
+    public void onSuccess(final long localTime, String id) {
+      long next = localTime + getMinimumTimeBetweenDeletes();
+      removeObsoleteId(id);
+      editor
+        .setNextSubmission(next)
+        .setLastDeleteSucceeded(localTime)
+        .commit();
+
+      if (Logger.LOG_PERSONAL_INFORMATION) {
+        Logger.pii(LOG_TAG, "Deleted an obsolete document with id " + id + " at " + localTime + ".");
+      } else {
+        Logger.info(LOG_TAG, "Deleted an obsolete document at " + localTime + ".");
+      }
+    }
+  }
+
+  public SharedPreferences getSharedPreferences() {
+    return this.sharedPreferences;
+  }
+
+  public long getMinimumTimeBetweenUploads() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_UPLOADS, HealthReportConstants.DEFAULT_MINIMUM_TIME_BETWEEN_UPLOADS);
+  }
+
+  public long getMinimumTimeBeforeFirstSubmission() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION, HealthReportConstants.DEFAULT_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION);
+  }
+
+  public long getMinimumTimeAfterFailure() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_AFTER_FAILURE, HealthReportConstants.DEFAULT_MINIMUM_TIME_AFTER_FAILURE);
+  }
+
+  public long getMaximumFailuresPerDay() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_MAXIMUM_FAILURES_PER_DAY, HealthReportConstants.DEFAULT_MAXIMUM_FAILURES_PER_DAY);
+  }
+
+  // Authoritative.
+  public long getFirstRunLocalTime() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_FIRST_RUN, -1);
+  }
+
+  // Authoritative.
+  public long getNextSubmission() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_NEXT_SUBMISSION, -1);
+  }
+
+  // Authoritative.
+  public int getCurrentDayFailureCount() {
+    return getSharedPreferences().getInt(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, 0);
+  }
+
+  /**
+   * To avoid writing to disk multiple times, we encapsulate writes in a
+   * helper class. Be sure to call <code>commit</code> to flush to disk!
+   */
+  protected Editor editor() {
+    return new Editor(getSharedPreferences().edit());
+  }
+
+  protected static class Editor {
+    protected final SharedPreferences.Editor editor;
+
+    public Editor(SharedPreferences.Editor editor) {
+      this.editor = editor;
+    }
+
+    public void commit() {
+      editor.commit();
+    }
+
+    // Authoritative.
+    public Editor setFirstRunLocalTime(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_FIRST_RUN, localTime);
+      return this;
+    }
+
+    // Authoritative.
+    public Editor setNextSubmission(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_NEXT_SUBMISSION, localTime);
+      return this;
+    }
+
+    // Authoritative.
+    public Editor setCurrentDayFailureCount(int failureCount) {
+      editor.putInt(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, failureCount);
+      return this;
+    }
+
+    // Forensics only.
+    public Editor setLastUploadRequested(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED, localTime);
+      return this;
+    }
+
+    // Forensics only.
+    public Editor setLastUploadSucceeded(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_SUCCEEDED, localTime);
+      return this;
+    }
+
+    // Forensics only.
+    public Editor setLastUploadFailed(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_FAILED, localTime);
+      return this;
+    }
+
+    // Forensics only.
+    public Editor setLastDeleteRequested(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_LAST_DELETE_REQUESTED, localTime);
+      return this;
+    }
+
+    // Forensics only.
+    public Editor setLastDeleteSucceeded(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_LAST_DELETE_SUCCEEDED, localTime);
+      return this;
+    }
+
+    // Forensics only.
+    public Editor setLastDeleteFailed(long localTime) {
+      editor.putLong(HealthReportConstants.PREF_LAST_DELETE_FAILED, localTime);
+      return this;
+    }
+  }
+
+  // Forensics only.
+  public long getLastUploadRequested() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED, -1);
+  }
+
+  // Forensics only.
+  public long getLastUploadSucceeded() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_SUCCEEDED, -1);
+  }
+
+  // Forensics only.
+  public long getLastUploadFailed() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_FAILED, -1);
+  }
+
+  // Forensics only.
+  public long getLastDeleteRequested() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_REQUESTED, -1);
+  }
+
+  // Forensics only.
+  public long getLastDeleteSucceeded() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_SUCCEEDED, -1);
+  }
+
+  // Forensics only.
+  public long getLastDeleteFailed() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_FAILED, -1);
+  }
+
+  public long getMinimumTimeBetweenDeletes() {
+    return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_DELETES, HealthReportConstants.DEFAULT_MINIMUM_TIME_BETWEEN_DELETES);
+  }
+
+  public ExtendedJSONObject getObsoleteIds() {
+    return HealthReportUtils.getObsoleteIds(getSharedPreferences());
+  }
+
+  public void setObsoleteIds(ExtendedJSONObject ids) {
+    HealthReportUtils.setObsoleteIds(getSharedPreferences(), ids);
+  }
+
+  /**
+   * Remove id from set of obsolete document ids tracked for deletion.
+   *
+   * Public for testing.
+   *
+   * @param id to stop tracking.
+   */
+  public void removeObsoleteId(String id) {
+    ExtendedJSONObject ids = HealthReportUtils.getObsoleteIds(getSharedPreferences());
+    ids.remove(id);
+    setObsoleteIds(ids);
+  }
+
+  /**
+   * Decrement attempts remaining for id in set of obsolete document ids tracked
+   * for deletion.
+   *
+   * Public for testing.
+   *
+   * @param id to decrement attempts.
+   */
+  public void decrementObsoleteIdAttempts(String id) {
+    ExtendedJSONObject ids = HealthReportUtils.getObsoleteIds(getSharedPreferences());
+
+    if (!ids.containsKey(id)) {
+      return;
+    }
+    try {
+      Long attempts = ids.getLong(id);
+      if (attempts == null || --attempts < 1) {
+        ids.remove(id);
+      } else {
+        ids.put(id, attempts);
+      }
+    } catch (ClassCastException e) {
+      Logger.info(LOG_TAG, "Got exception decrementing obsolete ids counter.", e);
+    }
+
+    setObsoleteIds(ids);
+  }
+}
--- a/mobile/android/base/sync/ExtendedJSONObject.java
+++ b/mobile/android/base/sync/ExtendedJSONObject.java
@@ -303,9 +303,32 @@ public class ExtendedJSONObject {
       return (JSONArray) o;
     }
     throw new NonArrayJSONException(o);
   }
 
   public int size() {
     return this.object.size();
   }
+
+  @Override
+  public int hashCode() {
+    if (this.object == null) {
+      return getClass().hashCode();
+    }
+    return this.object.hashCode() ^ getClass().hashCode();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o == null || !(o instanceof ExtendedJSONObject)) {
+      return false;
+    }
+    if (o == this) {
+      return true;
+    }
+    ExtendedJSONObject other = (ExtendedJSONObject) o;
+    if (this.object == null) {
+      return other.object == null;
+    }
+    return this.object.equals(other.object);
+  }
 }
--- a/mobile/android/services/java-sources.mn
+++ b/mobile/android/services/java-sources.mn
@@ -28,16 +28,23 @@ background/healthreport/Environment.java
 background/healthreport/EnvironmentBuilder.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
 background/healthreport/ProfileInformationCache.java
+background/healthreport/upload/AndroidSubmissionClient.java
+background/healthreport/upload/HealthReportBroadcastReceiver.java
+background/healthreport/upload/HealthReportBroadcastService.java
+background/healthreport/upload/HealthReportUploadService.java
+background/healthreport/upload/HealthReportUploadStartReceiver.java
+background/healthreport/upload/SubmissionClient.java
+background/healthreport/upload/SubmissionPolicy.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
--- a/mobile/android/services/manifests/AnnouncementsAndroidManifest_permissions.xml.in
+++ b/mobile/android/services/manifests/AnnouncementsAndroidManifest_permissions.xml.in
@@ -1,2 +1,5 @@
     <!-- So we can start our service. -->
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+    <!-- So we can receive messages from Fennec. -->
+    <uses-permission android:name="@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE" />
--- a/mobile/android/services/manifests/AnnouncementsAndroidManifest_services.xml.in
+++ b/mobile/android/services/manifests/AnnouncementsAndroidManifest_services.xml.in
@@ -1,10 +1,8 @@
         <service
-		        android:exported="false"
-		        android:name="org.mozilla.gecko.background.announcements.AnnouncementsService" >
+            android:exported="false"
+            android:name="org.mozilla.gecko.background.announcements.AnnouncementsService" >
         </service>
         <service
-		        android:exported="false"
-		        android:name="org.mozilla.gecko.background.announcements.AnnouncementsBroadcastService" >
+            android:exported="false"
+            android:name="org.mozilla.gecko.background.announcements.AnnouncementsBroadcastService" >
         </service>
-
-        <uses-permission android:name="@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE" />
--- a/mobile/android/services/manifests/HealthReportAndroidManifest_activities.xml.in
+++ b/mobile/android/services/manifests/HealthReportAndroidManifest_activities.xml.in
@@ -1,4 +1,30 @@
         <provider android:name="org.mozilla.gecko.background.healthreport.HealthReportProvider"
                   android:authorities="@ANDROID_PACKAGE_NAME@.health"
                   android:permission="@ANDROID_PACKAGE_NAME@.permissions.HEALTH_PROVIDER">
         </provider>
+
+        <!-- As well as system actions, we also listen for "Health
+             Report upload preference changed" notifications sent by
+             Fennec: @ANDROID_PACKAGE_NAME@.HEALTHREPORT_UPLOAD_PREF.
+
+             We have two receivers.  BroadcastReceiver is a thin
+             receiver whose single purpose is to start the background
+             service in response to external events. UploadStartReceiver
+             handles scheduling background work.
+          -->
+        <receiver android:name="org.mozilla.gecko.background.healthreport.upload.HealthReportBroadcastReceiver" >
+            <intent-filter>
+                <!-- Startup. -->
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+            </intent-filter>
+            <intent-filter>
+                <!-- SD card remounted. -->
+                <action android:name="android.intent.action.EXTERNAL_APPLICATIONS_AVAILABLE" />
+            </intent-filter>
+            <intent-filter >
+              <action android:name="@ANDROID_PACKAGE_NAME@.HEALTHREPORT_UPLOAD_PREF" />
+            </intent-filter>
+        </receiver>
+
+        <receiver android:name="org.mozilla.gecko.background.healthreport.upload.HealthReportUploadStartReceiver" >
+        </receiver>
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/manifests/HealthReportAndroidManifest_permissions.xml.in
@@ -0,0 +1,5 @@
+    <!-- So we can start our service. -->
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+    <!-- So we can receive messages from Fennec. -->
+    <uses-permission android:name="@ANDROID_PACKAGE_NAME@.permission.PER_ANDROID_PACKAGE" />
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/manifests/HealthReportAndroidManifest_services.xml.in
@@ -0,0 +1,14 @@
+        <!-- We have two services.  BroadcastService responds to
+             external events and starts the regular Service, which
+             does the actual background work, in a uniform manner.  We
+             don't export either of these services, since they are
+             only started by receivers internal to the Fennec package.
+          -->
+        <service
+            android:exported="false"
+            android:name="org.mozilla.gecko.background.healthreport.upload.HealthReportBroadcastService" >
+        </service>
+        <service
+            android:exported="false"
+            android:name="org.mozilla.gecko.background.healthreport.upload.HealthReportUploadService" >
+        </service>