• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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