Backed out changeset 776ddfd41f21 (bug 1045085) for Android bustage.
authorRyan VanderMeulen <ryanvm@gmail.com>
Mon, 08 Sep 2014 15:32:40 -0400
changeset 216676 70930f30da0e
parent 216675 776ddfd41f21
child 216677 db5539e42eb5
push id3872
push userryanvm@gmail.com
push date2014-09-08 19:43 +0000
treeherdermozilla-beta@d820ef3b256d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1045085
milestone33.0
backs out776ddfd41f21
Backed out changeset 776ddfd41f21 (bug 1045085) for Android bustage.
mobile/android/base/android-services.mozbuild
mobile/android/base/background/announcements/Announcement.java
mobile/android/base/background/announcements/AnnouncementPresenter.java
mobile/android/base/background/announcements/AnnouncementsBroadcastReceiver.java
mobile/android/base/background/announcements/AnnouncementsBroadcastService.java
mobile/android/base/background/announcements/AnnouncementsConstants.java.in
mobile/android/base/background/announcements/AnnouncementsFetchDelegate.java
mobile/android/base/background/announcements/AnnouncementsFetchResourceDelegate.java
mobile/android/base/background/announcements/AnnouncementsFetcher.java
mobile/android/base/background/announcements/AnnouncementsService.java
mobile/android/base/background/common/GlobalConstants.java.in
mobile/android/services/manifests/AnnouncementsAndroidManifest_activities.xml.in
mobile/android/services/manifests/AnnouncementsAndroidManifest_permissions.xml.in
mobile/android/services/manifests/AnnouncementsAndroidManifest_services.xml.in
mobile/android/tests/background/junit3/background_junit3_sources.mozbuild
mobile/android/tests/background/junit3/src/announcements/TestAnnouncementsBroadcastService.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -467,16 +467,24 @@ sync_thirdparty_java_files = [
     'org/mozilla/apache/commons/codec/net/URLCodec.java',
     'org/mozilla/apache/commons/codec/net/Utils.java',
     'org/mozilla/apache/commons/codec/StringDecoder.java',
     'org/mozilla/apache/commons/codec/StringEncoder.java',
     'org/mozilla/apache/commons/codec/StringEncoderComparator.java',
 ]
 
 sync_java_files = [
+    'background/announcements/Announcement.java',
+    'background/announcements/AnnouncementPresenter.java',
+    'background/announcements/AnnouncementsBroadcastReceiver.java',
+    'background/announcements/AnnouncementsBroadcastService.java',
+    'background/announcements/AnnouncementsFetchDelegate.java',
+    'background/announcements/AnnouncementsFetcher.java',
+    'background/announcements/AnnouncementsFetchResourceDelegate.java',
+    'background/announcements/AnnouncementsService.java',
     'background/BackgroundService.java',
     'background/bagheera/BagheeraClient.java',
     'background/bagheera/BagheeraRequestDelegate.java',
     'background/bagheera/BoundedByteArrayEntity.java',
     'background/bagheera/DeflateHelper.java',
     'background/common/DateUtils.java',
     'background/common/log/Logger.java',
     'background/common/log/writers/AndroidLevelCachingLogWriter.java',
@@ -840,13 +848,14 @@ sync_java_files = [
     'sync/Utils.java',
     'tokenserver/TokenServerClient.java',
     'tokenserver/TokenServerClientDelegate.java',
     'tokenserver/TokenServerException.java',
     'tokenserver/TokenServerToken.java',
 ]
 
 sync_generated_java_files = [
+    'org/mozilla/gecko/background/announcements/AnnouncementsConstants.java',
     'org/mozilla/gecko/background/common/GlobalConstants.java',
     'org/mozilla/gecko/background/healthreport/HealthReportConstants.java',
     'org/mozilla/gecko/fxa/FxAccountConstants.java',
     'org/mozilla/gecko/sync/SyncConstants.java',
 ]
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/Announcement.java
@@ -0,0 +1,111 @@
+/* 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.net.URI;
+import java.net.URISyntaxException;
+
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+/**
+ * Represents a retrieved product announcement.
+ *
+ * Instances of this class are immutable.
+ */
+public class Announcement {
+  private static final String LOG_TAG   = "Announcement";
+
+  private static final String KEY_ID    = "id";
+  private static final String KEY_TITLE = "title";
+  private static final String KEY_URL   = "url";
+  private static final String KEY_TEXT  = "text";
+
+  private final int id;
+  private final String title;
+  private final URI uri;
+  private final String text;
+
+  public Announcement(int id, String title, String text, URI uri) {
+    this.id    = id;
+    this.title = title;
+    this.uri   = uri;
+    this.text  = text;
+  }
+
+  public static Announcement parseAnnouncement(ExtendedJSONObject body) throws URISyntaxException, IllegalArgumentException {
+    final Integer id = body.getIntegerSafely(KEY_ID);
+    if (id == null) {
+      throw new IllegalArgumentException("No id provided in JSON.");
+    }
+    final String title = body.getString(KEY_TITLE);
+    if (title == null || title.trim().length() == 0) {
+      throw new IllegalArgumentException("Missing or empty announcement title.");
+    }
+    final String uri = body.getString(KEY_URL);
+    if (uri == null) {
+      // Empty or otherwise unhappy URI will throw a URISyntaxException.
+      throw new IllegalArgumentException("Missing announcement URI.");
+    }
+
+    final String text = body.getString(KEY_TEXT);
+    if (text == null) {
+      throw new IllegalArgumentException("Missing announcement body.");
+    }
+
+    return new Announcement(id, title, text, new URI(uri));
+  }
+
+  public int getId() {
+    return id;
+  }
+
+  public String getTitle() {
+    return title;
+  }
+
+  public String getText() {
+    return text;
+  }
+
+  public URI getUri() {
+    return uri;
+  }
+
+  public ExtendedJSONObject asJSON() {
+    ExtendedJSONObject out = new ExtendedJSONObject();
+    out.put(KEY_ID,    id);
+    out.put(KEY_TITLE, title);
+    out.put(KEY_URL,   uri.toASCIIString());
+    out.put(KEY_TEXT,  text);
+    return out;
+  }
+
+  /**
+   * Return false if the provided Announcement is in some way invalid,
+   * regardless of being well-formed.
+   */
+  public static boolean isValidAnnouncement(final Announcement an) {
+    final URI uri = an.getUri();
+    if (uri == null) {
+      Logger.warn(LOG_TAG, "No URI: announcement not valid.");
+      return false;
+    }
+
+    final String scheme = uri.getScheme();
+    if (scheme == null) {
+      Logger.warn(LOG_TAG, "Null scheme: announcement not valid.");
+      return false;
+    }
+
+    // Only allow HTTP and HTTPS URLs.
+    if (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
+      Logger.warn(LOG_TAG, "Scheme '" + scheme + "' forbidden: announcement not valid.");
+      return false;
+    }
+
+    return true;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementPresenter.java
@@ -0,0 +1,83 @@
+/* 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.net.URI;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.background.common.GlobalConstants;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+/**
+ * Handle requests to display a fetched announcement.
+ */
+public class AnnouncementPresenter {
+
+  /**
+   * Display the provided snippet.
+   * @param context
+   *        The context instance to use when obtaining the NotificationManager.
+   * @param notificationID
+   *        A unique ID for this notification.
+   * @param title
+   *        The *already localized* String title. Must not be null.
+   * @param body
+   *        The *already localized* String body. Must not be null.
+   * @param uri
+   *        The URL to open when the notification is tapped.
+   */
+  @SuppressWarnings("deprecation")
+  public static void displayAnnouncement(final Context context,
+                                         final int notificationID,
+                                         final String title,
+                                         final String body,
+                                         final URI uri) {
+    final String ns = Context.NOTIFICATION_SERVICE;
+    final NotificationManager notificationManager = (NotificationManager) context.getSystemService(ns);
+
+    // Set pending intent associated with the notification.
+    Uri u = Uri.parse(uri.toASCIIString());
+    Intent intent = new Intent(Intent.ACTION_VIEW, u);
+
+    // Always open the link with Fennec.
+    intent.setClassName(GlobalConstants.BROWSER_INTENT_PACKAGE, GlobalConstants.BROWSER_INTENT_CLASS);
+    PendingIntent contentIntent = PendingIntent.getActivity(context, 0, intent, 0);
+
+    final int icon = R.drawable.ic_status_logo;
+
+    // Deprecated approach to building a notification.
+    final long when = System.currentTimeMillis();
+    Notification notification = new Notification(icon, title, when);
+    notification.flags = Notification.FLAG_AUTO_CANCEL;
+    notification.setLatestEventInfo(context, title, body, contentIntent);
+
+    // Notification.Builder since API 11.
+    /*
+    Notification notification = new Notification.Builder(context)
+        .setContentTitle(title)
+        .setContentText(body)
+        .setAutoCancel(true)
+        .setContentIntent(contentIntent).getNotification();
+     */
+
+    // Send notification.
+    notificationManager.notify(notificationID, notification);
+  }
+
+  public static void displayAnnouncement(final Context context,
+                                         final Announcement snippet) {
+    final int notificationID = snippet.getId();
+    final String title = snippet.getTitle();
+    final String body = snippet.getText();
+    final URI uri = snippet.getUri();
+    AnnouncementPresenter.displayAnnouncement(context, notificationID, title, body, uri);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsBroadcastReceiver.java
@@ -0,0 +1,35 @@
+/* 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 org.mozilla.gecko.background.BackgroundService;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * Watch for notifications to start the announcements service.
+ *
+ * Some observations:
+ *
+ * "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."
+ */
+public class AnnouncementsBroadcastReceiver extends BroadcastReceiver {
+
+  /**
+   * Forward the intent to an IntentService to do background processing.
+   */
+  @Override
+  public void onReceive(Context context, Intent intent) {
+    if (AnnouncementsConstants.DISABLED) {
+      return;
+    }
+
+    BackgroundService.runIntentInService(context, intent, AnnouncementsBroadcastService.class);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsBroadcastService.java
@@ -0,0 +1,179 @@
+/* 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 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;
+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 AnnouncementsService} with the {@link AlarmManager}.
+ */
+public class AnnouncementsBroadcastService extends BackgroundService {
+  private static final String WORKER_THREAD_NAME = "AnnouncementsBroadcastServiceWorker";
+  private static final String LOG_TAG = "AnnounceBrSvc";
+
+  public AnnouncementsBroadcastService() {
+    super(WORKER_THREAD_NAME);
+  }
+
+  protected static SharedPreferences getSharedPreferences(Context context) {
+    return context.getSharedPreferences(AnnouncementsConstants.PREFS_BRANCH,
+        GlobalConstants.SHARED_PREFERENCES_MODE);
+  }
+
+  protected SharedPreferences getSharedPreferences() {
+    return this.getSharedPreferences(AnnouncementsConstants.PREFS_BRANCH,
+        GlobalConstants.SHARED_PREFERENCES_MODE);
+  }
+
+  private void toggleAlarm(final Context context, boolean enabled) {
+    final Class<?> serviceClass = AnnouncementsService.class;
+    Logger.info(LOG_TAG, (enabled ? "R" : "Unr") + "egistering " + serviceClass.getSimpleName() +
+        ".");
+
+    final Intent service = new Intent(context, serviceClass);
+    final PendingIntent pending =  PendingIntent.getService(context, 0, service,
+        PendingIntent.FLAG_CANCEL_CURRENT);
+
+    if (!enabled) {
+      cancelAlarm(pending);
+      return;
+    }
+
+    final long pollInterval = getPollInterval(context);
+    scheduleAlarm(pollInterval, pending);
+  }
+
+  /**
+   * Record the last launch time of our version of Fennec.
+   *
+   * @param context
+   *          the <code>Context</code> to use to gain access to
+   *          <code>SharedPreferences</code>.
+   */
+  public static void recordLastLaunch(final Context context) {
+    final long now = System.currentTimeMillis();
+    final SharedPreferences preferences = getSharedPreferences(context);
+
+    // One of several things might be true, according to our logs:
+    //
+    // * The new current time is older than the last
+    // * … or way in the future
+    // * … or way in the distant past
+    // * … or it's reasonable.
+    //
+    // Furthermore, when we come to calculate idle we might find that the clock
+    // is dramatically different — that the current time is thirteen years older
+    // than our saved timestamp (system clock resets to 2000 on battery change),
+    // or it's thirty years in the future (previous timestamp was saved as 0).
+    //
+    // We should try to do something vaguely sane in these situations.
+    long previous = preferences.getLong(AnnouncementsConstants.PREF_LAST_LAUNCH, -1);
+    if (previous == -1) {
+      Logger.debug(LOG_TAG, "No previous launch recorded.");
+    }
+
+    if (now < GlobalConstants.BUILD_TIMESTAMP_MSEC) {
+      Logger.warn(LOG_TAG, "Current time " + now + " is older than build date " +
+                           GlobalConstants.BUILD_TIMESTAMP_MSEC + ". Ignoring until clock is corrected.");
+      return;
+    }
+
+    if (now > AnnouncementsConstants.LATEST_ACCEPTED_LAUNCH_TIMESTAMP_MSEC) {
+      Logger.warn(LOG_TAG, "Launch time " + now + " is later than max sane launch timestamp " +
+                           AnnouncementsConstants.LATEST_ACCEPTED_LAUNCH_TIMESTAMP_MSEC +
+                           ". Ignoring until clock is corrected.");
+      return;
+    }
+
+    if (previous > now) {
+      Logger.debug(LOG_TAG, "Previous launch " + previous + " later than current time " +
+                            now + ", but new time is sane. Accepting new time.");
+    }
+
+    preferences.edit().putLong(AnnouncementsConstants.PREF_LAST_LAUNCH, now).commit();
+  }
+
+  public static long getPollInterval(final Context context) {
+    final SharedPreferences preferences = getSharedPreferences(context);
+    return preferences.getLong(AnnouncementsConstants.PREF_ANNOUNCE_FETCH_INTERVAL_MSEC, AnnouncementsConstants.DEFAULT_ANNOUNCE_FETCH_INTERVAL_MSEC);
+  }
+
+  public static void setPollInterval(final Context context, long interval) {
+    final SharedPreferences preferences = getSharedPreferences(context);
+    preferences.edit().putLong(AnnouncementsConstants.PREF_ANNOUNCE_FETCH_INTERVAL_MSEC, interval).commit();
+  }
+
+  @Override
+  protected void onHandleIntent(Intent intent) {
+    Logger.setThreadLogTag(AnnouncementsConstants.GLOBAL_LOG_TAG);
+
+    // Intent can be null. Bug 1025937.
+    if (intent == null) {
+      Logger.debug(LOG_TAG, "Short-circuiting on null intent.");
+      return;
+    }
+
+    final String action = intent.getAction();
+    Logger.debug(LOG_TAG, "Broadcast onReceive. Intent is " + action);
+
+    if (AnnouncementsConstants.ACTION_ANNOUNCEMENTS_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_ANNOUNCEMENTS_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.
+   *
+   * @param intent must be non-null.
+   */
+  private void handlePrefIntent(Intent intent) {
+    if (!intent.hasExtra("enabled")) {
+      Logger.warn(LOG_TAG, "Got ANNOUNCEMENTS_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 : ""));
+
+    toggleAlarm(this, enabled);
+
+    // Primarily intended for debugging and testing, but this doesn't do any harm.
+    if (!enabled) {
+      Logger.info(LOG_TAG, "!enabled: clearing last fetch.");
+      final SharedPreferences sharedPreferences = getSharedPreferences();
+      final Editor editor = sharedPreferences.edit();
+      editor.remove(AnnouncementsConstants.PREF_LAST_FETCH_LOCAL_TIME);
+      editor.remove(AnnouncementsConstants.PREF_EARLIEST_NEXT_ANNOUNCE_FETCH);
+      editor.commit();
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsConstants.java.in
@@ -0,0 +1,50 @@
+#filter substitution
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.announcements;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+
+import android.app.AlarmManager;
+
+public class AnnouncementsConstants {
+  // Not `final` so we have the option to turn this on at runtime with a magic addon.
+  public static boolean DISABLED = false;
+
+  public static final long MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+  public static final String GLOBAL_LOG_TAG = "GeckoAnnounce";
+  public static final String ACTION_ANNOUNCEMENTS_PREF = "@ANDROID_PACKAGE_NAME@.ANNOUNCEMENTS_PREF";
+
+  static final String PREFS_BRANCH = "background";
+  static final String PREF_LAST_FETCH_LOCAL_TIME  = "last_fetch";
+  static final String PREF_LAST_FETCH_SERVER_DATE = "last_announce_date";
+  static final String PREF_LAST_LAUNCH = "last_firefox_launch";
+  static final String PREF_ANNOUNCE_SERVER_BASE_URL  = "announce_server_base_url";
+  static final String PREF_EARLIEST_NEXT_ANNOUNCE_FETCH = "earliest_next_announce_fetch";
+  static final String PREF_ANNOUNCE_FETCH_INTERVAL_MSEC = "announce_fetch_interval_msec";
+
+  public static String DEFAULT_ANNOUNCE_SERVER_BASE_URL = "https://campaigns.services.mozilla.com/announce/";
+
+  public static final String ANNOUNCE_PROTOCOL_VERSION = "1";
+  public static final String ANNOUNCE_APPLICATION = "android";
+  public static String ANNOUNCE_PATH_SUFFIX = AnnouncementsConstants.ANNOUNCE_PROTOCOL_VERSION + "/" +
+                                              AnnouncementsConstants.ANNOUNCE_APPLICATION + "/";
+
+  public static long DEFAULT_ANNOUNCE_FETCH_INTERVAL_MSEC = AlarmManager.INTERVAL_HALF_DAY;
+  public static long DEFAULT_BACKOFF_MSEC = 2 * 24 * 60 * 60 * 1000;   // Two days. Used if no Retry-After header.
+  public static long MINIMUM_FETCH_INTERVAL_MSEC = 60 * 60 * 1000;     // 1 hour.
+
+  // Stop reporting idle counts once they hit one year.
+  public static long MAX_SANE_IDLE_DAYS = 365;
+
+  // Don't track last launch if the timestamp is ridiculously out of range:
+  // four years after build.
+  public static long LATEST_ACCEPTED_LAUNCH_TIMESTAMP_MSEC = GlobalConstants.BUILD_TIMESTAMP_MSEC +
+                                                             4 * 365 * MILLISECONDS_PER_DAY;
+
+  public static String USER_AGENT = "Firefox Announcements " + GlobalConstants.MOZ_APP_VERSION;
+  public static String ANNOUNCE_CHANNEL = GlobalConstants.MOZ_UPDATE_CHANNEL.replace("default", GlobalConstants.MOZ_OFFICIAL_BRANDING ? "release" : "dev");
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsFetchDelegate.java
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.background.announcements;
+
+import java.util.List;
+import java.util.Locale;
+
+public interface AnnouncementsFetchDelegate {
+  /**
+   * @return the timestamp of the last fetch in milliseconds.
+   */
+  public long getLastFetch();
+
+  /**
+   * @return the Date header string of the last response, or null if not present.
+   */
+  public String getLastDate();
+
+  /**
+   * @return the current system locale (e.g., en_us).
+   */
+  public Locale getLocale();
+
+  /**
+   * @return the User-Agent header to use for the request.
+   */
+  public String getUserAgent();
+
+  /**
+   * @return the server URL to interrogate, including path.
+   */
+  public String getServiceURL();
+
+  /*
+   * Callback methods.
+   * Note that we provide both a local fetch time and a server date here.
+   * This is so we can track how long we've waited (local), and supply the
+   * date back to the server for If-Modified-Since.
+   */
+  public void onNoNewAnnouncements(long localFetchTime, String serverDate);
+  public void onNewAnnouncements(List<Announcement> snippets, long localFetchTime, String serverDate);
+  public void onLocalError(Exception e);
+  public void onRemoteError(Exception e);
+  public void onRemoteFailure(int status);
+  public void onBackoff(int retryAfterInSeconds);
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsFetchResourceDelegate.java
@@ -0,0 +1,183 @@
+/* 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.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.net.BaseResource;
+import org.mozilla.gecko.sync.net.BaseResourceDelegate;
+import org.mozilla.gecko.sync.net.Resource;
+import org.mozilla.gecko.sync.net.SyncResponse;
+
+import ch.boye.httpclientandroidlib.Header;
+import ch.boye.httpclientandroidlib.HttpResponse;
+import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
+import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
+import ch.boye.httpclientandroidlib.impl.cookie.DateUtils;
+import ch.boye.httpclientandroidlib.protocol.HTTP;
+
+/**
+ * Converts HTTP resource callbacks into AnnouncementsFetchDelegate callbacks.
+ */
+public class AnnouncementsFetchResourceDelegate extends BaseResourceDelegate {
+  private static final String ACCEPT_HEADER = "application/json;charset=utf-8";
+
+  private static final String LOG_TAG = "AnnounceFetchRD";
+
+  protected final long startTime;
+  protected AnnouncementsFetchDelegate delegate;
+
+  public AnnouncementsFetchResourceDelegate(Resource resource, AnnouncementsFetchDelegate delegate) {
+    super(resource);
+    this.startTime = System.currentTimeMillis();
+    this.delegate  = delegate;
+  }
+
+  @Override
+  public String getUserAgent() {
+    return delegate.getUserAgent();
+  }
+
+  @Override
+  public void addHeaders(HttpRequestBase request, DefaultHttpClient client) {
+    super.addHeaders(request, client);
+
+    // The basics.
+    request.addHeader("Accept-Language", delegate.getLocale().toString());
+    request.addHeader("Accept",          ACCEPT_HEADER);
+
+    // We never want to keep connections alive.
+    request.addHeader("Connection", "close");
+
+    // Set If-Modified-Since to avoid re-fetching content.
+    final String ifModifiedSince = delegate.getLastDate();
+    if (ifModifiedSince != null) {
+      Logger.info(LOG_TAG, "If-Modified-Since: " + ifModifiedSince);
+      request.addHeader("If-Modified-Since", ifModifiedSince);
+    }
+
+    // Just in case.
+    request.removeHeaders("Cookie");
+  }
+
+  private List<Announcement> parseBody(ExtendedJSONObject body) throws NonArrayJSONException {
+    List<Announcement> out = new ArrayList<Announcement>(1);
+    JSONArray snippets = body.getArray("announcements");
+    if (snippets == null) {
+      Logger.warn(LOG_TAG, "Missing announcements body. Returning empty.");
+      return out;
+    }
+
+    for (Object s : snippets) {
+      try {
+        out.add(Announcement.parseAnnouncement(new ExtendedJSONObject((JSONObject) s)));
+      } catch (Exception e) {
+        Logger.warn(LOG_TAG, "Malformed announcement or display failed. Skipping.", e);
+      }
+    }
+    return out;
+  }
+
+  @Override
+  public void handleHttpResponse(HttpResponse response) {
+    final Header dateHeader = response.getFirstHeader(HTTP.DATE_HEADER);
+    String date = null;
+    if (dateHeader != null) {
+      // Note that we are deliberately not validating the server time here.
+      // We pass it directly back to the server; we don't care about the
+      // contents, and if we reject a value we essentially re-initialize
+      // the client, which will cause stale announcements to be re-fetched.
+      date = dateHeader.getValue();
+    }
+    if (date == null) {
+      // Use local clock, because skipping is better than re-fetching.
+      date = DateUtils.formatDate(new Date());
+      Logger.warn(LOG_TAG, "No fetch date; using local time " + date);
+    }
+
+    final SyncResponse r = new SyncResponse(response);    // For convenience.
+    try {
+      final int statusCode = r.getStatusCode();
+      Logger.debug(LOG_TAG, "Got announcements response: " + statusCode);
+
+      if (statusCode == 204 || statusCode == 304) {
+        BaseResource.consumeEntity(response);
+        delegate.onNoNewAnnouncements(startTime, date);
+        return;
+      }
+
+      if (statusCode == 200) {
+        final List<Announcement> snippets;
+        try {
+          snippets = parseBody(r.jsonObjectBody());
+        } catch (Exception e) {
+          delegate.onRemoteError(e);
+          return;
+        }
+        delegate.onNewAnnouncements(snippets, startTime, date);
+        return;
+      }
+
+      if (statusCode == 400 || statusCode == 405) {
+        // We did something wrong.
+        Logger.warn(LOG_TAG, "We did something wrong. Oh dear.");
+        // Fall through.
+      }
+
+      if (statusCode == 503 || statusCode == 500) {
+        Logger.warn(LOG_TAG, "Server issue: " + r.body());
+        delegate.onBackoff(r.retryAfterInSeconds());
+        return;
+      }
+
+      // Otherwise, clean up.
+      delegate.onRemoteFailure(statusCode);
+
+    } catch (Exception e) {
+      Logger.warn(LOG_TAG, "Failed to extract body.", e);
+      delegate.onRemoteError(e);
+    }
+  }
+
+  @Override
+  public void handleHttpProtocolException(ClientProtocolException e) {
+    Logger.warn(LOG_TAG, "Protocol exception.", e);
+    delegate.onLocalError(e);
+  }
+
+  @Override
+  public void handleHttpIOException(IOException e) {
+    Logger.warn(LOG_TAG, "IO exception.", e);
+    delegate.onLocalError(e);
+  }
+
+  @Override
+  public void handleTransportException(GeneralSecurityException e) {
+    Logger.warn(LOG_TAG, "Transport exception.", e);
+    // Class this as a remote error, because it's probably something odd
+    // with SSL negotiation.
+    delegate.onRemoteError(e);
+  }
+
+  /**
+   * Be very thorough in case the superclass implementation changes.
+   * We never want this to be an authenticated request.
+   */
+  @Override
+  public AuthHeaderProvider getAuthHeaderProvider() {
+    return null;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsFetcher.java
@@ -0,0 +1,134 @@
+/* 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.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+public class AnnouncementsFetcher {
+  private static final String LOG_TAG = "AnnounceFetch";
+  private static final long MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+  public static URI getSnippetURI(String base, String channel,
+                                  String version, String platform,
+                                  int idleDays)
+    throws URISyntaxException {
+    try {
+      final String c = URLEncoder.encode(channel, "UTF-8");
+      final String v = URLEncoder.encode(version, "UTF-8");
+      final String p = URLEncoder.encode(platform, "UTF-8");
+      final String s = base + c + "/" + v + "/" + p + ((idleDays == -1) ? "" : ("?idle=" + idleDays));
+      return new URI(s);
+    } catch (UnsupportedEncodingException e) {
+      // Nonsense.
+      return null;
+    }
+  }
+
+  public static URI getAnnounceURI(final String baseURL, final long lastLaunch) throws URISyntaxException {
+    final String channel = getChannel();
+    final String version = getVersion();
+    final String platform = getPlatform();
+    final int idleDays = getIdleDays(lastLaunch);
+
+    Logger.debug(LOG_TAG, "Fetch URI: idle for " + idleDays + " days.");
+    return getSnippetURI(baseURL, channel, version, platform, idleDays);
+  }
+
+  protected static String getChannel() {
+    return AnnouncementsConstants.ANNOUNCE_CHANNEL;
+  }
+
+  protected static String getVersion() {
+    return GlobalConstants.MOZ_APP_VERSION;
+  }
+
+  protected static String getPlatform() {
+    return GlobalConstants.ANDROID_CPU_ARCH;
+  }
+
+  /**
+   * Return the number of days that we've been idle, assuming that we have a
+   * sane last launch time and the current time is within range. If no sane idle
+   * time can be returned, we return -1.
+   *
+   * @param lastLaunch
+   *          Time at which the browser was last launched, in milliseconds since epoch.
+   * @param now
+   *          Milliseconds since epoch for which idle time should be calculated.
+   * @return number of idle days, or -1 if out of range.
+   */
+  protected static int getIdleDays(final long lastLaunch, final long now) {
+    if (lastLaunch <= 0) {
+      return -1;
+    }
+
+    if (now < GlobalConstants.BUILD_TIMESTAMP_MSEC) {
+      Logger.warn(LOG_TAG, "Current time " + now + " earlier than build date. Not calculating idle.");
+      return -1;
+    }
+
+    if (now < lastLaunch) {
+      Logger.warn(LOG_TAG, "Current time " + now + " earlier than last launch! Not calculating idle.");
+      return -1;
+    }
+
+    final long idleMillis = now - lastLaunch;
+    final int idleDays = (int) (idleMillis / MILLISECONDS_PER_DAY);
+
+    if (idleDays > AnnouncementsConstants.MAX_SANE_IDLE_DAYS) {
+      Logger.warn(LOG_TAG, "Idle from " + lastLaunch + " until " + now +
+                           ", which is insane. Not calculating idle.");
+      return -1;
+    }
+
+    return idleDays;
+  }
+
+  /**
+   * Return the number of days that we've been idle, assuming that we have a
+   * sane last launch time and the current time is within range. If no sane idle
+   * time can be returned, we return -1.
+   * The current time will be calculated from {@link System#currentTimeMillis()}.
+   *
+   * @param lastLaunch
+   *          Unix timestamp at which the browser was last launched.
+   * @return number of idle days, or -1 if out of range.
+   */
+  protected static int getIdleDays(final long lastLaunch) {
+    final long now = System.currentTimeMillis();
+    return getIdleDays(lastLaunch, now);
+  }
+
+  public static void fetchAnnouncements(URI uri, AnnouncementsFetchDelegate delegate) {
+    BaseResource r = new BaseResource(uri);
+    r.delegate = new AnnouncementsFetchResourceDelegate(r, delegate);
+    r.getBlocking();
+  }
+
+  /**
+   * Synchronous.
+   */
+  public static void fetchAndProcessAnnouncements(long lastLaunch,
+                                                  AnnouncementsFetchDelegate delegate) {
+    final long now = System.currentTimeMillis();
+    Logger.debug(LOG_TAG, "Fetching announcements. Last launch: " + lastLaunch + "; now: " + now);
+    try {
+      final String base = delegate.getServiceURL();
+      final URI uri = getAnnounceURI(base, lastLaunch);
+      Logger.info(LOG_TAG, "Fetching announcements from " + uri.toASCIIString());
+      fetchAnnouncements(uri, delegate);
+    } catch (URISyntaxException e) {
+      Logger.warn(LOG_TAG, "Couldn't create URL.", e);
+      return;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/background/announcements/AnnouncementsService.java
@@ -0,0 +1,292 @@
+/* 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.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.net.URI;
+import java.util.List;
+import java.util.Locale;
+
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.background.BackgroundService;
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.common.log.Logger;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.IBinder;
+
+/**
+ * A Service to periodically check for new published announcements,
+ * presenting them to the user if local conditions permit.
+ *
+ * We extend IntentService, rather than just Service, 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.
+ *
+ * The operation of this service is as follows:
+ *
+ * 0. Decide if a request should be made.
+ * 1. Compute the arguments to the request. This includes enough
+ *    pertinent details to allow the server to pre-filter a message
+ *    set, recording enough tracking details to compute statistics.
+ * 2. Issue the request. If this succeeds with a 200 or 204, great;
+ *    track that timestamp for the next run through Step 0.
+ * 3. Process any received messages.
+ *
+ * Message processing is as follows:
+ *
+ * 0. Decide if message display should occur. This might involve
+ *    user preference or other kinds of environmental factors.
+ * 1. Use the AnnouncementPresenter to open the announcement.
+ *
+ * Future:
+ * * Persisting of multiple announcements.
+ * * Prioritization.
+ */
+public class AnnouncementsService extends BackgroundService implements AnnouncementsFetchDelegate {
+  private static final String WORKER_THREAD_NAME = "AnnouncementsServiceWorker";
+  private static final String LOG_TAG = "AnnounceService";
+
+  public AnnouncementsService() {
+    super(WORKER_THREAD_NAME);
+    Logger.setThreadLogTag(AnnouncementsConstants.GLOBAL_LOG_TAG);
+    Logger.debug(LOG_TAG, "Creating AnnouncementsService.");
+  }
+
+  public boolean shouldFetchAnnouncements() {
+    final long now = System.currentTimeMillis();
+
+    if (!backgroundDataIsEnabled()) {
+      Logger.debug(LOG_TAG, "Background data not possible. Skipping.");
+      return false;
+    }
+
+    // Don't fetch if we were told to back off.
+    if (getEarliestNextFetch() > now) {
+      return false;
+    }
+
+    // Don't do anything if we haven't waited long enough.
+    final long lastFetch = getLastFetch();
+
+    // Just in case the alarm manager schedules us more frequently, or something
+    // goes awry with relaunches.
+    if ((now - lastFetch) < AnnouncementsConstants.MINIMUM_FETCH_INTERVAL_MSEC) {
+      Logger.debug(LOG_TAG, "Returning: minimum fetch interval of " + AnnouncementsConstants.MINIMUM_FETCH_INTERVAL_MSEC + "ms not met.");
+      return false;
+    }
+
+    return true;
+  }
+
+  /**
+   * Display the first valid announcement in the list.
+   */
+  protected void processAnnouncements(final List<Announcement> announcements) {
+    if (announcements == null) {
+      Logger.warn(LOG_TAG, "No announcements to present.");
+      return;
+    }
+
+    boolean presented = false;
+    for (Announcement an : announcements) {
+      // Do this so we at least log, rather than just returning.
+      if (presented) {
+        Logger.warn(LOG_TAG, "Skipping announcement \"" + an.getTitle() + "\": one already shown.");
+        continue;
+      }
+      if (Announcement.isValidAnnouncement(an)) {
+        presented = true;
+        AnnouncementPresenter.displayAnnouncement(this, an);
+      }
+    }
+  }
+
+  /**
+   * If it's time to do a fetch -- we've waited long enough,
+   * we're allowed to use background data, etc. -- then issue
+   * a fetch. The subsequent background check is handled implicitly
+   * by the AlarmManager.
+   */
+  @Override
+  public void onHandleIntent(Intent intent) {
+    Logger.setThreadLogTag(AnnouncementsConstants.GLOBAL_LOG_TAG);
+
+    // Intent can be null. Bug 1025937.
+    if (intent == null) {
+      Logger.debug(LOG_TAG, "Short-circuiting on null intent.");
+      return;
+    }
+
+    Logger.debug(LOG_TAG, "Running AnnouncementsService.");
+
+    if (AnnouncementsConstants.DISABLED) {
+      Logger.debug(LOG_TAG, "Announcements disabled. Returning from AnnouncementsService.");
+      return;
+    }
+
+    if (!shouldFetchAnnouncements()) {
+      Logger.debug(LOG_TAG, "Not fetching.");
+      return;
+    }
+
+    // Ensure that our locale is up to date, so that the fetcher's
+    // Accept-Language header is, too.
+    BrowserLocaleManager.getInstance().getAndApplyPersistedLocale(getApplicationContext());
+
+    // Otherwise, grab our announcements URL and process the contents.
+    AnnouncementsFetcher.fetchAndProcessAnnouncements(getLastLaunch(), this);
+  }
+
+  @Override
+  public IBinder onBind(Intent intent) {
+    return null;
+  }
+
+  protected long getLastLaunch() {
+    return getSharedPreferences().getLong(AnnouncementsConstants.PREF_LAST_LAUNCH, 0);
+  }
+
+  protected SharedPreferences getSharedPreferences() {
+    return this.getSharedPreferences(AnnouncementsConstants.PREFS_BRANCH, GlobalConstants.SHARED_PREFERENCES_MODE);
+  }
+
+  @Override
+  protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
+    super.dump(fd, writer, args);
+
+    final long lastFetch = getLastFetch();
+    final long lastLaunch = getLastLaunch();
+    writer.write("AnnouncementsService: last fetch " + lastFetch +
+                 ", last Firefox activity: " + lastLaunch + "\n");
+  }
+
+  protected void setEarliestNextFetch(final long earliestInMsec) {
+    this.getSharedPreferences().edit().putLong(AnnouncementsConstants.PREF_EARLIEST_NEXT_ANNOUNCE_FETCH, earliestInMsec).commit();
+  }
+
+  protected long getEarliestNextFetch() {
+    return this.getSharedPreferences().getLong(AnnouncementsConstants.PREF_EARLIEST_NEXT_ANNOUNCE_FETCH, 0L);
+  }
+
+  protected void setLastFetch(final long fetch) {
+    this.getSharedPreferences().edit().putLong(AnnouncementsConstants.PREF_LAST_FETCH_LOCAL_TIME, fetch).commit();
+  }
+
+  @Override
+  public long getLastFetch() {
+    return this.getSharedPreferences().getLong(AnnouncementsConstants.PREF_LAST_FETCH_LOCAL_TIME, 0L);
+  }
+
+  protected String setLastDate(final String fetch) {
+    if (fetch == null) {
+      this.getSharedPreferences().edit().remove(AnnouncementsConstants.PREF_LAST_FETCH_SERVER_DATE).commit();
+      return null;
+    }
+    this.getSharedPreferences().edit().putString(AnnouncementsConstants.PREF_LAST_FETCH_SERVER_DATE, fetch).commit();
+    return fetch;
+  }
+
+  @Override
+  public String getLastDate() {
+    return this.getSharedPreferences().getString(AnnouncementsConstants.PREF_LAST_FETCH_SERVER_DATE, null);
+  }
+
+  /**
+   * Use this to write the persisted server URL, overriding
+   * the default value.
+   * @param url a URI identifying the full request path, e.g.,
+   *            "http://foo.com:1234/announce/"
+   */
+  public void setAnnouncementsServerBaseURL(final URI url) {
+    if (url == null) {
+      throw new IllegalArgumentException("url cannot be null.");
+    }
+    final String scheme = url.getScheme();
+    if (scheme == null) {
+      throw new IllegalArgumentException("url must have a scheme.");
+    }
+    if (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
+      throw new IllegalArgumentException("url must be http or https.");
+    }
+    SharedPreferences p = this.getSharedPreferences();
+    p.edit().putString(AnnouncementsConstants.PREF_ANNOUNCE_SERVER_BASE_URL, url.toASCIIString()).commit();
+  }
+
+  /**
+   * Return the service URL, including protocol version and application identifier. E.g.,
+   *
+   *   "https://campaigns.services.mozilla.com/announce/1/android/"
+   */
+  @Override
+  public String getServiceURL() {
+    SharedPreferences p = this.getSharedPreferences();
+    String base = p.getString(AnnouncementsConstants.PREF_ANNOUNCE_SERVER_BASE_URL, AnnouncementsConstants.DEFAULT_ANNOUNCE_SERVER_BASE_URL);
+    return base + AnnouncementsConstants.ANNOUNCE_PATH_SUFFIX;
+  }
+
+  @Override
+  public Locale getLocale() {
+    return Locale.getDefault();
+  }
+
+  @Override
+  public String getUserAgent() {
+    return AnnouncementsConstants.USER_AGENT;
+  }
+
+  protected void persistTimes(long fetched, String date) {
+    setLastFetch(fetched);
+    if (date != null) {
+      setLastDate(date);
+    }
+  }
+
+  @Override
+  public void onNoNewAnnouncements(long fetched, String date) {
+    Logger.info(LOG_TAG, "No new announcements to display.");
+    persistTimes(fetched, date);
+  }
+
+  @Override
+  public void onNewAnnouncements(List<Announcement> announcements, long fetched, String date) {
+    Logger.info(LOG_TAG, "Processing announcements: " + announcements.size());
+    persistTimes(fetched, date);
+    processAnnouncements(announcements);
+  }
+
+  @Override
+  public void onRemoteFailure(int status) {
+    // Bump our fetch timestamp.
+    Logger.warn(LOG_TAG, "Got remote fetch status " + status + "; bumping fetch time.");
+    setLastFetch(System.currentTimeMillis());
+  }
+
+  @Override
+  public void onRemoteError(Exception e) {
+    // Bump our fetch timestamp.
+    Logger.warn(LOG_TAG, "Error processing response.", e);
+    setLastFetch(System.currentTimeMillis());
+  }
+
+  @Override
+  public void onLocalError(Exception e) {
+    Logger.error(LOG_TAG, "Got exception in fetch.", e);
+    // Do nothing yet, so we'll retry.
+  }
+
+  @Override
+  public void onBackoff(int retryAfterInSeconds) {
+    Logger.info(LOG_TAG, "Got retry after: " + retryAfterInSeconds);
+    final long delayInMsec = Math.max(retryAfterInSeconds * 1000, AnnouncementsConstants.DEFAULT_BACKOFF_MSEC);
+    final long fuzzedBackoffInMsec = delayInMsec + Math.round(((double) delayInMsec * 0.25d * Math.random()));
+    Logger.debug(LOG_TAG, "Fuzzed backoff: " + fuzzedBackoffInMsec + "ms.");
+    setEarliestNextFetch(fuzzedBackoffInMsec + System.currentTimeMillis());
+  }
+}
--- a/mobile/android/base/background/common/GlobalConstants.java.in
+++ b/mobile/android/base/background/common/GlobalConstants.java.in
@@ -40,15 +40,16 @@ public class GlobalConstants {
   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.preferences.GeckoPreferences";
+  public static String GECKO_BROADCAST_ANNOUNCEMENTS_PREF_METHOD  = "broadcastAnnouncementsPref";
   public static String GECKO_BROADCAST_HEALTHREPORT_UPLOAD_PREF_METHOD  = "broadcastHealthReportUploadPref";
   public static String GECKO_BROADCAST_HEALTHREPORT_PRUNE_METHOD = "broadcastHealthReportPrune";
 
   // Common time values.
   public static final long MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
   public static final long MILLISECONDS_PER_SIX_MONTHS = 180 * MILLISECONDS_PER_DAY;
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/manifests/AnnouncementsAndroidManifest_activities.xml.in
@@ -0,0 +1,17 @@
+        <!--
+             As well as these system actions, we also listen for pref notifications
+             sent by Fennec: @ANDROID_PACKAGE_NAME@.ANNOUNCEMENTS_PREF.
+          -->
+        <receiver android:name="org.mozilla.gecko.background.announcements.AnnouncementsBroadcastReceiver" >
+            <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@.ANNOUNCEMENTS_PREF" />
+            </intent-filter>
+        </receiver>
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/manifests/AnnouncementsAndroidManifest_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/AnnouncementsAndroidManifest_services.xml.in
@@ -0,0 +1,8 @@
+        <service
+            android:exported="false"
+            android:name="org.mozilla.gecko.background.announcements.AnnouncementsService" >
+        </service>
+        <service
+            android:exported="false"
+            android:name="org.mozilla.gecko.background.announcements.AnnouncementsBroadcastService" >
+        </service>
--- a/mobile/android/tests/background/junit3/background_junit3_sources.mozbuild
+++ b/mobile/android/tests/background/junit3/background_junit3_sources.mozbuild
@@ -1,15 +1,16 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 background_junit3_sources = [
+    'src/announcements/TestAnnouncementsBroadcastService.java',
     'src/common/TestAndroidLogWriters.java',
     'src/common/TestBrowserContractHelpers.java',
     'src/common/TestDateUtils.java',
     'src/common/TestUtils.java',
     'src/common/TestWaitHelper.java',
     'src/db/AndroidBrowserRepositoryTestCase.java',
     'src/db/TestAndroidBrowserBookmarksRepository.java',
     'src/db/TestAndroidBrowserHistoryDataExtender.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit3/src/announcements/TestAnnouncementsBroadcastService.java
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.background.announcements;
+
+import java.util.concurrent.BrokenBarrierException;
+
+import org.mozilla.gecko.background.common.GlobalConstants;
+import org.mozilla.gecko.background.helpers.BackgroundServiceTestCase;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+public class TestAnnouncementsBroadcastService
+    extends BackgroundServiceTestCase<TestAnnouncementsBroadcastService.MockAnnouncementsBroadcastService> {
+  public static class MockAnnouncementsBroadcastService extends AnnouncementsBroadcastService {
+    @Override
+    protected SharedPreferences getSharedPreferences() {
+      return this.getSharedPreferences(sharedPrefsName,
+          GlobalConstants.SHARED_PREFERENCES_MODE);
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+      super.onHandleIntent(intent);
+      try {
+        barrier.await();
+      } catch (InterruptedException e) {
+        fail("Awaiting thread should not be interrupted.");
+      } catch (BrokenBarrierException e) {
+        // This will happen on timeout - do nothing.
+      }
+    }
+  }
+
+  public TestAnnouncementsBroadcastService() {
+    super(MockAnnouncementsBroadcastService.class);
+  }
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    // We can't mock AlarmManager since it has a package-private constructor, so instead we reset
+    // the alarm by hand.
+    cancelAlarm(getServiceIntent());
+  }
+
+  @Override
+  public void tearDown() throws Exception {
+    cancelAlarm(getServiceIntent());
+    AnnouncementsConstants.DISABLED = false;
+    super.tearDown();
+  }
+
+  protected Intent getServiceIntent() {
+    final Intent intent = new Intent(getContext(), AnnouncementsService.class);
+    return intent;
+  }
+
+  public void testIgnoredServicePrefIntents() throws Exception {
+    // Intent without "enabled" extra is ignored.
+    intent.setAction(AnnouncementsConstants.ACTION_ANNOUNCEMENTS_PREF);
+    startService(intent);
+    await();
+
+    assertFalse(isServiceAlarmSet(getServiceIntent()));
+  }
+
+  public void testServicePrefIntentDisabled() throws Exception {
+    intent.setAction(AnnouncementsConstants.ACTION_ANNOUNCEMENTS_PREF)
+        .putExtra("enabled", false);
+    startService(intent);
+    await();
+    assertFalse(isServiceAlarmSet(getServiceIntent()));
+  }
+
+  public void testServicePrefIntentEnabled() throws Exception {
+    intent.setAction(AnnouncementsConstants.ACTION_ANNOUNCEMENTS_PREF)
+        .putExtra("enabled", true);
+    startService(intent);
+    await();
+    assertTrue(isServiceAlarmSet(getServiceIntent()));
+  }
+
+  public void testServicePrefCancelled() throws Exception {
+    intent.setAction(AnnouncementsConstants.ACTION_ANNOUNCEMENTS_PREF)
+        .putExtra("enabled", true);
+    startService(intent);
+    await();
+
+    assertTrue(isServiceAlarmSet(getServiceIntent()));
+    barrier.reset();
+
+    intent.putExtra("enabled", false);
+    startService(intent);
+    await();
+    assertFalse(isServiceAlarmSet(getServiceIntent()));
+  }
+}