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