blob: fab45f7fac893f06d554e6c645dad4c48cd65987 [file] [log] [blame]
/*
* Copyright 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.internal.telephony.nitz;
import static com.android.internal.telephony.nitz.TimeZoneLookupHelper.CountryResult.QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS;
import static com.android.internal.telephony.nitz.TimeZoneLookupHelper.CountryResult.QUALITY_MULTIPLE_ZONES_SAME_OFFSET;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.icu.util.TimeZone;
import android.text.TextUtils;
import android.timezone.CountryTimeZones;
import android.timezone.CountryTimeZones.OffsetResult;
import android.timezone.CountryTimeZones.TimeZoneMapping;
import android.timezone.TimeZoneFinder;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.telephony.NitzData;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
import java.util.Objects;
/**
* An interface to various time zone lookup behaviors.
*/
@VisibleForTesting
public final class TimeZoneLookupHelper {
/**
* The result of looking up a time zone using country information.
*/
@VisibleForTesting
public static final class CountryResult {
@IntDef({ QUALITY_SINGLE_ZONE, QUALITY_DEFAULT_BOOSTED, QUALITY_MULTIPLE_ZONES_SAME_OFFSET,
QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS })
@Retention(RetentionPolicy.SOURCE)
public @interface Quality {}
public static final int QUALITY_SINGLE_ZONE = 1;
public static final int QUALITY_DEFAULT_BOOSTED = 2;
public static final int QUALITY_MULTIPLE_ZONES_SAME_OFFSET = 3;
public static final int QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS = 4;
/** A time zone to use for the country. */
@NonNull
public final String zoneId;
/**
* The quality of the match.
*/
@Quality
public final int quality;
/**
* Freeform information about why the value of {@link #quality} was chosen. Not used for
* {@link #equals(Object)}.
*/
private final String mDebugInfo;
public CountryResult(@NonNull String zoneId, @Quality int quality, String debugInfo) {
this.zoneId = Objects.requireNonNull(zoneId);
this.quality = quality;
mDebugInfo = debugInfo;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CountryResult that = (CountryResult) o;
return quality == that.quality
&& zoneId.equals(that.zoneId);
}
@Override
public int hashCode() {
return Objects.hash(zoneId, quality);
}
@Override
public String toString() {
return "CountryResult{"
+ "zoneId='" + zoneId + '\''
+ ", quality=" + quality
+ ", mDebugInfo=" + mDebugInfo
+ '}';
}
}
/** The last CountryTimeZones object retrieved. */
@Nullable
private CountryTimeZones mLastCountryTimeZones;
@VisibleForTesting
public TimeZoneLookupHelper() {}
/**
* Looks for a time zone for the supplied NITZ and country information.
*
* <p><em>Note:</em> When there are multiple matching zones then one of the matching candidates
* will be returned in the result. If the current device default zone matches it will be
* returned in preference to other candidates. This method can return {@code null} if no
* matching time zones are found.
*/
@VisibleForTesting
@Nullable
public OffsetResult lookupByNitzCountry(
@NonNull NitzData nitzData, @NonNull String isoCountryCode) {
CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
if (countryTimeZones == null) {
return null;
}
TimeZone bias = TimeZone.getDefault();
// Android NITZ time zone matching doesn't try to do a precise match using the DST offset
// supplied by the carrier. It only considers whether or not the carrier suggests local time
// is DST (if known). NITZ is limited in only being able to express DST offsets in whole
// hours and the DST info is optional.
Integer dstAdjustmentMillis = nitzData.getDstAdjustmentMillis();
if (dstAdjustmentMillis == null) {
return countryTimeZones.lookupByOffsetWithBias(
nitzData.getCurrentTimeInMillis(), bias, nitzData.getLocalOffsetMillis());
} else {
// We don't try to match the exact DST offset given, we just use it to work out if
// the country is in DST.
boolean isDst = dstAdjustmentMillis != 0;
return countryTimeZones.lookupByOffsetWithBias(
nitzData.getCurrentTimeInMillis(), bias,
nitzData.getLocalOffsetMillis(), isDst);
}
}
/**
* Looks for a time zone using only information present in the supplied {@link NitzData} object.
*
* <p><em>Note:</em> Because multiple time zones can have the same offset / DST state at a given
* time this process is error prone; an arbitrary match is returned when there are multiple
* candidates. The algorithm can also return a non-exact match by assuming that the DST
* information provided by NITZ is incorrect. This method can return {@code null} if no matching
* time zones are found.
*/
@VisibleForTesting
@Nullable
public OffsetResult lookupByNitz(@NonNull NitzData nitzData) {
int utcOffsetMillis = nitzData.getLocalOffsetMillis();
long timeMillis = nitzData.getCurrentTimeInMillis();
// Android NITZ time zone matching doesn't try to do a precise match using the DST offset
// supplied by the carrier. It only considers whether or not the carrier suggests local time
// is DST (if known). NITZ is limited in only being able to express DST offsets in whole
// hours and the DST info is optional.
Integer dstAdjustmentMillis = nitzData.getDstAdjustmentMillis();
Boolean isDst = dstAdjustmentMillis == null ? null : dstAdjustmentMillis != 0;
OffsetResult match = lookupByInstantOffsetDst(timeMillis, utcOffsetMillis, isDst);
if (match == null && isDst != null) {
// This branch is extremely unlikely and could probably be removed. The match above will
// have searched the entire tzdb for a zone with the same total offset and isDst state.
// Here we try another match but use "null" for isDst to indicate that only the total
// offset should be considered. If, by the end of this, there isn't a match then the
// current offset suggested by the carrier must be highly unusual.
match = lookupByInstantOffsetDst(timeMillis, utcOffsetMillis, null /* isDst */);
}
return match;
}
/**
* Returns information about the time zones used in a country at a given time.
*
* {@code null} can be returned if a problem occurs during lookup, e.g. if the country code is
* unrecognized, if the country is uninhabited, or if there is a problem with the data.
*/
@VisibleForTesting
@Nullable
public CountryResult lookupByCountry(@NonNull String isoCountryCode, long whenMillis) {
CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
if (countryTimeZones == null) {
// Unknown country code.
return null;
}
TimeZone countryDefaultZone = countryTimeZones.getDefaultTimeZone();
if (countryDefaultZone == null) {
// This is not expected: the country default should have been validated before.
return null;
}
String debugInfo;
int matchQuality;
if (countryTimeZones.isDefaultTimeZoneBoosted()) {
matchQuality = CountryResult.QUALITY_DEFAULT_BOOSTED;
debugInfo = "Country default is boosted";
} else {
List<TimeZoneMapping> effectiveTimeZoneMappings =
countryTimeZones.getEffectiveTimeZoneMappingsAt(whenMillis);
if (effectiveTimeZoneMappings.isEmpty()) {
// This should never happen unless there's been an error loading the data.
// Treat it the same as a low quality answer.
matchQuality = QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS;
debugInfo = "No effective time zones found at whenMillis=" + whenMillis;
} else if (effectiveTimeZoneMappings.size() == 1) {
// The default is the only zone so it's a good candidate.
matchQuality = CountryResult.QUALITY_SINGLE_ZONE;
debugInfo = "One effective time zone found at whenMillis=" + whenMillis;
} else {
boolean countryUsesDifferentOffsets = countryUsesDifferentOffsets(
whenMillis, effectiveTimeZoneMappings, countryDefaultZone);
matchQuality = countryUsesDifferentOffsets
? QUALITY_MULTIPLE_ZONES_DIFFERENT_OFFSETS
: QUALITY_MULTIPLE_ZONES_SAME_OFFSET;
debugInfo = "countryUsesDifferentOffsets=" + countryUsesDifferentOffsets + " at"
+ " whenMillis=" + whenMillis;
}
}
return new CountryResult(countryDefaultZone.getID(), matchQuality, debugInfo);
}
private static boolean countryUsesDifferentOffsets(
long whenMillis, @NonNull List<TimeZoneMapping> effectiveTimeZoneMappings,
@NonNull TimeZone countryDefaultZone) {
String countryDefaultId = countryDefaultZone.getID();
int countryDefaultOffset = countryDefaultZone.getOffset(whenMillis);
for (TimeZoneMapping timeZoneMapping : effectiveTimeZoneMappings) {
if (timeZoneMapping.getTimeZoneId().equals(countryDefaultId)) {
continue;
}
TimeZone timeZone = timeZoneMapping.getTimeZone();
int candidateOffset = timeZone.getOffset(whenMillis);
if (countryDefaultOffset != candidateOffset) {
return true;
}
}
return false;
}
private static OffsetResult lookupByInstantOffsetDst(long timeMillis, int utcOffsetMillis,
@Nullable Boolean isDst) {
String[] zones = TimeZone.getAvailableIDs();
TimeZone match = null;
boolean isOnlyMatch = true;
for (String zone : zones) {
TimeZone tz = TimeZone.getFrozenTimeZone(zone);
if (offsetMatchesAtTime(tz, utcOffsetMillis, isDst, timeMillis)) {
if (match == null) {
match = tz;
} else {
isOnlyMatch = false;
break;
}
}
}
if (match == null) {
return null;
}
return new OffsetResult(match, isOnlyMatch);
}
/**
* Returns {@code true} if the specified {@code totalOffset} and {@code isDst} would be valid in
* the {@code timeZone} at time {@code whenMillis}. {@code totalOffetMillis} is always matched.
* If {@code isDst} is {@code null} this means the DST state is unknown so DST state is ignored.
* If {@code isDst} is not {@code null} then it is also matched.
*/
private static boolean offsetMatchesAtTime(@NonNull TimeZone timeZone, int totalOffsetMillis,
@Nullable Boolean isDst, long whenMillis) {
int[] offsets = new int[2];
timeZone.getOffset(whenMillis, false /* local */, offsets);
if (totalOffsetMillis != (offsets[0] + offsets[1])) {
return false;
}
return isDst == null || isDst == (offsets[1] != 0);
}
/**
* Returns {@code true} if the supplied (lower-case) ISO country code is for a country known to
* use a raw offset of zero from UTC at the time specified.
*/
@VisibleForTesting
public boolean countryUsesUtc(@NonNull String isoCountryCode, long whenMillis) {
if (TextUtils.isEmpty(isoCountryCode)) {
return false;
}
CountryTimeZones countryTimeZones = getCountryTimeZones(isoCountryCode);
return countryTimeZones != null && countryTimeZones.hasUtcZone(whenMillis);
}
@Nullable
private CountryTimeZones getCountryTimeZones(@NonNull String isoCountryCode) {
Objects.requireNonNull(isoCountryCode);
// A single entry cache of the last CountryTimeZones object retrieved since there should
// be strong consistency across calls.
synchronized (this) {
if (mLastCountryTimeZones != null) {
if (mLastCountryTimeZones.matchesCountryCode(isoCountryCode)) {
return mLastCountryTimeZones;
}
}
// Perform the lookup. It's very unlikely to return null, but we won't cache null.
CountryTimeZones countryTimeZones =
TimeZoneFinder.getInstance().lookupCountryTimeZones(isoCountryCode);
if (countryTimeZones != null) {
mLastCountryTimeZones = countryTimeZones;
}
return countryTimeZones;
}
}
}