diff options
Diffstat (limited to 'src/java/com/android/internal/telephony/nitz/service/TimeZoneDetectionService.java')
-rw-r--r-- | src/java/com/android/internal/telephony/nitz/service/TimeZoneDetectionService.java | 489 |
1 files changed, 489 insertions, 0 deletions
diff --git a/src/java/com/android/internal/telephony/nitz/service/TimeZoneDetectionService.java b/src/java/com/android/internal/telephony/nitz/service/TimeZoneDetectionService.java new file mode 100644 index 0000000000..a766d9a415 --- /dev/null +++ b/src/java/com/android/internal/telephony/nitz/service/TimeZoneDetectionService.java @@ -0,0 +1,489 @@ +/* + * 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.internal.telephony.nitz.service; + +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.EMULATOR_ZONE_ID; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.MATCH_TYPE_NA; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.MULTIPLE_ZONES_WITH_SAME_OFFSET; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.NETWORK_COUNTRY_AND_OFFSET; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.NETWORK_COUNTRY_ONLY; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.QUALITY_NA; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.SINGLE_ZONE; +import static com.android.internal.telephony.nitz.service.PhoneTimeZoneSuggestion.TEST_NETWORK_OFFSET_ONLY; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.util.ArrayMap; +import android.util.Log; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.IndentingPrintWriter; + +import java.io.PrintWriter; +import java.util.LinkedList; +import java.util.Objects; + +/** + * A singleton, stateful time zone detection service that is aware of multiple phone devices. It + * keeps track of the most recent suggestion from each phone and it uses the best based on a scoring + * algorithm. If both 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. + * + * <p>Ultimately, this responsibility will be moved to system server and then it will be extended / + * rewritten to handle non-telephony time zone signals. + */ +public class TimeZoneDetectionService { + + /** + * Used by {@link TimeZoneDetectionService} to interact with device settings. It can be faked + * for tests. + */ + @VisibleForTesting + public interface Helper { + + /** + * Callback interface for automatic detection enable/disable changes. + */ + interface Listener { + /** + * Automatic time zone detection has been enabled or disabled. + */ + void onTimeZoneDetectionChange(boolean enabled); + } + + /** + * Sets a listener that will be called when the automatic time / time zone detection setting + * changes. + */ + void setListener(Listener listener); + + /** + * Returns true if automatic time zone detection is enabled in settings. + */ + boolean isTimeZoneDetectionEnabled(); + + /** + * Returns true if the device has had an explicit time zone set. + */ + boolean isTimeZoneSettingInitialized(); + + /** + * Set the device time zone from the suggestion as needed. + */ + void setDeviceTimeZoneFromSuggestion(@NonNull PhoneTimeZoneSuggestion timeZoneSuggestion); + + /** + * Dumps any logs held to the supplied writer. + */ + void dumpLogs(IndentingPrintWriter ipw); + + /** + * Dumps internal state such as field values. + */ + void dumpState(PrintWriter pw); + } + + static final String LOG_TAG = "TimeZoneDetectionService"; + static final boolean DBG = true; + + /** + * The abstract score for an empty or invalid suggestion. + * + * Used to score suggestions where there is no zone. + */ + @VisibleForTesting + public static final int SCORE_NONE = 0; + + /** + * The abstract score for a low quality 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 SCORE_LOW = 1; + + /** + * The abstract score for a medium quality 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 SCORE_MEDIUM = 2; + + /** + * The abstract score for a high quality 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 SCORE_HIGH = 3; + + /** + * The abstract score for a highest quality suggestion. + * + * Used for: + * Suggestions that must "win" because they constitute test or emulator zone ID. + */ + @VisibleForTesting + public static final int SCORE_HIGHEST = 4; + + /** The threshold at which suggestions are good enough to use to set the device's time zone. */ + @VisibleForTesting + public static final int SCORE_USAGE_THRESHOLD = SCORE_MEDIUM; + + /** The singleton instance. */ + private static TimeZoneDetectionService sInstance; + + /** + * Returns the singleton instance, constructing as needed with the supplied context. + */ + public static synchronized TimeZoneDetectionService getInstance(Context context) { + if (sInstance == null) { + Helper timeZoneDetectionServiceHelper = new TimeZoneDetectionServiceHelperImpl(context); + sInstance = new TimeZoneDetectionService(timeZoneDetectionServiceHelper); + } + return sInstance; + } + + private static final int KEEP_SUGGESTION_HISTORY_SIZE = 30; + + /** + * A mapping from phoneId to a linked list of 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_SUGGESTION_HISTORY_SIZE} in size. + */ + @GuardedBy("this") + private ArrayMap<Integer, LinkedList<QualifiedPhoneTimeZoneSuggestion>> mSuggestionByPhoneId = + new ArrayMap<>(); + + /** + * The most recent best guess of time zone from all phones. Can be {@code null} to indicate + * there would be no current suggestion. + */ + @GuardedBy("this") + @Nullable + private QualifiedPhoneTimeZoneSuggestion mCurrentSuggestion; + + // Dependencies and log state. + private final Helper mTimeZoneDetectionServiceHelper; + + @VisibleForTesting + public TimeZoneDetectionService(Helper timeZoneDetectionServiceHelper) { + mTimeZoneDetectionServiceHelper = timeZoneDetectionServiceHelper; + mTimeZoneDetectionServiceHelper.setListener(enabled -> { + if (enabled) { + handleAutoTimeZoneEnabled(); + } + }); + } + + /** + * 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 service 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 newSuggestion) { + if (DBG) { + Log.d(LOG_TAG, "suggestPhoneTimeZone: newSuggestion=" + newSuggestion); + } + Objects.requireNonNull(newSuggestion); + + int score = scoreSuggestion(newSuggestion); + QualifiedPhoneTimeZoneSuggestion scoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(newSuggestion, score); + + // Record the suggestion against the correct phoneId. + LinkedList<QualifiedPhoneTimeZoneSuggestion> suggestions = + mSuggestionByPhoneId.get(newSuggestion.getPhoneId()); + if (suggestions == null) { + suggestions = new LinkedList<>(); + mSuggestionByPhoneId.put(newSuggestion.getPhoneId(), suggestions); + } + suggestions.addFirst(scoredSuggestion); + if (suggestions.size() > KEEP_SUGGESTION_HISTORY_SIZE) { + suggestions.removeLast(); + } + + // Now run the competition between the phones' suggestions. + doTimeZoneDetection(); + } + + private static int scoreSuggestion(@NonNull PhoneTimeZoneSuggestion suggestion) { + int score; + if (suggestion.getZoneId() == null || !isValid(suggestion)) { + score = SCORE_NONE; + } else if (suggestion.getMatchType() == TEST_NETWORK_OFFSET_ONLY + || suggestion.getMatchType() == EMULATOR_ZONE_ID) { + // Handle emulator / test cases : These suggestions should always just be used. + score = SCORE_HIGHEST; + } else if (suggestion.getQuality() == SINGLE_ZONE) { + score = SCORE_HIGH; + } else if (suggestion.getQuality() == MULTIPLE_ZONES_WITH_SAME_OFFSET) { + // The suggestion may be wrong, but at least the offset should be correct. + score = SCORE_MEDIUM; + } else if (suggestion.getQuality() == MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS) { + // The suggestion has a good chance of being wrong. + score = SCORE_LOW; + } else { + throw new AssertionError(); + } + return score; + } + + private static boolean isValid(@NonNull PhoneTimeZoneSuggestion suggestion) { + int quality = suggestion.getQuality(); + int matchType = suggestion.getMatchType(); + if (suggestion.getZoneId() == null) { + return quality == QUALITY_NA && matchType == MATCH_TYPE_NA; + } else { + boolean qualityValid = quality == SINGLE_ZONE + || quality == MULTIPLE_ZONES_WITH_SAME_OFFSET + || quality == MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS; + boolean matchTypeValid = matchType == NETWORK_COUNTRY_ONLY + || matchType == NETWORK_COUNTRY_AND_OFFSET + || matchType == EMULATOR_ZONE_ID + || matchType == TEST_NETWORK_OFFSET_ONLY; + return qualityValid && matchTypeValid; + } + } + + /** + * 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 service becomes / remains un-opinionated and nothing is set. + */ + @GuardedBy("this") + private void doTimeZoneDetection() { + QualifiedPhoneTimeZoneSuggestion bestSuggestion = findBestSuggestion(); + boolean timeZoneDetectionEnabled = + mTimeZoneDetectionServiceHelper.isTimeZoneDetectionEnabled(); + + // Work out what to do with the best suggestion. + if (bestSuggestion == null) { + // There is no suggestion. Become un-opinionated. + if (DBG) { + Log.d(LOG_TAG, "doTimeZoneDetection: No good suggestion." + + " bestSuggestion=null" + + ", timeZoneDetectionEnabled=" + timeZoneDetectionEnabled); + } + mCurrentSuggestion = null; + return; + } + + // Special case handling for uninitialized devices. This should only happen once. + String newZoneId = bestSuggestion.suggestion.getZoneId(); + if (newZoneId != null && !mTimeZoneDetectionServiceHelper.isTimeZoneSettingInitialized()) { + Log.i(LOG_TAG, "doTimeZoneDetection: Device has no time zone set so might set the" + + " device to the best available suggestion." + + " bestSuggestion=" + bestSuggestion + + ", timeZoneDetectionEnabled=" + timeZoneDetectionEnabled); + + mCurrentSuggestion = bestSuggestion; + if (timeZoneDetectionEnabled) { + mTimeZoneDetectionServiceHelper.setDeviceTimeZoneFromSuggestion( + bestSuggestion.suggestion); + } + return; + } + + boolean suggestionGoodEnough = bestSuggestion.score >= SCORE_USAGE_THRESHOLD; + if (!suggestionGoodEnough) { + if (DBG) { + Log.d(LOG_TAG, "doTimeZoneDetection: Suggestion not good enough." + + " bestSuggestion=" + bestSuggestion); + } + mCurrentSuggestion = null; + return; + } + + // Paranoia: Every suggestion above the SCORE_USAGE_THRESHOLD should have a non-null time + // zone ID. + if (newZoneId == null) { + Log.w(LOG_TAG, "Empty zone suggestion scored higher than expected. This is an error:" + + " bestSuggestion=" + bestSuggestion); + mCurrentSuggestion = null; + return; + } + + // There is a good suggestion. Store the suggestion and set the device time zone if + // settings allow. + mCurrentSuggestion = bestSuggestion; + + // Only set the device time zone if time zone detection is enabled. + if (!timeZoneDetectionEnabled) { + if (DBG) { + Log.d(LOG_TAG, "doTimeZoneDetection: Not setting the time zone because time zone" + + " detection is disabled." + + " bestSuggestion=" + bestSuggestion); + } + return; + } + mTimeZoneDetectionServiceHelper.setDeviceTimeZoneFromSuggestion(bestSuggestion.suggestion); + } + + @GuardedBy("this") + @Nullable + private QualifiedPhoneTimeZoneSuggestion findBestSuggestion() { + 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 suggestion. Not intended for general use: it is used during tests + * to check service behavior. + */ + @VisibleForTesting + @Nullable + public synchronized QualifiedPhoneTimeZoneSuggestion findBestSuggestionForTests() { + return findBestSuggestion(); + } + + private synchronized void handleAutoTimeZoneEnabled() { + if (DBG) { + Log.d(LOG_TAG, "handleAutoTimeEnabled() called"); + } + // When the user enabled time zone detection, run the time zone detection and change the + // device time zone if possible. + doTimeZoneDetection(); + } + + /** + * Dumps any logs held to the supplied writer. + */ + public void dumpLogs(IndentingPrintWriter ipw) { + mTimeZoneDetectionServiceHelper.dumpLogs(ipw); + } + + /** + * Dumps internal state such as field values. + */ + public void dumpState(PrintWriter pw) { + pw.println(" TimeZoneDetectionService.mCurrentSuggestion=" + mCurrentSuggestion); + pw.println(" TimeZoneDetectionService.mSuggestionsByPhoneId=" + mSuggestionByPhoneId); + mTimeZoneDetectionServiceHelper.dumpState(pw); + pw.flush(); + } + + /** + * A method used to inspect service 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 + + '}'; + } + } +} |