1 /*
2  * Copyright 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 com.android.internal.telephony.nitz;
18 
19 import static com.android.internal.telephony.nitz.TimeZoneLookupHelper.CountryResult.QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS;
20 import static com.android.internal.telephony.nitz.TimeZoneLookupHelper.CountryResult.QUALITY_MULTIPLE_ZONES_SAME_OFFSET;
21 
22 import android.annotation.IntDef;
23 import android.annotation.NonNull;
24 import android.annotation.Nullable;
25 import android.icu.util.TimeZone;
26 import android.text.TextUtils;
27 import android.timezone.CountryTimeZones;
28 import android.timezone.CountryTimeZones.OffsetResult;
29 import android.timezone.CountryTimeZones.TimeZoneMapping;
30 import android.timezone.TimeZoneFinder;
31 
32 import com.android.internal.annotations.VisibleForTesting;
33 import com.android.internal.telephony.NitzData;
34 
35 import java.lang.annotation.Retention;
36 import java.lang.annotation.RetentionPolicy;
37 import java.util.List;
38 import java.util.Objects;
39 
40 /**
41  * An interface to various time zone lookup behaviors.
42  */
43 @VisibleForTesting
44 public final class TimeZoneLookupHelper {
45 
46     /**
47      * The result of looking up a time zone using country information.
48      */
49     @VisibleForTesting
50     public static final class CountryResult {
51 
52         @IntDef({ QUALITY_SINGLE_ZONE, QUALITY_DEFAULT_BOOSTED, QUALITY_MULTIPLE_ZONES_SAME_OFFSET,
53                 QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS })
54         @Retention(RetentionPolicy.SOURCE)
55         public @interface Quality {}
56 
57         public static final int QUALITY_SINGLE_ZONE = 1;
58         public static final int QUALITY_DEFAULT_BOOSTED = 2;
59         public static final int QUALITY_MULTIPLE_ZONES_SAME_OFFSET = 3;
60         public static final int QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS = 4;
61 
62         /** A time zone to use for the country. */
63         @NonNull
64         public final String zoneId;
65 
66         /**
67          * The quality of the match.
68          */
69         @Quality
70         public final int quality;
71 
72         /**
73          * Freeform information about why the value of {@link #quality} was chosen. Not used for
74          * {@link #equals(Object)}.
75          */
76         private final String mDebugInfo;
77 
CountryResult(@onNull String zoneId, @Quality int quality, String debugInfo)78         public CountryResult(@NonNull String zoneId, @Quality int quality, String debugInfo) {
79             this.zoneId = Objects.requireNonNull(zoneId);
80             this.quality = quality;
81             mDebugInfo = debugInfo;
82         }
83 
84         @Override
equals(Object o)85         public boolean equals(Object o) {
86             if (this == o) {
87                 return true;
88             }
89             if (o == null || getClass() != o.getClass()) {
90                 return false;
91             }
92             CountryResult that = (CountryResult) o;
93             return quality == that.quality
94                     && zoneId.equals(that.zoneId);
95         }
96 
97         @Override
hashCode()98         public int hashCode() {
99             return Objects.hash(zoneId, quality);
100         }
101 
102         @Override
toString()103         public String toString() {
104             return "CountryResult{"
105                     + "zoneId='" + zoneId + '\''
106                     + ", quality=" + quality
107                     + ", mDebugInfo=" + mDebugInfo
108                     + '}';
109         }
110     }
111 
112     /** The last CountryTimeZones object retrieved. */
113     @Nullable
114     private CountryTimeZones mLastCountryTimeZones;
115 
116     @VisibleForTesting
TimeZoneLookupHelper()117     public TimeZoneLookupHelper() {}
118 
119     /**
120      * Looks for a time zone for the supplied NITZ and country information.
121      *
122      * <p><em>Note:</em> When there are multiple matching zones then one of the matching candidates
123      * will be returned in the result. If the current device default zone matches it will be
124      * returned in preference to other candidates. This method can return {@code null} if no
125      * matching time zones are found.
126      */
127     @VisibleForTesting
128     @Nullable
lookupByNitzCountry( @onNull NitzData nitzData, @NonNull String isoCountryCode)129     public OffsetResult lookupByNitzCountry(
130             @NonNull NitzData nitzData, @NonNull String isoCountryCode) {
131         CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
132         if (countryTimeZones == null) {
133             return null;
134         }
135         TimeZone bias = TimeZone.getDefault();
136 
137         // Android NITZ time zone matching doesn't try to do a precise match using the DST offset
138         // supplied by the carrier. It only considers whether or not the carrier suggests local time
139         // is DST (if known). NITZ is limited in only being able to express DST offsets in whole
140         // hours and the DST info is optional.
141         Integer dstAdjustmentMillis = nitzData.getDstAdjustmentMillis();
142         if (dstAdjustmentMillis == null) {
143             return countryTimeZones.lookupByOffsetWithBias(
144                     nitzData.getCurrentTimeInMillis(), bias, nitzData.getLocalOffsetMillis());
145 
146         } else {
147             // We don't try to match the exact DST offset given, we just use it to work out if
148             // the country is in DST.
149             boolean isDst = dstAdjustmentMillis != 0;
150             return countryTimeZones.lookupByOffsetWithBias(
151                     nitzData.getCurrentTimeInMillis(), bias,
152                     nitzData.getLocalOffsetMillis(), isDst);
153         }
154     }
155 
156     /**
157      * Looks for a time zone using only information present in the supplied {@link NitzData} object.
158      *
159      * <p><em>Note:</em> Because multiple time zones can have the same offset / DST state at a given
160      * time this process is error prone; an arbitrary match is returned when there are multiple
161      * candidates. The algorithm can also return a non-exact match by assuming that the DST
162      * information provided by NITZ is incorrect. This method can return {@code null} if no matching
163      * time zones are found.
164      */
165     @VisibleForTesting
166     @Nullable
lookupByNitz(@onNull NitzData nitzData)167     public OffsetResult lookupByNitz(@NonNull NitzData nitzData) {
168         int utcOffsetMillis = nitzData.getLocalOffsetMillis();
169         long timeMillis = nitzData.getCurrentTimeInMillis();
170 
171         // Android NITZ time zone matching doesn't try to do a precise match using the DST offset
172         // supplied by the carrier. It only considers whether or not the carrier suggests local time
173         // is DST (if known). NITZ is limited in only being able to express DST offsets in whole
174         // hours and the DST info is optional.
175         Integer dstAdjustmentMillis = nitzData.getDstAdjustmentMillis();
176         Boolean isDst = dstAdjustmentMillis == null ? null : dstAdjustmentMillis != 0;
177 
178         OffsetResult match = lookupByInstantOffsetDst(timeMillis, utcOffsetMillis, isDst);
179         if (match == null && isDst != null) {
180             // This branch is extremely unlikely and could probably be removed. The match above will
181             // have searched the entire tzdb for a zone with the same total offset and isDst state.
182             // Here we try another match but use "null" for isDst to indicate that only the total
183             // offset should be considered. If, by the end of this, there isn't a match then the
184             // current offset suggested by the carrier must be highly unusual.
185             match = lookupByInstantOffsetDst(timeMillis, utcOffsetMillis, null /* isDst */);
186         }
187         return match;
188     }
189 
190     /**
191      * Returns information about the time zones used in a country at a given time.
192      *
193      * {@code null} can be returned if a problem occurs during lookup, e.g. if the country code is
194      * unrecognized, if the country is uninhabited, or if there is a problem with the data.
195      */
196     @VisibleForTesting
197     @Nullable
lookupByCountry(@onNull String isoCountryCode, long whenMillis)198     public CountryResult lookupByCountry(@NonNull String isoCountryCode, long whenMillis) {
199         CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
200         if (countryTimeZones == null) {
201             // Unknown country code.
202             return null;
203         }
204         TimeZone countryDefaultZone = countryTimeZones.getDefaultTimeZone();
205         if (countryDefaultZone == null) {
206             // This is not expected: the country default should have been validated before.
207             return null;
208         }
209 
210         String debugInfo;
211         int matchQuality;
212         if (countryTimeZones.isDefaultTimeZoneBoosted()) {
213             matchQuality = CountryResult.QUALITY_DEFAULT_BOOSTED;
214             debugInfo = "Country default is boosted";
215         } else {
216             List<TimeZoneMapping> effectiveTimeZoneMappings =
217                     countryTimeZones.getEffectiveTimeZoneMappingsAt(whenMillis);
218             if (effectiveTimeZoneMappings.isEmpty()) {
219                 // This should never happen unless there's been an error loading the data.
220                 // Treat it the same as a low quality answer.
221                 matchQuality = QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS;
222                 debugInfo = "No effective time zones found at whenMillis=" + whenMillis;
223             } else if (effectiveTimeZoneMappings.size() == 1) {
224                 // The default is the only zone so it's a good candidate.
225                 matchQuality = CountryResult.QUALITY_SINGLE_ZONE;
226                 debugInfo = "One effective time zone found at whenMillis=" + whenMillis;
227             } else {
228                 boolean countryUsesDifferentOffsets = countryUsesDifferentOffsets(
229                         whenMillis, effectiveTimeZoneMappings, countryDefaultZone);
230                 matchQuality = countryUsesDifferentOffsets
231                         ? QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS
232                         : QUALITY_MULTIPLE_ZONES_SAME_OFFSET;
233                 debugInfo = "countryUsesDifferentOffsets=" + countryUsesDifferentOffsets + " at"
234                         + " whenMillis=" + whenMillis;
235             }
236         }
237         return new CountryResult(countryDefaultZone.getID(), matchQuality, debugInfo);
238     }
239 
countryUsesDifferentOffsets( long whenMillis, @NonNull List<TimeZoneMapping> effectiveTimeZoneMappings, @NonNull TimeZone countryDefaultZone)240     private static boolean countryUsesDifferentOffsets(
241             long whenMillis, @NonNull List<TimeZoneMapping> effectiveTimeZoneMappings,
242             @NonNull TimeZone countryDefaultZone) {
243         String countryDefaultId = countryDefaultZone.getID();
244         int countryDefaultOffset = countryDefaultZone.getOffset(whenMillis);
245         for (TimeZoneMapping timeZoneMapping : effectiveTimeZoneMappings) {
246             if (timeZoneMapping.getTimeZoneId().equals(countryDefaultId)) {
247                 continue;
248             }
249 
250             TimeZone timeZone = timeZoneMapping.getTimeZone();
251             int candidateOffset = timeZone.getOffset(whenMillis);
252             if (countryDefaultOffset != candidateOffset) {
253                 return true;
254             }
255         }
256         return false;
257     }
258 
lookupByInstantOffsetDst(long timeMillis, int utcOffsetMillis, @Nullable Boolean isDst)259     private static OffsetResult lookupByInstantOffsetDst(long timeMillis, int utcOffsetMillis,
260             @Nullable Boolean isDst) {
261 
262         String[] zones = TimeZone.getAvailableIDs();
263         TimeZone match = null;
264         boolean isOnlyMatch = true;
265         for (String zone : zones) {
266             TimeZone tz = TimeZone.getFrozenTimeZone(zone);
267             if (offsetMatchesAtTime(tz, utcOffsetMillis, isDst, timeMillis)) {
268                 if (match == null) {
269                     match = tz;
270                 } else {
271                     isOnlyMatch = false;
272                     break;
273                 }
274             }
275         }
276 
277         if (match == null) {
278             return null;
279         }
280         return new OffsetResult(match, isOnlyMatch);
281     }
282 
283     /**
284      * Returns {@code true} if the specified {@code totalOffset} and {@code isDst} would be valid in
285      * the {@code timeZone} at time {@code whenMillis}. {@code totalOffetMillis} is always matched.
286      * If {@code isDst} is {@code null} this means the DST state is unknown so DST state is ignored.
287      * If {@code isDst} is not {@code null} then it is also matched.
288      */
offsetMatchesAtTime(@onNull TimeZone timeZone, int totalOffsetMillis, @Nullable Boolean isDst, long whenMillis)289     private static boolean offsetMatchesAtTime(@NonNull TimeZone timeZone, int totalOffsetMillis,
290             @Nullable Boolean isDst, long whenMillis) {
291         int[] offsets = new int[2];
292         timeZone.getOffset(whenMillis, false /* local */, offsets);
293 
294         if (totalOffsetMillis != (offsets[0] + offsets[1])) {
295             return false;
296         }
297 
298         return isDst == null || isDst == (offsets[1] != 0);
299     }
300 
301     /**
302      * Returns {@code true} if the supplied (lower-case) ISO country code is for a country known to
303      * use a raw offset of zero from UTC at the time specified.
304      */
305     @VisibleForTesting
countryUsesUtc(@onNull String isoCountryCode, long whenMillis)306     public boolean countryUsesUtc(@NonNull String isoCountryCode, long whenMillis) {
307         if (TextUtils.isEmpty(isoCountryCode)) {
308             return false;
309         }
310 
311         CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
312         return countryTimeZones != null && countryTimeZones.hasUtcZone(whenMillis);
313     }
314 
315     @Nullable
getCountryTimeZones(@onNull String isoCountryCode)316     private CountryTimeZones getCountryTimeZones(@NonNull String isoCountryCode) {
317         Objects.requireNonNull(isoCountryCode);
318 
319         // A single entry cache of the last CountryTimeZones object retrieved since there should
320         // be strong consistency across calls.
321         synchronized (this) {
322             if (mLastCountryTimeZones != null) {
323                 if (mLastCountryTimeZones.matchesCountryCode(isoCountryCode)) {
324                     return mLastCountryTimeZones;
325                 }
326             }
327 
328             // Perform the lookup. It's very unlikely to return null, but we won't cache null.
329             CountryTimeZones countryTimeZones =
330                     TimeZoneFinder.getInstance().lookupCountryTimeZones(isoCountryCode);
331             if (countryTimeZones != null) {
332                 mLastCountryTimeZones = countryTimeZones;
333             }
334             return countryTimeZones;
335         }
336     }
337 }
338