• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.timezone;
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  * @hide
32  */
33 @libcore.api.CorePlatformApi
34 public final class CountryTimeZones {
35 
36     /**
37      * The result of lookup up a time zone using offset information (and possibly more).
38      * @hide
39      */
40     @libcore.api.CorePlatformApi
41     public static final class OffsetResult {
42 
43         /** A zone that matches the supplied criteria. See also {@link #isOnlyMatch}. */
44         private final TimeZone timeZone;
45 
46         /** True if there is one match for the supplied criteria */
47         private final boolean isOnlyMatch;
48 
OffsetResult(TimeZone timeZone, boolean isOnlyMatch)49         public OffsetResult(TimeZone timeZone, boolean isOnlyMatch) {
50             this.timeZone = java.util.Objects.requireNonNull(timeZone);
51             this.isOnlyMatch = isOnlyMatch;
52         }
53 
54         @libcore.api.CorePlatformApi
getTimeZone()55         public TimeZone getTimeZone() {
56             return timeZone;
57         }
58 
59         @libcore.api.CorePlatformApi
isOnlyMatch()60         public boolean isOnlyMatch() {
61             return isOnlyMatch;
62         }
63 
64         @Override
toString()65         public String toString() {
66             return "Result{"
67                     + "timeZone='" + timeZone + '\''
68                     + ", isOnlyMatch=" + isOnlyMatch
69                     + '}';
70         }
71     }
72 
73     /**
74      * A mapping to a time zone ID with some associated metadata.
75      *
76      * @hide
77      */
78     @libcore.api.CorePlatformApi
79     public static final class TimeZoneMapping {
80         private final String timeZoneId;
81         private final boolean shownInPicker;
82         private final Long notUsedAfter;
83 
84         /** Memoized TimeZone object for {@link #timeZoneId}. */
85         private TimeZone timeZone;
86 
TimeZoneMapping(String timeZoneId, boolean shownInPicker, Long notUsedAfter)87         TimeZoneMapping(String timeZoneId, boolean shownInPicker, Long notUsedAfter) {
88             this.timeZoneId = Objects.requireNonNull(timeZoneId);
89             this.shownInPicker = shownInPicker;
90             this.notUsedAfter = notUsedAfter;
91         }
92 
93         @libcore.api.CorePlatformApi
getTimeZoneId()94         public String getTimeZoneId() {
95             return timeZoneId;
96         }
97 
98         @libcore.api.CorePlatformApi
isShownInPicker()99         public boolean isShownInPicker() {
100             return shownInPicker;
101         }
102 
103         @libcore.api.CorePlatformApi
getNotUsedAfter()104         public Long getNotUsedAfter() {
105             return notUsedAfter;
106         }
107 
108         /**
109          * Returns a {@link TimeZone} object for this mapping, or {@code null} if the ID is unknown.
110          */
111         @libcore.api.CorePlatformApi
getTimeZone()112         public TimeZone getTimeZone() {
113             synchronized (this) {
114                 if (timeZone == null) {
115                     TimeZone tz = TimeZone.getFrozenTimeZone(timeZoneId);
116                     timeZone = tz;
117                     if (TimeZone.UNKNOWN_ZONE_ID.equals(timeZone.getID())) {
118                         // This shouldn't happen given the validation that takes place in
119                         // createValidatedCountryTimeZones().
120                         throw new IllegalStateException("Invalid zone in TimeZoneMapping: " + this);
121                     }
122                 }
123             }
124 
125             return TimeZone.UNKNOWN_ZONE_ID.equals(timeZone.getID()) ? null : timeZone;
126         }
127 
128         /**
129          * Returns {@code true} if the mapping is "effective" after {@code whenMillis}, i.e.
130          * it is distinct from other "effective" times zones used in the country at/after that
131          * time. This uses the {@link #notUsedAfter} metadata which ensures there is one time
132          * zone remaining when there are multiple candidate zones with the same rules. The one
133          * kept is based on country specific factors like population covered.
134          */
isEffectiveAt(long whenMillis)135         boolean isEffectiveAt(long whenMillis) {
136             return notUsedAfter == null || whenMillis <= notUsedAfter;
137         }
138 
139         // VisibleForTesting
140         @libcore.api.CorePlatformApi
createForTests( String timeZoneId, boolean showInPicker, Long notUsedAfter)141         public static TimeZoneMapping createForTests(
142                 String timeZoneId, boolean showInPicker, Long notUsedAfter) {
143             return new TimeZoneMapping(timeZoneId, showInPicker, notUsedAfter);
144         }
145 
146         @Override
equals(Object o)147         public boolean equals(Object o) {
148             if (this == o) {
149                 return true;
150             }
151             if (o == null || getClass() != o.getClass()) {
152                 return false;
153             }
154             TimeZoneMapping that = (TimeZoneMapping) o;
155             return shownInPicker == that.shownInPicker &&
156                     Objects.equals(timeZoneId, that.timeZoneId) &&
157                     Objects.equals(notUsedAfter, that.notUsedAfter);
158         }
159 
160         @Override
hashCode()161         public int hashCode() {
162             return Objects.hash(timeZoneId, shownInPicker, notUsedAfter);
163         }
164 
165         @Override
toString()166         public String toString() {
167             return "TimeZoneMapping{"
168                     + "timeZoneId='" + timeZoneId + '\''
169                     + ", shownInPicker=" + shownInPicker
170                     + ", notUsedAfter=" + notUsedAfter
171                     + '}';
172         }
173 
174         /**
175          * Returns {@code true} if one of the supplied {@link TimeZoneMapping} objects is for the
176          * specified time zone ID.
177          */
containsTimeZoneId( List<TimeZoneMapping> timeZoneMappings, String timeZoneId)178         static boolean containsTimeZoneId(
179                 List<TimeZoneMapping> timeZoneMappings, String timeZoneId) {
180             for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
181                 if (timeZoneMapping.timeZoneId.equals(timeZoneId)) {
182                     return true;
183                 }
184             }
185             return false;
186         }
187     }
188 
189     private final String countryIso;
190     private final String defaultTimeZoneId;
191     /**
192      * {@code true} indicates the default time zone for a country is a good choice if a time zone
193      * cannot be determined by other means.
194      */
195     private final boolean defaultTimeZoneBoosted;
196     private final List<TimeZoneMapping> timeZoneMappings;
197     private final boolean everUsesUtc;
198 
199     /**
200      * Memoized frozen ICU TimeZone object for the default. Can be {@link TimeZone#UNKNOWN_ZONE} if
201      * the {@link #defaultTimeZoneId} is missing or unrecognized.
202      */
203     private TimeZone defaultTimeZone;
204 
CountryTimeZones(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoosted, boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings)205     private CountryTimeZones(String countryIso, String defaultTimeZoneId,
206             boolean defaultTimeZoneBoosted, boolean everUsesUtc,
207             List<TimeZoneMapping> timeZoneMappings) {
208         this.countryIso = java.util.Objects.requireNonNull(countryIso);
209         this.defaultTimeZoneId = defaultTimeZoneId;
210         this.defaultTimeZoneBoosted = defaultTimeZoneBoosted;
211         this.everUsesUtc = everUsesUtc;
212         // Create a defensive copy of the mapping list.
213         this.timeZoneMappings = Collections.unmodifiableList(new ArrayList<>(timeZoneMappings));
214     }
215 
216     /**
217      * Creates a {@link CountryTimeZones} object containing only known time zone IDs.
218      */
createValidated(String countryIso, String defaultTimeZoneId, boolean defaultTimeZoneBoosted, boolean everUsesUtc, List<TimeZoneMapping> timeZoneMappings, String debugInfo)219     public static CountryTimeZones createValidated(String countryIso, String defaultTimeZoneId,
220             boolean defaultTimeZoneBoosted, boolean everUsesUtc,
221             List<TimeZoneMapping> timeZoneMappings, String debugInfo) {
222 
223         // We rely on ZoneInfoDB to tell us what the known valid time zone IDs are. ICU may
224         // recognize more but we want to be sure that zone IDs can be used with java.util as well as
225         // android.icu and ICU is expected to have a superset.
226         String[] validTimeZoneIdsArray = ZoneInfoDb.getInstance().getAvailableIDs();
227         HashSet<String> validTimeZoneIdsSet = new HashSet<>(Arrays.asList(validTimeZoneIdsArray));
228         List<TimeZoneMapping> validCountryTimeZoneMappings = new ArrayList<>();
229         for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
230             String timeZoneId = timeZoneMapping.timeZoneId;
231             if (!validTimeZoneIdsSet.contains(timeZoneId)) {
232                 System.logW("Skipping invalid zone: " + timeZoneId + " at " + debugInfo);
233             } else {
234                 validCountryTimeZoneMappings.add(timeZoneMapping);
235             }
236         }
237 
238         // We don't get too strict at runtime about whether the defaultTimeZoneId must be
239         // one of the country's time zones because this is the data we have to use (we also
240         // assume the data was validated by earlier steps). The default time zone ID must just
241         // be a recognized zone ID: if it's not valid we leave it null.
242         if (!validTimeZoneIdsSet.contains(defaultTimeZoneId)) {
243             System.logW("Invalid default time zone ID: " + defaultTimeZoneId
244                     + " at " + debugInfo);
245             defaultTimeZoneId = null;
246         }
247 
248         String normalizedCountryIso = normalizeCountryIso(countryIso);
249         return new CountryTimeZones(
250                 normalizedCountryIso, defaultTimeZoneId, defaultTimeZoneBoosted, everUsesUtc,
251                 validCountryTimeZoneMappings);
252     }
253 
254     /**
255      * Returns the ISO code for the country.
256      */
257     @libcore.api.CorePlatformApi
getCountryIso()258     public String getCountryIso() {
259         return countryIso;
260     }
261 
262     /**
263      * Returns true if the ISO code for the country is a match for the one specified.
264      */
265     @libcore.api.CorePlatformApi
isForCountryCode(String countryIso)266     public boolean isForCountryCode(String countryIso) {
267         return this.countryIso.equals(normalizeCountryIso(countryIso));
268     }
269 
270     /**
271      * Returns the default time zone for the country. Can return null in cases when no data is
272      * available or the time zone ID provided to
273      * {@link #createValidated(String, String, boolean, boolean, List, String)} was not recognized.
274      */
275     @libcore.api.CorePlatformApi
getDefaultTimeZone()276     public synchronized TimeZone getDefaultTimeZone() {
277         if (defaultTimeZone == null) {
278             TimeZone timeZone;
279             if (defaultTimeZoneId == null) {
280                 timeZone = TimeZone.UNKNOWN_ZONE;
281             } else {
282                 timeZone = TimeZone.getFrozenTimeZone(defaultTimeZoneId);
283             }
284             this.defaultTimeZone = timeZone;
285         }
286         return TimeZone.UNKNOWN_ZONE_ID.equals(defaultTimeZone.getID()) ? null : defaultTimeZone;
287     }
288 
289     /**
290      * Returns the default time zone ID for the country. Can return null in cases when no data is
291      * available or the time zone ID provided to
292      * {@link #createValidated(String, String, boolean, boolean, List, String)} was not recognized.
293      */
294     @libcore.api.CorePlatformApi
getDefaultTimeZoneId()295     public String getDefaultTimeZoneId() {
296         return defaultTimeZoneId;
297     }
298 
299     /**
300      * Qualifier for a country's default time zone. {@code true} indicates whether the default
301      * would be a good choice <em>generally</em> when there's no other information available.
302      */
303     @libcore.api.CorePlatformApi
isDefaultTimeZoneBoosted()304     public boolean isDefaultTimeZoneBoosted() {
305         return defaultTimeZoneBoosted;
306     }
307 
308     /**
309      * Returns an immutable, ordered list of time zone mappings for the country in an undefined but
310      * "priority" order. The list can be empty if there were no zones configured or the configured
311      * zone IDs were not recognized.
312      */
313     @libcore.api.CorePlatformApi
getTimeZoneMappings()314     public List<TimeZoneMapping> getTimeZoneMappings() {
315         return timeZoneMappings;
316     }
317 
318     /**
319      * Returns an immutable, ordered list of time zone mappings for the country in an undefined but
320      * "priority" order, filtered so that only "effective" time zone IDs are returned. An
321      * "effective" time zone is one that differs from another time zone used in the country after
322      * {@code whenMillis}. The list can be empty if there were no zones configured or the configured
323      * zone IDs were not recognized.
324      */
325     @libcore.api.CorePlatformApi
getEffectiveTimeZoneMappingsAt(long whenMillis)326     public List<TimeZoneMapping> getEffectiveTimeZoneMappingsAt(long whenMillis) {
327         ArrayList<TimeZoneMapping> filteredList = new ArrayList<>(timeZoneMappings.size());
328         for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
329             if (timeZoneMapping.isEffectiveAt(whenMillis)) {
330                 filteredList.add(timeZoneMapping);
331             }
332         }
333         return Collections.unmodifiableList(filteredList);
334     }
335 
336     @Override
equals(Object o)337     public boolean equals(Object o) {
338         if (this == o) {
339             return true;
340         }
341         if (o == null || getClass() != o.getClass()) {
342             return false;
343         }
344         CountryTimeZones that = (CountryTimeZones) o;
345         return defaultTimeZoneBoosted == that.defaultTimeZoneBoosted
346                 && everUsesUtc == that.everUsesUtc
347                 && countryIso.equals(that.countryIso)
348                 && Objects.equals(defaultTimeZoneId, that.defaultTimeZoneId)
349                 && timeZoneMappings.equals(that.timeZoneMappings);
350     }
351 
352     @Override
hashCode()353     public int hashCode() {
354         return Objects.hash(
355                 countryIso, defaultTimeZoneId, defaultTimeZoneBoosted, timeZoneMappings,
356                 everUsesUtc);
357     }
358 
359     @Override
toString()360     public String toString() {
361         return "CountryTimeZones{"
362                 + "countryIso='" + countryIso + '\''
363                 + ", defaultTimeZoneId='" + defaultTimeZoneId + '\''
364                 + ", defaultTimeZoneBoosted=" + defaultTimeZoneBoosted
365                 + ", timeZoneMappings=" + timeZoneMappings
366                 + ", everUsesUtc=" + everUsesUtc
367                 + '}';
368     }
369 
370     /**
371      * Returns true if the country has at least one zone that is the same as UTC at the given time.
372      */
373     @libcore.api.CorePlatformApi
hasUtcZone(long whenMillis)374     public boolean hasUtcZone(long whenMillis) {
375         // If the data tells us the country never uses UTC we don't have to check anything.
376         if (!everUsesUtc) {
377             return false;
378         }
379 
380         for (TimeZoneMapping timeZoneMapping : getEffectiveTimeZoneMappingsAt(whenMillis)) {
381             TimeZone timeZone = timeZoneMapping.getTimeZone();
382             if (timeZone != null && timeZone.getOffset(whenMillis) == 0) {
383                 return true;
384             }
385         }
386         return false;
387     }
388 
389     /**
390      * Returns a time zone for the country, if there is one, that matches the supplied properties.
391      * If there are multiple matches and the {@code bias} is one of them then it is returned,
392      * otherwise an arbitrary match is returned based on the {@link
393      * #getEffectiveTimeZoneMappingsAt(long)} ordering.
394      *
395      * @param whenMillis the UTC time to match against
396      * @param bias the time zone to prefer, can be {@code null} to indicate there is no preference
397      * @param totalOffsetMillis the offset from UTC at {@code whenMillis}
398      * @param isDst the Daylight Savings Time state at {@code whenMillis}. {@code true} means DST,
399      *     {@code false} means not DST
400      * @return an {@link OffsetResult} with information about a matching zone, or {@code null} if
401      *     there is no match
402      */
403     @libcore.api.CorePlatformApi
lookupByOffsetWithBias(long whenMillis, TimeZone bias, int totalOffsetMillis, boolean isDst)404     public OffsetResult lookupByOffsetWithBias(long whenMillis, TimeZone bias,
405             int totalOffsetMillis, boolean isDst) {
406         return lookupByOffsetWithBiasInternal(whenMillis, bias, totalOffsetMillis, isDst);
407     }
408 
409     /**
410      * Returns a time zone for the country, if there is one, that matches the supplied properties.
411      * If there are multiple matches and the {@code bias} is one of them then it is returned,
412      * otherwise an arbitrary match is returned based on the {@link
413      * #getEffectiveTimeZoneMappingsAt(long)} ordering.
414      *
415      * @param whenMillis the UTC time to match against
416      * @param bias the time zone to prefer, can be {@code null} to indicate there is no preference
417      * @param totalOffsetMillis the offset from UTC at {@code whenMillis}
418      * @return an {@link OffsetResult} with information about a matching zone, or {@code null} if
419      *     there is no match
420      */
421     @libcore.api.CorePlatformApi
lookupByOffsetWithBias(long whenMillis, TimeZone bias, int totalOffsetMillis)422     public OffsetResult lookupByOffsetWithBias(long whenMillis, TimeZone bias,
423             int totalOffsetMillis) {
424         final Boolean isDst = null;
425         return lookupByOffsetWithBiasInternal(whenMillis, bias, totalOffsetMillis, isDst);
426     }
427 
428     /**
429      * Returns a time zone for the country, if there is one, that matches the supplied properties.
430      * If there are multiple matches and the {@code bias} is one of them then it is returned,
431      * otherwise an arbitrary match is returned based on the {@link
432      * #getEffectiveTimeZoneMappingsAt(long)} ordering.
433      *
434      * @param whenMillis the UTC time to match against
435      * @param bias the time zone to prefer, can be {@code null}
436      * @param totalOffsetMillis the offset from UTC at {@code whenMillis}
437      * @param isDst the Daylight Savings Time state at {@code whenMillis}. {@code true} means DST,
438      *     {@code false} means not DST, {@code null} means unknown
439      */
lookupByOffsetWithBiasInternal(long whenMillis, TimeZone bias, int totalOffsetMillis, Boolean isDst)440     private OffsetResult lookupByOffsetWithBiasInternal(long whenMillis, TimeZone bias,
441             int totalOffsetMillis, Boolean isDst) {
442         List<TimeZoneMapping> timeZoneMappings = getEffectiveTimeZoneMappingsAt(whenMillis);
443         if (timeZoneMappings.isEmpty()) {
444             return null;
445         }
446 
447         TimeZone firstMatch = null;
448         boolean biasMatched = false;
449         boolean oneMatch = true;
450         for (TimeZoneMapping timeZoneMapping : timeZoneMappings) {
451             TimeZone match = timeZoneMapping.getTimeZone();
452             if (match == null
453                     || !offsetMatchesAtTime(whenMillis, match, totalOffsetMillis, isDst)) {
454                 continue;
455             }
456 
457             if (firstMatch == null) {
458                 firstMatch = match;
459             } else {
460                 oneMatch = false;
461             }
462             if (bias != null && match.getID().equals(bias.getID())) {
463                 biasMatched = true;
464             }
465             if (firstMatch != null && !oneMatch && (bias == null || biasMatched)) {
466                 break;
467             }
468         }
469         if (firstMatch == null) {
470             return null;
471         }
472 
473         TimeZone toReturn = biasMatched ? bias : firstMatch;
474         return new OffsetResult(toReturn, oneMatch);
475     }
476 
477     /**
478      * Returns {@code true} if the specified {@code totalOffset} and {@code isDst} would be valid in
479      * the {@code timeZone} at time {@code whenMillis}.
480      * {@code totalOffetMillis} is always matched.
481      * If {@code isDst} is {@code null}, this means the DST state is unknown.
482      * If {@code isDst} is {@code false}, this means the zone must not be in DST.
483      * If {@code isDst} is {@code true}, this means the zone must be in DST.
484      */
offsetMatchesAtTime(long whenMillis, TimeZone timeZone, int totalOffsetMillis, Boolean isDst)485     private static boolean offsetMatchesAtTime(long whenMillis, TimeZone timeZone,
486             int totalOffsetMillis, Boolean isDst) {
487         int[] offsets = new int[2];
488         timeZone.getOffset(whenMillis, false /* local */, offsets);
489 
490         if (totalOffsetMillis != (offsets[0] + offsets[1])) {
491             return false;
492         }
493 
494         return isDst == null || (isDst == (offsets[1] != 0));
495     }
496 
normalizeCountryIso(String countryIso)497     private static String normalizeCountryIso(String countryIso) {
498         // Lowercase ASCII is normalized for the purposes of the code in this class.
499         return countryIso.toLowerCase(Locale.US);
500     }
501 }
502