Bug 917480 - Part 2: add 'Language' section to top-level preferences, allowing for browser locale switching. r=nalexander
authorRichard Newman <rnewman@mozilla.com>
Tue, 13 May 2014 20:50:27 -0700
changeset 183053 cb8d6f98fbc2
parent 183052 b0819a1afd15
child 183054 1c25bbb1fd76
push id6787
push userrnewman@mozilla.com
push date2014-05-14 03:57 +0000
treeherderfx-team@2d5ab443d3c0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander
bugs917480
milestone32.0a1
Bug 917480 - Part 2: add 'Language' section to top-level preferences, allowing for browser locale switching. r=nalexander
* * *
Bug 917480 - Part 2d: compute locale list at build time.
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/BrowserApp.java
mobile/android/base/BrowserLocaleManager.java
mobile/android/base/GeckoApp.java
mobile/android/base/LocaleManager.java
mobile/android/base/locales/en-US/android_strings.dtd
mobile/android/base/moz.build
mobile/android/base/preferences/GeckoPreferenceFragment.java
mobile/android/base/preferences/GeckoPreferences.java
mobile/android/base/preferences/LocaleListPreference.java
mobile/android/base/resources/xml-v11/preferences.xml
mobile/android/base/resources/xml/preferences.xml
mobile/android/base/resources/xml/preferences_locale.xml
mobile/android/base/strings.xml.in
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -297,17 +297,16 @@
                   android:theme="@android:style/Theme.NoTitleBar">
             <intent-filter>
                 <action android:name="org.mozilla.gecko.PLAY_VIDEO" />
             </intent-filter>
         </activity>
 
         <activity android:name="org.mozilla.gecko.preferences.GeckoPreferences"
                   android:theme="@style/Gecko.Preferences"
-                  android:label="@string/settings_title"
                   android:configChanges="orientation|screenSize"
                   android:excludeFromRecents="true"/>
 
         <activity android:name="org.mozilla.gecko.home.HomePanelPicker"
                   android:theme="@style/Gecko.TitleDialog"
                   android:label="@string/home_add_panel_title"
                   android:excludeFromRecents="true" />
 
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -4,16 +4,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.net.URLEncoder;
 import java.util.EnumSet;
+import java.util.Locale;
 import java.util.Vector;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.AndroidGamepadManager;
 import org.mozilla.gecko.DynamicToolbar.PinReason;
 import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
@@ -127,16 +128,20 @@ abstract public class BrowserApp extends
     private static final int READER_ADD_DUPLICATE = 2;
 
     private static final String ADD_SHORTCUT_TOAST = "add_shortcut_toast";
     public static final String GUEST_BROWSING_ARG = "--guest";
 
     private static final String STATE_ABOUT_HOME_TOP_PADDING = "abouthome_top_padding";
 
     private static final String BROWSER_SEARCH_TAG = "browser_search";
+
+    // Request ID for startActivityForResult.
+    private static final int ACTIVITY_REQUEST_PREFERENCES = 1001;
+
     private BrowserSearch mBrowserSearch;
     private View mBrowserSearchContainer;
 
     public ViewFlipper mViewFlipper;
     public ActionModeCompatView mActionBar;
     private BrowserToolbar mBrowserToolbar;
     private ToolbarProgressView mProgressView;
     private HomePager mHomePager;
@@ -1283,17 +1288,17 @@ abstract public class BrowserApp extends
             } else if (event.equals("Settings:Show")) {
                 // null strings return "null" (http://code.google.com/p/android/issues/detail?id=13830)
                 String resource = null;
                 if (!message.isNull(GeckoPreferences.INTENT_EXTRA_RESOURCES)) {
                     resource = message.getString(GeckoPreferences.INTENT_EXTRA_RESOURCES);
                 }
                 Intent settingsIntent = new Intent(this, GeckoPreferences.class);
                 GeckoPreferences.setResourceToOpen(settingsIntent, resource);
-                startActivity(settingsIntent);
+                startActivityForResult(settingsIntent, ACTIVITY_REQUEST_PREFERENCES);
             } else if (event.equals("Updater:Launch")) {
                 handleUpdaterLaunch();
             } else if (event.equals("Prompt:ShowTop")) {
                 // Bring this activity to front so the prompt is visible..
                 Intent bringToFrontIntent = new Intent();
                 bringToFrontIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.BROWSER_INTENT_CLASS_NAME);
                 bringToFrontIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
                 startActivity(bringToFrontIntent);
