1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.os;
18 
19 import android.annotation.IntRange;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.annotation.Size;
23 import android.icu.util.ULocale;
24 
25 import com.android.internal.annotations.GuardedBy;
26 
27 import java.util.Arrays;
28 import java.util.Collection;
29 import java.util.HashSet;
30 import java.util.Locale;
31 
32 /**
33  * LocaleList is an immutable list of Locales, typically used to keep an ordered list of user
34  * preferences for locales.
35  */
36 public final class LocaleList implements Parcelable {
37     private final Locale[] mList;
38     // This is a comma-separated list of the locales in the LocaleList created at construction time,
39     // basically the result of running each locale's toLanguageTag() method and concatenating them
40     // with commas in between.
41     @NonNull
42     private final String mStringRepresentation;
43 
44     private static final Locale[] sEmptyList = new Locale[0];
45     private static final LocaleList sEmptyLocaleList = new LocaleList();
46 
47     /**
48      * Retrieves the {@link Locale} at the specified index.
49      *
50      * @param index The position to retrieve.
51      * @return The {@link Locale} in the given index.
52      */
get(int index)53     public Locale get(int index) {
54         return (0 <= index && index < mList.length) ? mList[index] : null;
55     }
56 
57     /**
58      * Returns whether the {@link LocaleList} contains no {@link Locale} items.
59      *
60      * @return {@code true} if this {@link LocaleList} has no {@link Locale} items, {@code false}
61      *     otherwise.
62      */
isEmpty()63     public boolean isEmpty() {
64         return mList.length == 0;
65     }
66 
67     /**
68      * Returns the number of {@link Locale} items in this {@link LocaleList}.
69      */
70     @IntRange(from=0)
size()71     public int size() {
72         return mList.length;
73     }
74 
75     /**
76      * Searches this {@link LocaleList} for the specified {@link Locale} and returns the index of
77      * the first occurrence.
78      *
79      * @param locale The {@link Locale} to search for.
80      * @return The index of the first occurrence of the {@link Locale} or {@code -1} if the item
81      *     wasn't found.
82      */
83     @IntRange(from=-1)
indexOf(Locale locale)84     public int indexOf(Locale locale) {
85         for (int i = 0; i < mList.length; i++) {
86             if (mList[i].equals(locale)) {
87                 return i;
88             }
89         }
90         return -1;
91     }
92 
93     @Override
equals(Object other)94     public boolean equals(Object other) {
95         if (other == this)
96             return true;
97         if (!(other instanceof LocaleList))
98             return false;
99         final Locale[] otherList = ((LocaleList) other).mList;
100         if (mList.length != otherList.length)
101             return false;
102         for (int i = 0; i < mList.length; i++) {
103             if (!mList[i].equals(otherList[i]))
104                 return false;
105         }
106         return true;
107     }
108 
109     @Override
hashCode()110     public int hashCode() {
111         int result = 1;
112         for (int i = 0; i < mList.length; i++) {
113             result = 31 * result + mList[i].hashCode();
114         }
115         return result;
116     }
117 
118     @Override
toString()119     public String toString() {
120         StringBuilder sb = new StringBuilder();
121         sb.append("[");
122         for (int i = 0; i < mList.length; i++) {
123             sb.append(mList[i]);
124             if (i < mList.length - 1) {
125                 sb.append(',');
126             }
127         }
128         sb.append("]");
129         return sb.toString();
130     }
131 
132     @Override
describeContents()133     public int describeContents() {
134         return 0;
135     }
136 
137     @Override
writeToParcel(Parcel dest, int parcelableFlags)138     public void writeToParcel(Parcel dest, int parcelableFlags) {
139         dest.writeString(mStringRepresentation);
140     }
141 
142     /**
143      * Retrieves a String representation of the language tags in this list.
144      */
145     @NonNull
toLanguageTags()146     public String toLanguageTags() {
147         return mStringRepresentation;
148     }
149 
150     /**
151      * Creates a new {@link LocaleList}.
152      *
153      * <p>For empty lists of {@link Locale} items it is better to use {@link #getEmptyLocaleList()},
154      * which returns a pre-constructed empty list.</p>
155      *
156      * @throws NullPointerException if any of the input locales is <code>null</code>.
157      * @throws IllegalArgumentException if any of the input locales repeat.
158      */
LocaleList(@onNull Locale... list)159     public LocaleList(@NonNull Locale... list) {
160         if (list.length == 0) {
161             mList = sEmptyList;
162             mStringRepresentation = "";
163         } else {
164             final Locale[] localeList = new Locale[list.length];
165             final HashSet<Locale> seenLocales = new HashSet<Locale>();
166             final StringBuilder sb = new StringBuilder();
167             for (int i = 0; i < list.length; i++) {
168                 final Locale l = list[i];
169                 if (l == null) {
170                     throw new NullPointerException("list[" + i + "] is null");
171                 } else if (seenLocales.contains(l)) {
172                     throw new IllegalArgumentException("list[" + i + "] is a repetition");
173                 } else {
174                     final Locale localeClone = (Locale) l.clone();
175                     localeList[i] = localeClone;
176                     sb.append(localeClone.toLanguageTag());
177                     if (i < list.length - 1) {
178                         sb.append(',');
179                     }
180                     seenLocales.add(localeClone);
181                 }
182             }
183             mList = localeList;
184             mStringRepresentation = sb.toString();
185         }
186     }
187 
188     /**
189      * Constructs a locale list, with the topLocale moved to the front if it already is
190      * in otherLocales, or added to the front if it isn't.
191      *
192      * {@hide}
193      */
LocaleList(@onNull Locale topLocale, LocaleList otherLocales)194     public LocaleList(@NonNull Locale topLocale, LocaleList otherLocales) {
195         if (topLocale == null) {
196             throw new NullPointerException("topLocale is null");
197         }
198 
199         final int inputLength = (otherLocales == null) ? 0 : otherLocales.mList.length;
200         int topLocaleIndex = -1;
201         for (int i = 0; i < inputLength; i++) {
202             if (topLocale.equals(otherLocales.mList[i])) {
203                 topLocaleIndex = i;
204                 break;
205             }
206         }
207 
208         final int outputLength = inputLength + (topLocaleIndex == -1 ? 1 : 0);
209         final Locale[] localeList = new Locale[outputLength];
210         localeList[0] = (Locale) topLocale.clone();
211         if (topLocaleIndex == -1) {
212             // topLocale was not in otherLocales
213             for (int i = 0; i < inputLength; i++) {
214                 localeList[i + 1] = (Locale) otherLocales.mList[i].clone();
215             }
216         } else {
217             for (int i = 0; i < topLocaleIndex; i++) {
218                 localeList[i + 1] = (Locale) otherLocales.mList[i].clone();
219             }
220             for (int i = topLocaleIndex + 1; i < inputLength; i++) {
221                 localeList[i] = (Locale) otherLocales.mList[i].clone();
222             }
223         }
224 
225         final StringBuilder sb = new StringBuilder();
226         for (int i = 0; i < outputLength; i++) {
227             sb.append(localeList[i].toLanguageTag());
228             if (i < outputLength - 1) {
229                 sb.append(',');
230             }
231         }
232 
233         mList = localeList;
234         mStringRepresentation = sb.toString();
235     }
236 
237     public static final Parcelable.Creator<LocaleList> CREATOR
238             = new Parcelable.Creator<LocaleList>() {
239         @Override
240         public LocaleList createFromParcel(Parcel source) {
241             return LocaleList.forLanguageTags(source.readString());
242         }
243 
244         @Override
245         public LocaleList[] newArray(int size) {
246             return new LocaleList[size];
247         }
248     };
249 
250     /**
251      * Retrieve an empty instance of {@link LocaleList}.
252      */
253     @NonNull
getEmptyLocaleList()254     public static LocaleList getEmptyLocaleList() {
255         return sEmptyLocaleList;
256     }
257 
258     /**
259      * Generates a new LocaleList with the given language tags.
260      *
261      * @param list The language tags to be included as a single {@link String} separated by commas.
262      * @return A new instance with the {@link Locale} items identified by the given tags.
263      */
264     @NonNull
forLanguageTags(@ullable String list)265     public static LocaleList forLanguageTags(@Nullable String list) {
266         if (list == null || list.equals("")) {
267             return getEmptyLocaleList();
268         } else {
269             final String[] tags = list.split(",");
270             final Locale[] localeArray = new Locale[tags.length];
271             for (int i = 0; i < localeArray.length; i++) {
272                 localeArray[i] = Locale.forLanguageTag(tags[i]);
273             }
274             return new LocaleList(localeArray);
275         }
276     }
277 
getLikelyScript(Locale locale)278     private static String getLikelyScript(Locale locale) {
279         final String script = locale.getScript();
280         if (!script.isEmpty()) {
281             return script;
282         } else {
283             // TODO: Cache the results if this proves to be too slow
284             return ULocale.addLikelySubtags(ULocale.forLocale(locale)).getScript();
285         }
286     }
287 
288     private static final String STRING_EN_XA = "en-XA";
289     private static final String STRING_AR_XB = "ar-XB";
290     private static final Locale LOCALE_EN_XA = new Locale("en", "XA");
291     private static final Locale LOCALE_AR_XB = new Locale("ar", "XB");
292     private static final int NUM_PSEUDO_LOCALES = 2;
293 
isPseudoLocale(String locale)294     private static boolean isPseudoLocale(String locale) {
295         return STRING_EN_XA.equals(locale) || STRING_AR_XB.equals(locale);
296     }
297 
isPseudoLocale(Locale locale)298     private static boolean isPseudoLocale(Locale locale) {
299         return LOCALE_EN_XA.equals(locale) || LOCALE_AR_XB.equals(locale);
300     }
301 
302     @IntRange(from=0, to=1)
matchScore(Locale supported, Locale desired)303     private static int matchScore(Locale supported, Locale desired) {
304         if (supported.equals(desired)) {
305             return 1;  // return early so we don't do unnecessary computation
306         }
307         if (!supported.getLanguage().equals(desired.getLanguage())) {
308             return 0;
309         }
310         if (isPseudoLocale(supported) || isPseudoLocale(desired)) {
311             // The locales are not the same, but the languages are the same, and one of the locales
312             // is a pseudo-locale. So this is not a match.
313             return 0;
314         }
315         final String supportedScr = getLikelyScript(supported);
316         if (supportedScr.isEmpty()) {
317             // If we can't guess a script, we don't know enough about the locales' language to find
318             // if the locales match. So we fall back to old behavior of matching, which considered
319             // locales with different regions different.
320             final String supportedRegion = supported.getCountry();
321             return (supportedRegion.isEmpty() ||
322                     supportedRegion.equals(desired.getCountry()))
323                     ? 1 : 0;
324         }
325         final String desiredScr = getLikelyScript(desired);
326         // There is no match if the two locales use different scripts. This will most imporantly
327         // take care of traditional vs simplified Chinese.
328         return supportedScr.equals(desiredScr) ? 1 : 0;
329     }
330 
findFirstMatchIndex(Locale supportedLocale)331     private int findFirstMatchIndex(Locale supportedLocale) {
332         for (int idx = 0; idx < mList.length; idx++) {
333             final int score = matchScore(supportedLocale, mList[idx]);
334             if (score > 0) {
335                 return idx;
336             }
337         }
338         return Integer.MAX_VALUE;
339     }
340 
341     private static final Locale EN_LATN = Locale.forLanguageTag("en-Latn");
342 
computeFirstMatchIndex(Collection<String> supportedLocales, boolean assumeEnglishIsSupported)343     private int computeFirstMatchIndex(Collection<String> supportedLocales,
344             boolean assumeEnglishIsSupported) {
345         if (mList.length == 1) {  // just one locale, perhaps the most common scenario
346             return 0;
347         }
348         if (mList.length == 0) {  // empty locale list
349             return -1;
350         }
351 
352         int bestIndex = Integer.MAX_VALUE;
353         // Try English first, so we can return early if it's in the LocaleList
354         if (assumeEnglishIsSupported) {
355             final int idx = findFirstMatchIndex(EN_LATN);
356             if (idx == 0) { // We have a match on the first locale, which is good enough
357                 return 0;
358             } else if (idx < bestIndex) {
359                 bestIndex = idx;
360             }
361         }
362         for (String languageTag : supportedLocales) {
363             final Locale supportedLocale = Locale.forLanguageTag(languageTag);
364             // We expect the average length of locale lists used for locale resolution to be
365             // smaller than three, so it's OK to do this as an O(mn) algorithm.
366             final int idx = findFirstMatchIndex(supportedLocale);
367             if (idx == 0) { // We have a match on the first locale, which is good enough
368                 return 0;
369             } else if (idx < bestIndex) {
370                 bestIndex = idx;
371             }
372         }
373         if (bestIndex == Integer.MAX_VALUE) {
374             // no match was found, so we fall back to the first locale in the locale list
375             return 0;
376         } else {
377             return bestIndex;
378         }
379     }
380 
computeFirstMatch(Collection<String> supportedLocales, boolean assumeEnglishIsSupported)381     private Locale computeFirstMatch(Collection<String> supportedLocales,
382             boolean assumeEnglishIsSupported) {
383         int bestIndex = computeFirstMatchIndex(supportedLocales, assumeEnglishIsSupported);
384         return bestIndex == -1 ? null : mList[bestIndex];
385     }
386 
387     /**
388      * Returns the first match in the locale list given an unordered array of supported locales
389      * in BCP 47 format.
390      *
391      * @return The first {@link Locale} from this list that appears in the given array, or
392      *     {@code null} if the {@link LocaleList} is empty.
393      */
394     @Nullable
getFirstMatch(String[] supportedLocales)395     public Locale getFirstMatch(String[] supportedLocales) {
396         return computeFirstMatch(Arrays.asList(supportedLocales),
397                 false /* assume English is not supported */);
398     }
399 
400     /**
401      * {@hide}
402      */
getFirstMatchIndex(String[] supportedLocales)403     public int getFirstMatchIndex(String[] supportedLocales) {
404         return computeFirstMatchIndex(Arrays.asList(supportedLocales),
405                 false /* assume English is not supported */);
406     }
407 
408     /**
409      * Same as getFirstMatch(), but with English assumed to be supported, even if it's not.
410      * {@hide}
411      */
412     @Nullable
getFirstMatchWithEnglishSupported(String[] supportedLocales)413     public Locale getFirstMatchWithEnglishSupported(String[] supportedLocales) {
414         return computeFirstMatch(Arrays.asList(supportedLocales),
415                 true /* assume English is supported */);
416     }
417 
418     /**
419      * {@hide}
420      */
getFirstMatchIndexWithEnglishSupported(Collection<String> supportedLocales)421     public int getFirstMatchIndexWithEnglishSupported(Collection<String> supportedLocales) {
422         return computeFirstMatchIndex(supportedLocales, true /* assume English is supported */);
423     }
424 
425     /**
426      * {@hide}
427      */
getFirstMatchIndexWithEnglishSupported(String[] supportedLocales)428     public int getFirstMatchIndexWithEnglishSupported(String[] supportedLocales) {
429         return getFirstMatchIndexWithEnglishSupported(Arrays.asList(supportedLocales));
430     }
431 
432     /**
433      * Returns true if the collection of locale tags only contains empty locales and pseudolocales.
434      * Assumes that there is no repetition in the input.
435      * {@hide}
436      */
isPseudoLocalesOnly(@ullable String[] supportedLocales)437     public static boolean isPseudoLocalesOnly(@Nullable String[] supportedLocales) {
438         if (supportedLocales == null) {
439             return true;
440         }
441 
442         if (supportedLocales.length > NUM_PSEUDO_LOCALES + 1) {
443             // This is for optimization. Since there's no repetition in the input, if we have more
444             // than the number of pseudo-locales plus one for the empty string, it's guaranteed
445             // that we have some meaninful locale in the collection, so the list is not "practically
446             // empty".
447             return false;
448         }
449         for (String locale : supportedLocales) {
450             if (!locale.isEmpty() && !isPseudoLocale(locale)) {
451                 return false;
452             }
453         }
454         return true;
455     }
456 
457     private final static Object sLock = new Object();
458 
459     @GuardedBy("sLock")
460     private static LocaleList sLastExplicitlySetLocaleList = null;
461     @GuardedBy("sLock")
462     private static LocaleList sDefaultLocaleList = null;
463     @GuardedBy("sLock")
464     private static LocaleList sDefaultAdjustedLocaleList = null;
465     @GuardedBy("sLock")
466     private static Locale sLastDefaultLocale = null;
467 
468     /**
469      * The result is guaranteed to include the default Locale returned by Locale.getDefault(), but
470      * not necessarily at the top of the list. The default locale not being at the top of the list
471      * is an indication that the system has set the default locale to one of the user's other
472      * preferred locales, having concluded that the primary preference is not supported but a
473      * secondary preference is.
474      *
475      * <p>Note that the default LocaleList would change if Locale.setDefault() is called. This
476      * method takes that into account by always checking the output of Locale.getDefault() and
477      * recalculating the default LocaleList if needed.</p>
478      */
479     @NonNull @Size(min=1)
getDefault()480     public static LocaleList getDefault() {
481         final Locale defaultLocale = Locale.getDefault();
482         synchronized (sLock) {
483             if (!defaultLocale.equals(sLastDefaultLocale)) {
484                 sLastDefaultLocale = defaultLocale;
485                 // It's either the first time someone has asked for the default locale list, or
486                 // someone has called Locale.setDefault() since we last set or adjusted the default
487                 // locale list. So let's recalculate the locale list.
488                 if (sDefaultLocaleList != null
489                         && defaultLocale.equals(sDefaultLocaleList.get(0))) {
490                     // The default Locale has changed, but it happens to be the first locale in the
491                     // default locale list, so we don't need to construct a new locale list.
492                     return sDefaultLocaleList;
493                 }
494                 sDefaultLocaleList = new LocaleList(defaultLocale, sLastExplicitlySetLocaleList);
495                 sDefaultAdjustedLocaleList = sDefaultLocaleList;
496             }
497             // sDefaultLocaleList can't be null, since it can't be set to null by
498             // LocaleList.setDefault(), and if getDefault() is called before a call to
499             // setDefault(), sLastDefaultLocale would be null and the check above would set
500             // sDefaultLocaleList.
501             return sDefaultLocaleList;
502         }
503     }
504 
505     /**
506      * Returns the default locale list, adjusted by moving the default locale to its first
507      * position.
508      */
509     @NonNull @Size(min=1)
getAdjustedDefault()510     public static LocaleList getAdjustedDefault() {
511         getDefault(); // to recalculate the default locale list, if necessary
512         synchronized (sLock) {
513             return sDefaultAdjustedLocaleList;
514         }
515     }
516 
517     /**
518      * Also sets the default locale by calling Locale.setDefault() with the first locale in the
519      * list.
520      *
521      * @throws NullPointerException if the input is <code>null</code>.
522      * @throws IllegalArgumentException if the input is empty.
523      */
setDefault(@onNull @izemin=1) LocaleList locales)524     public static void setDefault(@NonNull @Size(min=1) LocaleList locales) {
525         setDefault(locales, 0);
526     }
527 
528     /**
529      * This may be used directly by system processes to set the default locale list for apps. For
530      * such uses, the default locale list would always come from the user preferences, but the
531      * default locale may have been chosen to be a locale other than the first locale in the locale
532      * list (based on the locales the app supports).
533      *
534      * {@hide}
535      */
setDefault(@onNull @izemin=1) LocaleList locales, int localeIndex)536     public static void setDefault(@NonNull @Size(min=1) LocaleList locales, int localeIndex) {
537         if (locales == null) {
538             throw new NullPointerException("locales is null");
539         }
540         if (locales.isEmpty()) {
541             throw new IllegalArgumentException("locales is empty");
542         }
543         synchronized (sLock) {
544             sLastDefaultLocale = locales.get(localeIndex);
545             Locale.setDefault(sLastDefaultLocale);
546             sLastExplicitlySetLocaleList = locales;
547             sDefaultLocaleList = locales;
548             if (localeIndex == 0) {
549                 sDefaultAdjustedLocaleList = sDefaultLocaleList;
550             } else {
551                 sDefaultAdjustedLocaleList = new LocaleList(
552                         sLastDefaultLocale, sDefaultLocaleList);
553             }
554         }
555     }
556 }
557