| /* |
| * 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.timezonedetector.PhoneTimeZoneSuggestion.MATCH_TYPE_EMULATOR_ZONE_ID; |
| import static android.app.timezonedetector.PhoneTimeZoneSuggestion.MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY; |
| import static android.app.timezonedetector.PhoneTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS; |
| import static android.app.timezonedetector.PhoneTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET; |
| import static android.app.timezonedetector.PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE; |
| |
| import android.annotation.IntDef; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.timezonedetector.ManualTimeZoneSuggestion; |
| import android.app.timezonedetector.PhoneTimeZoneSuggestion; |
| import android.content.Context; |
| import android.util.ArrayMap; |
| import android.util.LocalLog; |
| import android.util.Slog; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.util.IndentingPrintWriter; |
| |
| import java.io.PrintWriter; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.LinkedList; |
| import java.util.Map; |
| import java.util.Objects; |
| |
| /** |
| * A singleton, stateful time zone detection strategy that is aware of user (manual) suggestions and |
| * suggestions from multiple phone devices. Suggestions are acted on or ignored as needed, dependent |
| * on the current "auto time zone detection" setting. |
| * |
| * <p>For automatic detection it keeps track of the most recent suggestion from each phone it uses |
| * the best suggestion based on a scoring algorithm. If several phones provide the same score then |
| * the phone with the lowest numeric ID "wins". If the situation changes and it is no longer |
| * possible to be confident about the time zone, phones must submit an empty suggestion in order to |
| * "withdraw" their previous suggestion. |
| */ |
| public class TimeZoneDetectorStrategy { |
| |
| /** |
| * Used by {@link TimeZoneDetectorStrategy} to interact with the surrounding service. It can be |
| * faked for tests. |
| * |
| * <p>Note: Because the system properties-derived values like |
| * {@link #isAutoTimeZoneDetectionEnabled()}, {@link #isAutoTimeZoneDetectionEnabled()}, |
| * {@link #getDeviceTimeZone()} can be modified independently and from different threads (and |
| * processes!), their use are prone to race conditions. That will be true until the |
| * responsibility for setting their values is moved to {@link TimeZoneDetectorStrategy}. |
| */ |
| @VisibleForTesting |
| public interface Callback { |
| |
| /** |
| * Returns true if automatic time zone detection is enabled in settings. |
| */ |
| boolean isAutoTimeZoneDetectionEnabled(); |
| |
| /** |
| * Returns true if the device has had an explicit time zone set. |
| */ |
| boolean isDeviceTimeZoneInitialized(); |
| |
| /** |
| * Returns the device's currently configured time zone. |
| */ |
| String getDeviceTimeZone(); |
| |
| /** |
| * Sets the device's time zone. |
| */ |
| void setDeviceTimeZone(@NonNull String zoneId); |
| } |
| |
| private static final String LOG_TAG = "TimeZoneDetectorStrategy"; |
| private static final boolean DBG = false; |
| |
| @IntDef({ ORIGIN_PHONE, ORIGIN_MANUAL }) |
| @Retention(RetentionPolicy.SOURCE) |
| public @interface Origin {} |
| |
| /** Used when a time value originated from a telephony signal. */ |
| @Origin |
| private static final int ORIGIN_PHONE = 1; |
| |
| /** Used when a time value originated from a user / manual settings. */ |
| @Origin |
| private static final int ORIGIN_MANUAL = 2; |
| |
| /** |
| * The abstract score for an empty or invalid phone suggestion. |
| * |
| * Used to score phone suggestions where there is no zone. |
| */ |
| @VisibleForTesting |
| public static final int PHONE_SCORE_NONE = 0; |
| |
| /** |
| * The abstract score for a low quality phone 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 PHONE_SCORE_LOW = 1; |
| |
| /** |
| * The abstract score for a medium quality phone 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 PHONE_SCORE_MEDIUM = 2; |
| |
| /** |
| * The abstract score for a high quality phone 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 PHONE_SCORE_HIGH = 3; |
| |
| /** |
| * The abstract score for a highest quality phone suggestion. |
| * |
| * Used for: |
| * Suggestions that must "win" because they constitute test or emulator zone ID. |
| */ |
| @VisibleForTesting |
| public static final int PHONE_SCORE_HIGHEST = 4; |
| |
| /** |
| * The threshold at which phone suggestions are good enough to use to set the device's time |
| * zone. |
| */ |
| @VisibleForTesting |
| public static final int PHONE_SCORE_USAGE_THRESHOLD = PHONE_SCORE_MEDIUM; |
| |
| /** The number of previous phone suggestions to keep for each ID (for use during debugging). */ |
| private static final int KEEP_PHONE_SUGGESTION_HISTORY_SIZE = 30; |
| |
| @NonNull |
| private final Callback mCallback; |
| |
| /** |
| * A log that records the decisions / decision metadata that affected the device's time zone |
| * (for use during debugging). |
| */ |
| @NonNull |
| private final LocalLog mTimeZoneChangesLog = new LocalLog(30); |
| |
| /** |
| * A mapping from phoneId to a linked list of phone time zone suggestions (the head being the |
| * latest). We typically expect one or two entries in this Map: devices will have a small number |
| * of telephony devices and phoneIds are assumed to be stable. The LinkedList associated with |
| * the ID will not exceed {@link #KEEP_PHONE_SUGGESTION_HISTORY_SIZE} in size. |
| */ |
| @GuardedBy("this") |
| private ArrayMap<Integer, LinkedList<QualifiedPhoneTimeZoneSuggestion>> mSuggestionByPhoneId = |
| new ArrayMap<>(); |
| |
| /** |
| * Creates a new instance of {@link TimeZoneDetectorStrategy}. |
| */ |
| public static TimeZoneDetectorStrategy create(Context context) { |
| Callback timeZoneDetectionServiceHelper = new TimeZoneDetectorCallbackImpl(context); |
| return new TimeZoneDetectorStrategy(timeZoneDetectionServiceHelper); |
| } |
| |
| @VisibleForTesting |
| public TimeZoneDetectorStrategy(Callback callback) { |
| mCallback = Objects.requireNonNull(callback); |
| } |
| |
| /** Process the suggested manually- / user-entered time zone. */ |
| public synchronized void suggestManualTimeZone(@NonNull ManualTimeZoneSuggestion suggestion) { |
| Objects.requireNonNull(suggestion); |
| |
| String timeZoneId = suggestion.getZoneId(); |
| String cause = "Manual time suggestion received: suggestion=" + suggestion; |
| setDeviceTimeZoneIfRequired(ORIGIN_MANUAL, timeZoneId, cause); |
| } |
| |
| /** |
| * Suggests a time zone for the device, or withdraws a previous suggestion if |
| * {@link PhoneTimeZoneSuggestion#getZoneId()} is {@code null}. The suggestion is scoped to a |
| * specific {@link PhoneTimeZoneSuggestion#getPhoneId() phone}. |
| * See {@link PhoneTimeZoneSuggestion} for an explanation of the metadata associated with a |
| * suggestion. The strategy uses suggestions to decide whether to modify the device's time zone |
| * setting and what to set it to. |
| */ |
| public synchronized void suggestPhoneTimeZone(@NonNull PhoneTimeZoneSuggestion suggestion) { |
| if (DBG) { |
| Slog.d(LOG_TAG, "Phone suggestion received. newSuggestion=" + suggestion); |
| } |
| Objects.requireNonNull(suggestion); |
| |
| // Score the suggestion. |
| int score = scorePhoneSuggestion(suggestion); |
| QualifiedPhoneTimeZoneSuggestion scoredSuggestion = |
| new QualifiedPhoneTimeZoneSuggestion(suggestion, score); |
| |
| // Store the suggestion against the correct phoneId. |
| LinkedList<QualifiedPhoneTimeZoneSuggestion> suggestions = |
| mSuggestionByPhoneId.get(suggestion.getPhoneId()); |
| if (suggestions == null) { |
| suggestions = new LinkedList<>(); |
| mSuggestionByPhoneId.put(suggestion.getPhoneId(), suggestions); |
| } |
| suggestions.addFirst(scoredSuggestion); |
| if (suggestions.size() > KEEP_PHONE_SUGGESTION_HISTORY_SIZE) { |
| suggestions.removeLast(); |
| } |
| |
| // Now perform auto time zone detection. The new suggestion may be used to modify the time |
| // zone setting. |
| String reason = "New phone time suggested. suggestion=" + suggestion; |
| doAutoTimeZoneDetection(reason); |
| } |
| |
| private static int scorePhoneSuggestion(@NonNull PhoneTimeZoneSuggestion suggestion) { |
| int score; |
| if (suggestion.getZoneId() == null) { |
| score = PHONE_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 = PHONE_SCORE_HIGHEST; |
| } else if (suggestion.getQuality() == QUALITY_SINGLE_ZONE) { |
| score = PHONE_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 = PHONE_SCORE_MEDIUM; |
| } else if (suggestion.getQuality() == QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS) { |
| // The suggestion has a good chance of being wrong. |
| score = PHONE_SCORE_LOW; |
| } else { |
| throw new AssertionError(); |
| } |
| return score; |
| } |
| |
| /** |
| * Finds the best available time zone suggestion from all phones. 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 doAutoTimeZoneDetection(@NonNull String detectionReason) { |
| if (!mCallback.isAutoTimeZoneDetectionEnabled()) { |
| // Avoid doing unnecessary work with this (race-prone) check. |
| return; |
| } |
| |
| QualifiedPhoneTimeZoneSuggestion bestPhoneSuggestion = findBestPhoneSuggestion(); |
| |
| // Work out what to do with the best suggestion. |
| if (bestPhoneSuggestion == null) { |
| // There is no phone suggestion available at all. Become un-opinionated. |
| if (DBG) { |
| Slog.d(LOG_TAG, "Could not determine time zone: No best phone suggestion." |
| + " detectionReason=" + detectionReason); |
| } |
| return; |
| } |
| |
| // Special case handling for uninitialized devices. This should only happen once. |
| String newZoneId = bestPhoneSuggestion.suggestion.getZoneId(); |
| if (newZoneId != null && !mCallback.isDeviceTimeZoneInitialized()) { |
| String cause = "Device has no time zone set. Attempting to set the device to the best" |
| + " available suggestion." |
| + " bestPhoneSuggestion=" + bestPhoneSuggestion |
| + ", detectionReason=" + detectionReason; |
| Slog.i(LOG_TAG, cause); |
| setDeviceTimeZoneIfRequired(ORIGIN_PHONE, newZoneId, cause); |
| return; |
| } |
| |
| boolean suggestionGoodEnough = bestPhoneSuggestion.score >= PHONE_SCORE_USAGE_THRESHOLD; |
| if (!suggestionGoodEnough) { |
| if (DBG) { |
| Slog.d(LOG_TAG, "Best suggestion not good enough." |
| + " bestPhoneSuggestion=" + bestPhoneSuggestion |
| + ", detectionReason=" + detectionReason); |
| } |
| return; |
| } |
| |
| // Paranoia: Every suggestion above the SCORE_USAGE_THRESHOLD should have a non-null time |
| // zone ID. |
| if (newZoneId == null) { |
| Slog.w(LOG_TAG, "Empty zone suggestion scored higher than expected. This is an error:" |
| + " bestPhoneSuggestion=" + bestPhoneSuggestion |
| + " detectionReason=" + detectionReason); |
| return; |
| } |
| |
| String zoneId = bestPhoneSuggestion.suggestion.getZoneId(); |
| String cause = "Found good suggestion." |
| + ", bestPhoneSuggestion=" + bestPhoneSuggestion |
| + ", detectionReason=" + detectionReason; |
| setDeviceTimeZoneIfRequired(ORIGIN_PHONE, zoneId, cause); |
| } |
| |
| @GuardedBy("this") |
| private void setDeviceTimeZoneIfRequired( |
| @Origin int origin, @NonNull String newZoneId, @NonNull String cause) { |
| Objects.requireNonNull(newZoneId); |
| Objects.requireNonNull(cause); |
| |
| boolean isOriginAutomatic = isOriginAutomatic(origin); |
| if (isOriginAutomatic) { |
| if (!mCallback.isAutoTimeZoneDetectionEnabled()) { |
| if (DBG) { |
| Slog.d(LOG_TAG, "Auto time zone detection is not enabled." |
| + " origin=" + origin |
| + ", newZoneId=" + newZoneId |
| + ", cause=" + cause); |
| } |
| return; |
| } |
| } else { |
| if (mCallback.isAutoTimeZoneDetectionEnabled()) { |
| if (DBG) { |
| Slog.d(LOG_TAG, "Auto time zone detection is enabled." |
| + " origin=" + origin |
| + ", newZoneId=" + newZoneId |
| + ", cause=" + cause); |
| } |
| return; |
| } |
| } |
| |
| String currentZoneId = mCallback.getDeviceTimeZone(); |
| |
| // Avoid unnecessary changes / intents. |
| if (newZoneId.equals(currentZoneId)) { |
| // No need to set the device time zone - the setting is already what we would be |
| // suggesting. |
| if (DBG) { |
| Slog.d(LOG_TAG, "No need to change the time zone;" |
| + " device is already set to the suggested zone." |
| + " origin=" + origin |
| + ", newZoneId=" + newZoneId |
| + ", cause=" + cause); |
| } |
| return; |
| } |
| |
| mCallback.setDeviceTimeZone(newZoneId); |
| String msg = "Set device time zone." |
| + " origin=" + origin |
| + ", currentZoneId=" + currentZoneId |
| + ", newZoneId=" + newZoneId |
| + ", cause=" + cause; |
| if (DBG) { |
| Slog.d(LOG_TAG, msg); |
| } |
| mTimeZoneChangesLog.log(msg); |
| } |
| |
| private static boolean isOriginAutomatic(@Origin int origin) { |
| return origin == ORIGIN_PHONE; |
| } |
| |
| @GuardedBy("this") |
| @Nullable |
| private QualifiedPhoneTimeZoneSuggestion findBestPhoneSuggestion() { |
| QualifiedPhoneTimeZoneSuggestion bestSuggestion = null; |
| |
| // Iterate over the latest QualifiedPhoneTimeZoneSuggestion objects received for each phone |
| // 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 < mSuggestionByPhoneId.size(); i++) { |
| LinkedList<QualifiedPhoneTimeZoneSuggestion> phoneSuggestions = |
| mSuggestionByPhoneId.valueAt(i); |
| if (phoneSuggestions == null) { |
| // Unexpected |
| continue; |
| } |
| QualifiedPhoneTimeZoneSuggestion candidateSuggestion = phoneSuggestions.getFirst(); |
| 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 phoneId. |
| int candidatePhoneId = candidateSuggestion.suggestion.getPhoneId(); |
| int bestPhoneId = bestSuggestion.suggestion.getPhoneId(); |
| if (candidatePhoneId < bestPhoneId) { |
| bestSuggestion = candidateSuggestion; |
| } |
| } |
| } |
| return bestSuggestion; |
| } |
| |
| /** |
| * Returns the current best phone suggestion. Not intended for general use: it is used during |
| * tests to check strategy behavior. |
| */ |
| @VisibleForTesting |
| @Nullable |
| public synchronized QualifiedPhoneTimeZoneSuggestion findBestPhoneSuggestionForTests() { |
| return findBestPhoneSuggestion(); |
| } |
| |
| /** |
| * Called when there has been a change to the automatic time zone detection setting. |
| */ |
| @VisibleForTesting |
| public synchronized void handleAutoTimeZoneDetectionChange() { |
| if (DBG) { |
| Slog.d(LOG_TAG, "handleTimeZoneDetectionChange() called"); |
| } |
| if (mCallback.isAutoTimeZoneDetectionEnabled()) { |
| // When the user enabled time zone detection, run the time zone detection and change the |
| // device time zone if possible. |
| String reason = "Auto time zone detection setting enabled."; |
| doAutoTimeZoneDetection(reason); |
| } |
| } |
| |
| /** |
| * Dumps internal state such as field values. |
| */ |
| public synchronized void dumpState(PrintWriter pw, String[] args) { |
| pw.println("TimeZoneDetectorStrategy:"); |
| pw.println("mCallback.isTimeZoneDetectionEnabled()=" |
| + mCallback.isAutoTimeZoneDetectionEnabled()); |
| pw.println("mCallback.isDeviceTimeZoneInitialized()=" |
| + mCallback.isDeviceTimeZoneInitialized()); |
| pw.println("mCallback.getDeviceTimeZone()=" |
| + mCallback.getDeviceTimeZone()); |
| |
| IndentingPrintWriter ipw = new IndentingPrintWriter(pw, " "); |
| ipw.println("Time zone change log:"); |
| ipw.increaseIndent(); // level 2 |
| mTimeZoneChangesLog.dump(ipw); |
| ipw.decreaseIndent(); // level 2 |
| |
| ipw.println("Phone suggestion history:"); |
| ipw.increaseIndent(); // level 2 |
| for (Map.Entry<Integer, LinkedList<QualifiedPhoneTimeZoneSuggestion>> entry |
| : mSuggestionByPhoneId.entrySet()) { |
| ipw.println("Phone " + entry.getKey()); |
| |
| ipw.increaseIndent(); // level 3 |
| for (QualifiedPhoneTimeZoneSuggestion suggestion : entry.getValue()) { |
| ipw.println(suggestion); |
| } |
| ipw.decreaseIndent(); // level 3 |
| } |
| ipw.decreaseIndent(); // level 2 |
| ipw.decreaseIndent(); // level 1 |
| ipw.flush(); |
| |
| pw.flush(); |
| } |
| |
| /** |
| * A method used to inspect strategy state during tests. Not intended for general use. |
| */ |
| @VisibleForTesting |
| public synchronized QualifiedPhoneTimeZoneSuggestion getLatestPhoneSuggestion(int phoneId) { |
| LinkedList<QualifiedPhoneTimeZoneSuggestion> suggestions = |
| mSuggestionByPhoneId.get(phoneId); |
| if (suggestions == null) { |
| return null; |
| } |
| return suggestions.getFirst(); |
| } |
| |
| /** |
| * A {@link PhoneTimeZoneSuggestion} with additional qualifying metadata. |
| */ |
| @VisibleForTesting |
| public static class QualifiedPhoneTimeZoneSuggestion { |
| |
| @VisibleForTesting |
| public final PhoneTimeZoneSuggestion 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 QualifiedPhoneTimeZoneSuggestion(PhoneTimeZoneSuggestion 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; |
| } |
| QualifiedPhoneTimeZoneSuggestion that = (QualifiedPhoneTimeZoneSuggestion) o; |
| return score == that.score |
| && suggestion.equals(that.suggestion); |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(score, suggestion); |
| } |
| |
| @Override |
| public String toString() { |
| return "QualifiedPhoneTimeZoneSuggestion{" |
| + "suggestion=" + suggestion |
| + ", score=" + score |
| + '}'; |
| } |
| } |
| } |