diff --git a/core/java/com/android/internal/inputmethod/LocaleUtils.java b/core/java/com/android/internal/inputmethod/LocaleUtils.java index 99bb4cbea14ad..dc33d509d0d7a 100644 --- a/core/java/com/android/internal/inputmethod/LocaleUtils.java +++ b/core/java/com/android/internal/inputmethod/LocaleUtils.java @@ -18,15 +18,17 @@ package com.android.internal.inputmethod; import com.android.internal.annotations.VisibleForTesting; +import android.annotation.IntRange; import android.annotation.NonNull; import android.annotation.Nullable; -import android.text.TextUtils; +import android.icu.util.ULocale; import android.util.LocaleList; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.Objects; public final class LocaleUtils { @@ -36,12 +38,120 @@ public final class LocaleUtils { Locale get(@Nullable T source); } - @Nullable - private static String getLanguage(@Nullable Locale locale) { - if (locale == null) { - return null; + /** + * Calculates a matching score for the single desired locale. + * + *

See {@link LocaleUtils#calculateMatchingScore(ULocale, LocaleList, byte[])} for + * details.

+ */ + @IntRange(from=1, to=3) + private static byte calculateMatchingSubScore(@NonNull final ULocale supported, + @NonNull final ULocale desired) { + + // Assuming supported/desired is fully expanded. + if (supported.equals(desired)) { + return 3; // Exact match. + } + + // Skip language matching since it was already done in calculateMatchingScore. + + final String supportedScript = supported.getScript(); + if (supportedScript.isEmpty() || !supportedScript.equals(desired.getScript())) { + // TODO: Need subscript matching. For example, Hanb should match with Bopo. + return 1; + } + + final String supportedCountry = supported.getCountry(); + if (supportedCountry.isEmpty() || !supportedCountry.equals(desired.getCountry())) { + return 2; + } + + // Ignore others e.g. variants, extensions. + return 3; + } + + /** + * Calculates a matching score for the desired locale list. + * + *

The supported locale gets a matching score of 3 if all language, script and country of the + * supported locale matches with the desired locale. The supported locale gets a matching + * score of 2 if the language and script of the supported locale matches with the desired + * locale. The supported locale gets a matching score of 1 if only language of the supported + * locale matches with the desired locale. The supported locale gets a matching score of 0 if + * the language of the supported locale doesn't match with the desired locale.

+ * + *

This function returns {@code false} if supported locale doesn't match with any desired + * locale list. Otherwise, this function returns {@code true}.

+ */ + private static boolean calculateMatchingScore(@NonNull final ULocale supported, + @NonNull final LocaleList desired, @NonNull byte[] out) { + if (desired.isEmpty()) { + return false; + } + + boolean allZeros = true; + for (int i = 0; i < desired.size(); ++i) { + final Locale loc = desired.get(i); + + if (!loc.getLanguage().equals(supported.getLanguage())) { + // TODO: cache the result of addLikelySubtags if it is slow. + out[i] = 0; + } else { + out[i] = calculateMatchingSubScore( + supported, ULocale.addLikelySubtags(ULocale.forLocale(loc))); + if (allZeros && out[i] != 0) { + allZeros = false; + } + } + } + return !allZeros; + } + + private static final class ScoreEntry implements Comparable { + public int index = -1; + @NonNull public byte[] score; // matching score of the i-th system languages. + + ScoreEntry(int capacity) { + score = new byte[capacity]; + } + + /** + * Update score and index if the given score is better than this. + */ + public void updateIfBetter(@NonNull byte[] newScore, int newIndex) { + if (isBetterThan(score) != 1) { + return; + } + + for (int i = 0; i < score.length; ++i) { + score[i] = newScore[i]; + } + index = newIndex; + } + + /** + * Determines given score is better than current. + * + *

Compares the matching score for the first priority locale. If the given score has + * higher score than current score, returns 1. If the current score has higher score than + * given score, returns -1. Otherwise, do the same comparison for the next priority locale. + * If given score and current score is same for the all system locale, returns 0.

+ */ + private int isBetterThan(@NonNull byte[] other) { + for (int i = 0; i < score.length; ++i) { + if (score[i] < other[i]) { + return 1; + } else if (score[i] > other[i]) { + return -1; + } + } + return 0; + } + + @Override + public int compareTo(ScoreEntry other) { + return isBetterThan(score); } - return locale.getLanguage(); } /** @@ -52,14 +162,8 @@ public final class LocaleUtils { * {@code "en-GB", "ja", "en-AU", "fr-CA", "en-IN"} is specified to {@code preferredLanguages}, * this method tries to copy at most one English locale, at most one Japanese, and at most one * French locale from {@code source} to {@code dest}. Here the best matching English locale - * will be searched from {@code source} as follows. - *
    - *
  1. The first instance in {@code sources} that exactly matches {@code "en-GB"}
  2. - *
  3. The first instance in {@code sources} that exactly matches {@code "en-AU"}
  4. - *
  5. The first instance in {@code sources} that exactly matches {@code "en-IN"}
  6. - *
  7. The first instance in {@code sources} that partially matches {@code "en"}
  8. - *
- *

Then this method iterates the same algorithm for Japanese then French.

+ * will be searched from {@code source} based on matching score. For the score design, see + * {@link LocaleUtils#calculateMatchingScore(ULocale, LocaleList, byte[])}

* * @param sources Source items to be filtered. * @param extractor Type converter from the source items to {@link Locale} object. @@ -74,69 +178,31 @@ public final class LocaleUtils { @NonNull LocaleExtractor extractor, @NonNull LocaleList preferredLanguages, @NonNull ArrayList dest) { - final Locale[] availableLocales = new Locale[sources.size()]; - for (int i = 0; i < availableLocales.length; ++i) { - availableLocales[i] = extractor.get(sources.get(i)); - } - final Locale[] sortedPreferredLanguages = new Locale[preferredLanguages.size()]; - if (sortedPreferredLanguages.length > 0) { - int nextIndex = 0; - final int N = preferredLanguages.size(); - languageLoop: - for (int i = 0; i < N; ++i) { - final String language = getLanguage(preferredLanguages.get(i)); - for (int j = 0; j < nextIndex; ++j) { - if (TextUtils.equals(getLanguage(sortedPreferredLanguages[j]), language)) { - continue languageLoop; - } - } - for (int j = i; j < N; ++j) { - final Locale locale = preferredLanguages.get(j); - if (TextUtils.equals(language, getLanguage(locale))) { - sortedPreferredLanguages[nextIndex] = locale; - ++nextIndex; - } - } + final HashMap scoreboard = new HashMap<>(); + final byte[] score = new byte[preferredLanguages.size()]; + + for (int i = 0; i < sources.size(); ++i) { + final Locale loc = extractor.get(sources.get(i)); + if (loc == null || + !calculateMatchingScore(ULocale.addLikelySubtags(ULocale.forLocale(loc)), + preferredLanguages, score)) { + continue; } + + final String lang = loc.getLanguage(); + ScoreEntry bestScore = scoreboard.get(lang); + if (bestScore == null) { + bestScore = new ScoreEntry(score.length); + scoreboard.put(lang, bestScore); + } + + bestScore.updateIfBetter(score, i); } - - for (int languageIndex = 0; languageIndex < sortedPreferredLanguages.length;) { - // Finding the range. - final String language = getLanguage(sortedPreferredLanguages[languageIndex]); - int nextLanguageIndex = languageIndex; - for (; nextLanguageIndex < sortedPreferredLanguages.length; ++nextLanguageIndex) { - final Locale locale = sortedPreferredLanguages[nextLanguageIndex]; - if (!TextUtils.equals(getLanguage(locale), language)) { - break; - } - } - - // Check exact match - boolean found = false; - for (int i = languageIndex; !found && i < nextLanguageIndex; ++i) { - final Locale locale = sortedPreferredLanguages[i]; - for (int j = 0; j < availableLocales.length; ++j) { - if (!Objects.equals(locale, availableLocales[j])) { - continue; - } - dest.add(sources.get(j)); - found = true; - break; - } - } - - if (!found) { - // No exact match. Use language match. - for (int j = 0; j < availableLocales.length; ++j) { - if (!TextUtils.equals(language, getLanguage(availableLocales[j]))) { - continue; - } - dest.add(sources.get(j)); - break; - } - } - languageIndex = nextLanguageIndex; + final ScoreEntry[] result = scoreboard.values().toArray(new ScoreEntry[scoreboard.size()]); + Arrays.sort(result); + for (final ScoreEntry entry : result) { + dest.add(sources.get(entry.index)); } } -} \ No newline at end of file +} diff --git a/core/tests/coretests/src/com/android/internal/inputmethod/LocaleUtilsTest.java b/core/tests/coretests/src/com/android/internal/inputmethod/LocaleUtilsTest.java index b9c2da75f55c8..a77800181acbe 100644 --- a/core/tests/coretests/src/com/android/internal/inputmethod/LocaleUtilsTest.java +++ b/core/tests/coretests/src/com/android/internal/inputmethod/LocaleUtilsTest.java @@ -191,4 +191,165 @@ public class LocaleUtilsTest extends InstrumentationTestCase { assertEquals(availableLocales.get(1), dest.get(0)); // "en-CA" } } + + @SmallTest + public void testFilterByLanguageFallbackRules() throws Exception { + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-Latn-RS"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-BA")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-CS")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-ME")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-BA")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-CS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-ME")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-RS")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(7), dest.get(0)); // "sr-Latn-RS" + } + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-Latn-RS-x-android"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-BA")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-CS")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-ME")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-BA")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-CS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-ME")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-RS")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(7), dest.get(0)); // "sr-Latn-RS" + } + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-Latn-RS"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-BA-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-CS-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-ME-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-RS-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-BA-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-CS-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-ME-x-android")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-RS-x-android")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(7), dest.get(0)); // "sr-Latn-RS-x-android" + } + + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-Latn-RS"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl")); + availableLocales.add(Locale.forLanguageTag("sr-Latn")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(2), dest.get(0)); // "sr-Latn" + } + + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-RS"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr")); + availableLocales.add(Locale.forLanguageTag("sr-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(0), dest.get(0)); // "sr" + } + + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-Latn"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr")); + availableLocales.add(Locale.forLanguageTag("sr-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(2), dest.get(0)); // "sr-Latn" + } + + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr")); + availableLocales.add(Locale.forLanguageTag("sr-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(0), dest.get(0)); // "sr" + } + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr-Latn")); + availableLocales.add(Locale.forLanguageTag("sr-RS")); + availableLocales.add(Locale.forLanguageTag("sr")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(1), dest.get(0)); // "sr-RS" + } + + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Latn-RS")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(0), dest.get(0)); // "sr-Cyrl-RS" + } + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("sr-Latn"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("sr-Latn-RS")); + availableLocales.add(Locale.forLanguageTag("sr-Cyrl-RS")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + assertEquals(availableLocales.get(0), dest.get(0)); // "sr-Latn-RS" + } + } + + public void testFilterKnownLimitation() throws Exception { + // Following test cases are not for intentional behavior but checks for preventing the + // behavior from becoming worse. + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("ja-Hrkt"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("ja-Jpan")); + availableLocales.add(Locale.forLanguageTag("ja-Hrkt")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + // Should be ja-Jpan since it supports ja-Hrkt and listed before ja-Hrkt. + assertEquals(availableLocales.get(1), dest.get(0)); + } + { + final LocaleList preferredLocales = LocaleList.forLanguageTags("zh-Hani"); + final ArrayList availableLocales = new ArrayList<>(); + availableLocales.add(Locale.forLanguageTag("zh-Hans")); + availableLocales.add(Locale.forLanguageTag("zh-Hant")); + availableLocales.add(Locale.forLanguageTag("zh-Hanb")); + availableLocales.add(Locale.forLanguageTag("zh-Hani")); + final ArrayList dest = new ArrayList<>(); + LocaleUtils.filterByLanguage(availableLocales, sIdentityMapper, preferredLocales, dest); + assertEquals(1, dest.size()); + // Should be zh-Hans since it supports zh-Hani. Also zh-Hant, zh-Hanb supports zh-Hani. + assertEquals(availableLocales.get(3), dest.get(0)); + } + } }