1 /* 2 * Copyright (C) 2015 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.settingslib.datetime; 18 19 import android.content.Context; 20 import android.content.res.XmlResourceParser; 21 import android.icu.text.TimeZoneFormat; 22 import android.icu.text.TimeZoneNames; 23 import android.text.SpannableString; 24 import android.text.SpannableStringBuilder; 25 import android.text.TextUtils; 26 import android.text.format.DateUtils; 27 import android.text.style.TtsSpan; 28 import android.util.Log; 29 import android.view.View; 30 31 import androidx.annotation.Nullable; 32 import androidx.annotation.VisibleForTesting; 33 import androidx.core.text.BidiFormatter; 34 import androidx.core.text.TextDirectionHeuristicsCompat; 35 36 import com.android.i18n.timezone.CountryTimeZones; 37 import com.android.i18n.timezone.CountryTimeZones.TimeZoneMapping; 38 import com.android.i18n.timezone.TimeZoneFinder; 39 import com.android.internal.app.LocaleHelper; 40 import com.android.settingslib.R; 41 42 import org.xmlpull.v1.XmlPullParserException; 43 44 import java.util.ArrayList; 45 import java.util.Collections; 46 import java.util.Date; 47 import java.util.HashMap; 48 import java.util.HashSet; 49 import java.util.List; 50 import java.util.Locale; 51 import java.util.Map; 52 import java.util.Set; 53 import java.util.TimeZone; 54 55 /** 56 * ZoneGetter is the utility class to get time zone and zone list, and both of them have display 57 * name in time zone. In this class, we will keep consistency about display names for all 58 * the methods. 59 * 60 * The display name chosen for each zone entry depends on whether the zone is one associated 61 * with the country of the user's chosen locale. For "local" zones we prefer the "long name" 62 * (e.g. "Europe/London" -> "British Summer Time" for people in the UK). For "non-local" 63 * zones we prefer the exemplar location (e.g. "Europe/London" -> "London" for English 64 * speakers from outside the UK). This heuristic is based on the fact that people are 65 * typically familiar with their local timezones and exemplar locations don't always match 66 * modern-day expectations for people living in the country covered. Large countries like 67 * China that mostly use a single timezone (olson id: "Asia/Shanghai") may not live near 68 * "Shanghai" and prefer the long name over the exemplar location. The only time we don't 69 * follow this policy for local zones is when Android supplies multiple olson IDs to choose 70 * from and the use of a zone's long name leads to ambiguity. For example, at the time of 71 * writing Android lists 5 olson ids for Australia which collapse to 2 different zone names 72 * in winter but 4 different zone names in summer. The ambiguity leads to the users 73 * selecting the wrong olson ids. 74 * 75 */ 76 public class ZoneGetter { 77 private static final String TAG = "ZoneGetter"; 78 79 public static final String KEY_ID = "id"; // value: String 80 81 /** 82 * @deprecated Use {@link #KEY_DISPLAY_LABEL} instead. 83 */ 84 @Deprecated 85 public static final String KEY_DISPLAYNAME = "name"; // value: String 86 87 public static final String KEY_DISPLAY_LABEL = "display_label"; // value: CharSequence 88 89 /** 90 * @deprecated Use {@link #KEY_OFFSET_LABEL} instead. 91 */ 92 @Deprecated 93 public static final String KEY_GMT = "gmt"; // value: String 94 public static final String KEY_OFFSET = "offset"; // value: int (Integer) 95 public static final String KEY_OFFSET_LABEL = "offset_label"; // value: CharSequence 96 97 private static final String XMLTAG_TIMEZONE = "timezone"; 98 getTimeZoneOffsetAndName(Context context, TimeZone tz, Date now)99 public static CharSequence getTimeZoneOffsetAndName(Context context, TimeZone tz, Date now) { 100 Locale locale = context.getResources().getConfiguration().locale; 101 TimeZoneFormat tzFormatter = TimeZoneFormat.getInstance(locale); 102 CharSequence gmtText = getGmtOffsetText(tzFormatter, locale, tz, now); 103 TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(locale); 104 String zoneNameString = capitalizeForStandaloneDisplay( 105 locale, getZoneLongName(locale, timeZoneNames, tz, now)); 106 if (zoneNameString == null) { 107 return gmtText; 108 } 109 110 // We don't use punctuation here to avoid having to worry about localizing that too! 111 return TextUtils.concat(gmtText, " ", zoneNameString); 112 } 113 114 /** 115 * Capitalizes {@code toCapitalize} for standalone display, i.e. in lists. This is intended for 116 * use with "display name" strings from sources like ICU/CLDR which typically capitalize strings 117 * for the inclusion in the middle of sentences. Some locales (such as Polish) do not capitalize 118 * terms like "Coordinated Universal Time" as in English but do capitalize the first letter for 119 * standalone locations like lists, and so must be explicitly capitalized. 120 * 121 * @return the capitalized string, or {@code null} if the argument is null 122 */ 123 @Nullable capitalizeForStandaloneDisplay( Locale locale, @Nullable String toCapitalize)124 public static String capitalizeForStandaloneDisplay( 125 Locale locale, @Nullable String toCapitalize) { 126 if (TextUtils.isEmpty(toCapitalize)) { 127 return toCapitalize; 128 } 129 return LocaleHelper.toSentenceCase(toCapitalize, locale); 130 } 131 getZonesList(Context context)132 public static List<Map<String, Object>> getZonesList(Context context) { 133 final Locale locale = context.getResources().getConfiguration().locale; 134 final Date now = new Date(); 135 final TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(locale); 136 final ZoneGetterData data = new ZoneGetterData(context); 137 138 // Work out whether the display names we would show by default would be ambiguous. 139 final boolean useExemplarLocationForLocalNames = 140 shouldUseExemplarLocationForLocalNames(locale, data, timeZoneNames); 141 142 // Generate the list of zone entries to return. 143 List<Map<String, Object>> zones = new ArrayList<Map<String, Object>>(); 144 for (int i = 0; i < data.zoneCount; i++) { 145 TimeZone tz = data.timeZones[i]; 146 CharSequence gmtOffsetText = data.gmtOffsetTexts[i]; 147 148 CharSequence displayName = getTimeZoneDisplayName(locale, data, timeZoneNames, 149 useExemplarLocationForLocalNames, tz, data.olsonIdsToDisplay[i]); 150 if (TextUtils.isEmpty(displayName)) { 151 displayName = gmtOffsetText; 152 } 153 154 int offsetMillis = tz.getOffset(now.getTime()); 155 Map<String, Object> displayEntry = 156 createDisplayEntry(tz, gmtOffsetText, displayName, offsetMillis); 157 zones.add(displayEntry); 158 } 159 return zones; 160 } 161 createDisplayEntry( TimeZone tz, CharSequence gmtOffsetText, CharSequence displayName, int offsetMillis)162 private static Map<String, Object> createDisplayEntry( 163 TimeZone tz, CharSequence gmtOffsetText, CharSequence displayName, int offsetMillis) { 164 Map<String, Object> map = new HashMap<>(); 165 map.put(KEY_ID, tz.getID()); 166 map.put(KEY_DISPLAYNAME, displayName.toString()); 167 map.put(KEY_DISPLAY_LABEL, displayName); 168 map.put(KEY_GMT, gmtOffsetText.toString()); 169 map.put(KEY_OFFSET_LABEL, gmtOffsetText); 170 map.put(KEY_OFFSET, offsetMillis); 171 return map; 172 } 173 readTimezonesToDisplay(Context context)174 private static List<String> readTimezonesToDisplay(Context context) { 175 List<String> olsonIds = new ArrayList<String>(); 176 try (XmlResourceParser xrp = context.getResources().getXml(R.xml.timezones)) { 177 while (xrp.next() != XmlResourceParser.START_TAG) { 178 continue; 179 } 180 xrp.next(); 181 while (xrp.getEventType() != XmlResourceParser.END_TAG) { 182 while (xrp.getEventType() != XmlResourceParser.START_TAG) { 183 if (xrp.getEventType() == XmlResourceParser.END_DOCUMENT) { 184 return olsonIds; 185 } 186 xrp.next(); 187 } 188 if (xrp.getName().equals(XMLTAG_TIMEZONE)) { 189 String olsonId = xrp.getAttributeValue(0); 190 olsonIds.add(olsonId); 191 } 192 while (xrp.getEventType() != XmlResourceParser.END_TAG) { 193 xrp.next(); 194 } 195 xrp.next(); 196 } 197 } catch (XmlPullParserException xppe) { 198 Log.e(TAG, "Ill-formatted timezones.xml file"); 199 } catch (java.io.IOException ioe) { 200 Log.e(TAG, "Unable to read timezones.xml file"); 201 } 202 return olsonIds; 203 } 204 shouldUseExemplarLocationForLocalNames(Locale locale, ZoneGetterData data, TimeZoneNames timeZoneNames)205 private static boolean shouldUseExemplarLocationForLocalNames(Locale locale, 206 ZoneGetterData data, TimeZoneNames timeZoneNames) { 207 final Set<CharSequence> localZoneNames = new HashSet<>(); 208 final Date now = new Date(); 209 for (int i = 0; i < data.zoneCount; i++) { 210 final String olsonId = data.olsonIdsToDisplay[i]; 211 if (data.localZoneIds.contains(olsonId)) { 212 final TimeZone tz = data.timeZones[i]; 213 CharSequence displayName = getZoneLongName(locale, timeZoneNames, tz, now); 214 if (displayName == null) { 215 displayName = data.gmtOffsetTexts[i]; 216 } 217 final boolean nameIsUnique = localZoneNames.add(displayName); 218 if (!nameIsUnique) { 219 return true; 220 } 221 } 222 } 223 224 return false; 225 } 226 getTimeZoneDisplayName(Locale locale, ZoneGetterData data, TimeZoneNames timeZoneNames, boolean useExemplarLocationForLocalNames, TimeZone tz, String olsonId)227 private static CharSequence getTimeZoneDisplayName(Locale locale, ZoneGetterData data, 228 TimeZoneNames timeZoneNames, boolean useExemplarLocationForLocalNames, TimeZone tz, 229 String olsonId) { 230 final Date now = new Date(); 231 final boolean isLocalZoneId = data.localZoneIds.contains(olsonId); 232 final boolean preferLongName = isLocalZoneId && !useExemplarLocationForLocalNames; 233 String displayName; 234 235 if (preferLongName) { 236 displayName = getZoneLongName(locale, timeZoneNames, tz, now); 237 } else { 238 // Canonicalize the zone ID for ICU. It will only return valid strings for zone IDs 239 // that match ICUs zone IDs (which are similar but not guaranteed the same as those 240 // in timezones.xml). timezones.xml and related files uses the IANA IDs. ICU IDs are 241 // stable and IANA IDs have changed over time so they have drifted. 242 // See http://bugs.icu-project.org/trac/ticket/13070 / http://b/36469833. 243 String canonicalZoneId = android.icu.util.TimeZone.getCanonicalID(tz.getID()); 244 if (canonicalZoneId == null) { 245 canonicalZoneId = tz.getID(); 246 } 247 displayName = capitalizeForStandaloneDisplay( 248 locale, timeZoneNames.getExemplarLocationName(canonicalZoneId)); 249 if (displayName == null || displayName.isEmpty()) { 250 // getZoneExemplarLocation can return null. Fall back to the long name. 251 displayName = getZoneLongName(locale, timeZoneNames, tz, now); 252 } 253 } 254 255 return displayName; 256 } 257 258 /** 259 * Returns the long name for the timezone for the given locale at the time specified. 260 * Can return {@code null}. 261 */ getZoneLongName( Locale locale, TimeZoneNames names, TimeZone tz, Date now)262 private static String getZoneLongName( 263 Locale locale, TimeZoneNames names, TimeZone tz, Date now) { 264 final TimeZoneNames.NameType nameType = 265 tz.inDaylightTime(now) ? TimeZoneNames.NameType.LONG_DAYLIGHT 266 : TimeZoneNames.NameType.LONG_STANDARD; 267 return capitalizeForStandaloneDisplay(locale, 268 names.getDisplayName(getCanonicalZoneId(tz), nameType, now.getTime())); 269 } 270 getCanonicalZoneId(TimeZone timeZone)271 private static String getCanonicalZoneId(TimeZone timeZone) { 272 final String id = timeZone.getID(); 273 final String canonicalId = android.icu.util.TimeZone.getCanonicalID(id); 274 if (canonicalId != null) { 275 return canonicalId; 276 } 277 return id; 278 } 279 appendWithTtsSpan(SpannableStringBuilder builder, CharSequence content, TtsSpan span)280 private static void appendWithTtsSpan(SpannableStringBuilder builder, CharSequence content, 281 TtsSpan span) { 282 int start = builder.length(); 283 builder.append(content); 284 builder.setSpan(span, start, builder.length(), 0); 285 } 286 287 // Input must be positive. minDigits must be 1 or 2. formatDigits(int input, int minDigits, String localizedDigits)288 private static String formatDigits(int input, int minDigits, String localizedDigits) { 289 final int tens = input / 10; 290 final int units = input % 10; 291 StringBuilder builder = new StringBuilder(minDigits); 292 if (input >= 10 || minDigits == 2) { 293 builder.append(localizedDigits.charAt(tens)); 294 } 295 builder.append(localizedDigits.charAt(units)); 296 return builder.toString(); 297 } 298 299 /** 300 * Get the GMT offset text label for the given time zone, in the format "GMT-08:00". This will 301 * also add TTS spans to give hints to the text-to-speech engine for the type of data it is. 302 * 303 * @param tzFormatter The timezone formatter to use. 304 * @param locale The locale which the string is displayed in. This should be the same as the 305 * locale of the time zone formatter. 306 * @param tz Time zone to get the GMT offset from. 307 * @param now The current time, used to tell whether daylight savings is active. 308 * @return A CharSequence suitable for display as the offset label of {@code tz}. 309 */ getGmtOffsetText(TimeZoneFormat tzFormatter, Locale locale, TimeZone tz, Date now)310 public static CharSequence getGmtOffsetText(TimeZoneFormat tzFormatter, Locale locale, 311 TimeZone tz, Date now) { 312 final SpannableStringBuilder builder = new SpannableStringBuilder(); 313 314 final String gmtPattern = tzFormatter.getGMTPattern(); 315 final int placeholderIndex = gmtPattern.indexOf("{0}"); 316 final String gmtPatternPrefix, gmtPatternSuffix; 317 if (placeholderIndex == -1) { 318 // Bad pattern. Replace with defaults. 319 gmtPatternPrefix = "GMT"; 320 gmtPatternSuffix = ""; 321 } else { 322 gmtPatternPrefix = gmtPattern.substring(0, placeholderIndex); 323 gmtPatternSuffix = gmtPattern.substring(placeholderIndex + 3); // After the "{0}". 324 } 325 326 if (!gmtPatternPrefix.isEmpty()) { 327 appendWithTtsSpan(builder, gmtPatternPrefix, 328 new TtsSpan.TextBuilder(gmtPatternPrefix).build()); 329 } 330 331 int offsetMillis = tz.getOffset(now.getTime()); 332 final boolean negative = offsetMillis < 0; 333 final TimeZoneFormat.GMTOffsetPatternType patternType; 334 if (negative) { 335 offsetMillis = -offsetMillis; 336 patternType = TimeZoneFormat.GMTOffsetPatternType.NEGATIVE_HM; 337 } else { 338 patternType = TimeZoneFormat.GMTOffsetPatternType.POSITIVE_HM; 339 } 340 final String gmtOffsetPattern = tzFormatter.getGMTOffsetPattern(patternType); 341 final String localizedDigits = tzFormatter.getGMTOffsetDigits(); 342 343 final int offsetHours = (int) (offsetMillis / DateUtils.HOUR_IN_MILLIS); 344 final int offsetMinutes = (int) (offsetMillis / DateUtils.MINUTE_IN_MILLIS); 345 final int offsetMinutesRemaining = Math.abs(offsetMinutes) % 60; 346 347 for (int i = 0; i < gmtOffsetPattern.length(); i++) { 348 char c = gmtOffsetPattern.charAt(i); 349 if (c == '+' || c == '-' || c == '\u2212' /* MINUS SIGN */) { 350 final String sign = String.valueOf(c); 351 appendWithTtsSpan(builder, sign, new TtsSpan.VerbatimBuilder(sign).build()); 352 } else if (c == 'H' || c == 'm') { 353 final int numDigits; 354 if (i + 1 < gmtOffsetPattern.length() && gmtOffsetPattern.charAt(i + 1) == c) { 355 numDigits = 2; 356 i++; // Skip the next formatting character. 357 } else { 358 numDigits = 1; 359 } 360 final int number; 361 final String unit; 362 if (c == 'H') { 363 number = offsetHours; 364 unit = "hour"; 365 } else { // c == 'm' 366 number = offsetMinutesRemaining; 367 unit = "minute"; 368 } 369 appendWithTtsSpan(builder, formatDigits(number, numDigits, localizedDigits), 370 new TtsSpan.MeasureBuilder().setNumber(number).setUnit(unit).build()); 371 } else { 372 builder.append(c); 373 } 374 } 375 376 if (!gmtPatternSuffix.isEmpty()) { 377 appendWithTtsSpan(builder, gmtPatternSuffix, 378 new TtsSpan.TextBuilder(gmtPatternSuffix).build()); 379 } 380 381 CharSequence gmtText = new SpannableString(builder); 382 383 // Ensure that the "GMT+" stays with the "00:00" even if the digits are RTL. 384 final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); 385 boolean isRtl = TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL; 386 gmtText = bidiFormatter.unicodeWrap(gmtText, 387 isRtl ? TextDirectionHeuristicsCompat.RTL : TextDirectionHeuristicsCompat.LTR); 388 return gmtText; 389 } 390 391 @VisibleForTesting 392 public static final class ZoneGetterData { 393 public final String[] olsonIdsToDisplay; 394 public final CharSequence[] gmtOffsetTexts; 395 public final TimeZone[] timeZones; 396 public final Set<String> localZoneIds; 397 public final int zoneCount; 398 399 public ZoneGetterData(Context context) { 400 final Locale locale = context.getResources().getConfiguration().locale; 401 final TimeZoneFormat tzFormatter = TimeZoneFormat.getInstance(locale); 402 final Date now = new Date(); 403 final List<String> olsonIdsToDisplayList = readTimezonesToDisplay(context); 404 405 // Load all the data needed to display time zones 406 zoneCount = olsonIdsToDisplayList.size(); 407 olsonIdsToDisplay = new String[zoneCount]; 408 timeZones = new TimeZone[zoneCount]; 409 gmtOffsetTexts = new CharSequence[zoneCount]; 410 for (int i = 0; i < zoneCount; i++) { 411 final String olsonId = olsonIdsToDisplayList.get(i); 412 olsonIdsToDisplay[i] = olsonId; 413 final TimeZone tz = TimeZone.getTimeZone(olsonId); 414 timeZones[i] = tz; 415 gmtOffsetTexts[i] = getGmtOffsetText(tzFormatter, locale, tz, now); 416 } 417 418 // Create a lookup of local zone IDs. 419 final List<String> zoneIds = lookupTimeZoneIdsByCountry(locale.getCountry()); 420 localZoneIds = zoneIds != null ? new HashSet<>(zoneIds) : new HashSet<>(); 421 } 422 423 @VisibleForTesting 424 public List<String> lookupTimeZoneIdsByCountry(String country) { 425 final CountryTimeZones countryTimeZones = 426 TimeZoneFinder.getInstance().lookupCountryTimeZones(country); 427 if (countryTimeZones == null) { 428 return null; 429 } 430 final List<TimeZoneMapping> mappings = countryTimeZones.getTimeZoneMappings(); 431 return extractTimeZoneIds(mappings); 432 } 433 434 private static List<String> extractTimeZoneIds(List<TimeZoneMapping> timeZoneMappings) { 435 final List<String> zoneIds = new ArrayList<>(timeZoneMappings.size()); 436 for (TimeZoneMapping timeZoneMapping : timeZoneMappings) { 437 zoneIds.add(timeZoneMapping.getTimeZoneId()); 438 } 439 return Collections.unmodifiableList(zoneIds); 440 } 441 } 442 } 443