Separate locale suggestion code to static library
Test: atest RunLcoalePickerRoboTests
Bug: 114040620
Change-Id: Ic47278955e837302aa3c7e68705d5cca86e30de6
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..680c703
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,24 @@
+// Copyright (C) 2018 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.
+
+android_library {
+ name: "localepicker",
+ manifest: "AndroidManifest.xml",
+ resource_dirs: [
+ "res",
+ ],
+ srcs: [
+ "src/**/*.java",
+ ],
+}
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..472f3e5
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2015 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.localepicker">
+
+ <uses-sdk android:minSdkVersion="28" android:targetSdkVersion="28" />
+
+</manifest>
diff --git a/res/layout/language_picker_item.xml b/res/layout/language_picker_item.xml
new file mode 100644
index 0000000..d63ed5d
--- /dev/null
+++ b/res/layout/language_picker_item.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:id="@+id/locale"
+ android:gravity="center_vertical"
+ android:minHeight="?android:attr/listPreferredItemHeight"
+ android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+ android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+ android:textAppearance="?android:attr/textAppearanceListItem"
+ android:layoutDirection="locale"
+ android:textDirection="locale"
+ android:paddingVertical="8dp" />
diff --git a/res/layout/language_picker_section_header.xml b/res/layout/language_picker_section_header.xml
new file mode 100644
index 0000000..87174ce
--- /dev/null
+++ b/res/layout/language_picker_section_header.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="?android:attr/preferenceCategoryStyle"
+ android:layout_width="match_parent"
+ android:layout_height="36dp"
+ android:gravity="center_vertical"
+ android:paddingStart="?android:attr/listPreferredItemPaddingStart"
+ android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
+ android:textColor="?android:attr/colorAccent"
+ android:textStyle="bold"
+ tools:text="@string/language_picker_section_all"/>
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 0000000..0eec907
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 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.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+ <!-- List section subheader for the language picker, containing a list of suggested languages determined by the default region [CHAR LIMIT=30] -->
+ <string name="language_picker_section_suggested">Suggested</string>
+ <!-- List section subheader for the language picker, containing a list of all languages available [CHAR LIMIT=30] -->
+ <string name="language_picker_section_all">All languages</string>
+ <!-- List section subheader for the region picker, containing a list of all regions supported for the selected language.
+ Warning: this is a more 'neutral' term for 'country', not for the sub-divisions of a country. [CHAR LIMIT=30] -->
+ <string name="region_picker_section_all">All regions</string>
+
+</resources>
\ No newline at end of file
diff --git a/src/com/android/localepicker/LocaleHelper.java b/src/com/android/localepicker/LocaleHelper.java
new file mode 100644
index 0000000..7fd598f
--- /dev/null
+++ b/src/com/android/localepicker/LocaleHelper.java
@@ -0,0 +1,271 @@
+/*
+ * 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.localepicker;
+
+import android.annotation.IntRange;
+import android.icu.text.ListFormatter;
+import android.icu.util.ULocale;
+import android.os.LocaleList;
+import android.text.TextUtils;
+
+import java.text.Collator;
+import java.util.Comparator;
+import java.util.Locale;
+
+/**
+ * This class implements some handy methods to process 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 a BreakIterator 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.
+ */
+ 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
+ */
+ 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 != null ? str.toUpperCase() : null;
+ }
+
+ // For some locales we want to use a "dialect" form, for instance
+ // "Dari" instead of "Persian (Afghanistan)", or "Moldavian" instead of "Romanian (Moldova)"
+ private static boolean shouldUseDialectName(Locale locale) {
+ final String lang = locale.getLanguage();
+ return "fa".equals(lang) // Persian
+ || "ro".equals(lang) // Romanian
+ || "zh".equals(lang); // Chinese
+ }
+
+ /**
+ * 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) {
+ final ULocale displayULocale = ULocale.forLocale(displayLocale);
+ String result = shouldUseDialectName(locale)
+ ? ULocale.getDisplayNameWithDialect(locale.toLanguageTag(), displayULocale)
+ : ULocale.getDisplayName(locale.toLanguageTag(), displayULocale);
+ 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) {
+ return getDisplayName(locale, Locale.getDefault(), sentenceCase);
+ }
+
+ /**
+ * 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) {
+ final String languageTag = locale.toLanguageTag();
+ final ULocale uDisplayLocale = ULocale.forLocale(displayLocale);
+ final String country = ULocale.getDisplayCountry(languageTag, uDisplayLocale);
+ final String numberingSystem = locale.getUnicodeLocaleType("nu");
+ if (numberingSystem != null) {
+ return String.format("%s (%s)", country,
+ ULocale.getDisplayKeywordValue(languageTag, "numbers", uDisplayLocale));
+ } else {
+ return country;
+ }
+ }
+
+ /**
+ * 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.
+ * @param maxLocales maximum number of locales to display. Generates ellipsis after that.
+ * @return the locale aware list of locale names
+ */
+ public static String getDisplayLocaleList(
+ LocaleList locales, Locale displayLocale, @IntRange(from=1) int maxLocales) {
+
+ final Locale dispLocale = displayLocale == null ? Locale.getDefault() : displayLocale;
+
+ final boolean ellipsisNeeded = locales.size() > maxLocales;
+ final int localeCount, listCount;
+ if (ellipsisNeeded) {
+ localeCount = maxLocales;
+ listCount = maxLocales + 1; // One extra slot for the ellipsis
+ } else {
+ listCount = localeCount = locales.size();
+ }
+ final String[] localeNames = new String[listCount];
+ for (int i = 0; i < localeCount; i++) {
+ localeNames[i] = LocaleHelper.getDisplayName(locales.get(i), dispLocale, false);
+ }
+ if (ellipsisNeeded) {
+ // Theoretically, we want to extract this from ICU's Resource Bundle for
+ // "Ellipsis/final", which seeAms to have different strings than the normal ellipsis for
+ // Hong Kong Traditional Chinese (zh_Hant_HK) and Dzongkha (dz). But that has two
+ // problems: it's expensive to extract it, and in case the output string becomes
+ // automatically ellipsized, it can result in weird output.
+ localeNames[maxLocales] = TextUtils.getEllipsisString(TextUtils.TruncateAt.END);
+ }
+
+ ListFormatter lfn = ListFormatter.getInstance(dispLocale);
+ return lfn.format((Object[]) localeNames);
+ }
+
+ /**
+ * 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>
+ */
+ public static final class LocaleInfoComparator implements Comparator<LocaleStore.LocaleInfo> {
+ private final Collator mCollator;
+ private final boolean mCountryMode;
+ private static final String PREFIX_ARABIC = "\u0627\u0644"; // ALEF-LAM, ال
+
+ /**
+ * Constructor.
+ *
+ * @param sortLocale the locale to be used for sorting.
+ */
+ public LocaleInfoComparator(Locale sortLocale, boolean countryMode) {
+ mCollator = Collator.getInstance(sortLocale);
+ mCountryMode = countryMode;
+ }
+
+ /*
+ * The Arabic collation should ignore Alef-Lam at the beginning (b/26277596)
+ *
+ * We look at the label's locale, not the current system locale.
+ * This is because the name of the Arabic language itself is in Arabic,
+ * and starts with Alef-Lam, no matter what the system locale is.
+ */
+ private String removePrefixForCompare(Locale locale, String str) {
+ if ("ar".equals(locale.getLanguage()) && str.startsWith(PREFIX_ARABIC)) {
+ return str.substring(PREFIX_ARABIC.length());
+ }
+ return str;
+ }
+
+ /**
+ * 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(
+ removePrefixForCompare(lhs.getLocale(), lhs.getLabel(mCountryMode)),
+ removePrefixForCompare(rhs.getLocale(), rhs.getLabel(mCountryMode)));
+ } else {
+ // One locale is suggested and one is not, so we put them in different "buckets"
+ return lhs.isSuggested() ? -1 : 1;
+ }
+ }
+ }
+}
diff --git a/src/com/android/localepicker/LocaleStore.java b/src/com/android/localepicker/LocaleStore.java
new file mode 100644
index 0000000..5d07ff2
--- /dev/null
+++ b/src/com/android/localepicker/LocaleStore.java
@@ -0,0 +1,382 @@
+/*
+ * 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.localepicker;
+
+import android.content.Context;
+import android.os.LocaleList;
+import android.provider.Settings;
+import android.telephony.TelephonyManager;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.app.LocalePicker;
+
+import java.io.Serializable;
+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<String, LocaleInfo> sLocaleCache = new HashMap<>();
+ private static boolean sFullyInitialized = false;
+
+ public static class LocaleInfo implements Serializable {
+ @VisibleForTesting static final int SUGGESTION_TYPE_NONE = 0;
+ @VisibleForTesting static final int SUGGESTION_TYPE_SIM = 1 << 0;
+ @VisibleForTesting static final int SUGGESTION_TYPE_CFG = 1 << 1;
+
+ 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.
+ @VisibleForTesting 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("")
+ .setExtension(Locale.UNICODE_LOCALE_EXTENSION, "")
+ .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) {
+ if (!mIsTranslated) { // Never suggest an untranslated locale
+ return false;
+ }
+ 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;
+ }
+
+ String getFullCountryNameInUiLanguage() {
+ // We don't cache the UI name because the default locale keeps changing
+ return LocaleHelper.getDisplayCountry(mLocale);
+ }
+
+ /** 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() {
+ // We don't cache the UI name because the default locale keeps changing
+ return LocaleHelper.getDisplayName(mLocale, true /* sentence case */);
+ }
+
+ private String getLangScriptKey() {
+ if (mLangScriptKey == null) {
+ Locale baseLocale = new Locale.Builder()
+ .setLocale(mLocale)
+ .setExtension(Locale.UNICODE_LOCALE_EXTENSION, "")
+ .build();
+ Locale parentWithScript = getParent(LocaleHelper.addLikelySubtags(baseLocale));
+ mLangScriptKey =
+ (parentWithScript == null)
+ ? mLocale.toLanguageTag()
+ : parentWithScript.toLanguageTag();
+ }
+ return mLangScriptKey;
+ }
+
+ String getLabel(boolean countryMode) {
+ if (countryMode) {
+ return getFullCountryNameNative();
+ } else {
+ return getFullNameNative();
+ }
+ }
+
+ String getContentDescription(boolean countryMode) {
+ if (countryMode) {
+ return getFullCountryNameInUiLanguage();
+ } else {
+ return getFullNameInUiLanguage();
+ }
+ }
+
+ public boolean getChecked() {
+ return mIsChecked;
+ }
+
+ public void setChecked(boolean checked) {
+ mIsChecked = checked;
+ }
+ }
+
+ private static Set<String> getSimCountries(Context context) {
+ Set<String> 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;
+ }
+
+ /*
+ * This method is added for SetupWizard, to force an update of the suggested locales
+ * when the SIM is initialized.
+ *
+ * <p>When the device is freshly started, it sometimes gets to the language selection
+ * before the SIM is properly initialized.
+ * So at the time the cache is filled, the info from the SIM might not be available.
+ * The SetupWizard has a SimLocaleMonitor class to detect onSubscriptionsChanged events.
+ * SetupWizard will call this function when that happens.</p>
+ *
+ * <p>TODO: decide if it is worth moving such kind of monitoring in this shared code.
+ * The user might change the SIM or might cross border and connect to a network
+ * in a different country, without restarting the Settings application or the phone.</p>
+ */
+ public static void updateSimCountries(Context context) {
+ Set<String> simCountries = getSimCountries(context);
+
+ for (LocaleInfo li : sLocaleCache.values()) {
+ // This method sets the suggestion flags for the (new) SIM locales, but it does not
+ // try to clean up the old flags. After all, if the user replaces a German SIM
+ // with a French one, it is still possible that they are speaking German.
+ // So both French and German are reasonable suggestions.
+ if (simCountries.contains(li.getLocale().getCountry())) {
+ li.mSuggestionFlags |= LocaleInfo.SUGGESTION_TYPE_SIM;
+ }
+ }
+ }
+
+ /*
+ * Show all the languages supported for a country in the suggested list.
+ * This is also handy for devices without SIM (tablets).
+ */
+ private static void addSuggestedLocalesForRegion(Locale locale) {
+ if (locale == null) {
+ return;
+ }
+ final String country = locale.getCountry();
+ if (country.isEmpty()) {
+ return;
+ }
+
+ for (LocaleInfo li : sLocaleCache.values()) {
+ if (country.equals(li.getLocale().getCountry())) {
+ // We don't need to differentiate between manual and SIM suggestions
+ li.mSuggestionFlags |= LocaleInfo.SUGGESTION_TYPE_SIM;
+ }
+ }
+ }
+
+ public static void fillCache(Context context) {
+ if (sFullyInitialized) {
+ return;
+ }
+
+ Set<String> simCountries = getSimCountries(context);
+
+ final boolean isInDeveloperMode = Settings.Global.getInt(context.getContentResolver(),
+ Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) != 0;
+ 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 (LocaleList.isPseudoLocale(li.getLocale())) {
+ if (isInDeveloperMode) {
+ li.setTranslated(true);
+ li.mIsPseudo = true;
+ li.mSuggestionFlags |= LocaleInfo.SUGGESTION_TYPE_SIM;
+ } else {
+ // Do not display pseudolocales unless in development mode.
+ continue;
+ }
+ }
+
+ 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));
+ }
+ }
+ }
+
+ // TODO: See if we can reuse what LocaleList.matchScore does
+ final HashSet<String> localizedLocales = new HashSet<>();
+ for (String localeId : LocalePicker.getSystemAssetLocales()) {
+ LocaleInfo li = new LocaleInfo(localeId);
+ final String country = li.getLocale().getCountry();
+ // All this is to figure out if we should suggest a country
+ if (!country.isEmpty()) {
+ LocaleInfo cachedLocale = null;
+ if (sLocaleCache.containsKey(li.getId())) { // the simple case, e.g. fr-CH
+ cachedLocale = sLocaleCache.get(li.getId());
+ } else { // e.g. zh-TW localized, zh-Hant-TW in cache
+ final String langScriptCtry = li.getLangScriptKey() + "-" + country;
+ if (sLocaleCache.containsKey(langScriptCtry)) {
+ cachedLocale = sLocaleCache.get(langScriptCtry);
+ }
+ }
+ if (cachedLocale != null) {
+ cachedLocale.mSuggestionFlags |= LocaleInfo.SUGGESTION_TYPE_CFG;
+ }
+ }
+ localizedLocales.add(li.getLangScriptKey());
+ }
+
+ for (LocaleInfo li : sLocaleCache.values()) {
+ li.setTranslated(localizedLocales.contains(li.getLangScriptKey()));
+ }
+
+ addSuggestedLocalesForRegion(Locale.getDefault());
+
+ sFullyInitialized = true;
+ }
+
+ private static int getLevel(Set<String> 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 parent.
+ * 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.
+ */
+ public static Set<LocaleInfo> getLevelLocales(Context context, Set<String> ignorables,
+ LocaleInfo parent, boolean translatedOnly) {
+ fillCache(context);
+ String parentId = parent == null ? null : parent.getId();
+
+ HashSet<LocaleInfo> 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())) {
+ 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/src/com/android/localepicker/SuggestedLocaleAdapter.java b/src/com/android/localepicker/SuggestedLocaleAdapter.java
new file mode 100644
index 0000000..97e2971
--- /dev/null
+++ b/src/com/android/localepicker/SuggestedLocaleAdapter.java
@@ -0,0 +1,327 @@
+/*
+ * 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.localepicker;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.text.TextUtils;
+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.TextView;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.localepicker.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.
+ *
+ * <p>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.</p>
+ *
+ * <p>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)</p>
+ *
+ * <p>This class implements {@link Filterable}; apps using this class can use
+ * {@code getFilter().filter(...)} to filter on the available list of locales.</p>
+ */
+public class SuggestedLocaleAdapter extends BaseAdapter implements Filterable {
+ @VisibleForTesting static final int TYPE_HEADER_SUGGESTED = 0;
+ @VisibleForTesting static final int TYPE_HEADER_ALL_OTHERS = 1;
+ @VisibleForTesting static final int TYPE_LOCALE = 2;
+ @VisibleForTesting static final int MIN_REGIONS_FOR_SUGGESTIONS = 6;
+
+ private ArrayList<LocaleStore.LocaleInfo> mLocaleOptions;
+ private ArrayList<LocaleStore.LocaleInfo> mOriginalLocaleOptions;
+ private int mSuggestionCount;
+ private final boolean mCountryMode;
+ private LayoutInflater mInflater;
+
+ private Locale mDisplayLocale = null;
+ // used to potentially cache a modified Context that uses mDisplayLocale
+ private Context mContextOverride = null;
+
+ public SuggestedLocaleAdapter(Set<LocaleStore.LocaleInfo> 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 LocaleStore.LocaleInfo getItem(int position) {
+ if (getItemViewType(position) != TYPE_LOCALE) {
+ return null;
+ }
+
+ int offset = 0;
+ if (showHeaders()) {
+ offset = position > mSuggestionCount ? -2 : -1;
+ }
+
+ return mLocaleOptions.get(position + offset);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ /**
+ * Overrides the locale used to display localized labels. Setting the locale to null will reset
+ * the Adapter to use the default locale for the labels.
+ */
+ public void setDisplayLocale(@NonNull Context context, @Nullable Locale locale) {
+ if (locale == null) {
+ mDisplayLocale = null;
+ mContextOverride = null;
+ } else if (!locale.equals(mDisplayLocale)) {
+ mDisplayLocale = locale;
+ final Configuration configOverride = new Configuration();
+ configOverride.setLocale(locale);
+ mContextOverride = context.createConfigurationContext(configOverride);
+ }
+ }
+
+ private void setTextTo(@NonNull TextView textView, int resId) {
+ if (mContextOverride == null) {
+ textView.setText(resId);
+ } else {
+ textView.setText(mContextOverride.getText(resId));
+ // If mContextOverride is not null, mDisplayLocale can't be null either.
+ }
+ }
+
+ @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) {
+ setTextTo(textView, R.string.language_picker_section_suggested);
+ } else {
+ if (mCountryMode) {
+ setTextTo(textView, R.string.region_picker_section_all);
+ } else {
+ setTextTo(textView, R.string.language_picker_section_all);
+ }
+ }
+ textView.setTextLocale(
+ mDisplayLocale != null ? mDisplayLocale : 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);
+ LocaleStore.LocaleInfo item = (LocaleStore.LocaleInfo) getItem(position);
+ text.setText(item.getLabel(mCountryMode));
+ text.setTextLocale(item.getLocale());
+ text.setContentDescription(item.getContentDescription(mCountryMode));
+ if (mCountryMode) {
+ int layoutDir = TextUtils.getLayoutDirectionFromLocale(item.getParent());
+ //noinspection ResourceType
+ convertView.setLayoutDirection(layoutDir);
+ text.setTextDirection(layoutDir == View.LAYOUT_DIRECTION_RTL
+ ? View.TEXT_DIRECTION_RTL
+ : View.TEXT_DIRECTION_LTR);
+ }
+ }
+ return convertView;
+ }
+
+ private boolean showHeaders() {
+ // We don't want to show suggestions for locales with very few regions
+ // (e.g. Romanian, with 2 regions)
+ // So we put a (somewhat) arbitrary limit.
+ //
+ // The initial idea was to make that limit dependent on the screen height.
+ // But that would mean rotating the screen could make the suggestions disappear,
+ // as the number of countries that fits on the screen would be different in portrait
+ // and landscape mode.
+ if (mCountryMode && mLocaleOptions.size() < MIN_REGIONS_FOR_SUGGESTIONS) {
+ return false;
+ }
+ return mSuggestionCount != 0 && mSuggestionCount != mLocaleOptions.size();
+ }
+
+ /**
+ * Sorts the items in the adapter using a locale-aware comparator.
+ * @param comp The locale-aware comparator to use.
+ */
+ 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<LocaleStore.LocaleInfo> 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<LocaleStore.LocaleInfo> 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-split 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
+ protected void publishResults(CharSequence constraint, FilterResults results) {
+ mLocaleOptions = (ArrayList<LocaleStore.LocaleInfo>) 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/tests/Android.mk b/tests/Android.mk
new file mode 100644
index 0000000..194ab27
--- /dev/null
+++ b/tests/Android.mk
@@ -0,0 +1,66 @@
+#############################################################
+# Build test package for locale picker lib. #
+#############################################################
+
+LOCAL_PATH := $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_PACKAGE_NAME := LocalePickerTest
+
+LOCAL_PRIVATE_PLATFORM_APIS := true
+LOCAL_PROGUARD_ENABLED := disabled
+
+LOCAL_STATIC_ANDROID_LIBRARIES += localepicker
+
+LOCAL_USE_AAPT2 := true
+
+LOCAL_MODULE_TAGS := optional
+
+include $(BUILD_PACKAGE)
+
+#############################################################
+# LocalePicker Robolectric test target. #
+#############################################################
+include $(CLEAR_VARS)
+
+LOCAL_MODULE := LocalePickerRoboTests
+LOCAL_MODULE_CLASS := JAVA_LIBRARIES
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
+LOCAL_JAVA_RESOURCE_DIRS := config
+
+LOCAL_JAVA_LIBRARIES := \
+ robolectric_android-all-stub \
+ Robolectric_all-target \
+ mockito-robolectric-prebuilt \
+ truth-prebuilt
+
+LOCAL_INSTRUMENTATION_FOR := LocalePickerTest
+
+LOCAL_MODULE_TAGS := optional
+
+# Generate test_config.properties
+include external/robolectric-shadows/gen_test_config.mk
+
+include $(BUILD_STATIC_JAVA_LIBRARY)
+
+#############################################################
+# LocalePicker runner target to run the previous target. #
+#############################################################
+include $(CLEAR_VARS)
+
+LOCAL_MODULE := RunLocalePickerRoboTests
+
+LOCAL_JAVA_LIBRARIES := \
+ LocalePickerRoboTests \
+ robolectric_android-all-stub \
+ Robolectric_all-target \
+ mockito-robolectric-prebuilt \
+ truth-prebuilt
+
+LOCAL_TEST_PACKAGE := LocalePickerTest
+
+LOCAL_INSTRUMENT_SOURCE_DIRS := $(LOCAL_PATH)/../src
+
+include external/robolectric-shadows/run_robotests.mk
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
new file mode 100644
index 0000000..bc6f0b6
--- /dev/null
+++ b/tests/AndroidManifest.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2018 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.localepicker.test">
+
+</manifest>
\ No newline at end of file
diff --git a/tests/config/robolectric.properties b/tests/config/robolectric.properties
new file mode 100644
index 0000000..4c863dc
--- /dev/null
+++ b/tests/config/robolectric.properties
@@ -0,0 +1,16 @@
+#
+# Copyright (C) 2018 Google Inc.
+#
+# 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.
+#
+sdk=NEWEST_SDK
diff --git a/tests/src/com/android/localepicker/LocaleHelperTest.java b/tests/src/com/android/localepicker/LocaleHelperTest.java
new file mode 100644
index 0000000..e4ddb42
--- /dev/null
+++ b/tests/src/com/android/localepicker/LocaleHelperTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2018 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.localepicker;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.android.localepicker.LocaleHelper.LocaleInfoComparator;
+import com.android.localepicker.LocaleStore.LocaleInfo;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Locale;
+
+@RunWith(RobolectricTestRunner.class)
+public class LocaleHelperTest {
+
+ @Test
+ public void toSentenceCase_shouldUpperCaseFirstLetter() {
+ String sentenceCase = LocaleHelper.toSentenceCase("hello Google", Locale.US);
+ assertThat(sentenceCase).isEqualTo("Hello Google");
+ }
+
+ @Test
+ public void normalizeForSearch_sameStrings_shouldBeEqualAfterNormalized() {
+ String lowerCase = LocaleHelper.normalizeForSearch("english", Locale.US);
+ String upperCase = LocaleHelper.normalizeForSearch("ENGLISH", Locale.US);
+ assertThat(lowerCase).isEqualTo(upperCase);
+ }
+
+ @Test
+ public void getDisplayName_shouldReturnLocaleDisplayName() {
+ String displayName =
+ LocaleHelper.getDisplayName(Locale.US, Locale.US, /* sentenceCase */ true);
+ assertThat(displayName).isEqualTo("English (United States)");
+ }
+
+ @Test
+ public void getDisplayName_withDifferentLocale_shouldReturnLocalizedDisplayName() {
+ String displayName =
+ LocaleHelper.getDisplayName(
+ Locale.CANADA_FRENCH,
+ Locale.US,
+ /* sentenceCase */ true);
+ assertThat(displayName).isEqualTo("French (Canada)");
+ }
+
+ @Test
+ public void getDisplayCountry_shouldReturnLocalizedCountryName() {
+ String displayCountry = LocaleHelper.getDisplayCountry(Locale.GERMANY, Locale.GERMANY);
+ assertThat(displayCountry).isEqualTo("Deutschland");
+ }
+
+ @Test
+ public void localeInfoComparator_shouldSortLocales() {
+ LocaleInfo germany = LocaleStore.getLocaleInfo(Locale.GERMANY);
+ LocaleInfo unitedStates = LocaleStore.getLocaleInfo(Locale.US);
+ LocaleInfo japan = LocaleStore.getLocaleInfo(Locale.JAPAN);
+
+ ArrayList<LocaleInfo> list =
+ new ArrayList<>(Arrays.asList(germany, unitedStates, japan));
+ list.sort(new LocaleInfoComparator(Locale.US, /* countryMode */ false));
+ assertThat(list).containsExactly(germany, unitedStates, japan).inOrder();
+ }
+}
diff --git a/tests/src/com/android/localepicker/LocaleStoreTest.java b/tests/src/com/android/localepicker/LocaleStoreTest.java
new file mode 100644
index 0000000..ae406e7
--- /dev/null
+++ b/tests/src/com/android/localepicker/LocaleStoreTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2018 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.localepicker;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.robolectric.RuntimeEnvironment.application;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.icu.util.ULocale;
+import android.telephony.TelephonyManager;
+
+import com.android.internal.app.LocalePicker;
+import com.android.localepicker.LocaleStore.LocaleInfo;
+import com.android.localepicker.LocaleStoreTest.ShadowICU;
+import com.android.localepicker.LocaleStoreTest.ShadowLocalePicker;
+
+import libcore.icu.ICU;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.util.Locale;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowLocalePicker.class,
+ ShadowICU.class,
+ })
+public class LocaleStoreTest {
+
+ @Before
+ public void setupTelephonyManager() {
+ TelephonyManager telephonyManager = application.getSystemService(TelephonyManager.class);
+ shadowOf(telephonyManager).setNetworkCountryIso("us");
+ shadowOf(telephonyManager).setSimCountryIso("us");
+ }
+
+ @Before
+ public void fillCache() {
+ LocaleStore.fillCache(application);
+ }
+
+ @Test
+ public void getLevel() {
+ LocaleInfo localeInfo = LocaleStore.getLocaleInfo(Locale.forLanguageTag("zh-Hant-HK"));
+ assertThat(localeInfo.getParent().toLanguageTag()).isEqualTo("zh-Hant");
+ assertThat(localeInfo.isTranslated()).named("is translated").isTrue();
+ }
+
+ @Implements(LocalePicker.class)
+ public static class ShadowLocalePicker {
+
+ @Implementation
+ public static String[] getSystemAssetLocales() {
+ return new String[] { "en-US", "zh-HK", "ja-JP", "zh-TW" };
+ }
+ }
+
+ @Implements(ICU.class)
+ public static class ShadowICU {
+
+ @Implementation
+ public static Locale addLikelySubtags(Locale locale) {
+ ULocale uLocale = ULocale.addLikelySubtags(ULocale.forLocale(locale));
+ return uLocale.toLocale();
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/localepicker/SuggestedLocaleAdapterTest.java b/tests/src/com/android/localepicker/SuggestedLocaleAdapterTest.java
new file mode 100644
index 0000000..2c735dd
--- /dev/null
+++ b/tests/src/com/android/localepicker/SuggestedLocaleAdapterTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2018 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.localepicker;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.android.localepicker.LocaleHelper.LocaleInfoComparator;
+import com.android.localepicker.LocaleStore.LocaleInfo;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+@RunWith(RobolectricTestRunner.class)
+public class SuggestedLocaleAdapterTest {
+
+ private HashSet<LocaleInfo> mLocaleOptions;
+ private HashSet<LocaleInfo> mEnglishCountryOptions;
+
+ @Before
+ public void setUp() {
+ mLocaleOptions = new HashSet<>();
+ mLocaleOptions.add(LocaleStore.getLocaleInfo(Locale.US));
+ mLocaleOptions.add(LocaleStore.getLocaleInfo(Locale.GERMANY));
+ mLocaleOptions.add(LocaleStore.getLocaleInfo(Locale.JAPAN));
+ LocaleInfo korea = LocaleStore.getLocaleInfo(Locale.KOREA);
+ korea.setTranslated(true);
+ korea.mSuggestionFlags = LocaleInfo.SUGGESTION_TYPE_SIM;
+ mLocaleOptions.add(korea);
+
+ mEnglishCountryOptions = new HashSet<>();
+ mEnglishCountryOptions.add(LocaleStore.getLocaleInfo(Locale.US));
+ mEnglishCountryOptions.add(LocaleStore.getLocaleInfo(Locale.UK));
+ mEnglishCountryOptions.add(LocaleStore.getLocaleInfo(Locale.CANADA));
+ mEnglishCountryOptions.add(LocaleStore.getLocaleInfo(Locale.forLanguageTag("en-IN")));
+ mEnglishCountryOptions.add(LocaleStore.getLocaleInfo(Locale.forLanguageTag("en-HK")));
+ mEnglishCountryOptions.add(LocaleStore.getLocaleInfo(Locale.forLanguageTag("en-SG")));
+ LocaleInfo australianEnglish = LocaleStore.getLocaleInfo(Locale.forLanguageTag("en-AU"));
+ australianEnglish.setTranslated(true);
+ australianEnglish.mSuggestionFlags = LocaleInfo.SUGGESTION_TYPE_SIM;
+ mEnglishCountryOptions.add(australianEnglish);
+ }
+
+ @Test
+ public void suggestedLocaleAdapter_notCountryMode_shouldDisplayLocalesSorted() {
+ SuggestedLocaleAdapter suggestedLocaleAdapter =
+ new SuggestedLocaleAdapter(mLocaleOptions, /* countryMode */ false);
+ suggestedLocaleAdapter.sort(new LocaleInfoComparator(Locale.US, /* countryMode */ false));
+
+ assertThat(getItemTypeList(suggestedLocaleAdapter))
+ .containsExactly(
+ SuggestedLocaleAdapter.TYPE_HEADER_SUGGESTED,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_HEADER_ALL_OTHERS,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE)
+ .inOrder();
+ assertThat(getLocaleTagList(suggestedLocaleAdapter))
+ .containsExactly(
+ null,
+ "ko-KR",
+ null,
+ "de-DE",
+ "en-US",
+ "ja-JP")
+ .inOrder();
+ }
+
+ @Test
+ public void suggestedLocaleAdapter_countryMode_shouldDisplayCountriesSorted() {
+ SuggestedLocaleAdapter suggestedLocaleAdapter =
+ new SuggestedLocaleAdapter(mEnglishCountryOptions, /* countryMode */ true);
+ suggestedLocaleAdapter.sort(new LocaleInfoComparator(Locale.US, /* countryMode */ true));
+
+ assertThat(getItemTypeList(suggestedLocaleAdapter))
+ .containsExactly(
+ SuggestedLocaleAdapter.TYPE_HEADER_SUGGESTED,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_HEADER_ALL_OTHERS,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE,
+ SuggestedLocaleAdapter.TYPE_LOCALE)
+ .inOrder();
+ assertThat(getLocaleTagList(suggestedLocaleAdapter))
+ .containsExactly(
+ null,
+ "en-AU",
+ null,
+ "en-CA",
+ "en-HK",
+ "en-IN",
+ "en-SG",
+ "en-GB",
+ "en-US")
+ .inOrder();
+ }
+
+ private static List<Integer> getItemTypeList(final SuggestedLocaleAdapter adapter) {
+ return IntStream.range(0, adapter.getCount())
+ .mapToObj(adapter::getItemViewType)
+ .collect(Collectors.toList());
+ }
+
+ private static List<String> getLocaleTagList(final SuggestedLocaleAdapter adapter) {
+ return IntStream.range(0, adapter.getCount())
+ .mapToObj(position -> {
+ LocaleInfo localeInfo = adapter.getItem(position);
+ if (localeInfo == null) {
+ return null;
+ }
+ return localeInfo.getLocale().toLanguageTag();
+ })
+ .collect(Collectors.toList());
+ }
+}