1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * This code is free software; you can redistribute it and/or modify it
5  * under the terms of the GNU General Public License version 2 only, as
6  * published by the Free Software Foundation.  The Android Open Source
7  * Project designates this particular file as subject to the "Classpath"
8  * exception as provided by The Android Open Source Project in the LICENSE
9  * file that accompanied this code.
10  *
11  * This code is distributed in the hope that it will be useful, but WITHOUT
12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
14  * version 2 for more details (a copy is included in the LICENSE file that
15  * accompanied this code).
16  *
17  * You should have received a copy of the GNU General Public License version
18  * 2 along with this work; if not, write to the Free Software Foundation,
19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20  */
21 
22 package java.time.zone;
23 
24 import android.icu.impl.OlsonTimeZone;
25 import android.icu.impl.ZoneMeta;
26 import android.icu.util.AnnualTimeZoneRule;
27 import android.icu.util.DateTimeRule;
28 import android.icu.util.InitialTimeZoneRule;
29 import android.icu.util.TimeZone;
30 import android.icu.util.TimeZoneRule;
31 import android.icu.util.TimeZoneTransition;
32 import java.time.DayOfWeek;
33 import java.time.LocalTime;
34 import java.time.Month;
35 import java.time.ZoneOffset;
36 import java.util.ArrayList;
37 import java.util.Collections;
38 import java.util.HashSet;
39 import java.util.List;
40 import java.util.NavigableMap;
41 import java.util.Set;
42 import java.util.TreeMap;
43 import java.util.concurrent.TimeUnit;
44 import libcore.util.BasicLruCache;
45 
46 /**
47  * A ZoneRulesProvider that generates rules from ICU4J TimeZones.
48  * This provider ensures that classes in {@link java.time} use the same time zone information
49  * as ICU4J.
50  */
51 public class IcuZoneRulesProvider extends ZoneRulesProvider {
52 
53     // Arbitrary upper limit to number of transitions including the final rules.
54     private static final int MAX_TRANSITIONS = 10000;
55 
56     private static final int SECONDS_IN_DAY = 24 * 60 * 60;
57 
58     private final BasicLruCache<String, ZoneRules> cache = new ZoneRulesCache(8);
59 
60     @Override
provideZoneIds()61     protected Set<String> provideZoneIds() {
62         Set<String> zoneIds = ZoneMeta.getAvailableIDs(TimeZone.SystemTimeZoneType.ANY, null, null);
63         zoneIds = new HashSet<>(zoneIds);
64         // java.time assumes ZoneId that start with "GMT" fit the pattern "GMT+HH:mm:ss" which these
65         // do not. Since they are equivalent to GMT, just remove these aliases.
66         zoneIds.remove("GMT+0");
67         zoneIds.remove("GMT-0");
68         return zoneIds;
69     }
70 
71     @Override
provideRules(String zoneId, boolean forCaching)72     protected ZoneRules provideRules(String zoneId, boolean forCaching) {
73         // Ignore forCaching, as this is a static provider.
74         return cache.get(zoneId);
75     }
76 
77     @Override
provideVersions(String zoneId)78     protected NavigableMap<String, ZoneRules> provideVersions(String zoneId) {
79         return new TreeMap<>(
80                 Collections.singletonMap(TimeZone.getTZDataVersion(),
81                         provideRules(zoneId, /* forCaching */ false)));
82     }
83 
84     /*
85      * This implementation is only tested with OlsonTimeZone objects and depends on
86      * implementation details of that class:
87      *
88      * 0. TimeZone.getFrozenTimeZone() always returns an OlsonTimeZone object.
89      * 1. The first rule is always an InitialTimeZoneRule (guaranteed by spec).
90      * 2. AnnualTimeZoneRules are only used as "final rules".
91      * 3. The final rules are either 0 or 2 AnnualTimeZoneRules
92      * 4. The final rules have endYear set to MAX_YEAR.
93      * 5. Each transition generated by the rules changes either the raw offset, the total offset
94      *    or both.
95      * 6. There is a non-immense number of transitions for any rule before the final rules apply
96      *    (enforced via the arbitrary limit defined in MAX_TRANSITIONS).
97      *
98      * Assumptions #5 and #6 are not strictly required for this code to work, but hold for the
99      * the data and code at the time of implementation. If they were broken they would indicate
100      * an incomplete understanding of how ICU TimeZoneRules are used which would probably mean that
101      * this code needs to be updated.
102      *
103      * These assumptions are verified using the verify() method where appropriate.
104      */
generateZoneRules(String zoneId)105     static ZoneRules generateZoneRules(String zoneId) {
106         TimeZone timeZone = TimeZone.getFrozenTimeZone(zoneId);
107         // Assumption #0
108         verify(timeZone instanceof OlsonTimeZone, zoneId,
109                 "Unexpected time zone class " + timeZone.getClass());
110         OlsonTimeZone tz = (OlsonTimeZone) timeZone;
111         TimeZoneRule[] rules = tz.getTimeZoneRules();
112         // Assumption #1
113         InitialTimeZoneRule initial = (InitialTimeZoneRule) rules[0];
114 
115         ZoneOffset baseStandardOffset = millisToOffset(initial.getRawOffset());
116         ZoneOffset baseWallOffset =
117                 millisToOffset((initial.getRawOffset() + initial.getDSTSavings()));
118 
119         List<ZoneOffsetTransition> standardOffsetTransitionList = new ArrayList<>();
120         List<ZoneOffsetTransition> transitionList = new ArrayList<>();
121         List<ZoneOffsetTransitionRule> lastRules = new ArrayList<>();
122 
123         int preLastDstSavings = 0;
124         AnnualTimeZoneRule last1 = null;
125         AnnualTimeZoneRule last2 = null;
126 
127         TimeZoneTransition transition = tz.getNextTransition(Long.MIN_VALUE, false);
128         int transitionCount = 1;
129         // This loop has two possible exit conditions (in normal operation):
130         // 1. for zones that end with a static value and have no ongoing DST changes, it will exit
131         //    via the normal condition (transition != null)
132         // 2. for zones with ongoing DST changes (represented by a "final zone" in ICU4J, and by
133         //    "last rules" in java.time) the "break transitionLoop" will be used to exit the loop.
134         transitionLoop:
135         while (transition != null) {
136             TimeZoneRule from = transition.getFrom();
137             TimeZoneRule to = transition.getTo();
138             boolean hadEffect = false;
139             if (from.getRawOffset() != to.getRawOffset()) {
140                 standardOffsetTransitionList.add(new ZoneOffsetTransition(
141                         TimeUnit.MILLISECONDS.toSeconds(transition.getTime()),
142                         millisToOffset(from.getRawOffset()),
143                         millisToOffset(to.getRawOffset())));
144                 hadEffect = true;
145             }
146             int fromTotalOffset = from.getRawOffset() + from.getDSTSavings();
147             int toTotalOffset = to.getRawOffset() + to.getDSTSavings();
148             if (fromTotalOffset != toTotalOffset) {
149                 transitionList.add(new ZoneOffsetTransition(
150                         TimeUnit.MILLISECONDS.toSeconds(transition.getTime()),
151                         millisToOffset(fromTotalOffset),
152                         millisToOffset(toTotalOffset)));
153                 hadEffect = true;
154             }
155             // Assumption #5
156             verify(hadEffect, zoneId, "Transition changed neither total nor raw offset.");
157             if (to instanceof AnnualTimeZoneRule) {
158                 // The presence of an AnnualTimeZoneRule is taken as an indication of a final rule.
159                 if (last1 == null) {
160                     preLastDstSavings = from.getDSTSavings();
161                     last1 = (AnnualTimeZoneRule) to;
162                     // Assumption #4
163                     verify(last1.getEndYear() == AnnualTimeZoneRule.MAX_YEAR, zoneId,
164                             "AnnualTimeZoneRule is not permanent.");
165                 } else {
166                     last2 = (AnnualTimeZoneRule) to;
167                     // Assumption #4
168                     verify(last2.getEndYear() == AnnualTimeZoneRule.MAX_YEAR, zoneId,
169                             "AnnualTimeZoneRule is not permanent.");
170 
171                     // Assumption #3
172                     transition = tz.getNextTransition(transition.getTime(), false);
173                     verify(transition.getTo() == last1, zoneId,
174                             "Unexpected rule after 2 AnnualTimeZoneRules.");
175                     break transitionLoop;
176                 }
177             } else {
178                 // Assumption #2
179                 verify(last1 == null, zoneId, "Unexpected rule after AnnualTimeZoneRule.");
180             }
181             verify(transitionCount <= MAX_TRANSITIONS, zoneId,
182                     "More than " + MAX_TRANSITIONS + " transitions.");
183             transition = tz.getNextTransition(transition.getTime(), false);
184             transitionCount++;
185         }
186         if (last1 != null) {
187             // Assumption #3
188             verify(last2 != null, zoneId, "Only one AnnualTimeZoneRule.");
189             lastRules.add(toZoneOffsetTransitionRule(last1, preLastDstSavings));
190             lastRules.add(toZoneOffsetTransitionRule(last2, last1.getDSTSavings()));
191         }
192 
193         return ZoneRules.of(baseStandardOffset, baseWallOffset, standardOffsetTransitionList,
194                 transitionList, lastRules);
195     }
196 
197     /**
198      * Verify an assumption about the zone rules.
199      *
200      * @param check
201      *         {@code true} if the assumption holds, {@code false} otherwise.
202      * @param zoneId
203      *         Zone ID for which to check.
204      * @param message
205      *         Error description of a failed check.
206      * @throws ZoneRulesException
207      *         If and only if {@code check} is {@code false}.
208      */
verify(boolean check, String zoneId, String message)209     private static void verify(boolean check, String zoneId, String message) {
210         if (!check) {
211             throw new ZoneRulesException(
212                     String.format("Failed verification of zone %s: %s", zoneId, message));
213         }
214     }
215 
216     /**
217      * Transform an {@link AnnualTimeZoneRule} into an equivalent {@link ZoneOffsetTransitionRule}.
218      * This is only used for the "final rules".
219      *
220      * @param rule
221      *         The rule to transform.
222      * @param dstSavingMillisBefore
223      *         The DST offset before the first transition in milliseconds.
224      */
toZoneOffsetTransitionRule( AnnualTimeZoneRule rule, int dstSavingMillisBefore)225     private static ZoneOffsetTransitionRule toZoneOffsetTransitionRule(
226             AnnualTimeZoneRule rule, int dstSavingMillisBefore) {
227         DateTimeRule dateTimeRule = rule.getRule();
228         // Calendar.JANUARY is 0, transform it into a proper Month.
229         Month month = Month.JANUARY.plus(dateTimeRule.getRuleMonth());
230         int dayOfMonthIndicator;
231         // Calendar.SUNDAY is 1, transform it into a proper DayOfWeek.
232         DayOfWeek dayOfWeek = DayOfWeek.SATURDAY.plus(dateTimeRule.getRuleDayOfWeek());
233         switch (dateTimeRule.getDateRuleType()) {
234             case DateTimeRule.DOM:
235                 // Transition always on a specific day of the month.
236                 dayOfMonthIndicator = dateTimeRule.getRuleDayOfMonth();
237                 dayOfWeek = null;
238                 break;
239             case DateTimeRule.DOW_GEQ_DOM:
240                 // ICU representation matches java.time representation.
241                 dayOfMonthIndicator = dateTimeRule.getRuleDayOfMonth();
242                 break;
243             case DateTimeRule.DOW_LEQ_DOM:
244                 // java.time uses a negative dayOfMonthIndicator to represent "Sun<=X" or "lastSun"
245                 // rules. ICU uses this constant and the normal day. So "lastSun" in January would
246                 // ruleDayOfMonth = 31 in ICU and dayOfMonthIndicator = -1 in java.time.
247                 dayOfMonthIndicator = -month.maxLength() + dateTimeRule.getRuleDayOfMonth() - 1;
248                 break;
249             case DateTimeRule.DOW:
250                 // DOW is unspecified in the documentation and seems to never be used.
251                 throw new ZoneRulesException("Date rule type DOW is unsupported");
252             default:
253                 throw new ZoneRulesException(
254                         "Unexpected date rule type: " + dateTimeRule.getDateRuleType());
255         }
256         // Cast to int is save, as input is int.
257         int secondOfDay = (int) TimeUnit.MILLISECONDS.toSeconds(dateTimeRule.getRuleMillisInDay());
258         LocalTime time;
259         boolean timeEndOfDay;
260         if (secondOfDay == SECONDS_IN_DAY) {
261             time = LocalTime.MIDNIGHT;
262             timeEndOfDay = true;
263         } else {
264             time = LocalTime.ofSecondOfDay(secondOfDay);
265             timeEndOfDay = false;
266         }
267         ZoneOffsetTransitionRule.TimeDefinition timeDefinition;
268         switch (dateTimeRule.getTimeRuleType()) {
269             case DateTimeRule.WALL_TIME:
270                 timeDefinition = ZoneOffsetTransitionRule.TimeDefinition.WALL;
271                 break;
272             case DateTimeRule.STANDARD_TIME:
273                 timeDefinition = ZoneOffsetTransitionRule.TimeDefinition.STANDARD;
274                 break;
275             case DateTimeRule.UTC_TIME:
276                 timeDefinition = ZoneOffsetTransitionRule.TimeDefinition.UTC;
277                 break;
278             default:
279                 throw new ZoneRulesException(
280                         "Unexpected time rule type " + dateTimeRule.getTimeRuleType());
281         }
282         ZoneOffset standardOffset = millisToOffset(rule.getRawOffset());
283         ZoneOffset offsetBefore = millisToOffset(rule.getRawOffset() + dstSavingMillisBefore);
284         ZoneOffset offsetAfter = millisToOffset(
285                 rule.getRawOffset() + rule.getDSTSavings());
286         return ZoneOffsetTransitionRule.of(
287                 month, dayOfMonthIndicator, dayOfWeek, time, timeEndOfDay, timeDefinition,
288                 standardOffset, offsetBefore, offsetAfter);
289     }
290 
millisToOffset(int offset)291     private static ZoneOffset millisToOffset(int offset) {
292         // Cast to int is save, as input is int.
293         return ZoneOffset.ofTotalSeconds((int) TimeUnit.MILLISECONDS.toSeconds(offset));
294     }
295 
296     private static class ZoneRulesCache extends BasicLruCache<String, ZoneRules> {
297 
ZoneRulesCache(int maxSize)298         ZoneRulesCache(int maxSize) {
299             super(maxSize);
300         }
301 
302         @Override
create(String zoneId)303         protected ZoneRules create(String zoneId) {
304             String canonicalId = TimeZone.getCanonicalID(zoneId);
305             if (!canonicalId.equals(zoneId)) {
306                 // Return the same object as the canonical one, to avoid wasting space, but cache
307                 // it under the non-cannonical name as well, to avoid future getCanonicalID calls.
308                 return get(canonicalId);
309             }
310             return generateZoneRules(zoneId);
311         }
312     }
313 }
314