Merge "Remove out-of-date comments about ICU4C."
diff --git a/JavaLibrary.mk b/JavaLibrary.mk
index 758b6ce..97e94f8 100644
--- a/JavaLibrary.mk
+++ b/JavaLibrary.mk
@@ -91,7 +91,7 @@
 LOCAL_MODULE_TAGS := optional
 LOCAL_JAVA_LANGUAGE_VERSION := 1.8
 LOCAL_MODULE := core-all
-LOCAL_REQUIRED_MODULES := tzdata
+LOCAL_REQUIRED_MODULES := tzdata tzlookup.xml
 LOCAL_CORE_LIBRARY := true
 LOCAL_UNINSTALLABLE_MODULE := true
 include $(BUILD_JAVA_LIBRARY)
@@ -110,7 +110,7 @@
 LOCAL_MODULE := core-oj
 LOCAL_JAVA_LIBRARIES := core-all
 LOCAL_NOTICE_FILE := $(LOCAL_PATH)/ojluni/NOTICE
-LOCAL_REQUIRED_MODULES := tzdata
+LOCAL_REQUIRED_MODULES := tzdata tzlookup.xml
 LOCAL_CORE_LIBRARY := true
 include $(BUILD_JAVA_LIBRARY)
 
@@ -132,7 +132,7 @@
 endif # EMMA_INSTRUMENT_STATIC
 endif # EMMA_INSTRUMENT
 LOCAL_CORE_LIBRARY := true
-LOCAL_REQUIRED_MODULES := tzdata
+LOCAL_REQUIRED_MODULES := tzdata tzlookup.xml
 include $(BUILD_JAVA_LIBRARY)
 
 # A library that exists to satisfy javac when
@@ -167,7 +167,7 @@
 LOCAL_MODULE := core-oj-testdex
 LOCAL_JAVA_LIBRARIES := core-all
 LOCAL_NOTICE_FILE := $(LOCAL_PATH)/ojluni/NOTICE
-LOCAL_REQUIRED_MODULES := tzdata
+LOCAL_REQUIRED_MODULES := tzdata tzlookup.xml
 LOCAL_CORE_LIBRARY := true
 include $(BUILD_JAVA_LIBRARY)
 
@@ -201,7 +201,7 @@
 LOCAL_MODULE := core-libart-testdex
 LOCAL_JAVA_LIBRARIES := core-all
 LOCAL_CORE_LIBRARY := true
-LOCAL_REQUIRED_MODULES := tzdata
+LOCAL_REQUIRED_MODULES := tzdata tzlookup.xml
 include $(BUILD_JAVA_LIBRARY)
 endif
 
@@ -330,7 +330,7 @@
 LOCAL_MODULE_TAGS := optional
 LOCAL_JAVA_LANGUAGE_VERSION := 1.8
 LOCAL_MODULE := core-all-hostdex
-LOCAL_REQUIRED_MODULES := tzdata-host
+LOCAL_REQUIRED_MODULES := tzdata-host tzlookup.xml-host
 LOCAL_CORE_LIBRARY := true
 LOCAL_UNINSTALLABLE_MODULE := true
 include $(BUILD_HOST_DALVIK_JAVA_LIBRARY)
@@ -346,7 +346,7 @@
 LOCAL_MODULE := core-oj-hostdex
 LOCAL_NOTICE_FILE := $(LOCAL_PATH)/ojluni/NOTICE
 LOCAL_JAVA_LIBRARIES := core-all-hostdex
-LOCAL_REQUIRED_MODULES := tzdata-host
+LOCAL_REQUIRED_MODULES := tzdata-host tzlookup.xml-host
 LOCAL_CORE_LIBRARY := true
 include $(BUILD_HOST_DALVIK_JAVA_LIBRARY)
 
@@ -361,7 +361,7 @@
 LOCAL_JAVA_LANGUAGE_VERSION := 1.8
 LOCAL_MODULE := core-libart-hostdex
 LOCAL_JAVA_LIBRARIES := core-oj-hostdex
-LOCAL_REQUIRED_MODULES := tzdata-host
+LOCAL_REQUIRED_MODULES := tzdata-host tzlookup.xml-host
 include $(BUILD_HOST_DALVIK_JAVA_LIBRARY)
 
 # A library that exists to satisfy javac when
diff --git a/luni/src/main/java/libcore/util/TimeZoneDataFiles.java b/luni/src/main/java/libcore/util/TimeZoneDataFiles.java
index 8361339..c792665 100644
--- a/luni/src/main/java/libcore/util/TimeZoneDataFiles.java
+++ b/luni/src/main/java/libcore/util/TimeZoneDataFiles.java
@@ -26,6 +26,14 @@
 
     private TimeZoneDataFiles() {}
 
+    /**
+     * Returns two time zone file paths for the specified file name in an array in the order they
+     * should be tried. See {@link #generateIcuDataPath()} for ICU files instead.
+     * <ul>
+     * <li>[0] - the location of the file in the /data partition (may not exist).</li>
+     * <li>[1] - the location of the file in the /system partition (should exist).</li>
+     * </ul>
+     */
     // VisibleForTesting
     public static String[] getTimeZoneFilePaths(String fileName) {
         return new String[] {
diff --git a/luni/src/main/java/libcore/util/TimeZoneFinder.java b/luni/src/main/java/libcore/util/TimeZoneFinder.java
new file mode 100644
index 0000000..4e47df4
--- /dev/null
+++ b/luni/src/main/java/libcore/util/TimeZoneFinder.java
@@ -0,0 +1,572 @@
+/*
+ * 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);
+        }
+    }
+}
diff --git a/luni/src/test/java/libcore/util/TimeZoneFinderTest.java b/luni/src/test/java/libcore/util/TimeZoneFinderTest.java
new file mode 100644
index 0000000..0b31c9a
--- /dev/null
+++ b/luni/src/test/java/libcore/util/TimeZoneFinderTest.java
@@ -0,0 +1,729 @@
+/*
+ * 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.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import android.icu.util.TimeZone;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+public class TimeZoneFinderTest {
+
+    private static final int HOUR_MILLIS = 60 * 60 * 1000;
+
+    // Zones used in the tests. NEW_YORK_TZ and LONDON_TZ chosen because they never overlap but both
+    // have DST.
+    private static final TimeZone NEW_YORK_TZ = TimeZone.getTimeZone("America/New_York");
+    private static final TimeZone LONDON_TZ = TimeZone.getTimeZone("Europe/London");
+    // A zone that matches LONDON_TZ for WHEN_NO_DST. It does not have DST so differs for WHEN_DST.
+    private static final TimeZone REYKJAVIK_TZ = TimeZone.getTimeZone("Atlantic/Reykjavik");
+    // Another zone that matches LONDON_TZ for WHEN_NO_DST. It does not have DST so differs for
+    // WHEN_DST.
+    private static final TimeZone UTC_TZ = TimeZone.getTimeZone("Etc/UTC");
+
+    // 22nd July 2017, 13:14:15 UTC (DST time in all the timezones used in these tests that observe
+    // DST).
+    private static final long WHEN_DST = 1500729255000L;
+    // 22nd January 2018, 13:14:15 UTC (non-DST time in all timezones used in these tests).
+    private static final long WHEN_NO_DST = 1516626855000L;
+
+    private static final int LONDON_DST_OFFSET_MILLIS = HOUR_MILLIS;
+    private static final int LONDON_NO_DST_OFFSET_MILLIS = 0;
+
+    private static final int NEW_YORK_DST_OFFSET_MILLIS = -4 * HOUR_MILLIS;
+    private static final int NEW_YORK_NO_DST_OFFSET_MILLIS = -5 * HOUR_MILLIS;
+
+    private Path testDir;
+
+    @Before
+    public void setUp() throws Exception {
+        testDir = Files.createTempDirectory("TimeZoneFinderTest");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        // Delete the testDir and all contents.
+        Files.walkFileTree(testDir, new SimpleFileVisitor<Path>() {
+            @Override
+            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+                    throws IOException {
+                Files.delete(file);
+                return FileVisitResult.CONTINUE;
+            }
+
+            @Override
+            public FileVisitResult postVisitDirectory(Path dir, IOException exc)
+                    throws IOException {
+                Files.delete(dir);
+                return FileVisitResult.CONTINUE;
+            }
+        });
+    }
+
+    @Test
+    public void createInstanceWithFallback() throws Exception {
+        String validXml1 = "<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n";
+        String validXml2 = "<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/Paris</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n";
+
+        String invalidXml = "<foo></foo>\n";
+        checkValidateThrowsParserException(invalidXml);
+
+        String validFile1 = createFile(validXml1);
+        String validFile2 = createFile(validXml2);
+        String invalidFile = createFile(invalidXml);
+        String missingFile = createMissingFile();
+
+        TimeZoneFinder file1ThenFile2 =
+                TimeZoneFinder.createInstanceWithFallback(validFile1, validFile2);
+        assertZonesEqual(zones("Europe/London"), file1ThenFile2.lookupTimeZonesByCountry("gb"));
+
+        TimeZoneFinder missingFileThenFile1 =
+                TimeZoneFinder.createInstanceWithFallback(missingFile, validFile1);
+        assertZonesEqual(zones("Europe/London"),
+                missingFileThenFile1.lookupTimeZonesByCountry("gb"));
+
+        TimeZoneFinder file2ThenFile1 =
+                TimeZoneFinder.createInstanceWithFallback(validFile2, validFile1);
+        assertZonesEqual(zones("Europe/Paris"), file2ThenFile1.lookupTimeZonesByCountry("gb"));
+
+        // We assume the file has been validated so an invalid file is not checked ahead of time.
+        // We will find out when we look something up.
+        TimeZoneFinder invalidThenValid =
+                TimeZoneFinder.createInstanceWithFallback(invalidFile, validFile1);
+        assertNull(invalidThenValid.lookupTimeZonesByCountry("gb"));
+
+        // This is not a normal case: It would imply a define shipped without a file in /system!
+        TimeZoneFinder missingFiles =
+                TimeZoneFinder.createInstanceWithFallback(missingFile, missingFile);
+        assertNull(missingFiles.lookupTimeZonesByCountry("gb"));
+    }
+
+    @Test
+    public void xmlParsing_emptyFile() throws Exception {
+        checkValidateThrowsParserException("");
+    }
+
+    @Test
+    public void xmlParsing_unexpectedRootElement() throws Exception {
+        checkValidateThrowsParserException("<foo></foo>\n");
+    }
+
+    @Test
+    public void xmlParsing_missingCountryZones() throws Exception {
+        checkValidateThrowsParserException("<timezones></timezones>\n");
+    }
+
+    @Test
+    public void xmlParsing_noCountriesOk() throws Exception {
+        validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+    }
+
+    @Test
+    public void xmlParsing_unexpectedComments() throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <!-- This is a comment -->"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        // This is a crazy comment, but also helps prove that TEXT nodes are coalesced by the
+        // parser.
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/<!-- Don't freak out! -->London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+    }
+
+    @Test
+    public void xmlParsing_unexpectedElementsIgnored() throws Exception {
+        String unexpectedElement = "<unexpected-element>\n<a /></unexpected-element>\n";
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  " + unexpectedElement
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    " + unexpectedElement
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      " + unexpectedElement
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "      " + unexpectedElement
+                + "      <id>Europe/Paris</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London", "Europe/Paris"),
+                finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "    " + unexpectedElement
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        // This test is important because it ensures we can extend the format in future with
+        // more information.
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "  " + unexpectedElement
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+    }
+
+    @Test
+    public void xmlParsing_unexpectedTextIgnored() throws Exception {
+        String unexpectedText = "unexpected-text";
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  " + unexpectedText
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    " + unexpectedText
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      " + unexpectedText
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+
+        finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "      " + unexpectedText
+                + "      <id>Europe/Paris</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London", "Europe/Paris"),
+                finder.lookupTimeZonesByCountry("gb"));
+    }
+
+    @Test
+    public void xmlParsing_truncatedInput() throws Exception {
+        checkValidateThrowsParserException("<timezones>\n");
+
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n");
+
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n");
+
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n");
+
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n");
+
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n");
+    }
+
+    @Test
+    public void xmlParsing_unexpectedChildInTimeZoneIdThrows() throws Exception {
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id><unexpected-element /></id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+    }
+
+    @Test
+    public void xmlParsing_unknownTimeZoneIdIgnored() throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Unknown_Id</id>\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+    }
+
+    @Test
+    public void xmlParsing_missingCountryCode() throws Exception {
+        checkValidateThrowsParserException("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country>\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+    }
+
+    @Test
+    public void xmlParsing_unknownCountryReturnsNull() throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertNull(finder.lookupTimeZonesByCountry("gb"));
+    }
+
+    @Test
+    public void lookupTimeZonesByCountry_structuresAreImmutable() throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+
+        List<TimeZone> gbList = finder.lookupTimeZonesByCountry("gb");
+        assertEquals(1, gbList.size());
+        assertImmutableList(gbList);
+        assertImmutableTimeZone(gbList.get(0));
+
+        assertNull(finder.lookupTimeZonesByCountry("unknown"));
+    }
+
+    @Test
+    public void lookupTimeZoneByCountryAndOffset_unknownCountry() throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"xx\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+
+        // Demonstrate the arguments work for a known country.
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, null /* bias */));
+
+        // Test with an unknown country.
+        String unknownCountryCode = "yy";
+        assertNull(finder.lookupTimeZoneByCountryAndOffset(unknownCountryCode,
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
+
+        assertNull(finder.lookupTimeZoneByCountryAndOffset(unknownCountryCode,
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
+    }
+
+    @Test
+    public void lookupTimeZoneByCountryAndOffset_oneCandidate() throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"xx\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+
+        // The three parameters match the configured zone: offset, isDst and when.
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_NO_DST_OFFSET_MILLIS,
+                        false /* isDst */, WHEN_NO_DST, null /* bias */));
+
+        // Some lookup failure cases where the offset, isDst and when do not match the configured
+        // zone.
+        TimeZone noDstMatch1 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch1);
+
+        TimeZone noDstMatch2 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch2);
+
+        TimeZone noDstMatch3 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch3);
+
+        TimeZone noDstMatch4 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch4);
+
+        TimeZone noDstMatch5 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch5);
+
+        TimeZone noDstMatch6 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch6);
+
+        // Some bias cases below.
+
+        // The bias is irrelevant here: it matches what would be returned anyway.
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_NO_DST_OFFSET_MILLIS,
+                        false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+        // A sample of a non-matching case with bias.
+        assertNull(finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                true /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+
+        // The bias should be ignored: it doesn't match any of the country's zones.
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
+
+        // The bias should still be ignored even though it matches the offset information given:
+        // it doesn't match any of the country's configured zones.
+        assertNull(finder.lookupTimeZoneByCountryAndOffset("xx", NEW_YORK_DST_OFFSET_MILLIS,
+                true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
+    }
+
+    @Test
+    public void lookupTimeZoneByCountryAndOffset_multipleNonOverlappingCandidates()
+            throws Exception {
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"xx\">\n"
+                + "      <id>America/New_York</id>\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+
+        // The three parameters match the configured zone: offset, isDst and when.
+        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(NEW_YORK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
+                NEW_YORK_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(NEW_YORK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
+                NEW_YORK_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */));
+
+        // Some lookup failure cases where the offset, isDst and when do not match the configured
+        // zone. This is a sample, not complete.
+        TimeZone noDstMatch1 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch1);
+
+        TimeZone noDstMatch2 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch2);
+
+        TimeZone noDstMatch3 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                NEW_YORK_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch3);
+
+        TimeZone noDstMatch4 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                NEW_YORK_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch4);
+
+        TimeZone noDstMatch5 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch5);
+
+        TimeZone noDstMatch6 = finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch6);
+
+        // Some bias cases below.
+
+        // The bias is irrelevant here: it matches what would be returned anyway.
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_NO_DST_OFFSET_MILLIS,
+                        false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+        // A sample of a non-matching case with bias.
+        assertNull(finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                true /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+
+        // The bias should be ignored: it matches a configured zone, but the offset is wrong so
+        // should not be considered a match.
+        assertZoneEquals(LONDON_TZ,
+                finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
+    }
+
+    // This is an artificial case very similar to America/Denver and America/Phoenix in the US: both
+    // have the same offset for 6 months of the year but diverge. Australia/Lord_Howe too.
+    @Test
+    public void lookupTimeZoneByCountryAndOffset_multipleOverlappingCandidates() throws Exception {
+        // Three zones that have the same offset for some of the year. Europe/London changes
+        // offset WHEN_DST, the others do not.
+        TimeZoneFinder finder = validate("<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"xx\">\n"
+                + "      <id>Atlantic/Reykjavik</id>\n"
+                + "      <id>Europe/London</id>\n"
+                + "      <id>Etc/UTC</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+
+        // This is the no-DST offset for LONDON_TZ, REYKJAVIK_TZ. UTC_TZ.
+        final int noDstOffset = LONDON_NO_DST_OFFSET_MILLIS;
+        // This is the DST offset for LONDON_TZ.
+        final int dstOffset = LONDON_DST_OFFSET_MILLIS;
+
+        // The three parameters match the configured zone: offset, isDst and when.
+        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
+                true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
+                true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                false /* isDst */, WHEN_DST, null /* bias */));
+
+        // Some lookup failure cases where the offset, isDst and when do not match the configured
+        // zones.
+        TimeZone noDstMatch1 = finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
+                true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch1);
+
+        TimeZone noDstMatch2 = finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                true /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch2);
+
+        TimeZone noDstMatch3 = finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch3);
+
+        TimeZone noDstMatch4 = finder.lookupTimeZoneByCountryAndOffset("xx", dstOffset,
+                false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch4);
+
+
+        // Some bias cases below.
+
+        // The bias is relevant here: it overrides what would be returned naturally.
+        assertZoneEquals(REYKJAVIK_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+        assertZoneEquals(UTC_TZ, finder.lookupTimeZoneByCountryAndOffset("xx", noDstOffset,
+                false /* isDst */, WHEN_NO_DST, UTC_TZ /* bias */));
+
+        // The bias should be ignored: it matches a configured zone, but the offset is wrong so
+        // should not be considered a match.
+        assertZoneEquals(LONDON_TZ, finder.lookupTimeZoneByCountryAndOffset("xx",
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, REYKJAVIK_TZ /* bias */));
+    }
+
+    @Test
+    public void consistencyTest() throws Exception {
+        // Confirm that no new zones have been added to zones.tab without also adding them to the
+        // configuration used to drive TimeZoneFinder.
+
+        // zone.tab is a tab separated ASCII file provided by IANA and included in Android's tzdata
+        // file. Each line contains a mapping from country code -> zone ID. The ordering used by
+        // TimeZoneFinder is Android-specific, but we can use zone.tab to make sure we know about
+        // all country zones. Any update to tzdata that adds, renames, or removes zones should be
+        // reflected in the file used by TimeZoneFinder.
+        Map<String, Set<String>> zoneTabMappings = new HashMap<>();
+        for (String line : ZoneInfoDB.getInstance().getZoneTab().split("\n")) {
+            int countryCodeEnd = line.indexOf('\t', 1);
+            int olsonIdStart = line.indexOf('\t', 4) + 1;
+            int olsonIdEnd = line.indexOf('\t', olsonIdStart);
+            if (olsonIdEnd == -1) {
+                olsonIdEnd = line.length(); // Not all zone.tab lines have a comment.
+            }
+            String countryCode = line.substring(0, countryCodeEnd);
+            String olsonId = line.substring(olsonIdStart, olsonIdEnd);
+            Set<String> zoneIds = zoneTabMappings.get(countryCode);
+            if (zoneIds == null) {
+                zoneIds = new HashSet<>();
+                zoneTabMappings.put(countryCode, zoneIds);
+            }
+            zoneIds.add(olsonId);
+        }
+
+        TimeZoneFinder timeZoneFinder = TimeZoneFinder.getInstance();
+        for (Map.Entry<String, Set<String>> countryEntry : zoneTabMappings.entrySet()) {
+            String countryCode = countryEntry.getKey();
+            // Android uses lower case, IANA uses upper.
+            countryCode = countryCode.toLowerCase();
+
+            List<String> ianaZoneIds = countryEntry.getValue().stream().sorted()
+                    .collect(Collectors.toList());
+            List<TimeZone> androidZones = timeZoneFinder.lookupTimeZonesByCountry(countryCode);
+            List<String> androidZoneIds =
+                    androidZones.stream().map(TimeZone::getID).sorted()
+                            .collect(Collectors.toList());
+
+            assertEquals("Android zones for " + countryCode + " do not match IANA data",
+                    ianaZoneIds, androidZoneIds);
+        }
+    }
+
+    private void assertImmutableTimeZone(TimeZone timeZone) {
+        try {
+            timeZone.setRawOffset(1000);
+            fail();
+        } catch (UnsupportedOperationException expected) {
+        }
+    }
+
+    private static void assertImmutableList(List<TimeZone> timeZones) {
+        try {
+            timeZones.add(null);
+            fail();
+        } catch (UnsupportedOperationException expected) {
+        }
+    }
+
+    private static void assertZoneEquals(TimeZone expected, TimeZone actual) {
+        // TimeZone.equals() only checks the ID, but that's ok for these tests.
+        assertEquals(expected, actual);
+    }
+
+    private static void assertZonesEqual(List<TimeZone> expected, List<TimeZone> actual) {
+        // TimeZone.equals() only checks the ID, but that's ok for these tests.
+        assertEquals(expected, actual);
+    }
+
+    private static void checkValidateThrowsParserException(String xml) throws Exception {
+        try {
+            validate(xml);
+            fail();
+        } catch (IOException expected) {
+        }
+    }
+
+    private static TimeZoneFinder validate(String xml) throws IOException {
+        TimeZoneFinder timeZoneFinder = TimeZoneFinder.createInstanceForTests(xml);
+        timeZoneFinder.validate();
+        return timeZoneFinder;
+    }
+
+    private static List<TimeZone> zones(String... ids) {
+        return Arrays.stream(ids).map(TimeZone::getTimeZone).collect(Collectors.toList());
+    }
+
+    private String createFile(String fileContent) throws IOException {
+        Path filePath = Files.createTempFile(testDir, null, null);
+        Files.write(filePath, fileContent.getBytes(StandardCharsets.UTF_8));
+        return filePath.toString();
+    }
+
+    private String createMissingFile() throws IOException {
+        Path filePath = Files.createTempFile(testDir, null, null);
+        Files.delete(filePath);
+        return filePath.toString();
+    }
+}
diff --git a/non_openjdk_java_files.mk b/non_openjdk_java_files.mk
index 31ee139..9d33f46 100644
--- a/non_openjdk_java_files.mk
+++ b/non_openjdk_java_files.mk
@@ -308,6 +308,7 @@
   luni/src/main/java/libcore/util/RecoverySystem.java \
   luni/src/main/java/libcore/util/SneakyThrow.java \
   luni/src/main/java/libcore/util/TimeZoneDataFiles.java \
