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