Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1808ff7c authored by Mihai Nita's avatar Mihai Nita
Browse files

Re-implements the locales selection with suggestions and search.

This replaces the initial implementation of a two-step locale selection
with a more advanced version, which does suggestions, search, removes
locales that already exist in the user preferences.

Bug: 25800339
Bug: 26414919
Bug: 26278049
Bug: 26275094
Bug: 26266914
Bug: 26266743
Bug: 26266712
Bug: 26266605
Bug: 26266490
Bug: 26266021

Change-Id: I88944c86e4cae5eaa00b7ae4855887ab11989253
parent 770e437d
Loading
Loading
Loading
Loading
+216 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.internal.app;

import android.icu.util.ULocale;
import android.util.LocaleList;

import java.text.Collator;
import java.util.Comparator;
import java.util.Locale;

/**
 * This class implements some handy methods to proces with locales.
 */
public class LocaleHelper {

    /**
     * Sentence-case (first character uppercased).
     *
     * <p>There is no good API available for this, not even in ICU.
     * We can revisit this if we get some ICU support later.</p>
     *
     * <p>There are currently several tickets requesting this feature:</p>
     * <ul>
     * <li>ICU needs to provide an easy way to titlecase only one first letter
     *   http://bugs.icu-project.org/trac/ticket/11729</li>
     * <li>Add "initial case"
     *    http://bugs.icu-project.org/trac/ticket/8394</li>
     * <li>Add code for initialCase, toTitlecase don't modify after Lt,
     *   avoid 49Ers, low-level language-specific casing
     *   http://bugs.icu-project.org/trac/ticket/10410</li>
     * <li>BreakIterator.getFirstInstance: Often you need to titlecase just the first
     *   word, and leave the rest of the string alone.  (closed as duplicate)
     *   http://bugs.icu-project.org/trac/ticket/8946</li>
     * </ul>
     *
     * <p>A (clunky) option with the current ICU API is:</p>
     * {{
     *   BreakIterator breakIterator = BreakIterator.getSentenceInstance(locale);
     *   String result = UCharacter.toTitleCase(locale,
     *       source, breakIterator, UCharacter.TITLECASE_NO_LOWERCASE);
     * }}
     *
     * <p>That also means creating BreakIteratos for each locale. Expensive...</p>
     *
     * @param str the string to sentence-case.
     * @param locale the locale used for the case conversion.
     * @return the string converted to sentence-case.
     */
    public static String toSentenceCase(String str, Locale locale) {
        if (str.isEmpty()) {
            return str;
        }
        final int firstCodePointLen = str.offsetByCodePoints(0, 1);
        return str.substring(0, firstCodePointLen).toUpperCase(locale)
                + str.substring(firstCodePointLen);
    }

    /**
     * Normalizes a string for locale name search. Does case conversion for now,
     * but might do more in the future.
     *
     * <p>Warning: it is only intended to be used in searches by the locale picker.
     * Don't use it for other things, it is very limited.</p>
     *
     * @param str the string to normalize
     * @param locale the locale that might be used for certain operations (i.e. case conversion)
     * @return the string normalized for search
     */
    public static String normalizeForSearch(String str, Locale locale) {
        // TODO: tbd if it needs to be smarter (real normalization, remove accents, etc.)
        // If needed we might use case folding and ICU/CLDR's collation-based loose searching.
        // TODO: decide what should the locale be, the default locale, or the locale of the string.
        // Uppercase is better than lowercase because of things like sharp S, Greek sigma, ...
        return str.toUpperCase();
    }

    /**
     * Returns the locale localized for display in the provided locale.
     *
     * @param locale the locale whose name is to be displayed.
     * @param displayLocale the locale in which to display the name.
     * @param sentenceCase true if the result should be sentence-cased
     * @return the localized name of the locale.
     */
    public static String getDisplayName(Locale locale, Locale displayLocale, boolean sentenceCase) {
        String result = ULocale.getDisplayName(locale.toLanguageTag(),
                ULocale.forLocale(displayLocale));
        return sentenceCase ? toSentenceCase(result, displayLocale) : result;
    }

    /**
     * Returns the locale localized for display in the default locale.
     *
     * @param locale the locale whose name is to be displayed.
     * @param sentenceCase true if the result should be sentence-cased
     * @return the localized name of the locale.
     */
    public static String getDisplayName(Locale locale, boolean sentenceCase) {
        String result = ULocale.getDisplayName(locale.toLanguageTag(), ULocale.getDefault());
        return sentenceCase ? toSentenceCase(result, Locale.getDefault()) : result;
    }

