Bug 936756 - Part 2: implement loading of locale from prefs. r=mfinkle
authorRichard Newman <rnewman@mozilla.com>
Sun, 01 Dec 2013 21:53:17 -0800
changeset 172851 686b4b5a5be3a5da1ddee082319944ebf6a0e816
parent 172850 606c4b4ab77f858f023e1f838219ceba45c7448d
child 172852 c46514ce6886b72bc6bce0b40026bfb790a35abc
push id3224
push userlsblakk@mozilla.com
push dateTue, 04 Feb 2014 01:06:49 +0000
treeherdermozilla-beta@60c04d0987f1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmfinkle
bugs936756
milestone28.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 936756 - Part 2: implement loading of locale from prefs. r=mfinkle
mobile/android/base/BrowserApp.java
mobile/android/base/GeckoActivity.java
mobile/android/base/GeckoApp.java
mobile/android/base/GeckoAppShell.java
mobile/android/base/GeckoApplication.java
mobile/android/base/LocaleManager.java
mobile/android/base/health/BrowserHealthRecorder.java
mobile/android/base/home/HomePager.java
mobile/android/base/home/TabMenuStrip.java
mobile/android/base/moz.build
mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
mobile/android/chrome/content/browser.js
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -1634,16 +1634,25 @@ abstract public class BrowserApp extends
             hideHomePager();
         }
     }
 
     private void showHomePager(HomePager.Page page) {
         showHomePagerWithAnimator(page, null);
     }
 
+    @Override
+    public void onLocaleReady(final String locale) {
+        super.onLocaleReady(locale);
+        if (mHomePager != null) {
+            // Blow it away and rebuild it with the right strings.
+            mHomePager.redisplay(getSupportFragmentManager());
+        }
+    }
+
     private void showHomePagerWithAnimator(HomePager.Page page, PropertyAnimator animator) {
         if (isHomePagerVisible()) {
             return;
         }
 
         // Refresh toolbar height to possibly restore the toolbar padding
         refreshToolbarHeight();
 
--- a/mobile/android/base/GeckoActivity.java
+++ b/mobile/android/base/GeckoActivity.java
@@ -7,16 +7,25 @@ package org.mozilla.gecko;
 import android.content.ComponentName;
 import android.content.Intent;
 import android.support.v4.app.FragmentActivity;
 
 public class GeckoActivity extends FragmentActivity implements GeckoActivityStatus {
     // has this activity recently started another Gecko activity?
     private boolean mGeckoActivityOpened = false;
 
+    /**
+     * Display any resources that show strings or encompass locale-specific
+     * representations.
+     *
+     * onLocaleReady must always be called on the UI thread.
+     */
+    public void onLocaleReady(final String locale) {
+    }
+
     @Override
     public void onPause() {
         super.onPause();
 
         if (getApplication() instanceof GeckoApplication) {
             ((GeckoApplication) getApplication()).onActivityPause(this);
         }
     }
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -710,16 +710,18 @@ public abstract class GeckoApp
                 }
                 JSONObject handlersJSON = new JSONObject();
                 handlersJSON.put("apps", new JSONArray(appList));
                 mCurrentResponse = handlersJSON.toString();
             } else if (event.equals("Intent:Open")) {
                 GeckoAppShell.openUriExternal(message.optString("url"),
                     message.optString("mime"), message.optString("packageName"),
                     message.optString("className"), message.optString("action"), message.optString("title"));
+            } else if (event.equals("Locale:Set")) {
+                setLocale(message.getString("locale"));
             }
         } catch (Exception e) {
             Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
         }
     }
 
     public String getResponse(JSONObject origMessage) {
         String res = mCurrentResponse;
@@ -1200,27 +1202,16 @@ public abstract class GeckoApp
 
         Tabs.getInstance().attachToContext(this);
         try {
             Favicons.attachToContext(this);
         } catch (Exception e) {
             Log.e(LOGTAG, "Exception starting favicon cache. Corrupt resources?", e);
         }
 
-        // When we detect a locale change, we need to restart Gecko, which
-        // actually means restarting the entire application. This logic should
-        // actually be handled elsewhere since GeckoApp may not be alive to
-        // handle this event if "Don't keep activities" is enabled (filed as
-        // bug 889082).
-        if (((GeckoApplication)getApplication()).needsRestart()) {
-            doRestart();
-            System.exit(0);
-            return;
-        }
-
         if (GeckoThread.isCreated()) {
             // This happens when the GeckoApp activity is destroyed by Android
             // without killing the entire application (see Bug 769269).
             mIsRestoringActivity = true;
             Telemetry.HistogramAdd("FENNEC_RESTORING_ACTIVITY", 1);
         }
 
         // Fix for Bug 830557 on Tegra boards running Froyo.
@@ -1286,16 +1277,20 @@ public abstract class GeckoApp
         }
 
         // Perform background initialization.
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 final SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
 
