1 /* 2 * Copyright (C) 2016 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 com.android.internal.app; 18 19 import android.annotation.IntRange; 20 import android.annotation.UnsupportedAppUsage; 21 import android.icu.text.ListFormatter; 22 import android.icu.util.ULocale; 23 import android.os.LocaleList; 24 import android.text.TextUtils; 25 26 import java.text.Collator; 27 import java.util.Comparator; 28 import java.util.Locale; 29 30 /** 31 * This class implements some handy methods to process with locales. 32 */ 33 public class LocaleHelper { 34 35 /** 36 * Sentence-case (first character uppercased). 37 * 38 * <p>There is no good API available for this, not even in ICU. 39 * We can revisit this if we get some ICU support later.</p> 40 * 41 * <p>There are currently several tickets requesting this feature:</p> 42 * <ul> 43 * <li>ICU needs to provide an easy way to titlecase only one first letter 44 * http://bugs.icu-project.org/trac/ticket/11729</li> 45 * <li>Add "initial case" 46 * http://bugs.icu-project.org/trac/ticket/8394</li> 47 * <li>Add code for initialCase, toTitlecase don't modify after Lt, 48 * avoid 49Ers, low-level language-specific casing 49 * http://bugs.icu-project.org/trac/ticket/10410</li> 50 * <li>BreakIterator.getFirstInstance: Often you need to titlecase just the first 51 * word, and leave the rest of the string alone. (closed as duplicate) 52 * http://bugs.icu-project.org/trac/ticket/8946</li> 53 * </ul> 54 * 55 * <p>A (clunky) option with the current ICU API is:</p> 56 * {{ 57 * BreakIterator breakIterator = BreakIterator.getSentenceInstance(locale); 58 * String result = UCharacter.toTitleCase(locale, 59 * source, breakIterator, UCharacter.TITLECASE_NO_LOWERCASE); 60 * }} 61 * 62 * <p>That also means creating a BreakIterator for each locale. Expensive...</p> 63 * 64 * @param str the string to sentence-case. 65 * @param locale the locale used for the case conversion. 66 * @return the string converted to sentence-case. 67 */ toSentenceCase(String str, Locale locale)68 public static String toSentenceCase(String str, Locale locale) { 69 if (str.isEmpty()) { 70 return str; 71 } 72 final int firstCodePointLen = str.offsetByCodePoints(0, 1); 73 return str.substring(0, firstCodePointLen).toUpperCase(locale) 74 + str.substring(firstCodePointLen); 75 } 76 77 /** 78 * Normalizes a string for locale name search. Does case conversion for now, 79 * but might do more in the future. 80 * 81 * <p>Warning: it is only intended to be used in searches by the locale picker. 82 * Don't use it for other things, it is very limited.</p> 83 * 84 * @param str the string to normalize 85 * @param locale the locale that might be used for certain operations (i.e. case conversion) 86 * @return the string normalized for search 87 */ 88 @UnsupportedAppUsage normalizeForSearch(String str, Locale locale)89 public static String normalizeForSearch(String str, Locale locale) { 90 // TODO: tbd if it needs to be smarter (real normalization, remove accents, etc.) 91 // If needed we might use case folding and ICU/CLDR's collation-based loose searching. 92 // TODO: decide what should the locale be, the default locale, or the locale of the string. 93 // Uppercase is better than lowercase because of things like sharp S, Greek sigma, ... 94 return str.toUpperCase(); 95 } 96 97 // For some locales we want to use a "dialect" form, for instance 98 // "Dari" instead of "Persian (Afghanistan)", or "Moldavian" instead of "Romanian (Moldova)" shouldUseDialectName(Locale locale)99 private static boolean shouldUseDialectName(Locale locale) { 100 final String lang = locale.getLanguage(); 101 return "fa".equals(lang) // Persian 102 || "ro".equals(lang) // Romanian 103 || "zh".equals(lang); // Chinese 104 } 105 106 /** 107 * Returns the locale localized for display in the provided locale. 108 * 109 * @param locale the locale whose name is to be displayed. 110 * @param displayLocale the locale in which to display the name. 111 * @param sentenceCase true if the result should be sentence-cased 112 * @return the localized name of the locale. 113 */ 114 @UnsupportedAppUsage getDisplayName(Locale locale, Locale displayLocale, boolean sentenceCase)115 public static String getDisplayName(Locale locale, Locale displayLocale, boolean sentenceCase) { 116 final ULocale displayULocale = ULocale.forLocale(displayLocale); 117 String result = shouldUseDialectName(locale) 118 ? ULocale.getDisplayNameWithDialect(locale.toLanguageTag(), displayULocale) 119 : ULocale.getDisplayName(locale.toLanguageTag(), displayULocale); 120 return sentenceCase ? toSentenceCase(result, displayLocale) : result; 121 } 122 123 /** 124 * Returns the locale localized for display in the default locale. 125 * 126 * @param locale the locale whose name is to be displayed. 127 * @param sentenceCase true if the result should be sentence-cased 128 * @return the localized name of the locale. 129 */ getDisplayName(Locale locale, boolean sentenceCase)130 public static String getDisplayName(Locale locale, boolean sentenceCase) { 131 return getDisplayName(locale, Locale.getDefault(), sentenceCase); 132 } 133 134 /** 135 * Returns a locale's country localized for display in the provided locale. 136 * 137 * @param locale the locale whose country will be displayed. 138 * @param displayLocale the locale in which to display the name. 139 * @return the localized country name. 140 */ 141 @UnsupportedAppUsage getDisplayCountry(Locale locale, Locale displayLocale)142 public static String getDisplayCountry(Locale locale, Locale displayLocale) { 143 final String languageTag = locale.toLanguageTag(); 144 final ULocale uDisplayLocale = ULocale.forLocale(displayLocale); 145 final String country = ULocale.getDisplayCountry(languageTag, uDisplayLocale); 146 final String numberingSystem = locale.getUnicodeLocaleType("nu"); 147 if (numberingSystem != null) { 148 return String.format("%s (%s)", country, 149 ULocale.getDisplayKeywordValue(languageTag, "numbers", uDisplayLocale)); 150 } else { 151 return country; 152 } 153 } 154 155 /** 156 * Returns a locale's country localized for display in the default locale. 157 * 158 * @param locale the locale whose country will be displayed. 159 * @return the localized country name. 160 */ getDisplayCountry(Locale locale)161 public static String getDisplayCountry(Locale locale) { 162 return ULocale.getDisplayCountry(locale.toLanguageTag(), ULocale.getDefault()); 163 } 164 165 /** 166 * Returns the locale list localized for display in the provided locale. 167 * 168 * @param locales the list of locales whose names is to be displayed. 169 * @param displayLocale the locale in which to display the names. 170 * If this is null, it will use the default locale. 171 * @param maxLocales maximum number of locales to display. Generates ellipsis after that. 172 * @return the locale aware list of locale names 173 */ getDisplayLocaleList( LocaleList locales, Locale displayLocale, @IntRange(from=1) int maxLocales)174 public static String getDisplayLocaleList( 175 LocaleList locales, Locale displayLocale, @IntRange(from=1) int maxLocales) { 176 177 final Locale dispLocale = displayLocale == null ? Locale.getDefault() : displayLocale; 178 179 final boolean ellipsisNeeded = locales.size() > maxLocales; 180 final int localeCount, listCount; 181 if (ellipsisNeeded) { 182 localeCount = maxLocales; 183 listCount = maxLocales + 1; // One extra slot for the ellipsis 184 } else { 185 listCount = localeCount = locales.size(); 186 } 187 final String[] localeNames = new String[listCount]; 188 for (int i = 0; i < localeCount; i++) { 189 localeNames[i] = LocaleHelper.getDisplayName(locales.get(i), dispLocale, false); 190 } 191 if (ellipsisNeeded) { 192 // Theoretically, we want to extract this from ICU's Resource Bundle for 193 // "Ellipsis/final", which seems to have different strings than the normal ellipsis for 194 // Hong Kong Traditional Chinese (zh_Hant_HK) and Dzongkha (dz). But that has two 195 // problems: it's expensive to extract it, and in case the output string becomes 196 // automatically ellipsized, it can result in weird output. 197 localeNames[maxLocales] = TextUtils.getEllipsisString(TextUtils.TruncateAt.END); 198 } 199 200 ListFormatter lfn = ListFormatter.getInstance(dispLocale); 201 return lfn.format((Object[]) localeNames); 202 } 203 204 /** 205 * Adds the likely subtags for a provided locale ID. 206 * 207 * @param locale the locale to maximize. 208 * @return the maximized Locale instance. 209 */ addLikelySubtags(Locale locale)210 public static Locale addLikelySubtags(Locale locale) { 211 return libcore.icu.ICU.addLikelySubtags(locale); 212 } 213 214 /** 215 * Locale-sensitive comparison for LocaleInfo. 216 * 217 * <p>It uses the label, leaving the decision on what to put there to the LocaleInfo. 218 * For instance fr-CA can be shown as "français" as a generic label in the language selection, 219 * or "français (Canada)" if it is a suggestion, or "Canada" in the country selection.</p> 220 * 221 * <p>Gives priority to suggested locales (to sort them at the top).</p> 222 */ 223 public static final class LocaleInfoComparator implements Comparator<LocaleStore.LocaleInfo> { 224 private final Collator mCollator; 225 private final boolean mCountryMode; 226 private static final String PREFIX_ARABIC = "\u0627\u0644"; // ALEF-LAM, ال 227 228 /** 229 * Constructor. 230 * 231 * @param sortLocale the locale to be used for sorting. 232 */ 233 @UnsupportedAppUsage LocaleInfoComparator(Locale sortLocale, boolean countryMode)234 public LocaleInfoComparator(Locale sortLocale, boolean countryMode) { 235 mCollator = Collator.getInstance(sortLocale); 236 mCountryMode = countryMode; 237 } 238 239 /* 240 * The Arabic collation should ignore Alef-Lam at the beginning (b/26277596) 241 * 242 * We look at the label's locale, not the current system locale. 243 * This is because the name of the Arabic language itself is in Arabic, 244 * and starts with Alef-Lam, no matter what the system locale is. 245 */ removePrefixForCompare(Locale locale, String str)246 private String removePrefixForCompare(Locale locale, String str) { 247 if ("ar".equals(locale.getLanguage()) && str.startsWith(PREFIX_ARABIC)) { 248 return str.substring(PREFIX_ARABIC.length()); 249 } 250 return str; 251 } 252 253 /** 254 * Compares its two arguments for order. 255 * 256 * @param lhs the first object to be compared 257 * @param rhs the second object to be compared 258 * @return a negative integer, zero, or a positive integer as the first 259 * argument is less than, equal to, or greater than the second. 260 */ 261 @UnsupportedAppUsage 262 @Override compare(LocaleStore.LocaleInfo lhs, LocaleStore.LocaleInfo rhs)263 public int compare(LocaleStore.LocaleInfo lhs, LocaleStore.LocaleInfo rhs) { 264 // We don't care about the various suggestion types, just "suggested" (!= 0) 265 // and "all others" (== 0) 266 if (lhs.isSuggested() == rhs.isSuggested()) { 267 // They are in the same "bucket" (suggested / others), so we compare the text 268 return mCollator.compare( 269 removePrefixForCompare(lhs.getLocale(), lhs.getLabel(mCountryMode)), 270 removePrefixForCompare(rhs.getLocale(), rhs.getLabel(mCountryMode))); 271 } else { 272 // One locale is suggested and one is not, so we put them in different "buckets" 273 return lhs.isSuggested() ? -1 : 1; 274 } 275 } 276 } 277 } 278