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.tzlookup.proto.CountryZonesFile; 19 import com.android.libcore.timezone.tzlookup.zonetree.CountryZoneTree; 20 import com.android.libcore.timezone.tzlookup.zonetree.CountryZoneUsage; 21 import com.ibm.icu.util.BasicTimeZone; 22 import com.ibm.icu.util.Calendar; 23 import com.ibm.icu.util.GregorianCalendar; 24 import com.ibm.icu.util.TimeZone; 25 import com.ibm.icu.util.TimeZoneRule; 26 27 import java.io.IOException; 28 import java.text.ParseException; 29 import java.time.Instant; 30 import java.time.temporal.ChronoUnit; 31 import java.util.HashSet; 32 import java.util.List; 33 import java.util.Map; 34 import java.util.Set; 35 import java.util.concurrent.TimeUnit; 36 import javax.xml.stream.XMLStreamException; 37 38 /** 39 * Generates the tzlookup.xml file using the information from countryzones.txt and zones.tab. 40 * 41 * See {@link #main(String[])} for commandline information. 42 */ 43 public final class TzLookupGenerator { 44 45 /** 46 * The start time (inclusive) for calculating country zone rules. 19700101 00:00:00 UTC. Chosen 47 * because this is the point in time for which the tzdb zone.tab data is supposed to be correct. 48 */ 49 public static final Instant ZONE_USAGE_CALCS_START = Instant.EPOCH; 50 51 /** 52 * The end time (exclusive) for generating country zone usage. 20380119 03:14:07 UTC. Any times 53 * after this will be considered "infinity" for the "notAfter" value and not included. Chosen 54 * because this is a "nice round number" and has historical significance for people that deal 55 * with computer time. There is no particular reason to choose this over another time; any 56 * future time after the last time we expect the code to reasonably encounter will do. 57 */ 58 public static final Instant ZONE_USAGE_NOT_AFTER_CUT_OFF = 59 Instant.ofEpochSecond(Integer.MAX_VALUE); 60 61 /** 62 * The end time (exclusive) for calculating country zone usage. The time zone periods are 63 * calculated to this point. The main requirement is that it's after 64 * {@link #ZONE_USAGE_NOT_AFTER_CUT_OFF} by an amount larger than the usual daylight savings 65 * period; here we use 2 years. 66 */ 67 public static final Instant ZONE_USAGE_CALCS_END = 68 ZONE_USAGE_NOT_AFTER_CUT_OFF.plus(2 * 365, ChronoUnit.DAYS); 69 70 private final String countryZonesFile; 71 private final String zoneTabFile; 72 private final String outputFile; 73 74 /** 75 * Executes the generator. 76 * 77 * Positional arguments: 78 * 1: The countryzones.txt file 79 * 2: the zone.tab file 80 * 3: the file to generate 81 */ main(String[] args)82 public static void main(String[] args) throws Exception { 83 if (args.length != 3) { 84 System.err.println( 85 "usage: java com.android.libcore.timezone.tzlookup.proto.TzLookupGenerator" 86 + " <input proto file> <zone.tab file> <output xml file>"); 87 System.exit(0); 88 } 89 boolean success = new TzLookupGenerator(args[0], args[1], args[2]).execute(); 90 System.exit(success ? 0 : 1); 91 } 92 TzLookupGenerator(String countryZonesFile, String zoneTabFile, String outputFile)93 TzLookupGenerator(String countryZonesFile, String zoneTabFile, String outputFile) { 94 this.countryZonesFile = countryZonesFile; 95 this.zoneTabFile = zoneTabFile; 96 this.outputFile = outputFile; 97 } 98 execute()99 boolean execute() throws IOException { 100 // Parse the countryzones input file. 101 CountryZonesFile.CountryZones countryZonesIn; 102 try { 103 countryZonesIn = CountryZonesFileSupport.parseCountryZonesTextFile(countryZonesFile); 104 } catch (ParseException e) { 105 logError("Unable to parse " + countryZonesFile, e); 106 return false; 107 } 108 109 // Check the countryzones rules version matches the version that ICU is using. 110 String icuTzDataVersion = TimeZone.getTZDataVersion(); 111 String inputIanaVersion = countryZonesIn.getIanaVersion(); 112 if (!icuTzDataVersion.equals(inputIanaVersion)) { 113 logError("Input data is for " + inputIanaVersion + " but the ICU you have is for " 114 + icuTzDataVersion); 115 return false; 116 } 117 118 // Pull out information we want to validate against from zone.tab (which we have to assume 119 // matches the ICU version since it doesn't contain its own version info). 120 ZoneTabFile zoneTabIn = ZoneTabFile.parse(zoneTabFile); 121 Map<String, List<String>> zoneTabMapping = 122 ZoneTabFile.createCountryToOlsonIdsMap(zoneTabIn); 123 List<CountryZonesFile.Country> countriesIn = countryZonesIn.getCountriesList(); 124 List<String> countriesInIsos = CountryZonesFileSupport.extractIsoCodes(countriesIn); 125 126 // Sanity check the countryzones file only contains lower-case country codes. The output 127 // file uses them and the on-device code assumes lower case. 128 if (!Utils.allLowerCaseAscii(countriesInIsos)) { 129 logError("Non-lowercase country ISO codes found in: " + countriesInIsos); 130 return false; 131 } 132 // Sanity check the countryzones file doesn't contain duplicate country entries. 133 if (!Utils.allUnique(countriesInIsos)) { 134 logError("Duplicate input country entries found: " + countriesInIsos); 135 return false; 136 } 137 138 // Validate the country iso codes found in the countryzones against those in zone.tab. 139 // zone.tab uses upper case, countryzones uses lower case. 140 List<String> upperCaseCountriesInIsos = Utils.toUpperCase(countriesInIsos); 141 Set<String> timezonesCountryIsos = new HashSet<>(upperCaseCountriesInIsos); 142 Set<String> zoneTabCountryIsos = zoneTabMapping.keySet(); 143 if (!zoneTabCountryIsos.equals(timezonesCountryIsos)) { 144 logError(zoneTabFile + " contains " 145 + Utils.subtract(zoneTabCountryIsos, timezonesCountryIsos) 146 + " not present in countryzones, " 147 + countryZonesFile + " contains " 148 + Utils.subtract(timezonesCountryIsos, zoneTabCountryIsos) 149 + " not present in zonetab."); 150 return false; 151 } 152 153 Errors processingErrors = new Errors(); 154 TzLookupFile.TimeZones timeZonesOut = createOutputTimeZones( 155 inputIanaVersion, zoneTabMapping, countriesIn, processingErrors); 156 if (!processingErrors.hasError()) { 157 // Write the output structure if there wasn't an error. 158 logInfo("Writing " + outputFile); 159 try { 160 TzLookupFile.write(timeZonesOut, outputFile); 161 } catch (XMLStreamException e) { 162 e.printStackTrace(System.err); 163 processingErrors.addFatal("Unable to write output file"); 164 } 165 } 166 167 // Report all warnings / errors 168 if (!processingErrors.isEmpty()) { 169 logInfo("Issues:\n" + processingErrors.asString()); 170 } 171 172 return !processingErrors.hasError(); 173 } 174 createOutputTimeZones(String inputIanaVersion, Map<String, List<String>> zoneTabMapping, List<CountryZonesFile.Country> countriesIn, Errors processingErrors)175 private static TzLookupFile.TimeZones createOutputTimeZones(String inputIanaVersion, 176 Map<String, List<String>> zoneTabMapping, List<CountryZonesFile.Country> countriesIn, 177 Errors processingErrors) { 178 // Start constructing the output structure. 179 TzLookupFile.TimeZones timeZonesOut = new TzLookupFile.TimeZones(inputIanaVersion); 180 TzLookupFile.CountryZones countryZonesOut = new TzLookupFile.CountryZones(); 181 timeZonesOut.setCountryZones(countryZonesOut); 182 183 // The time use when sampling the offsets for a zone. 184 final long offsetSampleTimeMillis = getSampleOffsetTimeMillisForData(inputIanaVersion); 185 186 // The start time to use when working out whether a zone has used UTC. 187 // We don't care about historical use of UTC (e.g. parts of Europe like France prior 188 // to WW2) so we start looking at the beginning of "this year". 189 long everUseUtcStartTimeMillis = getYearStartTimeMillisForData(inputIanaVersion); 190 191 // Process each Country. 192 for (CountryZonesFile.Country countryIn : countriesIn) { 193 String isoCode = countryIn.getIsoCode(); 194 List<String> zoneTabCountryTimeZoneIds = zoneTabMapping.get(isoCode.toUpperCase()); 195 if (zoneTabCountryTimeZoneIds == null) { 196 processingErrors.addError("Country=" + isoCode + " missing from zone.tab"); 197 // No point in continuing. 198 continue; 199 } 200 201 TzLookupFile.Country countryOut = processCountry( 202 offsetSampleTimeMillis, everUseUtcStartTimeMillis, countryIn, 203 zoneTabCountryTimeZoneIds, processingErrors); 204 if (processingErrors.hasFatal()) { 205 // Stop if there's a fatal error, continue processing countries if there are just 206 // errors. 207 break; 208 } else if (countryOut == null) { 209 continue; 210 } 211 countryZonesOut.addCountry(countryOut); 212 } 213 return timeZonesOut; 214 } 215 processCountry(long offsetSampleTimeMillis, long everUseUtcStartTimeMillis, CountryZonesFile.Country countryIn, List<String> zoneTabCountryTimeZoneIds, Errors processingErrors)216 private static TzLookupFile.Country processCountry(long offsetSampleTimeMillis, 217 long everUseUtcStartTimeMillis, CountryZonesFile.Country countryIn, 218 List<String> zoneTabCountryTimeZoneIds, 219 Errors processingErrors) { 220 String isoCode = countryIn.getIsoCode(); 221 processingErrors.pushScope("country=" + isoCode); 222 try { 223 // Each Country must have >= 1 time zone. 224 List<CountryZonesFile.TimeZoneMapping> timeZonesIn = 225 countryIn.getTimeZoneMappingsList(); 226 if (timeZonesIn.isEmpty()) { 227 processingErrors.addError("No time zones"); 228 // No point in continuing. 229 return null; 230 } 231 232 // Look for duplicate time zone IDs. 233 List<String> countryTimeZoneIds = CountryZonesFileSupport.extractIds(timeZonesIn); 234 if (!Utils.allUnique(countryTimeZoneIds)) { 235 processingErrors.addError("country's zones=" + countryTimeZoneIds 236 + " contains duplicates"); 237 // No point in continuing. 238 return null; 239 } 240 241 // Each Country needs a default time zone ID (but we can guess in some cases). 242 String defaultTimeZoneId = determineCountryDefaultZoneId(countryIn, processingErrors); 243 if (defaultTimeZoneId == null) { 244 // No point in continuing. 245 return null; 246 } 247 248 // Validate the default. 249 if (!countryTimeZoneIds.contains(defaultTimeZoneId)) { 250 processingErrors.addError("defaultTimeZoneId=" + defaultTimeZoneId 251 + " is not one of the country's zones=" + countryTimeZoneIds); 252 // No point in continuing. 253 return null; 254 } 255 256 // Validate the other zone IDs. 257 try { 258 processingErrors.pushScope("validate country zone ids"); 259 boolean errors = false; 260 for (String countryTimeZoneId : countryTimeZoneIds) { 261 if (invalidTimeZoneId(countryTimeZoneId)) { 262 processingErrors.addError("countryTimeZoneId=" + countryTimeZoneId 263 + " is not a valid zone ID"); 264 errors = true; 265 } 266 } 267 if (errors) { 268 // No point in continuing. 269 return null; 270 } 271 } finally { 272 processingErrors.popScope(); 273 } 274 275 // Work out the hint for whether the country uses a zero offset from UTC. 276 boolean everUsesUtc = anyZonesUseUtc(countryTimeZoneIds, everUseUtcStartTimeMillis); 277 278 // Validate the country information against the equivalent information in zone.tab. 279 processingErrors.pushScope("zone.tab comparison"); 280 try { 281 // Look for unexpected duplicate time zone IDs in zone.tab 282 if (!Utils.allUnique(zoneTabCountryTimeZoneIds)) { 283 processingErrors.addError( 284 "Duplicate time zone IDs found:" + zoneTabCountryTimeZoneIds); 285 // No point in continuing. 286 return null; 287 288 } 289 290 if (!Utils.setEquals(zoneTabCountryTimeZoneIds, countryTimeZoneIds)) { 291 processingErrors.addError("IANA lists " + isoCode 292 + " as having zones: " + zoneTabCountryTimeZoneIds 293 + ", but countryzones has " + countryTimeZoneIds); 294 // No point in continuing. 295 return null; 296 } 297 } finally { 298 processingErrors.popScope(); 299 } 300 301 // Calculate countryZoneUsage. 302 CountryZoneUsage countryZoneUsage = 303 calculateCountryZoneUsage(countryIn, processingErrors); 304 if (countryZoneUsage == null) { 305 // No point in continuing with this country. 306 return null; 307 } 308 309 // Add the country to the output structure. 310 TzLookupFile.Country countryOut = 311 new TzLookupFile.Country(isoCode, defaultTimeZoneId, everUsesUtc); 312 313 // Process each input time zone. 314 for (CountryZonesFile.TimeZoneMapping timeZoneIn : timeZonesIn) { 315 processingErrors.pushScope( 316 "id=" + timeZoneIn.getId() + ", offset=" + timeZoneIn.getUtcOffset() 317 + ", shownInPicker=" + timeZoneIn.getShownInPicker()); 318 try { 319 // Validate the offset information in countryIn. 320 validateNonDstOffset(offsetSampleTimeMillis, countryIn, timeZoneIn, 321 processingErrors); 322 323 String timeZoneInId = timeZoneIn.getId(); 324 boolean shownInPicker = timeZoneIn.getShownInPicker(); 325 if (!countryZoneUsage.hasEntry(timeZoneInId)) { 326 // This implies a programming error. 327 processingErrors.addFatal( 328 "No entry in CountryZoneUsage for " + timeZoneInId); 329 return null; 330 } 331 332 // The notUsedAfterInstant can be null if the zone is used until at least 333 // ZONE_CALCS_END_INSTANT. That's what we want. 334 Instant notUsedAfterInstant = 335 countryZoneUsage.getNotUsedAfterInstant(timeZoneInId); 336 337 // Add the id mapping and associated metadata. 338 TzLookupFile.TimeZoneMapping timeZoneIdOut = 339 new TzLookupFile.TimeZoneMapping( 340 timeZoneInId, shownInPicker, notUsedAfterInstant); 341 countryOut.addTimeZoneIdentifier(timeZoneIdOut); 342 } finally { 343 processingErrors.popScope(); 344 } 345 } 346 return countryOut; 347 } finally{ 348 // End of country processing. 349 processingErrors.popScope(); 350 } 351 } 352 353 /** 354 * Determines the default zone ID for the country. 355 */ determineCountryDefaultZoneId( CountryZonesFile.Country countryIn, Errors processingErrorsOut)356 private static String determineCountryDefaultZoneId( 357 CountryZonesFile.Country countryIn, Errors processingErrorsOut) { 358 List<CountryZonesFile.TimeZoneMapping> timeZonesIn = countryIn.getTimeZoneMappingsList(); 359 String defaultTimeZoneId; 360 if (countryIn.hasDefaultTimeZoneId()) { 361 defaultTimeZoneId = countryIn.getDefaultTimeZoneId(); 362 if (invalidTimeZoneId(defaultTimeZoneId)) { 363 processingErrorsOut.addError( 364 "Default time zone ID " + defaultTimeZoneId + " is not valid"); 365 // No point in continuing. 366 return null; 367 } 368 } else { 369 if (timeZonesIn.size() > 1) { 370 processingErrorsOut.addError( 371 "To pick a default time zone there must be a single offset group"); 372 // No point in continuing. 373 return null; 374 } 375 defaultTimeZoneId = timeZonesIn.get(0).getId(); 376 } 377 return defaultTimeZoneId; 378 } 379 380 /** 381 * Returns true if any of the zones use UTC after the time specified. 382 */ anyZonesUseUtc(List<String> timeZoneIds, long startTimeMillis)383 private static boolean anyZonesUseUtc(List<String> timeZoneIds, long startTimeMillis) { 384 for (String timeZoneId : timeZoneIds) { 385 BasicTimeZone timeZone = (BasicTimeZone) TimeZone.getTimeZone(timeZoneId); 386 TimeZoneRule[] rules = timeZone.getTimeZoneRules(startTimeMillis); 387 for (TimeZoneRule rule : rules) { 388 int utcOffset = rule.getRawOffset() + rule.getDSTSavings(); 389 if (utcOffset == 0) { 390 return true; 391 } 392 } 393 } 394 return false; 395 } 396 397 /** 398 * Returns a sample time related to the IANA version to enable any offset validation to be 399 * repeatable (rather than depending on the current time when the tool is run). 400 */ getSampleOffsetTimeMillisForData(String inputIanaVersion)401 private static long getSampleOffsetTimeMillisForData(String inputIanaVersion) { 402 // Uses <year>/07/02 12:00:00 UTC, where year is taken from the IANA version + 1. 403 // This is fairly arbitrary, but reflects the fact that we want a point in the future 404 // WRT to the data, and once a year has been picked then half-way through seems about right. 405 Calendar calendar = getYearStartForData(inputIanaVersion); 406 calendar.set(calendar.get(Calendar.YEAR) + 1, Calendar.JULY, 2, 12, 0, 0); 407 return calendar.getTimeInMillis(); 408 } 409 410 /** 411 * Returns the 1st Jan 00:00:00 UTC time on the year the IANA version relates to. Therefore 412 * guaranteed to be before the data is ever used and can be treated as "the beginning of time" 413 * (assuming derived information won't be used for historical calculations). 414 */ getYearStartTimeMillisForData(String inputIanaVersion)415 private static long getYearStartTimeMillisForData(String inputIanaVersion) { 416 return getYearStartForData(inputIanaVersion).getTimeInMillis(); 417 } 418 getYearStartForData(String inputIanaVersion)419 private static Calendar getYearStartForData(String inputIanaVersion) { 420 String yearString = inputIanaVersion.substring(0, inputIanaVersion.length() - 1); 421 int year = Integer.parseInt(yearString); 422 Calendar calendar = new GregorianCalendar(TimeZone.GMT_ZONE); 423 calendar.clear(); 424 calendar.set(year, Calendar.JANUARY, 1, 0, 0, 0); 425 return calendar; 426 } 427 invalidTimeZoneId(String timeZoneId)428 private static boolean invalidTimeZoneId(String timeZoneId) { 429 TimeZone zone = TimeZone.getTimeZone(timeZoneId); 430 return !(zone instanceof BasicTimeZone) || zone.getID().equals(TimeZone.UNKNOWN_ZONE_ID); 431 } 432 validateNonDstOffset(long offsetSampleTimeMillis, CountryZonesFile.Country country, CountryZonesFile.TimeZoneMapping timeZoneIn, Errors errors)433 private static void validateNonDstOffset(long offsetSampleTimeMillis, 434 CountryZonesFile.Country country, CountryZonesFile.TimeZoneMapping timeZoneIn, 435 Errors errors) { 436 String utcOffsetString = timeZoneIn.getUtcOffset(); 437 long utcOffsetMillis; 438 try { 439 utcOffsetMillis = Utils.parseUtcOffsetToMillis(utcOffsetString); 440 } catch (ParseException e) { 441 errors.addFatal("Bad offset string: " + utcOffsetString); 442 return; 443 } 444 445 final long minimumGranularity = TimeUnit.MINUTES.toMillis(15); 446 if (utcOffsetMillis % minimumGranularity != 0) { 447 errors.addWarning( 448 "Unexpected granularity: not a multiple of 15 minutes: " + utcOffsetString); 449 } 450 451 String timeZoneIdIn = timeZoneIn.getId(); 452 if (invalidTimeZoneId(timeZoneIdIn)) { 453 errors.addFatal("Time zone ID=" + timeZoneIdIn + " is not valid"); 454 return; 455 } 456 457 // Check the offset Android has matches what ICU thinks. 458 TimeZone timeZone = TimeZone.getTimeZone(timeZoneIdIn); 459 int[] offsets = new int[2]; 460 timeZone.getOffset(offsetSampleTimeMillis, false /* local */, offsets); 461 int actualOffsetMillis = offsets[0]; 462 if (actualOffsetMillis != utcOffsetMillis) { 463 errors.addFatal("Offset mismatch: You will want to confirm the ordering for " 464 + country.getIsoCode() + " still makes sense. Raw offset for " 465 + timeZoneIdIn + " is " + Utils.toUtcOffsetString(actualOffsetMillis) 466 + " and not " + Utils.toUtcOffsetString(utcOffsetMillis) 467 + " at " + Utils.formatUtc(offsetSampleTimeMillis)); 468 } 469 } 470 calculateCountryZoneUsage( CountryZonesFile.Country countryIn, Errors processingErrors)471 private static CountryZoneUsage calculateCountryZoneUsage( 472 CountryZonesFile.Country countryIn, Errors processingErrors) { 473 processingErrors.pushScope("Building zone tree"); 474 try { 475 CountryZoneTree countryZoneTree = CountryZoneTree.create( 476 countryIn, ZONE_USAGE_CALCS_START, ZONE_USAGE_CALCS_END); 477 List<String> countryIssues = countryZoneTree.validateNoPriorityClashes(); 478 if (!countryIssues.isEmpty()) { 479 processingErrors 480 .addError("Issues validating country zone trees. Adjust priorities:"); 481 countryIssues.forEach(processingErrors::addError); 482 return null; 483 } 484 return countryZoneTree.calculateCountryZoneUsage(ZONE_USAGE_NOT_AFTER_CUT_OFF); 485 } finally { 486 processingErrors.popScope(); 487 } 488 } 489 logError(String msg)490 private static void logError(String msg) { 491 System.err.println("E: " + msg); 492 } 493 logError(String s, Throwable e)494 private static void logError(String s, Throwable e) { 495 logError(s); 496 e.printStackTrace(System.err); 497 } 498 logInfo(String msg)499 private static void logInfo(String msg) { 500 System.err.println("I: " + msg); 501 } 502 } 503