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 
17 package androidx.core.os;
18 
19 import android.os.Build;
20 import android.os.LocaleList;
21 
22 import androidx.annotation.IntRange;
23 import androidx.annotation.RequiresApi;
24 import androidx.annotation.Size;
25 import androidx.core.text.ICUCompat;
26 
27 import org.jspecify.annotations.NonNull;
28 import org.jspecify.annotations.Nullable;
29 
30 import java.util.Locale;
31 
32 /**
33  * Helper for accessing features in {@link LocaleList}.
34  */
35 public final class LocaleListCompat {
36     private static final LocaleListCompat sEmptyLocaleList = create();
37 
38     private final LocaleListInterface mImpl;
39 
LocaleListCompat(LocaleListInterface impl)40     private LocaleListCompat(LocaleListInterface impl) {
41         mImpl = impl;
42     }
43 
44     /** @deprecated Use {@link #wrap(LocaleList)} */
45     @Deprecated
46     @RequiresApi(24)
wrap(Object localeList)47     public static LocaleListCompat wrap(Object localeList) {
48         return wrap((LocaleList) localeList);
49     }
50 
51     /**
52      * Creates a new instance of {@link LocaleListCompat} from the Locale list.
53      */
54     @RequiresApi(24)
wrap(@onNull LocaleList localeList)55     public static @NonNull LocaleListCompat wrap(@NonNull LocaleList localeList) {
56         return new LocaleListCompat(new LocaleListPlatformWrapper(localeList));
57     }
58 
59     /**
60      * Gets the underlying framework object.
61      *
62      * @return an android.os.LocaleList object if API >= 24 , or {@code null} if not.
63      */
unwrap()64     public @Nullable Object unwrap() {
65         return mImpl.getLocaleList();
66     }
67 
68     /**
69      * Creates a new instance of {@link LocaleListCompat} from the {@link Locale} array.
70      */
create(Locale @onNull .... localeList)71     public static @NonNull LocaleListCompat create(Locale @NonNull ... localeList) {
72         if (Build.VERSION.SDK_INT >= 24) {
73             return wrap(Api24Impl.createLocaleList(localeList));
74         }
75         return new LocaleListCompat(new LocaleListCompatWrapper(localeList));
76     }
77 
78     /**
79      * Retrieves the {@link Locale} at the specified index.
80      *
81      * @param index The position to retrieve.
82      * @return The {@link Locale} in the given index
83      */
get(int index)84     public @Nullable Locale get(int index) {
85         return mImpl.get(index);
86     }
87 
88     /**
89      * Returns whether the {@link LocaleListCompat} contains no {@link Locale} items.
90      *
91      * @return {@code true} if this {@link LocaleListCompat} has no {@link Locale} items,
92      *         {@code false} otherwise
93      */
isEmpty()94     public boolean isEmpty() {
95         return mImpl.isEmpty();
96     }
97 
98     /**
99      * Returns the number of {@link Locale} items in this {@link LocaleListCompat}.
100      */
101     @IntRange(from = 0)
size()102     public int size() {
103         return mImpl.size();
104     }
105 
106     /**
107      * Searches this {@link LocaleListCompat} for the specified {@link Locale} and returns the
108      * index of the first occurrence.
109      *
110      * @param locale The {@link Locale} to search for.
111      * @return The index of the first occurrence of the {@link Locale} or {@code -1} if the item
112      *         wasn't found
113      */
114     @IntRange(from = -1)
indexOf(@ullable Locale locale)115     public int indexOf(@Nullable Locale locale) {
116         return mImpl.indexOf(locale);
117     }
118 
119     /**
120      * Retrieves a String representation of the language tags in this list.
121      */
toLanguageTags()122     public @NonNull String toLanguageTags() {
123         return mImpl.toLanguageTags();
124     }
125 
126     /**
127      * Returns the first match in the locale list given an unordered array of supported locales
128      * in BCP 47 format.
129      *
130      * @return The first {@link Locale} from this list that appears in the given array, or
131      *         {@code null} if the {@link LocaleListCompat} is empty.
132      */
getFirstMatch(String @onNull [] supportedLocales)133     public @Nullable Locale getFirstMatch(String @NonNull [] supportedLocales) {
134         return mImpl.getFirstMatch(supportedLocales);
135     }
136 
137     /**
138      * Retrieve an empty instance of {@link LocaleListCompat}.
139      */
getEmptyLocaleList()140     public static @NonNull LocaleListCompat getEmptyLocaleList() {
141         return sEmptyLocaleList;
142     }
143 
144     /**
145      * Generates a new LocaleList with the given language tags.
146      *
147      * <p>Note that for API < 24 only the first language tag will be used.</>
148      *
149      * @param list The language tags to be included as a single {@link String} separated by commas.
150      * @return A new instance with the {@link Locale} items identified by the given tags.
151      */
forLanguageTags(@ullable String list)152     public static @NonNull LocaleListCompat forLanguageTags(@Nullable String list) {
153         if (list == null || list.isEmpty()) {
154             return getEmptyLocaleList();
155         } else {
156             final String[] tags = list.split(",", -1);
157             final Locale[] localeArray = new Locale[tags.length];
158             for (int i = 0; i < localeArray.length; i++) {
159                 localeArray[i] = Build.VERSION.SDK_INT >= 21
160                         ? Api21Impl.forLanguageTag(tags[i])
161                         : forLanguageTagCompat(tags[i]);
162             }
163             return create(localeArray);
164         }
165     }
166 
167     // Simpleton implementation for Locale.forLanguageTag(...)
forLanguageTagCompat(String str)168     static Locale forLanguageTagCompat(String str) {
169         if (str.contains("-")) {
170             String[] args = str.split("-", -1);
171             if (args.length > 2) {
172                 return new Locale(args[0], args[1], args[2]);
173             } else if (args.length > 1) {
174                 return new Locale(args[0], args[1]);
175             } else if (args.length == 1) {
176                 return new Locale(args[0]);
177             }
178         } else if (str.contains("_")) {
179             String[] args = str.split("_", -1);
180             if (args.length > 2) {
181                 return new Locale(args[0], args[1], args[2]);
182             } else if (args.length > 1) {
183                 return new Locale(args[0], args[1]);
184             } else if (args.length == 1) {
185                 return new Locale(args[0]);
186             }
187         } else {
188             return new Locale(str);
189         }
190 
191         throw new IllegalArgumentException("Can not parse language tag: [" + str + "]");
192     }
193 
194     /**
195      * Returns the default locale list, adjusted by moving the default locale to its first
196      * position.
197      */
198     @Size(min = 1)
getAdjustedDefault()199     public static @NonNull LocaleListCompat getAdjustedDefault() {
200         if (Build.VERSION.SDK_INT >= 24) {
201             return LocaleListCompat.wrap(Api24Impl.getAdjustedDefault());
202         } else {
203             return LocaleListCompat.create(Locale.getDefault());
204         }
205     }
206 
207     /**
208      * The result is guaranteed to include the default Locale returned by Locale.getDefault(), but
209      * not necessarily at the top of the list. The default locale not being at the top of the list
210      * is an indication that the system has set the default locale to one of the user's other
211      * preferred locales, having concluded that the primary preference is not supported but a
212      * secondary preference is.
213      *
214      * <p>Note that for API &gt;= 24 the default LocaleList would change if Locale.setDefault() is
215      * called. This method takes that into account by always checking the output of
216      * Locale.getDefault() and recalculating the default LocaleList if needed.</p>
217      */
218     @Size(min = 1)
getDefault()219     public static @NonNull LocaleListCompat getDefault() {
220         if (Build.VERSION.SDK_INT >= 24) {
221             return LocaleListCompat.wrap(Api24Impl.getDefault());
222         } else {
223             return LocaleListCompat.create(Locale.getDefault());
224         }
225     }
226 
227     /**
228      * Determine whether two locales are considered a match, even if they are not exactly equal.
229      * They are considered as a match when both of their languages and scripts
230      * (explicit or inferred) are identical. This means that a user would be able to understand
231      * the content written in the supported locale even if they say they prefer the desired locale.
232      *
233      * E.g. [zh-HK] matches [zh-Hant]; [en-US] matches [en-CA].
234      *
235      * @param supported The supported {@link Locale} to be compared.
236      * @param desired   The desired {@link Locale} to be compared.
237      * @return True if they match, false otherwise.
238      */
239     @RequiresApi(21)
matchesLanguageAndScript(@onNull Locale supported, @NonNull Locale desired)240     public static boolean matchesLanguageAndScript(@NonNull Locale supported,
241             @NonNull Locale desired) {
242         if (Build.VERSION.SDK_INT >= 33) {
243             return LocaleList.matchesLanguageAndScript(supported, desired);
244         } else if (Build.VERSION.SDK_INT >= 21) {
245             return Api21Impl.matchesLanguageAndScript(supported, desired);
246         } else {
247             throw new UnsupportedOperationException(
248                     "This method is only supported on API level 21+");
249         }
250     }
251 
252     @RequiresApi(21)
253     static class Api21Impl {
Api21Impl()254         private Api21Impl() {
255             // This class is not instantiable.
256         }
257 
matchesLanguageAndScript(@onNull Locale supported, @NonNull Locale desired)258         static boolean matchesLanguageAndScript(@NonNull Locale supported,
259                 @NonNull Locale desired) {
260             if (supported.equals(desired)) {
261                 return true;  // return early so we don't do unnecessary computation
262             }
263             if (!supported.getLanguage().equals(desired.getLanguage())) {
264                 return false;
265             }
266             if (isPseudoLocale(supported) || isPseudoLocale(desired)) {
267                 // The locales are not the same, but the languages are the same, and one of the
268                 // locales
269                 // is a pseudo-locale. So this is not a match.
270                 return false;
271             }
272             final String supportedScr = ICUCompat.maximizeAndGetScript(supported);
273             if (supportedScr.isEmpty()) {
274                 // If we can't guess a script, we don't know enough about the locales' language
275                 // to find
276                 // if the locales match. So we fall back to old behavior of matching, which
277                 // considered
278                 // locales with different regions different.
279                 final String supportedRegion = supported.getCountry();
280                 return supportedRegion.isEmpty() || supportedRegion.equals(desired.getCountry());
281             }
282             final String desiredScr = ICUCompat.maximizeAndGetScript(desired);
283             // There is no match if the two locales use different scripts. This will most imporantly
284             // take care of traditional vs simplified Chinese.
285             return supportedScr.equals(desiredScr);
286         }
287 
288         private static final Locale[] PSEUDO_LOCALE = {
289                 new Locale("en", "XA"), new Locale("ar", "XB")};
290 
isPseudoLocale(Locale locale)291         private static boolean isPseudoLocale(Locale locale) {
292             for (Locale pseudoLocale : PSEUDO_LOCALE) {
293                 if (pseudoLocale.equals(locale)) {
294                     return true;
295                 }
296             }
297             return false;
298         }
299 
forLanguageTag(String languageTag)300         static Locale forLanguageTag(String languageTag) {
301             return Locale.forLanguageTag(languageTag);
302         }
303     }
304 
305     @Override
equals(Object other)306     public boolean equals(Object other) {
307         return other instanceof LocaleListCompat && mImpl.equals(((LocaleListCompat) other).mImpl);
308     }
309 
310     @Override
hashCode()311     public int hashCode() {
312         return mImpl.hashCode();
313     }
314 
315     @Override
toString()316     public @NonNull String toString() {
317         return mImpl.toString();
318     }
319 
320     @RequiresApi(24)
321     static class Api24Impl {
Api24Impl()322         private Api24Impl() {
323             // This class is not instantiable.
324         }
325 
createLocaleList(Locale... list)326         static LocaleList createLocaleList(Locale... list) {
327             return new LocaleList(list);
328         }
329 
getAdjustedDefault()330         static LocaleList getAdjustedDefault() {
331             return LocaleList.getAdjustedDefault();
332         }
333 
getDefault()334         static LocaleList getDefault() {
335             return LocaleList.getDefault();
336         }
337     }
338 }
339