Java程序  |  573行  |  22.29 KB

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

    /**
     * Return a 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 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<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 &lt;countryzones&gt;
     * 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 &lt;countryzones&gt; 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 &lt;countryzones&gt; 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);
        }
    }
}