diff options
author | Treehugger Robot <treehugger-gerrit@google.com> | 2017-10-27 16:01:28 +0000 |
---|---|---|
committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2017-10-27 16:01:28 +0000 |
commit | d2085949cd61c7252ff1ba15aa914f6decd1a56c (patch) | |
tree | d0af33300a3ced9515f5bf9f8bfd70c19ca449f3 | |
parent | 58e96b621a6d730259db5c45b92dce0e6a3edf65 (diff) | |
parent | e041041b665cfdb4a13b420cf151102d144d9bf9 (diff) |
Merge changes from topic "tz_catchup2"
* changes:
Add lookupTimeZoneIdsByCountry
Make use of the default time zone for a country
-rw-r--r-- | luni/src/main/java/libcore/util/TimeZoneFinder.java | 291 | ||||
-rw-r--r-- | luni/src/test/java/libcore/icu/ICUTest.java | 56 | ||||
-rw-r--r-- | luni/src/test/java/libcore/util/TimeZoneFinderTest.java | 218 |
3 files changed, 475 insertions, 90 deletions
diff --git a/luni/src/main/java/libcore/util/TimeZoneFinder.java b/luni/src/main/java/libcore/util/TimeZoneFinder.java index babe5b2d23..fa15086823 100644 --- a/luni/src/main/java/libcore/util/TimeZoneFinder.java +++ b/luni/src/main/java/libcore/util/TimeZoneFinder.java @@ -36,6 +36,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; /** @@ -49,15 +50,15 @@ public class TimeZoneFinder { 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 DEFAULT_TIME_ZONE_ID_ATTRIBUTE = "default"; 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; + // Cached field for the last country looked up. + private CountryTimeZones lastCountryTimeZones; private TimeZoneFinder(ReaderSupplier xmlSource) { this.xmlSource = xmlSource; @@ -153,9 +154,7 @@ public class TimeZoneFinder { findRequiredStartTag(parser, TIMEZONES_ELEMENT); return parser.getAttributeValue(null /* namespace */, IANA_VERSION_ATTRIBUTE); - } catch (XmlPullParserException e) { - return null; - } catch (IOException e) { + } catch (XmlPullParserException | IOException e) { return null; } } @@ -173,14 +172,14 @@ public class TimeZoneFinder { 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()) { return null; } TimeZone firstMatch = null; - for (int i = 0; i < candidates.size(); i++) { - TimeZone match = candidates.get(i); + for (TimeZone match : candidates) { if (!offsetMatchesAtTime(match, offsetSeconds, isDst, whenMillis)) { continue; } @@ -223,45 +222,88 @@ public class TimeZoneFinder { } /** + * Returns a "default" time zone ID known to be used in the specified country. This is + * the time zone ID that can be used if only the country code is known and can be presumed to be + * the "best" choice in the absence of other information. For countries with more than one zone + * the time zone will not be correct for everybody. + * + * <p>If the country code is not recognized or there is an error during lookup this can return + * null. + */ + public String lookupDefaultTimeZoneIdByCountry(String countryIso) { + countryIso = normalizeCountryIso(countryIso); + CountryTimeZones countryTimeZones = findCountryTimeZones(countryIso); + return countryTimeZones == null ? null : countryTimeZones.getDefaultTimeZoneId(); + } + + /** * Returns an immutable list of frozen ICU 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 + * can return an empty list in a case when the underlying data files reference only unknown * zone IDs. */ public List<TimeZone> lookupTimeZonesByCountry(String countryIso) { - synchronized(this) { - if (countryIso.equals(lastCountryIso)) { + countryIso = normalizeCountryIso(countryIso); + CountryTimeZones countryTimeZones = findCountryTimeZones(countryIso); + return countryTimeZones == null ? null : countryTimeZones.getTimeZones(); + } + + /** + * Returns an immutable list of time zone IDs 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 IDs returned will all be valid for use with + * {@link java.util.TimeZone#getTimeZone(String)} and + * {@link android.icu.util.TimeZone#getTimeZone(String)}. This method can return an empty list + * 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); + 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. + */ + private CountryTimeZones findCountryTimeZones(String countryIso) { + synchronized (this) { + if (lastCountryTimeZones != null + && lastCountryTimeZones.getCountryIso().equals(countryIso)) { return lastCountryTimeZones; } } - CountryZonesExtractor extractor = new CountryZonesExtractor(countryIso); - List<TimeZone> countryTimeZones = null; + SelectiveCountryTimeZonesExtractor extractor = + new SelectiveCountryTimeZonesExtractor(countryIso); 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) { + CountryTimeZones countryTimeZones = extractor.getValidatedCountryTimeZones(); + if (countryTimeZones == null) { + // None matched. Return the null but don't change the cached value. + return null; + } + + // Update the cached value. + synchronized (this) { + lastCountryTimeZones = countryTimeZones; + } + return countryTimeZones; + } catch (XmlPullParserException | IOException 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; + // Error - don't change the cached value. + return null; } - return countryTimeZones; } /** * Processes the XML, applying the {@link CountryZonesProcessor} to the <countryzones> * element. Processing can terminate early if the - * {@link CountryZonesProcessor#process(String, List, String)} returns + * {@link CountryZonesProcessor#process(String, String, List, String)} returns * {@link CountryZonesProcessor#HALT} or it throws an exception. */ private void processXml(CountryZonesProcessor processor) @@ -277,12 +319,12 @@ public class TimeZoneFinder { * The expected XML structure is: * <timezones ianaversion="2017b"> * <countryzones> - * <country code="us"> + * <country code="us" default="America/New_York"> * <id>America/New_York"</id> * ... * <id>America/Los_Angeles</id> * </country> - * <country code="gb"> + * <country code="gb" default="Europe/London"> * <id>Europe/London</id> * </country> * </countryzones> @@ -331,10 +373,16 @@ public class TimeZoneFinder { throw new XmlPullParserException( "Unable to find country code: " + parser.getPositionDescription()); } + String defaultTimeZoneId = parser.getAttributeValue( + null /* namespace */, DEFAULT_TIME_ZONE_ID_ATTRIBUTE); + if (defaultTimeZoneId == null || defaultTimeZoneId.isEmpty()) { + throw new XmlPullParserException("Unable to find default time zone ID: " + + parser.getPositionDescription()); + } String debugInfo = parser.getPositionDescription(); List<String> timeZoneIds = parseZoneIds(parser); - if (processor.process(code, timeZoneIds, debugInfo) + if (processor.process(code, defaultTimeZoneId, timeZoneIds, debugInfo) == CountryZonesProcessor.HALT) { return CountryZonesProcessor.HALT; } @@ -512,79 +560,80 @@ public class TimeZoneFinder { * 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; + boolean process(String countryIso, String defaultTimeZoneId, List<String> timeZoneIds, + String debugInfo) throws XmlPullParserException; } /** - * Validates <countryzones> elements. To be valid the country ISO code must be unique - * and it must not be empty. + * Validates <countryzones> elements. Intended to be used before a proposed installation + * of new data. To be valid the country ISO code must be normalized, unique, the default time + * zone ID must be one of the time zones IDs and the time zone IDs list must not be empty. The + * IDs themselves are not checked against other data to see if they are recognized because other + * classes will not have been updated with the associated new time zone data yet and so will not + * be aware of newly added IDs. */ 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 + public boolean process(String countryIso, String defaultTimeZoneId, + List<String> timeZoneIds, String debugInfo) throws XmlPullParserException { + if (!normalizeCountryIso(countryIso).equals(countryIso)) { + throw new XmlPullParserException("Country code: " + countryIso + + " is not normalized at " + debugInfo); + } + if (knownCountryCodes.contains(countryIso)) { + throw new XmlPullParserException("Second entry for country code: " + countryIso + " at " + debugInfo); } if (timeZoneIds.isEmpty()) { - throw new XmlPullParserException("No time zone IDs for country code: " + countryCode + throw new XmlPullParserException("No time zone IDs for country code: " + countryIso + " 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); + if (!timeZoneIds.contains(defaultTimeZoneId)) { + throw new XmlPullParserException("defaultTimeZoneId for country code: " + + countryIso + " is not one of the zones " + timeZoneIds + " at " + + debugInfo); + } + knownCountryCodes.add(countryIso); return CONTINUE; } } /** - * Extracts the zones associated with a country code, halting when the country code is matched - * and making them available via {@link #getMatchedZones()}. + * Extracts <em>validated</em> time zones information associated with a specific country code. + * Processing is halted when the country code is matched and the validated result is also made + * available via {@link #getValidatedCountryTimeZones()}. */ - private static class CountryZonesExtractor implements CountryZonesProcessor { + private static class SelectiveCountryTimeZonesExtractor implements CountryZonesProcessor { private final String countryCodeToMatch; - private List<TimeZone> matchedZones; + private CountryTimeZones validatedCountryTimeZones; - private CountryZonesExtractor(String countryCodeToMatch) { + private SelectiveCountryTimeZonesExtractor(String countryCodeToMatch) { this.countryCodeToMatch = countryCodeToMatch; } @Override - public boolean process(String countryCode, List<String> timeZoneIds, String debugInfo) { - if (!countryCodeToMatch.equals(countryCode)) { + public boolean process(String countryIso, String defaultTimeZoneId, + List<String> countryTimeZoneIds, String debugInfo) { + countryIso = normalizeCountryIso(countryIso); + if (!countryCodeToMatch.equals(countryIso)) { return CONTINUE; } + validatedCountryTimeZones = createValidatedCountryTimeZones(countryIso, + defaultTimeZoneId, countryTimeZoneIds, debugInfo); - 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. + * Returns the CountryTimeZones that matched, or {@code null} if there were no matches. */ - List<TimeZone> getMatchedZones() { - return matchedZones; + CountryTimeZones getValidatedCountryTimeZones() { + return validatedCountryTimeZones; } } @@ -610,4 +659,114 @@ public class TimeZoneFinder { return () -> new StringReader(xml); } } + + /** + * 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/icu/ICUTest.java b/luni/src/test/java/libcore/icu/ICUTest.java index 0409f0cd40..a8e4d591e2 100644 --- a/luni/src/test/java/libcore/icu/ICUTest.java +++ b/luni/src/test/java/libcore/icu/ICUTest.java @@ -16,10 +16,15 @@ package libcore.icu; +import android.icu.util.TimeZone; + import java.text.BreakIterator; import java.text.Collator; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Locale; +import java.util.Set; import libcore.util.TimeZoneFinder; import libcore.util.ZoneInfoDB; @@ -269,4 +274,55 @@ public class ICUTest extends junit.framework.TestCase { String tzLookupTzVersion = TimeZoneFinder.getInstance().getIanaVersion(); assertEquals(icu4jTzVersion, tzLookupTzVersion); } + + /** + * Confirms that ICU can recognize all the time zone IDs used by the ZoneInfoDB data. + * ICU's IDs may be a superset. + */ + public void testTimeZoneIdLookup() { + String[] zoneInfoDbAvailableIds = ZoneInfoDB.getInstance().getAvailableIDs(); + + // ICU has a known set of IDs. We want ANY because we don't want to filter to ICU's canonical + // IDs only. + Set<String> icuAvailableIds = android.icu.util.TimeZone.getAvailableIDs( + TimeZone.SystemTimeZoneType.ANY, null /* region */, null /* rawOffset */); + + List<String> nonIcuAvailableIds = new ArrayList<>(); + List<String> creationFailureIds = new ArrayList<>(); + List<String> noCanonicalLookupIds = new ArrayList<>(); + List<String> nonSystemIds = new ArrayList<>(); + for (String zoneInfoDbId : zoneInfoDbAvailableIds) { + + if (zoneInfoDbId.equals("Asia/Hanoi")) { + // Known ICU lookup issue: http://b/30277331 + continue; + } + + if (!icuAvailableIds.contains(zoneInfoDbId)) { + nonIcuAvailableIds.add(zoneInfoDbId); + } + + boolean[] isSystemId = new boolean[1]; + String canonicalId = android.icu.util.TimeZone.getCanonicalID(zoneInfoDbId, isSystemId); + if (canonicalId == null) { + noCanonicalLookupIds.add(zoneInfoDbId); + } + if (!isSystemId[0]) { + nonSystemIds.add(zoneInfoDbId); + } + + android.icu.util.TimeZone icuTimeZone = android.icu.util.TimeZone.getTimeZone(zoneInfoDbId); + if (icuTimeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID)) { + creationFailureIds.add(zoneInfoDbId); + } + } + assertTrue("Non-ICU available IDs: " + nonIcuAvailableIds + + ", creation failed IDs: " + creationFailureIds + + ", non-system IDs: " + nonSystemIds + + ", ids without canonical IDs: " + noCanonicalLookupIds, + nonIcuAvailableIds.isEmpty() + && creationFailureIds.isEmpty() + && nonSystemIds.isEmpty() + && noCanonicalLookupIds.isEmpty()); + } } diff --git a/luni/src/test/java/libcore/util/TimeZoneFinderTest.java b/luni/src/test/java/libcore/util/TimeZoneFinderTest.java index dc93a80ebe..f675150fcd 100644 --- a/luni/src/test/java/libcore/util/TimeZoneFinderTest.java +++ b/luni/src/test/java/libcore/util/TimeZoneFinderTest.java @@ -30,6 +30,8 @@ import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; 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; @@ -39,6 +41,7 @@ import java.util.stream.Collectors; 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 { @@ -122,17 +125,20 @@ public class TimeZoneFinderTest { TimeZoneFinder file1ThenFile2 = TimeZoneFinder.createInstanceWithFallback(validFile1, validFile2); assertEquals("2017c", file1ThenFile2.getIanaVersion()); + assertEquals(list("Europe/London"), file1ThenFile2.lookupTimeZoneIdsByCountry("gb")); assertZonesEqual(zones("Europe/London"), file1ThenFile2.lookupTimeZonesByCountry("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")); TimeZoneFinder file2ThenFile1 = TimeZoneFinder.createInstanceWithFallback(validFile2, validFile1); assertEquals("2017b", file2ThenFile1.getIanaVersion()); + assertEquals(list("Europe/Paris"), file2ThenFile1.lookupTimeZoneIdsByCountry("gb")); assertZonesEqual(zones("Europe/Paris"), file2ThenFile1.lookupTimeZonesByCountry("gb")); // We assume the file has been validated so an invalid file is not checked ahead of time. @@ -140,12 +146,14 @@ public class TimeZoneFinderTest { TimeZoneFinder invalidThenValid = TimeZoneFinder.createInstanceWithFallback(invalidFile, validFile1); assertNull(invalidThenValid.getIanaVersion()); + assertNull(invalidThenValid.lookupTimeZoneIdsByCountry("gb")); 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.getIanaVersion()); + assertNull(missingFiles.lookupTimeZoneIdsByCountry("gb")); assertNull(missingFiles.lookupTimeZonesByCountry("gb")); } @@ -182,7 +190,7 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); // This is a crazy comment, but also helps prove that TEXT nodes are coalesced by the // parser. @@ -193,7 +201,7 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); } @Test @@ -207,7 +215,8 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -217,7 +226,8 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -227,7 +237,8 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -238,8 +249,9 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London", "Europe/Paris"), - finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London", "Europe/Paris"), + finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -249,7 +261,8 @@ public class TimeZoneFinderTest { + " " + unexpectedElement + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); // This test is important because it ensures we can extend the format in future with // more information. @@ -261,7 +274,8 @@ public class TimeZoneFinderTest { + " </countryzones>\n" + " " + unexpectedElement + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); } @Test @@ -275,7 +289,8 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -285,7 +300,8 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -295,7 +311,8 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -306,8 +323,9 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London", "Europe/Paris"), - finder.lookupTimeZonesByCountry("gb")); + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + assertEquals(list("Europe/London", "Europe/Paris"), + finder.lookupTimeZoneIdsByCountry("gb")); } @Test @@ -361,6 +379,7 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); } @@ -368,7 +387,18 @@ public class TimeZoneFinderTest { public void xmlParsing_missingCountryCode() throws Exception { checkValidateThrowsParserException("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" - + " <country>\n" + + " <country default=\"Europe/London\">\n" + + " <id>Europe/London</id>\n" + + " </country>\n" + + " </countryzones>\n" + + "</timezones>\n"); + } + + @Test + public void xmlParsing_missingDefault() throws Exception { + checkValidateThrowsParserException("<timezones ianaversion=\"2017b\">\n" + + " <countryzones>\n" + + " <country code=\"gb\">\n" + " <id>Europe/London</id>\n" + " </country>\n" + " </countryzones>\n" @@ -381,6 +411,7 @@ public class TimeZoneFinderTest { + " <countryzones>\n" + " </countryzones>\n" + "</timezones>\n"); + assertNull(finder.lookupTimeZoneIdsByCountry("gb")); assertNull(finder.lookupTimeZonesByCountry("gb")); } @@ -399,10 +430,87 @@ public class TimeZoneFinderTest { assertImmutableList(gbList); assertImmutableTimeZone(gbList.get(0)); + // Check country code normalization works too. + assertEquals(1, finder.lookupTimeZonesByCountry("GB").size()); + assertNull(finder.lookupTimeZonesByCountry("unknown")); } @Test + public void lookupTimeZoneIdsByCountry_structuresAreImmutable() throws Exception { + TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n" + + " <countryzones>\n" + + " <country code=\"gb\" default=\"Europe/London\">\n" + + " <id>Europe/London</id>\n" + + " </country>\n" + + " </countryzones>\n" + + "</timezones>\n"); + + List<String> gbList = finder.lookupTimeZoneIdsByCountry("gb"); + assertEquals(1, gbList.size()); + assertImmutableList(gbList); + + // Check country code normalization works too. + assertEquals(1, finder.lookupTimeZoneIdsByCountry("GB").size()); + + assertNull(finder.lookupTimeZoneIdsByCountry("unknown")); + } + + @Test + public void lookupDefaultTimeZoneIdByCountry() throws Exception { + TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n" + + " <countryzones>\n" + + " <country code=\"gb\" default=\"Europe/London\">\n" + + " <id>Europe/London</id>\n" + + " </country>\n" + + " </countryzones>\n" + + "</timezones>\n"); + + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("gb")); + + // Check country code normalization works too. + assertEquals("Europe/London", finder.lookupDefaultTimeZoneIdByCountry("GB")); + } + + /** + * At runtime we don't validate too much since there's nothing we can do if the data is + * incorrect. + */ + @Test + public void lookupDefaultTimeZoneIdByCountry_notCountryTimeZoneButValid() throws Exception { + String xml = "<timezones ianaversion=\"2017b\">\n" + + " <countryzones>\n" + + " <country code=\"gb\" default=\"America/New_York\">\n" + + " <id>Europe/London</id>\n" + + " </country>\n" + + " </countryzones>\n" + + "</timezones>\n"; + // validate() should fail because America/New_York is not one of the "gb" zones listed. + checkValidateThrowsParserException(xml); + + // But it should still work at runtime. + TimeZoneFinder finder = TimeZoneFinder.createInstanceForTests(xml); + assertEquals("America/New_York", finder.lookupDefaultTimeZoneIdByCountry("gb")); + } + + @Test + public void lookupDefaultTimeZoneIdByCountry_invalidDefault() throws Exception { + String xml = "<timezones ianaversion=\"2017b\">\n" + + " <countryzones>\n" + + " <country code=\"gb\" default=\"Moon/Tranquility_Base\">\n" + + " <id>Europe/London</id>\n" + + " <id>Moon/Tranquility_Base</id>\n" + + " </country>\n" + + " </countryzones>\n" + + "</timezones>\n"; + // validate() should pass because the IDs all match. + TimeZoneFinder finder = validate(xml); + + // But "Moon/Tranquility_Base" is not a valid time zone ID so should not be used. + assertNull(finder.lookupDefaultTimeZoneIdByCountry("gb")); + } + + @Test public void lookupTimeZoneByCountryAndOffset_unknownCountry() throws Exception { TimeZoneFinder finder = validate("<timezones ianaversion=\"2017b\">\n" + " <countryzones>\n" @@ -417,6 +525,11 @@ public class TimeZoneFinderTest { finder.lookupTimeZoneByCountryAndOffset("xx", LONDON_DST_OFFSET_MILLIS, true /* isDst */, WHEN_DST, null /* bias */)); + // Check country code normalization works too. + 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, @@ -664,15 +777,16 @@ public class TimeZoneFinderTest { // Android uses lower case, IANA uses upper. countryCode = countryCode.toLowerCase(); - List<String> ianaZoneIds = countryEntry.getValue().stream().sorted() - .collect(Collectors.toList()); + List<String> androidZoneIds = timeZoneFinder.lookupTimeZoneIdsByCountry(countryCode); List<TimeZone> androidZones = timeZoneFinder.lookupTimeZonesByCountry(countryCode); - List<String> androidZoneIds = - androidZones.stream().map(TimeZone::getID).sorted() - .collect(Collectors.toList()); + List<String> androidIdsFromZones = + androidZones.stream().map(TimeZone::getID).collect(Collectors.toList()); + assertEquals("lookupTimeZonesByCountry and lookupTimeZoneIdsByCountry differ for " + + countryCode, androidZoneIds, androidIdsFromZones); + Collection<String> ianaZoneIds = countryEntry.getValue(); assertEquals("Android zones for " + countryCode + " do not match IANA data", - ianaZoneIds, androidZoneIds); + sort(ianaZoneIds), sort(androidZoneIds)); } } @@ -687,7 +801,7 @@ public class TimeZoneFinderTest { + " </country>\n" + " </countryzones>\n" + "</timezones>\n"); - assertZonesEqual(zones("Europe/London"), finder.lookupTimeZonesByCountry("gb")); + assertEquals(list("Europe/London"), finder.lookupTimeZoneIdsByCountry("gb")); assertNull(finder.getIanaVersion()); } @@ -703,6 +817,53 @@ public class TimeZoneFinderTest { 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); @@ -711,9 +872,9 @@ public class TimeZoneFinderTest { } } - private static void assertImmutableList(List<TimeZone> timeZones) { + private static <X> void assertImmutableList(List<X> list) { try { - timeZones.add(null); + list.add(null); fail(); } catch (UnsupportedOperationException expected) { } @@ -743,6 +904,15 @@ public class TimeZoneFinderTest { return timeZoneFinder; } + private static <X> List<X> list(X... values) { + return Arrays.asList(values); + } + + private static <X> List<X> sort(Collection<X> value) { + return value.stream().sorted() + .collect(Collectors.toList()); + } + private static List<TimeZone> zones(String... ids) { return Arrays.stream(ids).map(TimeZone::getTimeZone).collect(Collectors.toList()); } |