All Downloads are FREE. Search and download functionalities are using the official Maven repository.

libcore.util.TimeZoneFinder Maven / Gradle / Ivy

Go to download

A library jar that provides APIs for Applications written for the Google Android Platform.

There is a newer version: 14-robolectric-10818077
Show newest version
/*
 * 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 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("");
    }

    /**
     * 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);
        }
    }

    /**
     * Return a time zone that has / would have had the specified offset and DST value at the
     * specified moment in the specified country.
     *
     * 

In order to be considered a configured zone must match the supplied offset information. * *

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 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 a list of 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 lookupTimeZonesByCountry(String countryIso) { synchronized(this) { if (countryIso.equals(lastCountryIso)) { return lastCountryTimeZones; } } CountryZonesExtractor extractor = new CountryZonesExtractor(countryIso); List 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: * * * * America/New_York" * ... * America/Los_Angeles * * * Europe/London * * * */ findRequiredStartTag(parser, TIMEZONES_ELEMENT); // There is only one expected sub-element 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 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, CountryZonesProcessor processor) throws IOException, XmlPullParserException { // Skip over any unexpected elements and process 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 timeZoneIds = parseZoneIds(parser); if (processor.process(code, timeZoneIds, debugInfo) == CountryZonesProcessor.HALT) { return CountryZonesProcessor.HALT; } } // Make sure we are on the element. checkOnEndTag(parser, COUNTRY_ELEMENT); } return CountryZonesExtractor.CONTINUE; } private static List parseZoneIds(XmlPullParser parser) throws IOException, XmlPullParserException { List timeZones = new ArrayList<>(); // Skip over any unexpected elements and process elements. while (findOptionalStartTag(parser, ID_ELEMENT)) { String zoneIdString = consumeText(parser); // Make sure we are on the 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 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 knownCountryCodes = new HashSet<>(); @Override public boolean process(String countryCode, List 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 matchedZones; private CountryZonesExtractor(String countryCodeToMatch) { this.countryCodeToMatch = countryCodeToMatch; } @Override public boolean process(String countryCode, List timeZoneIds, String debugInfo) { if (!countryCodeToMatch.equals(countryCode)) { return CONTINUE; } List 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 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); } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy