Addition of new time zone lookup code

Introduction of libcore.util.TimeZoneFinder for
resolving time zones by country / by country + offset
information.

This code is intended to be a work-a-like replacement
for code currently residing in android.util.TimeUtils
and using time_zones_by_country.xml in the plaform.

android.util.TimeUtils and
com.android.internal.telephony.ServiceStateTracker will
be switched over to using it in a follow up change.

At present, the algorithm should return the same
logical results as the code it is intended to replace.

A notable difference is that it uses ICU's TimeZone class
instead of java.util.TimeZone because
it has more convenient methods and the ability to
freeze timezones. More care has been taken to return
immutable data structures than the code it replaces.

This change also changes the TimeZoneDistroInstaller
and associated classes / tests to be aware of and
require the new file.

Bug: 25338903
Test: vogar luni/src/test/java/libcore/util/TimeZoneFinderTest.java
Test: CTS: run cts -m CtsLibcoreTestCases -t libcore.tzdata.shared2
Test: CTS: run cts -m CtsLibcoreTestCases -t libcore.tzdata.update2
Test: CTS: run cts -m CtsLibcoreTestCases -t
libcore.util.TimeZoneFinderTest
Test: Manual testing after install using prepareTzDataUpdates.sh /
      update test app and reboot
Change-Id: Ic6d0a617519a7685815c99a8e41ccab65be1ff57
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");
                 }