blob: dddb46f8072425e15c9b6aa3247c87d75b8eb1ed [file] [log] [blame]
/*
* Copyright 2019 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.server.timezonedetector;
import static android.app.time.Capabilities.CAPABILITY_POSSESSED;
import static android.app.timezonedetector.TelephonyTimeZoneSuggestion.MATCH_TYPE_EMULATOR_ZONE_ID;
import static android.app.timezonedetector.TelephonyTimeZoneSuggestion.MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY;
import static android.app.timezonedetector.TelephonyTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS;
import static android.app.timezonedetector.TelephonyTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET;
import static android.app.timezonedetector.TelephonyTimeZoneSuggestion.QUALITY_SINGLE_ZONE;
import static com.android.server.SystemTimeZone.TIME_ZONE_CONFIDENCE_HIGH;
import static com.android.server.SystemTimeZone.TIME_ZONE_CONFIDENCE_LOW;
import android.annotation.ElapsedRealtimeLong;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.UserIdInt;
import android.app.time.DetectorStatusTypes;
import android.app.time.LocationTimeZoneAlgorithmStatus;
import android.app.time.TelephonyTimeZoneAlgorithmStatus;
import android.app.time.TimeZoneCapabilities;
import android.app.time.TimeZoneCapabilitiesAndConfig;
import android.app.time.TimeZoneConfiguration;
import android.app.time.TimeZoneDetectorStatus;
import android.app.time.TimeZoneState;
import android.app.timezonedetector.ManualTimeZoneSuggestion;
import android.app.timezonedetector.TelephonyTimeZoneSuggestion;
import android.os.Handler;
import android.os.TimestampedValue;
import android.util.IndentingPrintWriter;
import android.util.Slog;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.SystemTimeZone.TimeZoneConfidence;
import com.android.server.timezonedetector.ConfigurationInternal.DetectionMode;
import java.io.PrintWriter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* The real implementation of {@link TimeZoneDetectorStrategy}.
*
* <p>Most public methods are marked synchronized to ensure thread safety around internal state.
*/
public final class TimeZoneDetectorStrategyImpl implements TimeZoneDetectorStrategy {
/**
* Used by {@link TimeZoneDetectorStrategyImpl} to interact with device state besides that
* available from {@link #mServiceConfigAccessor}. It can be faked for testing.
*/
@VisibleForTesting
public interface Environment {
/**
* Returns the device's currently configured time zone. May return an empty string.
*/
@NonNull String getDeviceTimeZone();
/**
* Returns the confidence of the device's current time zone.
*/
@TimeZoneConfidence int getDeviceTimeZoneConfidence();
/**
* Sets the device's time zone, associated confidence, and records a debug log entry.
*/
void setDeviceTimeZoneAndConfidence(
@NonNull String zoneId, @TimeZoneConfidence int confidence,
@NonNull String logInfo);
/**
* Returns the time according to the elapsed realtime clock, the same as {@link
* android.os.SystemClock#elapsedRealtime()}.
*/
@ElapsedRealtimeLong
long elapsedRealtimeMillis();
/**
* Adds a standalone entry to the time zone debug log.
*/
void addDebugLogEntry(@NonNull String logMsg);
/**
* Dumps the time zone debug log to the supplied {@link PrintWriter}.
*/
void dumpDebugLog(PrintWriter printWriter);
/**
* Requests that the supplied runnable be invoked asynchronously.
*/
void runAsync(@NonNull Runnable runnable);
}
private static final String LOG_TAG = TimeZoneDetectorService.TAG;
private static final boolean DBG = TimeZoneDetectorService.DBG;
/**
* The abstract score for an empty or invalid telephony suggestion.
*
* Used to score telephony suggestions where there is no zone.
*/
@VisibleForTesting
public static final int TELEPHONY_SCORE_NONE = 0;
/**
* The abstract score for a low quality telephony suggestion.
*
* Used to score suggestions where:
* The suggested zone ID is one of several possibilities, and the possibilities have different
* offsets.
*
* You would have to be quite desperate to want to use this choice.
*/
@VisibleForTesting
public static final int TELEPHONY_SCORE_LOW = 1;
/**
* The abstract score for a medium quality telephony suggestion.
*
* Used for:
* The suggested zone ID is one of several possibilities but at least the possibilities have the
* same offset. Users would get the correct time but for the wrong reason. i.e. their device may
* switch to DST at the wrong time and (for example) their calendar events.
*/
@VisibleForTesting
public static final int TELEPHONY_SCORE_MEDIUM = 2;
/**
* The abstract score for a high quality telephony suggestion.
*
* Used for:
* The suggestion was for one zone ID and the answer was unambiguous and likely correct given
* the info available.
*/
@VisibleForTesting
public static final int TELEPHONY_SCORE_HIGH = 3;
/**
* The abstract score for a highest quality telephony suggestion.
*
* Used for:
* Suggestions that must "win" because they constitute test or emulator zone ID.
*/
@VisibleForTesting
public static final int TELEPHONY_SCORE_HIGHEST = 4;
/**
* The threshold at which telephony suggestions are good enough to use to set the device's time
* zone.
*/
@VisibleForTesting
public static final int TELEPHONY_SCORE_USAGE_THRESHOLD = TELEPHONY_SCORE_MEDIUM;
/**
* The number of suggestions to keep. These are logged in bug reports to assist when debugging
* issues with detection.
*/
private static final int KEEP_SUGGESTION_HISTORY_SIZE = 10;
@NonNull
private final Environment mEnvironment;
/**
* A mapping from slotIndex to a telephony time zone suggestion. We typically expect one or two
* mappings: devices will have a small number of telephony devices and slotIndexes are assumed
* to be stable.
*/
@GuardedBy("this")
private final ArrayMapWithHistory<Integer, QualifiedTelephonyTimeZoneSuggestion>
mTelephonySuggestionsBySlotIndex =
new ArrayMapWithHistory<>(KEEP_SUGGESTION_HISTORY_SIZE);
/**
* The latest location algorithm event received.
*/
@GuardedBy("this")
private final ReferenceWithHistory<LocationAlgorithmEvent> mLatestLocationAlgorithmEvent =
new ReferenceWithHistory<>(KEEP_SUGGESTION_HISTORY_SIZE);
/**
* The latest manual suggestion received.
*/
@GuardedBy("this")
private final ReferenceWithHistory<ManualTimeZoneSuggestion> mLatestManualSuggestion =
new ReferenceWithHistory<>(KEEP_SUGGESTION_HISTORY_SIZE);
@NonNull
private final ServiceConfigAccessor mServiceConfigAccessor;
@GuardedBy("this")
@NonNull private final List<StateChangeListener> mStateChangeListeners = new ArrayList<>();
/**
* A snapshot of the current detector status. A local copy is cached because it is relatively
* heavyweight to obtain and is used more often than it is expected to change.
*/
@GuardedBy("this")
@NonNull
private TimeZoneDetectorStatus mDetectorStatus;
/**
* A snapshot of the current user's {@link ConfigurationInternal}. A local copy is cached
* because it is relatively heavyweight to obtain and is used more often than it is expected to
* change. Because many operations are asynchronous, this value may be out of date but should
* be "eventually consistent".
*/
@GuardedBy("this")
@NonNull
private ConfigurationInternal mCurrentConfigurationInternal;
/**
* Whether telephony time zone detection fallback is currently enabled (when device config also
* allows).
*
* <p>This field is only actually used when telephony time zone fallback is supported, but the
* value is maintained even when it isn't supported as support can be turned on at any time via
* server flags. The elapsed realtime when the mode last changed is used to help ordering
* between fallback mode switches and suggestions.
*
* <p>See {@link TimeZoneDetectorStrategy} for more information.
*/
@GuardedBy("this")
@NonNull
private TimestampedValue<Boolean> mTelephonyTimeZoneFallbackEnabled;
/**
* Creates a new instance of {@link TimeZoneDetectorStrategyImpl}.
*/
public static TimeZoneDetectorStrategyImpl create(
@NonNull Handler handler, @NonNull ServiceConfigAccessor serviceConfigAccessor) {
Environment environment = new EnvironmentImpl(handler);
return new TimeZoneDetectorStrategyImpl(serviceConfigAccessor, environment);
}
@VisibleForTesting
public TimeZoneDetectorStrategyImpl(
@NonNull ServiceConfigAccessor serviceConfigAccessor,
@NonNull Environment environment) {
mEnvironment = Objects.requireNonNull(environment);
mServiceConfigAccessor = Objects.requireNonNull(serviceConfigAccessor);
// Start with telephony fallback enabled.
mTelephonyTimeZoneFallbackEnabled =
new TimestampedValue<>(mEnvironment.elapsedRealtimeMillis(), true);
synchronized (this) {
// Listen for config and user changes and get an initial snapshot of configuration.
StateChangeListener stateChangeListener = this::handleConfigurationInternalMaybeChanged;
mServiceConfigAccessor.addConfigurationInternalChangeListener(stateChangeListener);
// Initialize mCurrentConfigurationInternal and mDetectorStatus with their starting
// values.
updateCurrentConfigurationInternalIfRequired("TimeZoneDetectorStrategyImpl:");
}
}
@Override
public synchronized TimeZoneCapabilitiesAndConfig getCapabilitiesAndConfig(
@UserIdInt int userId, boolean bypassUserPolicyChecks) {
ConfigurationInternal configurationInternal;
if (mCurrentConfigurationInternal.getUserId() == userId) {
// Use the cached snapshot we have.
configurationInternal = mCurrentConfigurationInternal;
} else {
// This is not a common case: It would be unusual to want the configuration for a user
// other than the "current" user, but it is supported because it is trivial to do so.
// Unlike the current user config, there's no cached copy to worry about so read it
// directly from mServiceConfigAccessor.
configurationInternal = mServiceConfigAccessor.getConfigurationInternal(userId);
}
return new TimeZoneCapabilitiesAndConfig(
mDetectorStatus,
configurationInternal.asCapabilities(bypassUserPolicyChecks),
configurationInternal.asConfiguration());
}
@Override
public synchronized boolean updateConfiguration(
@UserIdInt int userId, @NonNull TimeZoneConfiguration configuration,
boolean bypassUserPolicyChecks) {
// Write-through
boolean updateSuccessful = mServiceConfigAccessor.updateConfiguration(
userId, configuration, bypassUserPolicyChecks);
// The update above will trigger config update listeners asynchronously if they are needed,
// but that could mean an immediate call to getCapabilitiesAndConfig() for the current user
// wouldn't see the update. So, handle the cache update and notifications here. When the
// async update listener triggers it will find everything already up to date and do nothing.
if (updateSuccessful) {
String logMsg = "updateConfiguration:"
+ " userId=" + userId
+ ", configuration=" + configuration
+ ", bypassUserPolicyChecks=" + bypassUserPolicyChecks;
updateCurrentConfigurationInternalIfRequired(logMsg);
}
return updateSuccessful;
}
@GuardedBy("this")
private void updateCurrentConfigurationInternalIfRequired(@NonNull String logMsg) {
ConfigurationInternal newCurrentConfigurationInternal =
mServiceConfigAccessor.getCurrentUserConfigurationInternal();
// mCurrentConfigurationInternal is null the first time this method is called.
ConfigurationInternal oldCurrentConfigurationInternal = mCurrentConfigurationInternal;
// If the configuration actually changed, update the cached copy synchronously and do
// other necessary house-keeping / (async) listener notifications.
if (!newCurrentConfigurationInternal.equals(oldCurrentConfigurationInternal)) {
mCurrentConfigurationInternal = newCurrentConfigurationInternal;
logMsg += " [oldConfiguration=" + oldCurrentConfigurationInternal
+ ", newConfiguration=" + newCurrentConfigurationInternal
+ "]";
logTimeZoneDebugInfo(logMsg);
// ConfigurationInternal changes can affect the detector's status.
updateDetectorStatus();
// The configuration and maybe the status changed so notify listeners.
notifyStateChangeListenersAsynchronously();
// The configuration change may have changed available suggestions or the way
// suggestions are used, so re-run detection.
doAutoTimeZoneDetection(mCurrentConfigurationInternal, logMsg);
}
}
@GuardedBy("this")
private void notifyStateChangeListenersAsynchronously() {
for (StateChangeListener listener : mStateChangeListeners) {
// This is queuing asynchronous notification, so no need to surrender the "this" lock.
mEnvironment.runAsync(listener::onChange);
}
}
@Override
public synchronized void addChangeListener(StateChangeListener listener) {
mStateChangeListeners.add(listener);
}
@Override
public synchronized boolean confirmTimeZone(@NonNull String timeZoneId) {
Objects.requireNonNull(timeZoneId);
String currentTimeZoneId = mEnvironment.getDeviceTimeZone();
if (!currentTimeZoneId.equals(timeZoneId)) {
return false;
}
if (mEnvironment.getDeviceTimeZoneConfidence() < TIME_ZONE_CONFIDENCE_HIGH) {
mEnvironment.setDeviceTimeZoneAndConfidence(currentTimeZoneId,
TIME_ZONE_CONFIDENCE_HIGH, "confirmTimeZone: timeZoneId=" + timeZoneId);
}
return true;
}
@Override
public synchronized TimeZoneState getTimeZoneState() {
boolean userShouldConfirmId =
mEnvironment.getDeviceTimeZoneConfidence() < TIME_ZONE_CONFIDENCE_HIGH;
return new TimeZoneState(mEnvironment.getDeviceTimeZone(), userShouldConfirmId);
}
@Override
public void setTimeZoneState(@NonNull TimeZoneState timeZoneState) {
Objects.requireNonNull(timeZoneState);
@TimeZoneConfidence int confidence = timeZoneState.getUserShouldConfirmId()
? TIME_ZONE_CONFIDENCE_LOW : TIME_ZONE_CONFIDENCE_HIGH;
mEnvironment.setDeviceTimeZoneAndConfidence(
timeZoneState.getId(), confidence, "setTimeZoneState()");
}
@Override
public synchronized void handleLocationAlgorithmEvent(@NonNull LocationAlgorithmEvent event) {
ConfigurationInternal currentUserConfig = mCurrentConfigurationInternal;
if (DBG) {
Slog.d(LOG_TAG, "Location algorithm event received."
+ " currentUserConfig=" + currentUserConfig
+ " event=" + event);
}
Objects.requireNonNull(event);
// Location algorithm events may be stored but not used during time zone detection if the
// configuration doesn't have geo time zone detection enabled. The caller is expected to
// withdraw a previous suggestion, i.e. submit an event containing an "uncertain"
// suggestion, when geo time zone detection is disabled.
// We currently assume events are made in a sensible order and the most recent is always the
// best one to use.
mLatestLocationAlgorithmEvent.set(event);
// The latest location algorithm event can affect the cached detector status, so update it
// and notify state change listeners as needed.
boolean statusChanged = updateDetectorStatus();
if (statusChanged) {
notifyStateChangeListenersAsynchronously();
}
// Manage telephony fallback state.
if (event.getAlgorithmStatus().couldEnableTelephonyFallback()) {
// An event may trigger entry into telephony fallback mode if the status
// indicates the location algorithm cannot work and is likely to stay not working.
enableTelephonyTimeZoneFallback("handleLocationAlgorithmEvent(), event=" + event);
} else {
// A certain suggestion will exit telephony fallback mode.
disableTelephonyFallbackIfNeeded();
}
// Now perform auto time zone detection. The new event may be used to modify the time zone
// setting.
String reason = "New location algorithm event received. event=" + event;
doAutoTimeZoneDetection(currentUserConfig, reason);
}
@Override
public synchronized boolean suggestManualTimeZone(
@UserIdInt int userId, @NonNull ManualTimeZoneSuggestion suggestion,
boolean bypassUserPolicyChecks) {
ConfigurationInternal currentUserConfig = mCurrentConfigurationInternal;
if (currentUserConfig.getUserId() != userId) {
Slog.w(LOG_TAG, "Manual suggestion received but user != current user, userId=" + userId
+ " suggestion=" + suggestion);
// Only listen to changes from the current user.
return false;
}
Objects.requireNonNull(suggestion);
String timeZoneId = suggestion.getZoneId();
String cause = "Manual time suggestion received: suggestion=" + suggestion;
TimeZoneCapabilities capabilities =
currentUserConfig.asCapabilities(bypassUserPolicyChecks);
if (capabilities.getSetManualTimeZoneCapability() != CAPABILITY_POSSESSED) {
Slog.i(LOG_TAG, "User does not have the capability needed to set the time zone manually"
+ ": capabilities=" + capabilities
+ ", timeZoneId=" + timeZoneId
+ ", cause=" + cause);
return false;
}
// Record the manual suggestion for debugging / metrics (but only if manual detection is
// currently enabled).
// Note: This is not used to set the device back to a previous manual suggestion if the user
// later disables automatic time zone detection.
mLatestManualSuggestion.set(suggestion);
setDeviceTimeZoneIfRequired(timeZoneId, cause);
return true;
}
@Override
public synchronized void suggestTelephonyTimeZone(
@NonNull TelephonyTimeZoneSuggestion suggestion) {
ConfigurationInternal currentUserConfig = mCurrentConfigurationInternal;
if (DBG) {
Slog.d(LOG_TAG, "Telephony suggestion received. currentUserConfig=" + currentUserConfig
+ " suggestion=" + suggestion);
}
Objects.requireNonNull(suggestion);
// Score the suggestion.
int score = scoreTelephonySuggestion(suggestion);
QualifiedTelephonyTimeZoneSuggestion scoredSuggestion =
new QualifiedTelephonyTimeZoneSuggestion(suggestion, score);
// Store the suggestion against the correct slotIndex.
mTelephonySuggestionsBySlotIndex.put(suggestion.getSlotIndex(), scoredSuggestion);
// Now perform auto time zone detection: the new suggestion might be used to modify the
// time zone setting.
String reason = "New telephony time zone suggested. suggestion=" + suggestion;
doAutoTimeZoneDetection(currentUserConfig, reason);
}
@Override
public synchronized void enableTelephonyTimeZoneFallback(@NonNull String reason) {
// Only do any work to enter fallback mode if fallback is currently not already enabled.
if (!mTelephonyTimeZoneFallbackEnabled.getValue()) {
ConfigurationInternal currentUserConfig = mCurrentConfigurationInternal;
final boolean fallbackEnabled = true;
mTelephonyTimeZoneFallbackEnabled = new TimestampedValue<>(
mEnvironment.elapsedRealtimeMillis(), fallbackEnabled);
String logMsg = "enableTelephonyTimeZoneFallback: "
+ " reason=" + reason
+ ", currentUserConfig=" + currentUserConfig
+ ", mTelephonyTimeZoneFallbackEnabled=" + mTelephonyTimeZoneFallbackEnabled;
logTimeZoneDebugInfo(logMsg);
// mTelephonyTimeZoneFallbackEnabled and mLatestLocationAlgorithmEvent interact.
// If the latest location algorithm event contains a "certain" geolocation suggestion,
// then the telephony fallback mode needs to be (re)considered after changing it.
//
// With the way that the mTelephonyTimeZoneFallbackEnabled time is currently chosen
// above, and the fact that geolocation suggestions should never have a time in the
// future, the following call will usually be a no-op, and telephony fallback mode will
// remain enabled. This comment / call is left as a reminder that it is possible in some
// cases for there to be a current, "certain" geolocation suggestion when an attempt is
// made to enable telephony fallback mode and it is intentional that fallback mode stays
// enabled in this case. The choice to do this is mostly for symmetry WRT the case where
// fallback is enabled and then an old "certain" geolocation suggestion is received;
// that would also leave telephony fallback mode enabled.
//
// This choice means that telephony fallback mode remains enabled if there is an
// existing "certain" suggestion until a new "certain" geolocation suggestion is
// received. If, instead, the next geolocation suggestion is "uncertain", then telephony
// fallback, i.e. the use of a telephony suggestion, will actually occur.
disableTelephonyFallbackIfNeeded();
if (currentUserConfig.isTelephonyFallbackSupported()) {
doAutoTimeZoneDetection(currentUserConfig, reason);
}
}
}
@Override
@NonNull
public synchronized MetricsTimeZoneDetectorState generateMetricsState() {
// Just capture one telephony suggestion: the one that would be used right now if telephony
// detection is in use.
QualifiedTelephonyTimeZoneSuggestion bestQualifiedTelephonySuggestion =
findBestTelephonySuggestion();
TelephonyTimeZoneSuggestion telephonySuggestion =
bestQualifiedTelephonySuggestion == null
? null : bestQualifiedTelephonySuggestion.suggestion;
// A new generator is created each time: we don't want / require consistency.
OrdinalGenerator<String> tzIdOrdinalGenerator =
new OrdinalGenerator<>(new TimeZoneCanonicalizer());
return MetricsTimeZoneDetectorState.create(
tzIdOrdinalGenerator,
mCurrentConfigurationInternal,
mEnvironment.getDeviceTimeZone(),
getLatestManualSuggestion(),
telephonySuggestion,
getLatestLocationAlgorithmEvent());
}
@Override
public boolean isTelephonyTimeZoneDetectionSupported() {
synchronized (this) {
return mCurrentConfigurationInternal.isTelephonyDetectionSupported();
}
}
@Override
public boolean isGeoTimeZoneDetectionSupported() {
synchronized (this) {
return mCurrentConfigurationInternal.isGeoDetectionSupported();
}
}
private static int scoreTelephonySuggestion(@NonNull TelephonyTimeZoneSuggestion suggestion) {
int score;
if (suggestion.getZoneId() == null) {
score = TELEPHONY_SCORE_NONE;
} else if (suggestion.getMatchType() == MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY
|| suggestion.getMatchType() == MATCH_TYPE_EMULATOR_ZONE_ID) {
// Handle emulator / test cases : These suggestions should always just be used.
score = TELEPHONY_SCORE_HIGHEST;
} else if (suggestion.getQuality() == QUALITY_SINGLE_ZONE) {
score = TELEPHONY_SCORE_HIGH;
} else if (suggestion.getQuality() == QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET) {
// The suggestion may be wrong, but at least the offset should be correct.
score = TELEPHONY_SCORE_MEDIUM;
} else if (suggestion.getQuality() == QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS) {
// The suggestion has a good chance of being wrong.
score = TELEPHONY_SCORE_LOW;
} else {
throw new AssertionError();
}
return score;
}
/**
* Performs automatic time zone detection.
*/
@GuardedBy("this")
private void doAutoTimeZoneDetection(
@NonNull ConfigurationInternal currentUserConfig, @NonNull String detectionReason) {
// Use the correct detection algorithm based on the device's config and the user's current
// configuration. If user config changes, then detection will be re-run.
@DetectionMode int detectionMode = currentUserConfig.getDetectionMode();
switch (detectionMode) {
case ConfigurationInternal.DETECTION_MODE_MANUAL:
// No work to do.
break;
case ConfigurationInternal.DETECTION_MODE_GEO: {
boolean isGeoDetectionCertain = doGeolocationTimeZoneDetection(detectionReason);
// When geolocation detection is uncertain of the time zone, telephony detection
// can be used if telephony fallback is enabled and supported.
if (!isGeoDetectionCertain
&& mTelephonyTimeZoneFallbackEnabled.getValue()
&& currentUserConfig.isTelephonyFallbackSupported()) {
// This "only look at telephony if geolocation is uncertain" approach is
// deliberate to try to keep the logic simple and keep telephony and geolocation
// detection decoupled: when geolocation detection is in use, it is fully
// trusted and the most recent "certain" geolocation suggestion available will
// be used, even if the information it is based on is quite old.
// There could be newer telephony suggestions available, but telephony
// suggestions tend not to be withdrawn when they should be, and are based on
// combining information like MCC and NITZ signals, which could have been
// received at different times; thus it is hard to say what time the suggestion
// is actually "for" and reason clearly about ordering between telephony and
// geolocation suggestions.
//
// This approach is reliant on the location_time_zone_manager (and the location
// time zone providers it manages) correctly sending "uncertain" suggestions
// when the current location is unknown so that telephony fallback will actually
// be used.
doTelephonyTimeZoneDetection(detectionReason + ", telephony fallback mode");
}
break;
}
case ConfigurationInternal.DETECTION_MODE_TELEPHONY:
doTelephonyTimeZoneDetection(detectionReason);
break;
case ConfigurationInternal.DETECTION_MODE_UNKNOWN:
// The "DETECTION_MODE_UNKNOWN" state can occur on devices with only location
// detection algorithm support and when the user's master location toggle is off.
Slog.i(LOG_TAG, "Unknown detection mode: " + detectionMode + ", is location off?");
break;
default:
// Coding error
Slog.wtf(LOG_TAG, "Unknown detection mode: " + detectionMode);
}
}
/**
* Detects the time zone using the latest available geolocation time zone suggestion, if one is
* available. The outcome can be that this strategy becomes / remains un-opinionated and nothing
* is set.
*
* @return true if geolocation time zone detection was certain of the time zone, false if it is
* uncertain
*/
@GuardedBy("this")
private boolean doGeolocationTimeZoneDetection(@NonNull String detectionReason) {
// Terminate early if there's nothing to do.
LocationAlgorithmEvent latestLocationAlgorithmEvent = mLatestLocationAlgorithmEvent.get();
if (latestLocationAlgorithmEvent == null
|| latestLocationAlgorithmEvent.getSuggestion() == null) {
return false;
}
GeolocationTimeZoneSuggestion suggestion = latestLocationAlgorithmEvent.getSuggestion();
List<String> zoneIds = suggestion.getZoneIds();
if (zoneIds == null) {
// This means the originator of the suggestion is uncertain about the time zone. The
// existing time zone setting must be left as it is but detection can go on looking for
// a different answer elsewhere.
return false;
} else if (zoneIds.isEmpty()) {
// This means the originator is certain there is no time zone. The existing time zone
// setting must be left as it is and detection must not go looking for a different
// answer elsewhere.
return true;
}
// GeolocationTimeZoneSuggestion has no measure of quality. We assume all suggestions are
// reliable.
String zoneId;
// Introduce bias towards the device's current zone when there are multiple zone suggested.
String deviceTimeZone = mEnvironment.getDeviceTimeZone();
if (zoneIds.contains(deviceTimeZone)) {
if (DBG) {
Slog.d(LOG_TAG,
"Geo tz suggestion contains current device time zone. Applying bias.");
}
zoneId = deviceTimeZone;
} else {
zoneId = zoneIds.get(0);
}
setDeviceTimeZoneIfRequired(zoneId, detectionReason);
return true;
}
/**
* Sets the mTelephonyTimeZoneFallbackEnabled state to {@code false} if the latest location
* algorithm event contains a "certain" suggestion that comes after the time when telephony
* fallback was enabled.
*/
@GuardedBy("this")
private void disableTelephonyFallbackIfNeeded() {
LocationAlgorithmEvent latestLocationAlgorithmEvent = mLatestLocationAlgorithmEvent.get();
if (latestLocationAlgorithmEvent == null) {
return;
}
GeolocationTimeZoneSuggestion suggestion = latestLocationAlgorithmEvent.getSuggestion();
boolean isLatestSuggestionCertain = suggestion != null && suggestion.getZoneIds() != null;
if (isLatestSuggestionCertain && mTelephonyTimeZoneFallbackEnabled.getValue()) {
// This transition ONLY changes mTelephonyTimeZoneFallbackEnabled from
// true -> false. See mTelephonyTimeZoneFallbackEnabled javadocs for details.
// Telephony fallback will be disabled after a "certain" suggestion is processed
// if and only if the location information it is based on is from after telephony
// fallback was enabled.
boolean latestSuggestionIsNewerThanFallbackEnabled =
suggestion.getEffectiveFromElapsedMillis()
> mTelephonyTimeZoneFallbackEnabled.getReferenceTimeMillis();
if (latestSuggestionIsNewerThanFallbackEnabled) {
final boolean fallbackEnabled = false;
mTelephonyTimeZoneFallbackEnabled = new TimestampedValue<>(
mEnvironment.elapsedRealtimeMillis(), fallbackEnabled);
String logMsg = "disableTelephonyFallbackIfNeeded:"
+ " mTelephonyTimeZoneFallbackEnabled=" + mTelephonyTimeZoneFallbackEnabled;
logTimeZoneDebugInfo(logMsg);
}
}
}
private void logTimeZoneDebugInfo(@NonNull String logMsg) {
if (DBG) {
Slog.d(LOG_TAG, logMsg);
}
mEnvironment.addDebugLogEntry(logMsg);
}
/**
* Detects the time zone using the latest available telephony time zone suggestions.
* Finds the best available time zone suggestion from all slotIndexes. If it is high-enough
* quality and automatic time zone detection is enabled then it will be set on the device. The
* outcome can be that this strategy becomes / remains un-opinionated and nothing is set.
*/
@GuardedBy("this")
private void doTelephonyTimeZoneDetection(@NonNull String detectionReason) {
QualifiedTelephonyTimeZoneSuggestion bestTelephonySuggestion =
findBestTelephonySuggestion();
// Work out what to do with the best suggestion.
if (bestTelephonySuggestion == null) {
// There is no telephony suggestion available at all. Become un-opinionated.
if (DBG) {
Slog.d(LOG_TAG, "Could not determine time zone: No best telephony suggestion."
+ " detectionReason=" + detectionReason);
}
return;
}
boolean suggestionGoodEnough =
bestTelephonySuggestion.score >= TELEPHONY_SCORE_USAGE_THRESHOLD;
if (!suggestionGoodEnough) {
if (DBG) {
Slog.d(LOG_TAG, "Best suggestion not good enough:"
+ " bestTelephonySuggestion=" + bestTelephonySuggestion
+ ", detectionReason=" + detectionReason);
}
return;
}
// Paranoia: Every suggestion above the SCORE_USAGE_THRESHOLD should have a non-null time
// zone ID.
String zoneId = bestTelephonySuggestion.suggestion.getZoneId();
if (zoneId == null) {
Slog.w(LOG_TAG, "Empty zone suggestion scored higher than expected. This is an error:"
+ " bestTelephonySuggestion=" + bestTelephonySuggestion
+ ", detectionReason=" + detectionReason);
return;
}
String cause = "Found good suggestion:"
+ " bestTelephonySuggestion=" + bestTelephonySuggestion
+ ", detectionReason=" + detectionReason;
setDeviceTimeZoneIfRequired(zoneId, cause);
}
@GuardedBy("this")
private void setDeviceTimeZoneIfRequired(@NonNull String newZoneId, @NonNull String cause) {
String currentZoneId = mEnvironment.getDeviceTimeZone();
// All manual and automatic suggestions are considered high confidence as low-quality
// suggestions are not currently passed on.
int newConfidence = TIME_ZONE_CONFIDENCE_HIGH;
int currentConfidence = mEnvironment.getDeviceTimeZoneConfidence();
// Avoid unnecessary changes / intents. If the newConfidence is higher than the stored value
// then we want to upgrade it.
if (newZoneId.equals(currentZoneId) && newConfidence <= currentConfidence) {
// No need to modify the device time zone settings.
if (DBG) {
Slog.d(LOG_TAG, "No need to change the time zone device is already set to newZoneId"
+ ": newZoneId=" + newZoneId
+ ", cause=" + cause
+ ", currentScore=" + currentConfidence
+ ", newConfidence=" + newConfidence);
}
return;
}
String logInfo = "Set device time zone or higher confidence:"
+ " newZoneId=" + newZoneId
+ ", cause=" + cause
+ ", newConfidence=" + newConfidence;
if (DBG) {
Slog.d(LOG_TAG, logInfo);
}
mEnvironment.setDeviceTimeZoneAndConfidence(newZoneId, newConfidence, logInfo);
}
@GuardedBy("this")
@Nullable
private QualifiedTelephonyTimeZoneSuggestion findBestTelephonySuggestion() {
QualifiedTelephonyTimeZoneSuggestion bestSuggestion = null;
// Iterate over the latest QualifiedTelephonyTimeZoneSuggestion objects received for each
// slotIndex and find the best. Note that we deliberately do not look at age: the caller can
// rate-limit so age is not a strong indicator of confidence. Instead, the callers are
// expected to withdraw suggestions they no longer have confidence in.
for (int i = 0; i < mTelephonySuggestionsBySlotIndex.size(); i++) {
QualifiedTelephonyTimeZoneSuggestion candidateSuggestion =
mTelephonySuggestionsBySlotIndex.valueAt(i);
if (candidateSuggestion == null) {
// Unexpected
continue;
}
if (bestSuggestion == null) {
bestSuggestion = candidateSuggestion;
} else if (candidateSuggestion.score > bestSuggestion.score) {
bestSuggestion = candidateSuggestion;
} else if (candidateSuggestion.score == bestSuggestion.score) {
// Tie! Use the suggestion with the lowest slotIndex.
int candidateSlotIndex = candidateSuggestion.suggestion.getSlotIndex();
int bestSlotIndex = bestSuggestion.suggestion.getSlotIndex();
if (candidateSlotIndex < bestSlotIndex) {
bestSuggestion = candidateSuggestion;
}
}
}
return bestSuggestion;
}
/**
* Returns the current best telephony suggestion. Not intended for general use: it is used
* during tests to check strategy behavior.
*/
@VisibleForTesting
@Nullable
public synchronized QualifiedTelephonyTimeZoneSuggestion findBestTelephonySuggestionForTests() {
return findBestTelephonySuggestion();
}
/**
* Handles a configuration change notification.
*/
private synchronized void handleConfigurationInternalMaybeChanged() {
String logMsg = "handleConfigurationInternalMaybeChanged:";
updateCurrentConfigurationInternalIfRequired(logMsg);
}
/**
* Called whenever the information that contributes to {@link #mDetectorStatus} could have
* changed. Updates the cached status snapshot if required.
*
* @return true if the status had changed and has been updated
*/
@GuardedBy("this")
private boolean updateDetectorStatus() {
TimeZoneDetectorStatus newDetectorStatus = createTimeZoneDetectorStatus(
mCurrentConfigurationInternal, mLatestLocationAlgorithmEvent.get());
// mDetectorStatus is null the first time this method is called.
TimeZoneDetectorStatus oldDetectorStatus = mDetectorStatus;
boolean statusChanged = !newDetectorStatus.equals(oldDetectorStatus);
if (statusChanged) {
mDetectorStatus = newDetectorStatus;
}
return statusChanged;
}
/**
* Dumps internal state such as field values.
*/
@Override
public synchronized void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) {
ipw.println("TimeZoneDetectorStrategy:");
ipw.increaseIndent(); // level 1
ipw.println("mCurrentConfigurationInternal=" + mCurrentConfigurationInternal);
ipw.println("mDetectorStatus=" + mDetectorStatus);
final boolean bypassUserPolicyChecks = false;
ipw.println("[Capabilities="
+ mCurrentConfigurationInternal.asCapabilities(bypassUserPolicyChecks) + "]");
ipw.println("mEnvironment.getDeviceTimeZone()=" + mEnvironment.getDeviceTimeZone());
ipw.println("mEnvironment.getDeviceTimeZoneConfidence()="
+ mEnvironment.getDeviceTimeZoneConfidence());
ipw.println("Misc state:");
ipw.increaseIndent(); // level 2
ipw.println("mTelephonyTimeZoneFallbackEnabled="
+ formatDebugString(mTelephonyTimeZoneFallbackEnabled));
ipw.decreaseIndent(); // level 2
ipw.println("Time zone debug log:");
ipw.increaseIndent(); // level 2
mEnvironment.dumpDebugLog(ipw);
ipw.decreaseIndent(); // level 2
ipw.println("Manual suggestion history:");
ipw.increaseIndent(); // level 2
mLatestManualSuggestion.dump(ipw);
ipw.decreaseIndent(); // level 2
ipw.println("Location algorithm event history:");
ipw.increaseIndent(); // level 2
mLatestLocationAlgorithmEvent.dump(ipw);
ipw.decreaseIndent(); // level 2
ipw.println("Telephony suggestion history:");
ipw.increaseIndent(); // level 2
mTelephonySuggestionsBySlotIndex.dump(ipw);
ipw.decreaseIndent(); // level 2
ipw.decreaseIndent(); // level 1
}
/**
* A method used to inspect strategy state during tests. Not intended for general use.
*/
@VisibleForTesting
@Nullable
public synchronized ManualTimeZoneSuggestion getLatestManualSuggestion() {
return mLatestManualSuggestion.get();
}
/**
* A method used to inspect strategy state during tests. Not intended for general use.
*/
@VisibleForTesting
@Nullable
public synchronized QualifiedTelephonyTimeZoneSuggestion getLatestTelephonySuggestion(
int slotIndex) {
return mTelephonySuggestionsBySlotIndex.get(slotIndex);
}
/**
* A method used to inspect strategy state during tests. Not intended for general use.
*/
@VisibleForTesting
@Nullable
public synchronized LocationAlgorithmEvent getLatestLocationAlgorithmEvent() {
return mLatestLocationAlgorithmEvent.get();
}
@VisibleForTesting
public synchronized boolean isTelephonyFallbackEnabledForTests() {
return mTelephonyTimeZoneFallbackEnabled.getValue();
}
@VisibleForTesting
public synchronized ConfigurationInternal getCachedCapabilitiesAndConfigForTests() {
return mCurrentConfigurationInternal;
}
@VisibleForTesting
public synchronized TimeZoneDetectorStatus getCachedDetectorStatusForTests() {
return mDetectorStatus;
}
/**
* A {@link TelephonyTimeZoneSuggestion} with additional qualifying metadata.
*/
@VisibleForTesting
public static final class QualifiedTelephonyTimeZoneSuggestion {
@VisibleForTesting
public final TelephonyTimeZoneSuggestion suggestion;
/**
* The score the suggestion has been given. This can be used to rank against other
* suggestions of the same type.
*/
@VisibleForTesting
public final int score;
@VisibleForTesting
public QualifiedTelephonyTimeZoneSuggestion(
TelephonyTimeZoneSuggestion suggestion, int score) {
this.suggestion = suggestion;
this.score = score;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
QualifiedTelephonyTimeZoneSuggestion that = (QualifiedTelephonyTimeZoneSuggestion) o;
return score == that.score
&& suggestion.equals(that.suggestion);
}
@Override
public int hashCode() {
return Objects.hash(score, suggestion);
}
@Override
public String toString() {
return "QualifiedTelephonyTimeZoneSuggestion{"
+ "suggestion=" + suggestion
+ ", score=" + score
+ '}';
}
}
private static String formatDebugString(TimestampedValue<?> value) {
return value.getValue() + " @ " + Duration.ofMillis(value.getReferenceTimeMillis());
}
@NonNull
private static TimeZoneDetectorStatus createTimeZoneDetectorStatus(
@NonNull ConfigurationInternal currentConfigurationInternal,
@Nullable LocationAlgorithmEvent latestLocationAlgorithmEvent) {
int detectorStatus;
if (!currentConfigurationInternal.isAutoDetectionSupported()) {
detectorStatus = DetectorStatusTypes.DETECTOR_STATUS_NOT_SUPPORTED;
} else if (currentConfigurationInternal.getAutoDetectionEnabledBehavior()) {
detectorStatus = DetectorStatusTypes.DETECTOR_STATUS_RUNNING;
} else {
detectorStatus = DetectorStatusTypes.DETECTOR_STATUS_NOT_RUNNING;
}
TelephonyTimeZoneAlgorithmStatus telephonyAlgorithmStatus =
createTelephonyAlgorithmStatus(currentConfigurationInternal);
LocationTimeZoneAlgorithmStatus locationAlgorithmStatus = createLocationAlgorithmStatus(
currentConfigurationInternal, latestLocationAlgorithmEvent);
return new TimeZoneDetectorStatus(
detectorStatus, telephonyAlgorithmStatus, locationAlgorithmStatus);
}
@NonNull
private static LocationTimeZoneAlgorithmStatus createLocationAlgorithmStatus(
ConfigurationInternal currentConfigurationInternal,
LocationAlgorithmEvent latestLocationAlgorithmEvent) {
LocationTimeZoneAlgorithmStatus locationAlgorithmStatus;
if (latestLocationAlgorithmEvent != null) {
locationAlgorithmStatus = latestLocationAlgorithmEvent.getAlgorithmStatus();
} else if (!currentConfigurationInternal.isGeoDetectionSupported()) {
locationAlgorithmStatus = LocationTimeZoneAlgorithmStatus.NOT_SUPPORTED;
} else if (currentConfigurationInternal.isGeoDetectionExecutionEnabled()) {
locationAlgorithmStatus = LocationTimeZoneAlgorithmStatus.RUNNING_NOT_REPORTED;
} else {
locationAlgorithmStatus = LocationTimeZoneAlgorithmStatus.NOT_RUNNING;
}
return locationAlgorithmStatus;
}
@NonNull
private static TelephonyTimeZoneAlgorithmStatus createTelephonyAlgorithmStatus(
@NonNull ConfigurationInternal currentConfigurationInternal) {
int algorithmStatus;
if (!currentConfigurationInternal.isTelephonyDetectionSupported()) {
algorithmStatus = DetectorStatusTypes.DETECTION_ALGORITHM_STATUS_NOT_SUPPORTED;
} else {
// The telephony detector is passive, so we treat it as "running".
algorithmStatus = DetectorStatusTypes.DETECTION_ALGORITHM_STATUS_RUNNING;
}
return new TelephonyTimeZoneAlgorithmStatus(algorithmStatus);
}
}