Country tz lookup / expose CountyTimeZones

This commit moves CountryTimeZones to be a top-level class.
It also add a replacement for the telephony logic for looking
up a time zone given only an ISO code. The
isDefaultOkForCountryTimeZoneDetection() method logic has been
compared with the existing telephony tz lookup logic and it
returns the same answers for now and in 6 months.

Bug: 63743683
Test: vogar luni/src/test/java/libcore/libcore/util/CountryTimeZonesTest.java
Test: vogar luni/src/test/java/libcore/libcore/util/TimeZoneFinderTest.java
Change-Id: I817991657f02630db241a0e1134accddaf935b1c
diff --git a/luni/src/main/java/libcore/util/CountryTimeZones.java b/luni/src/main/java/libcore/util/CountryTimeZones.java
new file mode 100644
index 0000000..b1c5035
--- /dev/null
+++ b/luni/src/main/java/libcore/util/CountryTimeZones.java
@@ -0,0 +1,284 @@
+/*
+ * 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 android.icu.util.TimeZone;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Information about a country's time zones.
+ */
+public class CountryTimeZones {
+    private final String countryIso;
+    private final String defaultTimeZoneId;
+    private final List<String> timeZoneIds;
+
+    // Memoized frozen ICU TimeZone object for the default.
+    private TimeZone icuDefaultTimeZone;
+    // Memoized frozen ICU TimeZone objects for the timeZoneIds.
+    private List<TimeZone> icuTimeZones;
+
+    private CountryTimeZones(String countryIso, String defaultTimeZoneId,
+            List<String> timeZoneIds) {
+        this.countryIso = countryIso;
+        this.defaultTimeZoneId = defaultTimeZoneId;
+        // Create a defensive copy of the IDs list.
+        this.timeZoneIds = Collections.unmodifiableList(new ArrayList<>(timeZoneIds));
+    }
+
+    /**
+     * Creates a {@link CountryTimeZones} object containing only known time zone IDs.
+     */
+    public static CountryTimeZones createValidated(String countryIso, String defaultTimeZoneId,
+            List<String> countryTimeZoneIds, String debugInfo) {
+
+        // We rely on ZoneInfoDB to tell us what the known valid time zone IDs are. ICU may
+        // recognize more but we want to be sure that zone IDs can be used with java.util as well as
+        // android.icu and ICU is expected to have a superset.
+        String[] validTimeZoneIdsArray = ZoneInfoDB.getInstance().getAvailableIDs();
+        HashSet<String> validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray));
+        List<String> validCountryTimeZoneIds = new ArrayList<>();
+        for (String countryTimeZoneId : countryTimeZoneIds) {
+            if (!validTimeZoneIdsSet.contains(countryTimeZoneId)) {
+                System.logW("Skipping invalid zone: " + countryTimeZoneId + " at " + debugInfo);
+            } else {
+                validCountryTimeZoneIds.add(countryTimeZoneId);
+            }
+        }
+
+        // We don't get too strict at runtime about whether the defaultTimeZoneId must be
+        // one of the country's time zones because this is the data we have to use (we also
+        // assume the data was validated by earlier steps). The default time zone ID must just
+        // be a recognized zone ID: if it's not valid we leave it null.
+        if (!validTimeZoneIdsSet.contains(defaultTimeZoneId)) {
+            System.logW("Invalid default time zone ID: " + defaultTimeZoneId
+                    + " at " + debugInfo);
+            defaultTimeZoneId = null;
+        }
+
+        return new CountryTimeZones(countryIso, defaultTimeZoneId, validCountryTimeZoneIds);
+    }
+
+    /**
+     * Returns the ISO code for the country.
+     */
+    public String getCountryIso() {
+        return countryIso;
+    }
+
+    /**
+     * Returns the default time zone ID for the country. Can return null in cases when no data is
+     * available or the time zone ID provided to
+     * {@link #createValidated(String, String, List, String)} was not recognized.
+     */
+    public synchronized TimeZone getDefaultTimeZone() {
+        if (icuDefaultTimeZone == null) {
+            TimeZone defaultTimeZone;
+            if (defaultTimeZoneId == null) {
+                defaultTimeZone = null;
+            } else {
+                defaultTimeZone = getValidFrozenTimeZoneOrNull(defaultTimeZoneId);
+            }
+            icuDefaultTimeZone = defaultTimeZone;
+        }
+        return icuDefaultTimeZone;
+    }
+
+    /**
+     * Returns the default time zone ID for the country. Can return null in cases when no data is
+     * available or the time zone ID provided to
+     * {@link #createValidated(String, String, List, String)} was not recognized.
+     */
+    public String getDefaultTimeZoneId() {
+        return defaultTimeZoneId;
+    }
+
+    /**
+     * Returns an ordered list of time zone IDs for the country in an undefined but "priority"
+     * order for a country. The list can be empty if there were no zones configured or the
+     * configured zone IDs were not recognized.
+     */
+    public List<String> getTimeZoneIds() {
+        return timeZoneIds;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        CountryTimeZones that = (CountryTimeZones) o;
+
+        if (!countryIso.equals(that.countryIso)) {
+            return false;
+        }
+        if (defaultTimeZoneId != null ? !defaultTimeZoneId.equals(that.defaultTimeZoneId)
+                : that.defaultTimeZoneId != null) {
+            return false;
+        }
+        return timeZoneIds.equals(that.timeZoneIds);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = countryIso.hashCode();
+        result = 31 * result + (defaultTimeZoneId != null ? defaultTimeZoneId.hashCode() : 0);
+        result = 31 * result + timeZoneIds.hashCode();
+        return result;
+    }
+
+    /**
+     * Returns an ordered list of time zones for the country in an undefined but "priority"
+     * order for a country. The list can be empty if there were no zones configured or the
+     * configured zone IDs were not recognized.
+     */
+    public synchronized List<TimeZone> getIcuTimeZones() {
+        if (icuTimeZones == null) {
+            ArrayList<TimeZone> mutableList = new ArrayList<>(timeZoneIds.size());
+            for (String timeZoneId : timeZoneIds) {
+                TimeZone timeZone;
+                if (timeZoneId.equals(defaultTimeZoneId)) {
+                    timeZone = getDefaultTimeZone();
+                } else {
+                    timeZone = getValidFrozenTimeZoneOrNull(timeZoneId);
+                }
+                // This shouldn't happen given the validation that takes place in
+                // createValidatedCountryTimeZones().
+                if (timeZone == null) {
+                    System.logW("Skipping invalid zone: " + timeZoneId);
+                    continue;
+                }
+                mutableList.add(timeZone);
+            }
+            icuTimeZones = Collections.unmodifiableList(mutableList);
+        }
+        return icuTimeZones;
+    }
+
+    /**
+     * Returns {@code true} if the default time zone for the country is either the only zone used or
+     * if it has the same offsets as all other zones used by the country <em>at the specified time
+     * </em> making the default equivalent to all other zones used by the country <em>at that time
+     * </em>.
+     */
+    public boolean isDefaultOkForCountryTimeZoneDetection(long whenMillis) {
+        List<TimeZone> candidates = getIcuTimeZones();
+        if (candidates.isEmpty()) {
+            // Should never happen unless there's been an error loading the data.
+            return false;
+        } else if (candidates.size() == 1) {
+            // The default is the only zone so it's a good candidate.
+            return true;
+        } else {
+            TimeZone countryDefault = getDefaultTimeZone();
+            if (countryDefault == null) {
+                return false;
+            }
+
+            int countryDefaultOffset = countryDefault.getOffset(whenMillis);
+            for (TimeZone candidate : candidates) {
+                if (candidate == countryDefault) {
+                    continue;
+                }
+
+                int candidateOffset = candidate.getOffset(whenMillis);
+                if (countryDefaultOffset != candidateOffset) {
+                    // Multiple different offsets means the default should not be used.
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    /**
+     * Returns a time zone for the country, if there is one, that has the desired properties. If
+     * there are multiple matches and the {@code bias} is one of them then it is returned, otherwise
+     * an arbitrary match is returned based on the {@link #getTimeZoneIds()} ordering.
+     *
+     * @param offsetSeconds the offset from UTC at {@code whenMillis}
+     * @param isDst whether the zone is in DST
+     * @param whenMillis the UTC time to match against
+     * @param bias the time zone to prefer, can be null
+     */
+    public TimeZone lookupByOffsetWithBias(int offsetSeconds, boolean isDst, long whenMillis,
+            TimeZone bias) {
+        if (timeZoneIds == null || timeZoneIds.isEmpty()) {
+            return null;
+        }
+
+        List<TimeZone> candidates = getIcuTimeZones();
+        TimeZone firstMatch = null;
+        for (TimeZone match : candidates) {
+            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]);
+    }
+
+    private static TimeZone getValidFrozenTimeZoneOrNull(String timeZoneId) {
+        TimeZone timeZone = TimeZone.getFrozenTimeZone(timeZoneId);
+        if (timeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID)) {
+            return null;
+        }
+        return timeZone;
+    }
+}
diff --git a/luni/src/main/java/libcore/util/TimeZoneFinder.java b/luni/src/main/java/libcore/util/TimeZoneFinder.java
index fa15086..98af74a 100644
--- a/luni/src/main/java/libcore/util/TimeZoneFinder.java
+++ b/luni/src/main/java/libcore/util/TimeZoneFinder.java
@@ -172,53 +172,11 @@
     public TimeZone lookupTimeZoneByCountryAndOffset(
             String countryIso, int offsetSeconds, boolean isDst, long whenMillis, TimeZone bias) {
 
-        countryIso = normalizeCountryIso(countryIso);
-        List<TimeZone> candidates = lookupTimeZonesByCountry(countryIso);
-        if (candidates == null || candidates.isEmpty()) {
+        CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
+        if (countryTimeZones == null) {
             return null;
         }
-
-        TimeZone firstMatch = null;
-        for (TimeZone match : candidates) {
-            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]);
+        return countryTimeZones.lookupByOffsetWithBias(offsetSeconds, isDst, whenMillis, bias);
     }
 
     /**
@@ -231,8 +189,7 @@
      * null.
      */
     public String lookupDefaultTimeZoneIdByCountry(String countryIso) {
-        countryIso = normalizeCountryIso(countryIso);
-        CountryTimeZones countryTimeZones = findCountryTimeZones(countryIso);
+        CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
         return countryTimeZones == null ? null : countryTimeZones.getDefaultTimeZoneId();
     }
 
@@ -244,9 +201,8 @@
      * zone IDs.
      */
     public List<TimeZone> lookupTimeZonesByCountry(String countryIso) {
-        countryIso = normalizeCountryIso(countryIso);
-        CountryTimeZones countryTimeZones = findCountryTimeZones(countryIso);
-        return countryTimeZones == null ? null : countryTimeZones.getTimeZones();
+        CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
+        return countryTimeZones == null ? null : countryTimeZones.getIcuTimeZones();
     }
 
     /**
@@ -258,17 +214,17 @@
      * in a case when the underlying data files reference only unknown zone IDs.
      */
     public List<String> lookupTimeZoneIdsByCountry(String countryIso) {
-        countryIso = normalizeCountryIso(countryIso);
-        CountryTimeZones countryTimeZones = findCountryTimeZones(countryIso);
+        CountryTimeZones countryTimeZones = lookupCountryTimeZones(countryIso);
         return countryTimeZones == null ? null : countryTimeZones.getTimeZoneIds();
     }
 
     /**
      * Returns a {@link CountryTimeZones} object associated with the specified country code.
      * Caching is handled as needed. If the country code is not recognized or there is an error
-     * during lookup this can return null.
+     * during lookup this method can return null.
      */
-    private CountryTimeZones findCountryTimeZones(String countryIso) {
+    public CountryTimeZones lookupCountryTimeZones(String countryIso) {
+        countryIso = normalizeCountryIso(countryIso);
         synchronized (this) {
             if (lastCountryTimeZones != null
                     && lastCountryTimeZones.getCountryIso().equals(countryIso)) {
@@ -623,7 +579,7 @@
             if (!countryCodeToMatch.equals(countryIso)) {
                 return CONTINUE;
             }
-            validatedCountryTimeZones = createValidatedCountryTimeZones(countryIso,
+            validatedCountryTimeZones = CountryTimeZones.createValidated(countryIso,
                     defaultTimeZoneId, countryTimeZoneIds, debugInfo);
 
             return HALT;
@@ -632,7 +588,7 @@
         /**
          * Returns the CountryTimeZones that matched, or {@code null} if there were no matches.
          */
-        CountryTimeZones getValidatedCountryTimeZones() {
+        private CountryTimeZones getValidatedCountryTimeZones() {
             return validatedCountryTimeZones;
         }
     }
@@ -660,113 +616,9 @@
         }
     }
 
-    /**
-     * Information about a country's time zones.
-     */
-    // VisibleForTesting
-    public static class CountryTimeZones {
-        private final String countryIso;
-        private final String defaultTimeZoneId;
-        private final List<String> timeZoneIds;
-
-        // Memoized frozen ICU TimeZone objects for the timeZoneIds.
-        private List<TimeZone> timeZones;
-
-        public CountryTimeZones(String countryIso, String defaultTimeZoneId,
-                List<String> timeZoneIds) {
-            this.countryIso = countryIso;
-            this.defaultTimeZoneId = defaultTimeZoneId;
-            // Create a defensive copy of the IDs list.
-            this.timeZoneIds = Collections.unmodifiableList(new ArrayList<>(timeZoneIds));
-        }
-
-        public String getCountryIso() {
-            return countryIso;
-        }
-
-        /**
-         * Returns the default time zone ID for a country. Can return null in extreme cases when
-         * invalid data is found.
-         */
-        public String getDefaultTimeZoneId() {
-            return defaultTimeZoneId;
-        }
-
-        /**
-         * Returns an ordered list of time zone IDs for a country in an undefined but "priority"
-         * order for a country. The list can be empty if there were no zones configured or the
-         * configured zone IDs were not recognized.
-         */
-        public List<String> getTimeZoneIds() {
-            return timeZoneIds;
-        }
-
-        /**
-         * Returns an ordered list of time zones for a country in an undefined but "priority"
-         * order for a country. The list can be empty if there were no zones configured or the
-         * configured zone IDs were not recognized.
-         */
-        public synchronized List<TimeZone> getTimeZones() {
-            if (timeZones == null) {
-                ArrayList<TimeZone> mutableList = new ArrayList<>(timeZoneIds.size());
-                for (String timeZoneId : timeZoneIds) {
-                    TimeZone timeZone = getValidFrozenTimeZoneOrNull(timeZoneId);
-                    // This shouldn't happen given the validation that takes place in
-                    // createValidatedCountryTimeZones().
-                    if (timeZone == null) {
-                        System.logW("Skipping invalid zone: " + timeZoneId);
-                        continue;
-                    }
-                    mutableList.add(timeZone);
-                }
-                timeZones = Collections.unmodifiableList(mutableList);
-            }
-            return timeZones;
-        }
-
-        private static TimeZone getValidFrozenTimeZoneOrNull(String timeZoneId) {
-            TimeZone timeZone = TimeZone.getFrozenTimeZone(timeZoneId);
-            if (timeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID)) {
-                return null;
-            }
-            return timeZone;
-        }
-    }
-
     private static String normalizeCountryIso(String countryIso) {
         // Lowercase ASCII is normalized for the purposes of the input files and the code in this
         // class.
         return countryIso.toLowerCase(Locale.US);
     }
-
-    // VisibleForTesting
-    public static CountryTimeZones createValidatedCountryTimeZones(String countryIso,
-            String defaultTimeZoneId, List<String> countryTimeZoneIds, String debugInfo) {
-
-        // We rely on ZoneInfoDB to tell us what the known valid time zone IDs are. ICU may
-        // recognize more but we want to be sure that zone IDs can be used with java.util as well as
-        // android.icu and ICU is expected to have a superset.
-        String[] validTimeZoneIdsArray = ZoneInfoDB.getInstance().getAvailableIDs();
-        HashSet<String> validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray));
-        List<String> validCountryTimeZoneIds = new ArrayList<>();
-        for (String countryTimeZoneId : countryTimeZoneIds) {
-            if (!validTimeZoneIdsSet.contains(countryTimeZoneId)) {
-                System.logW("Skipping invalid zone: " + countryTimeZoneId + " at " + debugInfo);
-            } else {
-                validCountryTimeZoneIds.add(countryTimeZoneId);
-            }
-        }
-
-        // We don't get too strict at runtime about whether the defaultTimeZoneId must be
-        // one of the country's time zones because this is the data we have to use (we also
-        // assume the data was validated by earlier steps). The default time zone ID must just
-        // be a recognized zone ID: if it's not valid we leave it null.
-        if (!validTimeZoneIdsSet.contains(defaultTimeZoneId)) {
-            System.logW("Invalid default time zone ID: " + defaultTimeZoneId
-                    + " at " + debugInfo);
-            defaultTimeZoneId = null;
-        }
-
-        return new CountryTimeZones(countryIso, defaultTimeZoneId, validCountryTimeZoneIds);
-    }
 }