+                // Wait until now to set this, because we'd rather throw an exception than 
+                // have a caller of LocaleManager regress startup.
+                LocaleManager.setContextGetter(GeckoApp.this);
+
                 SessionInformation previousSession = SessionInformation.fromSharedPrefs(prefs);
                 if (previousSession.wasKilled()) {
                     Telemetry.HistogramAdd("FENNEC_WAS_KILLED", 1);
                 }
 
                 SharedPreferences.Editor editor = prefs.edit();
                 editor.putBoolean(GeckoApp.PREFS_OOM_EXCEPTION, false);
 
@@ -1306,34 +1301,70 @@ public abstract class GeckoApp
                 editor.commit();
 
                 // The lifecycle of mHealthRecorder is "shortly after onCreate"
                 // through "onDestroy" -- essentially the same as the lifecycle
                 // of the activity itself.
                 final String profilePath = getProfile().getDir().getAbsolutePath();
                 final EventDispatcher dispatcher = GeckoAppShell.getEventDispatcher();
                 Log.i(LOGTAG, "Creating BrowserHealthRecorder.");
+
                 final String osLocale = Locale.getDefault().toString();
-                Log.d(LOGTAG, "Locale is " + osLocale);
-
-                // Replace the duplicate `osLocale` argument when we support switchable
-                // application locales.
+                String appLocale = LocaleManager.getAndApplyPersistedLocale();
+                Log.d(LOGTAG, "OS locale is " + osLocale + ", app locale is " + appLocale);
+
+                if (appLocale == null) {
+                    appLocale = osLocale;
+                }
+
                 mHealthRecorder = new BrowserHealthRecorder(GeckoApp.this,
                                                             profilePath,
                                                             dispatcher,
                                                             osLocale,
-                                                            osLocale,    // Placeholder.
+                                                            appLocale,
                                                             previousSession);
+
+                final String uiLocale = appLocale;
+                ThreadUtils.postToUiThread(new Runnable() {
+                    @Override
+                    public void run() {
+                        GeckoApp.this.onLocaleReady(uiLocale);
+                    }
+                });
             }
         });
 
         GeckoAppShell.setNotificationClient(makeNotificationClient());
         NotificationHelper.init(getApplicationContext());
     }
 