@@ -1749,26 +1754,56 @@ abstract public class BrowserApp extends
             }
         } else {
             hideHomePager();
         }
     }
 
     @Override
     public void onLocaleReady(final String locale) {
+        Log.d(LOGTAG, "onLocaleReady: " + locale);
         super.onLocaleReady(locale);
 
         HomePanelsManager.getInstance().onLocaleReady(locale);
 
         if (mMenu != null) {
             mMenu.clear();
             onCreateOptionsMenu(mMenu);
         }
     }
 
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent data) {
+        Log.d(LOGTAG, "onActivityResult: " + requestCode + ", " + resultCode + ", " + data);
+        switch (requestCode) {
+        case ACTIVITY_REQUEST_PREFERENCES:
+            // We just returned from preferences. If our locale changed,
+            // we need to redisplay at this point, and do any other browser-level
+            // bookkeeping that we associate with a locale change.
+            if (resultCode != GeckoPreferences.RESULT_CODE_LOCALE_DID_CHANGE) {
+                Log.d(LOGTAG, "No locale change returning from preferences; nothing to do.");
+                return;
+            }
+
+            ThreadUtils.postToBackgroundThread(new Runnable() {
+                @Override
+                public void run() {
+                    final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+                    final Locale locale = localeManager.getCurrentLocale(getApplicationContext());
+                    Log.d(LOGTAG, "Persisted locale was " + locale);
+                    onLocaleChanged(BrowserLocaleManager.getLanguageTag(locale));
+                }
+            });
+
+            return;
+        default:
+            return;
+        }
+    }
+
     private void showHomePager(String panelId) {
         showHomePagerWithAnimator(panelId, null);
     }
 
     private void showHomePagerWithAnimator(String panelId, PropertyAnimator animator) {
         if (isHomePagerVisible()) {
             // Home pager already visible, make sure it shows the correct panel.
             mHomePager.showPanel(panelId);
@@ -2415,17 +2450,20 @@ abstract public class BrowserApp extends
 
         if (itemId == R.id.save_as_pdf) {
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SaveAs:PDF", null));
             return true;
         }
 
         if (itemId == R.id.settings) {
             intent = new Intent(this, GeckoPreferences.class);
-            startActivity(intent);
+
+            // We want to know when the Settings activity returns, because
+            // we might need to redisplay based on a locale change.
+            startActivityForResult(intent, ACTIVITY_REQUEST_PREFERENCES);
             return true;
         }
 
         if (itemId == R.id.addons) {
             Tabs.getInstance().loadUrlInTab(AboutPages.ADDONS);
             return true;
         }
 
--- a/mobile/android/base/BrowserLocaleManager.java
+++ b/mobile/android/base/BrowserLocaleManager.java
@@ -9,20 +9,29 @@ import android.content.BroadcastReceiver
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.SharedPreferences;
 import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.util.Log;
 
+import java.io.File;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.Locale;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.util.GeckoJarReader;
+
 /**
  * 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
@@ -34,16 +43,18 @@ import java.util.concurrent.atomic.Atomi
  *   Fennec's case, "GeckoApp") for performance.
  */
 public class BrowserLocaleManager implements LocaleManager {
     private static final String LOG_TAG = "GeckoLocales";
 
     private static final String EVENT_LOCALE_CHANGED = "Locale:Changed";
     private static final String PREF_LOCALE = "locale";
 
+    private static final String FALLBACK_LOCALE_TAG = "en-US";
+
     // This is volatile because we don't impose restrictions
     // over which thread calls our methods.
     private volatile Locale currentLocale = null;
 
     private AtomicBoolean inited = new AtomicBoolean(false);
     private boolean systemLocaleDidChange = false;
     private BroadcastReceiver receiver;
 
@@ -87,17 +98,17 @@ public class BrowserLocaleManager implem
 
         String country = locale.getCountry();    // Can be an empty string.
         if (country.equals("")) {
             return language;
         }
         return language + "-" + country;
     }
 
-    private static Locale parseLocaleCode(final String localeCode) {
+    public 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);
@@ -239,17 +250,18 @@ public class BrowserLocaleManager implem
         return locale;
     }
 
     private void persistLocale(Context context, String localeCode) {
         final SharedPreferences settings = getSharedPreferences(context);
         settings.edit().putString(PREF_LOCALE, localeCode).commit();
     }
 
-    private Locale getCurrentLocale(Context context) {
+    @Override
+    public Locale getCurrentLocale(Context context) {
         if (currentLocale != null) {
             return currentLocale;
         }
 
         final String current = getPersistedLocale(context);
         if (current == null) {
             return null;
         }
@@ -280,9 +292,72 @@ public class BrowserLocaleManager implem
         Locale.setDefault(locale);
         currentLocale = locale;
 
         // Update resources.
         updateConfiguration(context, locale);
 
         return locale.toString();
     }
+
+    /**
+     * Examines <code>multilocale.json</code>, returning the included list of
+     * locale codes.
+     *
+     * If <code>multilocale.json</code> is not present, returns
+     * <code>null</code>. In that case, consider {@link #getFallbackLocaleTag()}.
+     *
+     * multilocale.json currently looks like this:
+     *
+     * <code>
+     * {"locales": ["en-US", "be", "ca", "cs", "da", "de", "en-GB",
+     *              "en-ZA", "es-AR", "es-ES", "es-MX", "et", "fi",
+     *              "fr", "ga-IE", "hu", "id", "it", "ja", "ko",
+     *              "lt", "lv", "nb-NO", "nl", "pl", "pt-BR",
+     *              "pt-PT", "ro", "ru", "sk", "sl", "sv-SE", "th",
+     *              "tr", "uk", "zh-CN", "zh-TW", "en-US"]}
+     * </code>
+     */
+    public static Collection<String> getPackagedLocaleTags(final Context context) {
+        final String resPath = "res/multilocale.json";
+        final String apkPath = context.getPackageResourcePath();
+
+        final String jarURL = "jar:jar:" + new File(apkPath).toURI() + "!/" +
+                              AppConstants.OMNIJAR_NAME + "!/" +
+                              resPath;
+
+        final String contents = GeckoJarReader.getText(jarURL);
+        if (contents == null) {
+            // GeckoJarReader logs and swallows exceptions.
+            return null;
+        }
+
+        try {
+            final JSONObject multilocale = new JSONObject(contents);
+            final JSONArray locales = multilocale.getJSONArray("locales");
+            if (locales == null) {
+                Log.e(LOG_TAG, "No 'locales' array in multilocales.json!");
+                return null;
+            }
+
+            final Set<String> out = new HashSet<String>(locales.length());
+            for (int i = 0; i < locales.length(); ++i) {
+                // If any item in the array is invalid, this will throw,
+                // and the entire clause will fail, being caught below
+                // and returning null.
+                out.add(locales.getString(i));
+            }
+
+            return out;
+        } catch (JSONException e) {
+            Log.e(LOG_TAG, "Unable to parse multilocale.json.", e);
+            return null;
+        }
+    }
+
+    /**
+     * @return the single default locale baked into this application.
+     *         Applicable when there is no multilocale.json present.
+     */
+    public static String getFallbackLocaleTag() {
+        return FALLBACK_LOCALE_TAG;
+    }
 }
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -2707,61 +2707,73 @@ public abstract class GeckoApp
         // return (flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0;
     }
 
     // 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 BrowserLocaleManager to change our persisted and current locales,
