Bug 1124492 - Allow for distribution intent processing to occur after first use. r=margaret, a=sledru
authorRichard Newman <rnewman@mozilla.com>
Mon, 26 Jan 2015 10:02:39 -0800
changeset 243620 2dd8d79e19e4
parent 243619 6696e78c24ed
child 243621 e32f606d51e3
push id4419
push userryanvm@gmail.com
push date2015-02-02 15:44 +0000
treeherdermozilla-beta@ea6cff5fd829 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmargaret, sledru
bugs1124492
milestone36.0
Bug 1124492 - Allow for distribution intent processing to occur after first use. r=margaret, a=sledru
mobile/android/base/GeckoProfile.java
mobile/android/base/db/SuggestedSites.java
mobile/android/base/distribution/Distribution.java
mobile/android/base/distribution/ReferrerReceiver.java
mobile/android/base/health/BrowserHealthRecorder.java
mobile/android/base/tests/testDistribution.java
mobile/android/base/widget/ActivityChooserModel.java
mobile/android/chrome/content/browser.js
mobile/android/search/java/org/mozilla/search/providers/SearchEngineManager.java
--- a/mobile/android/base/GeckoProfile.java
+++ b/mobile/android/base/GeckoProfile.java
@@ -12,16 +12,17 @@ import java.io.IOException;
 import java.io.OutputStreamWriter;
 import java.nio.charset.Charset;
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Hashtable;
 
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.GeckoProfileDirectories.NoSuchProfileException;
+import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.LocalBrowserDB;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.mozglue.RobocopTarget;
 import org.mozilla.gecko.util.INIParser;
 import org.mozilla.gecko.util.INISection;
 
 import android.app.Activity;
 import android.content.ContentResolver;
