1 /* 2 * Copyright (C) 2017 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 libcore.timezone; 18 19 import android.icu.util.TimeZone; 20 21 import java.util.ArrayList; 22 import java.util.Arrays; 23 import java.util.Collections; 24 import java.util.HashSet; 25 import java.util.List; 26 import java.util.Locale; 27 import java.util.Objects; 28 29 /** 30 * Information about a country's time zones. 31 * @hide 32 */ 33 @libcore.api.CorePlatformApi 34 public final class CountryTimeZones { 35 36 /** 37 * The result of lookup up a time zone using offset information (and possibly more). 38 * @hide 39 */ 40 @libcore.api.CorePlatformApi 41 public static final class OffsetResult { 42 43 /** A zone that matches the supplied criteria. See also {@link #isOnlyMatch}. */ 44 private final TimeZone timeZone; 45 46 /** True if there is one match for the supplied criteria */ 47 private final boolean isOnlyMatch; 48 OffsetResult(TimeZone timeZone, boolean isOnlyMatch)49 public OffsetResult(TimeZone timeZone, boolean isOnlyMatch) { 50 this.timeZone = java.util.Objects.requireNonNull(timeZone); 51 this.isOnlyMatch = isOnlyMatch; 52 } 53 54 @libcore.api.CorePlatformApi getTimeZone()55 public TimeZone getTimeZone() { 56 return timeZone; 57 } 58 59 @libcore.api.CorePlatformApi isOnlyMatch()60 public boolean isOnlyMatch() { 61 return isOnlyMatch; 62 } 63 64 @Override toString()65 public String toString() { 66 return "Result{" 67 + "timeZone='" + timeZone + '\'' 68 + ", isOnlyMatch=" + isOnlyMatch 69 + '}'; 70 } 71 } 72 73 /** 74 * A mapping to a time zone ID with some associated metadata. 75 * 76 * @hide 77 */ 78 @libcore.api.CorePlatformApi 79 public static final class TimeZoneMapping { 80 private final String timeZoneId; 81 private final boolean shownInPicker; 82 private final Long notUsedAfter; 83 84 /** Memoized TimeZone object for {@link #timeZoneId}. */ 85 private TimeZone timeZone; 86 TimeZoneMapping(String timeZoneId, boolean shownInPicker, Long notUsedAfter)87 TimeZoneMapping(String timeZoneId, boolean shownInPicker, Long notUsedAfter) { 88 this.timeZoneId = Objects.requireNonNull(timeZoneId); 89 this.shownInPicker = shownInPicker; 90 this.notUsedAfter = notUsedAfter; 91 } 92 93 @libcore.api.CorePlatformApi getTimeZoneId()94 public String getTimeZoneId() { 95 return timeZoneId; 96 } 97 98 @libcore.api.CorePlatformApi isShownInPicker()99 public boolean isShownInPicker() { 100 return shownInPicker; 101 } 102 103 @libcore.api.CorePlatformApi getNotUsedAfter()104 public Long getNotUsedAfter() { 105 return notUsedAfter; 106 } 107 108 /** 109 * Returns a {@link TimeZone} object for this mapping, or {@code null} if the ID is unknown. 110 */ 111 @libcore.api.CorePlatformApi getTimeZone()112 public TimeZone getTimeZone() { 113 synchronized (this) { 114 if (timeZone == null) { 115 TimeZone tz = TimeZone.getFrozenTimeZone(timeZoneId); 116 timeZone = tz; 117 if (TimeZone.UNKNOWN_ZONE_ID.equals(timeZone.getID())) { 118 // This shouldn't happen given the validation that takes place in 119 // createValidatedCountryTimeZones(). 120 throw new IllegalStateException("Invalid zone in TimeZoneMapping: " + this); 121 } 122 } 123 } 124 125 return TimeZone.UNKNOWN_ZONE_ID.equals(timeZone.getID()) ? null : timeZone; 126 } 127 128 /** 129 * Returns {@code true} if the mapping is "effective" after {@code whenMillis}, i.e. 130 * it is distinct from other "effective" times zones used in the country at/after that 131 * time. This uses the {@link #notUsedAfter} metadata which ensures there is one time 132 * zone remaining when there are multiple candidate zones with the same rules. The one 133 * kept is based on country specific factors like population covered. 134 */ isEffectiveAt(long whenMillis)135 boolean isEffectiveAt(long whenMillis) { 136 return notUsedAfter == null || whenMillis <= notUsedAfter; 137 } 138 139 // VisibleForTesting 140 @libcore.api.CorePlatformApi createForTests( String timeZoneId, boolean showInPicker, Long notUsedAfter)141 public static TimeZoneMapping createForTests( 142 String timeZoneId, boolean showInPicker, Long notUsedAfter) { 143 return new TimeZoneMapping(timeZoneId, showInPicker, notUsedAfter); 144 } 145 146 @Override equals(Object o)147 public boolean equals(Object o) { 148 if (this == o) { 149 return true; 150 } 151 if (o == null || getClass() != o.getClass()) { 152 return false; 153 } 154 TimeZoneMapping that = (TimeZoneMapping) o; 155 return shownInPicker == that.shownInPicker && 156 Objects.equals(timeZoneId, that.timeZoneId) && 157 Objects.equals(notUsedAfter, that.notUsedAfter); 158 } 159 160 @Override hashCode()161 public int hashCode() { 162 return Objects.hash(timeZoneId, shownInPicker, notUsedAfter); 163 } 164 165 @Override toString()166 public String toString() { 167 return "TimeZoneMapping{" 168 + "timeZoneId='" + timeZoneId + '\'' 169 + ", shownInPicker=" + shownInPicker 170 + ", notUsedAfter=" + notUsedAfter 171 + '}'; 172 } 173 174 /** 175 * Returns {@code true} if one of the supplied {@link TimeZoneMapping} objects is for the 176 * specified time zone ID. 177 */ containsTimeZoneId( List<TimeZoneMapping> timeZoneMappings, String timeZoneId)178 static boolean containsTimeZoneId( 179 List<TimeZoneMapping> timeZoneMappings, String timeZoneId) { 180 for (TimeZoneMapping timeZoneMapping : timeZoneMappings) { 181 if (timeZoneMapping.timeZoneId.equals(timeZoneId)) { 182 return true; 183 } 184 } 185 return false; 186 } 187 } 188 189 private final String countryIso; 190 private final String defaultTimeZoneId; 191 /** 192 * {@code true} indicates the default time zone for a country is a good choice if a time zone 193 * cannot be determined by other means. 194 */ 195 private final boolean defaultTimeZoneBoosted; 196 private final List<TimeZoneMapping> timeZoneMappings; 197 private final boolean everUsesUtc; 198 199 /** 200 * Memoized frozen ICU TimeZone object for the default. Can be {@link TimeZone#UNKNOWN_ZONE} if 201 * the {@link #defaultTimeZoneId} is missing or unrecognized. 202 */ 203 private TimeZone defaultTimeZone; 204 CountryTimeZones(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoosted, boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings)205 private CountryTimeZones(String countryIso, String defaultTimeZoneId, 206 boolean defaultTimeZoneBoosted, boolean everUsesUtc, 207 List<TimeZoneMapping> timeZoneMappings) { 208 this.countryIso = java.util.Objects.requireNonNull(countryIso); 209 this.defaultTimeZoneId = defaultTimeZoneId; 210 this.defaultTimeZoneBoosted = defaultTimeZoneBoosted; 211 this.everUsesUtc = everUsesUtc; 212 // Create a defensive copy of the mapping list. 213 this.timeZoneMappings = Collections.unmodifiableList(new ArrayList<>(timeZoneMappings)); 214 } 215 216 /** 217 * Creates a {@link CountryTimeZones} object containing only known time zone IDs. 218 */ createValidated(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoosted, boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo)219 public static CountryTimeZones createValidated(String countryIso, String defaultTimeZoneId, 220 boolean defaultTimeZoneBoosted, boolean everUsesUtc, 221 List<TimeZoneMapping> timeZoneMappings, String debugInfo) { 222 223 // We rely on ZoneInfoDB to tell us what the known valid time zone IDs are. ICU may 224 // recognize more but we want to be sure that zone IDs can be used with java.util as well as 225 // android.icu and ICU is expected to have a superset. 226 String[] validTimeZoneIdsArray = ZoneInfoDb.getInstance().getAvailableIDs(); 227 HashSet<String> validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray)); 228 List<TimeZoneMapping> validCountryTimeZoneMappings = new ArrayList<>(); 229 for (TimeZoneMapping timeZoneMapping : timeZoneMappings) { 230 String timeZoneId = timeZoneMapping.timeZoneId; 231 if (!validTimeZoneIdsSet.contains(timeZoneId)) { 232 System.logW("Skipping invalid zone: " + timeZoneId + " at " + debugInfo); 233 } else { 234 validCountryTimeZoneMappings.add(timeZoneMapping); 235 } 236 } 237 238 // We don't get too strict at runtime about whether the defaultTimeZoneId must be 239 // one of the country's time zones because this is the data we have to use (we also 240 // assume the data was validated by earlier steps). The default time zone ID must just 241 // be a recognized zone ID: if it's not valid we leave it null. 242 if (!validTimeZoneIdsSet.contains(defaultTimeZoneId)) { 243 System.logW("Invalid default time zone ID: " + defaultTimeZoneId 244 + " at " + debugInfo); 245 defaultTimeZoneId = null; 246 } 247 248 String normalizedCountryIso = normalizeCountryIso(countryIso); 249 return new CountryTimeZones( 250 normalizedCountryIso, defaultTimeZoneId, defaultTimeZoneBoosted, everUsesUtc, 251 validCountryTimeZoneMappings); 252 } 253 254 /** 255 * Returns the ISO code for the country. 256 */ 257 @libcore.api.CorePlatformApi getCountryIso()258 public String getCountryIso() { 259 return countryIso; 260 } 261 262 /** 263 * Returns true if the ISO code for the country is a match for the one specified. 264 */ 265 @libcore.api.CorePlatformApi isForCountryCode(String countryIso)266 public boolean isForCountryCode(String countryIso) { 267 return this.countryIso.equals(normalizeCountryIso(countryIso)); 268 } 269 270 /** 271 * Returns the default time zone for the country. Can return null in cases when no data is 272 * available or the time zone ID provided to 273 * {@link #createValidated(String, String, boolean, boolean, List, String)} was not recognized. 274 */ 275 @libcore.api.CorePlatformApi getDefaultTimeZone()276 public synchronized TimeZone getDefaultTimeZone() { 277 if (defaultTimeZone == null) { 278 TimeZone timeZone; 279 if (defaultTimeZoneId == null) { 280 timeZone = TimeZone.UNKNOWN_ZONE; 281 } else { 282 timeZone = TimeZone.getFrozenTimeZone(defaultTimeZoneId); 283 } 284 this.defaultTimeZone = timeZone; 285 } 286 return TimeZone.UNKNOWN_ZONE_ID.equals(defaultTimeZone.getID()) ? null : defaultTimeZone; 287 } 288 289 /** 290 * Returns the default time zone ID for the country. Can return null in cases when no data is 291 * available or the time zone ID provided to 292 * {@link #createValidated(String, String, boolean, boolean, List, String)} was not recognized. 293 */ 294 @libcore.api.CorePlatformApi getDefaultTimeZoneId()295 public String getDefaultTimeZoneId() { 296 return defaultTimeZoneId; 297 } 298 299 /** 300 * Qualifier for a country's default time zone. {@code true} indicates whether the default 301 * would be a good choice <em>generally</em> when there's no other information available. 302 */ 303 @libcore.api.CorePlatformApi isDefaultTimeZoneBoosted()304 public boolean isDefaultTimeZoneBoosted() { 305 return defaultTimeZoneBoosted; 306 } 307 308 /** 309 * Returns an immutable, ordered list of time zone mappings for the country in an undefined but 310 * "priority" order. The list can be empty if there were no zones configured or the configured 311 * zone IDs were not recognized. 312 */ 313 @libcore.api.CorePlatformApi getTimeZoneMappings()314 public List<TimeZoneMapping> getTimeZoneMappings() { 315 return timeZoneMappings; 316 } 317 318 /** 319 * Returns an immutable, ordered list of time zone mappings for the country in an undefined but 320 * "priority" order, filtered so that only "effective" time zone IDs are returned. An 321 * "effective" time zone is one that differs from another time zone used in the country after 322 * {@code whenMillis}. The list can be empty if there were no zones configured or the configured 323 * zone IDs were not recognized. 324 */ 325 @libcore.api.CorePlatformApi getEffectiveTimeZoneMappingsAt(long whenMillis)326 public List<TimeZoneMapping> getEffectiveTimeZoneMappingsAt(long whenMillis) { 327 ArrayList<TimeZoneMapping> filteredList = new ArrayList<>(timeZoneMappings.size()); 328 for (TimeZoneMapping timeZoneMapping : timeZoneMappings) { 329 if (timeZoneMapping.isEffectiveAt(whenMillis)) { 330 filteredList.add(timeZoneMapping); 331 } 332 } 333 return Collections.unmodifiableList(filteredList); 334 } 335 336 @Override equals(Object o)337 public boolean equals(Object o) { 338 if (this == o) { 339 return true; 340 } 341 if (o == null || getClass() != o.getClass()) { 342 return false; 343 } 344 CountryTimeZones that = (CountryTimeZones) o; 345 return defaultTimeZoneBoosted == that.defaultTimeZoneBoosted 346 && everUsesUtc == that.everUsesUtc 347 && countryIso.equals(that.countryIso) 348 && Objects.equals(defaultTimeZoneId, that.defaultTimeZoneId) 349 && timeZoneMappings.equals(that.timeZoneMappings); 350 } 351 352 @Override hashCode()353 public int hashCode() { 354 return Objects.hash( 355 countryIso, defaultTimeZoneId, defaultTimeZoneBoosted, timeZoneMappings, 356 everUsesUtc); 357 } 358 359 @Override toString()360 public String toString() { 361 return "CountryTimeZones{" 362 + "countryIso='" + countryIso + '\'' 363 + ", defaultTimeZoneId='" + defaultTimeZoneId + '\'' 364 + ", defaultTimeZoneBoosted=" + defaultTimeZoneBoosted 365 + ", timeZoneMappings=" + timeZoneMappings 366 + ", everUsesUtc=" + everUsesUtc 367 + '}'; 368 } 369 370 /** 371 * Returns true if the country has at least one zone that is the same as UTC at the given time. 372 */ 373 @libcore.api.CorePlatformApi hasUtcZone(long whenMillis)374 public boolean hasUtcZone(long whenMillis) { 375 // If the data tells us the country never uses UTC we don't have to check anything. 376 if (!everUsesUtc) { 377 return false; 378 } 379 380 for (TimeZoneMapping timeZoneMapping : getEffectiveTimeZoneMappingsAt(whenMillis)) { 381 TimeZone timeZone = timeZoneMapping.getTimeZone(); 382 if (timeZone != null && timeZone.getOffset(whenMillis) == 0) { 383 return true; 384 } 385 } 386 return false; 387 } 388 389 /** 390 * Returns a time zone for the country, if there is one, that matches the supplied properties. 391 * If there are multiple matches and the {@code bias} is one of them then it is returned, 392 * otherwise an arbitrary match is returned based on the {@link 393 * #getEffectiveTimeZoneMappingsAt(long)} ordering. 394 * 395 * @param whenMillis the UTC time to match against 396 * @param bias the time zone to prefer, can be {@code null} to indicate there is no preference 397 * @param totalOffsetMillis the offset from UTC at {@code whenMillis} 398 * @param isDst the Daylight Savings Time state at {@code whenMillis}. {@code true} means DST, 399 * {@code false} means not DST 400 * @return an {@link OffsetResult} with information about a matching zone, or {@code null} if 401 * there is no match 402 */ 403 @libcore.api.CorePlatformApi lookupByOffsetWithBias(long whenMillis, TimeZone bias, int totalOffsetMillis, boolean isDst)404 public OffsetResult lookupByOffsetWithBias(long whenMillis, TimeZone bias, 405 int totalOffsetMillis, boolean isDst) { 406 return lookupByOffsetWithBiasInternal(whenMillis, bias, totalOffsetMillis, isDst); 407 } 408 409 /** 410 * Returns a time zone for the country, if there is one, that matches the supplied properties. 411 * If there are multiple matches and the {@code bias} is one of them then it is returned, 412 * otherwise an arbitrary match is returned based on the {@link 413 * #getEffectiveTimeZoneMappingsAt(long)} ordering. 414 * 415 * @param whenMillis the UTC time to match against 416 * @param bias the time zone to prefer, can be {@code null} to indicate there is no preference 417 * @param totalOffsetMillis the offset from UTC at {@code whenMillis} 418 * @return an {@link OffsetResult} with information about a matching zone, or {@code null} if 419 * there is no match 420 */ 421 @libcore.api.CorePlatformApi lookupByOffsetWithBias(long whenMillis, TimeZone bias, int totalOffsetMillis)422 public OffsetResult lookupByOffsetWithBias(long whenMillis, TimeZone bias, 423 int totalOffsetMillis) { 424 final Boolean isDst = null; 425 return lookupByOffsetWithBiasInternal(whenMillis, bias, totalOffsetMillis, isDst); 426 } 427 428 /** 429 * Returns a time zone for the country, if there is one, that matches the supplied properties. 430 * If there are multiple matches and the {@code bias} is one of them then it is returned, 431 * otherwise an arbitrary match is returned based on the {@link 432 * #getEffectiveTimeZoneMappingsAt(long)} ordering. 433 * 434 * @param whenMillis the UTC time to match against 435 * @param bias the time zone to prefer, can be {@code null} 436 * @param totalOffsetMillis the offset from UTC at {@code whenMillis} 437 * @param isDst the Daylight Savings Time state at {@code whenMillis}. {@code true} means DST, 438 * {@code false} means not DST, {@code null} means unknown 439 */ lookupByOffsetWithBiasInternal(long whenMillis, TimeZone bias, int totalOffsetMillis, Boolean isDst)440 private OffsetResult lookupByOffsetWithBiasInternal(long whenMillis, TimeZone bias, 441 int totalOffsetMillis, Boolean isDst) { 442 List<TimeZoneMapping> timeZoneMappings = getEffectiveTimeZoneMappingsAt(whenMillis); 443 if (timeZoneMappings.isEmpty()) { 444 return null; 445 } 446 447 TimeZone firstMatch = null; 448 boolean biasMatched = false; 449 boolean oneMatch = true; 450 for (TimeZoneMapping timeZoneMapping : timeZoneMappings) { 451 TimeZone match = timeZoneMapping.getTimeZone(); 452 if (match == null 453 || !offsetMatchesAtTime(whenMillis, match, totalOffsetMillis, isDst)) { 454 continue; 455 } 456 457 if (firstMatch == null) { 458 firstMatch = match; 459 } else { 460 oneMatch = false; 461 } 462 if (bias != null && match.getID().equals(bias.getID())) { 463 biasMatched = true; 464 } 465 if (firstMatch != null && !oneMatch && (bias == null || biasMatched)) { 466 break; 467 } 468 } 469 if (firstMatch == null) { 470 return null; 471 } 472 473 TimeZone toReturn = biasMatched ? bias : firstMatch; 474 return new OffsetResult(toReturn, oneMatch); 475 } 476 477 /** 478 * Returns {@code true} if the specified {@code totalOffset} and {@code isDst} would be valid in 479 * the {@code timeZone} at time {@code whenMillis}. 480 * {@code totalOffetMillis} is always matched. 481 * If {@code isDst} is {@code null}, this means the DST state is unknown. 482 * If {@code isDst} is {@code false}, this means the zone must not be in DST. 483 * If {@code isDst} is {@code true}, this means the zone must be in DST. 484 */ offsetMatchesAtTime(long whenMillis, TimeZone timeZone, int totalOffsetMillis, Boolean isDst)485 private static boolean offsetMatchesAtTime(long whenMillis, TimeZone timeZone, 486 int totalOffsetMillis, Boolean isDst) { 487 int[] offsets = new int[2]; 488 timeZone.getOffset(whenMillis, false /* local */, offsets); 489 490 if (totalOffsetMillis != (offsets[0] + offsets[1])) { 491 return false; 492 } 493 494 return isDst == null || (isDst == (offsets[1] != 0)); 495 } 496 normalizeCountryIso(String countryIso)497 private static String normalizeCountryIso(String countryIso) { 498 // Lowercase ASCII is normalized for the purposes of the code in this class. 499 return countryIso.toLowerCase(Locale.US); 500 } 501 } 502