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