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 >= 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