528 lines
21 KiB
Java
528 lines
21 KiB
Java
/*
|
|
* Copyright (C) 2017 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.i18n.timezone;
|
|
|
|
import android.icu.util.TimeZone;
|
|
|
|
import com.android.i18n.util.Log;
|
|
|
|
import java.time.Instant;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collections;
|
|
import java.util.HashSet;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* Information about a country's time zones.
|
|
*
|
|
* @hide
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public final class CountryTimeZones {
|
|
|
|
/**
|
|
* A mapping to a time zone ID with some associated metadata.
|
|
*
|
|
* @hide
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public static final class TimeZoneMapping {
|
|
private final String timeZoneId;
|
|
private final boolean shownInPicker;
|
|
private final Long notUsedAfter;
|
|
private final List<String> alternativeIds;
|
|
|
|
/** Memoized TimeZone object for {@link #timeZoneId}. */
|
|
private TimeZone timeZone;
|
|
|
|
TimeZoneMapping(String timeZoneId, boolean shownInPicker, Long notUsedAfter,
|
|
List<String> alternativeIds) {
|
|
this.timeZoneId = Objects.requireNonNull(timeZoneId);
|
|
this.shownInPicker = shownInPicker;
|
|
this.notUsedAfter = notUsedAfter;
|
|
this.alternativeIds = Collections.unmodifiableList(new ArrayList<>(alternativeIds));
|
|
}
|
|
|
|
@libcore.api.CorePlatformApi
|
|
public String getTimeZoneId() {
|
|
return timeZoneId;
|
|
}
|
|
|
|
@libcore.api.CorePlatformApi
|
|
public boolean isShownInPickerAt(Instant time) {
|
|
return shownInPicker
|
|
&& (notUsedAfter == null || notUsedAfter >= time.toEpochMilli());
|
|
}
|
|
|
|
/**
|
|
* Returns a list of alternative time zone IDs that are linked to this one. Can be empty,
|
|
* never returns null.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public List<String> getAlternativeIds() {
|
|
return alternativeIds;
|
|
}
|
|
|
|
/**
|
|
* Returns a {@link TimeZone} object for this mapping, or {@code null} if the ID is unknown.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public TimeZone getTimeZone() {
|
|
synchronized (this) {
|
|
if (timeZone == null) {
|
|
TimeZone tz = TimeZone.getFrozenTimeZone(timeZoneId);
|
|
timeZone = tz;
|
|
if (TimeZone.UNKNOWN_ZONE_ID.equals(timeZone.getID())) {
|
|
// This shouldn't happen given the validation that takes place in
|
|
// createValidatedCountryTimeZones().
|
|
throw new IllegalStateException("Invalid zone in TimeZoneMapping: " + this);
|
|
}
|
|
}
|
|
}
|
|
|
|
return TimeZone.UNKNOWN_ZONE_ID.equals(timeZone.getID()) ? null : timeZone;
|
|
}
|
|
|
|
/**
|
|
* Returns {@code true} if the mapping is "effective" after {@code whenMillis}, i.e.
|
|
* it is distinct from other "effective" times zones used in the country at/after that
|
|
* time. This uses the {@link #notUsedAfter} metadata which ensures there is one time
|
|
* zone remaining when there are multiple candidate zones with the same rules. The one
|
|
* kept is based on country specific factors like population covered.
|
|
*/
|
|
boolean isEffectiveAt(long whenMillis) {
|
|
return notUsedAfter == null || whenMillis <= notUsedAfter;
|
|
}
|
|
|
|
// VisibleForTesting
|
|
@libcore.api.CorePlatformApi
|
|
public static TimeZoneMapping createForTests(String timeZoneId, boolean showInPicker,
|
|
Long notUsedAfter, List<String> alternativeIds) {
|
|
return new TimeZoneMapping(timeZoneId, showInPicker, notUsedAfter, alternativeIds);
|
|
}
|
|
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (this == o) {
|
|
return true;
|
|
}
|
|
if (o == null || getClass() != o.getClass()) {
|
|
return false;
|
|
}
|
|
TimeZoneMapping that = (TimeZoneMapping) o;
|
|
return shownInPicker == that.shownInPicker &&
|
|
Objects.equals(timeZoneId, that.timeZoneId) &&
|
|
Objects.equals(notUsedAfter, that.notUsedAfter) &&
|
|
Objects.equals(alternativeIds, that.alternativeIds);
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return Objects.hash(timeZoneId, shownInPicker, notUsedAfter, alternativeIds);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "TimeZoneMapping{"
|
|
+ "timeZoneId='" + timeZoneId + '\''
|
|
+ ", shownInPicker=" + shownInPicker
|
|
+ ", notUsedAfter=" + notUsedAfter
|
|
+ ", alternativeIds=" + alternativeIds
|
|
+ '}';
|
|
}
|
|
|
|
/**
|
|
* Returns {@code true} if one of the supplied {@link TimeZoneMapping} objects is for the
|
|
* specified time zone ID.
|
|
*/
|
|
static boolean containsTimeZoneId(
|
|
List<TimeZoneMapping> timeZoneMappings, String timeZoneId) {
|
|
for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
|
|
if (timeZoneMapping.timeZoneId.equals(timeZoneId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The result of lookup up a time zone using offset information (and possibly more).
|
|
*
|
|
* @hide
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public static final class OffsetResult {
|
|
|
|
/** A zone that matches the supplied criteria. See also {@link #isOnlyMatch}. */
|
|
private final TimeZone timeZone;
|
|
|
|
/** True if there is one match for the supplied criteria */
|
|
private final boolean isOnlyMatch;
|
|
|
|
public OffsetResult(TimeZone timeZone, boolean isOnlyMatch) {
|
|
this.timeZone = Objects.requireNonNull(timeZone);
|
|
this.isOnlyMatch = isOnlyMatch;
|
|
}
|
|
|
|
@libcore.api.CorePlatformApi
|
|
public TimeZone getTimeZone() {
|
|
return timeZone;
|
|
}
|
|
|
|
@libcore.api.CorePlatformApi
|
|
public boolean isOnlyMatch() {
|
|
return isOnlyMatch;
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "OffsetResult{"
|
|
+ "timeZone(ID)='" + timeZone.getID() + '\''
|
|
+ ", isOnlyMatch=" + isOnlyMatch
|
|
+ '}';
|
|
}
|
|
}
|
|
|
|
private final String countryIso;
|
|
private final String defaultTimeZoneId;
|
|
/**
|
|
* {@code true} indicates the default time zone for a country is a good choice if a time zone
|
|
* cannot be determined by other means.
|
|
*/
|
|
private final boolean defaultTimeZoneBoosted;
|
|
private final List<TimeZoneMapping> timeZoneMappings;
|
|
private final boolean everUsesUtc;
|
|
|
|
/**
|
|
* Memoized frozen ICU TimeZone object for the default. Can be {@link TimeZone#UNKNOWN_ZONE} if
|
|
* the {@link #defaultTimeZoneId} is missing or unrecognized.
|
|
*/
|
|
private TimeZone defaultTimeZone;
|
|
|
|
private CountryTimeZones(String countryIso, String defaultTimeZoneId,
|
|
boolean defaultTimeZoneBoosted, boolean everUsesUtc,
|
|
List<TimeZoneMapping> timeZoneMappings) {
|
|
this.countryIso = Objects.requireNonNull(countryIso);
|
|
this.defaultTimeZoneId = defaultTimeZoneId;
|
|
this.defaultTimeZoneBoosted = defaultTimeZoneBoosted;
|
|
this.everUsesUtc = everUsesUtc;
|
|
// Create a defensive copy of the mapping list.
|
|
this.timeZoneMappings = Collections.unmodifiableList(new ArrayList<>(timeZoneMappings));
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link CountryTimeZones} object containing only known time zone IDs.
|
|
*/
|
|
public static CountryTimeZones createValidated(String countryIso, String defaultTimeZoneId,
|
|
boolean defaultTimeZoneBoosted, boolean everUsesUtc,
|
|
List<TimeZoneMapping> timeZoneMappings, String debugInfo) {
|
|
|
|
// We rely on ZoneInfoDB to tell us what the known valid time zone IDs are. ICU may
|
|
// recognize more but we want to be sure that zone IDs can be used with java.util as well as
|
|
// android.icu and ICU is expected to have a superset.
|
|
String[] validTimeZoneIdsArray = ZoneInfoDb.getInstance().getAvailableIDs();
|
|
HashSet<String> validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray));
|
|
List<TimeZoneMapping> validCountryTimeZoneMappings = new ArrayList<>();
|
|
for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
|
|
String timeZoneId = timeZoneMapping.timeZoneId;
|
|
if (!validTimeZoneIdsSet.contains(timeZoneId)) {
|
|
Log.w("Skipping invalid zone: " + timeZoneId + " at " + debugInfo);
|
|
} else {
|
|
validCountryTimeZoneMappings.add(timeZoneMapping);
|
|
}
|
|
}
|
|
|
|
// We don't get too strict at runtime about whether the defaultTimeZoneId must be
|
|
// one of the country's time zones because this is the data we have to use (we also
|
|
// assume the data was validated by earlier steps). The default time zone ID must just
|
|
// be a recognized zone ID: if it's not valid we leave it null.
|
|
if (!validTimeZoneIdsSet.contains(defaultTimeZoneId)) {
|
|
Log.w("Invalid default time zone ID: " + defaultTimeZoneId
|
|
+ " at " + debugInfo);
|
|
defaultTimeZoneId = null;
|
|
}
|
|
|
|
String normalizedCountryIso = normalizeCountryIso(countryIso);
|
|
return new CountryTimeZones(
|
|
normalizedCountryIso, defaultTimeZoneId, defaultTimeZoneBoosted, everUsesUtc,
|
|
validCountryTimeZoneMappings);
|
|
}
|
|
|
|
/**
|
|
* Returns the ISO code for the country.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public String getCountryIso() {
|
|
return countryIso;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the ISO code for the country is a case-insensitive match for the one
|
|
* supplied.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public boolean matchesCountryCode(String countryIso) {
|
|
return this.countryIso.equals(normalizeCountryIso(countryIso));
|
|
}
|
|
|
|
/**
|
|
* Returns the default time zone ID for the country. Can return {@code null} in cases when no
|
|
* data is available or the time zone ID provided to
|
|
* {@link #createValidated(String, String, boolean, boolean, List, String)} was not recognized.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public String getDefaultTimeZoneId() {
|
|
return defaultTimeZoneId;
|
|
}
|
|
|
|
/**
|
|
* Returns the default time zone for the country. Can return {@code null} in cases when no data
|
|
* is available or the time zone ID provided to
|
|
* {@link #createValidated(String, String, boolean, boolean, List, String)} was not recognized.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public synchronized TimeZone getDefaultTimeZone() {
|
|
if (defaultTimeZone == null) {
|
|
TimeZone timeZone;
|
|
if (defaultTimeZoneId == null) {
|
|
timeZone = TimeZone.UNKNOWN_ZONE;
|
|
} else {
|
|
timeZone = TimeZone.getFrozenTimeZone(defaultTimeZoneId);
|
|
}
|
|
this.defaultTimeZone = timeZone;
|
|
}
|
|
return TimeZone.UNKNOWN_ZONE_ID.equals(defaultTimeZone.getID()) ? null : defaultTimeZone;
|
|
}
|
|
|
|
/**
|
|
* Qualifier for a country's default time zone. {@code true} indicates that the country's
|
|
* default time zone would be a good choice <em>generally</em> when there's no UTC offset
|
|
* information available. This will only be {@code true} in countries with multiple zones where
|
|
* a large majority of the population is covered by only one of them.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public boolean isDefaultTimeZoneBoosted() {
|
|
return defaultTimeZoneBoosted;
|
|
}
|
|
|
|
/**
|
|
* Returns an immutable, ordered list of time zone mappings for the country in an undefined but
|
|
* "priority" order. The list can be empty if there were no zones configured or the configured
|
|
* zone IDs were not recognized.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public List<TimeZoneMapping> getTimeZoneMappings() {
|
|
return timeZoneMappings;
|
|
}
|
|
|
|
/**
|
|
* Returns {@code true} if the country has at least one time zone that uses UTC at the given
|
|
* time. This is an efficient check when trying to validate received UTC offset information.
|
|
* For example, there are situations when a detected zero UTC offset cannot be distinguished
|
|
* from "no information available" or a corrupted signal. This method is useful because checking
|
|
* offset information for large countries is relatively expensive but it is generally only the
|
|
* countries close to the prime meridian that use UTC at <em>any</em> time of the year.
|
|
*
|
|
* @param whenMillis the time the offset information is for in milliseconds since the beginning
|
|
* of the Unix epoch
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public boolean hasUtcZone(long whenMillis) {
|
|
// If the data tells us the country never uses UTC we don't have to check anything.
|
|
if (!everUsesUtc) {
|
|
return false;
|
|
}
|
|
|
|
for (TimeZoneMapping timeZoneMapping : getEffectiveTimeZoneMappingsAt(whenMillis)) {
|
|
TimeZone timeZone = timeZoneMapping.getTimeZone();
|
|
if (timeZone != null && timeZone.getOffset(whenMillis) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns a time zone for the country, if there is one, that matches the supplied properties.
|
|
* If there are multiple matches and the {@code bias} is one of them then it is returned,
|
|
* otherwise an arbitrary match is returned based on the {@link
|
|
* #getEffectiveTimeZoneMappingsAt(long)} ordering.
|
|
*
|
|
* @param whenMillis the Unix epoch time to match against
|
|
* @param bias the time zone to prefer, can be {@code null} to indicate there is no preference
|
|
* @param totalOffsetMillis the offset from UTC at {@code whenMillis}
|
|
* @param isDst the Daylight Savings Time state at {@code whenMillis}. {@code true} means DST,
|
|
* {@code false} means not DST
|
|
* @return an {@link OffsetResult} with information about a matching zone, or {@code null} if
|
|
* there is no match
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public OffsetResult lookupByOffsetWithBias(long whenMillis, TimeZone bias,
|
|
int totalOffsetMillis, boolean isDst) {
|
|
return lookupByOffsetWithBiasInternal(whenMillis, bias, totalOffsetMillis, isDst);
|
|
}
|
|
|
|
/**
|
|
* Returns a time zone for the country, if there is one, that matches the supplied properties.
|
|
* If there are multiple matches and the {@code bias} is one of them then it is returned,
|
|
* otherwise an arbitrary match is returned based on the {@link
|
|
* #getEffectiveTimeZoneMappingsAt(long)} ordering.
|
|
*
|
|
* @param whenMillis the Unix epoch time to match against
|
|
* @param bias the time zone to prefer, can be {@code null} to indicate there is no preference
|
|
* @param totalOffsetMillis the offset from UTC at {@code whenMillis}
|
|
* @return an {@link OffsetResult} with information about a matching zone, or {@code null} if
|
|
* there is no match
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public OffsetResult lookupByOffsetWithBias(long whenMillis, TimeZone bias,
|
|
int totalOffsetMillis) {
|
|
final Boolean isDst = null;
|
|
return lookupByOffsetWithBiasInternal(whenMillis, bias, totalOffsetMillis, isDst);
|
|
}
|
|
|
|
/**
|
|
* Returns an immutable, ordered list of time zone mappings for the country in an undefined but
|
|
* "priority" order, filtered so that only "effective" time zone IDs are returned. An
|
|
* "effective" time zone is one that differs from another time zone used in the country after
|
|
* {@code whenMillis}. The list can be empty if there were no zones configured or the configured
|
|
* zone IDs were not recognized.
|
|
*/
|
|
@libcore.api.CorePlatformApi
|
|
public List<TimeZoneMapping> getEffectiveTimeZoneMappingsAt(long whenMillis) {
|
|
ArrayList<TimeZoneMapping> filteredList = new ArrayList<>(timeZoneMappings.size());
|
|
for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
|
|
if (timeZoneMapping.isEffectiveAt(whenMillis)) {
|
|
filteredList.add(timeZoneMapping);
|
|
}
|
|
}
|
|
return Collections.unmodifiableList(filteredList);
|
|
}
|
|
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (this == o) {
|
|
return true;
|
|
}
|
|
if (o == null || getClass() != o.getClass()) {
|
|
return false;
|
|
}
|
|
CountryTimeZones that = (CountryTimeZones) o;
|
|
return defaultTimeZoneBoosted == that.defaultTimeZoneBoosted
|
|
&& everUsesUtc == that.everUsesUtc
|
|
&& countryIso.equals(that.countryIso)
|
|
&& Objects.equals(defaultTimeZoneId, that.defaultTimeZoneId)
|
|
&& timeZoneMappings.equals(that.timeZoneMappings);
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return Objects.hash(
|
|
countryIso, defaultTimeZoneId, defaultTimeZoneBoosted, timeZoneMappings,
|
|
everUsesUtc);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "CountryTimeZones{"
|
|
+ "countryIso='" + countryIso + '\''
|
|
+ ", defaultTimeZoneId='" + defaultTimeZoneId + '\''
|
|
+ ", defaultTimeZoneBoosted=" + defaultTimeZoneBoosted
|
|
+ ", timeZoneMappings=" + timeZoneMappings
|
|
+ ", everUsesUtc=" + everUsesUtc
|
|
+ '}';
|
|
}
|
|
|
|
/**
|
|
* Returns a time zone for the country, if there is one, that matches the supplied properties.
|
|
* If there are multiple matches and the {@code bias} is one of them then it is returned,
|
|
* otherwise an arbitrary match is returned based on the {@link
|
|
* #getEffectiveTimeZoneMappingsAt(long)} ordering.
|
|
*
|
|
* @param whenMillis the Unix epoch time to match against
|
|
* @param bias the time zone to prefer, can be {@code null}
|
|
* @param totalOffsetMillis the offset from UTC at {@code whenMillis}
|
|
* @param isDst the Daylight Savings Time state at {@code whenMillis}. {@code true} means DST,
|
|
* {@code false} means not DST, {@code null} means unknown
|
|
*/
|
|
private OffsetResult lookupByOffsetWithBiasInternal(long whenMillis, TimeZone bias,
|
|
int totalOffsetMillis, Boolean isDst) {
|
|
List<TimeZoneMapping> timeZoneMappings = getEffectiveTimeZoneMappingsAt(whenMillis);
|
|
if (timeZoneMappings.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
TimeZone firstMatch = null;
|
|
boolean biasMatched = false;
|
|
boolean oneMatch = true;
|
|
for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
|
|
TimeZone match = timeZoneMapping.getTimeZone();
|
|
if (match == null
|
|
|| !offsetMatchesAtTime(whenMillis, match, totalOffsetMillis, isDst)) {
|
|
continue;
|
|
}
|
|
|
|
if (firstMatch == null) {
|
|
firstMatch = match;
|
|
} else {
|
|
oneMatch = false;
|
|
}
|
|
if (bias != null && match.getID().equals(bias.getID())) {
|
|
biasMatched = true;
|
|
}
|
|
if (firstMatch != null && !oneMatch && (bias == null || biasMatched)) {
|
|
break;
|
|
}
|
|
}
|
|
if (firstMatch == null) {
|
|
return null;
|
|
}
|
|
|
|
TimeZone toReturn = biasMatched ? bias : firstMatch;
|
|
return new OffsetResult(toReturn, oneMatch);
|
|
}
|
|
|
|
/**
|
|
* Returns {@code true} if the specified {@code totalOffset} and {@code isDst} would be valid in
|
|
* the {@code timeZone} at time {@code whenMillis}.
|
|
* {@code totalOffetMillis} is always matched.
|
|
* If {@code isDst} is {@code null}, this means the DST state is unknown.
|
|
* If {@code isDst} is {@code false}, this means the zone must not be in DST.
|
|
* If {@code isDst} is {@code true}, this means the zone must be in DST.
|
|
*/
|
|
private static boolean offsetMatchesAtTime(long whenMillis, TimeZone timeZone,
|
|
int totalOffsetMillis, Boolean isDst) {
|
|
int[] offsets = new int[2];
|
|
timeZone.getOffset(whenMillis, false /* local */, offsets);
|
|
|
|
if (totalOffsetMillis != (offsets[0] + offsets[1])) {
|
|
return false;
|
|
}
|
|
|
|
return isDst == null || (isDst == (offsets[1] != 0));
|
|
}
|
|
|
|
private static String normalizeCountryIso(String countryIso) {
|
|
// Lowercase ASCII is normalized for the purposes of the code in this class.
|
|
return countryIso.toLowerCase(Locale.US);
|
|
}
|
|
}
|