/* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.i18n.timezone; import static com.android.i18n.timezone.XmlUtils.checkOnEndTag; import static com.android.i18n.timezone.XmlUtils.consumeText; import static com.android.i18n.timezone.XmlUtils.consumeUntilEndTag; import static com.android.i18n.timezone.XmlUtils.findNextStartTagOrEndTagNoRecurse; import static com.android.i18n.timezone.XmlUtils.findNextStartTagOrThrowNoRecurse; import static com.android.i18n.timezone.XmlUtils.normalizeCountryIso; import static com.android.i18n.timezone.XmlUtils.parseBooleanAttribute; import static com.android.i18n.timezone.XmlUtils.parseLongAttribute; import static com.android.i18n.timezone.XmlUtils.parseStringListAttribute; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; import java.io.IOException; import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import com.android.i18n.timezone.CountryTimeZones.TimeZoneMapping; import com.android.i18n.timezone.XmlUtils.ReaderSupplier; import com.android.i18n.util.Log; /** * A class that can find matching time zones by loading data from the tzlookup.xml file. * @hide */ @libcore.api.CorePlatformApi public final class TimeZoneFinder { public static final String TZLOOKUP_FILE_NAME = "tzlookup.xml"; // Root element. e.g. private static final String TIMEZONES_ELEMENT = "timezones"; private static final String IANA_VERSION_ATTRIBUTE = "ianaversion"; // Country zones section. e.g. private static final String COUNTRY_ZONES_ELEMENT = "countryzones"; // Country data. e.g. // private static final String COUNTRY_ELEMENT = "country"; private static final String COUNTRY_CODE_ATTRIBUTE = "code"; private static final String DEFAULT_TIME_ZONE_ID_ATTRIBUTE = "default"; private static final String DEFAULT_TIME_ZONE_BOOST_ATTRIBUTE = "defaultBoost"; private static final String EVER_USES_UTC_ATTRIBUTE = "everutc"; // Country -> Time zone mapping. e.g. ZoneId, ZoneId, // ZoneId // The default for the picker attribute when unspecified is "y". // The notafter attribute is optional. It specifies a timestamp (time in milliseconds from Unix // epoch start) after which the zone is not (effectively) in use. If unspecified the zone is in // use forever. // The alts attribute is optional. It contains a comma-separated String of alternative IDs that // are exact synonyms for the ZoneId. private static final String ZONE_ID_ELEMENT = "id"; private static final String ZONE_SHOW_IN_PICKER_ATTRIBUTE = "picker"; private static final String ZONE_NOT_USED_AFTER_ATTRIBUTE = "notafter"; private static final String ZONE_ALTERNATIVE_IDS_ATTRIBUTE = "alts"; private static TimeZoneFinder instance; private final ReaderSupplier xmlSource; // Cached field for the last country looked up. private CountryTimeZones lastCountryTimeZones; private TimeZoneFinder(ReaderSupplier xmlSource) { this.xmlSource = xmlSource; } /** * Obtains an instance for use when resolving time zones. This method handles using the correct * file when there are several to choose from. This method never returns {@code null}. No * in-depth validation is performed on the file content, see {@link #validate()}. */ @libcore.api.CorePlatformApi public static TimeZoneFinder getInstance() { synchronized(TimeZoneFinder.class) { if (instance == null) { String[] tzLookupFilePaths = TimeZoneDataFiles.getTimeZoneFilePaths(TZLOOKUP_FILE_NAME); instance = createInstanceWithFallback(tzLookupFilePaths); } } return instance; } // VisibleForTesting public static TimeZoneFinder createInstanceWithFallback(String... tzLookupFilePaths) { IOException lastException = null; for (String tzLookupFilePath : tzLookupFilePaths) { try { // We assume that any file in /data was validated before install, and the system // file was validated before the device shipped. Therefore, we do not pay the // validation cost here. return createInstance(tzLookupFilePath); } catch (IOException e) { // There's expected to be two files, and it's normal for the first file not to // exist so we don't log, but keep the lastException so we can log it if there // are no valid files available. if (lastException != null) { e.addSuppressed(lastException); } lastException = e; } } Log.e("No valid file found in set: " + Arrays.toString(tzLookupFilePaths) + " Printing exceptions and falling back to empty map.", lastException); return createInstanceForTests(""); } /** * Obtains an instance using a specific data file, throwing an IOException if the file does not * exist or is not readable. This method never returns {@code null}. No in-depth validation is * performed on the file content, see {@link #validate()}. */ @libcore.api.CorePlatformApi public static TimeZoneFinder createInstance(String path) throws IOException { ReaderSupplier xmlSupplier = ReaderSupplier.forFile(path, StandardCharsets.UTF_8); return new TimeZoneFinder(xmlSupplier); } /** Used to create an instance using an in-memory XML String instead of a file. */ // VisibleForTesting public static TimeZoneFinder createInstanceForTests(String xml) { return new TimeZoneFinder(ReaderSupplier.forString(xml)); } /** * Parses the data file, throws an exception if it is invalid or cannot be read. */ @libcore.api.CorePlatformApi public void validate() throws IOException { try { processXml(new TimeZonesValidator()); } catch (XmlPullParserException e) { throw new IOException("Parsing error", e); } } /** * Returns the IANA rules version associated with the data. If there is no version information * or there is a problem reading the file then {@code null} is returned. */ @libcore.api.CorePlatformApi public String getIanaVersion() { IanaVersionExtractor ianaVersionExtractor = new IanaVersionExtractor(); try { processXml(ianaVersionExtractor); return ianaVersionExtractor.getIanaVersion(); } catch (XmlPullParserException | IOException e) { return null; } } /** * Loads all the country <-> time zone mapping data into memory. This method can return * {@code null} in the event of an error while reading the underlying data files. */ @libcore.api.CorePlatformApi public CountryZonesFinder getCountryZonesFinder() { CountryZonesLookupExtractor extractor = new CountryZonesLookupExtractor(); try { processXml(extractor); return extractor.getCountryZonesLookup(); } catch (XmlPullParserException | IOException e) { Log.w("Error reading country zones ", e); return null; } } /** * Returns a {@link CountryTimeZones} object associated with the specified country code. * Caching is handled as needed. If the country code is not recognized or there is an error * during lookup this method can return null. */ @libcore.api.CorePlatformApi public CountryTimeZones lookupCountryTimeZones(String countryIso) { synchronized (this) { if (lastCountryTimeZones != null && lastCountryTimeZones.matchesCountryCode(countryIso)) { return lastCountryTimeZones; } } SelectiveCountryTimeZonesExtractor extractor = new SelectiveCountryTimeZonesExtractor(countryIso); try { processXml(extractor); CountryTimeZones countryTimeZones = extractor.getValidatedCountryTimeZones(); if (countryTimeZones == null) { // None matched. Return the null but don't change the cached value. return null; } // Update the cached value. synchronized (this) { lastCountryTimeZones = countryTimeZones; } return countryTimeZones; } catch (XmlPullParserException | IOException e) { Log.w("Error reading country zones ", e); // Error - don't change the cached value. return null; } } /** * Processes the XML, applying the {@link TimeZonesProcessor} to the <countryzones> * element. Processing can terminate early if the {@link TimeZonesProcessor#processCountryZones( * String, String, boolean, boolean, List, String)} returns {@link TimeZonesProcessor#HALT} or * it throws an exception. */ private void processXml(TimeZonesProcessor processor) throws XmlPullParserException, IOException { try (Reader reader = xmlSource.get()) { XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance(); xmlPullParserFactory.setNamespaceAware(false); XmlPullParser parser = xmlPullParserFactory.newPullParser(); parser.setInput(reader); /* * The expected XML structure is: * * * * America/New_York" * ... * America/Indiana/Vincennes * ... * America/Los_Angeles * * * Europe/London * * * */ findNextStartTagOrThrowNoRecurse(parser, TIMEZONES_ELEMENT); // We do not require the ianaversion attribute be present. It is metadata that helps // with versioning but is not required. String ianaVersion = parser.getAttributeValue( null /* namespace */, IANA_VERSION_ATTRIBUTE); if (processor.processHeader(ianaVersion) == TimeZonesProcessor.HALT) { return; } // There is only one expected sub-element in the format currently, skip // over anything before it. findNextStartTagOrThrowNoRecurse(parser, COUNTRY_ZONES_ELEMENT); if (processCountryZones(parser, processor) == TimeZonesProcessor.HALT) { return; } // Make sure we are on the tag. checkOnEndTag(parser, COUNTRY_ZONES_ELEMENT); // Advance to the next tag. parser.next(); // Skip anything until , and make sure the file is not truncated and we can // find the end. consumeUntilEndTag(parser, TIMEZONES_ELEMENT); // Make sure we are on the tag. checkOnEndTag(parser, TIMEZONES_ELEMENT); } } private static boolean processCountryZones(XmlPullParser parser, TimeZonesProcessor processor) throws IOException, XmlPullParserException { // Skip over any unexpected elements and process elements. while (findNextStartTagOrEndTagNoRecurse(parser, COUNTRY_ELEMENT)) { String code = parser.getAttributeValue( null /* namespace */, COUNTRY_CODE_ATTRIBUTE); if (code == null || code.isEmpty()) { throw new XmlPullParserException( "Unable to find country code: " + parser.getPositionDescription()); } String defaultTimeZoneId = parser.getAttributeValue( null /* namespace */, DEFAULT_TIME_ZONE_ID_ATTRIBUTE); if (defaultTimeZoneId == null || defaultTimeZoneId.isEmpty()) { throw new XmlPullParserException("Unable to find default time zone ID: " + parser.getPositionDescription()); } boolean defaultTimeZoneBoost = parseBooleanAttribute(parser, DEFAULT_TIME_ZONE_BOOST_ATTRIBUTE, false); Boolean everUsesUtc = parseBooleanAttribute( parser, EVER_USES_UTC_ATTRIBUTE, null /* defaultValue */); if (everUsesUtc == null) { // There is no valid default: we require this to be specified. throw new XmlPullParserException( "Unable to find UTC hint attribute (" + EVER_USES_UTC_ATTRIBUTE + "): " + parser.getPositionDescription()); } String debugInfo = parser.getPositionDescription(); List timeZoneMappings = parseTimeZoneMappings(parser); boolean result = processor.processCountryZones(code, defaultTimeZoneId, defaultTimeZoneBoost, everUsesUtc, timeZoneMappings, debugInfo); if (result == TimeZonesProcessor.HALT) { return TimeZonesProcessor.HALT; } // Make sure we are on the element. checkOnEndTag(parser, COUNTRY_ELEMENT); } return TimeZonesProcessor.CONTINUE; } private static List parseTimeZoneMappings(XmlPullParser parser) throws IOException, XmlPullParserException { List timeZoneMappings = new ArrayList<>(); // Skip over any unexpected elements and process elements. while (findNextStartTagOrEndTagNoRecurse(parser, ZONE_ID_ELEMENT)) { // The picker attribute is optional and defaulted to true. boolean showInPicker = parseBooleanAttribute( parser, ZONE_SHOW_IN_PICKER_ATTRIBUTE, true /* defaultValue */); Long notUsedAfter = parseLongAttribute( parser, ZONE_NOT_USED_AFTER_ATTRIBUTE, null /* defaultValue */); List alternativeIds = parseStringListAttribute( parser, ZONE_ALTERNATIVE_IDS_ATTRIBUTE, Collections.emptyList()); String zoneIdString = consumeText(parser); // Make sure we are on the element. checkOnEndTag(parser, ZONE_ID_ELEMENT); // Process the TimeZoneMapping. if (zoneIdString == null || zoneIdString.length() == 0) { throw new XmlPullParserException("Missing text for " + ZONE_ID_ELEMENT + "): " + parser.getPositionDescription()); } // intern() zone Ids because they are a fixed set of well-known strings that are used in // other low-level library calls. String internedZoneIdString = zoneIdString.intern(); List internedAlternativeIds = internStrings(alternativeIds); TimeZoneMapping timeZoneMapping = new TimeZoneMapping( internedZoneIdString, showInPicker, notUsedAfter, internedAlternativeIds); timeZoneMappings.add(timeZoneMapping); } // The list is made unmodifiable to avoid callers changing it. return Collections.unmodifiableList(timeZoneMappings); } private static List internStrings(List stringsToIntern) { if (stringsToIntern.isEmpty()) { return stringsToIntern; } List internedStrings = new ArrayList<>(stringsToIntern.size()); for (String stringToIntern : stringsToIntern) { internedStrings.add(stringToIntern.intern()); } return internedStrings; } /** * Processes <timezones> data. */ private interface TimeZonesProcessor { boolean CONTINUE = true; boolean HALT = false; /** * Return {@link #CONTINUE} if processing of the XML should continue, {@link #HALT} if it * should stop (but without considering this an error). Problems with the data are * reported as an exception. * *

The default implementation returns {@link #CONTINUE}. */ default boolean processHeader(String ianaVersion) throws XmlPullParserException { return CONTINUE; } /** * Returns {@link #CONTINUE} if processing of the XML should continue, {@link #HALT} if it * should stop (but without considering this an error). Problems with the data are * reported as an exception. * *

The default implementation returns {@link #CONTINUE}. */ default boolean processCountryZones(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoost, boolean everUsesUtc, List timeZoneMappings, String debugInfo) throws XmlPullParserException { return CONTINUE; } } /** * Validates <countryzones> elements. Intended to be used before a proposed installation * of new data. To be valid the country ISO code must be normalized, unique, the default time * zone ID must be one of the time zones IDs and the time zone IDs list must not be empty. The * IDs themselves are not checked against other data to see if they are recognized because other * classes will not have been updated with the associated new time zone data yet and so will not * be aware of newly added IDs. */ private static class TimeZonesValidator implements TimeZonesProcessor { private final Set knownCountryCodes = new HashSet<>(); @Override public boolean processCountryZones(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoost, boolean everUsesUtc, List timeZoneMappings, String debugInfo) throws XmlPullParserException { if (!normalizeCountryIso(countryIso).equals(countryIso)) { throw new XmlPullParserException("Country code: " + countryIso + " is not normalized at " + debugInfo); } if (knownCountryCodes.contains(countryIso)) { throw new XmlPullParserException("Second entry for country code: " + countryIso + " at " + debugInfo); } if (timeZoneMappings.isEmpty()) { throw new XmlPullParserException("No time zone IDs for country code: " + countryIso + " at " + debugInfo); } if (!TimeZoneMapping.containsTimeZoneId(timeZoneMappings, defaultTimeZoneId)) { throw new XmlPullParserException("defaultTimeZoneId for country code: " + countryIso + " is not one of the zones " + timeZoneMappings + " at " + debugInfo); } knownCountryCodes.add(countryIso); return CONTINUE; } } /** * Reads just the IANA version from the file header. The version is then available via * {@link #getIanaVersion()}. */ private static class IanaVersionExtractor implements TimeZonesProcessor { private String ianaVersion; @Override public boolean processHeader(String ianaVersion) throws XmlPullParserException { this.ianaVersion = ianaVersion; return HALT; } public String getIanaVersion() { return ianaVersion; } } /** * Reads all country time zone information into memory and makes it available as a * {@link CountryZonesFinder}. */ private static class CountryZonesLookupExtractor implements TimeZonesProcessor { private List countryTimeZonesList = new ArrayList<>(250 /* default */); @Override public boolean processCountryZones(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoost, boolean everUsesUtc, List timeZoneMappings, String debugInfo) throws XmlPullParserException { CountryTimeZones countryTimeZones = CountryTimeZones .createValidated( countryIso, defaultTimeZoneId, defaultTimeZoneBoost, everUsesUtc, timeZoneMappings, debugInfo); countryTimeZonesList.add(countryTimeZones); return CONTINUE; } CountryZonesFinder getCountryZonesLookup() { return new CountryZonesFinder(countryTimeZonesList); } } /** * Extracts validated time zones information associated with a specific country code. * Processing is halted when the country code is matched and the validated result is also made * available via {@link #getValidatedCountryTimeZones()}. */ private static class SelectiveCountryTimeZonesExtractor implements TimeZonesProcessor { private final String countryCodeToMatch; private CountryTimeZones validatedCountryTimeZones; private SelectiveCountryTimeZonesExtractor(String countryCodeToMatch) { this.countryCodeToMatch = normalizeCountryIso(countryCodeToMatch); } @Override public boolean processCountryZones(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoost, boolean everUsesUtc, List timeZoneMappings, String debugInfo) { countryIso = normalizeCountryIso(countryIso); if (!countryCodeToMatch.equals(countryIso)) { return CONTINUE; } validatedCountryTimeZones = CountryTimeZones.createValidated(countryIso, defaultTimeZoneId, defaultTimeZoneBoost, everUsesUtc, timeZoneMappings, debugInfo); return HALT; } /** * Returns the CountryTimeZones that matched, or {@code null} if there were no matches. */ CountryTimeZones getValidatedCountryTimeZones() { return validatedCountryTimeZones; } } }