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 183067 cb8d6f98fbc2
parent 183066 b0819a1afd15
child 183068 1c25bbb1fd76
push id26779
push usercbook@mozilla.com
push date2014-05-14 11:02 +0000
treeherdermozilla-central@5754fc307237 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander
bugs917480
milestone32.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 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>