+  luni/src/main/java/libcore/util/TimeZoneFinder.java \
   luni/src/main/java/libcore/util/ZoneInfo.java \
   luni/src/main/java/libcore/util/ZoneInfoDB.java \
   luni/src/main/java/libcore/util/HexEncoding.java \
diff --git a/tzdata/shared2/src/main/libcore/tzdata/shared2/TimeZoneDistro.java b/tzdata/shared2/src/main/libcore/tzdata/shared2/TimeZoneDistro.java
index dd01fb0..9358c70 100644
--- a/tzdata/shared2/src/main/libcore/tzdata/shared2/TimeZoneDistro.java
+++ b/tzdata/shared2/src/main/libcore/tzdata/shared2/TimeZoneDistro.java
@@ -37,6 +37,9 @@
     /** The name of the file inside the distro containing ICU TZ data. */
     public static final String ICU_DATA_FILE_NAME = "icu/icu_tzdata.dat";
 
+    /** The name of the file inside the distro containing time zone lookup data. */
+    public static final String TZLOOKUP_FILE_NAME = "tzlookup.xml";
+
     /**
      * The name of the file inside the distro containing the distro version information.
      * The content is ASCII bytes representing a set of version numbers. See {@link DistroVersion}.
diff --git a/tzdata/tools2/src/main/libcore/tzdata/update2/tools/CreateTimeZoneDistro.java b/tzdata/tools2/src/main/libcore/tzdata/update2/tools/CreateTimeZoneDistro.java
index 4b70152..7fabf7a 100644
--- a/tzdata/tools2/src/main/libcore/tzdata/update2/tools/CreateTimeZoneDistro.java
+++ b/tzdata/tools2/src/main/libcore/tzdata/update2/tools/CreateTimeZoneDistro.java
@@ -30,7 +30,7 @@
  * A command-line tool for creating a timezone update distro.
  *
  * Args:
- * tzdata.properties file - the file describing the distro (see template file in tzdata/tools)
+ * tzdata.properties file - the file describing the distro (see template file in tzdata/tools2)
  * output file - the name of the file to be generated
  */
 public class CreateTimeZoneDistro {
@@ -56,8 +56,9 @@
                 Integer.parseInt(getMandatoryProperty(p, "revision")));
         TimeZoneDistroBuilder builder = new TimeZoneDistroBuilder()
                 .setDistroVersion(distroVersion)
