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;
18 
19 import android.text.TextUtils;
20 
21 import libcore.util.CountryTimeZones;
22 import libcore.util.TimeZoneFinder;
23 
24 import java.util.Date;
25 import java.util.TimeZone;
26 
27 /**
28  * An interface to various time zone lookup behaviors.
29  */
30 // Non-final to allow mocking.
31 public class TimeZoneLookupHelper {
32 
33     /**
34      * The result of looking up a time zone using offset information (and possibly more).
35      */
36     public static final class OffsetResult {
37 
38         /** A zone that matches the supplied criteria. See also {@link #isOnlyMatch}. */
39         public final String zoneId;
40 
41         /** True if there is only one matching time zone for the supplied criteria. */
42         public final boolean isOnlyMatch;
43 
OffsetResult(String zoneId, boolean isOnlyMatch)44         public OffsetResult(String zoneId, boolean isOnlyMatch) {
45             this.zoneId = zoneId;
46             this.isOnlyMatch = isOnlyMatch;
47         }
48 
49         @Override
equals(Object o)50         public boolean equals(Object o) {
51             if (this == o) {
52                 return true;
53             }
54             if (o == null || getClass() != o.getClass()) {
55                 return false;
56             }
57 
58             OffsetResult result = (OffsetResult) o;
59 
60             if (isOnlyMatch != result.isOnlyMatch) {
61                 return false;
62             }
63             return zoneId.equals(result.zoneId);
64         }
65 
66         @Override
hashCode()67         public int hashCode() {
68             int result = zoneId.hashCode();
69             result = 31 * result + (isOnlyMatch ? 1 : 0);
70             return result;
71         }
72 
73         @Override
toString()74         public String toString() {
75             return "Result{"
76                     + "zoneId='" + zoneId + '\''
77                     + ", isOnlyMatch=" + isOnlyMatch
78                     + '}';
79         }
80     }
81 
82     /**
83      * The result of looking up a time zone using country information.
84      */
85     public static final class CountryResult {
86 
87         /** A time zone for the country. */
88         public final String zoneId;
89 
90         /**
91          * True if all the time zones in the country have the same offset at {@link #whenMillis}.
92          */
93         public final boolean allZonesHaveSameOffset;
94 
95         /** The time associated with {@link #allZonesHaveSameOffset}. */
96         public final long whenMillis;
97 
CountryResult(String zoneId, boolean allZonesHaveSameOffset, long whenMillis)98         public CountryResult(String zoneId, boolean allZonesHaveSameOffset, long whenMillis) {
99             this.zoneId = zoneId;
100             this.allZonesHaveSameOffset = allZonesHaveSameOffset;
101             this.whenMillis = whenMillis;
102         }
103 
104         @Override
equals(Object o)105         public boolean equals(Object o) {
106             if (this == o) {
107                 return true;
108             }
109             if (o == null || getClass() != o.getClass()) {
110                 return false;
111             }
112 
113             CountryResult that = (CountryResult) o;
114 
115             if (allZonesHaveSameOffset != that.allZonesHaveSameOffset) {
116                 return false;
117             }
118             if (whenMillis != that.whenMillis) {
119                 return false;
120             }
121             return zoneId.equals(that.zoneId);
122         }
123 
124         @Override
hashCode()125         public int hashCode() {
126             int result = zoneId.hashCode();
127             result = 31 * result + (allZonesHaveSameOffset ? 1 : 0);
128             result = 31 * result + (int) (whenMillis ^ (whenMillis >>> 32));
129             return result;
130         }
131 
132         @Override
toString()133         public String toString() {
134             return "CountryResult{"
135                     + "zoneId='" + zoneId + '\''
136                     + ", allZonesHaveSameOffset=" + allZonesHaveSameOffset
137                     + ", whenMillis=" + whenMillis
138                     + '}';
139         }
140     }
141 
142     private static final int MS_PER_HOUR = 60 * 60 * 1000;
143 
144     /** The last CountryTimeZones object retrieved. */
145     private CountryTimeZones mLastCountryTimeZones;
146 
TimeZoneLookupHelper()147     public TimeZoneLookupHelper() {}
148 
149     /**
150      * Looks for a time zone for the supplied NITZ and country information.
151      *
152      * <p><em>Note:</em> When there are multiple matching zones then one of the matching candidates
153      * will be returned in the result. If the current device default zone matches it will be
154      * returned in preference to other candidates. This method can return {@code null} if no
155      * matching time zones are found.
156      */
lookupByNitzCountry(NitzData nitzData, String isoCountryCode)157     public OffsetResult lookupByNitzCountry(NitzData nitzData, String isoCountryCode) {
158         CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
159         if (countryTimeZones == null) {
160             return null;
161         }
162         android.icu.util.TimeZone bias = android.icu.util.TimeZone.getDefault();
163 
164         CountryTimeZones.OffsetResult offsetResult = countryTimeZones.lookupByOffsetWithBias(
165                 nitzData.getLocalOffsetMillis(), nitzData.isDst(),
166                 nitzData.getCurrentTimeInMillis(), bias);
167 
168         if (offsetResult == null) {
169             return null;
170         }
171         return new OffsetResult(offsetResult.mTimeZone.getID(), offsetResult.mOneMatch);
172     }
173 
174     /**
175      * Looks for a time zone using only information present in the supplied {@link NitzData} object.
176      *
177      * <p><em>Note:</em> Because multiple time zones can have the same offset / DST state at a given
178      * time this process is error prone; an arbitrary match is returned when there are multiple
179      * candidates. The algorithm can also return a non-exact match by assuming that the DST
180      * information provided by NITZ is incorrect. This method can return {@code null} if no matching
181      * time zones are found.
182      */
lookupByNitz(NitzData nitzData)183     public OffsetResult lookupByNitz(NitzData nitzData) {
184         return lookupByNitzStatic(nitzData);
185     }
186 
187     /**
188      * Returns a time zone ID for the country if possible. For counties that use a single time zone
189      * this will provide a good choice. For countries with multiple time zones, a time zone is
190      * returned if all time zones used in the country currently have the same offset (currently ==
191      * according to the device's current system clock time). If this is not the case then
192      * {@code null} can be returned.
193      */
lookupByCountry(String isoCountryCode, long whenMillis)194     public CountryResult lookupByCountry(String isoCountryCode, long whenMillis) {
195         CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
196         if (countryTimeZones == null) {
197             // Unknown country code.
198             return null;
199         }
200         if (countryTimeZones.getDefaultTimeZoneId() == null) {
201             return null;
202         }
203 
204         return new CountryResult(
205                 countryTimeZones.getDefaultTimeZoneId(),
206                 countryTimeZones.isDefaultOkForCountryTimeZoneDetection(whenMillis),
207                 whenMillis);
208     }
209 
210     /**
211      * Finds a time zone using only information present in the supplied {@link NitzData} object.
212      * This is a static method for use by {@link ServiceStateTracker}.
213      *
214      * <p><em>Note:</em> Because multiple time zones can have the same offset / DST state at a given
215      * time this process is error prone; an arbitrary match is returned when there are multiple
216      * candidates. The algorithm can also return a non-exact match by assuming that the DST
217      * information provided by NITZ is incorrect. This method can return {@code null} if no matching
218      * time zones are found.
219      */
guessZoneByNitzStatic(NitzData nitzData)220     static TimeZone guessZoneByNitzStatic(NitzData nitzData) {
221         OffsetResult result = lookupByNitzStatic(nitzData);
222         return result != null ? TimeZone.getTimeZone(result.zoneId) : null;
223     }
224 
lookupByNitzStatic(NitzData nitzData)225     private static OffsetResult lookupByNitzStatic(NitzData nitzData) {
226         int utcOffsetMillis = nitzData.getLocalOffsetMillis();
227         boolean isDst = nitzData.isDst();
228         long timeMillis = nitzData.getCurrentTimeInMillis();
229 
230         OffsetResult match = lookupByInstantOffsetDst(timeMillis, utcOffsetMillis, isDst);
231         if (match == null) {
232             // Couldn't find a proper timezone.  Perhaps the DST data is wrong.
233             match = lookupByInstantOffsetDst(timeMillis, utcOffsetMillis, !isDst);
234         }
235         return match;
236     }
237 
lookupByInstantOffsetDst(long timeMillis, int utcOffsetMillis, boolean isDst)238     private static OffsetResult lookupByInstantOffsetDst(long timeMillis, int utcOffsetMillis,
239             boolean isDst) {
240         int rawOffset = utcOffsetMillis;
241         if (isDst) {
242             rawOffset -= MS_PER_HOUR;
243         }
244         String[] zones = TimeZone.getAvailableIDs(rawOffset);
245         TimeZone match = null;
246         Date d = new Date(timeMillis);
247         boolean isOnlyMatch = true;
248         for (String zone : zones) {
249             TimeZone tz = TimeZone.getTimeZone(zone);
250             if (tz.getOffset(timeMillis) == utcOffsetMillis && tz.inDaylightTime(d) == isDst) {
251                 if (match == null) {
252                     match = tz;
253                 } else {
254                     isOnlyMatch = false;
255                     break;
256                 }
257             }
258         }
259 
260         if (match == null) {
261             return null;
262         }
263         return new OffsetResult(match.getID(), isOnlyMatch);
264     }
265 
266     /**
267      * Returns {@code true} if the supplied (lower-case) ISO country code is for a country known to
268      * use a raw offset of zero from UTC at the time specified.
269      */
countryUsesUtc(String isoCountryCode, long whenMillis)270     public boolean countryUsesUtc(String isoCountryCode, long whenMillis) {
271         if (TextUtils.isEmpty(isoCountryCode)) {
272             return false;
273         }
274 
275         CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
276         return countryTimeZones != null && countryTimeZones.hasUtcZone(whenMillis);
277     }
278 
getCountryTimeZones(String isoCountryCode)279     private CountryTimeZones getCountryTimeZones(String isoCountryCode) {
280         // A single entry cache of the last CountryTimeZones object retrieved since there should
281         // be strong consistency across calls.
282         synchronized (this) {
283             if (mLastCountryTimeZones != null) {
284                 if (mLastCountryTimeZones.isForCountryCode(isoCountryCode)) {
285                     return mLastCountryTimeZones;
286                 }
287             }
288 
289             // Perform the lookup. It's very unlikely to return null, but we won't cache null.
290             CountryTimeZones countryTimeZones =
291                     TimeZoneFinder.getInstance().lookupCountryTimeZones(isoCountryCode);
292             if (countryTimeZones != null) {
293                 mLastCountryTimeZones = countryTimeZones;
294             }
295             return countryTimeZones;
296         }
297     }
298 }
299