Bug 828654, Bug 840127 - Android service for submitting Firefox Health Report payloads. r=rnewman, a=akeybl
authorNick Alexander <nalexander@mozilla.com>
Tue, 18 Jun 2013 14:04:52 -0700
changeset 142938 fe30bac80d31f29fb0f93e2d717406e44a27fd33
parent 142937 71778324511add9f740b7b3785f26c3f534d4e1e
child 142939 8bba8f847b9f12c3d5685cf8e34c6d9f22730147
push id2579
push userakeybl@mozilla.com
push dateMon, 24 Jun 2013 18:52:47 +0000
treeherdermozilla-beta@b69b7de8a05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, akeybl
bugs828654, 840127
milestone23.0a2
Bug 828654, Bug 840127 - Android service for submitting Firefox Health Report payloads. r=rnewman, a=akeybl
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
@@ -195,16 +195,17 @@
                   android:theme="@style/Gecko">
           <intent-filter>
             <action android:name="org.mozilla.gecko.restart"/>
             <action android:name="org.mozilla.gecko.restart_update"/>
           </intent-filter>
         </activity>
 
 #include ../services/manifests/AnnouncementsAndroidManifest_activities.xml.in
+#include ../services/manifests/HealthReportAndroidManifest_permissions.xml.in
 #include ../services/manifests/SyncAndroidManifest_activities.xml.in
 #include ../services/manifests/HealthReportAndroidManifest_activities.xml.in
 
 #if MOZ_CRASHREPORTER
   <activity android:name="org.mozilla.gecko.CrashReporter"
             android:label="@string/crash_reporter_title"
             android:icon="@drawable/crash_reporter"
             android:theme="@style/Gecko"
@@ -271,17 +272,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
@@ -1705,20 +1705,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.mAppContext;
                 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(GeckoApp.mAppContext, ((Boolean) newValue).booleanValue());
         } else if (PREFS_UPDATER_AUTODOWNLOAD.equals(prefName)) {
             org.mozilla.gecko.updater.UpdateServiceHelper.registerForUpdates(GeckoApp.mAppContext, (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>