-                .setTzData(getMandatoryPropertyFile(p, "bionic.file"))
-                .setIcuData(getMandatoryPropertyFile(p, "icu.file"));
+                .setTzDataFile(getMandatoryPropertyFile(p, "bionic.file"))
+                .setIcuDataFile(getMandatoryPropertyFile(p, "icu.file"))
+                .setTzLookupFile(getMandatoryPropertyFile(p, "tzlookup.file"));
 
         TimeZoneDistro distro = builder.build();
         File outputFile = new File(args[1]);
diff --git a/tzdata/tools2/src/main/libcore/tzdata/update2/tools/TimeZoneDistroBuilder.java b/tzdata/tools2/src/main/libcore/tzdata/update2/tools/TimeZoneDistroBuilder.java
index 4c12e96..9860489 100644
--- a/tzdata/tools2/src/main/libcore/tzdata/update2/tools/TimeZoneDistroBuilder.java
+++ b/tzdata/tools2/src/main/libcore/tzdata/update2/tools/TimeZoneDistroBuilder.java
@@ -19,6 +19,7 @@
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 import libcore.tzdata.shared2.DistroException;
@@ -34,6 +35,7 @@
     private DistroVersion distroVersion;
     private byte[] tzData;
     private byte[] icuData;
+    private String tzLookupXml;
 
     public TimeZoneDistroBuilder setDistroVersion(DistroVersion distroVersion) {
         this.distroVersion = distroVersion;
@@ -56,11 +58,11 @@
         return this;
     }
 