-     * and poke HealthRecorder to tell it of our changed state.
+     * This exists so that a locale can be applied in two places: when saved
+     * in a nested activity, and then again when we get back up to GeckoApp.
+     *
+     * GeckoApp needs to do a bunch more stuff than, say, GeckoPreferences.
      */
-    private void setLocale(final String locale) {
-        if (locale == null) {
-            return;
-        }
-        final String resultant = BrowserLocaleManager.getInstance().setSelectedLocale(this, locale);
-        if (resultant == null) {
-            return;
-        }
-
+    protected void onLocaleChanged(final String locale) {
         final boolean startNewSession = true;
         final boolean shouldRestart = false;
 
         // If the HealthRecorder is not yet initialized (unlikely), the locale change won't
         // trigger a session transition and subsequent events will be recorded in an environment
         // with the wrong locale.
         final HealthRecorder rec = mHealthRecorder;
         if (rec != null) {
-            rec.onAppLocaleChanged(resultant);
+            rec.onAppLocaleChanged(locale);
             rec.onEnvironmentChanged(startNewSession, SESSION_END_LOCALE_CHANGED);
         }
 
         if (!shouldRestart) {
             ThreadUtils.postToUiThread(new Runnable() {
                 @Override
                 public void run() {
-                    GeckoApp.this.onLocaleReady(resultant);
+                    GeckoApp.this.onLocaleReady(locale);
                 }
             });
             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();
             }
         });
     }
 
