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