+    /**
+     * At this point, the resource system and the rest of the browser are
+     * aware of the locale.
+     *
+     * Now we can display strings!
+     */
+    @Override
+    public void onLocaleReady(final String locale) {
+        if (!ThreadUtils.isOnUiThread()) {
+            throw new RuntimeException("onLocaleReady must always be called from the UI thread.");
+        }
+
+        // The URL bar hint needs to be populated.
+        TextView urlBar = (TextView) findViewById(R.id.url_bar_title);
+        if (urlBar == null) {
+            return;
+        }
+        final String hint = getResources().getString(R.string.url_bar_default_text);
+        urlBar.setHint(hint);
+
+        // Allow onConfigurationChanged to take care of the rest.
+        onConfigurationChanged(getResources().getConfiguration());
+    }
+
     protected void initializeChrome() {
         mDoorHangerPopup = new DoorHangerPopup(this, null);
         mPluginContainer = (AbsoluteLayout) findViewById(R.id.plugin_container);
         mFormAssistPopup = (FormAssistPopup) findViewById(R.id.form_assist_popup);
 
         if (mCameraView == null) {
             if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
                 mCameraView = new SurfaceView(this);
@@ -1533,16 +1564,17 @@ public abstract class GeckoApp
         registerEventListener("Sanitize:ClearHistory");
         registerEventListener("Update:Check");
         registerEventListener("Update:Download");
         registerEventListener("Update:Install");
         registerEventListener("PrivateBrowsing:Data");
         registerEventListener("Contact:Add");
         registerEventListener("Intent:Open");
         registerEventListener("Intent:GetHandlers");
+        registerEventListener("Locale:Set");
 
         if (SmsManager.getInstance() != null) {
           SmsManager.getInstance().start();
         }
 
         mContactService = new ContactService(GeckoAppShell.getEventDispatcher(), this);
 
         mPromptService = new PromptService(this);
@@ -1586,33 +1618,16 @@ public abstract class GeckoApp
                 final Context context = GeckoApp.this;
                 AnnouncementsBroadcastService.recordLastLaunch(context);
 
                 // Kick off our background services. We do this by invoking the broadcast
                 // receiver, which uses the system alarm infrastructure to perform tasks at
                 // intervals.
                 GeckoPreferences.broadcastAnnouncementsPref(context);
                 GeckoPreferences.broadcastHealthReportUploadPref(context);
-
-                /*
-                XXXX see Bug 635342.
-                We want to disable this code if possible.  It is about 145ms in runtime.
-
-                If this code ever becomes live again, you'll need to chain the
-                new locale into BrowserHealthRecorder correctly. See
-                GeckoAppShell.setSelectedLocale.
-                We pass the OS locale into the BHR constructor: we need to grab
-                that *before* we modify the current locale!
-
-                SharedPreferences settings = getPreferences(Activity.MODE_PRIVATE);
-                String localeCode = settings.getString(getPackageName() + ".locale", "");
-                if (localeCode != null && localeCode.length() > 0)
-                    GeckoAppShell.setSelectedLocale(localeCode);
-                */
-
                 if (!GeckoThread.checkLaunchState(GeckoThread.LaunchState.Launched)) {
                     return;
                 }
             }
         }, 50);
 
         if (mIsRestoringActivity) {
             GeckoThread.setLaunchState(GeckoThread.LaunchState.GeckoRunning);
@@ -2147,16 +2162,18 @@ public abstract class GeckoApp
             return;
         for (File file : files) {
             file.delete();
         }
     }
 
     @Override
     public void onConfigurationChanged(Configuration newConfig) {
+        Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale);
+        LocaleManager.correctLocale(getResources(), newConfig);
         super.onConfigurationChanged(newConfig);
 
         if (mOrientation != newConfig.orientation) {
             mOrientation = newConfig.orientation;
             if (mFormAssistPopup != null)
                 mFormAssistPopup.hide();
             refreshChrome();
         }
@@ -2732,9 +2749,57 @@ public abstract class GeckoApp
         int versionCode = 0;
         try {
             versionCode = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode;
         } catch (NameNotFoundException e) {
             Log.wtf(LOGTAG, getPackageName() + " not found", e);
         }
         return versionCode;
     }
+
+    // FHR reason code for a session end prior to a restart for a
+    // locale change.
+    private static final String SESSION_END_LOCALE_CHANGED = "L";
+
+    /**
+     * Use LocaleManager to change our persisted and current locales,
+     * and poke BrowserHealthRecorder to tell it of our changed state.
+     */
+    private void setLocale(final String locale) {
+        if (locale == null) {
+            return;
+        }
+        final String resultant = LocaleManager.setSelectedLocale(locale);
+        if (resultant == null) {
+            return;
+        }
+
+        final BrowserHealthRecorder rec = mHealthRecorder;
+        if (rec == null) {
+            return;
+        }
+
+        final boolean startNewSession = true;
+        final boolean shouldRestart = false;
+        rec.onAppLocaleChanged(resultant);
+        rec.onEnvironmentChanged(startNewSession, SESSION_END_LOCALE_CHANGED);
+
+        if (!shouldRestart) {
+            ThreadUtils.postToUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    GeckoApp.this.onLocaleReady(resultant);
+                }
+            });
+            return;
+        }
+
+        // Do this in the background so that the health recorder has its
+        // time to finish.
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                GeckoApp.this.doRestart();
+                GeckoApp.this.finish();
+            }
+        });
+    }
 }
