| /* |
| * 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); |
| } |
| } |