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
17package com.android.internal.telephony;
18
19import android.text.TextUtils;
20
21import libcore.util.CountryTimeZones;
22import libcore.util.TimeZoneFinder;
23
24import java.util.Date;
25import java.util.TimeZone;
26
27/**
28 * An interface to various time zone lookup behaviors.
29 */
30// Non-final to allow mocking.
31public 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
44        public OffsetResult(String zoneId, boolean isOnlyMatch) {
45            this.zoneId = zoneId;
46            this.isOnlyMatch = isOnlyMatch;
47        }
48
49        @Override
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
67        public int hashCode() {
68            int result = zoneId.hashCode();
69            result = 31 * result + (isOnlyMatch ? 1 : 0);
70            return result;
71        }
72
73        @Override
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
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
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
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
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
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     */
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     */
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     */
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     */
220    static TimeZone guessZoneByNitzStatic(NitzData nitzData) {
221        OffsetResult result = lookupByNitzStatic(nitzData);
222        return result != null ? TimeZone.getTimeZone(result.zoneId) : null;
223    }
224
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
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     */
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
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