--- a/mobile/android/base/GeckoAppShell.java
+++ b/mobile/android/base/GeckoAppShell.java
@@ -1581,54 +1581,16 @@ public class GeckoAppShell
                 return LINK_TYPE_4G; // 3.9G
             case TelephonyManager.NETWORK_TYPE_UNKNOWN:
             default:
                 Log.w(LOGTAG, "Connected to an unknown mobile network!");
                 return LINK_TYPE_UNKNOWN;
         }
     }
 
-    public static void setSelectedLocale(String localeCode) {
-        /* Bug 713464: This method is still called from Gecko side.
-           Earlier we had an option to run Firefox in a language other than system's language.
-           However, this is not supported as of now.
-           Gecko resets the locale to en-US by calling this function with an empty string.
-           This affects GeckoPreferences activity in multi-locale builds.
-
-        N.B., if this code ever becomes live again, you need to hook it up to locale
-        recording in BrowserHealthRecorder: we track the current app and OS locales
-        as part of the recorded environment.
-
-        See similar note in GeckoApp.java for the startup path.
-
-        //We're not using this, not need to save it (see bug 635342)
-        SharedPreferences settings =
-            getContext().getPreferences(Activity.MODE_PRIVATE);
-        settings.edit().putString(getContext().getPackageName() + ".locale",
-                                  localeCode).commit();
-        Locale locale;
-        int index;
-        if ((index = localeCode.indexOf('-')) != -1 ||
-            (index = localeCode.indexOf('_')) != -1) {
-            String langCode = localeCode.substring(0, index);
-            String countryCode = localeCode.substring(index + 1);
-            locale = new Locale(langCode, countryCode);
-        } else {
-            locale = new Locale(localeCode);
-        }
-        Locale.setDefault(locale);
-
-        Resources res = getContext().getBaseContext().getResources();
-        Configuration config = res.getConfiguration();
-        config.locale = locale;
-        res.updateConfiguration(config, res.getDisplayMetrics());
-        */
-    }
-
-
     @WrapElementForJNI(stubName = "GetSystemColoursWrapper")
     public static int[] getSystemColors() {
         // attrsAppearance[] must correspond to AndroidSystemColors structure in android/AndroidBridge.h
         final int[] attrsAppearance = {
             android.R.attr.textColor,
             android.R.attr.textColorPrimary,
             android.R.attr.textColorPrimaryInverse,
             android.R.attr.textColorSecondary,
--- a/mobile/android/base/GeckoApplication.java
+++ b/mobile/android/base/GeckoApplication.java
@@ -7,30 +7,49 @@ package org.mozilla.gecko;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.mozglue.GeckoLoader;
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.app.Application;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.util.Log;
 
 public class GeckoApplication extends Application {
 
     private boolean mInited;
     private boolean mInBackground;
     private boolean mPausedGecko;
-    private boolean mNeedsRestart;
 
     private LightweightTheme mLightweightTheme;
 
+    /**
+     * We need to do locale work here, because we need to intercept
+     * each hit to onConfigurationChanged.
+     */
+    @Override
+    public void onConfigurationChanged(Configuration config) {
+        Log.d("GeckoApplication", "onConfigurationChanged: " + config.locale +
+                                  ", background: " + mInBackground);
+
+        // Do nothing if we're in the background. It'll simply cause a loop
+        // (Bug 936756 Comment 11), and it's not necessary.
+        if (mInBackground) {
+            super.onConfigurationChanged(config);
+            return;
+        }
+
+        // Otherwise, correct the locale. This catches some cases that GeckoApp
+        // doesn't get a chance to.
+        LocaleManager.correctLocale(getResources(), config);
+        super.onConfigurationChanged(config);
+    }
+
     protected void initialize() {
         if (mInited)
             return;
 
         // workaround for http://code.google.com/p/android/issues/detail?id=20915
         try {
             Class.forName("android.os.AsyncTask");
         } catch (ClassNotFoundException e) {}
@@ -38,24 +57,16 @@ public class GeckoApplication extends Ap
         mLightweightTheme = new LightweightTheme(this);
 
         GeckoConnectivityReceiver.getInstance().init(getApplicationContext());
         GeckoBatteryManager.getInstance().init(getApplicationContext());
         GeckoBatteryManager.getInstance().start();
         GeckoNetworkManager.getInstance().init(getApplicationContext());
         MemoryMonitor.getInstance().init(getApplicationContext());
 
-        BroadcastReceiver receiver = new BroadcastReceiver() {
-            @Override
-            public void onReceive(Context context, Intent intent) {
-                mNeedsRestart = true;
-            }
-        };
-        registerReceiver(receiver, new IntentFilter(Intent.ACTION_LOCALE_CHANGED));
-
         mInited = true;
     }
 
     public void onActivityPause(GeckoActivityStatus activity) {
         mInBackground = true;
 
         if ((activity.isFinishing() == false) &&
             (activity.isGeckoActivityOpened() == false)) {
@@ -85,20 +96,16 @@ public class GeckoApplication extends Ap
             mPausedGecko = false;
         }
         GeckoConnectivityReceiver.getInstance().start();
         GeckoNetworkManager.getInstance().start();
 
         mInBackground = false;
     }
 
-    protected boolean needsRestart() {
-        return mNeedsRestart;
-    }
-
     @Override
     public void onCreate() {
         HardwareUtils.init(getApplicationContext());
         Clipboard.init(getApplicationContext());
         GeckoLoader.loadMozGlue();
         super.onCreate();
     }
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/LocaleManager.java
@@ -0,0 +1,189 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.util.Log;
+
+import java.util.Locale;
+
+/**
+ * This class manages persistence, application, and otherwise handling of
+ * user-specified locales.
+ *
+ * Of note:
+ * 
+ * * It's a singleton, because its scope extends to that of the application,
+ *   and definitionally all changes to the locale of the app must go through
+ *   this.
+ * * It's lazy.
+ * * It has ties into the Gecko event system, because it has to tell Gecko when
+ *   to switch locale.
+ * * It relies on using the SharedPreferences file owned by the browser (in
+ *   Fennec's case, "GeckoApp") for performance.
+ */
+public class LocaleManager {
+    private static final String LOG_TAG = "GeckoLocales";
+
+    // These are both volatile because we don't impose restrictions
+    // over which thread calls our methods.
+    private static volatile ContextGetter getter = null;
+    private static volatile Locale currentLocale = null;
+
+    public static void setContextGetter(ContextGetter getter) {
+        LocaleManager.getter = getter;
+    }
+
+    private static Context getContext() {
+        if (getter == null) {
+            throw new IllegalStateException("No ContextGetter; cannot fetch context.");
+        }
+        return getter.getContext();
+    }
+
+    private static SharedPreferences getSharedPreferences() {
+        if (getter == null) {
+            throw new IllegalStateException("No ContextGetter; cannot fetch prefs.", new RuntimeException("No prefs."));
+        }
+        return getter.getSharedPreferences();
+    }
+
+    /**
+     * Every time the system gives us a new configuration, it
+     * carries the external locale. Fix it.
+     */
+    public static void correctLocale(Resources res, Configuration config) {
+        Locale current = getCurrentLocale();
+        if (current == null) {
+            return;
+        }
+
+        // I know it's tempting to short-circuit here if the config seems to be
+        // up-to-date, but the rest is necessary.
+
+        config.locale = current;
+
+        // The following two lines are heavily commented in case someone
+        // decides to chase down performance improvements and decides to
+        // question what's going on here.
+        // Both lines should be cheap, *but*...
+
+        // This is unnecessary for basic string choice, but it almost
+        // certainly comes into play when rendering numbers, deciding on RTL,
+        // etc. Take it out if you can prove that's not the case.
+        Locale.setDefault(current);
+
+        // This seems to be a no-op, but every piece of documentation under the
+        // sun suggests that it's necessary, and it certainly makes sense.
+        res.updateConfiguration(config, res.getDisplayMetrics());
+    }
+
+    private static Locale parseLocaleCode(final String localeCode) {
+        int index;
+        if ((index = localeCode.indexOf('-')) != -1 ||
+            (index = localeCode.indexOf('_')) != -1) {
+            final String langCode = localeCode.substring(0, index);
+            final String countryCode = localeCode.substring(index + 1);
+            return new Locale(langCode, countryCode);
+        } else {
+            return new Locale(localeCode);
+        }
+    }
+
+    public static Locale getCurrentLocale() {
+        if (currentLocale != null) {
+            return currentLocale;
+        }
+
+        final String current = getPersistedLocale();
+        if (current == null) {
+            return null;
+        }
+        return currentLocale = parseLocaleCode(current);
+    }
+
+    /**
+     * Returns the persisted locale if it differed from the current.
+     */
+    public static String updateLocale(String localeCode) {
+        // Fast path.
+        final Locale defaultLocale = Locale.getDefault();
+        if (defaultLocale.toString().equals(localeCode)) {
+            return null;
+        }
+
+        final Locale locale = parseLocaleCode(localeCode);
+
+        // Fast path.
+        if (defaultLocale.equals(locale)) {
+            return null;
+        }
+
+        Locale.setDefault(locale);
+        currentLocale = locale;
+
+        // Update resources.
+        Resources res = getContext().getResources();
+        Configuration config = res.getConfiguration();
+        config.locale = locale;
+        res.updateConfiguration(config, res.getDisplayMetrics());
+
+        // Tell Gecko.
+        GeckoEvent ev = GeckoEvent.createBroadcastEvent("Locale:Changed", locale.toString());
+        GeckoAppShell.sendEventToGecko(ev);
+
+        return locale.toString();
+    }
+
+    private static String getPrefName() {
+        return getContext().getPackageName() + ".locale";
+    }
+
+    public static String getPersistedLocale() {
+        final SharedPreferences settings = getSharedPreferences();
+
+        // N.B., it is expected that any per-profile settings will be
+        // implemented via SharedPreferences multiplexing in ContextGetter, not
+        // via profile-annotated preference names.
+        final String locale = settings.getString(getPrefName(), "");
+
+        if ("".equals(locale)) {
+            return null;
+        }
+        return locale;
+    }
+
+    private static void persistLocale(String localeCode) {
+        final SharedPreferences settings = getSharedPreferences();
+        settings.edit().putString(getPrefName(), localeCode).commit();
+    }
+
+    public static String getAndApplyPersistedLocale() {
+        final long t1 = android.os.SystemClock.uptimeMillis();
+        final String localeCode = getPersistedLocale();
+        if (localeCode == null) {
+            return null;
+        }
+
+        updateLocale(localeCode);
+        final long t2 = android.os.SystemClock.uptimeMillis();
+        Log.i(LOG_TAG, "Locale read and update took: " + (t2 - t1) + "ms.");
+        return localeCode;
+    }
+
+    /**
+     * Returns the set locale if it changed. Always persists.
+     */
+    public static String setSelectedLocale(String localeCode) {
+        final String resultant = updateLocale(localeCode);
+        persistLocale(localeCode);
+        return resultant;
+    }
+}
+
--- a/mobile/android/base/health/BrowserHealthRecorder.java
+++ b/mobile/android/base/health/BrowserHealthRecorder.java
@@ -314,16 +314,17 @@ public class BrowserHealthRecorder imple
         this.dispatcher.unregisterEventListener(EVENT_ADDONS_CHANGE, this);
         this.dispatcher.unregisterEventListener(EVENT_ADDONS_UNINSTALLING, this);
         this.dispatcher.unregisterEventListener(EVENT_PREF_CHANGE, this);
         this.dispatcher.unregisterEventListener(EVENT_KEYWORD_SEARCH, this);
         this.dispatcher.unregisterEventListener(EVENT_SEARCH, this);
     }
 
     public void onAppLocaleChanged(String to) {
+        Log.d(LOG_TAG, "Setting health recorder app locale to " + to);
         this.profileCache.beginInitialization();
         this.profileCache.setAppLocale(to);
     }
 
     public void onAddonChanged(String id, JSONObject json) {
         this.profileCache.beginInitialization();
         try {
             this.profileCache.updateJSONForAddon(id, json);
@@ -344,20 +345,29 @@ public class BrowserHealthRecorder imple
     /**
      * Call this when a material change might have occurred in the running
      * environment, such that a new environment should be computed and prepared
      * for use in future events.
      *
      * Invoke this method after calls that mutate the environment.
      *
      * If this change resulted in a transition between two environments, {@link
-     * #onEnvironmentTransition(int, int)} will be invoked on the background
+     * #onEnvironmentTransition(int, int, boolean, String)} will be invoked on the background
      * thread.
      */
     public synchronized void onEnvironmentChanged() {
+        onEnvironmentChanged(true, "E");
+    }
+
+    /**
+     * If `startNewSession` is false, it means no new session should begin
+     * (e.g., because we're about to restart, and we don't want to create
+     * an orphan).
+     */
+    public synchronized void onEnvironmentChanged(final boolean startNewSession, final String sessionEndReason) {
         final int previousEnv = this.env;
         this.env = -1;
         try {
             profileCache.completeInitialization();
         } catch (java.io.IOException e) {
             Log.e(LOG_TAG, "Error completing profile cache initialization.", e);
             this.state = State.INITIALIZATION_FAILED;
             return;
@@ -369,17 +379,17 @@ public class BrowserHealthRecorder imple
             updatedEnv == previousEnv) {
             Log.v(LOG_TAG, "Environment didn't change.");
             return;
         }
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 try {
-                    onEnvironmentTransition(previousEnv, updatedEnv);
+                    onEnvironmentTransition(previousEnv, updatedEnv, startNewSession, sessionEndReason);
                 } catch (Exception e) {
                     Log.w(LOG_TAG, "Could not record environment transition.", e);
                 }
             }
         });
     }
 
     protected synchronized int ensureEnvironment() {
@@ -638,26 +648,31 @@ public class BrowserHealthRecorder imple
             }
         });
     }
 
     /**
      * Invoked in the background whenever the environment transitions between
      * two valid values.
      */
