447 lines
19 KiB
Java
447 lines
19 KiB
Java
/*
|
|
* 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);
|
|
}
|
|
}
|