    /**
     * Returns a locale's country localized for display in the provided locale.
     *
     * @param locale the locale whose country will be displayed.
     * @param displayLocale the locale in which to display the name.
     * @return the localized country name.
     */
    public static String getDisplayCountry(Locale locale, Locale displayLocale) {
        return ULocale.getDisplayCountry(locale.toLanguageTag(), ULocale.forLocale(displayLocale));
    }

    /**
     * Returns a locale's country localized for display in the default locale.
     *
     * @param locale the locale whose country will be displayed.
     * @return the localized country name.
     */
    public static String getDisplayCountry(Locale locale) {
        return ULocale.getDisplayCountry(locale.toLanguageTag(), ULocale.getDefault());
    }

    /**
     * Returns the locale list localized for display in the provided locale.
     *
     * @param locales the list of locales whose names is to be displayed.
     * @param displayLocale the locale in which to display the names.
     *                      If this is null, it will use the default locale.
     * @return the locale aware list of locale names
     */
    public static String getDisplayLocaleList(LocaleList locales, Locale displayLocale) {
        final StringBuilder result = new StringBuilder();

        final Locale dispLocale = displayLocale == null ? Locale.getDefault() : displayLocale;
        int localeCount = locales.size();
        for (int i = 0; i < localeCount; i++) {
            Locale locale = locales.get(i);
            result.append(LocaleHelper.getDisplayName(locale, dispLocale, false));
            // TODO: language aware list formatter. ICU has one.
            if (i < localeCount - 1) {
                result.append(", ");
            }
        }

        return result.toString();
    }

    /**
     * Adds the likely subtags for a provided locale ID.
     *
     * @param locale the locale to maximize.
     * @return the maximized Locale instance.
     */
    public static Locale addLikelySubtags(Locale locale) {
        return libcore.icu.ICU.addLikelySubtags(locale);
    }

    /**
     * Locale-sensitive comparison for LocaleInfo.
     *
     * <p>It uses the label, leaving the decision on what to put there to the LocaleInfo.
     * For instance fr-CA can be shown as "français" as a generic label in the language selection,
     * or "français (Canada)" if it is a suggestion, or "Canada" in the country selection.</p>
     *
     * <p>Gives priority to suggested locales (to sort them at the top).</p>
     */
    static final class LocaleInfoComparator implements Comparator<LocaleStore.LocaleInfo> {
        private final Collator mCollator;

        /**
         * Constructor.
         *
         * @param sortLocale the locale to be used for sorting.
         */
        public LocaleInfoComparator(Locale sortLocale) {
            mCollator = Collator.getInstance(sortLocale);
        }

        /**
         * Compares its two arguments for order.
         *
         * @param lhs   the first object to be compared
         * @param rhs   the second object to be compared
         * @return  a negative integer, zero, or a positive integer as the first
         *          argument is less than, equal to, or greater than the second.
         */
        @Override
        public int compare(LocaleStore.LocaleInfo lhs, LocaleStore.LocaleInfo rhs) {
            // We don't care about the various suggestion types, just "suggested" (!= 0)
            // and "all others" (== 0)
            if (lhs.isSuggested() == rhs.isSuggested()) {
                // They are in the same "bucket" (suggested / others), so we compare the text
                return mCollator.compare(lhs.getLabel(), rhs.getLabel());
            } else {
                // One locale is suggested and one is not, so we put them in different "buckets"
                return lhs.isSuggested() ? -1 : 1;
            }
        }
    }
}
+163 −162
Original line number Diff line number Diff line
/*
 * Copyright (C) 2015 The Android Open Source Project
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
@@ -16,215 +16,216 @@

package com.android.internal.app;

import com.android.internal.R;

import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.app.ListFragment;
import android.content.Context;
import android.content.res.Resources;
import android.os.Bundle;
import android.util.ArrayMap;
import android.view.LayoutInflater;
import android.util.LocaleList;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.SearchView;

import com.android.internal.R;

import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

class LocaleAdapter extends ArrayAdapter<LocalePicker.LocaleInfo> {
    final private Map<String, LocalePicker.LocaleInfo> mLevelOne = new ArrayMap<>();
    final private Map<String, HashSet<LocalePicker.LocaleInfo>> mLevelTwo = new ArrayMap<>();
    final private LayoutInflater mInflater;
/**
 * A two-step locale picker. It shows a language, then a country.
 *
 * <p>It shows suggestions at the top, then the rest of the locales.
 * Allows the user to search for locales using both their native name and their name in the
 * default locale.</p>
 */
