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