@@ -711,19 +712,24 @@ public final class GeckoProfile {
      */
     @RobocopTarget
     public void enqueueInitialization(final File profileDir) {
         Log.i(LOGTAG, "Enqueuing profile init.");
         final Context context = mApplicationContext;
 
         // Add everything when we're done loading the distribution.
         final Distribution distribution = Distribution.getInstance(context);
-        distribution.addOnDistributionReadyCallback(new Runnable() {
+        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
             @Override
-            public void run() {
+            public void distributionNotFound() {
+                this.distributionFound(null);
+            }
+
+            @Override
+            public void distributionFound(Distribution distribution) {
                 Log.d(LOGTAG, "Running post-distribution task: bookmarks.");
 
                 final ContentResolver cr = context.getContentResolver();
 
                 // Because we are running in the background, we want to synchronize on the
                 // GeckoProfile instance so that we don't race with main thread operations
                 // such as locking/unlocking/removing the profile.
                 synchronized (GeckoProfile.this) {
@@ -733,15 +739,34 @@ public final class GeckoProfile {
                     }
 
                     // We pass the number of added bookmarks to ensure that the
                     // indices of the distribution and default bookmarks are
                     // contiguous. Because there are always at least as many
                     // bookmarks as there are favicons, we can also guarantee that
                     // the favicon IDs won't overlap.
                     final LocalBrowserDB db = new LocalBrowserDB(getName());
-                    final int offset = db.addDistributionBookmarks(cr, distribution, 0);
+                    final int offset = distribution == null ? 0 : db.addDistributionBookmarks(cr, distribution, 0);
                     db.addDefaultBookmarks(context, cr, offset);
                 }
             }
+
+            @Override
+            public void distributionArrivedLate(Distribution distribution) {
+                Log.d(LOGTAG, "Running late distribution task: bookmarks.");
+                // Recover as best we can.
+                synchronized (GeckoProfile.this) {
+                    // Skip initialization if the profile directory has been removed.
+                    if (!profileDir.exists()) {
+                        return;
+                    }
+
+                    final LocalBrowserDB db = new LocalBrowserDB(getName());
+                    // We assume we've been called very soon after startup, and so our offset
+                    // into "Mobile Bookmarks" is the number of bookmarks in the DB.
+                    final ContentResolver cr = context.getContentResolver();
+                    final int offset = db.getCount(cr, "bookmarks");
+                    db.addDistributionBookmarks(cr, distribution, offset);
+                }
+            }
         });
     }
 }
--- a/mobile/android/base/db/SuggestedSites.java
+++ b/mobile/android/base/db/SuggestedSites.java
@@ -284,27 +284,26 @@ public class SuggestedSites {
         }
     }
 
     private void maybeWaitForDistribution() {
         if (distribution == null) {
             return;
         }
 
-        distribution.addOnDistributionReadyCallback(new Runnable() {
+        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
             @Override
-            public void run() {
-                Log.d(LOGTAG, "Running post-distribution task: suggested sites.");
-
+            public void distributionNotFound() {
                 // If distribution doesn't exist, simply continue to load
                 // suggested sites directly from resources. See refresh().
-                if (!distribution.exists()) {
-                    return;
-                }
+            }
 
+            @Override
+            public void distributionFound(Distribution distribution) {
+                Log.d(LOGTAG, "Running post-distribution task: suggested sites.");
                 // Merge suggested sites from distribution with the
                 // default ones. Distribution takes precedence.
                 Map<String, Site> sites = loadFromDistribution(distribution);
                 if (sites == null) {
                     sites = new LinkedHashMap<String, Site>();
                 }
                 sites.putAll(loadFromResource());
 
@@ -316,16 +315,21 @@ public class SuggestedSites {
                 synchronized (file) {
                     saveSites(file, sites);
                 }
 
                 // Then notify any active loaders about the changes.
                 final ContentResolver cr = context.getContentResolver();
                 cr.notifyChange(BrowserContract.SuggestedSites.CONTENT_URI, null);
             }
+
+            @Override
+            public void distributionArrivedLate(Distribution distribution) {
+                distributionFound(distribution);
+            }
         });
     }
 
     /**
      * Loads suggested sites from a distribution file either matching the
      * current locale or with the fallback locale (en-US).
      *
      * It's assumed that the given distribution instance is ready to be
--- a/mobile/android/base/distribution/Distribution.java
+++ b/mobile/android/base/distribution/Distribution.java
@@ -31,22 +31,24 @@ import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
 import javax.net.ssl.SSLException;
 
 import org.apache.http.protocol.HTTP;
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.mozglue.RobocopTarget;
 import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.app.Activity;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.os.SystemClock;
 import android.util.Log;
 
@@ -99,39 +101,63 @@ public class Distribution {
     private static final int CODE_CATEGORY_FETCH_SOCKET_ERROR = 11;
     private static final int CODE_CATEGORY_FETCH_SSL_ERROR = 12;
     private static final int CODE_CATEGORY_FETCH_NON_SUCCESS_RESPONSE = 13;
     private static final int CODE_CATEGORY_FETCH_INVALID_CONTENT_TYPE = 14;
 
     // Corresponds to the high value in Histograms.json.
     private static final long MAX_DOWNLOAD_TIME_MSEC = 40000;    // 40 seconds.
 
-    // Wait just a little while for the system to send a referrer intent after install.
-    private static final long DELAY_WAIT_FOR_REFERRER_MSEC = 400;
+    // If this is true, ready callbacks that arrive after our state is initially determined
+    // will be queued for delayed running.
+    // This should only be the case on first run, when we're in STATE_NONE.
+    // Implicitly accessed from any non-UI threads via Distribution.doInit, but in practice only one
+    // will actually perform initialization, and "non-UI thread" really means "background thread".
+    private volatile boolean shouldDelayLateCallbacks = false;
 
+    /**
+     * These tasks can be queued to run when a distribution is available.
+     *
+     * If <code>distributionFound</code> is called, it will be the only call.
+     * If <code>distributionNotFound</code> is called, it might be followed by
+     * a call to <code>distributionArrivedLate</code>.
+     *
+     * When <code>distributionNotFound</code> is called,
+     * {@link org.mozilla.gecko.distribution.Distribution#exists()} will return
+     * false. In the other two callbacks, it will return true.
+     */
+    public interface ReadyCallback {
+        void distributionNotFound();
+        void distributionFound(Distribution distribution);
+        void distributionArrivedLate(Distribution distribution);
+    }
 
     /**
      * Used as a drop-off point for ReferrerReceiver. Checked when we process
      * first-run distribution.
      *
      * This is `protected` so that test code can clear it between runs.
      */
     @RobocopTarget
     protected static volatile ReferrerDescriptor referrer;
 
     private static Distribution instance;
 
     private final Context context;
     private final String packagePath;
     private final String prefsBranch;
 
-    private volatile int state = STATE_UNKNOWN;
+    volatile int state = STATE_UNKNOWN;
     private File distributionDir;
 
-    private final Queue<Runnable> onDistributionReady = new ConcurrentLinkedQueue<Runnable>();
+    private final Queue<ReadyCallback> onDistributionReady = new ConcurrentLinkedQueue<>();
+
+    // Callbacks in this queue have been invoked once as distributionNotFound.
+    // If they're invoked again, it'll be with distributionArrivedLate.
+    private final Queue<ReadyCallback> onLateReady = new ConcurrentLinkedQueue<>();
 
     /**
      * This is a little bit of a bad singleton, because in principle a Distribution
      * can be created with arbitrary paths. So we only have one path to get here, and
      * it uses the default arguments. Watch out if you're creating your own instances!
      */
     public static synchronized Distribution getInstance(Context context) {
         if (instance == null) {
@@ -241,19 +267,61 @@ public class Distribution {
     }
 
     /**
      * This method is called by ReferrerReceiver when we receive a post-install
      * notification from Google Play.
      *
      * @param ref a parsed referrer value from the store-supplied intent.
      */
-    public static void onReceivedReferrer(ReferrerDescriptor ref) {
+    public static void onReceivedReferrer(final Context context, final ReferrerDescriptor ref) {
         // Track the referrer object for distribution handling.
         referrer = ref;
+
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                final Distribution distribution = Distribution.getInstance(context);
+
+                // This will bail if we aren't delayed, or we already have a distribution.
+                distribution.processDelayedReferrer(ref);
+            }
+        });
+    }
+
+    /**
+     * Handle a referrer intent that arrives after first use of the distribution.
+     */
+    private void processDelayedReferrer(final ReferrerDescriptor ref) {
+        ThreadUtils.assertOnBackgroundThread();
+        if (state != STATE_NONE) {
+            return;
+        }
+
+        Log.i(LOGTAG, "Processing delayed referrer.");
+
+        if (!checkIntentDistribution(ref)) {
+            // Oh well. No sense keeping these tasks around.
+            this.onLateReady.clear();
+            return;
+        }
+
+        // Persist our new state.
+        this.state = STATE_SET;
+        getSharedPreferences().edit().putInt(getKeyName(), this.state).apply();
+
+        // Just in case this isn't empty but doInit has finished.
+        runReadyQueue();
+
+        // Now process any tasks that already ran while we were in STATE_NONE
+        // to tell them of our good news.
+        runLateReadyQueue();
+
+        // Make sure that changes to search defaults are applied immediately.
+        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Distribution:Changed", ""));
     }
 
     /**
      * Helper to grab a file in the distribution directory.
      *
      * Returns null if there is no distribution directory or the file
      * doesn't exist. Ensures init first.
      */
@@ -339,83 +407,86 @@ public class Distribution {
      * @return true if we've set a distribution.
      */
     @RobocopTarget
     protected boolean doInit() {
         ThreadUtils.assertNotOnUiThread();
 
         // Bail if we've already tried to initialize the distribution, and
         // there wasn't one.
-        final SharedPreferences settings;
-        if (prefsBranch == null) {
-            settings = GeckoSharedPrefs.forApp(context);
-        } else {
-            settings = context.getSharedPreferences(prefsBranch, Activity.MODE_PRIVATE);
-        }
+        final SharedPreferences settings = getSharedPreferences();
 
-        String keyName = context.getPackageName() + ".distribution_state";
+        final String keyName = getKeyName();
         this.state = settings.getInt(keyName, STATE_UNKNOWN);
+
         if (this.state == STATE_NONE) {
             runReadyQueue();
             return false;
         }
 
         // We've done the work once; don't do it again.
         if (this.state == STATE_SET) {
             // Note that we don't compute the distribution directory.
             // Call `ensureDistributionDir` if you need it.
             runReadyQueue();
             return true;
         }
 
         // We try the install intent, then the APK, then the system directory.
         final boolean distributionSet =
-                checkIntentDistribution() ||
+                checkIntentDistribution(referrer) ||
                 checkAPKDistribution() ||
                 checkSystemDistribution();
 
+        // If this is our first run -- and thus we weren't already in STATE_NONE or STATE_SET above --
+        // and we didn't find a distribution already, then we should hold on to callbacks in case we
+        // get a late distribution.
+        this.shouldDelayLateCallbacks = !distributionSet;
         this.state = distributionSet ? STATE_SET : STATE_NONE;
         settings.edit().putInt(keyName, this.state).apply();
 
         runReadyQueue();
         return distributionSet;
     }
 
     /**
      * If applicable, download and select the distribution specified in
      * the referrer intent.
      *
      * @return true if a referrer-supplied distribution was selected.
      */
-    private boolean checkIntentDistribution() {
+    private boolean checkIntentDistribution(final ReferrerDescriptor referrer) {
         if (referrer == null) {
-            // Wait a predetermined time and try again.
-            // Just block the thread, because it's the simplest solution.
-            try {
-                Thread.sleep(DELAY_WAIT_FOR_REFERRER_MSEC);
-            } catch (InterruptedException e) {
-                // Good enough.
-            }
-            if (referrer == null) {
-                return false;
-            }
+            return false;
         }
 
         URI uri = getReferredDistribution(referrer);
         if (uri == null) {
             return false;
         }
 
         long start = SystemClock.uptimeMillis();
         Log.v(LOGTAG, "Downloading referred distribution: " + uri);
 
         try {
-            HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
+            final HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
 
-            connection.setRequestProperty(HTTP.USER_AGENT, GeckoAppShell.getGeckoInterface().getDefaultUAString());
+            // If the Search Activity starts, and we handle the referrer intent, this'll return
+            // null. Recover gracefully in this case.
+            final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface();
+            final String ua;
+            if (geckoInterface == null) {
+                // Fall back to GeckoApp's default implementation.
+                ua = HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET :
+                                                AppConstants.USER_AGENT_FENNEC_MOBILE;
+            } else {
+                ua = geckoInterface.getDefaultUAString();
+            }
+
+            connection.setRequestProperty(HTTP.USER_AGENT, ua);
             connection.setRequestProperty("Accept", EXPECTED_CONTENT_TYPE);
 
             try {
                 final JarInputStream distro;
                 try {
                     distro = fetchDistribution(uri, connection);
                 } catch (Exception e) {
                     Log.e(LOGTAG, "Error fetching distribution from network.", e);
@@ -531,28 +602,16 @@ public class Distribution {
             Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_SOCKET_ERROR);
             return;
         }
 
         Telemetry.addToHistogram(HISTOGRAM_CODE_CATEGORY, CODE_CATEGORY_FETCH_EXCEPTION);
     }
 
     /**
-     * Execute tasks that wanted to run when we were done loading
-     * the distribution. These tasks are expected to call {@link #exists()}
-     * to find out whether there's a distribution or not.
-     */
-    private void runReadyQueue() {
-        Runnable task;
-        while ((task = onDistributionReady.poll()) != null) {
-            ThreadUtils.postToBackgroundThread(task);
-        }
-    }
-
-    /**
      * @return true if we copied files out of the APK. Sets distributionDir in that case.
      */
     private boolean checkAPKDistribution() {
         try {
             // First, try copying distribution files out of the APK.
             if (copyFiles()) {
                 // We always copy to the data dir, and we only copy files from
                 // a 'distribution' subdirectory. Track our dist dir now that
@@ -754,31 +813,101 @@ public class Distribution {
         return context.getApplicationInfo().dataDir;
     }
 
     private File getSystemDistributionDir() {
         return new File("/system/" + context.getPackageName() + "/distribution");
     }
 
     /**
-     * The provided <code>Runnable</code> will be queued for execution after
+     * The provided <code>ReadyCallback</code> will be queued for execution after
      * the distribution is ready, or queued for immediate execution if the
      * distribution has already been processed.
      *
-     * Each <code>Runnable</code> will be executed on the background thread.
+     * Each <code>ReadyCallback</code> will be executed on the background thread.
      */
-    public void addOnDistributionReadyCallback(Runnable runnable) {
+    public void addOnDistributionReadyCallback(final ReadyCallback callback) {
         if (state == STATE_UNKNOWN) {
-            this.onDistributionReady.add(runnable);
+            // Queue for later.
+            onDistributionReady.add(callback);
         } else {
-            // If we're already initialized, just queue up the runnable.
-            ThreadUtils.postToBackgroundThread(runnable);
+            invokeCallbackDelayed(callback);
+        }
+    }
+
+    /**
+     * Run our delayed queue, after a delayed distribution arrives.
+     */
+    private void runLateReadyQueue() {
+        ReadyCallback task;
+        while ((task = onLateReady.poll()) != null) {
+            invokeLateCallbackDelayed(task);
+        }
+    }
+
+    /**
+     * Execute tasks that wanted to run when we were done loading
+     * the distribution.
+     */
+    private void runReadyQueue() {
+        ReadyCallback task;
+        while ((task = onDistributionReady.poll()) != null) {
+            invokeCallbackDelayed(task);
         }
     }
 
+    private void invokeLateCallbackDelayed(final ReadyCallback callback) {
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                // Sanity.
+                if (state != STATE_SET) {
+                    Log.w(LOGTAG, "Refusing to invoke late distro callback in state " + state);
+                    return;
+                }
+                callback.distributionArrivedLate(Distribution.this);
+            }
+        });
+    }
+
+    private void invokeCallbackDelayed(final ReadyCallback callback) {
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                switch (state) {
+                    case STATE_SET:
+                        callback.distributionFound(Distribution.this);
+                        break;
+                    case STATE_NONE:
+                        callback.distributionNotFound();
+                        if (shouldDelayLateCallbacks) {
+                            onLateReady.add(callback);
+                        }
+                        break;
+                    default:
+                        throw new IllegalStateException("Expected STATE_NONE or STATE_SET, got " + state);
+                }
+            }
+        });
+    }
+
     /**
      * A safe way for callers to determine if this Distribution instance
      * represents a real live distribution.
      */
     public boolean exists() {
         return state == STATE_SET;
     }
+
+    private String getKeyName() {
+        return context.getPackageName() + ".distribution_state";
+    }
+
+    private SharedPreferences getSharedPreferences() {
+        final SharedPreferences settings;
+        if (prefsBranch == null) {
+            settings = GeckoSharedPrefs.forApp(context);
+        } else {
+            settings = context.getSharedPreferences(prefsBranch, Activity.MODE_PRIVATE);
+        }
+        return settings;
+    }
 }
--- a/mobile/android/base/distribution/ReferrerReceiver.java
+++ b/mobile/android/base/distribution/ReferrerReceiver.java
@@ -44,17 +44,17 @@ public class ReferrerReceiver extends Br
             // This should never happen.
             return;
         }
 
         ReferrerDescriptor referrer = new ReferrerDescriptor(intent.getStringExtra("referrer"));
 
         // Track the referrer object for distribution handling.
         if (TextUtils.equals(referrer.campaign, DISTRIBUTION_UTM_CAMPAIGN)) {
-            Distribution.onReceivedReferrer(referrer);
+            Distribution.onReceivedReferrer(context, referrer);
         } else {
             Log.d(LOGTAG, "Not downloading distribution: non-matching campaign.");
         }
 
         // If this is a Mozilla campaign, pass the campaign along to Gecko.
         if (TextUtils.equals(referrer.source, MOZILLA_UTM_SOURCE)) {
             propagateMozillaCampaign(referrer);
         }
--- a/mobile/android/base/health/BrowserHealthRecorder.java
+++ b/mobile/android/base/health/BrowserHealthRecorder.java
@@ -526,27 +526,60 @@ public class BrowserHealthRecorder imple
         this.profileCache.beginInitialization();
         this.profileCache.setProfileCreationTime(getAndPersistProfileInitTime(context, profilePath));
         this.profileCache.setOSLocale(osLocale);
         this.profileCache.setAppLocale(appLocale);
 
         // Because the distribution lookup can take some time, do it at the end of
         // our background startup work, along with the Gecko snapshot fetch.
         final Distribution distribution = Distribution.getInstance(context);
-        distribution.addOnDistributionReadyCallback(new Runnable() {
+        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
+            private void requestGeckoFields() {
+                Log.d(LOG_TAG, "Requesting all add-ons and FHR prefs from Gecko.");
+                dispatcher.registerGeckoThreadListener(BrowserHealthRecorder.this, EVENT_SNAPSHOT);
+                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("HealthReport:RequestSnapshot", null));
+            }
+
             @Override
-            public void run() {
+            public void distributionNotFound() {
+                requestGeckoFields();
+            }
+
+            @Override
+            public void distributionFound(Distribution distribution) {
                 Log.d(LOG_TAG, "Running post-distribution task: health recorder.");
                 final DistributionDescriptor desc = distribution.getDescriptor();
                 if (desc != null && desc.valid) {
                     profileCache.setDistributionString(desc.id, desc.version);
                 }
-                Log.d(LOG_TAG, "Requesting all add-ons and FHR prefs from Gecko.");
-                dispatcher.registerGeckoThreadListener(BrowserHealthRecorder.this, EVENT_SNAPSHOT);
-                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("HealthReport:RequestSnapshot", null));
+                requestGeckoFields();
+            }
+
+            @Override
+            public void distributionArrivedLate(Distribution distribution) {
+                profileCache.beginInitialization();
+
+                final DistributionDescriptor desc = distribution.getDescriptor();
+                if (desc != null && desc.valid) {
+                    profileCache.setDistributionString(desc.id, desc.version);
+                }
+
+                // Now rebuild.
+                try {
+                    profileCache.completeInitialization();
+
+                    if (state == State.INITIALIZING) {
+                        initializeStorage();
+                    } else {
+                        onEnvironmentChanged();
+                    }
+                } catch (Exception e) {
+                    // Well, we tried.
+                    Log.e(LOG_TAG, "Couldn't complete profile cache init.", e);
+                }
             }
         });
     }
 
     /**
      * Invoked in the background whenever the environment transitions between
      * two valid values.
      */