diff --git a/luni/src/test/java/libcore/libcore/util/CountryTimeZonesTest.java b/luni/src/test/java/libcore/libcore/util/CountryTimeZonesTest.java
new file mode 100644
index 0000000..73a9338
--- /dev/null
+++ b/luni/src/test/java/libcore/libcore/util/CountryTimeZonesTest.java
@@ -0,0 +1,372 @@
+/*
+ * 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.libcore.util;
+
+import org.junit.Test;
+
+import android.icu.util.TimeZone;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import libcore.util.CountryTimeZones;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class CountryTimeZonesTest {
+
+    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;
+
+    @Test
+    public void createValidated() throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+        assertEquals("gb", countryTimeZones.getCountryIso());
+        assertEquals("Europe/London", countryTimeZones.getDefaultTimeZoneId());
+        assertZoneEquals(zone("Europe/London"), countryTimeZones.getDefaultTimeZone());
+        assertEquals(list("Europe/London"), countryTimeZones.getTimeZoneIds());
+        assertZonesEqual(zones("Europe/London"), countryTimeZones.getIcuTimeZones());
+    }
+
+    @Test
+    public void createValidated_nullDefault() throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "gb", null, list("Europe/London"), "test");
+        assertNull(countryTimeZones.getDefaultTimeZoneId());
+    }
+
+    @Test
+    public void createValidated_invalidDefault() throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Moon/Tranquility_Base", list("Europe/London", "Moon/Tranquility_Base"),
+                "test");
+
+        // "Moon/Tranquility_Base" is not a valid time zone ID so should not be used.
+        assertNull(countryTimeZones.getDefaultTimeZoneId());
+        assertEquals(list("Europe/London"), countryTimeZones.getTimeZoneIds());
+        assertZonesEqual(zones("Europe/London"), countryTimeZones.getIcuTimeZones());
+    }
+
+    @Test
+    public void createValidated_unknownTimeZoneIdIgnored() throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Unknown_Id", "Europe/London"), "test");
+        assertEquals(list("Europe/London"), countryTimeZones.getTimeZoneIds());
+        assertZonesEqual(zones("Europe/London"), countryTimeZones.getIcuTimeZones());
+    }
+
+    @Test
+    public void structuresAreImmutable() throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+
+        assertImmutableTimeZone(countryTimeZones.getDefaultTimeZone());
+
+        List<TimeZone> tzList = countryTimeZones.getIcuTimeZones();
+        assertEquals(1, tzList.size());
+        assertImmutableList(tzList);
+        assertImmutableTimeZone(tzList.get(0));
+
+        List<String> tzIdList = countryTimeZones.getTimeZoneIds();
+        assertEquals(1, tzIdList.size());
+        assertImmutableList(tzIdList);
+    }
+
+    @Test
+    public void lookupByOffsetWithBias_oneCandidate() throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+
+        // The three parameters match the configured zone: offset, isDst and when.
+        assertZoneEquals(LONDON_TZ,
+                countryTimeZones.lookupByOffsetWithBias(LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ,
+                countryTimeZones.lookupByOffsetWithBias(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 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch1);
+
+        TimeZone noDstMatch2 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch2);
+
+        TimeZone noDstMatch3 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch3);
+
+        TimeZone noDstMatch4 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch4);
+
+        TimeZone noDstMatch5 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch5);
+
+        TimeZone noDstMatch6 = countryTimeZones.lookupByOffsetWithBias(
+                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,
+                countryTimeZones.lookupByOffsetWithBias(LONDON_DST_OFFSET_MILLIS,
+                        true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
+        assertZoneEquals(LONDON_TZ,
+                countryTimeZones.lookupByOffsetWithBias(LONDON_NO_DST_OFFSET_MILLIS,
+                        false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+        // A sample of a non-matching case with bias.
+        assertNull(countryTimeZones.lookupByOffsetWithBias(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,
+                countryTimeZones.lookupByOffsetWithBias(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(countryTimeZones.lookupByOffsetWithBias(NEW_YORK_DST_OFFSET_MILLIS,
+                true /* isDst */, WHEN_DST, NEW_YORK_TZ /* bias */));
+    }
+
+    @Test
+    public void lookupByOffsetWithBias_multipleNonOverlappingCandidates()
+            throws Exception {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "xx", "Europe/London", list("America/New_York", "Europe/London"), "test");
+
+        // The three parameters match the configured zone: offset, isDst and when.
+        assertZoneEquals(LONDON_TZ, countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ, countryTimeZones.lookupByOffsetWithBias(
+                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(NEW_YORK_TZ, countryTimeZones.lookupByOffsetWithBias(
+                NEW_YORK_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(NEW_YORK_TZ, countryTimeZones.lookupByOffsetWithBias(
+                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 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch1);
+
+        TimeZone noDstMatch2 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch2);
+
+        TimeZone noDstMatch3 = countryTimeZones.lookupByOffsetWithBias(
+                NEW_YORK_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch3);
+
+        TimeZone noDstMatch4 = countryTimeZones.lookupByOffsetWithBias(
+                NEW_YORK_NO_DST_OFFSET_MILLIS, true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch4);
+
+        TimeZone noDstMatch5 = countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, false /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch5);
+
+        TimeZone noDstMatch6 = countryTimeZones.lookupByOffsetWithBias(
+                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, countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, LONDON_TZ /* bias */));
+        assertZoneEquals(LONDON_TZ, countryTimeZones.lookupByOffsetWithBias(
+                LONDON_NO_DST_OFFSET_MILLIS, false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+        // A sample of a non-matching case with bias.
+        assertNull(countryTimeZones.lookupByOffsetWithBias(
+                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, countryTimeZones.lookupByOffsetWithBias(
+                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 lookupByOffsetWithBias_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.
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "xx", "Europe/London", list("Atlantic/Reykjavik", "Europe/London", "Etc/UTC"),
+                "test");
+
+        // 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, countryTimeZones.lookupByOffsetWithBias(dstOffset,
+                true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(REYKJAVIK_TZ, countryTimeZones.lookupByOffsetWithBias(noDstOffset,
+                false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ, countryTimeZones.lookupByOffsetWithBias(dstOffset,
+                true /* isDst */, WHEN_DST, null /* bias */));
+        assertZoneEquals(REYKJAVIK_TZ, countryTimeZones.lookupByOffsetWithBias(noDstOffset,
+                false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(REYKJAVIK_TZ, countryTimeZones.lookupByOffsetWithBias(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 = countryTimeZones.lookupByOffsetWithBias(dstOffset,
+                true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch1);
+
+        TimeZone noDstMatch2 = countryTimeZones.lookupByOffsetWithBias(noDstOffset,
+                true /* isDst */, WHEN_DST, null /* bias */);
+        assertNull(noDstMatch2);
+
+        TimeZone noDstMatch3 = countryTimeZones.lookupByOffsetWithBias(noDstOffset,
+                true /* isDst */, WHEN_NO_DST, null /* bias */);
+        assertNull(noDstMatch3);
+
+        TimeZone noDstMatch4 = countryTimeZones.lookupByOffsetWithBias(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, countryTimeZones.lookupByOffsetWithBias(noDstOffset,
+                false /* isDst */, WHEN_NO_DST, null /* bias */));
+        assertZoneEquals(LONDON_TZ, countryTimeZones.lookupByOffsetWithBias(noDstOffset,
+                false /* isDst */, WHEN_NO_DST, LONDON_TZ /* bias */));
+        assertZoneEquals(UTC_TZ, countryTimeZones.lookupByOffsetWithBias(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, countryTimeZones.lookupByOffsetWithBias(
+                LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, REYKJAVIK_TZ /* bias */));
+    }
+
+    @Test
+    public void isDefaultOkForCountryTimeZoneDetection_noZones() {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "xx", "Europe/London", list(), "test");
+        assertFalse(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_DST));
+        assertFalse(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_NO_DST));
+    }
+
+    @Test
+    public void isDefaultOkForCountryTimeZoneDetection_oneZone() {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "xx", "Europe/London", list("Europe/London"), "test");
+        assertTrue(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_DST));
+        assertTrue(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_NO_DST));
+    }
+
+    @Test
+    public void isDefaultOkForCountryTimeZoneDetection_twoZones_overlap() {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "xx", "Europe/London", list("Europe/London", "Etc/UTC"), "test");
+        // Europe/London is the same as UTC in the Winter, so all the zones have the same offset
+        // in Winter, but not in Summer.
+        assertFalse(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_DST));
+        assertTrue(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_NO_DST));
+    }
+
+    @Test
+    public void isDefaultOkForCountryTimeZoneDetection_twoZones_noOverlap() {
+        CountryTimeZones countryTimeZones = CountryTimeZones.createValidated(
+                "xx", "Europe/London", list("Europe/London", "America/New_York"), "test");
+        // The zones have different offsets all year, so it would never be ok to use the default
+        // zone for the country of "xx".
+        assertFalse(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_DST));
+        assertFalse(countryTimeZones.isDefaultOkForCountryTimeZoneDetection(WHEN_NO_DST));
+    }
+
+    private void assertImmutableTimeZone(TimeZone timeZone) {
+        try {
+            timeZone.setRawOffset(1000);
+            fail();
+        } catch (UnsupportedOperationException expected) {
+        }
+    }
+
+    private static <X> void assertImmutableList(List<X> list) {
+        try {
+            list.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 <X> List<X> list(X... values) {
+        return Arrays.asList(values);
+    }
+
+    private static TimeZone zone(String id) {
+        return TimeZone.getTimeZone(id);
+    }
+
+    private static List<TimeZone> zones(String... ids) {
+        return Arrays.stream(ids).map(TimeZone::getTimeZone).collect(Collectors.toList());
+    }
+}
diff --git a/luni/src/test/java/libcore/libcore/util/TimeZoneFinderTest.java b/luni/src/test/java/libcore/libcore/util/TimeZoneFinderTest.java
index 8d83953..c23f8bb 100644
--- a/luni/src/test/java/libcore/libcore/util/TimeZoneFinderTest.java
+++ b/luni/src/test/java/libcore/libcore/util/TimeZoneFinderTest.java
@@ -31,20 +31,18 @@
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 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 libcore.util.CountryTimeZones;
 import libcore.util.TimeZoneFinder;
 import libcore.util.ZoneInfoDB;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
 public class TimeZoneFinderTest {
@@ -109,13 +107,18 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n";
+        CountryTimeZones expectedCountryTimeZones1 = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+
         String validXml2 = "<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
-                + "    <country code=\"gb\" default=\"Europe/London\">\n"
+                + "    <country code=\"gb\" default=\"Europe/Paris\">\n"
                 + "      <id>Europe/Paris</id>\n"
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n";
+        CountryTimeZones expectedCountryTimeZones2 = CountryTimeZones.createValidated(
+                "gb", "Europe/Paris", list("Europe/Paris"), "test");
 
         String invalidXml = "<foo></foo>\n";
         checkValidateThrowsParserException(invalidXml);
@@ -128,36 +131,30 @@
         TimeZoneFinder file1ThenFile2 =
                 TimeZoneFinder.createInstanceWithFallback(validFile1, validFile2);
         assertEquals("2017c", file1ThenFile2.getIanaVersion());
-        assertEquals(list("Europe/London"), file1ThenFile2.lookupTimeZoneIdsByCountry("gb"));
-        assertZonesEqual(zones("Europe/London"), file1ThenFile2.lookupTimeZonesByCountry("gb"));
+        assertEquals(expectedCountryTimeZones1, file1ThenFile2.lookupCountryTimeZones("gb"));
 
         TimeZoneFinder missingFileThenFile1 =
                 TimeZoneFinder.createInstanceWithFallback(missingFile, validFile1);
         assertEquals("2017c", missingFileThenFile1.getIanaVersion());
-        assertEquals(list("Europe/London"), missingFileThenFile1.lookupTimeZoneIdsByCountry("gb"));
-        assertZonesEqual(zones("Europe/London"),
-                missingFileThenFile1.lookupTimeZonesByCountry("gb"));
+        assertEquals(expectedCountryTimeZones1, missingFileThenFile1.lookupCountryTimeZones("gb"));
 
         TimeZoneFinder file2ThenFile1 =
                 TimeZoneFinder.createInstanceWithFallback(validFile2, validFile1);
         assertEquals("2017b", file2ThenFile1.getIanaVersion());
-        assertEquals(list("Europe/Paris"), file2ThenFile1.lookupTimeZoneIdsByCountry("gb"));
-        assertZonesEqual(zones("Europe/Paris"), file2ThenFile1.lookupTimeZonesByCountry("gb"));
+        assertEquals(expectedCountryTimeZones2, file2ThenFile1.lookupCountryTimeZones("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.getIanaVersion());
-        assertNull(invalidThenValid.lookupTimeZoneIdsByCountry("gb"));
-        assertNull(invalidThenValid.lookupTimeZonesByCountry("gb"));
+        assertNull(invalidThenValid.lookupCountryTimeZones("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.getIanaVersion());
-        assertNull(missingFiles.lookupTimeZoneIdsByCountry("gb"));
-        assertNull(missingFiles.lookupTimeZonesByCountry("gb"));
+        assertNull(missingFiles.lookupCountryTimeZones("gb"));
     }
 
     @Test
@@ -185,6 +182,9 @@
 
     @Test
     public void xmlParsing_unexpectedComments() throws Exception {
+        CountryTimeZones expectedCountryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+
         TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
                 + "    <country code=\"gb\" default=\"Europe/London\">\n"
@@ -193,7 +193,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         // This is a crazy comment, but also helps prove that TEXT nodes are coalesced by the
         // parser.
@@ -204,11 +204,14 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
     }
 
     @Test
     public void xmlParsing_unexpectedElementsIgnored() throws Exception {
+        CountryTimeZones expectedCountryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+
         String unexpectedElement = "<unexpected-element>\n<a /></unexpected-element>\n";
         TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  " + unexpectedElement
@@ -218,8 +221,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
@@ -229,8 +231,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
@@ -240,21 +241,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
-
-        finder = validate("<timezones ianaversion=\"2017b\">\n"
-                + "  <countryzones>\n"
-                + "    <country code=\"gb\" default=\"Europe/London\">\n"
-                + "      <id>Europe/London</id>\n"
-                + "      " + unexpectedElement
-                + "      <id>Europe/Paris</id>\n"
-                + "    </country>\n"
-                + "  </countryzones>\n"
-                + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London", "Europe/Paris"),
-                finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
@@ -264,8 +251,7 @@
                 + "    " + unexpectedElement
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         // This test is important because it ensures we can extend the format in future with
         // more information.
@@ -277,12 +263,27 @@
                 + "  </countryzones>\n"
                 + "  " + unexpectedElement
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
+
+        expectedCountryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London", "Europe/Paris"), "test");
+        finder = validate("<timezones ianaversion=\"2017b\">\n"
+                + "  <countryzones>\n"
+                + "    <country code=\"gb\" default=\"Europe/London\">\n"
+                + "      <id>Europe/London</id>\n"
+                + "      " + unexpectedElement
+                + "      <id>Europe/Paris</id>\n"
+                + "    </country>\n"
+                + "  </countryzones>\n"
+                + "</timezones>\n");
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
     }
 
     @Test
     public void xmlParsing_unexpectedTextIgnored() throws Exception {
+        CountryTimeZones expectedCountryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
+
         String unexpectedText = "unexpected-text";
         TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  " + unexpectedText
@@ -292,8 +293,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
@@ -303,8 +303,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
         finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
@@ -314,9 +313,10 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
 
+        expectedCountryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London", "Europe/Paris"), "test");
         finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
                 + "    <country code=\"gb\" default=\"Europe/London\">\n"
@@ -326,9 +326,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb"));
-        assertEquals(list("Europe/London", "Europe/Paris"),
-                finder.lookupTimeZoneIdsByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
     }
 
     @Test
@@ -374,6 +372,8 @@
 
     @Test
     public void xmlParsing_unknownTimeZoneIdIgnored() throws Exception {
+        CountryTimeZones expectedCountryTimeZones = CountryTimeZones.createValidated(
+                "gb", "Europe/London", list("Europe/London"), "test");
         TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n"
                 + "  <countryzones>\n"
                 + "    <country code=\"gb\" default=\"Europe/London\">\n"
@@ -382,8 +382,7 @@
                 + "    </country>\n"
                 + "  </countryzones>\n"
                 + "</timezones>\n");
-        assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb"));
-        assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb"));
+        assertEquals(expectedCountryTimeZones, finder.lookupCountryTimeZones("gb"));
     }
 
     @Test
@@ -820,53 +819,6 @@
         assertEquals(expectedIanaVersion, finder.getIanaVersion());
     }
 
-    @Test
-    public void createValidatedCountryTimeZones_filtersBadIds() throws Exception {
-        String countryIso = "iso";
-        String knownTimeZoneId1 = "Europe/London";
-        String knownTimeZoneId2 = "America/Los_Angeles";
-        String knownTimeZoneId3 = "America/New_York";
-        String unknownTimeZoneId = "Moon/Tranquility_Base";
-
-        List<String> countryZoneIds = list(
-                knownTimeZoneId1, knownTimeZoneId2, unknownTimeZoneId, knownTimeZoneId3);
-        TimeZoneFinder.CountryTimeZones countryTimeZones =
-                TimeZoneFinder.createValidatedCountryTimeZones(countryIso, knownTimeZoneId1,
-                        countryZoneIds, "debugInfoIgnored");
-
-        assertEquals(countryIso, countryTimeZones.getCountryIso());
-
-        assertEquals(knownTimeZoneId1, countryTimeZones.getDefaultTimeZoneId());
-        assertEquals(knownTimeZoneId1, countryTimeZones.getDefaultTimeZoneId());
-
-        // Validation should have filtered the unknown ID.
-        String[] expectedTimeZoneIds = { knownTimeZoneId1, knownTimeZoneId2, knownTimeZoneId3 };
-        assertEquals(list(expectedTimeZoneIds), countryTimeZones.getTimeZoneIds());
-        List<TimeZone> timeZones = countryTimeZones.getTimeZones();
-        for (int i = 0; i < timeZones.size(); i++) {
-            TimeZone timeZone = timeZones.get(i);
-            assertEquals(expectedTimeZoneIds[i], timeZone.getID());
-            assertTrue(timeZone.isFrozen());
-        }
-    }
-
-    @Test
-    public void createValidatedCountryTimeZones_filtersBadDefaultId() throws Exception {
-        String countryIso = "iso";
-        String unknownTimeZoneId = "Moon/Tranquility_Base";
-
-        List<String> countryZoneIds = list(unknownTimeZoneId);
-        TimeZoneFinder.CountryTimeZones countryTimeZones =
-                TimeZoneFinder.createValidatedCountryTimeZones(countryIso, unknownTimeZoneId,
-                        countryZoneIds, "debugInfoIgnored");
-
-        assertEquals(countryIso, countryTimeZones.getCountryIso());
-
-        assertNull(countryTimeZones.getDefaultTimeZoneId());
-        assertEquals(Collections.emptyList(), countryTimeZones.getTimeZoneIds());
-        assertEquals(Collections.emptyList(), countryTimeZones.getTimeZones());
-    }
-
     private void assertImmutableTimeZone(TimeZone timeZone) {
         try {
             timeZone.setRawOffset(1000);
@@ -888,11 +840,6 @@
         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);
@@ -916,10 +863,6 @@
                 .collect(Collectors.toList());
     }
 
-    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));
diff --git a/non_openjdk_java_files.bp b/non_openjdk_java_files.bp
index e37a84a..ee3a507 100644
--- a/non_openjdk_java_files.bp
+++ b/non_openjdk_java_files.bp
@@ -301,6 +301,7 @@
         "luni/src/main/java/libcore/util/EmptyArray.java",
         "luni/src/main/java/libcore/util/BasicLruCache.java",
         "luni/src/main/java/libcore/util/CollectionUtils.java",
+        "luni/src/main/java/libcore/util/CountryTimeZones.java",
         "luni/src/main/java/libcore/util/EmptyArray.java",
         "luni/src/main/java/libcore/util/NativeAllocationRegistry.java",
         "luni/src/main/java/libcore/util/NonNull.java",