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