Home | History | Annotate | Download | only in util
      1 /*
      2  * Copyright (C) 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package libcore.util;
     18 
     19 import org.xmlpull.v1.XmlPullParser;
     20 import org.xmlpull.v1.XmlPullParserException;
     21 import org.xmlpull.v1.XmlPullParserFactory;
     22 
     23 import android.icu.util.TimeZone;
     24 
     25 import java.io.FileNotFoundException;
     26 import java.io.IOException;
     27 import java.io.Reader;
     28 import java.io.StringReader;
     29 import java.nio.charset.Charset;
     30 import java.nio.charset.StandardCharsets;
     31 import java.nio.file.Files;
     32 import java.nio.file.Path;
     33 import java.nio.file.Paths;
     34 import java.util.ArrayList;
     35 import java.util.Arrays;
     36 import java.util.Collections;
     37 import java.util.HashSet;
     38 import java.util.List;
     39 import java.util.Locale;
     40 import java.util.Set;
     41 import libcore.util.CountryTimeZones.TimeZoneMapping;
     42 
     43 /**
     44  * A class that can find matching time zones by loading data from the tzlookup.xml file.
     45  */
     46 public final class TimeZoneFinder {
     47 
     48     private static final String TZLOOKUP_FILE_NAME = "tzlookup.xml";
     49 
     50     // Root element. e.g. <timezones ianaversion="2017b">
     51     private static final String TIMEZONES_ELEMENT = "timezones";
     52     private static final String IANA_VERSION_ATTRIBUTE = "ianaversion";
     53 
     54     // Country zones section. e.g. <countryzones>
     55     private static final String COUNTRY_ZONES_ELEMENT = "countryzones";
     56 
     57     // Country data. e.g. <country code="gb" default="Europe/London" everutc="y">
     58     private static final String COUNTRY_ELEMENT = "country";
     59     private static final String COUNTRY_CODE_ATTRIBUTE = "code";
     60     private static final String DEFAULT_TIME_ZONE_ID_ATTRIBUTE = "default";
     61     private static final String EVER_USES_UTC_ATTRIBUTE = "everutc";
     62 
     63     // Country -> Time zone mapping. e.g. <id>ZoneId</id>, <id picker="n">ZoneId</id>,
     64     // <id notafter={timestamp}>ZoneId</id>
     65     // The default for the picker attribute when unspecified is "y".
     66     // The notafter attribute is optional. It specifies a timestamp (time in milliseconds from Unix
     67     // epoch start) after which the zone is not (effectively) in use. If unspecified the zone is in
     68     // use forever.
     69     private static final String ZONE_ID_ELEMENT = "id";
     70     private static final String ZONE_SHOW_IN_PICKER_ATTRIBUTE = "picker";
     71     private static final String ZONE_NOT_USED_AFTER_ATTRIBUTE = "notafter";
     72 
     73     private static final String TRUE_ATTRIBUTE_VALUE = "y";
     74     private static final String FALSE_ATTRIBUTE_VALUE = "n";
     75 
     76     private static TimeZoneFinder instance;
     77 
     78     private final ReaderSupplier xmlSource;
     79 
     80     // Cached field for the last country looked up.
     81     private CountryTimeZones lastCountryTimeZones;
     82 
     83     private TimeZoneFinder(ReaderSupplier xmlSource) {
     84         this.xmlSource = xmlSource;
     85     }
     86 
     87     /**
     88      * Obtains an instance for use when resolving time zones. This method handles using the correct
     89      * file when there are several to choose from. This method never returns {@code null}. No
     90      * in-depth validation is performed on the file content, see {@link #validate()}.
     91      */
     92     public static TimeZoneFinder getInstance() {
     93         synchronized(TimeZoneFinder.class) {
     94             if (instance == null) {
     95                 String[] tzLookupFilePaths =
     96                         TimeZoneDataFiles.getTimeZoneFilePaths(TZLOOKUP_FILE_NAME);
     97                 instance = createInstanceWithFallback(tzLookupFilePaths[0], tzLookupFilePaths[1]);
     98             }
     99         }
    100         return instance;
    101     }
    102 
    103     // VisibleForTesting
    104     public static TimeZoneFinder createInstanceWithFallback(String... tzLookupFilePaths) {
    105         IOException lastException = null;
    106         for (String tzLookupFilePath : tzLookupFilePaths) {
    107             try {
    108                 // We assume that any file in /data was validated before install, and the system
    109                 // file was validated before the device shipped. Therefore, we do not pay the
    110                 // validation cost here.
    111                 return createInstance(tzLookupFilePath);
    112             } catch (IOException e) {
    113                 // There's expected to be two files, and it's normal for the first file not to
    114                 // exist so we don't log, but keep the lastException so we can log it if there
    115                 // are no valid files available.
    116                 if (lastException != null) {
    117                     e.addSuppressed(lastException);
    118                 }
    119                 lastException = e;
    120             }
    121         }
    122 
    123         System.logE("No valid file found in set: " + Arrays.toString(tzLookupFilePaths)
    124                 + " Printing exceptions and falling back to empty map.", lastException);
    125         return createInstanceForTests("<timezones><countryzones /></timezones>");
    126     }
    127 
    128     /**
    129      * Obtains an instance using a specific data file, throwing an IOException if the file does not
    130      * exist or is not readable. This method never returns {@code null}. No in-depth validation is
    131      * performed on the file content, see {@link #validate()}.
    132      */
    133     public static TimeZoneFinder createInstance(String path) throws IOException {
    134         ReaderSupplier xmlSupplier = ReaderSupplier.forFile(path, StandardCharsets.UTF_8);
    135         return new TimeZoneFinder(xmlSupplier);
    136     }
    137 
    138     /** Used to create an instance using an in-memory XML String instead of a file. */
    139     // VisibleForTesting
    140     public static TimeZoneFinder createInstanceForTests(String xml) {
    141         return new TimeZoneFinder(ReaderSupplier.forString(xml));
    142     }
    143 
    144     /**
    145      * Parses the data file, throws an exception if it is invalid or cannot be read.
    146      */
    147     public void validate() throws IOException {
    148         try {
    149             processXml(new TimeZonesValidator());
    150         } catch (XmlPullParserException e) {
    151             throw new IOException("Parsing error", e);
    152         }
    153     }
    154 
    155     /**
    156      * Returns the IANA rules version associated with the data. If there is no version information
    157      * or there is a problem reading the file then {@code null} is returned.
    158      */
    159     public String getIanaVersion() {
    160         IanaVersionExtractor ianaVersionExtractor = new IanaVersionExtractor();
    161         try {
    162             processXml(ianaVersionExtractor);
    163             return ianaVersionExtractor.getIanaVersion();
    164         } catch (XmlPullParserException | IOException e) {
    165             return null;
    166         }
    167     }
    168 
    169     /**
    170      * Loads all the country &lt;-&gt; time zone mapping data into memory. This method can return
    171      * {@code null} in the event of an error while reading the underlying data files.
    172      */
    173     public CountryZonesFinder getCountryZonesFinder() {
    174         CountryZonesLookupExtractor extractor = new CountryZonesLookupExtractor();
    175         try {
    176             processXml(extractor);
    177 
    178             return extractor.getCountryZonesLookup();
    179         } catch (XmlPullParserException | IOException e) {
    180             System.logW("Error reading country zones ", e);
    181             return null;
    182         }
    183     }
    184 
    185     /**
    186      * Returns a frozen ICU time zone that has / would have had the specified offset and DST value
    187      * at the specified moment in the specified country.
    188      *
    189      * <p>In order to be considered a configured zone must match the supplied offset information.
    190      *
    191      * <p>Matches are considered in a well-defined order. If multiple zones match and one of them
    192      * also matches the (optional) bias parameter then the bias time zone will be returned.
    193      * Otherwise the first match found is returned.
    194      */
    195     public TimeZone lookupTimeZoneByCountryAndOffset(
    196             String countryIso, int offsetMillis, boolean isDst, long whenMillis, TimeZone bias) {
    197 
    198         CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
    199         if (countryTimeZones == null) {
    200             return null;
    201         }
    202         CountryTimeZones.OffsetResult offsetResult =
    203                 countryTimeZones.lookupByOffsetWithBias(offsetMillis, isDst, whenMillis, bias);
    204         return offsetResult != null ? offsetResult.mTimeZone : null;
    205     }
    206 
    207     /**
    208      * Returns a "default" time zone ID known to be used in the specified country. This is
    209      * the time zone ID that can be used if only the country code is known and can be presumed to be
    210      * the "best" choice in the absence of other information. For countries with more than one zone
    211      * the time zone will not be correct for everybody.
    212      *
    213      * <p>If the country code is not recognized or there is an error during lookup this can return
    214      * null.
    215      */
    216     public String lookupDefaultTimeZoneIdByCountry(String countryIso) {
    217         CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
    218         return countryTimeZones == null ? null : countryTimeZones.getDefaultTimeZoneId();
    219     }
    220 
    221     /**
    222      * Returns an immutable list of frozen ICU time zones known to be used in the specified country.
    223      * If the country code is not recognized or there is an error during lookup this can return
    224      * null. The TimeZones returned will never contain {@link TimeZone#UNKNOWN_ZONE}. This method
    225      * can return an empty list in a case when the underlying data files reference only unknown
    226      * zone IDs.
    227      */
    228     public List<TimeZone> lookupTimeZonesByCountry(String countryIso) {
    229         CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
    230         return countryTimeZones == null ? null : countryTimeZones.getIcuTimeZones();
    231     }
    232 
    233     /**
    234      * Returns an immutable list of time zone IDs known to be used in the specified country.
    235      * If the country code is not recognized or there is an error during lookup this can return
    236      * null. The IDs returned will all be valid for use with
    237      * {@link java.util.TimeZone#getTimeZone(String)} and
    238      * {@link android.icu.util.TimeZone#getTimeZone(String)}. This method can return an empty list
    239      * in a case when the underlying data files reference only unknown zone IDs.
    240      */
    241     public List<String> lookupTimeZoneIdsByCountry(String countryIso) {
    242         CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
    243         return countryTimeZones == null
    244                 ? null : extractTimeZoneIds(countryTimeZones.getTimeZoneMappings());
    245     }
    246 
    247     /**
    248      * Returns a {@link CountryTimeZones} object associated with the specified country code.
    249      * Caching is handled as needed. If the country code is not recognized or there is an error
    250      * during lookup this method can return null.
    251      */
    252     public CountryTimeZones lookupCountryTimeZones(String countryIso) {
    253         synchronized (this) {
    254             if (lastCountryTimeZones != null && lastCountryTimeZones.isForCountryCode(countryIso)) {
    255                 return lastCountryTimeZones;
    256             }
    257         }
    258 
    259         SelectiveCountryTimeZonesExtractor extractor =
    260                 new SelectiveCountryTimeZonesExtractor(countryIso);
    261         try {
    262             processXml(extractor);
    263 
    264             CountryTimeZones countryTimeZones = extractor.getValidatedCountryTimeZones();
    265             if (countryTimeZones == null) {
    266                 // None matched. Return the null but don't change the cached value.
    267                 return null;
    268             }
    269 
    270             // Update the cached value.
    271             synchronized (this) {
    272                 lastCountryTimeZones = countryTimeZones;
    273             }
    274             return countryTimeZones;
    275         } catch (XmlPullParserException | IOException e) {
    276             System.logW("Error reading country zones ", e);
    277 
    278             // Error - don't change the cached value.
    279             return null;
    280         }
    281     }
    282 
    283     /**
    284      * Processes the XML, applying the {@link TimeZonesProcessor} to the &lt;countryzones&gt;
    285      * element. Processing can terminate early if the
    286      * {@link TimeZonesProcessor#processCountryZones(String, String, boolean, List, String)} returns
    287      * {@link TimeZonesProcessor#HALT} or it throws an exception.
    288      */
    289     private void processXml(TimeZonesProcessor processor)
    290             throws XmlPullParserException, IOException {
    291         try (Reader reader = xmlSource.get()) {
    292             XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance();
    293             xmlPullParserFactory.setNamespaceAware(false);
    294 
    295             XmlPullParser parser = xmlPullParserFactory.newPullParser();
    296             parser.setInput(reader);
    297 
    298             /*
    299              * The expected XML structure is:
    300              * <timezones ianaversion="2017b">
    301              *   <countryzones>
    302              *     <country code="us" default="America/New_York">
    303              *       <id>America/New_York"</id>
    304              *       ...
    305              *       <id picker="n">America/Indiana/Vincennes</id>
    306              *       ...
    307              *       <id>America/Los_Angeles</id>
    308              *     </country>
    309              *     <country code="gb" default="Europe/London">
    310              *       <id>Europe/London</id>
    311              *     </country>
    312              *   </countryzones>
    313              * </timezones>
    314              */
    315 
    316             findRequiredStartTag(parser, TIMEZONES_ELEMENT);
    317 
    318             // We do not require the ianaversion attribute be present. It is metadata that helps
    319             // with versioning but is not required.
    320             String ianaVersion = parser.getAttributeValue(
    321                     null /* namespace */, IANA_VERSION_ATTRIBUTE);
    322             if (processor.processHeader(ianaVersion) == TimeZonesProcessor.HALT) {
    323                 return;
    324             }
    325 
    326             // There is only one expected sub-element <countryzones> in the format currently, skip
    327             // over anything before it.
    328             findRequiredStartTag(parser, COUNTRY_ZONES_ELEMENT);
    329 
    330             if (processCountryZones(parser, processor) == TimeZonesProcessor.HALT) {
    331                 return;
    332             }
    333 
    334             // Make sure we are on the </countryzones> tag.
    335             checkOnEndTag(parser, COUNTRY_ZONES_ELEMENT);
    336 
    337             // Advance to the next tag.
    338             parser.next();
    339 
    340             // Skip anything until </timezones>, and make sure the file is not truncated and we can
    341             // find the end.
    342             consumeUntilEndTag(parser, TIMEZONES_ELEMENT);
    343 
    344             // Make sure we are on the </timezones> tag.
    345             checkOnEndTag(parser, TIMEZONES_ELEMENT);
    346         }
    347     }
    348 
    349     private static boolean processCountryZones(XmlPullParser parser,
    350             TimeZonesProcessor processor) throws IOException, XmlPullParserException {
    351 
    352         // Skip over any unexpected elements and process <country> elements.
    353         while (findOptionalStartTag(parser, COUNTRY_ELEMENT)) {
    354             if (processor == null) {
    355                 consumeUntilEndTag(parser, COUNTRY_ELEMENT);
    356             } else {
    357                 String code = parser.getAttributeValue(
    358                         null /* namespace */, COUNTRY_CODE_ATTRIBUTE);
    359                 if (code == null || code.isEmpty()) {
    360                     throw new XmlPullParserException(
    361                             "Unable to find country code: " + parser.getPositionDescription());
    362                 }
    363                 String defaultTimeZoneId = parser.getAttributeValue(
    364                         null /* namespace */, DEFAULT_TIME_ZONE_ID_ATTRIBUTE);
    365                 if (defaultTimeZoneId == null || defaultTimeZoneId.isEmpty()) {
    366                     throw new XmlPullParserException("Unable to find default time zone ID: "
    367                             + parser.getPositionDescription());
    368                 }
    369                 Boolean everUsesUtc = parseBooleanAttribute(
    370                         parser, EVER_USES_UTC_ATTRIBUTE, null /* defaultValue */);
    371                 if (everUsesUtc == null) {
    372                     // There is no valid default: we require this to be specified.
    373                     throw new XmlPullParserException(
    374                             "Unable to find UTC hint attribute (" + EVER_USES_UTC_ATTRIBUTE + "): "
    375                             + parser.getPositionDescription());
    376                 }
    377 
    378                 String debugInfo = parser.getPositionDescription();
    379                 List<TimeZoneMapping> timeZoneMappings = parseTimeZoneMappings(parser);
    380                 boolean result = processor.processCountryZones(code, defaultTimeZoneId, everUsesUtc,
    381                         timeZoneMappings, debugInfo);
    382                 if (result == TimeZonesProcessor.HALT) {
    383                     return TimeZonesProcessor.HALT;
    384                 }
    385             }
    386 
    387             // Make sure we are on the </country> element.
    388             checkOnEndTag(parser, COUNTRY_ELEMENT);
    389         }
    390 
    391         return TimeZonesProcessor.CONTINUE;
    392     }
    393 
    394     private static List<TimeZoneMapping> parseTimeZoneMappings(XmlPullParser parser)
    395             throws IOException, XmlPullParserException {
    396         List<TimeZoneMapping> timeZoneMappings = new ArrayList<>();
    397 
    398         // Skip over any unexpected elements and process <id> elements.
    399         while (findOptionalStartTag(parser, ZONE_ID_ELEMENT)) {
    400             // The picker attribute is optional and defaulted to true.
    401             boolean showInPicker = parseBooleanAttribute(
    402                     parser, ZONE_SHOW_IN_PICKER_ATTRIBUTE, true /* defaultValue */);
    403             Long notUsedAfter = parseLongAttribute(
    404                     parser, ZONE_NOT_USED_AFTER_ATTRIBUTE, null /* defaultValue */);
    405             String zoneIdString = consumeText(parser);
    406 
    407             // Make sure we are on the </id> element.
    408             checkOnEndTag(parser, ZONE_ID_ELEMENT);
    409 
    410             // Process the TimeZoneMapping.
    411             if (zoneIdString == null || zoneIdString.length() == 0) {
    412                 throw new XmlPullParserException("Missing text for " + ZONE_ID_ELEMENT + "): "
    413                         + parser.getPositionDescription());
    414             }
    415 
    416             TimeZoneMapping timeZoneMapping =
    417                     new TimeZoneMapping(zoneIdString, showInPicker, notUsedAfter);
    418             timeZoneMappings.add(timeZoneMapping);
    419         }
    420 
    421         // The list is made unmodifiable to avoid callers changing it.
    422         return Collections.unmodifiableList(timeZoneMappings);
    423     }
    424 
    425     /**
    426      * Parses an attribute value, which must be either {@code null} or a valid signed long value.
    427      * If the attribute value is {@code null} then {@code defaultValue} is returned. If the
    428      * attribute is present but not a valid long value then an XmlPullParserException is thrown.
    429      */
    430     private static Long parseLongAttribute(XmlPullParser parser, String attributeName,
    431             Long defaultValue) throws XmlPullParserException {
    432         String attributeValueString = parser.getAttributeValue(null /* namespace */, attributeName);
    433         if (attributeValueString == null) {
    434             return defaultValue;
    435         }
    436         try {
    437             return Long.parseLong(attributeValueString);
    438         } catch (NumberFormatException e) {
    439             throw new XmlPullParserException("Attribute \"" + attributeName
    440                     + "\" is not a long value: " + parser.getPositionDescription());
    441         }
    442     }
    443 
    444     /**
    445      * Parses an attribute value, which must be either {@code null}, {@code "y"} or {@code "n"}.
    446      * If the attribute value is {@code null} then {@code defaultValue} is returned. If the
    447      * attribute is present but not "y" or "n" then an XmlPullParserException is thrown.
    448      */
    449     private static Boolean parseBooleanAttribute(XmlPullParser parser,
    450             String attributeName, Boolean defaultValue) throws XmlPullParserException {
    451         String attributeValueString = parser.getAttributeValue(null /* namespace */, attributeName);
    452         if (attributeValueString == null) {
    453             return defaultValue;
    454         }
    455         boolean isTrue = TRUE_ATTRIBUTE_VALUE.equals(attributeValueString);
    456         if (!(isTrue || FALSE_ATTRIBUTE_VALUE.equals(attributeValueString))) {
    457             throw new XmlPullParserException("Attribute \"" + attributeName
    458                     + "\" is not \"y\" or \"n\": " + parser.getPositionDescription());
    459         }
    460         return isTrue;
    461     }
    462 
    463     private static void findRequiredStartTag(XmlPullParser parser, String elementName)
    464             throws IOException, XmlPullParserException {
    465         findStartTag(parser, elementName, true /* elementRequired */);
    466     }
    467 
    468     /** Called when on a START_TAG. When returning false, it leaves the parser on the END_TAG. */
    469     private static boolean findOptionalStartTag(XmlPullParser parser, String elementName)
    470             throws IOException, XmlPullParserException {
    471         return findStartTag(parser, elementName, false /* elementRequired */);
    472     }
    473 
    474     /**
    475      * Find a START_TAG with the specified name without decreasing the depth, or increasing the
    476      * depth by more than one. More deeply nested elements and text are skipped, even START_TAGs
    477      * with matching names. Returns when the START_TAG is found or the next (non-nested) END_TAG is
    478      * encountered. The return can take the form of an exception or a false if the START_TAG is not
    479      * found. True is returned when it is.
    480      */
    481     private static boolean findStartTag(
    482             XmlPullParser parser, String elementName, boolean elementRequired)
    483             throws IOException, XmlPullParserException {
    484 
    485         int type;
    486         while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
    487             switch (type) {
    488                 case XmlPullParser.START_TAG:
    489                     String currentElementName = parser.getName();
    490                     if (elementName.equals(currentElementName)) {
    491                         return true;
    492                     }
    493 
    494                     // It was not the START_TAG we were looking for. Consume until the end.
    495                     parser.next();
    496                     consumeUntilEndTag(parser, currentElementName);
    497                     break;
    498                 case XmlPullParser.END_TAG:
    499                     if (elementRequired) {
    500                         throw new XmlPullParserException(
    501                                 "No child element found with name " + elementName);
    502                     }
    503                     return false;
    504                 default:
    505                     // Ignore.
    506                     break;
    507             }
    508         }
    509         throw new XmlPullParserException("Unexpected end of document while looking for "
    510                 + elementName);
    511     }
    512 
    513     /**
    514      * Consume the remaining contents of an element and move to the END_TAG. Used when processing
    515      * within an element can stop. The parser must be pointing at either the END_TAG we are looking
    516      * for, a TEXT, or a START_TAG nested within the element to be consumed.
    517      */
    518     private static void consumeUntilEndTag(XmlPullParser parser, String elementName)
    519             throws IOException, XmlPullParserException {
    520 
    521         if (parser.getEventType() == XmlPullParser.END_TAG
    522                 && elementName.equals(parser.getName())) {
    523             // Early return - we are already there.
    524             return;
    525         }
    526 
    527         // Keep track of the required depth in case there are nested elements to be consumed.
    528         // Both the name and the depth must match our expectation to complete.
    529 
    530         int requiredDepth = parser.getDepth();
    531         // A TEXT tag would be at the same depth as the END_TAG we are looking for.
    532         if (parser.getEventType() == XmlPullParser.START_TAG) {
    533             // A START_TAG would have incremented the depth, so we're looking for an END_TAG one
    534             // higher than the current tag.
    535             requiredDepth--;
    536         }
    537 
    538         while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
    539             int type = parser.next();
    540 
    541             int currentDepth = parser.getDepth();
    542             if (currentDepth < requiredDepth) {
    543                 throw new XmlPullParserException(
    544                         "Unexpected depth while looking for end tag: "
    545                                 + parser.getPositionDescription());
    546             } else if (currentDepth == requiredDepth) {
    547                 if (type == XmlPullParser.END_TAG) {
    548                     if (elementName.equals(parser.getName())) {
    549                         return;
    550                     }
    551                     throw new XmlPullParserException(
    552                             "Unexpected eng tag: " + parser.getPositionDescription());
    553                 }
    554             }
    555             // Everything else is either a type we are not interested in or is too deep and so is
    556             // ignored.
    557         }
    558         throw new XmlPullParserException("Unexpected end of document");
    559     }
    560 
    561     /**
    562      * Reads the text inside the current element. Should be called when the parser is currently
    563      * on the START_TAG before the TEXT. The parser will be positioned on the END_TAG after this
    564      * call when it completes successfully.
    565      */
    566     private static String consumeText(XmlPullParser parser)
    567             throws IOException, XmlPullParserException {
    568 
    569         int type = parser.next();
    570         String text;
    571         if (type == XmlPullParser.TEXT) {
    572             text = parser.getText();
    573         } else {
    574             throw new XmlPullParserException("Text not found. Found type=" + type
    575                     + " at " + parser.getPositionDescription());
    576         }
    577 
    578         type = parser.next();
    579         if (type != XmlPullParser.END_TAG) {
    580             throw new XmlPullParserException(
    581                     "Unexpected nested tag or end of document when expecting text: type=" + type
    582                             + " at " + parser.getPositionDescription());
    583         }
    584         return text;
    585     }
    586 
    587     private static void checkOnEndTag(XmlPullParser parser, String elementName)
    588             throws XmlPullParserException {
    589         if (!(parser.getEventType() == XmlPullParser.END_TAG
    590                 && parser.getName().equals(elementName))) {
    591             throw new XmlPullParserException(
    592                     "Unexpected tag encountered: " + parser.getPositionDescription());
    593         }
    594     }
    595 
    596     /**
    597      * Processes &lt;timezones&gt; data.
    598      */
    599     private interface TimeZonesProcessor {
    600 
    601         boolean CONTINUE = true;
    602         boolean HALT = false;
    603 
    604         /**
    605          * Return {@link #CONTINUE} if processing of the XML should continue, {@link #HALT} if it
    606          * should stop (but without considering this an error). Problems with the data are
    607          * reported as an exception.
    608          *
    609          * <p>The default implementation returns {@link #CONTINUE}.
    610          */
    611         default boolean processHeader(String ianaVersion) throws XmlPullParserException {
    612             return CONTINUE;
    613         }
    614 
    615         /**
    616          * Returns {@link #CONTINUE} if processing of the XML should continue, {@link #HALT} if it
    617          * should stop (but without considering this an error). Problems with the data are
    618          * reported as an exception.
    619          *
    620          * <p>The default implementation returns {@link #CONTINUE}.
    621          */
    622         default boolean processCountryZones(String countryIso, String defaultTimeZoneId,
    623                 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo)
    624                 throws XmlPullParserException {
    625             return CONTINUE;
    626         }
    627     }
    628 
    629     /**
    630      * Validates &lt;countryzones&gt; elements. Intended to be used before a proposed installation
    631      * of new data. To be valid the country ISO code must be normalized, unique, the default time
    632      * zone ID must be one of the time zones IDs and the time zone IDs list must not be empty. The
    633      * IDs themselves are not checked against other data to see if they are recognized because other
    634      * classes will not have been updated with the associated new time zone data yet and so will not
    635      * be aware of newly added IDs.
    636      */
    637     private static class TimeZonesValidator implements TimeZonesProcessor {
    638 
    639         private final Set<String> knownCountryCodes = new HashSet<>();
    640 
    641         @Override
    642         public boolean processCountryZones(String countryIso, String defaultTimeZoneId,
    643                 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo)
    644                 throws XmlPullParserException {
    645             if (!normalizeCountryIso(countryIso).equals(countryIso)) {
    646                 throw new XmlPullParserException("Country code: " + countryIso
    647                         + " is not normalized at " + debugInfo);
    648             }
    649             if (knownCountryCodes.contains(countryIso)) {
    650                 throw new XmlPullParserException("Second entry for country code: " + countryIso
    651                         + " at " + debugInfo);
    652             }
    653             if (timeZoneMappings.isEmpty()) {
    654                 throw new XmlPullParserException("No time zone IDs for country code: " + countryIso
    655                         + " at " + debugInfo);
    656             }
    657             if (!TimeZoneMapping.containsTimeZoneId(timeZoneMappings, defaultTimeZoneId)) {
    658                 throw new XmlPullParserException("defaultTimeZoneId for country code: "
    659                         + countryIso + " is not one of the zones " + timeZoneMappings + " at "
    660                         + debugInfo);
    661             }
    662             knownCountryCodes.add(countryIso);
    663 
    664             return CONTINUE;
    665         }
    666     }
    667 
    668     /**
    669      * Reads just the IANA version from the file header. The version is then available via
    670      * {@link #getIanaVersion()}.
    671      */
    672     private static class IanaVersionExtractor implements TimeZonesProcessor {
    673 
    674         private String ianaVersion;
    675 
    676         @Override
    677         public boolean processHeader(String ianaVersion) throws XmlPullParserException {
    678             this.ianaVersion = ianaVersion;
    679             return HALT;
    680         }
    681 
    682         public String getIanaVersion() {
    683             return ianaVersion;
    684         }
    685     }
    686 
    687     /**
    688      * Reads all country time zone information into memory and makes it available as a
    689      * {@link CountryZonesFinder}.
    690      */
    691     private static class CountryZonesLookupExtractor implements TimeZonesProcessor {
    692         private List<CountryTimeZones> countryTimeZonesList = new ArrayList<>(250 /* default */);
    693 
    694         @Override
    695         public boolean processCountryZones(String countryIso, String defaultTimeZoneId,
    696                 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo)
    697                 throws XmlPullParserException {
    698 
    699             CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
    700                     countryIso, defaultTimeZoneId, everUsesUtc, timeZoneMappings, debugInfo);
    701             countryTimeZonesList.add(countryTimeZones);
    702             return CONTINUE;
    703         }
    704 
    705         CountryZonesFinder getCountryZonesLookup() {
    706             return new CountryZonesFinder(countryTimeZonesList);
    707         }
    708     }
    709 
    710     /**
    711      * Extracts <em>validated</em> time zones information associated with a specific country code.
    712      * Processing is halted when the country code is matched and the validated result is also made
    713      * available via {@link #getValidatedCountryTimeZones()}.
    714      */
    715     private static class SelectiveCountryTimeZonesExtractor implements TimeZonesProcessor {
    716 
    717         private final String countryCodeToMatch;
    718         private CountryTimeZones validatedCountryTimeZones;
    719 
    720         private SelectiveCountryTimeZonesExtractor(String countryCodeToMatch) {
    721             this.countryCodeToMatch = normalizeCountryIso(countryCodeToMatch);
    722         }
    723 
    724         @Override
    725         public boolean processCountryZones(String countryIso, String defaultTimeZoneId,
    726                 boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo) {
    727             countryIso = normalizeCountryIso(countryIso);
    728             if (!countryCodeToMatch.equals(countryIso)) {
    729                 return CONTINUE;
    730             }
    731             validatedCountryTimeZones = CountryTimeZones.createValidated(countryIso,
    732                     defaultTimeZoneId, everUsesUtc, timeZoneMappings, debugInfo);
    733 
    734             return HALT;
    735         }
    736 
    737         /**
    738          * Returns the CountryTimeZones that matched, or {@code null} if there were no matches.
    739          */
    740         CountryTimeZones getValidatedCountryTimeZones() {
    741             return validatedCountryTimeZones;
    742         }
    743     }
    744 
    745     /**
    746      * A source of Readers that can be used repeatedly.
    747      */
    748     private interface ReaderSupplier {
    749         /** Returns a Reader. Throws an IOException if the Reader cannot be created. */
    750         Reader get() throws IOException;
    751 
    752         static ReaderSupplier forFile(String fileName, Charset charSet) throws IOException {
    753             Path file = Paths.get(fileName);
    754             if (!Files.exists(file)) {
    755                 throw new FileNotFoundException(fileName + " does not exist");
    756             }
    757             if (!Files.isRegularFile(file) && Files.isReadable(file)) {
    758                 throw new IOException(fileName + " must be a regular readable file.");
    759             }
    760             return () -> Files.newBufferedReader(file, charSet);
    761         }
    762 
    763         static ReaderSupplier forString(String xml) {
    764             return () -> new StringReader(xml);
    765         }
    766     }
    767 
    768     private static List<String> extractTimeZoneIds(List<TimeZoneMapping> timeZoneMappings) {
    769         List<String> zoneIds = new ArrayList<>(timeZoneMappings.size());
    770         for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
    771             zoneIds.add(timeZoneMapping.timeZoneId);
    772         }
    773         return Collections.unmodifiableList(zoneIds);
    774     }
    775 
    776     static String normalizeCountryIso(String countryIso) {
    777         // Lowercase ASCII is normalized for the purposes of the input files and the code in this
    778         // class and related classes.
    779         return countryIso.toLowerCase(Locale.US);
    780     }
    781 }
    782