Bug 1044794 - Part 3: a service for handling share intents. r=rnewman
authorChris Kitching <chriskitching@linux.com>
Wed, 27 Aug 2014 18:59:41 -0700
changeset 224411 2aec90e5aed2ee7b855fceaf0559382dbc9aa9bd
parent 224410 e3cf9620539c3fce8c82091db3cff6d75b8612ee
child 224412 48e03a88b6a80479e53ef7e16693d4ed54fdbc21
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1044794
milestone34.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1044794 - Part 3: a service for handling share intents. r=rnewman
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/moz.build
mobile/android/base/overlays/OverlayConstants.java
mobile/android/base/overlays/service/OverlayActionService.java
mobile/android/base/overlays/service/sharemethods/AddBookmark.java
mobile/android/base/overlays/service/sharemethods/AddToReadingList.java
mobile/android/base/overlays/service/sharemethods/ParcelableClientRecord.java
mobile/android/base/overlays/service/sharemethods/SendTab.java
mobile/android/base/overlays/service/sharemethods/ShareMethod.java
mobile/android/base/overlays/ui/OverlayToastHelper.java
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -366,16 +366,20 @@
                   android:authorities="@ANDROID_PACKAGE_NAME@.db.browser"
                   android:permission="@ANDROID_PACKAGE_NAME@.permissions.BROWSER_PROVIDER">
 
             <path-permission android:pathPrefix="/search_suggest_query"
                              android:readPermission="android.permission.GLOBAL_SEARCH" />
 
         </provider>
 
+#ifdef MOZ_ANDROID_SHARE_OVERLAY
+        <!-- Service to handle requests from overlays. -->
+        <service android:name="org.mozilla.gecko.overlays.service.OverlayActionService" />
+#endif
         <!--
           Ensure that passwords provider runs in its own process. (Bug 718760.)
           Process name is per-application to avoid loading CPs from multiple
           Fennec versions into the same process. (Bug 749727.)
           Process name is a mangled version to avoid a Talos bug. (Bug 750548.)
           -->
         <provider android:name="org.mozilla.gecko.db.PasswordsProvider"
                   android:label="@string/sync_configure_engines_title_passwords"
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -474,16 +474,28 @@ gbjar.generated_sources += [
     'org/mozilla/gecko/widget/ThemedTextSwitcher.java',
     'org/mozilla/gecko/widget/ThemedTextView.java',
     'org/mozilla/gecko/widget/ThemedView.java',
 ]
 if CONFIG['MOZ_CRASHREPORTER']:
     gbjar.sources += [ 'CrashReporter.java' ]
     ANDROID_RES_DIRS += [ SRCDIR + '/crashreporter/res' ]
 