+    /**
+     * Use BrowserLocaleManager to change our persisted and current locales,
+     * and poke HealthRecorder to tell it of our changed state.
+     */
+    protected void setLocale(final String locale) {
+        Log.d(LOGTAG, "setLocale: " + locale);
+        if (locale == null) {
+            return;
+        }
+
+        final String resultant = BrowserLocaleManager.getInstance().setSelectedLocale(this, locale);
+        if (resultant == null) {
+            return;
+        }
+
+        onLocaleChanged(resultant);
+    }
+
     private void setSystemUiVisible(final boolean visible) {
         if (Build.VERSION.SDK_INT < 14) {
             return;
         }
 
         ThreadUtils.postToUiThread(new Runnable() {
             @Override
             public void run() {
--- a/mobile/android/base/LocaleManager.java
+++ b/mobile/android/base/LocaleManager.java
@@ -7,14 +7,15 @@ package org.mozilla.gecko;
 import java.util.Locale;
 
 import android.content.Context;
 import android.content.res.Configuration;
 import android.content.res.Resources;
 
 public interface LocaleManager {
     void initialize(Context context);
+    Locale getCurrentLocale(Context context);
     String getAndApplyPersistedLocale(Context context);
     void correctLocale(Context context, Resources resources, Configuration newConfig);
     void updateConfiguration(Context context, Locale locale);
     String setSelectedLocale(Context context, String localeCode);
     boolean systemLocaleDidChange();
 }
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -62,16 +62,28 @@
      We can't use android plural forms, sadly. See bug #753859. -->
 <!ENTITY num_tabs2 "&formatD; tabs">
 <!ENTITY new_tab_opened "New tab opened">
 
 <!ENTITY settings "Settings">
 <!ENTITY settings_title "Settings">
 <!ENTITY pref_category_advanced "Advanced">
 <!ENTITY pref_category_customize "Customize">
+
+<!-- Localization note (pref_category_language) : This is the preferences
+     section in which the user picks the locale in which to display Firefox
+     UI. The locale includes both language and region concepts. -->
+<!ENTITY pref_category_language "Language">
+<!ENTITY pref_browser_locale "Browser language">
+
+<!-- Localization note (locale_system_default) : This string indicates that
+     Firefox will use the locale currently selected in Android's settings
+     to display browser chrome. -->
+<!ENTITY locale_system_default "System default">
+
 <!ENTITY pref_category_search3 "Search">
 <!ENTITY pref_category_search_summary "Customize your search providers">
 <!ENTITY pref_category_display "Display">
 <!ENTITY pref_category_privacy_short "Privacy">
 <!ENTITY pref_category_vendor "&vendorShortName;">
 <!ENTITY pref_category_datareporting "Data choices">
 <!ENTITY pref_learn_more "Learn more">
 <!ENTITY pref_category_installed_search_engines "Installed search engines">
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -318,16 +318,17 @@ gbjar.sources += [
     'preferences/AndroidImport.java',
     'preferences/AndroidImportPreference.java',
     'preferences/CustomListCategory.java',
     'preferences/CustomListPreference.java',
     'preferences/FontSizePreference.java',
     'preferences/GeckoPreferenceFragment.java',
     'preferences/GeckoPreferences.java',
     'preferences/LinkPreference.java',
+    'preferences/LocaleListPreference.java',
     'preferences/ModifiableHintPreference.java',
     'preferences/MultiChoicePreference.java',
     'preferences/PanelsPreference.java',
     'preferences/PanelsPreferenceCategory.java',
     'preferences/PrivateDataPreference.java',
     'preferences/SearchEnginePreference.java',
     'preferences/SearchPreferenceCategory.java',
     'preferences/SyncPreference.java',
--- a/mobile/android/base/preferences/GeckoPreferenceFragment.java
+++ b/mobile/android/base/preferences/GeckoPreferenceFragment.java
@@ -1,22 +1,28 @@
 /* -*- 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.preferences;
 
 import java.lang.reflect.Field;
+import java.util.Locale;
 
+import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 
+import android.app.ActionBar;
 import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Build;
 import android.os.Bundle;
 import android.preference.PreferenceActivity;
 import android.preference.PreferenceFragment;
 import android.preference.PreferenceScreen;
 import android.util.Log;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.ViewConfiguration;
@@ -24,16 +30,17 @@ import android.view.ViewConfiguration;
 /* A simple implementation of PreferenceFragment for large screen devices
  * This will strip category headers (so that they aren't shown to the user twice)
  * as well as initializing Gecko prefs when a fragment is shown.
 */
 public class GeckoPreferenceFragment extends PreferenceFragment {
 
     private static final String LOGTAG = "GeckoPreferenceFragment";
     private int mPrefsRequestId = 0;
+    private Locale lastLocale = Locale.getDefault();
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
         // Write prefs to our custom GeckoSharedPrefs file.
         getPreferenceManager().setSharedPreferencesName(GeckoSharedPrefs.APP_PREFS_NAME);
 
@@ -46,37 +53,97 @@ public class GeckoPreferenceFragment ext
 
         addPreferencesFromResource(res);
 
         PreferenceScreen screen = getPreferenceScreen();
         setPreferenceScreen(screen);
         mPrefsRequestId = ((GeckoPreferences)getActivity()).setupPreferences(screen);
     }
 
+    /**
+     * Return the title to use for this preference fragment. This allows
+     * for us to redisplay this fragment in a different locale.
+     *
+     * We only return titles for the preference screens that are in the
+     * flow for selecting a locale, and thus might need to be redisplayed.
+     */
+    protected String getTitle() {
+        final int res = getResource();
+        if (res == R.xml.preferences_locale) {
+            return getString(R.string.pref_category_language);
+        }
+
+        if (res == R.xml.preferences) {
+            return getString(R.string.settings_title);
+        }
+
+        return null;
+    }
+
+    private void updateTitle() {
+        final String newTitle = getTitle();
+        if (newTitle != null) {
+            final Activity activity = getActivity();
+
+            Log.v(LOGTAG, "Setting activity title to " + newTitle);
+            activity.setTitle(newTitle);
+
+            if (Build.VERSION.SDK_INT >= 14) {
+                final ActionBar actionBar = activity.getActionBar();
+                actionBar.setTitle(newTitle);
+            }
+        }
+    }
+
+    @Override
+    public void onResume() {
+        final Locale currentLocale = Locale.getDefault();
+        final Context context = getActivity().getApplicationContext();
+
+        BrowserLocaleManager.getInstance().updateConfiguration(context, currentLocale);
+
+        if (!currentLocale.equals(lastLocale)) {
+            // Locales differ. Let's redisplay.
+            Log.d(LOGTAG, "Locale changed: " + currentLocale);
+            this.lastLocale = currentLocale;
+
+            // Rebuild the list to reflect the current locale.
+            getPreferenceScreen().removeAll();
+            addPreferencesFromResource(getResource());
+        }
+
+        // Fix the parent title regardless.
+        updateTitle();
+
+        super.onResume();
+    }
+
     /*
      * Get the resource from Fragment arguments and return it.
      *
      * If no resource can be found, return the resource id of the default preference screen.
      */
     private int getResource() {
         int resid = 0;
 
-        String resourceName = getArguments().getString("resource");
+        final String resourceName = getArguments().getString("resource");
+        final Activity activity = getActivity();
+
         if (resourceName != null) {
             // Fetch resource id by resource name.
-            resid = getActivity().getResources().getIdentifier(resourceName,
-                                                             "xml",
-                                                             getActivity().getPackageName());
+            final Resources resources = activity.getResources();
+            final String packageName = activity.getPackageName();
+            resid = resources.getIdentifier(resourceName, "xml", packageName);
         }
 
         if (resid == 0) {
             // The resource was invalid. Use the default resource.
             Log.e(LOGTAG, "Failed to find resource: " + resourceName + ". Displaying default settings.");
 
-            boolean isMultiPane = ((PreferenceActivity) getActivity()).onIsMultiPane();
+            boolean isMultiPane = ((PreferenceActivity) activity).onIsMultiPane();
             resid = isMultiPane ? R.xml.preferences_customize_tablet : R.xml.preferences;
         }
 
         return resid;
     }
 
     @Override
     public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
--- a/mobile/android/base/preferences/GeckoPreferences.java
+++ b/mobile/android/base/preferences/GeckoPreferences.java
@@ -2,37 +2,41 @@
  * 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.preferences;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 
 import org.json.JSONObject;
 import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BrowserApp;
+import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.DataReportingNotification;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoActivityStatus;
-import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.LocaleManager;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.background.announcements.AnnouncementsConstants;
 import org.mozilla.gecko.background.common.GlobalConstants;
 import org.mozilla.gecko.background.healthreport.HealthReportConstants;
 import org.mozilla.gecko.home.HomePanelPicker;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.ThreadUtils;
 
+import android.app.ActionBar;
 import android.app.Activity;
 import android.app.AlertDialog;
 import android.app.Dialog;
 import android.app.Fragment;
 import android.app.NotificationManager;
 import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
@@ -99,18 +103,89 @@ public class GeckoPreferences
     private static final String PREFS_BROWSER_LOCALE = "locale";
 
     public static final String PREFS_RESTORE_SESSION = NON_PREF_PREFIX + "restoreSession3";
 
     // These values are chosen to be distinct from other Activity constants.
     private static final int REQUEST_CODE_PREF_SCREEN = 5;
     private static final int RESULT_CODE_EXIT_SETTINGS = 6;
 
+    // Result code used when a locale preference changes.
+    // Callers can recognize this code to refresh themselves to
+    // accommodate a locale change.
+    public static final int RESULT_CODE_LOCALE_DID_CHANGE = 7;
+
+    /**
+     * Track the last locale so we know whether to redisplay.
+     */
+    private Locale lastLocale = Locale.getDefault();
+
+    private void updateTitle(int title) {
+        // Due to locale switching, we need to dynamically impose the title on
+        // the default preferences view.
+
+        final String newTitle = getString(title);
+        if (newTitle != null) {
+            Log.v(LOGTAG, "Setting activity title to " + newTitle);
+            setTitle(newTitle);
+
+            if (Build.VERSION.SDK_INT >= 14) {
+                final ActionBar actionBar = getActionBar();
+                actionBar.setTitle(newTitle);
+            }
+        }
+    }
+
+    private void updateTitleForPrefsResource(int res) {
+        // At present we only need to do this for the top-level prefs view
+        // and the locale switcher itself.
+        // The others don't allow you to change locales, and have their
+        // titles set in their fragment descriptors.
+        if (res == R.xml.preferences) {
+            updateTitle(R.string.settings_title);
+            return;
+        }
+
+        if (res == R.xml.preferences_locale) {
+            updateTitle(R.string.pref_category_language);
+            return;
+        }
+    }
+
+    private void onLocaleChanged(Locale newLocale) {
+        Log.d(LOGTAG, "onLocaleChanged: " + newLocale);
+
+        BrowserLocaleManager.getInstance().updateConfiguration(getApplicationContext(), newLocale);
+        this.lastLocale = newLocale;
+
+        // Cause the current fragment to redisplay, the hard way.
+        // This avoids nonsense with trying to reach inside fragments and force them
+        // to redisplay themselves.
+        // We also don't need to update the title.
+        final Intent intent = (Intent) getIntent().clone();
+        intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+        startActivityForResult(intent, REQUEST_CODE_PREF_SCREEN);
+
+        setResult(RESULT_CODE_LOCALE_DID_CHANGE);
+        finish();
+    }
+
+    private void checkLocale() {
+        final Locale currentLocale = Locale.getDefault();
+        if (currentLocale.equals(lastLocale)) {
+            return;
+        }
+
+        onLocaleChanged(currentLocale);
+    }
+
     @Override
     protected void onCreate(Bundle savedInstanceState) {
+        // Apply the current user-selected locale, if necessary.
+        checkLocale();
 
         // For Android v11+ where we use Fragments (v11+ only due to bug 866352),
         // check that PreferenceActivity.EXTRA_SHOW_FRAGMENT has been set
         // (or set it) before super.onCreate() is called so Android can display
         // the correct Fragment resource.
 
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB &&
             !getIntent().hasExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT)) {
@@ -141,16 +216,19 @@ public class GeckoPreferences
                     }
                 }
             }
             if (res == 0) {
                 // No resource specified, or the resource was invalid; use the default preferences screen.
                 Log.e(LOGTAG, "Displaying default settings.");
                 res = R.xml.preferences;
             }
+
+            // We don't include a title in the XML, so set it here, in a locale-aware fashion.
+            updateTitleForPrefsResource(res);
             addPreferencesFromResource(res);
         }
 
         EventDispatcher.getInstance().registerGeckoThreadListener(this,
             "Sanitize:Finished");
 
         // Add handling for long-press click.
         // This is only for Android 3.0 and below (which use the long-press-context-menu paradigm).
@@ -166,28 +244,35 @@ public class GeckoPreferences
                 if (listItem instanceof CustomListPreference && listItem instanceof View.OnLongClickListener) {
                     final View.OnLongClickListener longClickListener = (View.OnLongClickListener) listItem;
                     return longClickListener.onLongClick(view);
                 }
                 return false;
             }
         });
 
