From cce4dcd300efe1268e74c311807c719aa678b3f0 Mon Sep 17 00:00:00 2001 From: Neil Fuller Date: Tue, 4 Feb 2020 15:51:58 +0000 Subject: [PATCH] Correct a permission check / add a test This change corrects a permission check in TimeZoneDetectorService which is now covered by a test. A TestHandler common to the timezonedetector and timedetector classes has been extracted and made less flaky. There are some changes included to make the equivalent time zone and time classes consistent and to improve method names. Bug: 140712361 Test: atest com.android.server.timezonedetector Test: atest com.android.server.timedetector Change-Id: Ic380ede6c7276d6b80f0fc74b30bba8c89c07fcc --- .../timedetector/TimeDetectorService.java | 8 +- .../timedetector/TimeDetectorStrategy.java | 4 +- .../TimeDetectorStrategyImpl.java | 2 +- .../TimeZoneDetectorCallbackImpl.java | 4 +- .../TimeZoneDetectorService.java | 24 +- .../TimeZoneDetectorStrategy.java | 489 +---------------- .../TimeZoneDetectorStrategyImpl.java | 514 ++++++++++++++++++ .../timedetector/TimeDetectorServiceTest.java | 77 +-- .../server/timezonedetector/TestHandler.java | 76 +++ .../TimeZoneDetectorServiceTest.java | 233 ++++++++ ... => TimeZoneDetectorStrategyImplTest.java} | 29 +- 11 files changed, 899 insertions(+), 561 deletions(-) create mode 100644 services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategyImpl.java create mode 100644 services/tests/servicestests/src/com/android/server/timezonedetector/TestHandler.java create mode 100644 services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorServiceTest.java rename services/tests/servicestests/src/com/android/server/timezonedetector/{TimeZoneDetectorStrategyTest.java => TimeZoneDetectorStrategyImplTest.java} (97%) diff --git a/services/core/java/com/android/server/timedetector/TimeDetectorService.java b/services/core/java/com/android/server/timedetector/TimeDetectorService.java index b7d63609cff91..0bb0f94d12432 100644 --- a/services/core/java/com/android/server/timedetector/TimeDetectorService.java +++ b/services/core/java/com/android/server/timedetector/TimeDetectorService.java @@ -37,6 +37,9 @@ import java.io.FileDescriptor; import java.io.PrintWriter; import java.util.Objects; +/** + * The implementation of ITimeDetectorService.aidl. + */ public final class TimeDetectorService extends ITimeDetectorService.Stub { private static final String TAG = "TimeDetectorService"; @@ -75,7 +78,7 @@ public final class TimeDetectorService extends ITimeDetectorService.Stub { Settings.Global.getUriFor(Settings.Global.AUTO_TIME), true, new ContentObserver(handler) { public void onChange(boolean selfChange) { - timeDetectorService.handleAutoTimeDetectionToggle(); + timeDetectorService.handleAutoTimeDetectionChanged(); } }); @@ -114,8 +117,9 @@ public final class TimeDetectorService extends ITimeDetectorService.Stub { mHandler.post(() -> mTimeDetectorStrategy.suggestNetworkTime(timeSignal)); } + /** Internal method for handling the auto time setting being changed. */ @VisibleForTesting - public void handleAutoTimeDetectionToggle() { + public void handleAutoTimeDetectionChanged() { mHandler.post(mTimeDetectorStrategy::handleAutoTimeDetectionChanged); } diff --git a/services/core/java/com/android/server/timedetector/TimeDetectorStrategy.java b/services/core/java/com/android/server/timedetector/TimeDetectorStrategy.java index 468b806d6dceb..a7c3b4dad552e 100644 --- a/services/core/java/com/android/server/timedetector/TimeDetectorStrategy.java +++ b/services/core/java/com/android/server/timedetector/TimeDetectorStrategy.java @@ -26,8 +26,8 @@ import android.os.TimestampedValue; import java.io.PrintWriter; /** - * The interface for classes that implement the time detection algorithm used by the - * TimeDetectorService. + * The interface for the class that implements the time detection algorithm used by the + * {@link TimeDetectorService}. * *

Most calls will be handled by a single thread but that is not true for all calls. For example * {@link #dump(PrintWriter, String[])}) may be called on a different thread so implementations must diff --git a/services/core/java/com/android/server/timedetector/TimeDetectorStrategyImpl.java b/services/core/java/com/android/server/timedetector/TimeDetectorStrategyImpl.java index a1e643f15a8e9..19435ee16660b 100644 --- a/services/core/java/com/android/server/timedetector/TimeDetectorStrategyImpl.java +++ b/services/core/java/com/android/server/timedetector/TimeDetectorStrategyImpl.java @@ -38,7 +38,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** - * An implementation of TimeDetectorStrategy that passes phone and manual suggestions to + * An implementation of {@link TimeDetectorStrategy} that passes phone and manual suggestions to * {@link AlarmManager}. When there are multiple phone sources, the one with the lowest ID is used * unless the data becomes too stale. * diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java index adf6d7e51f4f7..2520316b5d543 100644 --- a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java @@ -24,9 +24,9 @@ import android.os.SystemProperties; import android.provider.Settings; /** - * The real implementation of {@link TimeZoneDetectorStrategy.Callback}. + * The real implementation of {@link TimeZoneDetectorStrategyImpl.Callback}. */ -public final class TimeZoneDetectorCallbackImpl implements TimeZoneDetectorStrategy.Callback { +public final class TimeZoneDetectorCallbackImpl implements TimeZoneDetectorStrategyImpl.Callback { private static final String TIMEZONE_PROPERTY = "persist.sys.timezone"; diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java index 9a1fe6501221a..381ee101e125f 100644 --- a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java @@ -67,19 +67,21 @@ public final class TimeZoneDetectorService extends ITimeZoneDetectorService.Stub private static TimeZoneDetectorService create(@NonNull Context context) { final TimeZoneDetectorStrategy timeZoneDetectorStrategy = - TimeZoneDetectorStrategy.create(context); + TimeZoneDetectorStrategyImpl.create(context); Handler handler = FgThread.getHandler(); + TimeZoneDetectorService service = + new TimeZoneDetectorService(context, handler, timeZoneDetectorStrategy); + ContentResolver contentResolver = context.getContentResolver(); contentResolver.registerContentObserver( Settings.Global.getUriFor(Settings.Global.AUTO_TIME_ZONE), true, new ContentObserver(handler) { public void onChange(boolean selfChange) { - timeZoneDetectorStrategy.handleAutoTimeZoneDetectionChange(); + service.handleAutoTimeZoneDetectionChanged(); } }); - - return new TimeZoneDetectorService(context, handler, timeZoneDetectorStrategy); + return service; } @VisibleForTesting @@ -111,17 +113,25 @@ public final class TimeZoneDetectorService extends ITimeZoneDetectorService.Stub @Nullable String[] args) { if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return; - mTimeZoneDetectorStrategy.dumpState(pw, args); + mTimeZoneDetectorStrategy.dump(pw, args); + } + + /** Internal method for handling the auto time zone setting being changed. */ + @VisibleForTesting + public void handleAutoTimeZoneDetectionChanged() { + mHandler.post(mTimeZoneDetectorStrategy::handleAutoTimeZoneDetectionChanged); } private void enforceSuggestPhoneTimeZonePermission() { mContext.enforceCallingPermission( - android.Manifest.permission.SET_TIME_ZONE, "set time zone"); + android.Manifest.permission.SUGGEST_PHONE_TIME_AND_ZONE, + "suggest phone time and time zone"); } private void enforceSuggestManualTimeZonePermission() { mContext.enforceCallingOrSelfPermission( - android.Manifest.permission.SET_TIME_ZONE, "set time zone"); + android.Manifest.permission.SUGGEST_MANUAL_TIME_AND_ZONE, + "suggest manual time and time zone"); } } diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java index b0e0069082312..1d439e93a1f72 100644 --- a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java @@ -15,192 +15,26 @@ */ 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.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.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. + * The interface for the class that implement the time detection algorithm used by the + * {@link TimeZoneDetectorService}. * - *

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. + *

