blob: d0ec5938e92974e64948c161d02c0b1bc37f6f5f [file] [log] [blame]
/*
* Copyright (C) 2020 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 com.android.icu.util;
import android.icu.impl.Grego;
import android.icu.util.AnnualTimeZoneRule;
import android.icu.util.BasicTimeZone;
import android.icu.util.DateTimeRule;
import android.icu.util.InitialTimeZoneRule;
import android.icu.util.TimeArrayTimeZoneRule;
import android.icu.util.TimeZone;
import android.icu.util.TimeZoneRule;
import android.icu.util.TimeZoneTransition;
import libcore.api.IntraCoreApi;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.time.ZoneOffset;
import java.time.zone.ZoneOffsetTransition;
import java.time.zone.ZoneOffsetTransitionRule;
import java.time.zone.ZoneRules;
import java.time.zone.ZoneRulesException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
/**
* Provide extra functionalities on top of {@link TimeZone} public APIs.
*
* @hide
*/
@IntraCoreApi
public class ExtendedTimeZone {
private final TimeZone timezone;
private ExtendedTimeZone(String id) {
timezone = TimeZone.getTimeZone(id);
}
// The API which calls an implementation in android.icu does not use nullability annotation
// because the upstream can't guarantee the stability. See http://b/140196694.
/**
* Returns an instance from the time zone ID. Note that the returned instance could be shared.
*
* @see TimeZone#getTimeZone(String) for the more information.
* @hide
*/
@IntraCoreApi
public static ExtendedTimeZone getInstance(String id) {
return new ExtendedTimeZone(id);
}
/**
* Clears the default time zone in ICU4J. When next {@link TimeZone#getDefault()} is called,
* ICU4J will re-initialize the default time zone from the value obtained from the libcore's
* {@link java.util.TimeZone#getDefault()}.
*
* This API is useful for libcore's {@link java.util.TimeZone#setDefault(java.util.TimeZone)} to
* break the cycle of synchronizing the default time zone between libcore and ICU4J.
*
* @hide
*/
@IntraCoreApi
public static void clearDefaultTimeZone() {
TimeZone.setICUDefault(null);
}
/**
* Returns the underlying {@link TimeZone} instance.
*
* @hide
*/
@IntraCoreApi
public TimeZone getTimeZone() {
return timezone;
}
/**
* Returns a {@link ZoneRules} instance for this time zone.
*
* @throws ZoneRulesException if the internal rules can't be parsed correctly, or it's not
* implemented for the subtype of {@link TimeZone}.
*
* @implNote This implementations relies on {@link BasicTimeZone#getTimeZoneRules()} in the
* following way:
* Returned array starts with {@code InitialTimeZoneRule}, followed by {@code
* TimeArrayTimeZoneRule}, and, if available, ends with {@code AnnualTimeZoneRule}.
* @hide
*/
@IntraCoreApi
public ZoneRules createZoneRules() {
if (!(timezone instanceof BasicTimeZone)) {
throw zoneRulesException("timezone is "
+ timezone.getClass().getCanonicalName()
+ " which is not instance of BasicTimeZone");
}
BasicTimeZone basicTimeZone = (BasicTimeZone) timezone;
TimeZoneRule[] timeZoneRules = basicTimeZone.getTimeZoneRules();
if (timeZoneRules.length == 0) {
throw zoneRulesException("Got 0 time zone rules");
}
ZoneOffset baseStandardOffset = null;
ZoneOffset baseWallOffset = null;
NavigableMap<Long, TimeArrayTimeZoneRule> rulesByStartTime = new TreeMap<>();
boolean hasRecurringRules = false;
for (TimeZoneRule timeZoneRule : timeZoneRules) {
if (timeZoneRule instanceof InitialTimeZoneRule) {
InitialTimeZoneRule initialTimeZoneRule = (InitialTimeZoneRule) timeZoneRule;
baseStandardOffset = standardOffset(initialTimeZoneRule);
baseWallOffset = fullOffset(initialTimeZoneRule);
} else if (timeZoneRule instanceof TimeArrayTimeZoneRule) {
TimeArrayTimeZoneRule timeArrayTimeZoneRule = (TimeArrayTimeZoneRule) timeZoneRule;
for (long startTime : timeArrayTimeZoneRule.getStartTimes()) {
rulesByStartTime.put(
utcStartTime(startTime, timeArrayTimeZoneRule), timeArrayTimeZoneRule);
}
} else if (timeZoneRule instanceof AnnualTimeZoneRule) {
// Order of AnnualTimeZoneRule-s in BasicTimeZone#getTimeZoneRules is not
// specified, they will be fetched using different API.
hasRecurringRules = true;
} else {
throw zoneRulesException(
"Unrecognized time zone rule " + timeZoneRule.getClass() + ".");
}
}
// Keep in mind that transitionList is not superset of standardOffsetTransitionList.
// transitionList keeps track of wall clock changes, but it might remain the same after
// standard offset change if DST was changed too.
List<ZoneOffsetTransition> standardOffsetTransitionList = new ArrayList<>();
List<ZoneOffsetTransition> transitionList = new ArrayList<>();
ZoneOffset lastStandardOffset = baseStandardOffset;
ZoneOffset lastWallOffset = baseWallOffset;
for (Map.Entry<Long, TimeArrayTimeZoneRule> entry : rulesByStartTime.entrySet()) {
long startTime = entry.getKey();
TimeArrayTimeZoneRule timeZoneRule = entry.getValue();
ZoneOffset ruleStandardOffset = standardOffset(timeZoneRule);
if (!ruleStandardOffset.equals(lastStandardOffset)) {
// ZoneRules needs changes in standard offsets only as an argument.
// ZoneOffsetTransition requires before and after offsets to be different, so wall
// clock offset can't be used as beforeOffset(it can be equal to afterOffset). Using
// previous standard offset seems to be the only reasonable choice left.
// As of 2021 transition and beforeOffset arguments are used to calculate UTC offset
// of the switch date and previous standard offset will do the trick.
ZoneOffsetTransition zoneOffsetTransition =
ZoneOffsetTransition.of(
localDateTime(startTime, lastStandardOffset),
lastStandardOffset,
ruleStandardOffset);
standardOffsetTransitionList.add(zoneOffsetTransition);
lastStandardOffset = ruleStandardOffset;
}
ZoneOffset ruleWallOffset = fullOffset(timeZoneRule);
// ZoneOffsetTransition tracks only changes in full offset - if raw and DST offsets
// sum is not changed after a transition, such transition is not tracked by ZoneRules.
// ICU does not squash such transitions.
if (!lastWallOffset.equals(ruleWallOffset)) {
ZoneOffsetTransition zoneOffsetTransition =
ZoneOffsetTransition.of(
localDateTime(startTime, lastWallOffset),
lastWallOffset,
ruleWallOffset);
transitionList.add(zoneOffsetTransition);
lastWallOffset = ruleWallOffset;
}
}
List<ZoneOffsetTransitionRule> lastRules = new ArrayList<>();
if (hasRecurringRules) {
List<AnnualTimeZoneRule> annualTimeZoneRules = new ArrayList<>();
// ZoneOffsetTransitionRule requires beforeOffset. As total offset in
// TimeArrayTimeZoneRule may differ from offset of the last recurring rule
// we apply all available recurring rule once. It is possible to build lastRules
// in loop below, but doing it in separate loop simplifies code significantly.
TimeZoneTransition firstTransitionToAnnualRule = basicTimeZone
.getNextTransition(rulesByStartTime.lastKey(), false /* inclusive */);
AnnualTimeZoneRule firstAnnualRule =
(AnnualTimeZoneRule) firstTransitionToAnnualRule.getTo();
AnnualTimeZoneRule currentTimeZoneRule = firstAnnualRule;
long currentUnixEpochTime = firstTransitionToAnnualRule.getTime();
do {
annualTimeZoneRules.add(currentTimeZoneRule);
if (annualTimeZoneRules.size() > 16) {
throw zoneRulesException("More than 16 annual transitions found.");
}
ZoneOffset ruleStandardOffset = standardOffset(currentTimeZoneRule);
if (!lastStandardOffset.equals(ruleStandardOffset)) {
standardOffsetTransitionList.add(
ZoneOffsetTransition.of(
localDateTime(currentUnixEpochTime, lastStandardOffset),
lastStandardOffset,
ruleStandardOffset
));
lastStandardOffset = ruleStandardOffset;
}
int currentYear =
Instant.ofEpochMilli(currentUnixEpochTime).atOffset(lastWallOffset).getYear();
ZoneOffsetTransition recurringRuleTransition =
createZoneOffsetTransitionRule(
currentTimeZoneRule,
lastStandardOffset,
lastWallOffset)
.createTransition(currentYear);
// After introduction of first annual rule wall offset may not change.
if (!lastWallOffset.equals(recurringRuleTransition.getOffsetAfter())) {
transitionList.add(ZoneOffsetTransition.of(
localDateTime(currentUnixEpochTime, lastWallOffset),
lastWallOffset,
recurringRuleTransition.getOffsetAfter()));
lastWallOffset = recurringRuleTransition.getOffsetAfter();
}
TimeZoneTransition nextTransition =
basicTimeZone.getNextTransition(currentUnixEpochTime, false /* inclusive */);
currentUnixEpochTime = nextTransition.getTime();
currentTimeZoneRule = (AnnualTimeZoneRule) nextTransition.getTo();
if (currentTimeZoneRule == null) {
throw zoneRulesException("No transitions after "
+ currentUnixEpochTime + " for a timezone with recurring rules");
}
} while (!currentTimeZoneRule.isEquivalentTo(firstAnnualRule));
// All annual rules use the same standard offset and wall offset is always updated on
// transition.
// The initial value of lastWallOffset is the wall offset of the last recurring
// AnnualTimeZoneRule.
for (AnnualTimeZoneRule annualTimeZoneRule : annualTimeZoneRules) {
ZoneOffsetTransitionRule zoneOffsetTransitionRule =
createZoneOffsetTransitionRule(
annualTimeZoneRule, lastStandardOffset, lastWallOffset);
lastWallOffset = zoneOffsetTransitionRule.getOffsetAfter();
lastRules.add(zoneOffsetTransitionRule);
}
// ZoneRules does not specify it, but internally it expects lastRules to be sorted
// (see ZoneRules#getOffset) in the order they will happen within a year. For example,
// if rule A starts in October 2021, and rule B starts in March 2022, expected order
// is [B, A].
// We assume that for any year that order is fixed, even though it is possible
// to build set of rules where order depends on a given year.
// annualTimeZoneRules stores rules in the order they happened, so we just need to find
// a break in startYear.
int firstRuleIndex = 0;
while (firstRuleIndex < annualTimeZoneRules.size()
&& firstAnnualRule.getStartYear()
== annualTimeZoneRules.get(firstRuleIndex).getStartYear()) {
++firstRuleIndex;
}
Collections.rotate(lastRules, -firstRuleIndex);
}
return ZoneRules.of(
baseStandardOffset,
baseWallOffset,
standardOffsetTransitionList,
transitionList,
lastRules);
}
/**
* Converts {@link AnnualTimeZoneRule} to {@link ZoneOffsetTransitionRule}. Switch date may be
* represented relative to UTC, wall clock, or standard offset. For the latter 2 cases
* {@code lastWallOffset} and {@code lastStandardOffset} are used.
*
* @param annualTimeZoneRule rule to be converted
* @param lastStandardOffset standard offset of a rule which preceded {@code
* annualTimeZoneRule}
* @param lastWallOffset wall offset of a rule which preceded {@code annualTimeZoneRule}
*/
private ZoneOffsetTransitionRule createZoneOffsetTransitionRule(
AnnualTimeZoneRule annualTimeZoneRule,
ZoneOffset lastStandardOffset,
ZoneOffset lastWallOffset) {
DateTimeRule dateTimeRule = annualTimeZoneRule.getRule();
Month month = Month.of(dateTimeRule.getRuleMonth() + 1);
final DayOfWeek dayOfWeek;
final int dayOfMonthIndicator;
switch (dateTimeRule.getDateRuleType()) {
case DateTimeRule.DOM:
dayOfMonthIndicator = dateTimeRule.getRuleDayOfMonth();
dayOfWeek = null;
break;
case DateTimeRule.DOW:
int weekInMonth = dateTimeRule.getRuleWeekInMonth();
if (weekInMonth > 0) {
dayOfMonthIndicator = (weekInMonth - 1) * 7 + 1;
} else if (weekInMonth < 0) {
dayOfMonthIndicator = (weekInMonth + 1) * 7 - 1;
} else {
throw zoneRulesException("Invalid DateTimeRule in "
+ annualTimeZoneRule +
". Non-zero weekInMonth expected in " + dateTimeRule);
}
dayOfWeek = dayOfWeek(dateTimeRule);
break;
case DateTimeRule.DOW_GEQ_DOM:
dayOfMonthIndicator = dateTimeRule.getRuleDayOfMonth();
dayOfWeek = dayOfWeek(dateTimeRule);
break;
case DateTimeRule.DOW_LEQ_DOM:
// java.time.ZoneRules uses negative numbers to indicate that switch date should
// come before certain date. Using leap year so that lastSun like rule will
// always work correctly.
dayOfMonthIndicator =
dateTimeRule.getRuleDayOfMonth() - month.maxLength() - 1;
dayOfWeek = dayOfWeek(dateTimeRule);
break;
default:
throw zoneRulesException("Unexpected dateTimeRule.dateRuleType="
+ dateTimeRule.getTimeRuleType() + " in " + annualTimeZoneRule);
}
final boolean timeEndOfDay;
final LocalTime switchDateTime;
if (dateTimeRule.getRuleMillisInDay() == Grego.MILLIS_PER_DAY) {
timeEndOfDay = true;
switchDateTime = LocalTime.MIDNIGHT;
} else {
timeEndOfDay = false;
switchDateTime = LocalTime.ofNanoOfDay(dateTimeRule.getRuleMillisInDay() * 1_000_000L);
}
ZoneOffsetTransitionRule.TimeDefinition timeDefinition = timeDefinition(annualTimeZoneRule);
// JavaDoc for standardOffset tells that it should be "offset in force at the cutover".
// It's not clear what offset is in effect at the cutover moment, but zic format assumes
// that only DST is changed in annual rules and standard offset is handled differently.
return ZoneOffsetTransitionRule.of(month,
dayOfMonthIndicator,
dayOfWeek,
switchDateTime,
timeEndOfDay,
timeDefinition,
lastStandardOffset,
lastWallOffset,
fullOffset(annualTimeZoneRule));
}
private ZoneOffsetTransitionRule.TimeDefinition timeDefinition(
AnnualTimeZoneRule annualTimeZoneRule) {
DateTimeRule dateTimeRule = annualTimeZoneRule.getRule();
switch (dateTimeRule.getTimeRuleType()) {
case DateTimeRule.STANDARD_TIME:
return ZoneOffsetTransitionRule.TimeDefinition.STANDARD;
case DateTimeRule.UTC_TIME:
return ZoneOffsetTransitionRule.TimeDefinition.UTC;
case DateTimeRule.WALL_TIME:
return ZoneOffsetTransitionRule.TimeDefinition.WALL;
default:
throw zoneRulesException(
"Unexpected dateTimeRule.timeRuleType=" + dateTimeRule.getTimeRuleType()
+ " in AnnualTimeZoneRule: " + annualTimeZoneRule);
}
}
private long utcStartTime(long startTime, TimeArrayTimeZoneRule timeZoneRule) {
switch (timeZoneRule.getTimeType()) {
case DateTimeRule.UTC_TIME:
return startTime;
case DateTimeRule.STANDARD_TIME:
return startTime - timeZoneRule.getRawOffset();
case DateTimeRule.WALL_TIME:
return startTime - timeZoneRule.getRawOffset() - timeZoneRule.getDSTSavings();
default:
throw zoneRulesException("Unexpected timeType in " + timeZoneRule);
}
}
private ZoneRulesException zoneRulesException(String message) {
return new ZoneRulesException("Failed to build ZoneRules for " + timezone.getID() +
". " + message);
}
private static LocalDateTime localDateTime(long epochMillis, ZoneOffset zoneOffset) {
return Instant.ofEpochMilli(epochMillis)
.atOffset(zoneOffset)
.toLocalDateTime();
}
private static DayOfWeek dayOfWeek(DateTimeRule dateTimeRule) {
return DayOfWeek.SUNDAY.plus(dateTimeRule.getRuleDayOfWeek() - 1);
}
private static ZoneOffset standardOffset(TimeZoneRule timeZoneRule) {
return toOffset(timeZoneRule.getRawOffset());
}
private static ZoneOffset fullOffset(TimeZoneRule timeZoneRule) {
return toOffset(timeZoneRule.getRawOffset() + timeZoneRule.getDSTSavings());
}
private static ZoneOffset toOffset(int rawOffset) {
return ZoneOffset.ofTotalSeconds(rawOffset / 1_000);
}
}