Merge "Add 2 APIs in ExtendedTimeZoneNames and ExtendedTimeZone"
diff --git a/android_icu4j/api/intra/current.txt b/android_icu4j/api/intra/current.txt
index 8ade871..ae66e3b 100644
--- a/android_icu4j/api/intra/current.txt
+++ b/android_icu4j/api/intra/current.txt
@@ -134,6 +134,7 @@
public class ExtendedTimeZoneNames {
method @NonNull public static com.android.icu.text.ExtendedTimeZoneNames getInstance(@NonNull android.icu.util.ULocale);
method @NonNull public android.icu.text.TimeZoneNames getTimeZoneNames();
+ method @Nullable public com.android.icu.text.ExtendedTimeZoneNames.MatchedTimeZone matchName(@NonNull CharSequence, int, @NonNull String);
}
public static class ExtendedTimeZoneNames.MatchedTimeZone {
@@ -162,6 +163,7 @@
public class ExtendedTimeZone {
method public static void clearDefaultTimeZone();
+ method public java.time.zone.ZoneRules createZoneRules();
method public static com.android.icu.util.ExtendedTimeZone getInstance(String);
method public android.icu.util.TimeZone getTimeZone();
}
diff --git a/android_icu4j/libcore_bridge/src/java/com/android/icu/text/ExtendedTimeZoneNames.java b/android_icu4j/libcore_bridge/src/java/com/android/icu/text/ExtendedTimeZoneNames.java
index 0bba744..38f8ce6 100644
--- a/android_icu4j/libcore_bridge/src/java/com/android/icu/text/ExtendedTimeZoneNames.java
+++ b/android_icu4j/libcore_bridge/src/java/com/android/icu/text/ExtendedTimeZoneNames.java
@@ -17,12 +17,20 @@
package com.android.icu.text;
import android.icu.text.TimeZoneNames;
+import android.icu.util.TimeZone;
import android.icu.util.ULocale;
import libcore.api.IntraCoreApi;
import libcore.util.NonNull;
import libcore.util.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
/**
* Provide extra functionalities on top of {@link TimeZoneNames} public APIs.
*
@@ -31,6 +39,20 @@
@IntraCoreApi
public class ExtendedTimeZoneNames {
+ private static final Set<TimeZoneNames.NameType> DST_NAME_TYPES =
+ Collections.unmodifiableSet(
+ EnumSet.of(
+ TimeZoneNames.NameType.LONG_DAYLIGHT,
+ TimeZoneNames.NameType.SHORT_DAYLIGHT));
+
+ private static final EnumSet<TimeZoneNames.NameType> STANDARD_AND_DST_TYPES =
+ EnumSet.of(
+ TimeZoneNames.NameType.SHORT_STANDARD,
+ TimeZoneNames.NameType.LONG_STANDARD,
+ TimeZoneNames.NameType.SHORT_DAYLIGHT,
+ TimeZoneNames.NameType.LONG_DAYLIGHT);
+
+ private final ULocale locale;
private final TimeZoneNames timeZoneNames;
/**
@@ -83,6 +105,7 @@
}
private ExtendedTimeZoneNames(ULocale locale) {
+ this.locale = locale;
this.timeZoneNames = TimeZoneNames.getInstance(locale);
}
@@ -106,4 +129,100 @@
return timeZoneNames;
}
+ /**
+ * Returns {@link MatchedTimeZone} if a time zone name in ICU can be matched against the input
+ * CharSequence {@code s}.
+ * The best match is found by the following principles:
+ * <ul>
+ * <li>Length of the matched name. Longest name matched to the given {@code s} has the
+ * highest priority.</li>
+ * <li>The current time zone and meta zones possible in the current country have higher
+ * priority than other zones.</li>
+ * <li>If only meta zones are matched, the country/region in the locale is used to select
+ * a reference time zone. For example, if the name is "Pacific Standard Time" and the country
+ * is US, America/Los_Angeles is returned.</li>
+ * </ul>
+ *
+ * @param s input string to be matched against time zone names in ICU
+ * @param start the begin index in the CharSequence {@code s}
+ * @param currentTzId the time zone ID prioritized to be matched if multiple time zone IDs can
+ * be matched and this is one of the matched IDs.
+ * @return null if no match is found
+ *
+ * @hide
+ */
+ @IntraCoreApi
+ public @Nullable MatchedTimeZone matchName(@NonNull CharSequence s, int start,
+ @NonNull String currentTzId) {
+ currentTzId = TimeZone.getCanonicalID(currentTzId);
+
+ Collection<TimeZoneNames.MatchInfo> matchedInfos =
+ timeZoneNames.find(s, start, STANDARD_AND_DST_TYPES);
+
+ if (matchedInfos.isEmpty()) {
+ return null;
+ }
+
+ List<TimeZoneNames.MatchInfo> maxLengthMatchInfos = new ArrayList<>();
+ int maxMatchedInfoLength = 0;
+
+ for (TimeZoneNames.MatchInfo matchInfo : matchedInfos) {
+ if (matchInfo.matchLength() > maxMatchedInfoLength) {
+ maxMatchedInfoLength = matchInfo.matchLength();
+ maxLengthMatchInfos.clear();
+ }
+
+ if (matchInfo.matchLength() >= maxMatchedInfoLength) {
+ maxLengthMatchInfos.add(matchInfo);
+ }
+ }
+
+ Set<String> metaZonesInCurrentZone = timeZoneNames.getAvailableMetaZoneIDs(currentTzId);
+
+ TimeZoneNames.MatchInfo tzMatchInfo = null;
+
+ for (TimeZoneNames.MatchInfo matchInfo : maxLengthMatchInfos) {
+ if (matchInfo.tzID() != null && matchInfo.tzID().equals(currentTzId)) {
+ return matchedTimeZone(matchInfo.tzID(), matchInfo);
+ }
+ if (matchInfo.mzID() != null && metaZonesInCurrentZone.contains(matchInfo.mzID())) {
+ return matchedTimeZone(currentTzId, matchInfo);
+ }
+
+ if (matchInfo.tzID() != null) {
+ tzMatchInfo = matchInfo;
+ }
+ }
+
+ if (tzMatchInfo != null) {
+ return matchedTimeZone(tzMatchInfo.tzID(), tzMatchInfo);
+ }
+
+ String region = locale.getCountry();
+ if (region == null || region.isEmpty()) {
+ // An UN M49 code to represent the world. See TimeZoneNames#getReferenceZoneID().
+ region = "001";
+ }
+
+ for (TimeZoneNames.MatchInfo matchInfo : maxLengthMatchInfos) {
+ if (matchInfo.mzID() != null) {
+ String timeZoneId = timeZoneNames.getReferenceZoneID(matchInfo.mzID(), region);
+ if (timeZoneId != null) {
+ return matchedTimeZone(timeZoneId, matchInfo);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static MatchedTimeZone matchedTimeZone(
+ String timeZoneId, TimeZoneNames.MatchInfo matchInfo) {
+ return new MatchedTimeZone(
+ matchInfo.matchLength(),
+ timeZoneId,
+ DST_NAME_TYPES.contains(matchInfo.nameType()));
+
+ }
+
}
diff --git a/android_icu4j/libcore_bridge/src/java/com/android/icu/util/ExtendedTimeZone.java b/android_icu4j/libcore_bridge/src/java/com/android/icu/util/ExtendedTimeZone.java
index fe3d9ed..5c2f3e5 100644
--- a/android_icu4j/libcore_bridge/src/java/com/android/icu/util/ExtendedTimeZone.java
+++ b/android_icu4j/libcore_bridge/src/java/com/android/icu/util/ExtendedTimeZone.java
@@ -16,12 +16,34 @@
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;
/**
@@ -76,4 +98,348 @@
return timezone;
}
+ /**
+ * Returns a {@link ZoneRules} instance for this time zone.
+ *
+ * @throws ZoneRulesException if the zone rules can't be created.
+ *
+ * @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 currentUtcTime = 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(currentUtcTime, lastStandardOffset),
+ lastStandardOffset,
+ ruleStandardOffset
+ ));
+ lastStandardOffset = ruleStandardOffset;
+ }
+
+ int currentYear =
+ Instant.ofEpochMilli(currentUtcTime).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(currentUtcTime, lastWallOffset),
+ lastWallOffset,
+ recurringRuleTransition.getOffsetAfter()));
+ lastWallOffset = recurringRuleTransition.getOffsetAfter();
+ }
+
+ TimeZoneTransition nextTransition =
+ basicTimeZone.getNextTransition(currentUtcTime, false /* inclusive */);
+ currentUtcTime = nextTransition.getTime();
+ currentTimeZoneRule = (AnnualTimeZoneRule) nextTransition.getTo();
+
+ if (currentTimeZoneRule == null) {
+ throw zoneRulesException("No transitions after "
+ + currentUtcTime + " 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);
+ }
}