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());
+    }
+}