/* * 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 alternativeIds; /** Memoized TimeZone object for {@link #timeZoneId}. */ private TimeZone timeZone; TimeZoneMapping(String timeZoneId, boolean shownInPicker, Long notUsedAfter, List 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 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 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 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 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 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 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 validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray)); List 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 generally 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 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 any 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 getEffectiveTimeZoneMappingsAt(long whenMillis) { ArrayList 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 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); } }