blob: 0528b109979811364c77608cd207541824b2e69d [file] [log] [blame]
/*
* 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);
}
}