-    protected void onEnvironmentTransition(int prev, int env) {
+    protected void onEnvironmentTransition(int prev, int env, boolean startNewSession, String sessionEndReason) {
         if (this.state != State.INITIALIZED) {
             Log.d(LOG_TAG, "Not initialized: not recording env transition (" + prev + " => " + env + ").");
             return;
         }
 
         final SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
         final SharedPreferences.Editor editor = prefs.edit();
 
-        recordSessionEnd("E", editor, prev);
+        recordSessionEnd(sessionEndReason, editor, prev);
+
+        if (!startNewSession) {
+            editor.commit();
+            return;
+        }
 
         final SessionInformation newSession = SessionInformation.forRuntimeTransition();
         setCurrentSession(newSession);
         newSession.recordBegin(editor);
         editor.commit();
     }
 
     @Override
--- a/mobile/android/base/home/HomePager.java
+++ b/mobile/android/base/home/HomePager.java
@@ -124,16 +124,21 @@ public class HomePager extends ViewPager
                 @Override
                 public void onPageScrollStateChanged(int state) { }
             });
         }
 
         super.addView(child, index, params);
     }
 
+    public void redisplay(FragmentManager fm) {
+        final TabsAdapter adapter = (TabsAdapter) getAdapter();
+        show(fm, adapter.getCurrentPage(), null);
+    }
+
     /**
      * Loads and initializes the pager.
      *
      * @param fm FragmentManager for the adapter
      */
     public void show(FragmentManager fm, Page page, PropertyAnimator animator) {
         mLoaded = true;
         final TabsAdapter adapter = new TabsAdapter(fm);
@@ -276,16 +281,22 @@ public class HomePager extends ViewPager
                 if (info.page == page) {
                     return i;
                 }
             }
 
             return -1;
         }
 