Most calls will be handled by a single thread but that is not true for all calls. For example + * {@link #dump(PrintWriter, String[])}) may be called on a different thread so implementations must + * handle thread safety. + * + * @hide */ -public class TimeZoneDetectorStrategy { +public interface TimeZoneDetectorStrategy { - /** - * Used by {@link TimeZoneDetectorStrategy} to interact with the surrounding service. It can be - * faked for tests. - * - *

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, false /* useLocalTimestamps */); - - /** - * A mapping from slotIndex to a phone time zone suggestion. We typically expect one or two - * mappings: devices will have a small number of telephony devices and slotIndexs are assumed to - * be stable. - */ - @GuardedBy("this") - private ArrayMapWithHistory mSuggestionBySlotIndex = - new ArrayMapWithHistory<>(KEEP_PHONE_SUGGESTION_HISTORY_SIZE); - - /** - * 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); - } + /** Process the suggested manually-entered (i.e. user sourced) time zone. */ + void suggestManualTimeZone(@NonNull ManualTimeZoneSuggestion suggestion); /** * Suggests a time zone for the device, or withdraws a previous suggestion if @@ -210,312 +44,15 @@ public class TimeZoneDetectorStrategy { * 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 slotIndex. - mSuggestionBySlotIndex.put(suggestion.getSlotIndex(), scoredSuggestion); - - // 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_MANUAL; - } - - @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 < mSuggestionBySlotIndex.size(); i++) { - QualifiedPhoneTimeZoneSuggestion candidateSuggestion = - mSuggestionBySlotIndex.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 phone suggestion. Not intended for general use: it is used during - * tests to check strategy behavior. - */ - @VisibleForTesting - @Nullable - public synchronized QualifiedPhoneTimeZoneSuggestion findBestPhoneSuggestionForTests() { - return findBestPhoneSuggestion(); - } + void suggestPhoneTimeZone(@NonNull PhoneTimeZoneSuggestion suggestion); /** * 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); - } - } + void handleAutoTimeZoneDetectionChanged(); /** * Dumps internal state such as field values. */ - public synchronized void dumpState(PrintWriter pw, String[] args) { - IndentingPrintWriter ipw = new IndentingPrintWriter(pw, " "); - ipw.println("TimeZoneDetectorStrategy:"); - - ipw.increaseIndent(); // level 1 - ipw.println("mCallback.isTimeZoneDetectionEnabled()=" - + mCallback.isAutoTimeZoneDetectionEnabled()); - ipw.println("mCallback.isDeviceTimeZoneInitialized()=" - + mCallback.isDeviceTimeZoneInitialized()); - ipw.println("mCallback.getDeviceTimeZone()=" - + mCallback.getDeviceTimeZone()); - - 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 - mSuggestionBySlotIndex.dump(ipw); - ipw.decreaseIndent(); // level 2 - ipw.decreaseIndent(); // level 1 - ipw.flush(); - } - - /** - * A method used to inspect strategy state during tests. Not intended for general use. - */ - @VisibleForTesting - public synchronized QualifiedPhoneTimeZoneSuggestion getLatestPhoneSuggestion(int slotIndex) { - return mSuggestionBySlotIndex.get(slotIndex); - } - - /** - * 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 - + '}'; - } - } + void dump(PrintWriter pw, String[] args); } diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategyImpl.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategyImpl.java new file mode 100644 index 0000000000000..f85f9fe998a55 --- /dev/null +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategyImpl.java @@ -0,0 +1,514 @@ +/* + * 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.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.Objects; + +/** + * An implementation of {@link TimeZoneDetectorStrategy} that handle telephony and manual + * suggestions. Suggestions are acted on or ignored as needed, dependent on the current "auto time + * zone detection" setting. + * + *

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. + * + *

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 the surrounding service. It can + * be faked for tests. + * + *

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 TimeZoneDetectorStrategyImpl}. + */ + @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, false /* useLocalTimestamps */); + + /** + * A mapping from slotIndex to a phone time zone suggestion. We typically expect one or two + * mappings: devices will have a small number of telephony devices and slotIndexs are assumed to + * be stable. + */ + @GuardedBy("this") + private ArrayMapWithHistory mSuggestionBySlotIndex = + new ArrayMapWithHistory<>(KEEP_PHONE_SUGGESTION_HISTORY_SIZE); + + /** + * Creates a new instance of {@link TimeZoneDetectorStrategyImpl}. + */ + public static TimeZoneDetectorStrategyImpl create(Context context) { + Callback timeZoneDetectionServiceHelper = new TimeZoneDetectorCallbackImpl(context); + return new TimeZoneDetectorStrategyImpl(timeZoneDetectionServiceHelper); + } + + @VisibleForTesting + public TimeZoneDetectorStrategyImpl(Callback callback) { + mCallback = Objects.requireNonNull(callback); + } + + @Override + 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); + } + + @Override + 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 slotIndex. + mSuggestionBySlotIndex.put(suggestion.getSlotIndex(), scoredSuggestion); + + // 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_MANUAL; + } + + @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 < mSuggestionBySlotIndex.size(); i++) { + QualifiedPhoneTimeZoneSuggestion candidateSuggestion = + mSuggestionBySlotIndex.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 phone suggestion. Not intended for general use: it is used during + * tests to check strategy behavior. + */ + @VisibleForTesting + @Nullable + public synchronized QualifiedPhoneTimeZoneSuggestion findBestPhoneSuggestionForTests() { + return findBestPhoneSuggestion(); + } + + @Override + public synchronized void handleAutoTimeZoneDetectionChanged() { + 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. + */ + @Override + public synchronized void dump(PrintWriter pw, String[] args) { + IndentingPrintWriter ipw = new IndentingPrintWriter(pw, " "); + ipw.println("TimeZoneDetectorStrategy:"); + + ipw.increaseIndent(); // level 1 + ipw.println("mCallback.isTimeZoneDetectionEnabled()=" + + mCallback.isAutoTimeZoneDetectionEnabled()); + ipw.println("mCallback.isDeviceTimeZoneInitialized()=" + + mCallback.isDeviceTimeZoneInitialized()); + ipw.println("mCallback.getDeviceTimeZone()=" + + mCallback.getDeviceTimeZone()); + + 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 + mSuggestionBySlotIndex.dump(ipw); + ipw.decreaseIndent(); // level 2 + ipw.decreaseIndent(); // level 1 + ipw.flush(); + } + + /** + * A method used to inspect strategy state during tests. Not intended for general use. + */ + @VisibleForTesting + public synchronized QualifiedPhoneTimeZoneSuggestion getLatestPhoneSuggestion(int slotIndex) { + return mSuggestionBySlotIndex.get(slotIndex); + } + + /** + * 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 + + '}'; + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/timedetector/TimeDetectorServiceTest.java b/services/tests/servicestests/src/com/android/server/timedetector/TimeDetectorServiceTest.java index ae53692044280..218f43c9495de 100644 --- a/services/tests/servicestests/src/com/android/server/timedetector/TimeDetectorServiceTest.java +++ b/services/tests/servicestests/src/com/android/server/timedetector/TimeDetectorServiceTest.java @@ -33,14 +33,13 @@ import android.app.timedetector.NetworkTimeSuggestion; import android.app.timedetector.PhoneTimeSuggestion; import android.content.Context; import android.content.pm.PackageManager; -import android.os.Handler; import android.os.HandlerThread; -import android.os.Looper; -import android.os.Message; import android.os.TimestampedValue; import androidx.test.runner.AndroidJUnit4; +import com.android.server.timezonedetector.TestHandler; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -108,7 +107,7 @@ public class TimeDetectorServiceTest { eq(android.Manifest.permission.SUGGEST_PHONE_TIME_AND_ZONE), anyString()); - mTestHandler.waitForEmptyQueue(); + mTestHandler.waitForMessagesToBeProcessed(); mStubbedTimeDetectorStrategy.verifySuggestPhoneTimeCalled(phoneTimeSuggestion); } @@ -140,7 +139,7 @@ public class TimeDetectorServiceTest { eq(android.Manifest.permission.SUGGEST_MANUAL_TIME_AND_ZONE), anyString()); - mTestHandler.waitForEmptyQueue(); + mTestHandler.waitForMessagesToBeProcessed(); mStubbedTimeDetectorStrategy.verifySuggestManualTimeCalled(manualTimeSuggestion); } @@ -170,7 +169,7 @@ public class TimeDetectorServiceTest { verify(mMockContext).enforceCallingOrSelfPermission( eq(android.Manifest.permission.SET_TIME), anyString()); - mTestHandler.waitForEmptyQueue(); + mTestHandler.waitForMessagesToBeProcessed(); mStubbedTimeDetectorStrategy.verifySuggestNetworkTimeCalled(NetworkTimeSuggestion); } @@ -187,21 +186,23 @@ public class TimeDetectorServiceTest { @Test public void testAutoTimeDetectionToggle() throws Exception { - mTimeDetectorService.handleAutoTimeDetectionToggle(); + mTimeDetectorService.handleAutoTimeDetectionChanged(); mTestHandler.assertTotalMessagesEnqueued(1); - mTestHandler.waitForEmptyQueue(); - mStubbedTimeDetectorStrategy.verifyHandleAutoTimeDetectionToggleCalled(); + mTestHandler.waitForMessagesToBeProcessed(); + mStubbedTimeDetectorStrategy.verifyHandleAutoTimeDetectionChangedCalled(); - mTimeDetectorService.handleAutoTimeDetectionToggle(); + mStubbedTimeDetectorStrategy.resetCallTracking(); + + mTimeDetectorService.handleAutoTimeDetectionChanged(); mTestHandler.assertTotalMessagesEnqueued(2); - mTestHandler.waitForEmptyQueue(); - mStubbedTimeDetectorStrategy.verifyHandleAutoTimeDetectionToggleCalled(); + mTestHandler.waitForMessagesToBeProcessed(); + mStubbedTimeDetectorStrategy.verifyHandleAutoTimeDetectionChangedCalled(); } private static PhoneTimeSuggestion createPhoneTimeSuggestion() { - int phoneId = 1234; + int slotIndex = 1234; TimestampedValue timeValue = new TimestampedValue<>(100L, 1_000_000L); - return new PhoneTimeSuggestion.Builder(phoneId) + return new PhoneTimeSuggestion.Builder(slotIndex) .setUtcTime(timeValue) .build(); } @@ -222,7 +223,7 @@ public class TimeDetectorServiceTest { private PhoneTimeSuggestion mLastPhoneSuggestion; private ManualTimeSuggestion mLastManualSuggestion; private NetworkTimeSuggestion mLastNetworkSuggestion; - private boolean mLastAutoTimeDetectionToggleCalled; + private boolean mHandleAutoTimeDetectionChangedCalled; private boolean mDumpCalled; @Override @@ -231,31 +232,26 @@ public class TimeDetectorServiceTest { @Override public void suggestPhoneTime(PhoneTimeSuggestion timeSuggestion) { - resetCallTracking(); mLastPhoneSuggestion = timeSuggestion; } @Override public void suggestManualTime(ManualTimeSuggestion timeSuggestion) { - resetCallTracking(); mLastManualSuggestion = timeSuggestion; } @Override public void suggestNetworkTime(NetworkTimeSuggestion timeSuggestion) { - resetCallTracking(); mLastNetworkSuggestion = timeSuggestion; } @Override public void handleAutoTimeDetectionChanged() { - resetCallTracking(); - mLastAutoTimeDetectionToggleCalled = true; + mHandleAutoTimeDetectionChangedCalled = true; } @Override public void dump(PrintWriter pw, String[] args) { - resetCallTracking(); mDumpCalled = true; } @@ -263,7 +259,7 @@ public class TimeDetectorServiceTest { mLastPhoneSuggestion = null; mLastManualSuggestion = null; mLastNetworkSuggestion = null; - mLastAutoTimeDetectionToggleCalled = false; + mHandleAutoTimeDetectionChangedCalled = false; mDumpCalled = false; } @@ -279,45 +275,12 @@ public class TimeDetectorServiceTest { assertEquals(expectedSuggestion, mLastNetworkSuggestion); } - void verifyHandleAutoTimeDetectionToggleCalled() { - assertTrue(mLastAutoTimeDetectionToggleCalled); + void verifyHandleAutoTimeDetectionChangedCalled() { + assertTrue(mHandleAutoTimeDetectionChangedCalled); } void verifyDumpCalled() { assertTrue(mDumpCalled); } } - - /** - * A Handler that can track posts/sends and wait for work to be completed. - */ - private static class TestHandler extends Handler { - - private int mMessagesSent; - - TestHandler(Looper looper) { - super(looper); - } - - @Override - public boolean sendMessageAtTime(Message msg, long uptimeMillis) { - mMessagesSent++; - return super.sendMessageAtTime(msg, uptimeMillis); - } - - /** Asserts the number of messages posted or sent is as expected. */ - void assertTotalMessagesEnqueued(int expected) { - assertEquals(expected, mMessagesSent); - } - - /** - * Waits for all currently enqueued work due to be processed to be completed before - * returning. - */ - void waitForEmptyQueue() throws InterruptedException { - while (!getLooper().getQueue().isIdle()) { - Thread.sleep(100); - } - } - } } diff --git a/services/tests/servicestests/src/com/android/server/timezonedetector/TestHandler.java b/services/tests/servicestests/src/com/android/server/timezonedetector/TestHandler.java new file mode 100644 index 0000000000000..21c9685b05d20 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/timezonedetector/TestHandler.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2020 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 org.junit.Assert.assertEquals; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +/** + * A Handler that can track posts/sends and wait for them to be completed. + */ +public class TestHandler extends Handler { + + private final Object mMonitor = new Object(); + private int mMessagesProcessed = 0; + private int mMessagesSent = 0; + + public TestHandler(Looper looper) { + super(looper); + } + + @Override + public boolean sendMessageAtTime(Message msg, long uptimeMillis) { + synchronized (mMonitor) { + mMessagesSent++; + } + + Runnable callback = msg.getCallback(); + // Have the callback increment the mMessagesProcessed when it is done. It will notify + // any threads waiting for all messages to be processed if appropriate. + Runnable newCallback = () -> { + callback.run(); + synchronized (mMonitor) { + mMessagesProcessed++; + if (mMessagesSent == mMessagesProcessed) { + mMonitor.notifyAll(); + } + } + }; + msg.setCallback(newCallback); + return super.sendMessageAtTime(msg, uptimeMillis); + } + + /** Asserts the number of messages posted or sent is as expected. */ + public void assertTotalMessagesEnqueued(int expected) { + synchronized (mMonitor) { + assertEquals(expected, mMessagesSent); + } + } + + /** + * Waits for all enqueued work to be completed before returning. + */ + public void waitForMessagesToBeProcessed() throws InterruptedException { + synchronized (mMonitor) { + if (mMessagesSent != mMessagesProcessed) { + mMonitor.wait(); + } + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorServiceTest.java b/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorServiceTest.java new file mode 100644 index 0000000000000..3e7d40a0335ae --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorServiceTest.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2020 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 org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.timezonedetector.ManualTimeZoneSuggestion; +import android.app.timezonedetector.PhoneTimeZoneSuggestion; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.HandlerThread; + +import androidx.test.runner.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.PrintWriter; + +@RunWith(AndroidJUnit4.class) +public class TimeZoneDetectorServiceTest { + + private Context mMockContext; + private StubbedTimeZoneDetectorStrategy mStubbedTimeZoneDetectorStrategy; + + private TimeZoneDetectorService mTimeZoneDetectorService; + private HandlerThread mHandlerThread; + private TestHandler mTestHandler; + + + @Before + public void setUp() { + mMockContext = mock(Context.class); + + // Create a thread + handler for processing the work that the service posts. + mHandlerThread = new HandlerThread("TimeZoneDetectorServiceTest"); + mHandlerThread.start(); + mTestHandler = new TestHandler(mHandlerThread.getLooper()); + + mStubbedTimeZoneDetectorStrategy = new StubbedTimeZoneDetectorStrategy(); + + mTimeZoneDetectorService = new TimeZoneDetectorService( + mMockContext, mTestHandler, mStubbedTimeZoneDetectorStrategy); + } + + @After + public void tearDown() throws Exception { + mHandlerThread.quit(); + mHandlerThread.join(); + } + + @Test(expected = SecurityException.class) + public void testSuggestPhoneTime_withoutPermission() { + doThrow(new SecurityException("Mock")) + .when(mMockContext).enforceCallingPermission(anyString(), any()); + PhoneTimeZoneSuggestion timeZoneSuggestion = createPhoneTimeZoneSuggestion(); + + try { + mTimeZoneDetectorService.suggestPhoneTimeZone(timeZoneSuggestion); + fail(); + } finally { + verify(mMockContext).enforceCallingPermission( + eq(android.Manifest.permission.SUGGEST_PHONE_TIME_AND_ZONE), + anyString()); + } + } + + @Test + public void testSuggestPhoneTimeZone() throws Exception { + doNothing().when(mMockContext).enforceCallingPermission(anyString(), any()); + + PhoneTimeZoneSuggestion timeZoneSuggestion = createPhoneTimeZoneSuggestion(); + mTimeZoneDetectorService.suggestPhoneTimeZone(timeZoneSuggestion); + mTestHandler.assertTotalMessagesEnqueued(1); + + verify(mMockContext).enforceCallingPermission( + eq(android.Manifest.permission.SUGGEST_PHONE_TIME_AND_ZONE), + anyString()); + + mTestHandler.waitForMessagesToBeProcessed(); + mStubbedTimeZoneDetectorStrategy.verifySuggestPhoneTimeZoneCalled(timeZoneSuggestion); + } + + @Test(expected = SecurityException.class) + public void testSuggestManualTime_withoutPermission() { + doThrow(new SecurityException("Mock")) + .when(mMockContext).enforceCallingOrSelfPermission(anyString(), any()); + ManualTimeZoneSuggestion timeZoneSuggestion = createManualTimeZoneSuggestion(); + + try { + mTimeZoneDetectorService.suggestManualTimeZone(timeZoneSuggestion); + fail(); + } finally { + verify(mMockContext).enforceCallingOrSelfPermission( + eq(android.Manifest.permission.SUGGEST_MANUAL_TIME_AND_ZONE), + anyString()); + } + } + + @Test + public void testSuggestManualTimeZone() throws Exception { + doNothing().when(mMockContext).enforceCallingOrSelfPermission(anyString(), any()); + + ManualTimeZoneSuggestion timeZoneSuggestion = createManualTimeZoneSuggestion(); + mTimeZoneDetectorService.suggestManualTimeZone(timeZoneSuggestion); + mTestHandler.assertTotalMessagesEnqueued(1); + + verify(mMockContext).enforceCallingOrSelfPermission( + eq(android.Manifest.permission.SUGGEST_MANUAL_TIME_AND_ZONE), + anyString()); + + mTestHandler.waitForMessagesToBeProcessed(); + mStubbedTimeZoneDetectorStrategy.verifySuggestManualTimeZoneCalled(timeZoneSuggestion); + } + + @Test + public void testDump() { + when(mMockContext.checkCallingOrSelfPermission(android.Manifest.permission.DUMP)) + .thenReturn(PackageManager.PERMISSION_GRANTED); + + mTimeZoneDetectorService.dump(null, null, null); + + verify(mMockContext).checkCallingOrSelfPermission(eq(android.Manifest.permission.DUMP)); + mStubbedTimeZoneDetectorStrategy.verifyDumpCalled(); + } + + @Test + public void testAutoTimeZoneDetectionChanged() throws Exception { + mTimeZoneDetectorService.handleAutoTimeZoneDetectionChanged(); + mTestHandler.assertTotalMessagesEnqueued(1); + mTestHandler.waitForMessagesToBeProcessed(); + mStubbedTimeZoneDetectorStrategy.verifyHandleAutoTimeZoneDetectionChangedCalled(); + + mStubbedTimeZoneDetectorStrategy.resetCallTracking(); + + mTimeZoneDetectorService.handleAutoTimeZoneDetectionChanged(); + mTestHandler.assertTotalMessagesEnqueued(2); + mTestHandler.waitForMessagesToBeProcessed(); + mStubbedTimeZoneDetectorStrategy.verifyHandleAutoTimeZoneDetectionChangedCalled(); + } + + private static PhoneTimeZoneSuggestion createPhoneTimeZoneSuggestion() { + int slotIndex = 1234; + return new PhoneTimeZoneSuggestion.Builder(slotIndex) + .setZoneId("TestZoneId") + .setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET) + .setQuality(PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE) + .build(); + } + + private static ManualTimeZoneSuggestion createManualTimeZoneSuggestion() { + return new ManualTimeZoneSuggestion("TestZoneId"); + } + + private static class StubbedTimeZoneDetectorStrategy implements TimeZoneDetectorStrategy { + + // Call tracking. + private PhoneTimeZoneSuggestion mLastPhoneSuggestion; + private ManualTimeZoneSuggestion mLastManualSuggestion; + private boolean mHandleAutoTimeZoneDetectionChangedCalled; + private boolean mDumpCalled; + + @Override + public void suggestPhoneTimeZone(PhoneTimeZoneSuggestion timeZoneSuggestion) { + mLastPhoneSuggestion = timeZoneSuggestion; + } + + @Override + public void suggestManualTimeZone(ManualTimeZoneSuggestion timeZoneSuggestion) { + mLastManualSuggestion = timeZoneSuggestion; + } + + @Override + public void handleAutoTimeZoneDetectionChanged() { + mHandleAutoTimeZoneDetectionChangedCalled = true; + } + + @Override + public void dump(PrintWriter pw, String[] args) { + mDumpCalled = true; + } + + void resetCallTracking() { + mLastPhoneSuggestion = null; + mLastManualSuggestion = null; + mHandleAutoTimeZoneDetectionChangedCalled = false; + mDumpCalled = false; + } + + void verifySuggestPhoneTimeZoneCalled(PhoneTimeZoneSuggestion expectedSuggestion) { + assertEquals(expectedSuggestion, mLastPhoneSuggestion); + } + + public void verifySuggestManualTimeZoneCalled(ManualTimeZoneSuggestion expectedSuggestion) { + assertEquals(expectedSuggestion, mLastManualSuggestion); + } + + void verifyHandleAutoTimeZoneDetectionChangedCalled() { + assertTrue(mHandleAutoTimeZoneDetectionChangedCalled); + } + + void verifyDumpCalled() { + assertTrue(mDumpCalled); + } + } + +} diff --git a/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyTest.java b/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyImplTest.java similarity index 97% rename from services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyTest.java rename to services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyImplTest.java index 2429cfc1bcd0b..1e387110ed434 100644 --- a/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyTest.java +++ b/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyImplTest.java @@ -24,12 +24,12 @@ import static android.app.timezonedetector.PhoneTimeZoneSuggestion.QUALITY_MULTI import static android.app.timezonedetector.PhoneTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET; import static android.app.timezonedetector.PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE; -import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.PHONE_SCORE_HIGH; -import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.PHONE_SCORE_HIGHEST; -import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.PHONE_SCORE_LOW; -import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.PHONE_SCORE_MEDIUM; -import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.PHONE_SCORE_NONE; -import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.PHONE_SCORE_USAGE_THRESHOLD; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.PHONE_SCORE_HIGH; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.PHONE_SCORE_HIGHEST; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.PHONE_SCORE_LOW; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.PHONE_SCORE_MEDIUM; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.PHONE_SCORE_NONE; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.PHONE_SCORE_USAGE_THRESHOLD; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -41,7 +41,7 @@ import android.app.timezonedetector.PhoneTimeZoneSuggestion; import android.app.timezonedetector.PhoneTimeZoneSuggestion.MatchType; import android.app.timezonedetector.PhoneTimeZoneSuggestion.Quality; -import com.android.server.timezonedetector.TimeZoneDetectorStrategy.QualifiedPhoneTimeZoneSuggestion; +import com.android.server.timezonedetector.TimeZoneDetectorStrategyImpl.QualifiedPhoneTimeZoneSuggestion; import org.junit.Before; import org.junit.Test; @@ -52,9 +52,9 @@ import java.util.Collections; import java.util.LinkedList; /** - * White-box unit tests for {@link TimeZoneDetectorStrategy}. + * White-box unit tests for {@link TimeZoneDetectorStrategyImpl}. */ -public class TimeZoneDetectorStrategyTest { +public class TimeZoneDetectorStrategyImplTest { /** A time zone used for initialization that does not occur elsewhere in tests. */ private static final String ARBITRARY_TIME_ZONE_ID = "Etc/UTC"; @@ -78,14 +78,14 @@ public class TimeZoneDetectorStrategyTest { newTestCase(MATCH_TYPE_EMULATOR_ZONE_ID, QUALITY_SINGLE_ZONE, PHONE_SCORE_HIGHEST), }; - private TimeZoneDetectorStrategy mTimeZoneDetectorStrategy; + private TimeZoneDetectorStrategyImpl mTimeZoneDetectorStrategy; private FakeTimeZoneDetectorStrategyCallback mFakeTimeZoneDetectorStrategyCallback; @Before public void setUp() { mFakeTimeZoneDetectorStrategyCallback = new FakeTimeZoneDetectorStrategyCallback(); mTimeZoneDetectorStrategy = - new TimeZoneDetectorStrategy(mFakeTimeZoneDetectorStrategyCallback); + new TimeZoneDetectorStrategyImpl(mFakeTimeZoneDetectorStrategyCallback); } @Test @@ -364,7 +364,7 @@ public class TimeZoneDetectorStrategyTest { } /** - * The {@link TimeZoneDetectorStrategy.Callback} is left to detect whether changing the time + * The {@link TimeZoneDetectorStrategyImpl.Callback} is left to detect whether changing the time * zone is actually necessary. This test proves that the service doesn't assume it knows the * current setting. */ @@ -441,7 +441,8 @@ public class TimeZoneDetectorStrategyTest { return new PhoneTimeZoneSuggestion.Builder(PHONE2_ID).build(); } - static class FakeTimeZoneDetectorStrategyCallback implements TimeZoneDetectorStrategy.Callback { + static class FakeTimeZoneDetectorStrategyCallback + implements TimeZoneDetectorStrategyImpl.Callback { private boolean mAutoTimeZoneDetectionEnabled; private TestState mTimeZoneId = new TestState<>(); @@ -560,7 +561,7 @@ public class TimeZoneDetectorStrategyTest { Script autoTimeZoneDetectionEnabled(boolean enabled) { mFakeTimeZoneDetectorStrategyCallback.setAutoTimeZoneDetectionEnabled(enabled); - mTimeZoneDetectorStrategy.handleAutoTimeZoneDetectionChange(); + mTimeZoneDetectorStrategy.handleAutoTimeZoneDetectionChanged(); return this; }