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