--- a/mobile/android/base/tests/testDistribution.java
+++ b/mobile/android/base/tests/testDistribution.java
@@ -164,17 +164,32 @@ public class testDistribution extends Co
         doTestInvalidReferrerIntent();
     }
 
     private void setOSLocale(Locale locale) {
         Locale.setDefault(locale);
         BrowserLocaleManager.storeAndNotifyOSLocale(GeckoSharedPrefs.forProfile(mActivity), locale);
     }
 
-    private void doReferrerTest(String ref, final TestableDistribution distribution, final Runnable distributionReady) throws InterruptedException {
+    private abstract class ExpectNoDistributionCallback implements Distribution.ReadyCallback {
+            @Override
+            public void distributionFound(final Distribution distribution) {
+                mAsserter.ok(false, "No distributionFound.", "Wasn't expecting a distribution!");
+                synchronized (distribution) {
+                    distribution.notifyAll();
+                }
+            }
+
+            @Override
+            public void distributionArrivedLate(final Distribution distribution) {
+                mAsserter.ok(false, "No distributionArrivedLate.", "Wasn't expecting a late distribution!");
+            }
+    }
+
+    private void doReferrerTest(String ref, final TestableDistribution distribution, final Distribution.ReadyCallback distributionReady) throws InterruptedException {
         final Intent intent = new Intent(ACTION_INSTALL_REFERRER);
         intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, CLASS_REFERRER_RECEIVER);
         intent.putExtra("referrer", ref);
 
         final BroadcastReceiver receiver = new BroadcastReceiver() {
             @Override
             public void onReceive(Context context, Intent intent) {
                 Log.i(LOGTAG, "Test received " + intent.getAction());
@@ -206,20 +221,20 @@ public class testDistribution extends Co
 
     public void doTestValidReferrerIntent() throws Exception {
         // Equivalent to
         // am broadcast -a com.android.vending.INSTALL_REFERRER \
         //              -n org.mozilla.fennec/org.mozilla.gecko.distribution.ReferrerReceiver \
         //              --es "referrer" "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=distribution"
         final String ref = "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=distribution";
         final TestableDistribution distribution = new TestableDistribution(mActivity);
-        final Runnable distributionReady = new Runnable() {
+        final Distribution.ReadyCallback distributionReady = new ExpectNoDistributionCallback() {
             @Override
-            public void run() {
-                Log.i(LOGTAG, "Test told distribution is ready.");
+            public void distributionNotFound() {
+                Log.i(LOGTAG, "Test told distribution processing is done.");
                 mAsserter.ok(!distribution.exists(), "Not processed.", "No download because we're offline.");
                 ReferrerDescriptor referrerValue = TestableDistribution.getReferrerDescriptorForTesting();
                 mAsserter.dumpLog("Referrer was " + referrerValue);
                 mAsserter.is(referrerValue.content, "testcontent", "Referrer content");
                 mAsserter.is(referrerValue.medium, "testmedium", "Referrer medium");
                 mAsserter.is(referrerValue.campaign, "distribution", "Referrer campaign");
                 synchronized (distribution) {
                     distribution.notifyAll();
@@ -237,19 +252,19 @@ public class testDistribution extends Co
      */
     public void doTestInvalidReferrerIntent() throws Exception {
         // Equivalent to
         // am broadcast -a com.android.vending.INSTALL_REFERRER \
         //              -n org.mozilla.fennec/org.mozilla.gecko.distribution.ReferrerReceiver \
         //              --es "referrer" "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=testname"
         final String ref = "utm_source=mozilla&utm_medium=testmedium&utm_term=testterm&utm_content=testcontent&utm_campaign=testname";
         final TestableDistribution distribution = new TestableDistribution(mActivity);
-        final Runnable distributionReady = new Runnable() {
+        final Distribution.ReadyCallback distributionReady = new ExpectNoDistributionCallback() {
             @Override
-            public void run() {
+            public void distributionNotFound() {
                 mAsserter.ok(!distribution.exists(), "Not processed.", "No download because campaign was wrong.");
                 ReferrerDescriptor referrerValue = TestableDistribution.getReferrerDescriptorForTesting();
                 mAsserter.is(referrerValue, null, "No referrer.");
                 synchronized (distribution) {
                     distribution.notifyAll();
                 }
             }
         };
--- a/mobile/android/base/widget/ActivityChooserModel.java
+++ b/mobile/android/base/widget/ActivityChooserModel.java
@@ -1044,41 +1044,44 @@ public class ActivityChooserModel extend
             if (!f.exists()) {
                 // Fall back to the non-profile aware file if it exists...
                 File oldFile = new File(mHistoryFileName);
                 oldFile.renameTo(f);
             }
             readHistoricalDataFromStream(new FileInputStream(f));
         } catch (FileNotFoundException fnfe) {
             final Distribution dist = Distribution.getInstance(mContext);
-            dist.addOnDistributionReadyCallback(new Runnable() {
+            dist.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
                 @Override
-                public void run() {
-                    Log.d(LOGTAG, "Running post-distribution task: quickshare.");
+                public void distributionNotFound() {
+                }
 
-                    if (!dist.exists()) {
-                        return;
-                    }
-
+                @Override
+                public void distributionFound(Distribution distribution) {
                     try {
                         File distFile = dist.getDistributionFile("quickshare/" + mHistoryFileName);
                         if (distFile == null) {
                             if (DEBUG) {
                                 Log.i(LOG_TAG, "Could not open historical records file: " + mHistoryFileName);
                             }
                             return;
                         }
                         readHistoricalDataFromStream(new FileInputStream(distFile));
                     } catch (Exception ex) {
                         if (DEBUG) {
                             Log.i(LOG_TAG, "Could not open historical records file: " + mHistoryFileName);
                         }
                         return;
                     }
                 }
+
+                @Override
+                public void distributionArrivedLate(Distribution distribution) {
+                    distributionFound(distribution);
+                }
             });
         }
     }
 
     void readHistoricalDataFromStream(FileInputStream fis) {
         try {
             XmlPullParser parser = Xml.newPullParser();
             parser.setInput(fis, null);
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -7467,29 +7467,39 @@ var ExternalApps = {
   },
 };
 
 var Distribution = {
   // File used to store campaign data
   _file: null,
 
   init: function dc_init() {
+    Services.obs.addObserver(this, "Distribution:Changed", false);
     Services.obs.addObserver(this, "Distribution:Set", false);
     Services.obs.addObserver(this, "prefservice:after-app-defaults", false);
     Services.obs.addObserver(this, "Campaign:Set", false);
 
     // Look for file outside the APK:
     // /data/data/org.mozilla.xxx/distribution.json
     this._file = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
     this._file.append("distribution.json");
     this.readJSON(this._file, this.update);
   },
 
   observe: function dc_observe(aSubject, aTopic, aData) {
     switch (aTopic) {
+      case "Distribution:Changed":
+        // Re-init the search service.
+        try {
+          Services.search._asyncReInit();
+        } catch (e) {
+          console.log("Unable to reinit search service.");
+        }
+        // Fall through.
+
       case "Distribution:Set":
         // Reload the default prefs so we can observe "prefservice:after-app-defaults"
         Services.prefs.QueryInterface(Ci.nsIObserver).observe(null, "reload-default-prefs", null);
         break;
 
       case "prefservice:after-app-defaults":
         this.getPrefs();
         break;
--- a/mobile/android/search/java/org/mozilla/search/providers/SearchEngineManager.java
+++ b/mobile/android/search/java/org/mozilla/search/providers/SearchEngineManager.java
@@ -62,19 +62,19 @@ public class SearchEngineManager impleme
         this.context = context;
         this.distribution = distribution;
         GeckoSharedPrefs.forApp(context).registerOnSharedPreferenceChangeListener(this);
     }
 
     /**
      * Sets a callback to be called when the default engine changes.
      *
-     * @param callback SearchEngineCallback to be called after the search engine
-     *                 changed. This will run on the UI thread.
-     *                 Note: callback may be called with null engine.
+     * @param changeCallback SearchEngineCallback to be called after the search engine
+     *                       changed. This will run on the UI thread.
+     *                       Note: callback may be called with null engine.
      */
     public void setChangeCallback(SearchEngineCallback changeCallback) {
         this.changeCallback = changeCallback;
     }
 
     /**
      * Perform an action with the user's default search engine.
      *
@@ -136,35 +136,67 @@ public class SearchEngineManager impleme
      * the distribution (if one exists), and finally fall back to the localized default.
      *
      * @param callback SearchEngineCallback to be called after successfully looking
      *                 up the search engine. This will run on the UI thread.
      *                 Note: callback may be called with null engine.
      */
     private void getDefaultEngine(final SearchEngineCallback callback) {
         // This runnable is posted to the background thread.
-        distribution.addOnDistributionReadyCallback(new Runnable() {
+        distribution.addOnDistributionReadyCallback(new Distribution.ReadyCallback() {
+            @Override
+            public void distributionNotFound() {
+                defaultBehavior();
+            }
+
+            @Override
+            public void distributionFound(Distribution distribution) {
+                defaultBehavior();
+            }
+
             @Override
-            public void run() {
+            public void distributionArrivedLate(Distribution distribution) {
+                // Let's see if there's a name in the distro.
+                // If so, just this once we'll override the saved value.
+                final String name = getDefaultEngineNameFromDistribution();
+
+                if (name == null) {
+                    return;
+                }
+
+                // Store the default engine name for the future.
+                // Increment an 'ignore' counter so that this preference change
+                // won't cause getDefaultEngine to be called again.
+                ignorePreferenceChange++;
+                GeckoSharedPrefs.forApp(context)
+                        .edit()
+                        .putString(PREF_DEFAULT_ENGINE_KEY, name)
+                        .apply();
+
+                final SearchEngine engine = createEngineFromName(name);
+                runCallback(engine, callback);
+            }
+
+            private void defaultBehavior() {
                 // First look for a default name stored in shared preferences.
                 String name = GeckoSharedPrefs.forApp(context).getString(PREF_DEFAULT_ENGINE_KEY, null);
 
                 if (name != null) {
                     Log.d(LOG_TAG, "Found default engine name in SharedPreferences: " + name);
                 } else {
                     // First, look for the default search engine in a distribution.
                     name = getDefaultEngineNameFromDistribution();
                     if (name == null) {
                         // Otherwise, get the default engine that we ship.
                         name = getDefaultEngineNameFromLocale();
                     }
 
                     // Store the default engine name for the future.
                     // Increment an 'ignore' counter so that this preference change
-                    // won'tcause getDefaultEngine to be called again.
+                    // won't cause getDefaultEngine to be called again.
                     ignorePreferenceChange++;
                     GeckoSharedPrefs.forApp(context)
                                     .edit()
                                     .putString(PREF_DEFAULT_ENGINE_KEY, name)
                                     .apply();
                 }
 
                 final SearchEngine engine = createEngineFromName(name);