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 package com.android.libcore.timezone.tzlookup; 17 18 import com.android.libcore.timezone.countryzones.proto.CountryZonesFile; 19 import com.android.libcore.timezone.tzlookup.zonetree.CountryZoneTree; 20 import com.android.libcore.timezone.tzlookup.zonetree.CountryZoneUsage; 21 import com.android.libcore.timezone.util.Errors; 22 import com.android.libcore.timezone.util.Errors.HaltExecutionException; 23 import com.android.timezone.tzids.TimeZoneIds; 24 import com.android.timezone.tzids.proto.TzIdsProto; 25 import com.ibm.icu.util.BasicTimeZone; 26 import com.ibm.icu.util.Calendar; 27 import com.ibm.icu.util.GregorianCalendar; 28 import com.ibm.icu.util.TimeZone; 29 import com.ibm.icu.util.TimeZoneRule; 30 31 import java.io.File; 32 import java.io.IOException; 33 import java.text.ParseException; 34 import java.time.Instant; 35 import java.time.temporal.ChronoUnit; 36 import java.util.ArrayList; 37 import java.util.Collections; 38 import java.util.HashSet; 39 import java.util.List; 40 import java.util.Map; 41 import java.util.Objects; 42 import java.util.Set; 43 import java.util.concurrent.TimeUnit; 44 45 import javax.xml.stream.XMLStreamException; 46 47 /** 48 * Generates Android's tzlookup.xml and tzids.prototxt file using ICU4J, Android's countryzones.txt 49 * file, and TZDB's backwards and zones.tab files. 50 */ 51 public final class TzLookupGenerator { 52 53 /** 54 * The start time (inclusive) for calculating country zone rules. 19700101 00:00:00 UTC. Chosen 55 * because this is the point in time for which the tzdb zone.tab data is supposed to be correct. 56 */ 57 public static final Instant ZONE_USAGE_CALCS_START = Instant.EPOCH; 58 59 /** 60 * The end time (exclusive) for generating country zone usage. 20380119 03:14:07 UTC. Any times 61 * after this will be considered "infinity" for the "notafter" value and not included. Chosen 62 * because this is a "nice round number" and has historical significance for people that deal 63 * with computer time. There is no particular reason to choose this over another time; any 64 * future time after the last time we expect the code to reasonably encounter will do. 65 */ 66 public static final Instant ZONE_USAGE_NOT_AFTER_CUT_OFF = 67 Instant.ofEpochSecond(Integer.MAX_VALUE); 68 69 /** 70 * The end time (exclusive) for calculating country zone usage. The time zone periods are 71 * calculated to this point. The main requirement is that it's after 72 * {@link #ZONE_USAGE_NOT_AFTER_CUT_OFF} by an amount larger than the usual daylight savings 73 * period; here we use 2 years. 74 */ 75 public static final Instant ZONE_USAGE_CALCS_END = 76 ZONE_USAGE_NOT_AFTER_CUT_OFF.plus(2 * 365, ChronoUnit.DAYS); 77 78 private final String countryZonesFileIn; 79 private final String zoneTabFileIn; 80 private final String backwardFileIn; 81 private final String tzLookupXmlFileOut; 82 private final String timeZoneIdsFileOut; 83 84 /** 85 * Executes the generator. 86 */ main(String[] args)87 public static void main(String[] args) throws Exception { 88 if (args.length != 5) { 89 System.err.println( 90 "usage: java com.android.libcore.timezone.tzlookup.TzLookupGenerator" 91 + " <[in] countryzones.txt file> <[in] zone.tab file>" 92 + " <[in] backward file>" 93 + " <[out] tzlookup.xml file> <[out] zone IDs file>"); 94 System.exit(0); 95 } 96 TzLookupGenerator tzLookupGenerator = 97 new TzLookupGenerator(args[0], args[1], args[2], args[3], args[4]); 98 boolean success = tzLookupGenerator.execute(); 99 System.exit(success ? 0 : 1); 100 } 101 TzLookupGenerator(String countryZonesFileIn, String zoneTabFileIn, String backwardFileIn, String tzLookupXmlFileOut, String timeZoneIdsFileOut)102 TzLookupGenerator(String countryZonesFileIn, String zoneTabFileIn, String backwardFileIn, 103 String tzLookupXmlFileOut, String timeZoneIdsFileOut) { 104 this.countryZonesFileIn = countryZonesFileIn; 105 this.zoneTabFileIn = zoneTabFileIn; 106 this.backwardFileIn = backwardFileIn; 107 this.tzLookupXmlFileOut = tzLookupXmlFileOut; 108 this.timeZoneIdsFileOut = timeZoneIdsFileOut; 109 } 110 execute()111 boolean execute() { 112 Errors errors = new Errors(); 113 try { 114 // Parse the countryzones input file. 115 CountryZonesFile.CountryZones countryZonesIn = 116 parseAndValidateCountryZones(countryZonesFileIn, errors); 117 118 // Check the countryzones.txt rules version matches the version that ICU is using. 119 String icuTzDataVersion = TimeZone.getTZDataVersion(); 120 String inputIanaVersion = countryZonesIn.getIanaVersion(); 121 if (!icuTzDataVersion.equals(inputIanaVersion)) { 122 throw errors.addFatalAndHalt("Input data (countryzones.txt) is for " 123 + inputIanaVersion + " but the ICU you have is for " + icuTzDataVersion); 124 } 125 126 // Pull out information we want to validate against from zone.tab (which we have to 127 // assume matches the ICU version since it doesn't contain its own version info). 128 Map<String, List<String>> zoneTabMapping = parseZoneTabFile(zoneTabFileIn, errors); 129 130 List<CountryZonesFile.Country> countriesIn = countryZonesIn.getCountriesList(); 131 List<String> countriesInIsos = CountryZonesFileSupport.extractIsoCodes(countriesIn); 132 133 // Confidence check the countryzones file only contains lower-case country codes. The 134 // output file uses them and the on-device code assumes lower case. 135 if (!Utils.allLowerCaseAscii(countriesInIsos)) { 136 throw errors.addFatalAndHalt( 137 "Non-lowercase country ISO codes found in: " + countriesInIsos); 138 } 139 // Confidence check the countryzones file doesn't contain duplicate country entries. 140 if (!Utils.allUnique(countriesInIsos)) { 141 throw errors.addFatalAndHalt( 142 "Duplicate input country entries found: " + countriesInIsos); 143 } 144 145 // Validate the country iso codes found in the countryzones.txt against those in 146 // zone.tab. zone.tab uses upper case, countryzones uses lower case. 147 List<String> upperCaseCountriesInIsos = Utils.toUpperCase(countriesInIsos); 148 Set<String> timezonesCountryIsos = new HashSet<>(upperCaseCountriesInIsos); 149 Set<String> zoneTabCountryIsos = zoneTabMapping.keySet(); 150 if (!zoneTabCountryIsos.equals(timezonesCountryIsos)) { 151 throw errors.addFatalAndHalt(zoneTabFileIn + " contains " 152 + Utils.subtract(zoneTabCountryIsos, timezonesCountryIsos) 153 + " not present in countryzones, " 154 + countryZonesFileIn + " contains " 155 + Utils.subtract(timezonesCountryIsos, zoneTabCountryIsos) 156 + " not present in zonetab."); 157 } 158 159 // Obtain and validate a mapping from old IDs to new IDs. 160 BackwardFile backwardIn = parseAndValidateBackwardFile(backwardFileIn, errors); 161 errors.throwIfError("Errors accumulated"); 162 163 OutputData outputData = createOutputData( 164 inputIanaVersion, zoneTabMapping, countriesIn, backwardIn, errors); 165 166 // Write the output structure if there wasn't an error. 167 errors.throwIfError("Errors accumulated"); 168 writeOutputData(outputData, tzLookupXmlFileOut, timeZoneIdsFileOut, errors); 169 return true; 170 } catch (HaltExecutionException e) { 171 logError("Stopping due to fatal condition", e); 172 return false; 173 } finally { 174 // Report all warnings / errors 175 if (!errors.isEmpty()) { 176 logInfo("Issues:\n" + errors.asString()); 177 } 178 } 179 } 180 parseZoneTabFile(String zoneTabFile, Errors errors)181 private Map<String, List<String>> parseZoneTabFile(String zoneTabFile, Errors errors) 182 throws HaltExecutionException { 183 errors.pushScope("Parsing " + zoneTabFile); 184 try { 185 ZoneTabFile zoneTabIn; 186 zoneTabIn = ZoneTabFile.parse(zoneTabFile); 187 return ZoneTabFile.createCountryToOlsonIdsMap(zoneTabIn); 188 } catch (ParseException | IOException e) { 189 throw errors.addFatalAndHalt("Unable to parse " + zoneTabFile, e); 190 } finally { 191 errors.popScope(); 192 } 193 } 194 195 /** 196 * Load the backward file and return the links contained within. This is used as the source of 197 * equivalent time zone IDs. 198 */ parseAndValidateBackwardFile(String backwardFile, Errors errors)199 private static BackwardFile parseAndValidateBackwardFile(String backwardFile, Errors errors) { 200 errors.pushScope("Parsing " + backwardFile); 201 try { 202 BackwardFile backward = BackwardFile.parse(backwardFile); 203 204 // Validate the links. 205 Map<String, String> zoneIdLinks = backward.getLinks(); 206 zoneIdLinks.forEach( 207 (k, v) -> { 208 if (invalidTimeZoneId(k)) { 209 errors.addError("Bad 'from' link: " + k + "->" + v); 210 } 211 if (invalidTimeZoneId(v)) { 212 errors.addError("Bad 'to' link: " + k + "->" + v); 213 } 214 }); 215 return backward; 216 } catch (ParseException | IOException e) { 217 errors.addError("Unable to parse " + backwardFile, e); 218 return null; 219 } finally { 220 errors.popScope(); 221 } 222 } 223 parseAndValidateCountryZones( String countryZonesFile, Errors errors)224 private static CountryZonesFile.CountryZones parseAndValidateCountryZones( 225 String countryZonesFile, Errors errors) throws HaltExecutionException { 226 errors.pushScope("Parsing " + countryZonesFile); 227 try { 228 CountryZonesFile.CountryZones countryZonesIn; 229 countryZonesIn = CountryZonesFileSupport.parseCountryZonesTextFile(countryZonesFile); 230 return countryZonesIn; 231 } catch (ParseException | IOException e) { 232 throw errors.addFatalAndHalt("Unable to parse " + countryZonesFile, e); 233 } finally { 234 errors.popScope(); 235 } 236 } 237 writeOutputData(OutputData outputData, String tzLookupXmlFileName, String timeZoneIdsFileName, Errors errors)238 private static void writeOutputData(OutputData outputData, 239 String tzLookupXmlFileName, String timeZoneIdsFileName, Errors errors) 240 throws HaltExecutionException { 241 errors.pushScope("write " + tzLookupXmlFileName); 242 try { 243 // Write out the file used on device. 244 logInfo("Writing " + tzLookupXmlFileName); 245 246 TzLookupFile.TimeZones timeZonesOut = outputData.getTzLookupTimeZones(); 247 TzLookupFile.write(timeZonesOut, tzLookupXmlFileName); 248 } catch (IOException | XMLStreamException e) { 249 errors.addFatalAndHalt("Unable to write " + tzLookupXmlFileName, e); 250 } finally { 251 errors.popScope(); 252 } 253 254 errors.pushScope("write " + timeZoneIdsFileName); 255 try { 256 // Write out the tz IDs file used during later stages of the pipeline. 257 logInfo("Writing " + timeZoneIdsFileName); 258 259 TimeZoneIds timeZoneIds = outputData.getTimeZoneIds(); 260 timeZoneIds.store(new File(timeZoneIdsFileName)); 261 } catch (IOException e) { 262 errors.addFatalAndHalt("Unable to write " + timeZoneIdsFileName, e); 263 } finally { 264 errors.popScope(); 265 } 266 } 267 createOutputData(String inputIanaVersion, Map<String, List<String>> zoneTabMapping, List<CountryZonesFile.Country> countriesIn, BackwardFile backwardIn, Errors errors)268 private static OutputData createOutputData(String inputIanaVersion, 269 Map<String, List<String>> zoneTabMapping, List<CountryZonesFile.Country> countriesIn, 270 BackwardFile backwardIn, Errors errors) throws HaltExecutionException { 271 272 // Start constructing the output structure. 273 TzLookupFile.TimeZones timeZonesOut = new TzLookupFile.TimeZones(inputIanaVersion); 274 TzLookupFile.CountryZones tzLookupCountryZones = new TzLookupFile.CountryZones(); 275 timeZonesOut.setCountryZones(tzLookupCountryZones); 276 277 // The time use when sampling the offsets for a zone. 278 final long offsetSampleTimeMillis = getSampleOffsetTimeMillisForData(inputIanaVersion); 279 280 // The start time to use when working out whether a zone has used UTC. 281 // We don't care about historical use of UTC (e.g. parts of Europe like France prior 282 // to WW2) so we start looking at the beginning of "this year". 283 long everUseUtcStartTimeMillis = getYearStartTimeMillisForData(inputIanaVersion); 284 285 TzIdsProto.TimeZoneIds.Builder tzIdsBuilder = TzIdsProto.TimeZoneIds.newBuilder() 286 .setIanaVersion(inputIanaVersion); 287 288 // Process each Country. 289 for (CountryZonesFile.Country countryIn : countriesIn) { 290 String isoCode = countryIn.getIsoCode(); 291 List<String> zoneTabCountryTimeZoneIds = zoneTabMapping.get(isoCode.toUpperCase()); 292 if (zoneTabCountryTimeZoneIds == null) { 293 errors.addError("Country=" + isoCode + " missing from zone.tab"); 294 // No point in continuing. 295 continue; 296 } 297 298 CountryOutputData countryOutputData = processCountry( 299 offsetSampleTimeMillis, everUseUtcStartTimeMillis, countryIn, 300 zoneTabCountryTimeZoneIds, backwardIn, errors); 301 if (countryOutputData == null) { 302 // Continue processing countries if there are only errors. 303 continue; 304 } 305 306 tzLookupCountryZones.addCountry(countryOutputData.getTzLookupCountry()); 307 tzIdsBuilder.addCountryMappings(countryOutputData.getTimeZoneIdsCountryMapping()); 308 } 309 errors.throwIfError("One or more countries failed"); 310 TimeZoneIds timeZoneIds = new TimeZoneIds(tzIdsBuilder.build()); 311 return new OutputData(timeZonesOut, timeZoneIds); 312 } 313 processCountry(long offsetSampleTimeMillis, long everUseUtcStartTimeMillis, CountryZonesFile.Country countryIn, List<String> zoneTabCountryTimeZoneIds, BackwardFile backwardIn, Errors errors)314 private static CountryOutputData processCountry(long offsetSampleTimeMillis, 315 long everUseUtcStartTimeMillis, CountryZonesFile.Country countryIn, 316 List<String> zoneTabCountryTimeZoneIds, BackwardFile backwardIn, 317 Errors errors) { 318 String isoCode = countryIn.getIsoCode(); 319 errors.pushScope("country=" + isoCode); 320 try { 321 // Each Country must have >= 1 time zone. 322 List<CountryZonesFile.TimeZoneMapping> timeZonesIn = 323 countryIn.getTimeZoneMappingsList(); 324 if (timeZonesIn.isEmpty()) { 325 errors.addError("No time zones"); 326 // No point in continuing. 327 return null; 328 } 329 330 List<String> countryTimeZoneIds = CountryZonesFileSupport.extractIds(timeZonesIn); 331 332 // Look for duplicate time zone IDs. 333 if (!Utils.allUnique(countryTimeZoneIds)) { 334 errors.addError("country's zones=" + countryTimeZoneIds + " contains duplicates"); 335 // No point in continuing. 336 return null; 337 } 338 339 // Each Country needs a default time zone ID (but we can guess in some cases). 340 String defaultTimeZoneId = determineCountryDefaultZoneId(countryIn, errors); 341 if (defaultTimeZoneId == null) { 342 // No point in continuing. 343 return null; 344 } 345 boolean defaultTimeZoneBoost = 346 determineCountryDefaultTimeZoneBoost(countryIn, errors); 347 348 // Validate the default. 349 if (!countryTimeZoneIds.contains(defaultTimeZoneId)) { 350 errors.addError("defaultTimeZoneId=" + defaultTimeZoneId 351 + " is not one of the country's zones=" + countryTimeZoneIds); 352 // No point in continuing. 353 return null; 354 } 355 356 // Validate the other zone IDs. 357 try { 358 errors.pushScope("validate country zone ids"); 359 for (String countryTimeZoneId : countryTimeZoneIds) { 360 if (invalidTimeZoneId(countryTimeZoneId)) { 361 errors.addError("countryTimeZoneId=" + countryTimeZoneId 362 + " is not a valid zone ID"); 363 } 364 } 365 if (errors.hasError()) { 366 // No point in continuing. 367 return null; 368 } 369 } finally { 370 errors.popScope(); 371 } 372 373 // Work out the hint for whether the country uses a zero offset from UTC. 374 boolean everUsesUtc = anyZonesUseUtc(countryTimeZoneIds, everUseUtcStartTimeMillis); 375 376 // Validate the country information against the equivalent information in zone.tab. 377 errors.pushScope("zone.tab comparison"); 378 try { 379 // Look for unexpected duplicate time zone IDs in zone.tab 380 if (!Utils.allUnique(zoneTabCountryTimeZoneIds)) { 381 errors.addError("Duplicate time zone IDs found:" + zoneTabCountryTimeZoneIds); 382 // No point in continuing. 383 return null; 384 } 385 386 // Validate the IDs being used against the IANA data for the country. If it fails 387 // the countryzones.txt needs to be updated with new IDs (or an alias can be added 388 // if there's some reason to keep using the old ID). 389 validateCountryZonesTzIdsAgainstIana(isoCode, zoneTabCountryTimeZoneIds, 390 timeZonesIn, backwardIn.getDirectLinks(), errors); 391 if (errors.hasError()) { 392 // No point in continuing. 393 return null; 394 } 395 } finally { 396 errors.popScope(); 397 } 398 399 // Calculate countryZoneUsage. 400 CountryZoneUsage countryZoneUsage = calculateCountryZoneUsage(countryIn, errors); 401 if (countryZoneUsage == null) { 402 // No point in continuing with this country. 403 return null; 404 } 405 406 // Create the tzlookup country structure. 407 TzLookupFile.Country countryOut = new TzLookupFile.Country( 408 isoCode, defaultTimeZoneId, defaultTimeZoneBoost, everUsesUtc); 409 410 // Process each input time zone. 411 for (CountryZonesFile.TimeZoneMapping timeZoneIn : timeZonesIn) { 412 errors.pushScope( 413 "id=" + timeZoneIn.getId() + ", offset=" + timeZoneIn.getUtcOffset() 414 + ", shownInPicker=" + timeZoneIn.getShownInPicker()); 415 try { 416 String timeZoneInId = timeZoneIn.getId(); 417 418 // The notUsedAfterInstant can be null if the zone is used until at least 419 // ZONE_CALCS_END_INSTANT. That's what we want. 420 Instant notUsedAfterInstant = 421 countryZoneUsage.getNotUsedAfterInstant(timeZoneInId); 422 String notUsedReplacementId = 423 countryZoneUsage.getNotUsedReplacementId(timeZoneInId); 424 425 // Validate the offset information in countryIn. 426 validateNonDstOffset(offsetSampleTimeMillis, countryIn, timeZoneIn, errors); 427 428 boolean shownInPicker = timeZoneIn.getShownInPicker(); 429 if (!countryZoneUsage.hasEntry(timeZoneInId)) { 430 // This implies a programming error. 431 errors.addError("No entry in CountryZoneUsage for " + timeZoneInId); 432 return null; 433 } 434 435 // Find all the alternative zone IDs for the chosen zone ID. 436 List<String> alternativeZoneIds = 437 new ArrayList<>(backwardIn.getAllAlternativeIds(timeZoneInId)); 438 Collections.sort(alternativeZoneIds); 439 440 // Add the id mapping and associated metadata. 441 TzLookupFile.TimeZoneMapping timeZoneIdOut = new TzLookupFile.TimeZoneMapping( 442 timeZoneInId, shownInPicker, notUsedAfterInstant, notUsedReplacementId, 443 alternativeZoneIds); 444 countryOut.addTimeZoneMapping(timeZoneIdOut); 445 } finally { 446 errors.popScope(); 447 } 448 } 449 450 // CountryMapping contains only information that is available from Country so we can 451 // currently build one from the other. 452 TzIdsProto.CountryMapping countryMappingProto = 453 TzLookupFile.Country.createCountryMappingProto(countryOut); 454 455 return new CountryOutputData(countryOut, countryMappingProto); 456 } finally{ 457 // End of country processing. 458 errors.popScope(); 459 } 460 } 461 validateCountryZonesTzIdsAgainstIana(String isoCode, List<String> zoneTabCountryTimeZoneIds, List<CountryZonesFile.TimeZoneMapping> timeZoneMappings, Map<String, String> zoneIdLinks, Errors errors)462 private static void validateCountryZonesTzIdsAgainstIana(String isoCode, 463 List<String> zoneTabCountryTimeZoneIds, 464 List<CountryZonesFile.TimeZoneMapping> timeZoneMappings, 465 Map<String, String> zoneIdLinks, Errors errors) { 466 467 List<String> expectedIanaTimeZoneIds = new ArrayList<>(); 468 for (CountryZonesFile.TimeZoneMapping mapping : timeZoneMappings) { 469 String timeZoneId = mapping.getId(); 470 String expectedIanaTimeZoneId; 471 if (!mapping.hasAliasId()) { 472 expectedIanaTimeZoneId = timeZoneId; 473 } else { 474 String aliasTimeZoneId = mapping.getAliasId(); 475 476 // Confirm the alias is valid. 477 if (!aliasTimeZoneId.equals(zoneIdLinks.get(timeZoneId))) { 478 errors.addError(timeZoneId + " does not link to " + aliasTimeZoneId); 479 return; 480 } 481 expectedIanaTimeZoneId = aliasTimeZoneId; 482 } 483 expectedIanaTimeZoneIds.add(expectedIanaTimeZoneId); 484 } 485 486 if (!Utils.setEquals(zoneTabCountryTimeZoneIds, expectedIanaTimeZoneIds)) { 487 errors.addError("IANA lists " + isoCode 488 + " as having zones: " + zoneTabCountryTimeZoneIds 489 + ", but countryzones has " + expectedIanaTimeZoneIds); 490 } 491 } 492 493 /** 494 * Determines the default zone ID for the country. 495 */ determineCountryDefaultZoneId( CountryZonesFile.Country countryIn, Errors errors)496 private static String determineCountryDefaultZoneId( 497 CountryZonesFile.Country countryIn, Errors errors) { 498 List<CountryZonesFile.TimeZoneMapping> timeZonesIn = countryIn.getTimeZoneMappingsList(); 499 String defaultTimeZoneId; 500 if (countryIn.hasDefaultTimeZoneId()) { 501 defaultTimeZoneId = countryIn.getDefaultTimeZoneId(); 502 if (invalidTimeZoneId(defaultTimeZoneId)) { 503 errors.addError( 504 "Default time zone ID " + defaultTimeZoneId + " is not valid"); 505 // No point in continuing. 506 return null; 507 } 508 } else { 509 if (timeZonesIn.size() > 1) { 510 errors.addError( 511 "To pick a default time zone there must be a single offset group"); 512 // No point in continuing. 513 return null; 514 } 515 defaultTimeZoneId = timeZonesIn.get(0).getId(); 516 } 517 return defaultTimeZoneId; 518 } 519 520 /** 521 * Determines the defaultTimeZoneBoost value for the country. 522 */ determineCountryDefaultTimeZoneBoost( CountryZonesFile.Country countryIn, Errors errors)523 private static boolean determineCountryDefaultTimeZoneBoost( 524 CountryZonesFile.Country countryIn, Errors errors) { 525 if (!countryIn.hasDefaultTimeZoneBoost()) { 526 return false; 527 } 528 529 boolean defaultTimeZoneBoost = countryIn.getDefaultTimeZoneBoost(); 530 if (!countryIn.hasDefaultTimeZoneId() && defaultTimeZoneBoost) { 531 errors.addError( 532 "defaultTimeZoneBoost is specified but defaultTimeZoneId is not explicit"); 533 } 534 535 return defaultTimeZoneBoost; 536 } 537 538 /** 539 * Returns true if any of the zones use UTC after the time specified. 540 */ anyZonesUseUtc(List<String> timeZoneIds, long startTimeMillis)541 private static boolean anyZonesUseUtc(List<String> timeZoneIds, long startTimeMillis) { 542 for (String timeZoneId : timeZoneIds) { 543 BasicTimeZone timeZone = (BasicTimeZone) TimeZone.getTimeZone(timeZoneId); 544 TimeZoneRule[] rules = timeZone.getTimeZoneRules(startTimeMillis); 545 for (TimeZoneRule rule : rules) { 546 int utcOffset = rule.getRawOffset() + rule.getDSTSavings(); 547 if (utcOffset == 0) { 548 return true; 549 } 550 } 551 } 552 return false; 553 } 554 555 /** 556 * Returns a sample time related to the IANA version to enable any offset validation to be 557 * repeatable (rather than depending on the current time when the tool is run). 558 */ getSampleOffsetTimeMillisForData(String inputIanaVersion)559 private static long getSampleOffsetTimeMillisForData(String inputIanaVersion) { 560 // Uses <year>/07/02 12:00:00 UTC, where year is taken from the IANA version + 1. 561 // This is fairly arbitrary, but reflects the fact that we want a point in the future 562 // WRT to the data, and once a year has been picked then half-way through seems about right. 563 Calendar calendar = getYearStartForData(inputIanaVersion); 564 calendar.set(calendar.get(Calendar.YEAR) + 1, Calendar.JULY, 2, 12, 0, 0); 565 return calendar.getTimeInMillis(); 566 } 567 568 /** 569 * Returns the 1st Jan 00:00:00 UTC time on the year the IANA version relates to. Therefore 570 * guaranteed to be before the data is ever used and can be treated as "the beginning of time" 571 * (assuming derived information won't be used for historical calculations). 572 */ getYearStartTimeMillisForData(String inputIanaVersion)573 private static long getYearStartTimeMillisForData(String inputIanaVersion) { 574 return getYearStartForData(inputIanaVersion).getTimeInMillis(); 575 } 576 getYearStartForData(String inputIanaVersion)577 private static Calendar getYearStartForData(String inputIanaVersion) { 578 String yearString = inputIanaVersion.substring(0, inputIanaVersion.length() - 1); 579 int year = Integer.parseInt(yearString); 580 Calendar calendar = new GregorianCalendar(TimeZone.GMT_ZONE); 581 calendar.clear(); 582 calendar.set(year, Calendar.JANUARY, 1, 0, 0, 0); 583 return calendar; 584 } 585 invalidTimeZoneId(String timeZoneId)586 private static boolean invalidTimeZoneId(String timeZoneId) { 587 TimeZone zone = TimeZone.getTimeZone(timeZoneId); 588 return !(zone instanceof BasicTimeZone) || zone.getID().equals(TimeZone.UNKNOWN_ZONE_ID); 589 } 590 validateNonDstOffset(long offsetSampleTimeMillis, CountryZonesFile.Country country, CountryZonesFile.TimeZoneMapping timeZoneIn, Errors errors)591 private static void validateNonDstOffset(long offsetSampleTimeMillis, 592 CountryZonesFile.Country country, CountryZonesFile.TimeZoneMapping timeZoneIn, 593 Errors errors) { 594 String utcOffsetString = timeZoneIn.getUtcOffset(); 595 long utcOffsetMillis; 596 try { 597 utcOffsetMillis = Utils.parseUtcOffsetToMillis(utcOffsetString); 598 } catch (ParseException e) { 599 errors.addError("Bad offset string: " + utcOffsetString); 600 return; 601 } 602 603 final long minimumGranularity = TimeUnit.MINUTES.toMillis(15); 604 if (utcOffsetMillis % minimumGranularity != 0) { 605 errors.addWarning( 606 "Unexpected granularity: not a multiple of 15 minutes: " + utcOffsetString); 607 } 608 609 String timeZoneIdIn = timeZoneIn.getId(); 610 if (invalidTimeZoneId(timeZoneIdIn)) { 611 errors.addError("Time zone ID=" + timeZoneIdIn + " is not valid"); 612 return; 613 } 614 615 // Check the offset Android has matches what ICU thinks. 616 TimeZone timeZone = TimeZone.getTimeZone(timeZoneIdIn); 617 int[] offsets = new int[2]; 618 timeZone.getOffset(offsetSampleTimeMillis, false /* local */, offsets); 619 int actualOffsetMillis = offsets[0]; 620 if (actualOffsetMillis != utcOffsetMillis) { 621 errors.addError("Offset mismatch: You will want to confirm the ordering for " 622 + country.getIsoCode() + " still makes sense. Raw offset for " 623 + timeZoneIdIn + " is " + Utils.toUtcOffsetString(actualOffsetMillis) 624 + " and not " + Utils.toUtcOffsetString(utcOffsetMillis) 625 + " at " + Utils.formatUtc(offsetSampleTimeMillis)); 626 } 627 } 628 calculateCountryZoneUsage( CountryZonesFile.Country countryIn, Errors errors)629 private static CountryZoneUsage calculateCountryZoneUsage( 630 CountryZonesFile.Country countryIn, Errors errors) { 631 errors.pushScope("Building zone tree"); 632 try { 633 CountryZoneTree countryZoneTree = CountryZoneTree.create( 634 countryIn, ZONE_USAGE_CALCS_START, ZONE_USAGE_CALCS_END); 635 List<String> countryIssues = countryZoneTree.validateNoPriorityClashes(); 636 if (!countryIssues.isEmpty()) { 637 errors.addError("Issues validating country zone trees. Adjust priorities:"); 638 countryIssues.forEach(errors::addError); 639 return null; 640 } 641 return countryZoneTree.calculateCountryZoneUsage(ZONE_USAGE_NOT_AFTER_CUT_OFF); 642 } finally { 643 errors.popScope(); 644 } 645 } 646 logError(String msg)647 private static void logError(String msg) { 648 System.err.println("E: " + msg); 649 } 650 logError(String s, Throwable e)651 private static void logError(String s, Throwable e) { 652 logError(s); 653 e.printStackTrace(System.err); 654 } 655 logInfo(String msg)656 private static void logInfo(String msg) { 657 System.err.println("I: " + msg); 658 } 659 660 private static class CountryOutputData { 661 private final TzLookupFile.Country tzLookupCountry; 662 private final TzIdsProto.CountryMapping timeZoneIdsCountryMapping; 663 CountryOutputData(TzLookupFile.Country tzLookupCountry, TzIdsProto.CountryMapping timeZoneIdsCountryMapping)664 private CountryOutputData(TzLookupFile.Country tzLookupCountry, 665 TzIdsProto.CountryMapping timeZoneIdsCountryMapping) { 666 this.tzLookupCountry = Objects.requireNonNull(tzLookupCountry); 667 this.timeZoneIdsCountryMapping = Objects.requireNonNull(timeZoneIdsCountryMapping); 668 } 669 getTzLookupCountry()670 private TzLookupFile.Country getTzLookupCountry() { 671 return tzLookupCountry; 672 } 673 getTimeZoneIdsCountryMapping()674 private TzIdsProto.CountryMapping getTimeZoneIdsCountryMapping() { 675 return timeZoneIdsCountryMapping; 676 } 677 } 678 679 private static class OutputData { 680 681 private final TzLookupFile.TimeZones tzLookupTimeZones; 682 private final TimeZoneIds timeZoneIds; 683 OutputData(TzLookupFile.TimeZones tzLookupTimeZones, TimeZoneIds timeZoneIds)684 private OutputData(TzLookupFile.TimeZones tzLookupTimeZones, TimeZoneIds timeZoneIds) { 685 this.tzLookupTimeZones = Objects.requireNonNull(tzLookupTimeZones); 686 this.timeZoneIds = Objects.requireNonNull(timeZoneIds); 687 } 688 getTzLookupTimeZones()689 private TzLookupFile.TimeZones getTzLookupTimeZones() { 690 return tzLookupTimeZones; 691 } 692 getTimeZoneIds()693 private TimeZoneIds getTimeZoneIds() { 694 return timeZoneIds; 695 } 696 } 697 } 698