+        public Page getCurrentPage() {
+            int currentItem = getCurrentItem();
+            TabInfo info = mTabs.get(currentItem);
+            return info.page;
+        }
+
         @Override
         public int getCount() {
             return mTabs.size();
         }
 
         @Override
         public Fragment getItem(int position) {
             TabInfo info = mTabs.get(position);
--- a/mobile/android/base/home/TabMenuStrip.java
+++ b/mobile/android/base/home/TabMenuStrip.java
@@ -90,17 +90,17 @@ public class TabMenuStrip extends Linear
                     }
 
                     mPrevProgress = position;
                 }
             });
         }
     }
 
-    // Page scroll animates the drawable and it's bounds from the previous to next child view.
+    // Page scroll animates the drawable and its bounds from the previous to next child view.
     @Override
     public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
         if (mStrip == null) {
             return;
         }
 
         setScrollingData(position, positionOffset);
 
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -227,16 +227,17 @@ gbjar.sources += [
     'home/TopSitesGridView.java',
     'home/TopSitesPage.java',
     'home/TopSitesThumbnailView.java',
     'home/TwoLinePageRow.java',
     'InputMethods.java',
     'JavaAddonManager.java',
     'LightweightTheme.java',
     'LightweightThemeDrawable.java',
+    'LocaleManager.java',
     'MemoryMonitor.java',
     'menu/GeckoMenu.java',
     'menu/GeckoMenuInflater.java',
     'menu/GeckoMenuItem.java',
     'menu/GeckoSubMenu.java',
     'menu/MenuItemActionBar.java',
     'menu/MenuItemActionView.java',
     'menu/MenuItemDefault.java',
--- a/mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
+++ b/mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
@@ -99,17 +99,16 @@
                                                 android:layout_width="fill_parent"
                                                 android:layout_height="fill_parent"
                                                 android:layout_weight="1.0"
                                                 android:singleLine="true"
                                                 android:paddingRight="8dp"
                                                 android:textColor="@color/url_bar_title"
                                                 android:textColorHint="@color/url_bar_title_hint"
                                                 android:gravity="center_vertical|left"
-                                                android:hint="@string/url_bar_default_text"
                                                 android:layout_gravity="center_vertical"
                                                 gecko:autoUpdateTheme="false"/>
 
         <org.mozilla.gecko.toolbar.PageActionLayout android:id="@+id/page_action_layout"
                                                     android:layout_width="wrap_content"
                                                     android:layout_height="match_parent"
                                                     android:layout_marginRight="@dimen/browser_toolbar_button_padding"
                                                     android:visibility="gone"
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -269,16 +269,17 @@ var BrowserApp = {
     dump("zerdatime " + Date.now() + " - browser chrome startup finished.");
 
     this.deck = document.getElementById("browsers");
     BrowserEventHandler.init();
     ViewportHandler.init();
 
     Services.androidBridge.browserApp = this;
 
+    Services.obs.addObserver(this, "Locale:Changed", false);
     Services.obs.addObserver(this, "Tab:Load", false);
     Services.obs.addObserver(this, "Tab:Selected", false);
     Services.obs.addObserver(this, "Tab:Closed", false);
     Services.obs.addObserver(this, "Session:Back", false);
     Services.obs.addObserver(this, "Session:ShowHistory", false);
     Services.obs.addObserver(this, "Session:Forward", false);
     Services.obs.addObserver(this, "Session:Reload", false);
     Services.obs.addObserver(this, "Session:Stop", false);
@@ -405,16 +406,24 @@ var BrowserApp = {
 #expand    let ourmstone = "__MOZ_APP_VERSION__";
     if (ourmstone != savedmstone) {
       Services.prefs.setCharPref("browser.startup.homepage_override.mstone", ourmstone);
       return savedmstone ? "upgrade" : "new";
     }
     return "";
   },
 
+  /**
+   * Pass this a locale string, such as "fr" or "es_ES".
+   */
+  setLocale: function (locale) {
+    console.log("browser.js: requesting locale set: " + locale);
+    sendMessageToJava({ type: "Locale:Set", locale: locale });
+  },
+
   initContextMenu: function ba_initContextMenu() {
     // TODO: These should eventually move into more appropriate classes
     NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.openInNewTab"),
       NativeWindow.contextmenus.linkOpenableNonPrivateContext,
       function(aTarget) {
         let url = NativeWindow.contextmenus._getLinkURL(aTarget);
         BrowserApp.addTab(url, { selected: false, parentId: BrowserApp.selectedTab.id });
 
@@ -1496,16 +1505,23 @@ var BrowserApp = {
         gViewportMargins = JSON.parse(aData);
         this.selectedTab.updateViewportSize(gScreenWidth);
         break;
 
       case "nsPref:changed":
         this.notifyPrefObservers(aData);
         break;
 
+      case "Locale:Changed":
+        // TODO: do we need to be more nuanced here -- e.g., checking for the
+        // OS locale -- or should it always be false on Fennec?
+        Services.prefs.setBoolPref("intl.locale.matchOS", false);
+        Services.prefs.setCharPref("general.useragent.locale", aData);
+        break;
+
       default:
         dump('BrowserApp.observe: unexpected topic "' + aTopic + '"\n');
         break;
 
     }
   },
 
   get defaultBrowserWidth() {