/* * Copyright (C) 2016 The Android Open Source Project * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. The Android Open Source * Project designates this particular file as subject to the "Classpath" * exception as provided by The Android Open Source Project in the LICENSE * file that accompanied this code. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. */ package java.time.zone; import android.icu.impl.OlsonTimeZone; import android.icu.impl.ZoneMeta; import android.icu.util.AnnualTimeZoneRule; import android.icu.util.DateTimeRule; import android.icu.util.InitialTimeZoneRule; import android.icu.util.TimeZone; import android.icu.util.TimeZoneRule; import android.icu.util.TimeZoneTransition; import java.time.DayOfWeek; import java.time.LocalTime; import java.time.Month; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.NavigableMap; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.TimeUnit; import libcore.util.BasicLruCache; /** * A ZoneRulesProvider that generates rules from ICU4J TimeZones. * This provider ensures that classes in {@link java.time} use the same time zone information * as ICU4J. */ public class IcuZoneRulesProvider extends ZoneRulesProvider { // Arbitrary upper limit to number of transitions including the final rules. private static final int MAX_TRANSITIONS = 10000; private static final int SECONDS_IN_DAY = 24 * 60 * 60; private final BasicLruCache cache = new ZoneRulesCache(8); @Override protected Set provideZoneIds() { Set zoneIds = ZoneMeta.getAvailableIDs(TimeZone.SystemTimeZoneType.ANY, null, null); zoneIds = new HashSet<>(zoneIds); // java.time assumes ZoneId that start with "GMT" fit the pattern "GMT+HH:mm:ss" which these // do not. Since they are equivalent to GMT, just remove these aliases. zoneIds.remove("GMT+0"); zoneIds.remove("GMT-0"); return zoneIds; } @Override protected ZoneRules provideRules(String zoneId, boolean forCaching) { // Ignore forCaching, as this is a static provider. return cache.get(zoneId); } @Override protected NavigableMap provideVersions(String zoneId) { return new TreeMap<>( Collections.singletonMap(TimeZone.getTZDataVersion(), provideRules(zoneId, /* forCaching */ false))); } /* * This implementation is only tested with OlsonTimeZone objects and depends on * implementation details of that class: * * 0. TimeZone.getFrozenTimeZone() always returns an OlsonTimeZone object. * 1. The first rule is always an InitialTimeZoneRule (guaranteed by spec). * 2. AnnualTimeZoneRules are only used as "final rules". * 3. The final rules are either 0 or 2 AnnualTimeZoneRules * 4. The final rules have endYear set to MAX_YEAR. * 5. Each transition generated by the rules changes either the raw offset, the total offset * or both. * 6. There is a non-immense number of transitions for any rule before the final rules apply * (enforced via the arbitrary limit defined in MAX_TRANSITIONS). * * Assumptions #5 and #6 are not strictly required for this code to work, but hold for the * the data and code at the time of implementation. If they were broken they would indicate * an incomplete understanding of how ICU TimeZoneRules are used which would probably mean that * this code needs to be updated. * * These assumptions are verified using the verify() method where appropriate. */ static ZoneRules generateZoneRules(String zoneId) { TimeZone timeZone = TimeZone.getFrozenTimeZone(zoneId); // Assumption #0 verify(timeZone instanceof OlsonTimeZone, zoneId, "Unexpected time zone class " + timeZone.getClass()); OlsonTimeZone tz = (OlsonTimeZone) timeZone; TimeZoneRule[] rules = tz.getTimeZoneRules(); // Assumption #1 InitialTimeZoneRule initial = (InitialTimeZoneRule) rules[0]; ZoneOffset baseStandardOffset = millisToOffset(initial.getRawOffset()); ZoneOffset baseWallOffset = millisToOffset((initial.getRawOffset() + initial.getDSTSavings())); List standardOffsetTransitionList = new ArrayList<>(); List transitionList = new ArrayList<>(); List lastRules = new ArrayList<>(); int preLastDstSavings = 0; AnnualTimeZoneRule last1 = null; AnnualTimeZoneRule last2 = null; TimeZoneTransition transition = tz.getNextTransition(Long.MIN_VALUE, false); int transitionCount = 1; // This loop has two possible exit conditions (in normal operation): // 1. for zones that end with a static value and have no ongoing DST changes, it will exit // via the normal condition (transition != null) // 2. for zones with ongoing DST changes (represented by a "final zone" in ICU4J, and by // "last rules" in java.time) the "break transitionLoop" will be used to exit the loop. transitionLoop: while (transition != null) { TimeZoneRule from = transition.getFrom(); TimeZoneRule to = transition.getTo(); boolean hadEffect = false; if (from.getRawOffset() != to.getRawOffset()) { standardOffsetTransitionList.add(new ZoneOffsetTransition( TimeUnit.MILLISECONDS.toSeconds(transition.getTime()), millisToOffset(from.getRawOffset()), millisToOffset(to.getRawOffset()))); hadEffect = true; } int fromTotalOffset = from.getRawOffset() + from.getDSTSavings(); int toTotalOffset = to.getRawOffset() + to.getDSTSavings(); if (fromTotalOffset != toTotalOffset) { transitionList.add(new ZoneOffsetTransition( TimeUnit.MILLISECONDS.toSeconds(transition.getTime()), millisToOffset(fromTotalOffset), millisToOffset(toTotalOffset))); hadEffect = true; } // Assumption #5 verify(hadEffect, zoneId, "Transition changed neither total nor raw offset."); if (to instanceof AnnualTimeZoneRule) { // The presence of an AnnualTimeZoneRule is taken as an indication of a final rule. if (last1 == null) { preLastDstSavings = from.getDSTSavings(); last1 = (AnnualTimeZoneRule) to; // Assumption #4 verify(last1.getEndYear() == AnnualTimeZoneRule.MAX_YEAR, zoneId, "AnnualTimeZoneRule is not permanent."); } else { last2 = (AnnualTimeZoneRule) to; // Assumption #4 verify(last2.getEndYear() == AnnualTimeZoneRule.MAX_YEAR, zoneId, "AnnualTimeZoneRule is not permanent."); // Assumption #3 transition = tz.getNextTransition(transition.getTime(), false); verify(transition.getTo() == last1, zoneId, "Unexpected rule after 2 AnnualTimeZoneRules."); break transitionLoop; } } else { // Assumption #2 verify(last1 == null, zoneId, "Unexpected rule after AnnualTimeZoneRule."); } verify(transitionCount <= MAX_TRANSITIONS, zoneId, "More than " + MAX_TRANSITIONS + " transitions."); transition = tz.getNextTransition(transition.getTime(), false); transitionCount++; } if (last1 != null) { // Assumption #3 verify(last2 != null, zoneId, "Only one AnnualTimeZoneRule."); lastRules.add(toZoneOffsetTransitionRule(last1, preLastDstSavings)); lastRules.add(toZoneOffsetTransitionRule(last2, last1.getDSTSavings())); } return ZoneRules.of(baseStandardOffset, baseWallOffset, standardOffsetTransitionList, transitionList, lastRules); } /** * Verify an assumption about the zone rules. * * @param check * {@code true} if the assumption holds, {@code false} otherwise. * @param zoneId * Zone ID for which to check. * @param message * Error description of a failed check. * @throws ZoneRulesException * If and only if {@code check} is {@code false}. */ private static void verify(boolean check, String zoneId, String message) { if (!check) { throw new ZoneRulesException( String.format("Failed verification of zone %s: %s", zoneId, message)); } } /** * Transform an {@link AnnualTimeZoneRule} into an equivalent {@link ZoneOffsetTransitionRule}. * This is only used for the "final rules". * * @param rule * The rule to transform. * @param dstSavingMillisBefore * The DST offset before the first transition in milliseconds. */ private static ZoneOffsetTransitionRule toZoneOffsetTransitionRule( AnnualTimeZoneRule rule, int dstSavingMillisBefore) { DateTimeRule dateTimeRule = rule.getRule(); // Calendar.JANUARY is 0, transform it into a proper Month. Month month = Month.JANUARY.plus(dateTimeRule.getRuleMonth()); int dayOfMonthIndicator; // Calendar.SUNDAY is 1, transform it into a proper DayOfWeek. DayOfWeek dayOfWeek = DayOfWeek.SATURDAY.plus(dateTimeRule.getRuleDayOfWeek()); switch (dateTimeRule.getDateRuleType()) { case DateTimeRule.DOM: // Transition always on a specific day of the month. dayOfMonthIndicator = dateTimeRule.getRuleDayOfMonth(); dayOfWeek = null; break; case DateTimeRule.DOW_GEQ_DOM: // ICU representation matches java.time representation. dayOfMonthIndicator = dateTimeRule.getRuleDayOfMonth(); break; case DateTimeRule.DOW_LEQ_DOM: // java.time uses a negative dayOfMonthIndicator to represent "Sun<=X" or "lastSun" // rules. ICU uses this constant and the normal day. So "lastSun" in January would // ruleDayOfMonth = 31 in ICU and dayOfMonthIndicator = -1 in java.time. dayOfMonthIndicator = -month.maxLength() + dateTimeRule.getRuleDayOfMonth() - 1; break; case DateTimeRule.DOW: // DOW is unspecified in the documentation and seems to never be used. throw new ZoneRulesException("Date rule type DOW is unsupported"); default: throw new ZoneRulesException( "Unexpected date rule type: " + dateTimeRule.getDateRuleType()); } // Cast to int is save, as input is int. int secondOfDay = (int) TimeUnit.MILLISECONDS.toSeconds(dateTimeRule.getRuleMillisInDay()); LocalTime time; boolean timeEndOfDay; if (secondOfDay == SECONDS_IN_DAY) { time = LocalTime.MIDNIGHT; timeEndOfDay = true; } else { time = LocalTime.ofSecondOfDay(secondOfDay); timeEndOfDay = false; } ZoneOffsetTransitionRule.TimeDefinition timeDefinition; switch (dateTimeRule.getTimeRuleType()) { case DateTimeRule.WALL_TIME: timeDefinition = ZoneOffsetTransitionRule.TimeDefinition.WALL; break; case DateTimeRule.STANDARD_TIME: timeDefinition = ZoneOffsetTransitionRule.TimeDefinition.STANDARD; break; case DateTimeRule.UTC_TIME: timeDefinition = ZoneOffsetTransitionRule.TimeDefinition.UTC; break; default: throw new ZoneRulesException( "Unexpected time rule type " + dateTimeRule.getTimeRuleType()); } ZoneOffset standardOffset = millisToOffset(rule.getRawOffset()); ZoneOffset offsetBefore = millisToOffset(rule.getRawOffset() + dstSavingMillisBefore); ZoneOffset offsetAfter = millisToOffset( rule.getRawOffset() + rule.getDSTSavings()); return ZoneOffsetTransitionRule.of( month, dayOfMonthIndicator, dayOfWeek, time, timeEndOfDay, timeDefinition, standardOffset, offsetBefore, offsetAfter); } private static ZoneOffset millisToOffset(int offset) { // Cast to int is save, as input is int. return ZoneOffset.ofTotalSeconds((int) TimeUnit.MILLISECONDS.toSeconds(offset)); } private static class ZoneRulesCache extends BasicLruCache { ZoneRulesCache(int maxSize) { super(maxSize); } @Override protected ZoneRules create(String zoneId) { String canonicalId = TimeZone.getCanonicalID(zoneId); if (!canonicalId.equals(zoneId)) { // Return the same object as the canonical one, to avoid wasting space, but cache // it under the non-cannonical name as well, to avoid future getCanonicalID calls. return get(canonicalId); } return generateZoneRules(zoneId); } } }