| /* |
| * 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 libcore.util; |
| |
| import org.xmlpull.v1.XmlPullParser; |
| import org.xmlpull.v1.XmlPullParserException; |
| import org.xmlpull.v1.XmlPullParserFactory; |
| |
| import android.icu.util.TimeZone; |
| |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.Reader; |
| import java.io.StringReader; |
| import java.nio.charset.Charset; |
| import java.nio.charset.StandardCharsets; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.file.Paths; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Set; |
| |
| /** |
| * A structure that can find matching time zones. |
| */ |
| public class TimeZoneFinder { |
| |
| private static final String TZLOOKUP_FILE_NAME = "tzlookup.xml"; |
| private static final String TIMEZONES_ELEMENT = "timezones"; |
| private static final String COUNTRY_ZONES_ELEMENT = "countryzones"; |
| private static final String COUNTRY_ELEMENT = "country"; |
| private static final String COUNTRY_CODE_ATTRIBUTE = "code"; |
| private static final String ID_ELEMENT = "id"; |
| |
| private static TimeZoneFinder instance; |
| |
| private final ReaderSupplier xmlSource; |
| |
| // Cached fields for the last country looked up. |
| private String lastCountryIso; |
| private List<TimeZone> 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()}. |
| */ |
| public static TimeZoneFinder getInstance() { |
| synchronized(TimeZoneFinder.class) { |
| if (instance == null) { |
| String[] tzLookupFilePaths = |
| TimeZoneDataFiles.getTimeZoneFilePaths(TZLOOKUP_FILE_NAME); |
| instance = createInstanceWithFallback(tzLookupFilePaths[0], tzLookupFilePaths[1]); |
| } |
| } |
| return instance; |
| } |
| |
| // VisibleForTesting |
| public static TimeZoneFinder createInstanceWithFallback(String... tzLookupFilePaths) { |
| 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) { |
| System.logE("Unable to process file: " + tzLookupFilePath + " Trying next one.", e); |
| } |
| } |
| |
| System.logE("No valid file found in set: " + Arrays.toString(tzLookupFilePaths) |
| + " Falling back to empty map."); |
| return createInstanceForTests("<timezones><countryzones /></timezones>"); |
| } |
| |
| /** |
| * 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()}. |
| */ |
| 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. |
| */ |
| public void validate() throws IOException { |
| try { |
| processXml(new CountryZonesValidator()); |
| } catch (XmlPullParserException e) { |
| throw new IOException("Parsing error", e); |
| } |
| } |
| |
| /** |
| * Returns a frozen ICU time zone that has / would have had the specified offset and DST value |
| * at the specified moment in the specified country. |
| * |
| * <p>In order to be considered a configured zone must match the supplied offset information. |
| * |
| * <p>Matches are considered in a well-defined order. If multiple zones match and one of them |
| * also matches the (optional) bias parameter then the bias time zone will be returned. |
| * Otherwise the first match found is returned. |
| */ |
| public TimeZone lookupTimeZoneByCountryAndOffset( |
| String countryIso, int offsetSeconds, boolean isDst, long whenMillis, TimeZone bias) { |
| |
| List<TimeZone> candidates = lookupTimeZonesByCountry(countryIso); |
| if (candidates == null || candidates.isEmpty()) { |
| return null; |
| } |
| |
| TimeZone firstMatch = null; |
| for (int i = 0; i < candidates.size(); i++) { |
| TimeZone match = candidates.get(i); |
| if (!offsetMatchesAtTime(match, offsetSeconds, isDst, whenMillis)) { |
| continue; |
| } |
| |
| if (firstMatch == null) { |
| if (bias == null) { |
| // No bias, so we can stop at the first match. |
| return match; |
| } |
| // We have to carry on checking in case the bias matches. We want to return the |
| // first if it doesn't, though. |
| firstMatch = match; |
| } |
| |
| // Check if match is also the bias. There must be a bias otherwise we'd have terminated |
| // already. |
| if (match.getID().equals(bias.getID())) { |
| return match; |
| } |
| } |
| // Return firstMatch, which can be null if there was no match. |
| return firstMatch; |
| } |
| |
| /** |
| * Returns {@code true} if the specified offset, DST state and time would be valid in the |
| * timeZone. |
| */ |
| private static boolean offsetMatchesAtTime(TimeZone timeZone, int offsetMillis, boolean isDst, |
| long whenMillis) { |
| int[] offsets = new int[2]; |
| timeZone.getOffset(whenMillis, false /* local */, offsets); |
| |
| // offsets[1] == 0 when the zone is not in DST. |
| boolean zoneIsDst = offsets[1] != 0; |
| if (isDst != zoneIsDst) { |
| return false; |
| } |
| return offsetMillis == (offsets[0] + offsets[1]); |
| } |
| |
| /** |
| * Returns an immutable list of frozen ICU time zones known to be used in the specified country. |
| * If the country code is not recognized or there is an error during lookup this can return |
| * null. The TimeZones returned will never contain {@link TimeZone#UNKNOWN_ZONE}. This method |
| * can return an empty list in a case when the underlying configuration references only unknown |
| * zone IDs. |
| */ |
| public List<TimeZone> lookupTimeZonesByCountry(String countryIso) { |
| synchronized(this) { |
| if (countryIso.equals(lastCountryIso)) { |
| return lastCountryTimeZones; |
| } |
| } |
| |
| CountryZonesExtractor extractor = new CountryZonesExtractor(countryIso); |
| List<TimeZone> countryTimeZones = null; |
| try { |
| processXml(extractor); |
| countryTimeZones = extractor.getMatchedZones(); |
| } catch (IOException e) { |
| System.logW("Error reading country zones ", e); |
| |
| // Clear the cached code so we will try again next time. |
| countryIso = null; |
| } catch (XmlPullParserException e) { |
| System.logW("Error reading country zones ", e); |
| // We want to cache the null. This won't get better over time. |
| } |
| |
| synchronized(this) { |
| lastCountryIso = countryIso; |
| lastCountryTimeZones = countryTimeZones; |
| } |
| return countryTimeZones; |
| } |
| |
| /** |
| * Processes the XML, applying the {@link CountryZonesProcessor} to the <countryzones> |
| * element. Processing can terminate early if the |
| * {@link CountryZonesProcessor#process(String, List, String)} returns |
| * {@link CountryZonesProcessor#HALT} or it throws an exception. |
| */ |
| private void processXml(CountryZonesProcessor 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: |
| * <timezones> |
| * <countryzones> |
| * <country code="us"> |
| * <id>America/New_York"</id> |
| * ... |
| * <id>America/Los_Angeles</id> |
| * </country> |
| * <country code="gb"> |
| * <id>Europe/London</id> |
| * </country> |
| * </countryzones> |
| * </timezones> |
| */ |
| |
| findRequiredStartTag(parser, TIMEZONES_ELEMENT); |
| |
| // There is only one expected sub-element <countryzones> in the format currently, skip |
| // over anything before it. |
| findRequiredStartTag(parser, COUNTRY_ZONES_ELEMENT); |
| |
| if (processCountryZones(parser, processor) == CountryZonesProcessor.HALT) { |
| return; |
| } |
| |
| // Make sure we are on the </countryzones> tag. |
| checkOnEndTag(parser, COUNTRY_ZONES_ELEMENT); |
| |
| // Advance to the next tag. |
| parser.next(); |
| |
| // Skip anything until </timezones>, and make sure the file is not truncated and we can |
| // find the end. |
| consumeUntilEndTag(parser, TIMEZONES_ELEMENT); |
| |
| // Make sure we are on the </timezones> tag. |
| checkOnEndTag(parser, TIMEZONES_ELEMENT); |
| } |
| } |
| |
| private static boolean processCountryZones(XmlPullParser parser, |
| CountryZonesProcessor processor) throws IOException, XmlPullParserException { |
| |
| // Skip over any unexpected elements and process <country> elements. |
| while (findOptionalStartTag(parser, COUNTRY_ELEMENT)) { |
| if (processor == null) { |
| consumeUntilEndTag(parser, COUNTRY_ELEMENT); |
| } else { |
| 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 debugInfo = parser.getPositionDescription(); |
| List<String> timeZoneIds = parseZoneIds(parser); |
| if (processor.process(code, timeZoneIds, debugInfo) |
| == CountryZonesProcessor.HALT) { |
| return CountryZonesProcessor.HALT; |
| } |
| } |
| |
| // Make sure we are on the </country> element. |
| checkOnEndTag(parser, COUNTRY_ELEMENT); |
| } |
| |
| return CountryZonesExtractor.CONTINUE; |
| } |
| |
| private static List<String> parseZoneIds(XmlPullParser parser) |
| throws IOException, XmlPullParserException { |
| List<String> timeZones = new ArrayList<>(); |
| |
| // Skip over any unexpected elements and process <id> elements. |
| while (findOptionalStartTag(parser, ID_ELEMENT)) { |
| String zoneIdString = consumeText(parser); |
| |
| // Make sure we are on the </id> element. |
| checkOnEndTag(parser, ID_ELEMENT); |
| |
| // Process the zone ID. |
| timeZones.add(zoneIdString); |
| } |
| |
| // The list is made unmodifiable to avoid callers changing it. |
| return Collections.unmodifiableList(timeZones); |
| } |
| |
| private static void findRequiredStartTag(XmlPullParser parser, String elementName) |
| throws IOException, XmlPullParserException { |
| findStartTag(parser, elementName, true /* elementRequired */); |
| } |
| |
| /** Called when on a START_TAG. When returning false, it leaves the parser on the END_TAG. */ |
| private static boolean findOptionalStartTag(XmlPullParser parser, String elementName) |
| throws IOException, XmlPullParserException { |
| return findStartTag(parser, elementName, false /* elementRequired */); |
| } |
| |
| /** |
| * Find a START_TAG with the specified name without decreasing the depth, or increasing the |
| * depth by more than one. More deeply nested elements and text are skipped, even START_TAGs |
| * with matching names. Returns when the START_TAG is found or the next (non-nested) END_TAG is |
| * encountered. The return can take the form of an exception or a false if the START_TAG is not |
| * found. True is returned when it is. |
| */ |
| private static boolean findStartTag( |
| XmlPullParser parser, String elementName, boolean elementRequired) |
| throws IOException, XmlPullParserException { |
| |
| int type; |
| while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) { |
| switch (type) { |
| case XmlPullParser.START_TAG: |
| String currentElementName = parser.getName(); |
| if (elementName.equals(currentElementName)) { |
| return true; |
| } |
| |
| // It was not the START_TAG we were looking for. Consume until the end. |
| parser.next(); |
| consumeUntilEndTag(parser, currentElementName); |
| break; |
| case XmlPullParser.END_TAG: |
| if (elementRequired) { |
| throw new XmlPullParserException( |
| "No child element found with name " + elementName); |
| } |
| return false; |
| default: |
| // Ignore. |
| break; |
| } |
| } |
| throw new XmlPullParserException("Unexpected end of document while looking for " |
| + elementName); |
| } |
| |
| /** |
| * Consume the remaining contents of an element and move to the END_TAG. Used when processing |
| * within an element can stop. The parser must be pointing at either the END_TAG we are looking |
| * for, a TEXT, or a START_TAG nested within the element to be consumed. |
| */ |
| private static void consumeUntilEndTag(XmlPullParser parser, String elementName) |
| throws IOException, XmlPullParserException { |
| |
| if (parser.getEventType() == XmlPullParser.END_TAG |
| && elementName.equals(parser.getName())) { |
| // Early return - we are already there. |
| return; |
| } |
| |
| // Keep track of the required depth in case there are nested elements to be consumed. |
| // Both the name and the depth must match our expectation to complete. |
| |
| int requiredDepth = parser.getDepth(); |
| // A TEXT tag would be at the same depth as the END_TAG we are looking for. |
| if (parser.getEventType() == XmlPullParser.START_TAG) { |
| // A START_TAG would have incremented the depth, so we're looking for an END_TAG one |
| // higher than the current tag. |
| requiredDepth--; |
| } |
| |
| while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { |
| int type = parser.next(); |
| |
| int currentDepth = parser.getDepth(); |
| if (currentDepth < requiredDepth) { |
| throw new XmlPullParserException( |
| "Unexpected depth while looking for end tag: " |
| + parser.getPositionDescription()); |
| } else if (currentDepth == requiredDepth) { |
| if (type == XmlPullParser.END_TAG) { |
| if (elementName.equals(parser.getName())) { |
| return; |
| } |
| throw new XmlPullParserException( |
| "Unexpected eng tag: " + parser.getPositionDescription()); |
| } |
| } |
| // Everything else is either a type we are not interested in or is too deep and so is |
| // ignored. |
| } |
| throw new XmlPullParserException("Unexpected end of document"); |
| } |
| |
| /** |
| * Reads the text inside the current element. Should be called when the parser is currently |
| * on the START_TAG before the TEXT. The parser will be positioned on the END_TAG after this |
| * call when it completes successfully. |
| */ |
| private static String consumeText(XmlPullParser parser) |
| throws IOException, XmlPullParserException { |
| |
| int type = parser.next(); |
| String text; |
| if (type == XmlPullParser.TEXT) { |
| text = parser.getText(); |
| } else { |
| throw new XmlPullParserException("Text not found. Found type=" + type |
| + " at " + parser.getPositionDescription()); |
| } |
| |
| type = parser.next(); |
| if (type != XmlPullParser.END_TAG) { |
| throw new XmlPullParserException( |
| "Unexpected nested tag or end of document when expecting text: type=" + type |
| + " at " + parser.getPositionDescription()); |
| } |
| return text; |
| } |
| |
| private static void checkOnEndTag(XmlPullParser parser, String elementName) |
| throws XmlPullParserException { |
| if (!(parser.getEventType() == XmlPullParser.END_TAG |
| && parser.getName().equals(elementName))) { |
| throw new XmlPullParserException( |
| "Unexpected tag encountered: " + parser.getPositionDescription()); |
| } |
| } |
| |
| /** |
| * Processes <countryzones> data. |
| */ |
| private interface CountryZonesProcessor { |
| |
| boolean CONTINUE = true; |
| boolean HALT = false; |
| |
| /** |
| * Returns {@code #CONTINUE} if processing of the XML should continue, {@code HALT} if it |
| * should stop (but without considering this an error). Problems with parser are reported as |
| * an exception. |
| */ |
| boolean process(String countryCode, List<String> timeZoneIds, String debugInfo) |
| throws XmlPullParserException; |
| } |
| |
| /** |
| * Validates <countryzones> elements. To be valid the country ISO code must be unique |
| * and it must not be empty. |
| */ |
| private static class CountryZonesValidator implements CountryZonesProcessor { |
| |
| private final Set<String> knownCountryCodes = new HashSet<>(); |
| |
| @Override |
| public boolean process(String countryCode, List<String> timeZoneIds, String debugInfo) |
| throws XmlPullParserException { |
| if (knownCountryCodes.contains(countryCode)) { |
| throw new XmlPullParserException("Second entry for country code: " + countryCode |
| + " at " + debugInfo); |
| } |
| if (timeZoneIds.isEmpty()) { |
| throw new XmlPullParserException("No time zone IDs for country code: " + countryCode |
| + " at " + debugInfo); |
| } |
| |
| // We don't validate the zone IDs - they may be new and we can't easily check them |
| // against other timezone data that may be associated with this file. |
| |
| knownCountryCodes.add(countryCode); |
| |
| return CONTINUE; |
| } |
| } |
| |
| /** |
| * Extracts the zones associated with a country code, halting when the country code is matched |
| * and making them available via {@link #getMatchedZones()}. |
| */ |
| private static class CountryZonesExtractor implements CountryZonesProcessor { |
| |
| private final String countryCodeToMatch; |
| private List<TimeZone> matchedZones; |
| |
| private CountryZonesExtractor(String countryCodeToMatch) { |
| this.countryCodeToMatch = countryCodeToMatch; |
| } |
| |
| @Override |
| public boolean process(String countryCode, List<String> timeZoneIds, String debugInfo) { |
| if (!countryCodeToMatch.equals(countryCode)) { |
| return CONTINUE; |
| } |
| |
| List<TimeZone> timeZones = new ArrayList<>(); |
| for (String zoneIdString : timeZoneIds) { |
| TimeZone tz = TimeZone.getTimeZone(zoneIdString); |
| if (tz.getID().equals(TimeZone.UNKNOWN_ZONE_ID)) { |
| System.logW("Skipping invalid zone: " + zoneIdString + " at " + debugInfo); |
| } else { |
| // The zone is frozen to prevent mutation by callers. |
| timeZones.add(tz.freeze()); |
| } |
| } |
| matchedZones = Collections.unmodifiableList(timeZones); |
| return HALT; |
| } |
| |
| /** |
| * Returns the matched zones, or {@code null} if there were no matches. Unknown zone IDs are |
| * ignored so the list can be empty if there were no zones or the zone IDs were not |
| * recognized. |
| */ |
| List<TimeZone> getMatchedZones() { |
| return matchedZones; |
| } |
| } |
| |
| /** |
| * A source of Readers that can be used repeatedly. |
| */ |
| private interface ReaderSupplier { |
| /** Returns a Reader. Throws an IOException if the Reader cannot be created. */ |
| Reader get() throws IOException; |
| |
| static ReaderSupplier forFile(String fileName, Charset charSet) throws IOException { |
| Path file = Paths.get(fileName); |
| if (!Files.exists(file)) { |
| throw new FileNotFoundException(fileName + " does not exist"); |
| } |
| if (!Files.isRegularFile(file) && Files.isReadable(file)) { |
| throw new IOException(fileName + " must be a regular readable file."); |
| } |
| return () -> Files.newBufferedReader(file, charSet); |
| } |
| |
| static ReaderSupplier forString(String xml) { |
| return () -> new StringReader(xml); |
| } |
| } |
| } |