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