-        if (Build.VERSION.SDK_INT >= 14)
-            getActionBar().setHomeButtonEnabled(true);
+        if (Build.VERSION.SDK_INT >= 14) {
+            final ActionBar actionBar = getActionBar();
+            actionBar.setHomeButtonEnabled(true);
+        }
+
+        // N.B., if we ever need to redisplay the locale selection UI without
+        // just finishing and recreating the activity, right here we'll need to
+        // capture EXTRA_SHOW_FRAGMENT_TITLE from the intent and store the title ID.
 
         // If launched from notification, explicitly cancel the notification.
         if (intentExtras != null && intentExtras.containsKey(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION)) {
             NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
             notificationManager.cancel(DataReportingNotification.ALERT_NAME_DATAREPORTING_NOTIFICATION.hashCode());
         }
     }
 
     /**
-     * Set intent to display top-level settings fragment.
+     * Set intent to display top-level settings fragment,
+     * and show the correct title.
      */
     private void setupTopLevelFragmentIntent() {
         Intent intent = getIntent();
         // Check intent to determine settings screen to display.
         Bundle intentExtras = intent.getExtras();
         Bundle fragmentArgs = new Bundle();
         // Add resource argument to fragment if it exists.
         if (intentExtras != null && intentExtras.containsKey(INTENT_EXTRA_RESOURCES)) {
@@ -200,16 +285,19 @@ public class GeckoPreferences
             } else {
                 fragmentArgs.putString(INTENT_EXTRA_RESOURCES, "preferences_customize_tablet");
             }
         }
 
         // Build fragment intent.
         intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT, GeckoPreferenceFragment.class.getName());
         intent.putExtra(PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArgs);
