Home | History | Annotate | Download | only in util
      1 /*
      2  * Copyright (C) 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package libcore.util;
     18 
     19 import android.icu.util.TimeZone;
     20 
     21 import java.util.ArrayList;
     22 import java.util.Arrays;
     23 import java.util.Collections;
     24 import java.util.HashSet;
     25 import java.util.List;
     26 import java.util.Locale;
     27 import java.util.Objects;
     28 
     29 /**
     30  * Information about a country's time zones.
     31  */
     32 public final class CountryTimeZones {
     33 
     34     /**
     35      * The result of lookup up a time zone using offset information (and possibly more).
     36      */
     37     public final static class OffsetResult {
     38 
     39         /** A zone that matches the supplied criteria. See also {@link #mOneMatch}. */
     40         public final TimeZone mTimeZone;
     41 
     42         /** True if there is one match for the supplied criteria */
     43         public final boolean mOneMatch;
     44 
     45         public OffsetResult(TimeZone timeZone, boolean oneMatch) {
     46             mTimeZone = java.util.Objects.requireNonNull(timeZone);
     47             mOneMatch = oneMatch;
     48         }
     49 
     50         @Override
     51         public String toString() {
     52             return "Result{" +
     53                     "mTimeZone='" + mTimeZone + '\'' +
     54                     ", mOneMatch=" + mOneMatch +
     55                     '}';
     56         }
     57     }
     58 
     59     /**
     60      * A mapping to a time zone ID with some associated metadata.
     61      */
     62     public final static class TimeZoneMapping {
     63         public final String timeZoneId;
     64         public final boolean showInPicker;
     65         public final Long notUsedAfter;
     66 
     67         TimeZoneMapping(String timeZoneId, boolean showInPicker, Long notUsedAfter) {
     68             this.timeZoneId = timeZoneId;
     69             this.showInPicker = showInPicker;
     70             this.notUsedAfter = notUsedAfter;
     71         }
     72 
     73         // VisibleForTesting
     74         public static TimeZoneMapping createForTests(
     75                 String timeZoneId, boolean showInPicker, Long notUsedAfter) {
     76             return new TimeZoneMapping(timeZoneId, showInPicker, notUsedAfter);
     77         }
     78 
     79         @Override
     80         public boolean equals(Object o) {
     81             if (this == o) {
     82                 return true;
     83             }
     84             if (o == null || getClass() != o.getClass()) {
     85                 return false;
     86             }
     87             TimeZoneMapping that = (TimeZoneMapping) o;
     88             return showInPicker == that.showInPicker &&
     89                     Objects.equals(timeZoneId, that.timeZoneId) &&
     90                     Objects.equals(notUsedAfter, that.notUsedAfter);
     91         }
     92 
     93         @Override
     94         public int hashCode() {
     95             return Objects.hash(timeZoneId, showInPicker, notUsedAfter);
     96         }
     97 
     98         @Override
     99         public String toString() {
    100             return "TimeZoneMapping{"
    101                     + "timeZoneId='" + timeZoneId + '\''
    102                     + ", showInPicker=" + showInPicker
    103                     + ", notUsedAfter=" + notUsedAfter
    104                     + '}';
    105         }
    106 
    107         /**
    108          * Returns {@code true} if one of the supplied {@link TimeZoneMapping} objects is for the
    109          * specified time zone ID.
    110          */
    111         public static boolean containsTimeZoneId(
    112                 List<TimeZoneMapping> timeZoneMappings, String timeZoneId) {
    113             for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
    114                 if (timeZoneMapping.timeZoneId.equals(timeZoneId)) {
    115                     return true;
    116                 }
    117             }
    118             return false;
    119         }
    120     }
    121 
    122     private final String countryIso;
    123     private final String defaultTimeZoneId;
    124     private final List<TimeZoneMapping> timeZoneMappings;
    125     private final boolean everUsesUtc;
    126 
    127     // Memoized frozen ICU TimeZone object for the default.
    128     private TimeZone icuDefaultTimeZone;
    129     // Memoized frozen ICU TimeZone objects for the timeZoneIds.
    130     private List<TimeZone> icuTimeZones;
    131 
    132     private CountryTimeZones(String countryIso, String defaultTimeZoneId, boolean everUsesUtc,
    133             List<TimeZoneMapping> timeZoneMappings) {
    134         this.countryIso = java.util.Objects.requireNonNull(countryIso);
    135         this.defaultTimeZoneId = defaultTimeZoneId;
    136         this.everUsesUtc = everUsesUtc;
    137         // Create a defensive copy of the mapping list.
    138         this.timeZoneMappings = Collections.unmodifiableList(new ArrayList<>(timeZoneMappings));
    139     }
    140 
    141     /**
    142      * Creates a {@link CountryTimeZones} object containing only known time zone IDs.
    143      */
    144     public static CountryTimeZones createValidated(String countryIso, String defaultTimeZoneId,
    145             boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo) {
    146 
    147         // We rely on ZoneInfoDB to tell us what the known valid time zone IDs are. ICU may
    148         // recognize more but we want to be sure that zone IDs can be used with java.util as well as
    149         // android.icu and ICU is expected to have a superset.
    150         String[] validTimeZoneIdsArray = ZoneInfoDB.getInstance().getAvailableIDs();
    151         HashSet<String> validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray));
    152         List<TimeZoneMapping> validCountryTimeZoneMappings = new ArrayList<>();
    153         for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
    154             String timeZoneId = timeZoneMapping.timeZoneId;
    155             if (!validTimeZoneIdsSet.contains(timeZoneId)) {
    156                 System.logW("Skipping invalid zone: " + timeZoneId + " at " + debugInfo);
    157             } else {
    158                 validCountryTimeZoneMappings.add(timeZoneMapping);
    159             }
    160         }
    161 
    162         // We don't get too strict at runtime about whether the defaultTimeZoneId must be
    163         // one of the country's time zones because this is the data we have to use (we also
    164         // assume the data was validated by earlier steps). The default time zone ID must just
    165         // be a recognized zone ID: if it's not valid we leave it null.
    166         if (!validTimeZoneIdsSet.contains(defaultTimeZoneId)) {
    167             System.logW("Invalid default time zone ID: " + defaultTimeZoneId
    168                     + " at " + debugInfo);
    169             defaultTimeZoneId = null;
    170         }
    171 
    172         String normalizedCountryIso = normalizeCountryIso(countryIso);
    173         return new CountryTimeZones(
    174                 normalizedCountryIso, defaultTimeZoneId, everUsesUtc, validCountryTimeZoneMappings);
    175     }
    176 
    177     /**
    178      * Returns the ISO code for the country.
    179      */
    180     public String getCountryIso() {
    181         return countryIso;
    182     }
    183 
    184     /**
    185      * Returns true if the ISO code for the country is a match for the one specified.
    186      */
    187     public boolean isForCountryCode(String countryIso) {
    188         return this.countryIso.equals(normalizeCountryIso(countryIso));
    189     }
    190 
    191     /**
    192      * Returns the default time zone ID for the country. Can return null in cases when no data is
    193      * available or the time zone ID provided to
    194      * {@link #createValidated(String, String, boolean, List, String)} was not recognized.
    195      */
    196     public synchronized TimeZone getDefaultTimeZone() {
    197         if (icuDefaultTimeZone == null) {
    198             TimeZone defaultTimeZone;
    199             if (defaultTimeZoneId == null) {
    200                 defaultTimeZone = null;
    201             } else {
    202                 defaultTimeZone = getValidFrozenTimeZoneOrNull(defaultTimeZoneId);
    203             }
    204             icuDefaultTimeZone = defaultTimeZone;
    205         }
    206         return icuDefaultTimeZone;
    207     }
    208 
    209     /**
    210      * Returns the default time zone ID for the country. Can return null in cases when no data is
    211      * available or the time zone ID provided to
    212      * {@link #createValidated(String, String, boolean, List, String)} was not recognized.
    213      */
    214     public String getDefaultTimeZoneId() {
    215         return defaultTimeZoneId;
    216     }
    217 
    218     /**
    219      * Returns an immutable, ordered list of time zone mappings for the country in an undefined but
    220      * "priority" order. The list can be empty if there were no zones configured or the configured
    221      * zone IDs were not recognized.
    222      */
    223     public List<TimeZoneMapping> getTimeZoneMappings() {
    224         return timeZoneMappings;
    225     }
    226 
    227     @Override
    228     public boolean equals(Object o) {
    229         if (this == o) {
    230             return true;
    231         }
    232         if (o == null || getClass() != o.getClass()) {
    233             return false;
    234         }
    235 
    236         CountryTimeZones that = (CountryTimeZones) o;
    237 
    238         if (everUsesUtc != that.everUsesUtc) {
    239             return false;
    240         }
    241         if (!countryIso.equals(that.countryIso)) {
    242             return false;
    243         }
    244         if (defaultTimeZoneId != null ? !defaultTimeZoneId.equals(that.defaultTimeZoneId)
    245                 : that.defaultTimeZoneId != null) {
    246             return false;
    247         }
    248         return timeZoneMappings.equals(that.timeZoneMappings);
    249     }
    250 
    251     @Override
    252     public int hashCode() {
    253         int result = countryIso.hashCode();
    254         result = 31 * result + (defaultTimeZoneId != null ? defaultTimeZoneId.hashCode() : 0);
    255         result = 31 * result + timeZoneMappings.hashCode();
    256         result = 31 * result + (everUsesUtc ? 1 : 0);
    257         return result;
    258     }
    259 
    260     /**
    261      * Returns an ordered list of time zones for the country in an undefined but "priority"
    262      * order for a country. The list can be empty if there were no zones configured or the
    263      * configured zone IDs were not recognized.
    264      */
    265     public synchronized List<TimeZone> getIcuTimeZones() {
    266         if (icuTimeZones == null) {
    267             ArrayList<TimeZone> mutableList = new ArrayList<>(timeZoneMappings.size());
    268             for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
    269                 String timeZoneId = timeZoneMapping.timeZoneId;
    270                 TimeZone timeZone;
    271                 if (timeZoneId.equals(defaultTimeZoneId)) {
    272                     timeZone = getDefaultTimeZone();
    273                 } else {
    274                     timeZone = getValidFrozenTimeZoneOrNull(timeZoneId);
    275                 }
    276                 // This shouldn't happen given the validation that takes place in
    277                 // createValidatedCountryTimeZones().
    278                 if (timeZone == null) {
    279                     System.logW("Skipping invalid zone: " + timeZoneId);
    280                     continue;
    281                 }
    282                 mutableList.add(timeZone);
    283             }
    284             icuTimeZones = Collections.unmodifiableList(mutableList);
    285         }
    286         return icuTimeZones;
    287     }
    288 
    289     /**
    290      * Returns true if the country has at least one zone that is the same as UTC at the given time.
    291      */
    292     public boolean hasUtcZone(long whenMillis) {
    293         // If the data tells us the country never uses UTC we don't have to check anything.
    294         if (!everUsesUtc) {
    295             return false;
    296         }
    297 
    298         for (TimeZone zone : getIcuTimeZones()) {
    299             if (zone.getOffset(whenMillis) == 0) {
    300                 return true;
    301             }
    302         }
    303         return false;
    304     }
    305 
    306     /**
    307      * Returns {@code true} if the default time zone for the country is either the only zone used or
    308      * if it has the same offsets as all other zones used by the country <em>at the specified time
    309      * </em> making the default equivalent to all other zones used by the country <em>at that time
    310      * </em>.
    311      */
    312     public boolean isDefaultOkForCountryTimeZoneDetection(long whenMillis) {
    313         if (timeZoneMappings.isEmpty()) {
    314             // Should never happen unless there's been an error loading the data.
    315             return false;
    316         } else if (timeZoneMappings.size() == 1) {
    317             // The default is the only zone so it's a good candidate.
    318             return true;
    319         } else {
    320             TimeZone countryDefault = getDefaultTimeZone();
    321             if (countryDefault == null) {
    322                 return false;
    323             }
    324 
    325             int countryDefaultOffset = countryDefault.getOffset(whenMillis);
    326             List<TimeZone> candidates = getIcuTimeZones();
    327             for (TimeZone candidate : candidates) {
    328                 if (candidate == countryDefault) {
    329                     continue;
    330                 }
    331 
    332                 int candidateOffset = candidate.getOffset(whenMillis);
    333                 if (countryDefaultOffset != candidateOffset) {
    334                     // Multiple different offsets means the default should not be used.
    335                     return false;
    336                 }
    337             }
    338             return true;
    339         }
    340     }
    341 
    342     /**
    343      * Returns a time zone for the country, if there is one, that has the desired properties. If
    344      * there are multiple matches and the {@code bias} is one of them then it is returned, otherwise
    345      * an arbitrary match is returned based on the {@link #getTimeZoneMappings()} ordering.
    346      *
    347      * @param offsetMillis the offset from UTC at {@code whenMillis}
    348      * @param isDst whether the zone is in DST
    349      * @param whenMillis the UTC time to match against
    350      * @param bias the time zone to prefer, can be null
    351      * @deprecated Use {@link #lookupByOffsetWithBias(int, Integer, long, TimeZone)} instead
    352      */
    353     @Deprecated
    354     public OffsetResult lookupByOffsetWithBias(int offsetMillis, boolean isDst, long whenMillis,
    355             TimeZone bias) {
    356         if (timeZoneMappings == null || timeZoneMappings.isEmpty()) {
    357             return null;
    358         }
    359 
    360         List<TimeZone> candidates = getIcuTimeZones();
    361 
    362         TimeZone firstMatch = null;
    363         boolean biasMatched = false;
    364         boolean oneMatch = true;
    365         for (TimeZone match : candidates) {
    366             if (!offsetMatchesAtTime(match, offsetMillis, isDst, whenMillis)) {
    367                 continue;
    368             }
    369 
    370             if (firstMatch == null) {
    371                 firstMatch = match;
    372             } else {
    373                 oneMatch = false;
    374             }
    375             if (bias != null && match.getID().equals(bias.getID())) {
    376                 biasMatched = true;
    377             }
    378             if (firstMatch != null && !oneMatch && (bias == null || biasMatched)) {
    379                 break;
    380             }
    381         }
    382         if (firstMatch == null) {
    383             return null;
    384         }
    385 
    386         TimeZone toReturn = biasMatched ? bias : firstMatch;
    387         return new OffsetResult(toReturn, oneMatch);
    388     }
    389 
    390     /**
    391      * Returns {@code true} if the specified offset, DST state and time would be valid in the
    392      * timeZone.
    393      */
    394     private static boolean offsetMatchesAtTime(TimeZone timeZone, int offsetMillis, boolean isDst,
    395             long whenMillis) {
    396         int[] offsets = new int[2];
    397         timeZone.getOffset(whenMillis, false /* local */, offsets);
    398 
    399         // offsets[1] == 0 when the zone is not in DST.
    400         boolean zoneIsDst = offsets[1] != 0;
    401         if (isDst != zoneIsDst) {
    402             return false;
    403         }
    404         return offsetMillis == (offsets[0] + offsets[1]);
    405     }
    406 
    407     /**
    408      * Returns a time zone for the country, if there is one, that has the desired properties. If
    409      * there are multiple matches and the {@code bias} is one of them then it is returned, otherwise
    410      * an arbitrary match is returned based on the {@link #getTimeZoneMappings()} ordering.
    411      *
    412      * @param offsetMillis the offset from UTC at {@code whenMillis}
    413      * @param dstOffsetMillis the part of {@code offsetMillis} contributed by DST, {@code null}
    414      *                        means unknown
    415      * @param whenMillis the UTC time to match against
    416      * @param bias the time zone to prefer, can be null
    417      */
    418     public OffsetResult lookupByOffsetWithBias(int offsetMillis, Integer dstOffsetMillis,
    419             long whenMillis, TimeZone bias) {
    420         if (timeZoneMappings == null || timeZoneMappings.isEmpty()) {
    421             return null;
    422         }
    423 
    424         List<TimeZone> candidates = getIcuTimeZones();
    425 
    426         TimeZone firstMatch = null;
    427         boolean biasMatched = false;
    428         boolean oneMatch = true;
    429         for (TimeZone match : candidates) {
    430             if (!offsetMatchesAtTime(match, offsetMillis, dstOffsetMillis, whenMillis)) {
    431                 continue;
    432             }
    433 
    434             if (firstMatch == null) {
    435                 firstMatch = match;
    436             } else {
    437                 oneMatch = false;
    438             }
    439             if (bias != null && match.getID().equals(bias.getID())) {
    440                 biasMatched = true;
    441             }
    442             if (firstMatch != null && !oneMatch && (bias == null || biasMatched)) {
    443                 break;
    444             }
    445         }
    446         if (firstMatch == null) {
    447             return null;
    448         }
    449 
    450         TimeZone toReturn = biasMatched ? bias : firstMatch;
    451         return new OffsetResult(toReturn, oneMatch);
    452     }
    453 
    454     /**
    455      * Returns {@code true} if the specified offset, DST and time would be valid in the
    456      * timeZone.
    457      */
    458     private static boolean offsetMatchesAtTime(TimeZone timeZone, int offsetMillis,
    459             Integer dstOffsetMillis, long whenMillis) {
    460         int[] offsets = new int[2];
    461         timeZone.getOffset(whenMillis, false /* local */, offsets);
    462 
    463         if (dstOffsetMillis != null) {
    464             if (dstOffsetMillis.intValue() != offsets[1]) {
    465                 return false;
    466             }
    467         }
    468         return offsetMillis == (offsets[0] + offsets[1]);
    469     }
    470 
    471     private static TimeZone getValidFrozenTimeZoneOrNull(String timeZoneId) {
    472         TimeZone timeZone = TimeZone.getFrozenTimeZone(timeZoneId);
    473         if (timeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID)) {
    474             return null;
    475         }
    476         return timeZone;
    477     }
    478 
    479     private static String normalizeCountryIso(String countryIso) {
    480         // Lowercase ASCII is normalized for the purposes of the code in this class.
    481         return countryIso.toLowerCase(Locale.US);
    482     }
    483 }
    484