diff --git a/core/java/com/android/internal/app/LocaleHelper.java b/core/java/com/android/internal/app/LocaleHelper.java new file mode 100644 index 0000000000000..5ac786a971dd4 --- /dev/null +++ b/core/java/com/android/internal/app/LocaleHelper.java @@ -0,0 +1,216 @@ +/* + * 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). + * + *

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:

+ * + * + *

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...

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + *

Gives priority to suggested locales (to sort them at the top).

+ */ + static final class LocaleInfoComparator implements Comparator { + 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; + } + } + } +} diff --git a/core/java/com/android/internal/app/LocalePickerWithRegion.java b/core/java/com/android/internal/app/LocalePickerWithRegion.java index 3b8f865bec5df..9a17883735342 100644 --- a/core/java/com/android/internal/app/LocalePickerWithRegion.java +++ b/core/java/com/android/internal/app/LocalePickerWithRegion.java @@ -1,5 +1,5 @@ /* - * 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,189 +16,158 @@ 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 { - final private Map mLevelOne = new ArrayMap<>(); - final private Map> mLevelTwo = new ArrayMap<>(); - final private LayoutInflater mInflater; +/** + * A two-step locale picker. It shows a language, then a country. + * + *

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.

+ */ +public class LocalePickerWithRegion extends ListFragment implements SearchView.OnQueryTextListener { - final static class LocaleAwareComparator implements Comparator { - private final Collator mCollator; + private SuggestedLocaleAdapter mAdapter; + private LocaleSelectedListener mListener; + private Set mLocaleList; + private LocaleStore.LocaleInfo mParentLocale; + private boolean mTranslatedOnly = false; + private boolean mCountryMode = false; - public LocaleAwareComparator(Locale sortLocale) { - mCollator = Collator.getInstance(sortLocale); - } - - @Override - public int compare(LocalePicker.LocaleInfo lhs, LocalePicker.LocaleInfo rhs) { - return mCollator.compare(lhs.getLabel(), rhs.getLabel()); - } + /** + * Other classes can register to be notified when a locale was selected. + * + *

This is the mechanism to "return" the result of the selection.

+ */ + 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); } - static List getCuratedLocaleList(Context context) { - final Resources resources = context.getResources(); - final String[] supportedLocaleCodes = resources.getStringArray(R.array.supported_locales); + 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; + } - final ArrayList result = new ArrayList<>(supportedLocaleCodes.length); - for (String localeId : supportedLocaleCodes) { - Locale locale = Locale.forLanguageTag(localeId); - if (!locale.getCountry().isEmpty()) { - result.add(Locale.forLanguageTag(localeId)); + 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; + } + + /** + * Sets the listener and initializes the locale list. + * + *

Returns true if we need to show the list, false if not.

+ * + *

Can return false because of an error, trying to show a list of countries, + * but no parent locale was provided.

+ * + *

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.

+ */ + 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."); + } + + this.mCountryMode = countryMode; + this.mParentLocale = parent; + this.mListener = listener; + this.mTranslatedOnly = translatedOnly; + setRetainInstance(true); + + final HashSet langTagsToIgnore = new HashSet<>(); + if (!translatedOnly) { + final LocaleList userLocales = LocalePicker.getLocales(); + final String[] langTags = userLocales.toLanguageTags().split(","); + Collections.addAll(langTagsToIgnore, langTags); + } + + if (countryMode) { + mLocaleList = LocaleStore.getLevelLocales(context, + langTagsToIgnore, parent, translatedOnly); + if (mLocaleList.size() <= 1) { + if (listener != null && (mLocaleList.size() == 1)) { + listener.onLocaleSelected(mLocaleList.iterator().next()); + } + return false; } + } else { + mLocaleList = LocaleStore.getLevelLocales(context, langTagsToIgnore, + null /* no parent */, translatedOnly); } - return result; + + return true; } - public LocaleAdapter(Context context) { - this(context, getCuratedLocaleList(context)); - } + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); - static Locale getBaseLocale(Locale locale) { - return new Locale.Builder() - .setLocale(locale) - .setRegion("") - .build(); - } - - // 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; - } - final int firstCodePointLen = s.offsetByCodePoints(0, 1); - return s.substring(0, firstCodePointLen).toUpperCase(locale) - + s.substring(firstCodePointLen); - } - - public LocaleAdapter(Context context, List 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)); - } - - final HashSet subLocales; - if (mLevelTwo.containsKey(language)) { - subLocales = mLevelTwo.get(language); + 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)); - } - - setAdapterLevel(null); - } - - public void setAdapterLevel(String parentLocale) { - this.clear(); - - if (parentLocale == null) { - this.addAll(mLevelOne.values()); } else { - this.addAll(mLevelTwo.get(parentLocale)); + sortingLocale = Locale.getDefault(); + this.getActivity().setTitle(R.string.language_selection_title); } - Locale sortLocale = (parentLocale == null) - ? Locale.getDefault() - : Locale.forLanguageTag(parentLocale); - LocaleAwareComparator comparator = new LocaleAwareComparator(sortLocale); - this.sort(comparator); - - this.notifyDataSetChanged(); - } - - @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(); - } - LocalePicker.LocaleInfo item = getItem(position); - text.setText(item.getLabel()); - text.setTextLocale(item.getLocale()); - return view; - } -} - -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); - - mAdapter = new LocaleAdapter(getContext()); - mAdapter.setAdapterLevel(null); + mAdapter = new SuggestedLocaleAdapter(mLocaleList, mCountryMode); + LocaleHelper.LocaleInfoComparator comp = + new LocaleHelper.LocaleInfoComparator(sortingLocale); + mAdapter.sort(comp); setListAdapter(mAdapter); } - public void setLocaleSelectionListener(LocaleSelectionListener listener) { - mListener = listener; + @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); } @Override @@ -207,24 +176,56 @@ public class LocalePickerWithRegion extends ListFragment { getListView().requestFocus(); } - /** - * Each listener needs to call {@link LocalePicker.updateLocale(Locale)} to actually - * change the locale. - *

- * 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); + final LocaleStore.LocaleInfo locale = + (LocaleStore.LocaleInfo) getListAdapter().getItem(position); + + if (mCountryMode || locale.getParent() != null) { + if (mListener != null) { + mListener.onLocaleSelected(locale); + } + 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); + } } } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + if (!mCountryMode) { + inflater.inflate(R.menu.language_selection_list, menu); + + MenuItem mSearchMenuItem = menu.findItem(R.id.locale_search_menu); + SearchView mSearchView = (SearchView) mSearchMenuItem.getActionView(); + + mSearchView.setQueryHint(getText(R.string.search_language_hint)); + mSearchView.setOnQueryTextListener(this); + mSearchView.setQuery("", false /* submit */); + } + } + + @Override + public boolean onQueryTextSubmit(String query) { + return false; + } + + @Override + public boolean onQueryTextChange(String newText) { + if (mAdapter != null) { + mAdapter.getFilter().filter(newText); + } + return false; + } } diff --git a/core/java/com/android/internal/app/LocaleStore.java b/core/java/com/android/internal/app/LocaleStore.java new file mode 100644 index 0000000000000..2191c58a82e79 --- /dev/null +++ b/core/java/com/android/internal/app/LocaleStore.java @@ -0,0 +1,287 @@ +/* + * 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.content.Context; +import android.provider.Settings; +import android.telephony.TelephonyManager; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.IllformedLocaleException; +import java.util.Locale; +import java.util.Set; + +public class LocaleStore { + private static final HashMap sLocaleCache = new HashMap<>(); + private static boolean sFullyInitialized = false; + + public static class LocaleInfo { + private static final int SUGGESTION_TYPE_NONE = 0x00; + private static final int SUGGESTION_TYPE_SIM = 0x01; + + private final Locale mLocale; + private final Locale mParent; + private final String mId; + private boolean mIsTranslated; + private boolean mIsPseudo; + private boolean mIsChecked; // Used by the LocaleListEditor to mark entries for deletion + // Combination of flags for various reasons to show a locale as a suggestion. + // Can be SIM, location, etc. + private int mSuggestionFlags; + + private String mFullNameNative; + private String mFullCountryNameNative; + private String mLangScriptKey; + + private LocaleInfo(Locale locale) { + this.mLocale = locale; + this.mId = locale.toLanguageTag(); + this.mParent = getParent(locale); + this.mIsChecked = false; + this.mSuggestionFlags = SUGGESTION_TYPE_NONE; + this.mIsTranslated = false; + this.mIsPseudo = false; + } + + private LocaleInfo(String localeId) { + this(Locale.forLanguageTag(localeId)); + } + + private static Locale getParent(Locale locale) { + if (locale.getCountry().isEmpty()) { + return null; + } + return new Locale.Builder() + .setLocale(locale).setRegion("") + .build(); + } + + @Override + public String toString() { + return mId; + } + + public Locale getLocale() { + return mLocale; + } + + public Locale getParent() { + return mParent; + } + + public String getId() { + return mId; + } + + public boolean isTranslated() { + return mIsTranslated; + } + + public void setTranslated(boolean isTranslated) { + mIsTranslated = isTranslated; + } + + /* package */ boolean isSuggested() { + if (!mIsTranslated) { // Never suggest an untranslated locale + return false; + } + return mSuggestionFlags != SUGGESTION_TYPE_NONE; + } + + private boolean isSuggestionOfType(int suggestionMask) { + return (mSuggestionFlags & suggestionMask) == suggestionMask; + } + + public String getFullNameNative() { + if (mFullNameNative == null) { + mFullNameNative = + LocaleHelper.getDisplayName(mLocale, mLocale, true /* sentence case */); + } + return mFullNameNative; + } + + String getFullCountryNameNative() { + if (mFullCountryNameNative == null) { + mFullCountryNameNative = LocaleHelper.getDisplayCountry(mLocale, mLocale); + } + return mFullCountryNameNative; + } + + /** Returns the name of the locale in the language of the UI. + * It is used for search, but never shown. + * For instance German will show as "Deutsch" in the list, but we will also search for + * "allemand" if the system UI is in French. + */ + public String getFullNameInUiLanguage() { + return LocaleHelper.getDisplayName(mLocale, true /* sentence case */); + } + + private String getLangScriptKey() { + if (mLangScriptKey == null) { + Locale parentWithScript = getParent(LocaleHelper.addLikelySubtags(mLocale)); + mLangScriptKey = + (parentWithScript == null) + ? mLocale.toLanguageTag() + : parentWithScript.toLanguageTag(); + } + return mLangScriptKey; + } + + String getLabel() { + if (getParent() == null || this.isSuggestionOfType(SUGGESTION_TYPE_SIM)) { + return getFullNameNative(); + } else { + return getFullCountryNameNative(); + } + } + + public boolean getChecked() { + return mIsChecked; + } + + public void setChecked(boolean checked) { + mIsChecked = checked; + } + } + + private static Set getSimCountries(Context context) { + Set result = new HashSet<>(); + + TelephonyManager tm = TelephonyManager.from(context); + + if (tm != null) { + String iso = tm.getSimCountryIso().toUpperCase(Locale.US); + if (!iso.isEmpty()) { + result.add(iso); + } + + iso = tm.getNetworkCountryIso().toUpperCase(Locale.US); + if (!iso.isEmpty()) { + result.add(iso); + } + } + + return result; + } + + public static void fillCache(Context context) { + if (sFullyInitialized) { + return; + } + + Set simCountries = getSimCountries(context); + + for (String localeId : LocalePicker.getSupportedLocales(context)) { + if (localeId.isEmpty()) { + throw new IllformedLocaleException("Bad locale entry in locale_config.xml"); + } + LocaleInfo li = new LocaleInfo(localeId); + if (simCountries.contains(li.getLocale().getCountry())) { + li.mSuggestionFlags |= LocaleInfo.SUGGESTION_TYPE_SIM; + } + sLocaleCache.put(li.getId(), li); + final Locale parent = li.getParent(); + if (parent != null) { + String parentId = parent.toLanguageTag(); + if (!sLocaleCache.containsKey(parentId)) { + sLocaleCache.put(parentId, new LocaleInfo(parent)); + } + } + } + + boolean isInDeveloperMode = Settings.Global.getInt(context.getContentResolver(), + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) != 0; + for (String localeId : LocalePicker.getPseudoLocales()) { + LocaleInfo li = getLocaleInfo(Locale.forLanguageTag(localeId)); + if (isInDeveloperMode) { + li.setTranslated(true); + li.mIsPseudo = true; + li.mSuggestionFlags |= LocaleInfo.SUGGESTION_TYPE_SIM; + } else { + sLocaleCache.remove(li.getId()); + } + } + + // TODO: See if we can reuse what LocaleList.matchScore does + final HashSet localizedLocales = new HashSet<>(); + for (String localeId : LocalePicker.getSystemAssetLocales()) { + LocaleInfo li = new LocaleInfo(localeId); + localizedLocales.add(li.getLangScriptKey()); + } + + for (LocaleInfo li : sLocaleCache.values()) { + li.setTranslated(localizedLocales.contains(li.getLangScriptKey())); + } + + sFullyInitialized = true; + } + + private static int getLevel(Set ignorables, LocaleInfo li, boolean translatedOnly) { + if (ignorables.contains(li.getId())) return 0; + if (li.mIsPseudo) return 2; + if (translatedOnly && !li.isTranslated()) return 0; + if (li.getParent() != null) return 2; + return 0; + } + + /** + * Returns a list of locales for language or region selection. + * If the parent is null, then it is the language list. + * If it is not null, then the list will contain all the locales that belong to that perent. + * Example: if the parent is "ar", then the region list will contain all Arabic locales. + * (this is not language based, but language-script, so that it works for zh-Hant and so on. + */ + /* package */ static Set getLevelLocales(Context context, Set ignorables, + LocaleInfo parent, boolean translatedOnly) { + fillCache(context); + String parentId = parent == null ? null : parent.getId(); + + HashSet result = new HashSet<>(); + for (LocaleStore.LocaleInfo li : sLocaleCache.values()) { + int level = getLevel(ignorables, li, translatedOnly); + if (level == 2) { + if (parent != null) { // region selection + if (parentId.equals(li.getParent().toLanguageTag())) { + if (!li.isSuggestionOfType(LocaleInfo.SUGGESTION_TYPE_SIM)) { + result.add(li); + } + } + } else { // language selection + if (li.isSuggestionOfType(LocaleInfo.SUGGESTION_TYPE_SIM)) { + result.add(li); + } else { + result.add(getLocaleInfo(li.getParent())); + } + } + } + } + return result; + } + + public static LocaleInfo getLocaleInfo(Locale locale) { + String id = locale.toLanguageTag(); + LocaleInfo result; + if (!sLocaleCache.containsKey(id)) { + result = new LocaleInfo(locale); + sLocaleCache.put(id, result); + } else { + result = sLocaleCache.get(id); + } + return result; + } +} diff --git a/core/java/com/android/internal/app/SuggestedLocaleAdapter.java b/core/java/com/android/internal/app/SuggestedLocaleAdapter.java new file mode 100644 index 0000000000000..2f855c6cb42f3 --- /dev/null +++ b/core/java/com/android/internal/app/SuggestedLocaleAdapter.java @@ -0,0 +1,265 @@ +/* + * 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.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Filter; +import android.widget.Filterable; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.internal.R; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Locale; +import java.util.Set; + + +/** + * This adapter wraps around a regular ListAdapter for LocaleInfo, and creates 2 sections. + * + *

The first section contains "suggested" languages (usually including a region), + * the second section contains all the languages within the original adapter. + * The "others" might still include languages that appear in the "suggested" section.

+ * + *

Example: if we show "German Switzerland" as "suggested" (based on SIM, let's say), + * then "German" will still show in the "others" section, clicking on it will only show the + * countries for all the other German locales, but not Switzerland + * (Austria, Belgium, Germany, Liechtenstein, Luxembourg)

+ */ +class SuggestedLocaleAdapter extends BaseAdapter implements Filterable { + private static final int TYPE_HEADER_SUGGESTED = 0; + private static final int TYPE_HEADER_ALL_OTHERS = 1; + private static final int TYPE_LOCALE = 2; + + private ArrayList mLocaleOptions; + private ArrayList mOriginalLocaleOptions; + private int mSuggestionCount; + private final boolean mCountryMode; + private LayoutInflater mInflater; + + SuggestedLocaleAdapter(Set localeOptions, boolean countryMode) { + mCountryMode = countryMode; + mLocaleOptions = new ArrayList<>(localeOptions.size()); + for (LocaleStore.LocaleInfo li : localeOptions) { + if (li.isSuggested()) { + mSuggestionCount++; + } + mLocaleOptions.add(li); + } + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int position) { + return getItemViewType(position) == TYPE_LOCALE; + } + + @Override + public int getItemViewType(int position) { + if (!showHeaders()) { + return TYPE_LOCALE; + } else { + if (position == 0) { + return TYPE_HEADER_SUGGESTED; + } + if (position == mSuggestionCount + 1) { + return TYPE_HEADER_ALL_OTHERS; + } + return TYPE_LOCALE; + } + } + + @Override + public int getViewTypeCount() { + if (showHeaders()) { + return 3; // Two headers in addition to the locales + } else { + return 1; // Locales items only + } + } + + @Override + public int getCount() { + if (showHeaders()) { + return mLocaleOptions.size() + 2; // 2 extra for the headers + } else { + return mLocaleOptions.size(); + } + } + + @Override + public Object getItem(int position) { + int offset = 0; + if (showHeaders()) { + offset = position > mSuggestionCount ? -2 : -1; + } + + return mLocaleOptions.get(position + offset); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null && mInflater == null) { + mInflater = LayoutInflater.from(parent.getContext()); + } + + int itemType = getItemViewType(position); + switch (itemType) { + case TYPE_HEADER_SUGGESTED: // intentional fallthrough + case TYPE_HEADER_ALL_OTHERS: + // Covers both null, and "reusing" a wrong kind of view + if (!(convertView instanceof TextView)) { + convertView = mInflater.inflate(R.layout.language_picker_section_header, + parent, false); + } + TextView textView = (TextView) convertView; + if (itemType == TYPE_HEADER_SUGGESTED) { + textView.setText(R.string.language_picker_section_suggested); + } else { + textView.setText(R.string.language_picker_section_all); + } + textView.setTextLocale(Locale.getDefault()); + break; + default: + // Covers both null, and "reusing" a wrong kind of view + if (!(convertView instanceof ViewGroup)) { + convertView = mInflater.inflate(R.layout.language_picker_item, parent, false); + } + + TextView text = (TextView) convertView.findViewById(R.id.locale); + ImageView localized = (ImageView) convertView.findViewById(R.id.l10nWarn); + LocaleStore.LocaleInfo item = (LocaleStore.LocaleInfo) getItem(position); + text.setText(item.getLabel()); + if (item.isTranslated() || mCountryMode) { + localized.setVisibility(View.GONE); + text.setTextLocale(item.getLocale()); + } else { + localized.setVisibility(View.VISIBLE); + text.setTextLocale(Locale.getDefault()); + } + } + return convertView; + } + + + private boolean showHeaders() { + return mSuggestionCount != 0 && mSuggestionCount != mLocaleOptions.size(); + } + + public void sort(LocaleHelper.LocaleInfoComparator comp) { + Collections.sort(mLocaleOptions, comp); + } + + class FilterByNativeAndUiNames extends Filter { + + @Override + protected FilterResults performFiltering(CharSequence prefix) { + FilterResults results = new FilterResults(); + + if (mOriginalLocaleOptions == null) { + mOriginalLocaleOptions = new ArrayList<>(mLocaleOptions); + } + + ArrayList values; + values = new ArrayList<>(mOriginalLocaleOptions); + if (prefix == null || prefix.length() == 0) { + results.values = values; + results.count = values.size(); + } else { + // TODO: decide if we should use the string's locale + Locale locale = Locale.getDefault(); + String prefixString = LocaleHelper.normalizeForSearch(prefix.toString(), locale); + + final int count = values.size(); + final ArrayList newValues = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + final LocaleStore.LocaleInfo value = values.get(i); + final String nameToCheck = LocaleHelper.normalizeForSearch( + value.getFullNameInUiLanguage(), locale); + final String nativeNameToCheck = LocaleHelper.normalizeForSearch( + value.getFullNameNative(), locale); + if (wordMatches(nativeNameToCheck, prefixString) + || wordMatches(nameToCheck, prefixString)) { + newValues.add(value); + } + } + + results.values = newValues; + results.count = newValues.size(); + } + + return results; + } + + // TODO: decide if this is enough, or we want to use a BreakIterator... + boolean wordMatches(String valueText, String prefixString) { + // First match against the whole, non-splitted value + if (valueText.startsWith(prefixString)) { + return true; + } + + final String[] words = valueText.split(" "); + // Start at index 0, in case valueText starts with space(s) + for (String word : words) { + if (word.startsWith(prefixString)) { + return true; + } + } + + return false; + } + + @Override + @SuppressWarnings("unchecked") + protected void publishResults(CharSequence constraint, FilterResults results) { + mLocaleOptions = (ArrayList) results.values; + + mSuggestionCount = 0; + for (LocaleStore.LocaleInfo li : mLocaleOptions) { + if (li.isSuggested()) { + mSuggestionCount++; + } + } + + if (results.count > 0) { + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + } + + @Override + public Filter getFilter() { + return new FilterByNativeAndUiNames(); + } +} diff --git a/core/res/res/layout/language_picker_item.xml b/core/res/res/layout/language_picker_item.xml new file mode 100644 index 0000000000000..22cb514b10b9f --- /dev/null +++ b/core/res/res/layout/language_picker_item.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/core/res/res/layout/language_picker_section_header.xml b/core/res/res/layout/language_picker_section_header.xml new file mode 100644 index 0000000000000..c4d3069c19188 --- /dev/null +++ b/core/res/res/layout/language_picker_section_header.xml @@ -0,0 +1,25 @@ + + + + diff --git a/core/res/res/menu/language_selection_list.xml b/core/res/res/menu/language_selection_list.xml new file mode 100644 index 0000000000000..63b962740e75a --- /dev/null +++ b/core/res/res/menu/language_selection_list.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/core/res/res/values/locale_config.xml b/core/res/res/values/locale_config.xml index 3cfd9f4be0cef..f07fe7086693d 100644 --- a/core/res/res/values/locale_config.xml +++ b/core/res/res/values/locale_config.xml @@ -1,21 +1,20 @@ - - + + af-NA diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml index f780df3dfcf02..258c5d9466507 100644 --- a/core/res/res/values/strings.xml +++ b/core/res/res/values/strings.xml @@ -4146,4 +4146,22 @@ %1$s is trying to add a new user, but the account %2$s already exists on this device. Proceed anyway? %1$s is trying to add a new user for the account %2$s. Proceed? + + + + + Language preference + + Region preference + + Type language name + + + Suggested + + All languages + + + Search + diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index ee334be9835ba..9023dd420e62d 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -2473,4 +2473,17 @@ + + + + + + + + + + + + +