+
+        // Show the title for the top level.
+        updateTitle(R.string.settings_title);
     }
 
     @Override
     public void onBuildHeaders(List<Header> target) {
         if (onIsMultiPane())
             loadHeadersFromResource(R.xml.preference_headers, target);
     }
 
@@ -262,41 +350,47 @@ public class GeckoPreferences
         // We need to start nested PreferenceScreens withStartActivityForResult().
         // Android doesn't let us do that (see Preference.onClick), so we're overriding here.
         startActivityForResult(intent, REQUEST_CODE_PREF_SCREEN);
     }
 
     @Override
     public void startWithFragment(String fragmentName, Bundle args,
             Fragment resultTo, int resultRequestCode, int titleRes, int shortTitleRes) {
+        Log.v(LOGTAG, "Starting with fragment: " + fragmentName + ", title " + titleRes);
+
         // Overriding because we want to use startActivityForResult for Fragment intents.
         Intent intent = onBuildStartFragmentIntent(fragmentName, args, titleRes, shortTitleRes);
         if (resultTo == null) {
             startActivityForResult(intent, REQUEST_CODE_PREF_SCREEN);
         } else {
             resultTo.startActivityForResult(intent, resultRequestCode);
         }
     }
 
     @Override
     public void onActivityResult(int requestCode, int resultCode, Intent data) {
+        // We might have just returned from a settings activity that allows us
+        // to switch locales, so reflect any change that occurred.
+        checkLocale();
+
         switch (requestCode) {
           case REQUEST_CODE_PREF_SCREEN:
               if (resultCode == RESULT_CODE_EXIT_SETTINGS) {
                   // Pass this result up to the parent activity.
                   setResult(RESULT_CODE_EXIT_SETTINGS);
                   finish();
               }
               break;
 
           case HomePanelPicker.REQUEST_CODE_ADD_PANEL:
               switch (resultCode) {
                   case Activity.RESULT_OK:
-                     // Panel installed, refresh panels list.
-                     mPanelsPreferenceCategory.refresh();
+                      // Panel installed, refresh panels list.
+                      mPanelsPreferenceCategory.refresh();
                       break;
                   case Activity.RESULT_CANCELED:
                       // Dialog was cancelled, do nothing.
                       break;
                   default:
                       Log.w(LOGTAG, "Unhandled ADD_PANEL result code " + requestCode);
                       break;
               }
@@ -398,17 +492,18 @@ public class GeckoPreferences
                         public boolean onPreferenceClick(Preference preference) {
                             // Display toast to remind setting up tcp forwarding.
                             if (((CheckBoxPreference) preference).isChecked()) {
                                 Toast.makeText(thisContext, R.string.devtools_remote_debugging_forward, Toast.LENGTH_SHORT).show();
                             }
                             return true;
                         }
                     });
-                } else if (PREFS_RESTORE_SESSION.equals(key)) {
+                } else if (PREFS_RESTORE_SESSION.equals(key) ||
+                           PREFS_BROWSER_LOCALE.equals(key)) {
                     // Set the summary string to the current entry. The summary
                     // for other list prefs will be set in the PrefsHelper
                     // callback, but since this pref doesn't live in Gecko, we
                     // need to handle it separately.
                     ListPreference listPref = (ListPreference) pref;
                     CharSequence selectedEntry = listPref.getEntry();
                     listPref.setSummary(selectedEntry);
                     continue;
@@ -597,26 +692,84 @@ public class GeckoPreferences
      * @param def     the default value to return if the preference is not present.
      * @return        the value of the preference, or the default.
      */
     public static boolean getBooleanPref(final Context context, final String name, boolean def) {
         final SharedPreferences prefs = GeckoSharedPrefs.forApp(context);
         return prefs.getBoolean(name, def);
     }
 
+    /**
+     * Immediately handle the user's selection of a browser locale.
+     *
+     * Earlier locale-handling code did this with centralized logic in
+     * GeckoApp, delegating to LocaleManager for persistence and refreshing
+     * the activity as necessary.
+     *
+     * We no longer handle this by sending a message to GeckoApp, for
+     * several reasons:
+     *
+     * * GeckoApp might not be running. Activities don't always stick around.
+     *   A Java bridge message might not be handled.
+     * * We need to adapt the preferences UI to the locale ourselves.
+     * * The user might not hit Back (or Up) -- they might hit Home and never
+     *   come back.
+     *
+     * We handle the case of the user returning to the browser via the
+     * onActivityResult mechanism: see {@link BrowserApp#onActivityResult(int, int, Intent)}.
+     */
+    private boolean onLocaleSelected(final String newValue) {
+        if (newValue.equals("")) {
+            // TODO: reset our locale to match system.
+            return false;
+        }
+
+        final Context context = getApplicationContext();
+
+        // LocaleManager operations need to occur on the background thread.
+        // ... but activity operations need to occur on the UI thread. So we
+        // have nested runnables.
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+                if (null == localeManager.setSelectedLocale(context, newValue)) {
+                    localeManager.updateConfiguration(context, Locale.getDefault());
+                }
+
+                ThreadUtils.postToUiThread(new Runnable() {
+                    @Override
+                    public void run() {
+                        onLocaleChanged(Locale.getDefault());
+                    }
+                });
+            }
+        });
+
+        return true;
+    }
+
     @Override
     public boolean onPreferenceChange(Preference preference, Object newValue) {
-        String prefName = preference.getKey();
+        final String prefName = preference.getKey();
         if (PREFS_MP_ENABLED.equals(prefName)) {
             showDialog((Boolean) newValue ? DIALOG_CREATE_MASTER_PASSWORD : DIALOG_REMOVE_MASTER_PASSWORD);
 
             // We don't want the "use master password" pref to change until the
             // user has gone through the dialog.
             return false;
-        } else if (PREFS_MENU_CHAR_ENCODING.equals(prefName)) {
+        }
+
+        if (PREFS_BROWSER_LOCALE.equals(prefName)) {
+            // Even though this is a list preference, we don't want to handle it
+            // below, so we return here.
+            return onLocaleSelected((String) newValue);
+        }
+
+        if (PREFS_MENU_CHAR_ENCODING.equals(prefName)) {
             setCharEncodingState(((String) newValue).equals("true"));
         } else if (PREFS_ANNOUNCEMENTS_ENABLED.equals(prefName)) {
             // Send a broadcast intent to the product announcements service, either to start or
             // to stop the repeated background checks.
             broadcastAnnouncementsPref(GeckoAppShell.getContext(), ((Boolean) newValue).booleanValue());
         } else if (PREFS_UPDATER_AUTODOWNLOAD.equals(prefName)) {
             org.mozilla.gecko.updater.UpdateServiceHelper.registerForUpdates(GeckoAppShell.getContext(), (String) newValue);
         } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(prefName)) {
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/preferences/LocaleListPreference.java
@@ -0,0 +1,106 @@
+/* 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.preferences;
+
+import java.text.Collator;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Locale;
+
+import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.preference.ListPreference;
+import android.util.AttributeSet;
+
+public class LocaleListPreference extends ListPreference {
+    public LocaleListPreference(Context context) {
+        this(context, null);
+    }
+
+    public LocaleListPreference(Context context, AttributeSet attributes) {
+        super(context, attributes);
+        buildList();
+    }
+
+    private static final class LocaleDescriptor implements Comparable<LocaleDescriptor> {
+        // We use Locale.US here to ensure a stable ordering of entries.
+        private static final Collator COLLATOR = Collator.getInstance(Locale.US);
+
+        public final String tag;
+        private final String nativeName;
+
+        public LocaleDescriptor(String tag) {
+            this(BrowserLocaleManager.parseLocaleCode(tag), tag);
+        }
+
+        public LocaleDescriptor(Locale locale, String tag) {
+            this.nativeName = locale.getDisplayName(locale);
+            this.tag = tag;
+        }
+
+        public String getTag() {
+            return this.tag;
+        }
+
+        public String getDisplayName() {
+            return this.nativeName;
+        }
+
+        @Override
+        public String toString() {
+            return this.nativeName;
+        }
+
+
+        @Override
+        public int compareTo(LocaleDescriptor another) {
+            // We sort by name, so we use Collator.
+            return COLLATOR.compare(this.nativeName, another.nativeName);
+        }
+    }
+
+    private LocaleDescriptor[] getShippingLocales() {
+        Collection<String> shippingLocales = BrowserLocaleManager.getPackagedLocaleTags(getContext());
+
+        // Future: single-locale builds should be specified, too.
+        if (shippingLocales == null) {
+            final String fallbackTag = BrowserLocaleManager.getFallbackLocaleTag();
+            return new LocaleDescriptor[] { new LocaleDescriptor(fallbackTag) };
+        }
+
+        final int count = shippingLocales.size();
+        final LocaleDescriptor[] descriptors = new LocaleDescriptor[count];
+
+        int i = 0;
+        for (String tag : shippingLocales) {
+            descriptors[i++] = new LocaleDescriptor(tag);
+        }
+
+        Arrays.sort(descriptors, 0, count);
+        return descriptors;
+    }
+
+    private void buildList() {
+        final LocaleDescriptor[] descriptors = getShippingLocales();
+        final int count = descriptors.length;
+
+        // We leave room for "System default".
+        final String[] entries = new String[count + 1];
+        final String[] values = new String[count + 1];
+
+        entries[0] = getContext().getString(R.string.locale_system_default);
+        values[0] = "";
+
+        for (int i = 0; i < count; ++i) {
+            entries[i + 1] = descriptors[i].getDisplayName();
+            values[i + 1] = descriptors[i].getTag();
+        }
+
+        setEntries(entries);
+        setEntryValues(values);
+    }
+}
--- a/mobile/android/base/resources/xml-v11/preferences.xml
+++ b/mobile/android/base/resources/xml-v11/preferences.xml
@@ -29,16 +29,21 @@
 
 
     <PreferenceScreen android:title="@string/pref_category_privacy_short"
                       android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment" >
         <extra android:name="resource"
                android:value="preferences_privacy" />
     </PreferenceScreen>
 
+    <PreferenceScreen android:title="@string/pref_category_language"
+                      android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment" >
+        <extra android:name="resource"
+               android:value="preferences_locale" />
+    </PreferenceScreen>
 
     <PreferenceScreen android:title="@string/pref_category_vendor"
                       android:fragment="org.mozilla.gecko.preferences.GeckoPreferenceFragment" >
         <extra android:name="resource"
                android:value="preferences_vendor"/>
     </PreferenceScreen>
 
     <PreferenceScreen android:title="@string/pref_category_devtools"
--- a/mobile/android/base/resources/xml/preferences.xml
+++ b/mobile/android/base/resources/xml/preferences.xml
@@ -40,16 +40,26 @@
                 android:targetPackage="@string/android_package_name"
                 android:targetClass="org.mozilla.gecko.preferences.GeckoPreferences" >
             <extra
                 android:name="resource"
                 android:value="preferences_privacy" />
         </intent>
     </PreferenceScreen>
 
+    <PreferenceScreen android:title="@string/pref_category_language" >
+        <intent android:action="android.intent.action.VIEW"
+                android:targetPackage="@string/android_package_name"
+                android:targetClass="org.mozilla.gecko.preferences.GeckoPreferences" >
+            <extra
+                android:name="resource"
+                android:value="preferences_locale" />
+        </intent>
+    </PreferenceScreen>
+
     <PreferenceScreen android:title="@string/pref_category_vendor">
         <intent android:action="android.intent.action.VIEW"
                 android:targetPackage="@string/android_package_name"
                 android:targetClass="org.mozilla.gecko.preferences.GeckoPreferences" >
             <extra
                 android:name="resource"
                 android:value="preferences_vendor" />
         </intent>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/xml/preferences_locale.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+                  xmlns:gecko="http://schemas.android.com/apk/res-auto"
+                  android:title="@string/pref_category_language"
+                  android:enabled="false">
+    <PreferenceCategory android:title="@string/pref_browser_locale">
+        <!-- No title set here. We set the title to the current locale in
+             GeckoPreferences. -->
+        <org.mozilla.gecko.preferences.LocaleListPreference
+            android:key="locale"
+            android:persistent="true"
+            android:defaultValue=""
+        />
+    </PreferenceCategory>
+</PreferenceScreen>
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -103,16 +103,20 @@
   <string name="pref_category_datareporting">&pref_category_datareporting;</string>
   <string name="pref_category_installed_search_engines">&pref_category_installed_search_engines;</string>
   <string name="pref_category_add_search_providers">&pref_category_add_search_providers;</string>
   <string name="pref_category_search_restore_defaults">&pref_category_search_restore_defaults;</string>
   <string name="pref_search_restore_defaults">&pref_search_restore_defaults;</string>
   <string name="pref_search_restore_defaults_summary">&pref_search_restore_defaults_summary;</string>
   <string name="pref_search_hint">&pref_search_hint;</string>
 
+  <string name="pref_category_language">&pref_category_language;</string>
+  <string name="pref_browser_locale">&pref_browser_locale;</string>
+  <string name="locale_system_default">&locale_system_default;</string>
+
   <string name="pref_category_devtools">&pref_category_devtools;</string>
   <string name="pref_developer_remotedebugging">&pref_developer_remotedebugging;</string>
 
   <string name="pref_category_home">&pref_category_home;</string>
   <string name="pref_category_home_summary">&pref_category_home_summary;</string>
   <string name="pref_category_home_panels">&pref_category_home_panels;</string>
   <string name="pref_home_add_panel">&pref_home_add_panel;</string>
   <string name="home_add_panel_title">&home_add_panel_title;</string>