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