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