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