-    public TimeZoneDistroBuilder setTzData(File tzDataFile) throws IOException {
-        return setTzData(readFileAsByteArray(tzDataFile));
+    public TimeZoneDistroBuilder setTzDataFile(File tzDataFile) throws IOException {
+        return setTzDataFile(readFileAsByteArray(tzDataFile));
     }
 
-    public TimeZoneDistroBuilder setTzData(byte[] tzData) {
+    public TimeZoneDistroBuilder setTzDataFile(byte[] tzData) {
         this.tzData = tzData;
         return this;
     }
@@ -71,15 +73,24 @@
         return this;
     }
 
-    public TimeZoneDistroBuilder setIcuData(File icuDataFile) throws IOException {
-        return setIcuData(readFileAsByteArray(icuDataFile));
+    public TimeZoneDistroBuilder setIcuDataFile(File icuDataFile) throws IOException {
+        return setIcuDataFile(readFileAsByteArray(icuDataFile));
     }
 
-    public TimeZoneDistroBuilder setIcuData(byte[] icuData) {
+    public TimeZoneDistroBuilder setIcuDataFile(byte[] icuData) {
         this.icuData = icuData;
         return this;
     }
 
+    public TimeZoneDistroBuilder setTzLookupFile(File tzLookupFile) throws IOException {
+        return setTzLookupXml(readFileAsUtf8(tzLookupFile));
+    }
+
+    public TimeZoneDistroBuilder setTzLookupXml(String tzlookupXml) {
+        this.tzLookupXml = tzlookupXml;
+        return this;
+    }
+
     // For use in tests.
     public TimeZoneDistroBuilder clearIcuDataForTests() {
         this.icuData = null;
@@ -102,6 +113,10 @@
             if (icuData != null) {
                 addZipEntry(zos, TimeZoneDistro.ICU_DATA_FILE_NAME, icuData);
             }
+            if (tzLookupXml != null) {
+                addZipEntry(zos, TimeZoneDistro.TZLOOKUP_FILE_NAME,
+                        tzLookupXml.getBytes(StandardCharsets.UTF_8));
+            }
         } catch (IOException e) {
             throw new DistroException("Unable to create zip file", e);
         }
@@ -140,7 +155,7 @@
     /**
      * Returns the contents of 'path' as a byte array.
      */
-    public static byte[] readFileAsByteArray(File file) throws IOException {
+    private static byte[] readFileAsByteArray(File file) throws IOException {
         byte[] buffer = new byte[8192];
         ByteArrayOutputStream baos = new ByteArrayOutputStream();
         try (FileInputStream  fis = new FileInputStream(file)) {
@@ -151,5 +166,12 @@
         }
         return baos.toByteArray();
     }
+
+    /**
+     * Returns the contents of 'path' as a String, having interpreted the file as UTF-8.
+     */
+    private String readFileAsUtf8(File file) throws IOException {
+        return new String(readFileAsByteArray(file), StandardCharsets.UTF_8);
+    }
 }
 
diff --git a/tzdata/tools2/testing/prepareTzDataUpdates.sh b/tzdata/tools2/testing/prepareTzDataUpdates.sh
index 5b9b2ab..9a41942 100755
--- a/tzdata/tools2/testing/prepareTzDataUpdates.sh
+++ b/tzdata/tools2/testing/prepareTzDataUpdates.sh
@@ -26,6 +26,7 @@
 
 # Get the current tzdata version and find both the previous and new versions.
 TZDATA=libc/zoneinfo/tzdata
+TZLOOKUP=libc/zoneinfo/tzlookup.xml
 
 TZHEADER=$(head -n1 bionic/$TZDATA | cut -c1-11)
 
@@ -88,6 +89,7 @@
 rules.version=${TZ_PREVIOUS}
 bionic.file=${TMP_PREVIOUS}/tzdata
 icu.file=${TMP_PREVIOUS}/icu_tzdata.dat
+tzlookup.file=${ANDROID_BUILD_TOP}/bionic/${TZLOOKUP}
 EOF
 
 TZ_PREVIOUS_UPDATE_ZIP=update_${TZ_PREVIOUS}_test.zip
@@ -117,6 +119,7 @@
 rules.version=${TZ_CURRENT}
 bionic.file=${TMP_CURRENT}/tzdata
 icu.file=${TMP_CURRENT}/icu_tzdata.dat
+tzlookup.file=${ANDROID_BUILD_TOP}/bionic/${TZLOOKUP}
 EOF
 
 TZ_CURRENT_UPDATE_ZIP=update_${TZ_CURRENT}_test.zip
@@ -146,6 +149,7 @@
 rules.version=${TZ_NEXT}
 bionic.file=${TMP_NEXT}/tzdata
 icu.file=${TMP_NEXT}/icu_tzdata.dat
+tzlookup.file=${ANDROID_BUILD_TOP}/bionic/${TZLOOKUP}
 EOF
 
 TZ_NEXT_UPDATE_ZIP=update_${TZ_NEXT}_test.zip
diff --git a/tzdata/tools2/tzupdate.properties b/tzdata/tools2/tzupdate.properties
index 82fa2c4..264960b 100644
--- a/tzdata/tools2/tzupdate.properties
+++ b/tzdata/tools2/tzupdate.properties
@@ -8,4 +8,4 @@
 
 bionic.file=
 icu.file=
-
+tzlookup.file=
diff --git a/tzdata/update2/src/main/libcore/tzdata/update2/TimeZoneDistroInstaller.java b/tzdata/update2/src/main/libcore/tzdata/update2/TimeZoneDistroInstaller.java
index e1ed794..baadc85 100644
--- a/tzdata/update2/src/main/libcore/tzdata/update2/TimeZoneDistroInstaller.java
+++ b/tzdata/update2/src/main/libcore/tzdata/update2/TimeZoneDistroInstaller.java
@@ -25,6 +25,7 @@
 import libcore.tzdata.shared2.FileUtils;
 import libcore.tzdata.shared2.StagedDistroOperation;
 import libcore.tzdata.shared2.TimeZoneDistro;
+import libcore.util.TimeZoneFinder;
 import libcore.util.ZoneInfoDB;
 
 /**
@@ -150,6 +151,7 @@
                 return INSTALL_FAIL_RULES_TOO_OLD;
             }
 
+            // Validate the tzdata file.
             File zoneInfoFile = new File(workingDir, TimeZoneDistro.TZDATA_FILE_NAME);
             ZoneInfoDB.TzData tzData = ZoneInfoDB.TzData.loadTzData(zoneInfoFile.getPath());
             if (tzData == null) {
@@ -164,7 +166,23 @@
             } finally {
                 tzData.close();
             }
-            // TODO(nfuller): Add deeper validity checks / canarying before applying.
+
+            // Validate the tzlookup.xml file.
+            File tzLookupFile = new File(workingDir, TimeZoneDistro.TZLOOKUP_FILE_NAME);
+            if (!tzLookupFile.exists()) {
+                Slog.i(logTag, "Update not applied: " + tzLookupFile + " does not exist");
+                return INSTALL_FAIL_BAD_DISTRO_STRUCTURE;
+            }
+            try {
+                TimeZoneFinder timeZoneFinder =
+                        TimeZoneFinder.createInstance(tzLookupFile.getPath());
+                timeZoneFinder.validate();
+            } catch (IOException e) {
+                Slog.i(logTag, "Update not applied: " + tzLookupFile + " failed validation", e);
+                return INSTALL_FAIL_VALIDATION_ERROR;
+            }
+
+            // TODO(nfuller): Add validity checks for ICU data / canarying before applying.
             // http://b/31008728
 
             Slog.i(logTag, "Applying time zone update");
diff --git a/tzdata/update2/src/test/libcore/tzdata/update2/TimeZoneDistroInstallerTest.java b/tzdata/update2/src/test/libcore/tzdata/update2/TimeZoneDistroInstallerTest.java
index cf40bf6..3af9f15 100644
--- a/tzdata/update2/src/test/libcore/tzdata/update2/TimeZoneDistroInstallerTest.java
+++ b/tzdata/update2/src/test/libcore/tzdata/update2/TimeZoneDistroInstallerTest.java
@@ -24,7 +24,6 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.Arrays;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 import java.util.zip.ZipOutputStream;
@@ -190,7 +189,7 @@
         assertNoInstalledDistro();
     }
 
-    /** Tests that a distro with a missing file will not update the content. */
+    /** Tests that a distro with a missing tzdata file will not update the content. */
     public void testStageInstallWithErrorCode_missingTzDataFile() throws Exception {
         TimeZoneDistro stagedDistro = createValidTimeZoneDistro(NEW_RULES_VERSION, 1);
         assertEquals(
@@ -209,7 +208,7 @@
         assertNoInstalledDistro();
     }
 
-    /** Tests that a distro with a missing file will not update the content. */
+    /** Tests that a distro with a missing ICU file will not update the content. */
     public void testStageInstallWithErrorCode_missingIcuFile() throws Exception {
         TimeZoneDistro stagedDistro = createValidTimeZoneDistro(NEW_RULES_VERSION, 1);
         assertEquals(
@@ -228,6 +227,44 @@
         assertNoInstalledDistro();
     }
 
+    /** Tests that a distro with a missing tzlookup file will not update the content. */
+    public void testStageInstallWithErrorCode_missingTzLookupFile() throws Exception {
+        TimeZoneDistro stagedDistro = createValidTimeZoneDistro(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(stagedDistro.getBytes()));
+        assertInstallDistroStaged(stagedDistro);
+
+        TimeZoneDistro incompleteDistro =
+                createValidTimeZoneDistroBuilder(NEWER_RULES_VERSION, 1)
+                        .setTzLookupXml(null)
+                        .buildUnvalidated();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_BAD_DISTRO_STRUCTURE,
+                installer.stageInstallWithErrorCode(incompleteDistro.getBytes()));
+        assertInstallDistroStaged(stagedDistro);
+        assertNoInstalledDistro();
+    }
+
+    /** Tests that a distro with a bad tzlookup file will not update the content. */
+    public void testStageInstallWithErrorCode_badTzLookupFile() throws Exception {
+        TimeZoneDistro stagedDistro = createValidTimeZoneDistro(NEW_RULES_VERSION, 1);
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_SUCCESS,
+                installer.stageInstallWithErrorCode(stagedDistro.getBytes()));
+        assertInstallDistroStaged(stagedDistro);
+
+        TimeZoneDistro incompleteDistro =
+                createValidTimeZoneDistroBuilder(NEWER_RULES_VERSION, 1)
+                        .setTzLookupXml("<foo />")
+                        .buildUnvalidated();
+        assertEquals(
+                TimeZoneDistroInstaller.INSTALL_FAIL_VALIDATION_ERROR,
+                installer.stageInstallWithErrorCode(incompleteDistro.getBytes()));
+        assertInstallDistroStaged(stagedDistro);
+        assertNoInstalledDistro();
+    }
+
     /**
      * Tests that an update will be unpacked even if there is a partial update from a previous run.
      */
@@ -449,6 +486,17 @@
 
         byte[] bionicTzData = createTzData(rulesVersion);
         byte[] icuData = new byte[] { 'a' };
+        String tzlookupXml = "<timezones>\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"us\">\n"
+                + "      <id>America/New_York\"</id>\n"
+                + "      <id>America/Los_Angeles</id>\n"
+                + "    </country>\n"
+                + "    <country code=\"gb\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n";
         DistroVersion distroVersion = new DistroVersion(
                 DistroVersion.CURRENT_FORMAT_MAJOR_VERSION,
                 DistroVersion.CURRENT_FORMAT_MINOR_VERSION,
@@ -456,8 +504,9 @@
                 revision);
         return new TimeZoneDistroBuilder()
                 .setDistroVersion(distroVersion)
-                .setTzData(bionicTzData)
-                .setIcuData(icuData);
+                .setTzDataFile(bionicTzData)
+                .setIcuDataFile(icuData)
+                .setTzLookupXml(tzlookupXml);
     }
 
     private void assertInstallDistroStaged(TimeZoneDistro expectedDistro) throws Exception {
@@ -476,6 +525,9 @@
         File icuFile = new File(stagedTzDataDir, TimeZoneDistro.ICU_DATA_FILE_NAME);
         assertTrue(icuFile.exists());
 
+        File tzLookupFile = new File(stagedTzDataDir, TimeZoneDistro.TZLOOKUP_FILE_NAME);
+        assertTrue(tzLookupFile.exists());
+
         // Assert getStagedDistroState() is reporting correctly.
         StagedDistroOperation stagedDistroOperation = installer.getStagedDistroOperation();
         assertNotNull(stagedDistroOperation);
@@ -494,6 +546,8 @@
                     actualFile = icuFile;
                 } else if (entryName.endsWith(TimeZoneDistro.TZDATA_FILE_NAME)) {
                     actualFile = bionicFile;
+                } else if (entryName.endsWith(TimeZoneDistro.TZLOOKUP_FILE_NAME)) {
+                    actualFile = tzLookupFile;
                 } else {
                     throw new AssertionFailedError("Unknown file found");
                 }