+if CONFIG['MOZ_ANDROID_SHARE_OVERLAY']:
+    gbjar.sources += [
+        'overlays/OverlayConstants.java',
+        'overlays/service/OverlayActionService.java',
+        'overlays/service/sharemethods/AddBookmark.java',
+        'overlays/service/sharemethods/AddToReadingList.java',
+        'overlays/service/sharemethods/ParcelableClientRecord.java',
+        'overlays/service/sharemethods/SendTab.java',
+        'overlays/service/sharemethods/ShareMethod.java',
+        'overlays/ui/OverlayToastHelper.java'
+    ]
+
 gbjar.sources += sync_java_files
 gbjar.generated_sources += sync_generated_java_files
 gbjar.extra_jars = [
     'gecko-R.jar',
     'gecko-mozglue.jar',
     'gecko-util.jar',
     'nineoldandroids.jar',
     'squareup-picasso.jar',
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/OverlayConstants.java
@@ -0,0 +1,68 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.overlays;
+
+/**
+ * Constants used by the share handler service (and clients).
+ * The intent API used by the service is defined herein.
+ */
+public class OverlayConstants {
+    /*
+     * OverlayIntentHandler service intent actions.
+     */
+
+    /*
+     * Causes the service to broadcast an intent containing state necessary for proper display of
+     * a UI to select a target share method.
+     *
+     * Intent parameters:
+     *
+     * None.
+     */
+    public static final String ACTION_PREPARE_SHARE = "org.mozilla.gecko.overlays.ACTION_PREPARE_SHARE";
+
+    /*
+     * Action for sharing a page.
+     *
+     * Intent parameters:
+     *
+     * $EXTRA_URL: URL of page to share.    (required)
+     * $EXTRA_SHARE_METHOD: Method(s) via which to share this url/title combination. Can be either a
+     *                ShareType or a ShareType[]
+     * $EXTRA_TITLE: Title of page to share (optional)
+     * $EXTRA_PARAMETERS: Parcelable of extra data to pass to the ShareMethod (optional)
+     */
+    public static final String ACTION_SHARE = "org.mozilla.gecko.overlays.ACTION_SHARE";
+
+    /*
+     * OverlayIntentHandler service intent extra field keys.
+     */
+
+    // The URL/title of the page being shared
+    public static final String EXTRA_URL = "URL";
+    public static final String EXTRA_TITLE = "TITLE";
+
+    // The optional extra Parcelable parameters for a ShareMethod.
+    public static final String EXTRA_PARAMETERS = "EXTRA";
+
+    // The extra field key used for holding one or more share method names (See above).
+    public static final String EXTRA_SHARE_METHOD = "SHARE_METHOD";
+
+    /*
+     * ShareMethod UI event intent constants. Broadcast by ShareMethods using LocalBroadcastManager
+     * when state has changed that requires an update of any currently-displayed share UI.
+     */
+
+    /*
+     * Action for a ShareMethod UI event.
+     *
+     * Intent parameters:
+     *
+     * $EXTRA_SHARE_METHOD: The ShareType to which this event relates.
+     * ... ShareType-specific parameters as desired... (optional)
+     */
+    public static final String SHARE_METHOD_UI_EVENT = "org.mozilla.gecko.overlays.ACTION_SHARE_METHOD_UI_EVENT";
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/service/OverlayActionService.java
@@ -0,0 +1,148 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.overlays.service;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.Parcelable;
+import android.util.Log;
+import android.view.View;
+import org.mozilla.gecko.Assert;
+import org.mozilla.gecko.overlays.OverlayConstants;
+import org.mozilla.gecko.overlays.service.sharemethods.AddBookmark;
+import org.mozilla.gecko.overlays.service.sharemethods.AddToReadingList;
+import org.mozilla.gecko.overlays.service.sharemethods.SendTab;
+import org.mozilla.gecko.overlays.service.sharemethods.ShareMethod;
+import org.mozilla.gecko.overlays.ui.OverlayToastHelper;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+import static org.mozilla.gecko.overlays.OverlayConstants.ACTION_PREPARE_SHARE;
+import static org.mozilla.gecko.overlays.OverlayConstants.ACTION_SHARE;
+import static org.mozilla.gecko.overlays.OverlayConstants.EXTRA_SHARE_METHOD;
+
+/**
+ * A service to receive requests from overlays to perform actions.
+ * See OverlayConstants for details of the intent API supported by this service.
+ *
+ * Currently supported operations are:
+ *
+ * Add bookmark*
+ * Add to reading list*
+ * Send tab (delegates to Sync's existing handler)
+ * Future: Load page in background.
+ *
+ * * Neither of these incur a page fetch on the service... yet. That will require headless Gecko,
+ *   something we're yet to have. Refactoring Gecko as a service itself and restructing the rest of
+ *   the app to talk to it seems like the way to go there.
+ */
+public class OverlayActionService extends Service {
+    private static final String LOGTAG = "GeckoOverlayService";
+
+    // Map used for selecting the appropriate helper object when handling a share.
+    private final Map<ShareMethod.Type, ShareMethod> shareTypes = new EnumMap<>(ShareMethod.Type.class);
+
+    // Map relating Strings representing share types to the corresponding ShareMethods.
+    // Share methods are initialised (and shown in the UI) in the order they are given here.
+    // This map is used to look up the appropriate ShareMethod when handling a request, as well as
+    // for identifying which ShareMethod needs re-initialising in response to such an intent (which
+    // will be necessary in situations such as the deletion of Sync accounts).
+
+    // Not a bindable service.
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        if (intent == null) {
+            return START_NOT_STICKY;
+        }
+
+        // Dispatch intent to appropriate method according to its action.
+        String action = intent.getAction();
+
+        switch (action) {
+            case ACTION_SHARE:
+                handleShare(intent);
+                break;
+            case ACTION_PREPARE_SHARE:
+                initShareMethods(getApplicationContext());
+                break;
+            default:
+                throw new IllegalArgumentException("Unsupported intent action: " + action);
+        }
+
+        return START_NOT_STICKY;
+    }
+
+    /**
+     * Reinitialise all ShareMethods, causing them to broadcast any UI update events necessary.
+     */
+    private void initShareMethods(Context context) {
+        shareTypes.clear();
+
+        shareTypes.put(ShareMethod.Type.ADD_BOOKMARK, new AddBookmark(context));
+        shareTypes.put(ShareMethod.Type.ADD_TO_READING_LIST, new AddToReadingList(context));
+        shareTypes.put(ShareMethod.Type.SEND_TAB, new SendTab(context));
+    }
+
+    public void handleShare(final Intent intent) {
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                Bundle extras = intent.getExtras();
+
+                // Fish the parameters out of the Intent.
+                final String url = extras.getString(OverlayConstants.EXTRA_URL);
+                final String title = extras.getString(OverlayConstants.EXTRA_TITLE);
+                final Parcelable extra = extras.getParcelable(OverlayConstants.EXTRA_PARAMETERS);
+
+                if (url == null) {
+                    Log.e(LOGTAG, "Null url passed to handleShare!");
+                    return;
+                }
+
+                ShareMethod.Type shareMethodType = (ShareMethod.Type) extras.get(EXTRA_SHARE_METHOD);
+                ShareMethod shareMethod = shareTypes.get(shareMethodType);
+
+                final ShareMethod.Result result = shareMethod.handle(title, url, extra);
+                // Dispatch the share to the targeted ShareMethod.
+                switch (result) {
+                    case SUCCESS:
+                        // \o/
+                        OverlayToastHelper.showSuccessToast(getApplicationContext(), shareMethod.getSuccessMesssage());
+                        break;
+                    case TRANSIENT_FAILURE:
+                        // An OnClickListener to do this share again.
+                        View.OnClickListener retryListener = new View.OnClickListener() {
+                            @Override
+                            public void onClick(View view) {
+                                handleShare(intent);
+                            }
+                        };
+
+                        // Show a failure toast with a retry button.
+                        OverlayToastHelper.showFailureToast(getApplicationContext(), shareMethod.getFailureMessage(), retryListener);
+                        break;
+                    case PERMANENT_FAILURE:
+                        // Show a failure toast without a retry button.
+                        OverlayToastHelper.showFailureToast(getApplicationContext(), shareMethod.getFailureMessage());
+                        break;
+                    default:
+                        Assert.isTrue(false, "Unknown share method result code: " + result);
+                        break;
+                }
+            }
+        });
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/service/sharemethods/AddBookmark.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.overlays.service.sharemethods;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.Parcelable;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.LocalBrowserDB;
+
+public class AddBookmark extends ShareMethod {
+    private static final String LOGTAG = "GeckoAddBookmark";
+
+    @Override
+    public Result handle(String title, String url, Parcelable unused) {
+        ContentResolver resolver = context.getContentResolver();
+
+        LocalBrowserDB browserDB = new LocalBrowserDB(GeckoProfile.DEFAULT_PROFILE);
+        browserDB.addBookmark(resolver, url, title);
+
+        return Result.SUCCESS;
+    }
+
+    public String getSuccessMesssage() {
+        return context.getResources().getString(R.string.bookmark_added);
+    }
+
+    // Unused.
+    @Override
+    public String getFailureMessage() {
+        return null;
+    }
+
+    public AddBookmark(Context context) {
+        super(context);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/service/sharemethods/AddToReadingList.java
@@ -0,0 +1,56 @@
+/* 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.overlays.service.sharemethods;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.os.Parcelable;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.db.LocalBrowserDB;
+
+import static org.mozilla.gecko.db.BrowserContract.Bookmarks;
+
+/**
+ * ShareMethod to add a page to the reading list.
+ *
+ * Inserts the given URL/title pair into the reading list database.
+ * TODO: In the event the page turns out not to be reader-mode-compatible, freezes sometimes occur
+ * when we subsequently load the page in reader mode. (Bug 1044781)
+ */
+public class AddToReadingList extends ShareMethod {
+    private static final String LOGTAG = "GeckoAddToReadingList";
+
+    @Override
+    public Result handle(String title, String url, Parcelable unused) {
+        ContentResolver resolver = context.getContentResolver();
+
+        LocalBrowserDB browserDB = new LocalBrowserDB(GeckoProfile.DEFAULT_PROFILE);
+
+        ContentValues values = new ContentValues();
+        values.put(Bookmarks.TITLE, title);
+        values.put(Bookmarks.URL, url);
+
+        browserDB.addReadingListItem(resolver, values);
+
+        return Result.SUCCESS;
+    }
+
+    @Override
+    public String getSuccessMesssage() {
+        return context.getResources().getString(R.string.reading_list_added);
+    }
+
+    // Unused.
+    @Override
+    public String getFailureMessage() {
+        return null;
+    }
+
+    public AddToReadingList(Context context) {
+        super(context);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/service/sharemethods/ParcelableClientRecord.java
@@ -0,0 +1,72 @@
+/* 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.overlays.service.sharemethods;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+
+/**
+ * An immutable representation of a Sync ClientRecord for Parceling, storing only name, guid, type.
+ * Implemented this way instead of by making ClientRecord itself parcelable to avoid an undesirable
+ * dependency between Sync and the IPC system used by the share system (things which really should
+ * be kept as independent as possible).
+ */
+public class ParcelableClientRecord implements Parcelable {
+    private static final String LOGTAG = "GeckoParcelableClientRecord";
+
+    public final String name;
+    public final String type;
+    public final String guid;
+
+    private ParcelableClientRecord(String aName, String aType, String aGUID) {
+        name = aName;
+        type = aType;
+        guid = aGUID;
+    }
+
+    /**
+     * Create a ParcelableClientRecord from a vanilla ClientRecord.
+     */
+    public static ParcelableClientRecord fromClientRecord(ClientRecord record) {
+        return new ParcelableClientRecord(record.name, record.type, record.guid);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel parcel, int flags) {
+        parcel.writeString(name);
+        parcel.writeString(type);
+        parcel.writeString(guid);
+    }
+
+    public static final Creator<ParcelableClientRecord> CREATOR = new Creator<ParcelableClientRecord>() {
+        @Override
+        public ParcelableClientRecord createFromParcel(final Parcel source) {
+            String name = source.readString();
+            String type = source.readString();
+            String guid = source.readString();
+
+            return new ParcelableClientRecord(name, type, guid);
+        }
+
+        @Override
+        public ParcelableClientRecord[] newArray(final int size) {
+            return new ParcelableClientRecord[size];
+        }
+    };
+
+    /**
+     * Used by SendTabDeviceListArrayAdapter to populate ListViews.
+     */
+    @Override
+    public String toString() {
+        return name;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/service/sharemethods/SendTab.java
@@ -0,0 +1,370 @@
+/* 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.overlays.service.sharemethods;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.fxa.FirefoxAccounts;
+import org.mozilla.gecko.fxa.FxAccountConstants;
+import org.mozilla.gecko.fxa.activities.FxAccountGetStartedActivity;
+import org.mozilla.gecko.fxa.activities.FxAccountStatusActivity;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.fxa.login.State;
+import org.mozilla.gecko.overlays.OverlayConstants;
+import org.mozilla.gecko.sync.CommandProcessor;
+import org.mozilla.gecko.sync.CommandRunner;
+import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.SyncConfiguration;
+import org.mozilla.gecko.sync.SyncConstants;
+import org.mozilla.gecko.sync.repositories.NullCursorException;
+import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.setup.SyncAccounts;
+import org.mozilla.gecko.sync.syncadapter.SyncAdapter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * ShareMethod implementation to handle Sync's "Send tab to device" mechanism.
+ * See OverlayConstants for documentation of OverlayIntentHandler service intent API (which is how
+ * this class is chiefly interacted with).
+ */
+public class SendTab extends ShareMethod {
+    private static final String LOGTAG = "GeckoSendTab";
+
+    // Key used in the extras Bundle in the share intent used for a send tab ShareMethod.
+    public static final String SEND_TAB_TARGET_DEVICES = "SEND_TAB_TARGET_DEVICES";
+
+    // Key used in broadcast intent from SendTab ShareMethod specifying available ClientRecords.
+    public static final String EXTRA_CLIENT_RECORDS = "RECORDS";
+
+    // The intent we should dispatch when the button for this ShareMethod is tapped, instead of
+    // taking the normal action (eg. "Set up sync!")
+    public static final String OVERRIDE_INTENT = "OVERRIDE_INTENT";
+
+    private Set<String> validGUIDs;
+
+    // A TabSender appropriate to the account type we're connected to.
+    private TabSender tabSender;
+
+    @Override
+    public Result handle(String title, String url, Parcelable extra) {
+        if (extra == null) {
+            Log.e(LOGTAG, "No target devices specified!");
+
+            // Retrying with an identical lack of devices ain't gonna fix it...
+            return Result.PERMANENT_FAILURE;
+        }
+
+        String[] targetGUIDs = ((Bundle) extra).getStringArray(SEND_TAB_TARGET_DEVICES);
+
+        // Ensure all target GUIDs are devices we actually know about.
+        if (!validGUIDs.containsAll(Arrays.asList(targetGUIDs))) {
+            // Find the set of invalid GUIDs to provide a nice error message.
+            Log.e(LOGTAG, "Not all provided GUIDs are real devices:");
+            for (String targetGUID : targetGUIDs) {
+                if (!validGUIDs.contains(targetGUID)) {
+                    Log.e(LOGTAG, "Invalid GUID: " + targetGUID);
+                }
+            }
+
+            return Result.PERMANENT_FAILURE;
+        }
+
+        Log.i(LOGTAG, "Send tab handler invoked.");
+
+        final CommandProcessor processor = CommandProcessor.getProcessor();
+
+        final String accountGUID = tabSender.getAccountGUID();
+        Log.d(LOGTAG, "Retrieved local account GUID '" + accountGUID + "'.");
+
+        if (accountGUID == null) {
+            Log.e(LOGTAG, "Cannot determine account GUID");
+
+            // It's not completely out of the question that a background sync might come along and
+            // fix everything for us...
+            return Result.TRANSIENT_FAILURE;
+        }
+
+        // Queue up the share commands for each destination device.
+        // Remember that ShareMethod.handle is always run on the background thread, so the database
+        // access here is of no concern.
+        for (int i = 0; i < targetGUIDs.length; i++) {
+            processor.sendURIToClientForDisplay(url, targetGUIDs[i], title, accountGUID, context);
+        }
+
+        // Request an immediate sync to push these new commands to the network ASAP.
+        Log.i(LOGTAG, "Requesting immediate clients stage sync.");
+        tabSender.sync();
+
+        return Result.SUCCESS;
+        // ... Probably.
+    }
+
+    /**
+     * Get an Intent suitable for broadcasting the UI state of this ShareMethod.
+     * The caller shall populate the intent with the actual state.
+     */
+    private Intent getUIStateIntent() {
+        Intent uiStateIntent = new Intent(OverlayConstants.SHARE_METHOD_UI_EVENT);
+        uiStateIntent.putExtra(OverlayConstants.EXTRA_SHARE_METHOD, (Parcelable) Type.SEND_TAB);
+        return uiStateIntent;
+    }
+
+    /**
+     * Broadcast the given intent to any UIs that may be listening.
+     */
+    private void broadcastUIState(Intent uiStateIntent) {
+        LocalBroadcastManager.getInstance(context).sendBroadcast(uiStateIntent);
+    }
+
+    /**
+     * Load the state of the user's Firefox Sync accounts and broadcast it to any registered
+     * listeners. This will cause any UIs that may exist that depend on this information to update.
+     */
+    public SendTab(Context aContext) {
+        super(aContext);
+        // Initialise the UI state intent...
+
+        // Determine if the user has a new or old style sync account and load the available sync
+        // clients for it.
+        final AccountManager accountManager = AccountManager.get(context);
+        final Account[] fxAccounts = accountManager.getAccountsByType(FxAccountConstants.ACCOUNT_TYPE);
+
+        if (fxAccounts.length > 0) {
+            final AndroidFxAccount fxAccount = new AndroidFxAccount(context, fxAccounts[0]);
+            if (fxAccount.getState().getNeededAction() != State.Action.None) {
+                // We have a Firefox Account, but it's definitely not able to send a tab
+                // right now. Redirect to the status activity.
+                Log.w(LOGTAG, "Firefox Account named like " + fxAccount.getObfuscatedEmail() +
+                              " needs action before it can send a tab; redirecting to status activity.");
+
+                setOverrideIntent(FxAccountStatusActivity.class);
+                return;
+            }
+
+            tabSender = new FxAccountTabSender(fxAccount);
+
+            updateClientList(tabSender);
+
+            Log.i(LOGTAG, "Allowing tab send for Firefox Account.");
+            registerDisplayURICommand();
+            return;
+        }
+
+        final Account[] syncAccounts = accountManager.getAccountsByType(SyncConstants.ACCOUNTTYPE_SYNC);
+        if (syncAccounts.length > 0) {
+            tabSender = new Sync11TabSender(context, syncAccounts[0], accountManager);
+
+            updateClientList(tabSender);
+
+            Log.i(LOGTAG, "Allowing tab send for Sync account.");
+            registerDisplayURICommand();
+            return;
+        }
+
+        // Have registered UIs offer to set up a Firefox Account.
+        setOverrideIntent(FxAccountGetStartedActivity.class);
+    }
+
+    /**
+     * Load the list of Sync clients that are not this device using the given TabSender.
+     */
+    private void updateClientList(TabSender tabSender) {
+        Collection<ClientRecord> otherClients = getOtherClients(tabSender);
+
+        ParcelableClientRecord[] records = new ParcelableClientRecord[otherClients.size()];
+        validGUIDs = new HashSet<>();
+        int i = 0;
+
+        // Put the list of ClientRecords into the uiStateIntent and broadcast it.
+        for (ClientRecord client : otherClients) {
+            ParcelableClientRecord record = ParcelableClientRecord.fromClientRecord(client);
+
+            records[i] = record;
+
+            validGUIDs.add(record.guid);
+            i++;
+        }
+
+        Intent uiStateIntent = getUIStateIntent();
+        uiStateIntent.putExtra(EXTRA_CLIENT_RECORDS, records);
+        broadcastUIState(uiStateIntent);
+    }
+
+    /**
+     * Record our intention to redirect the user to a different activity when they attempt to share
+     * with us, usually because we found something wrong with their Sync account (a need to login,
+     * register, etc.)
+     * This will be recorded in the OVERRIDE_INTENT field of the UI broadcast. Consumers should
+     * dispatch this intent instead of attempting to share with this ShareMethod whenever it is
+     * non-null.
+     *
+     * @param activityClass The class of the activity we wish to launch instead of invoking a share.
+     */
+    protected void setOverrideIntent(Class<? extends Activity> activityClass) {
+        Intent intent = new Intent(context, activityClass);
+        // Per http://stackoverflow.com/a/8992365, this triggers a known bug with
+        // the soft keyboard not being shown for the started activity. Why, Android, why?
+        intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+        Intent uiStateIntent = getUIStateIntent();
+        uiStateIntent.putExtra(OVERRIDE_INTENT, intent);
+        broadcastUIState(uiStateIntent);
+    }
+
+    private static void registerDisplayURICommand() {
+        final CommandProcessor processor = CommandProcessor.getProcessor();
+        processor.registerCommand("displayURI", new CommandRunner(3) {
+            @Override
+            public void executeCommand(final GlobalSession session, List<String> args) {
+                CommandProcessor.displayURI(args, session.getContext());
+            }
+        });
+    }
+
+    /**
+     * @return A map from GUID to client record for all sync clients, including our own; or null iff
+     * ClientsDatabaseAccessor.fetchAllClients throws NullCursorException.
+     */
+    protected Map<String, ClientRecord> getAllClients() {
+        ClientsDatabaseAccessor db = new ClientsDatabaseAccessor(context);
+        try {
+            return db.fetchAllClients();
+        } catch (NullCursorException e) {
+            Log.w(LOGTAG, "NullCursorException while populating device list.", e);
+            return null;
+        } finally {
+            db.close();
+        }
+    }
+
+    /**
+     * @return a collection of client records, excluding our own.
+     */
+    protected Collection<ClientRecord> getOtherClients(final TabSender sender) {
+        if (sender == null) {
+            Log.w(LOGTAG, "No tab sender when fetching other client IDs.");
+            return Collections.emptyList();
+        }
+
+        final Map<String, ClientRecord> all = getAllClients();
+        if (all == null) {
+            return Collections.emptyList();
+        }
+
+        final String ourGUID = sender.getAccountGUID();
+        if (ourGUID == null) {
+            return all.values();
+        }
+
+        final ArrayList<ClientRecord> out = new ArrayList<>(all.size());
+        for (Map.Entry<String, ClientRecord> entry : all.entrySet()) {
+            if (!ourGUID.equals(entry.getKey())) {
+                out.add(entry.getValue());
+            }
+        }
+
+        return out;
+    }
+
+    @Override
+    public String getSuccessMesssage() {
+        return context.getResources().getString(R.string.sync_text_tab_sent);
+    }
+
+    @Override
+    public String getFailureMessage() {
+        return context.getResources().getString(R.string.sync_text_tab_not_sent);
+    }
+
+    /**
+     * Inteface for interacting with Sync accounts. Used to hide the difference in implementation
+     * between FXA and "old sync" accounts when sending tabs.
+     */
+    private interface TabSender {
+        public static final String[] STAGES_TO_SYNC = new String[] { "clients", "tabs" };
+
+        /**
+         * @return Return null if the account isn't correctly initialized. Return
+         *         the account GUID otherwise.
+         */
+        String getAccountGUID();
+
+        /**
+         * Sync this account, specifying only clients and tabs as the engines to sync.
+         */
+        void sync();
+    }
+
+    private static class FxAccountTabSender implements TabSender {
+        private final AndroidFxAccount fxAccount;
+
+        public FxAccountTabSender(AndroidFxAccount fxa) {
+            fxAccount = fxa;
+        }
+
+        @Override
+        public String getAccountGUID() {
+            try {
+                final SharedPreferences prefs = fxAccount.getSyncPrefs();
+                return prefs.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null);
+            } catch (Exception e) {
+                Log.w(LOGTAG, "Could not get Firefox Account parameters or preferences; aborting.");
+                return null;
+            }
+        }
+
+        @Override
+        public void sync() {
+            fxAccount.requestSync(FirefoxAccounts.FORCE, STAGES_TO_SYNC, null);
+        }
+    }
+
+    private static class Sync11TabSender implements TabSender {
+        private final Account account;
+        private final AccountManager accountManager;
+        private final Context context;
+
+        private Sync11TabSender(Context aContext, Account syncAccount, AccountManager manager) {
+            context = aContext;
+            account = syncAccount;
+            accountManager = manager;
+        }
+
+        @Override
+        public String getAccountGUID() {
+            try {
+                SharedPreferences prefs = SyncAccounts.blockingPrefsFromDefaultProfileV0(context, accountManager, account);
+                return prefs.getString(SyncConfiguration.PREF_ACCOUNT_GUID, null);
+            } catch (Exception e) {
+                Log.w(LOGTAG, "Could not get Sync account parameters or preferences; aborting.");
+                return null;
+            }
+        }
+
+        @Override
+        public void sync() {
+            SyncAdapter.requestImmediateSync(account, STAGES_TO_SYNC);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/service/sharemethods/ShareMethod.java
@@ -0,0 +1,92 @@
+/*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.overlays.service.sharemethods;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * Represents a method of sharing a URL/title. Add a bookmark? Send to a device? Add to reading list?
+ */
+public abstract class ShareMethod {
+    protected final Context context;
+
+    public ShareMethod(Context aContext) {
+        context = aContext;
+    }
+
+    /**
+     * Perform a share for the given title/URL combination. Called on the background thread by the
+     * handler service when a request is made. The "extra" parameter is provided should a ShareMethod
+     * desire to handle the share differently based on some additional parameters.
+     *
+     * @param title The page title for the page being shared. May be null if none can be found.
+     * @param url The URL of the page to be shared. Never null.
+     * @param extra A Parcelable of ShareMethod-specific parameters that may be provided by the
+     *              caller. Generally null, but this field may be used to provide extra input to
+     *              the ShareMethod (such as the device to share to in the case of SendTab).
+     * @return true if the attempt to share was a success. False in the event of an error.
+     */
+    public abstract Result handle(String title, String url, Parcelable extra);
+
+    /**
+     * Convenience method for calling handlers on objects that don't require extra data.
+     */
+    public Result handle(String title, String url) {
+        return handle(title, url, null);
+    }
+
+    public abstract String getSuccessMesssage();
+    public abstract String getFailureMessage();
+
+    /**
+     * Enum representing the possible results of performing a share.
+     */
+    public static enum Result {
+        // Victory!
+        SUCCESS,
+
+        // Failure, but retrying the same action again might lead to success.
+        TRANSIENT_FAILURE,
+
+        // Failure, and you're not going to succeed until you reinitialise the ShareMethod (ie.
+        // until you repeat the entire share action). Examples include broken Sync accounts, or
+        // Sync accounts with no valid target devices (so the only way to fix this is to add some
+        // and try again: pushing a retry button isn't sane).
+        PERMANENT_FAILURE
+    }
+
+    /**
+     * Enum representing types of ShareMethod. Parcelable so it may be efficiently used in Intents.
+     */
+    public static enum Type implements Parcelable {
+        ADD_BOOKMARK,
+        ADD_TO_READING_LIST,
+        SEND_TAB;
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        @Override
+        public void writeToParcel(final Parcel dest, final int flags) {
+            dest.writeInt(ordinal());
+        }
+
+        public static final Creator<Type> CREATOR = new Creator<Type>() {
+            @Override
+            public Type createFromParcel(final Parcel source) {
+                return Type.values()[source.readInt()];
+            }
+
+            @Override
+            public Type[] newArray(final int size) {
+                return new Type[size];
+            }
+        };
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/overlays/ui/OverlayToastHelper.java
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.overlays.ui;
+
+import android.content.Context;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.mozilla.gecko.R;
+
+/**
+ * Static helper class for generating toasts for share events.
+ *
+ * The overlay toasts come in a variety of flavours: success (rectangle with happy green tick,
+ * failure (no tick, a retry button), and success-with-tutorial (as success, but with a pretty
+ * picture of some description to educate the user on how to use the feature) TODO: Bug 1048645.
+ */
+public class OverlayToastHelper {
+    /**
+     * Show a toast indicating a failure to share.
+     * @param context Context in which to inflate the toast.
+     * @param failureMessage String to display in the toast.
+     * @param isTransient Should a retry button be presented?
+     * @param retryListener Listener to fire when the retry button is pressed.
+     */
+    public static void showFailureToast(Context context, String failureMessage, boolean isTransient, View.OnClickListener retryListener) {
+        showToast(context, failureMessage, isTransient, retryListener);
+    }
+    public static void showFailureToast(Context context, String failureMessage, boolean isTransient) {
+        showFailureToast(context, failureMessage, isTransient, null);
+    }
+
+    /**
+     * Show a toast indicating a successful share.
+     * @param successMesssage Message to show in the toast.
+     */
+    public static void showSuccessToast(Context context, String successMesssage) {
+        showToast(context, successMesssage, false, null);
+    }
+
+    private static void showToast(Context context, String message, boolean withRetry, View.OnClickListener retryListener) {
+    }
+}