public class LocalePickerWithRegion extends ListFragment implements SearchView.OnQueryTextListener {

    final static class LocaleAwareComparator implements Comparator<LocalePicker.LocaleInfo> {
        private final Collator mCollator;
    private SuggestedLocaleAdapter mAdapter;
    private LocaleSelectedListener mListener;
    private Set<LocaleStore.LocaleInfo> mLocaleList;
    private LocaleStore.LocaleInfo mParentLocale;
    private boolean mTranslatedOnly = false;
    private boolean mCountryMode = false;

        public LocaleAwareComparator(Locale sortLocale) {
            mCollator = Collator.getInstance(sortLocale);
    /**
     * Other classes can register to be notified when a locale was selected.
     *
     * <p>This is the mechanism to "return" the result of the selection.</p>
     */
    public interface LocaleSelectedListener {
        /**
         * The classes that want to retrieve the locale picked should implement this method.
         * @param locale    the locale picked.
         */
        void onLocaleSelected(LocaleStore.LocaleInfo locale);
    }

        @Override
        public int compare(LocalePicker.LocaleInfo lhs, LocalePicker.LocaleInfo rhs) {
            return mCollator.compare(lhs.getLabel(), rhs.getLabel());
        }
    private static LocalePickerWithRegion createCountryPicker(Context context,
            LocaleSelectedListener listener, LocaleStore.LocaleInfo parent,
            boolean translatedOnly) {
        LocalePickerWithRegion localePicker = new LocalePickerWithRegion();
        boolean shouldShowTheList = localePicker.setListener(context, listener, parent,
                true /* country mode */, translatedOnly);
        return shouldShowTheList ? localePicker : null;
    }

    static List<Locale> getCuratedLocaleList(Context context) {
        final Resources resources = context.getResources();
        final String[] supportedLocaleCodes = resources.getStringArray(R.array.supported_locales);

        final ArrayList<Locale> result = new ArrayList<>(supportedLocaleCodes.length);
        for (String localeId : supportedLocaleCodes) {
            Locale locale = Locale.forLanguageTag(localeId);
            if (!locale.getCountry().isEmpty()) {
                result.add(Locale.forLanguageTag(localeId));
            }
        }
        return result;
    public static LocalePickerWithRegion createLanguagePicker(Context context,
            LocaleSelectedListener listener, boolean translatedOnly) {
        LocalePickerWithRegion localePicker = new LocalePickerWithRegion();
        localePicker.setListener(context, listener, null,
                false /* language mode */, translatedOnly);
        return localePicker;
    }

    public LocaleAdapter(Context context) {
        this(context, getCuratedLocaleList(context));
    /**
     * Sets the listener and initializes the locale list.
     *
     * <p>Returns true if we need to show the list, false if not.</p>
     *
     * <p>Can return false because of an error, trying to show a list of countries,
     * but no parent locale was provided.</p>
     *
     * <p>It can also return false if the caller tries to show the list in country mode and
     * there is only one country available (i.e. Japanese => Japan).
     * In this case we don't even show the list, we call the listener with that locale,
     * "pretending" it was selected, and return false.</p>
     */
    private boolean setListener(Context context, LocaleSelectedListener listener,
            LocaleStore.LocaleInfo parent, boolean countryMode, boolean translatedOnly) {
        if (countryMode && (parent == null || parent.getLocale() == null)) {
            // The list of countries is determined as all the countries where the parent language
            // is used.
            throw new IllegalArgumentException("The country selection list needs a parent.");
        }

    static Locale getBaseLocale(Locale locale) {
        return new Locale.Builder()
                .setLocale(locale)
                .setRegion("")
                .build();
        this.mCountryMode = countryMode;
        this.mParentLocale = parent;
        this.mListener = listener;
        this.mTranslatedOnly = translatedOnly;
        setRetainInstance(true);

        final HashSet<String> langTagsToIgnore = new HashSet<>();
        if (!translatedOnly) {
            final LocaleList userLocales = LocalePicker.getLocales();
            final String[] langTags = userLocales.toLanguageTags().split(",");
            Collections.addAll(langTagsToIgnore, langTags);
        }

    // There is no good API available for this, not even in ICU.
    // We can revisit this if we get some ICU support later
    //
    // There are currently several tickets requesting this feature:
    // * ICU needs to provide an easy way to titlecase only one first letter
    //   http://bugs.icu-project.org/trac/ticket/11729
    // * Add "initial case"
    //    http://bugs.icu-project.org/trac/ticket/8394
    // * Add code for initialCase, toTitlecase don't modify after Lt,
    //   avoid 49Ers, low-level language-specific casing
    //   http://bugs.icu-project.org/trac/ticket/10410
    // * BreakIterator.getFirstInstance: Often you need to titlecase just the first
    //   word, and leave the rest of the string alone.  (closed as duplicate)
    //   http://bugs.icu-project.org/trac/ticket/8946
    //
    // A (clunky) option with the current ICU API is:
    //   BreakIterator breakIterator = BreakIterator.getSentenceInstance(locale);
    //   String result = UCharacter.toTitleCase(locale,
    //       source, breakIterator, UCharacter.TITLECASE_NO_LOWERCASE);
    // That also means creating BreakIteratos for each locale. Expensive...
    private static String toTitleCase(String s, Locale locale) {
        if (s.length() == 0) {
            return s;
        if (countryMode) {
            mLocaleList = LocaleStore.getLevelLocales(context,
                    langTagsToIgnore, parent, translatedOnly);
            if (mLocaleList.size() <= 1) {
                if (listener != null && (mLocaleList.size() == 1)) {
                    listener.onLocaleSelected(mLocaleList.iterator().next());
                }
        final int firstCodePointLen = s.offsetByCodePoints(0, 1);
        return s.substring(0, firstCodePointLen).toUpperCase(locale)
                + s.substring(firstCodePointLen);
                return false;
            }
        } else {
            mLocaleList = LocaleStore.getLevelLocales(context, langTagsToIgnore,
                    null /* no parent */, translatedOnly);
        }

    public LocaleAdapter(Context context, List<Locale> locales) {
        super(context, R.layout.locale_picker_item, R.id.locale);
        mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

        for (Locale locale : locales) {
            Locale baseLocale = getBaseLocale(locale);
            String language = baseLocale.toLanguageTag();
            if (!mLevelOne.containsKey(language)) {
                String label = toTitleCase(baseLocale.getDisplayName(baseLocale), baseLocale);
                mLevelOne.put(language, new LocalePicker.LocaleInfo(label, baseLocale));
        return true;
    }

            final HashSet<LocalePicker.LocaleInfo> subLocales;
            if (mLevelTwo.containsKey(language)) {
                subLocales = mLevelTwo.get(language);
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setHasOptionsMenu(true);

        Locale sortingLocale;
        if (mCountryMode) {
            if (mParentLocale == null) {
                sortingLocale = Locale.getDefault();
                this.getActivity().setTitle(R.string.country_selection_title);
            } else {
                subLocales = new HashSet<>();
                mLevelTwo.put(language, subLocales);
                sortingLocale = mParentLocale.getLocale();
                this.getActivity().setTitle(mParentLocale.getFullNameNative());
            }
            String label = locale.getDisplayCountry(locale);
            subLocales.add(new LocalePicker.LocaleInfo(label, locale));
        } else {
            sortingLocale = Locale.getDefault();
            this.getActivity().setTitle(R.string.language_selection_title);
        }

        setAdapterLevel(null);
        mAdapter = new SuggestedLocaleAdapter(mLocaleList, mCountryMode);
        LocaleHelper.LocaleInfoComparator comp =
                new LocaleHelper.LocaleInfoComparator(sortingLocale);
        mAdapter.sort(comp);
        setListAdapter(mAdapter);
    }

    public void setAdapterLevel(String parentLocale) {
        this.clear();

        if (parentLocale == null) {
            this.addAll(mLevelOne.values());
        } else {
            this.addAll(mLevelTwo.get(parentLocale));
    @Override
    public boolean onOptionsItemSelected(MenuItem menuItem) {
        int id = menuItem.getItemId();
        switch (id) {
            case android.R.id.home:
                getFragmentManager().popBackStack();
                return true;
        }
        return super.onOptionsItemSelected(menuItem);
    }

        Locale sortLocale = (parentLocale == null)
                ? Locale.getDefault()
                : Locale.forLanguageTag(parentLocale);
        LocaleAwareComparator comparator = new LocaleAwareComparator(sortLocale);
        this.sort(comparator);

        this.notifyDataSetChanged();
    @Override
    public void onResume() {
        super.onResume();
        getListView().requestFocus();
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view;
        TextView text;
        if (convertView == null) {
            view = mInflater.inflate(R.layout.locale_picker_item, parent, false);
            text = (TextView) view.findViewById(R.id.locale);
            view.setTag(text);
        } else {
            view = convertView;
            text = (TextView) view.getTag();
    public void onListItemClick(ListView l, View v, int position, long id) {
        final LocaleStore.LocaleInfo locale =
                (LocaleStore.LocaleInfo) getListAdapter().getItem(position);

        if (mCountryMode || locale.getParent() != null) {
            if (mListener != null) {
                mListener.onLocaleSelected(locale);
            }
        LocalePicker.LocaleInfo item = getItem(position);
        text.setText(item.getLabel());
        text.setTextLocale(item.getLocale());
        return view;
            getFragmentManager().popBackStack("localeListEditor",
                    FragmentManager.POP_BACK_STACK_INCLUSIVE);
        } else {
            LocalePickerWithRegion selector = LocalePickerWithRegion.createCountryPicker(
                    getContext(), mListener, locale, mTranslatedOnly /* translate only */);
            if (selector != null) {
                getFragmentManager().beginTransaction()
                        .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
                        .replace(getId(), selector).addToBackStack(null)
                        .commit();
            } else {
                getFragmentManager().popBackStack("localeListEditor",
                        FragmentManager.POP_BACK_STACK_INCLUSIVE);
            }
        }

public class LocalePickerWithRegion extends ListFragment {
    private static final int LIST_MODE_LANGUAGE = 0;
    private static final int LIST_MODE_COUNTRY = 1;

    private LocaleAdapter mAdapter;
    private int mDisplayMode = LIST_MODE_LANGUAGE;

    public static interface LocaleSelectionListener {
        // You can add any argument if you really need it...
        public void onLocaleSelected(Locale locale);
    }

    private LocaleSelectionListener mListener = null;

    @Override
    public void onActivityCreated(final Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
        if (!mCountryMode) {
            inflater.inflate(R.menu.language_selection_list, menu);

        mAdapter = new LocaleAdapter(getContext());
        mAdapter.setAdapterLevel(null);
        setListAdapter(mAdapter);
    }
            MenuItem mSearchMenuItem = menu.findItem(R.id.locale_search_menu);
            SearchView mSearchView = (SearchView) mSearchMenuItem.getActionView();

    public void setLocaleSelectionListener(LocaleSelectionListener listener) {
        mListener = listener;
            mSearchView.setQueryHint(getText(R.string.search_language_hint));
            mSearchView.setOnQueryTextListener(this);
            mSearchView.setQuery("", false /* submit */);
        }
    }

    @Override
    public void onResume() {
        super.onResume();
        getListView().requestFocus();
    public boolean onQueryTextSubmit(String query) {
        return false;
    }

    /**
     * Each listener needs to call {@link LocalePicker.updateLocale(Locale)} to actually
     * change the locale.
     * <p/>
     * We don't call {@link LocalePicker.updateLocale(Locale)} automatically, as it halts
     * the system for a moment and some callers won't want it.
     */
    @Override
    public void onListItemClick(ListView l, View v, int position, long id) {
        final Locale locale = ((LocalePicker.LocaleInfo) getListAdapter().getItem(position)).locale;
        // TODO: handle the back buttons to return to the language list
        if (mDisplayMode == LIST_MODE_LANGUAGE) {
            mDisplayMode = LIST_MODE_COUNTRY;
            mAdapter.setAdapterLevel(locale.toLanguageTag());
            return;
        }
        if (mListener != null) {
            mListener.onLocaleSelected(locale);
    public boolean onQueryTextChange(String newText) {
        if (mAdapter != null) {
            mAdapter.getFilter().filter(newText);
        }
        return false;
    }
}
+287 −0

File added.

Preview size limit exceeded, changes collapsed.

+265 −0

File added.

Preview size limit exceeded, changes collapsed.

+50 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading