diff options
| author | 2019-11-28 13:00:28 +0000 | |
|---|---|---|
| committer | 2019-11-28 13:00:28 +0000 | |
| commit | 8c6790cc7ef208a3e976bc5f239e9de1ba8aef39 (patch) | |
| tree | 0f87a2087de5f85762e878d92e936977c5053e08 | |
| parent | 69e5e282be2c172105b25de19189a5dacd75f3e4 (diff) | |
| parent | 3e3b5405b6c5e77a640ad9450eb1cac5b7c80ff1 (diff) | |
Merge "Add a new time zone detection service"
13 files changed, 1953 insertions, 1 deletions
diff --git a/core/java/android/app/SystemServiceRegistry.java b/core/java/android/app/SystemServiceRegistry.java index d9b3d3bf57e8..1829f74700fd 100644 --- a/core/java/android/app/SystemServiceRegistry.java +++ b/core/java/android/app/SystemServiceRegistry.java @@ -34,6 +34,7 @@ import android.app.role.RoleManager; import android.app.slice.SliceManager; import android.app.timedetector.TimeDetector; import android.app.timezone.RulesManager; +import android.app.timezonedetector.TimeZoneDetector; import android.app.trust.TrustManager; import android.app.usage.IStorageStatsManager; import android.app.usage.IUsageStatsManager; @@ -1125,6 +1126,14 @@ public final class SystemServiceRegistry { return new TimeDetector(); }}); + registerService(Context.TIME_ZONE_DETECTOR_SERVICE, TimeZoneDetector.class, + new CachedServiceFetcher<TimeZoneDetector>() { + @Override + public TimeZoneDetector createService(ContextImpl ctx) + throws ServiceNotFoundException { + return new TimeZoneDetector(); + }}); + registerService(Context.PERMISSION_SERVICE, PermissionManager.class, new CachedServiceFetcher<PermissionManager>() { @Override diff --git a/core/java/android/app/timezonedetector/ITimeZoneDetectorService.aidl b/core/java/android/app/timezonedetector/ITimeZoneDetectorService.aidl new file mode 100644 index 000000000000..260c7df72fba --- /dev/null +++ b/core/java/android/app/timezonedetector/ITimeZoneDetectorService.aidl @@ -0,0 +1,36 @@ +/* + * Copyright (C) 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 android.app.timezonedetector; + +import android.app.timezonedetector.PhoneTimeZoneSuggestion; + +/** + * System private API to communicate with time zone detector service. + * + * <p>Used to provide information to the Time Zone Detector Service from other parts of the Android + * system that have access to time zone-related signals, e.g. telephony. + * + * <p>Use the {@link android.app.timezonedetector.TimeZoneDetector} class rather than going through + * this Binder interface directly. See {@link android.app.timezonedetector.TimeZoneDetectorService} + * for more complete documentation. + * + * + * {@hide} + */ +interface ITimeZoneDetectorService { + void suggestPhoneTimeZone(in PhoneTimeZoneSuggestion timeZoneSuggestion); +} diff --git a/core/java/android/app/timezonedetector/PhoneTimeZoneSuggestion.aidl b/core/java/android/app/timezonedetector/PhoneTimeZoneSuggestion.aidl new file mode 100644 index 000000000000..3ad903bb5949 --- /dev/null +++ b/core/java/android/app/timezonedetector/PhoneTimeZoneSuggestion.aidl @@ -0,0 +1,19 @@ +/* + * Copyright (C) 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 android.app.timezonedetector; + +parcelable PhoneTimeZoneSuggestion; diff --git a/core/java/android/app/timezonedetector/PhoneTimeZoneSuggestion.java b/core/java/android/app/timezonedetector/PhoneTimeZoneSuggestion.java new file mode 100644 index 000000000000..e8162488394c --- /dev/null +++ b/core/java/android/app/timezonedetector/PhoneTimeZoneSuggestion.java @@ -0,0 +1,341 @@ +/* + * 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 android.app.timezonedetector; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.os.Parcel; +import android.os.Parcelable; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A suggested time zone from a Phone-based signal, e.g. from MCC and NITZ information. + * + * @hide + */ +public final class PhoneTimeZoneSuggestion implements Parcelable { + + @NonNull + public static final Creator<PhoneTimeZoneSuggestion> CREATOR = + new Creator<PhoneTimeZoneSuggestion>() { + public PhoneTimeZoneSuggestion createFromParcel(Parcel in) { + return PhoneTimeZoneSuggestion.createFromParcel(in); + } + + public PhoneTimeZoneSuggestion[] newArray(int size) { + return new PhoneTimeZoneSuggestion[size]; + } + }; + + /** + * Creates an empty time zone suggestion, i.e. one that will cancel previous suggestions with + * the same {@code phoneId}. + */ + @NonNull + public static PhoneTimeZoneSuggestion createEmptySuggestion( + int phoneId, @NonNull String debugInfo) { + return new Builder(phoneId).addDebugInfo(debugInfo).build(); + } + + @IntDef({ MATCH_TYPE_NA, MATCH_TYPE_NETWORK_COUNTRY_ONLY, MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET, + MATCH_TYPE_EMULATOR_ZONE_ID, MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY }) + @Retention(RetentionPolicy.SOURCE) + public @interface MatchType {} + + /** Used when match type is not applicable. */ + public static final int MATCH_TYPE_NA = 0; + + /** + * Only the network country is known. + */ + public static final int MATCH_TYPE_NETWORK_COUNTRY_ONLY = 2; + + /** + * Both the network county and offset were known. + */ + public static final int MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET = 3; + + /** + * The device is running in an emulator and an NITZ signal was simulated containing an + * Android extension with an explicit Olson ID. + */ + public static final int MATCH_TYPE_EMULATOR_ZONE_ID = 4; + + /** + * The phone is most likely running in a test network not associated with a country (this is + * distinct from the country just not being known yet). + * Historically, Android has just picked an arbitrary time zone with the correct offset when + * on a test network. + */ + public static final int MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY = 5; + + @IntDef({ QUALITY_NA, QUALITY_SINGLE_ZONE, QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET, + QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS }) + @Retention(RetentionPolicy.SOURCE) + public @interface Quality {} + + /** Used when quality is not applicable. */ + public static final int QUALITY_NA = 0; + + /** There is only one answer */ + public static final int QUALITY_SINGLE_ZONE = 1; + + /** + * There are multiple answers, but they all shared the same offset / DST state at the time + * the suggestion was created. i.e. it might be the wrong zone but the user won't notice + * immediately if it is wrong. + */ + public static final int QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET = 2; + + /** + * There are multiple answers with different offsets. The one given is just one possible. + */ + public static final int QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS = 3; + + /** + * The ID of the phone this suggestion is associated with. For multiple-sim devices this + * helps to establish origin so filtering / stickiness can be implemented. + */ + private final int mPhoneId; + + /** + * The suggestion. {@code null} means there is no current suggestion and any previous suggestion + * should be forgotten. + */ + private final String mZoneId; + + /** + * The type of "match" used to establish the time zone. + */ + @MatchType + private final int mMatchType; + + /** + * A measure of the quality of the time zone suggestion, i.e. how confident one could be in + * it. + */ + @Quality + private final int mQuality; + + /** + * Free-form debug information about how the signal was derived. Used for debug only, + * intentionally not used in equals(), etc. + */ + private List<String> mDebugInfo; + + private PhoneTimeZoneSuggestion(Builder builder) { + mPhoneId = builder.mPhoneId; + mZoneId = builder.mZoneId; + mMatchType = builder.mMatchType; + mQuality = builder.mQuality; + mDebugInfo = builder.mDebugInfo != null ? new ArrayList<>(builder.mDebugInfo) : null; + } + + @SuppressWarnings("unchecked") + private static PhoneTimeZoneSuggestion createFromParcel(Parcel in) { + // Use the Builder so we get validation during build(). + int phoneId = in.readInt(); + PhoneTimeZoneSuggestion suggestion = new Builder(phoneId) + .setZoneId(in.readString()) + .setMatchType(in.readInt()) + .setQuality(in.readInt()) + .build(); + List<String> debugInfo = in.readArrayList(PhoneTimeZoneSuggestion.class.getClassLoader()); + if (debugInfo != null) { + suggestion.addDebugInfo(debugInfo); + } + return suggestion; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(mPhoneId); + dest.writeString(mZoneId); + dest.writeInt(mMatchType); + dest.writeInt(mQuality); + dest.writeList(mDebugInfo); + } + + @Override + public int describeContents() { + return 0; + } + + public int getPhoneId() { + return mPhoneId; + } + + @Nullable + public String getZoneId() { + return mZoneId; + } + + @MatchType + public int getMatchType() { + return mMatchType; + } + + @Quality + public int getQuality() { + return mQuality; + } + + @NonNull + public List<String> getDebugInfo() { + return mDebugInfo == null + ? Collections.emptyList() : Collections.unmodifiableList(mDebugInfo); + } + + /** + * Associates information with the instance that can be useful for debugging / logging. The + * information is present in {@link #toString()} but is not considered for + * {@link #equals(Object)} and {@link #hashCode()}. + */ + public void addDebugInfo(@NonNull String debugInfo) { + if (mDebugInfo == null) { + mDebugInfo = new ArrayList<>(); + } + mDebugInfo.add(debugInfo); + } + + /** + * Associates information with the instance that can be useful for debugging / logging. The + * information is present in {@link #toString()} but is not considered for + * {@link #equals(Object)} and {@link #hashCode()}. + */ + public void addDebugInfo(@NonNull List<String> debugInfo) { + if (mDebugInfo == null) { + mDebugInfo = new ArrayList<>(debugInfo.size()); + } + mDebugInfo.addAll(debugInfo); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PhoneTimeZoneSuggestion that = (PhoneTimeZoneSuggestion) o; + return mPhoneId == that.mPhoneId + && mMatchType == that.mMatchType + && mQuality == that.mQuality + && Objects.equals(mZoneId, that.mZoneId); + } + + @Override + public int hashCode() { + return Objects.hash(mPhoneId, mZoneId, mMatchType, mQuality); + } + + @Override + public String toString() { + return "PhoneTimeZoneSuggestion{" + + "mPhoneId=" + mPhoneId + + ", mZoneId='" + mZoneId + '\'' + + ", mMatchType=" + mMatchType + + ", mQuality=" + mQuality + + ", mDebugInfo=" + mDebugInfo + + '}'; + } + + /** + * Builds {@link PhoneTimeZoneSuggestion} instances. + * + * @hide + */ + public static class Builder { + private final int mPhoneId; + private String mZoneId; + @MatchType private int mMatchType; + @Quality private int mQuality; + private List<String> mDebugInfo; + + public Builder(int phoneId) { + mPhoneId = phoneId; + } + + /** Returns the builder for call chaining. */ + public Builder setZoneId(String zoneId) { + mZoneId = zoneId; + return this; + } + + /** Returns the builder for call chaining. */ + public Builder setMatchType(@MatchType int matchType) { + mMatchType = matchType; + return this; + } + + /** Returns the builder for call chaining. */ + public Builder setQuality(@Quality int quality) { + mQuality = quality; + return this; + } + + /** Returns the builder for call chaining. */ + public Builder addDebugInfo(@NonNull String debugInfo) { + if (mDebugInfo == null) { + mDebugInfo = new ArrayList<>(); + } + mDebugInfo.add(debugInfo); + return this; + } + + /** + * Performs basic structural validation of this instance. e.g. Are all the fields populated + * that must be? Are the enum ints set to valid values? + */ + void validate() { + int quality = mQuality; + int matchType = mMatchType; + if (mZoneId == null) { + if (quality != QUALITY_NA || matchType != MATCH_TYPE_NA) { + throw new RuntimeException("Invalid quality or match type for null zone ID." + + " quality=" + quality + ", matchType=" + matchType); + } + } else { + boolean qualityValid = (quality == QUALITY_SINGLE_ZONE + || quality == QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET + || quality == QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS); + boolean matchTypeValid = (matchType == MATCH_TYPE_NETWORK_COUNTRY_ONLY + || matchType == MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET + || matchType == MATCH_TYPE_EMULATOR_ZONE_ID + || matchType == MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY); + if (!qualityValid || !matchTypeValid) { + throw new RuntimeException("Invalid quality or match type with zone ID." + + " quality=" + quality + ", matchType=" + matchType); + } + } + } + + /** Returns the {@link PhoneTimeZoneSuggestion}. */ + public PhoneTimeZoneSuggestion build() { + validate(); + return new PhoneTimeZoneSuggestion(this); + } + } +} diff --git a/core/java/android/app/timezonedetector/TimeZoneDetector.java b/core/java/android/app/timezonedetector/TimeZoneDetector.java new file mode 100644 index 000000000000..909cbc2ccdf7 --- /dev/null +++ b/core/java/android/app/timezonedetector/TimeZoneDetector.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 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 android.app.timezonedetector; + +import android.annotation.NonNull; +import android.annotation.SystemService; +import android.content.Context; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.os.ServiceManager.ServiceNotFoundException; +import android.util.Log; + +/** + * The interface through which system components can send signals to the TimeZoneDetectorService. + * @hide + */ +@SystemService(Context.TIME_ZONE_DETECTOR_SERVICE) +public final class TimeZoneDetector { + private static final String TAG = "timezonedetector.TimeZoneDetector"; + private static final boolean DEBUG = false; + + private final ITimeZoneDetectorService mITimeZoneDetectorService; + + public TimeZoneDetector() throws ServiceNotFoundException { + mITimeZoneDetectorService = ITimeZoneDetectorService.Stub.asInterface( + ServiceManager.getServiceOrThrow(Context.TIME_ZONE_DETECTOR_SERVICE)); + } + + /** + * Suggests the current time zone to the detector. The detector may ignore the signal if better + * signals are available such as those that come from more reliable sources or were + * determined more recently. + */ + public void suggestPhoneTimeZone(@NonNull PhoneTimeZoneSuggestion timeZoneSuggestion) { + if (DEBUG) { + Log.d(TAG, "suggestPhoneTimeZone called: " + timeZoneSuggestion); + } + try { + mITimeZoneDetectorService.suggestPhoneTimeZone(timeZoneSuggestion); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + +} diff --git a/core/java/android/content/Context.java b/core/java/android/content/Context.java index 777d210e21de..341b5206ba90 100644 --- a/core/java/android/content/Context.java +++ b/core/java/android/content/Context.java @@ -3425,6 +3425,7 @@ public abstract class Context { CROSS_PROFILE_APPS_SERVICE, //@hide: SYSTEM_UPDATE_SERVICE, //@hide: TIME_DETECTOR_SERVICE, + //@hide: TIME_ZONE_DETECTOR_SERVICE, PERMISSION_SERVICE, INCREMENTAL_SERVICE, }) @@ -4878,7 +4879,7 @@ public abstract class Context { /** * Use with {@link #getSystemService(String)} to retrieve an - * {@link android.app.timedetector.ITimeDetectorService}. + * {@link android.app.timedetector.TimeDetector}. * @hide * * @see #getSystemService(String) @@ -4886,6 +4887,15 @@ public abstract class Context { public static final String TIME_DETECTOR_SERVICE = "time_detector"; /** + * Use with {@link #getSystemService(String)} to retrieve an + * {@link android.app.timezonedetector.TimeZoneDetector}. + * @hide + * + * @see #getSystemService(String) + */ + public static final String TIME_ZONE_DETECTOR_SERVICE = "time_zone_detector"; + + /** * Binder service name for {@link AppBindingService}. * @hide */ diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index 99dfffe51449..936099f51ff6 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -635,6 +635,11 @@ <protected-broadcast android:name="android.intent.action.DEVICE_CUSTOMIZATION_READY" /> + <!-- NETWORK_SET_TIME / NETWORK_SET_TIMEZONE moved from com.android.phone to system server. + They should ultimately be removed. --> + <protected-broadcast android:name="android.intent.action.NETWORK_SET_TIME" /> + <protected-broadcast android:name="android.intent.action.NETWORK_SET_TIMEZONE" /> + <!-- For tether entitlement recheck--> <protected-broadcast android:name="com.android.server.connectivity.tethering.PROVISIONING_RECHECK_ALARM" /> diff --git a/core/tests/coretests/src/android/app/timezonedetector/PhoneTimeZoneSuggestionTest.java b/core/tests/coretests/src/android/app/timezonedetector/PhoneTimeZoneSuggestionTest.java new file mode 100644 index 000000000000..ae91edc5db35 --- /dev/null +++ b/core/tests/coretests/src/android/app/timezonedetector/PhoneTimeZoneSuggestionTest.java @@ -0,0 +1,170 @@ +/* + * 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 android.app.timezonedetector; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import android.os.Parcel; +import android.os.Parcelable; + +import org.junit.Test; + +public class PhoneTimeZoneSuggestionTest { + private static final int PHONE_ID = 99999; + + @Test + public void testEquals() { + PhoneTimeZoneSuggestion.Builder builder1 = new PhoneTimeZoneSuggestion.Builder(PHONE_ID); + { + PhoneTimeZoneSuggestion one = builder1.build(); + assertEquals(one, one); + } + + PhoneTimeZoneSuggestion.Builder builder2 = new PhoneTimeZoneSuggestion.Builder(PHONE_ID); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertEquals(one, two); + assertEquals(two, one); + } + + PhoneTimeZoneSuggestion.Builder builder3 = + new PhoneTimeZoneSuggestion.Builder(PHONE_ID + 1); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion three = builder3.build(); + assertNotEquals(one, three); + assertNotEquals(three, one); + } + + builder1.setZoneId("Europe/London"); + builder1.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_ONLY); + builder1.setQuality(PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertNotEquals(one, two); + } + + builder2.setZoneId("Europe/Paris"); + builder2.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_ONLY); + builder2.setQuality(PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertNotEquals(one, two); + } + + builder1.setZoneId("Europe/Paris"); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertEquals(one, two); + } + + builder1.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_EMULATOR_ZONE_ID); + builder2.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_ONLY); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertNotEquals(one, two); + } + + builder1.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_ONLY); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertEquals(one, two); + } + + builder1.setQuality(PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE); + builder2.setQuality(PhoneTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertNotEquals(one, two); + } + + builder1.setQuality(PhoneTimeZoneSuggestion.QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS); + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + assertEquals(one, two); + } + + // DebugInfo must not be considered in equals(). + { + PhoneTimeZoneSuggestion one = builder1.build(); + PhoneTimeZoneSuggestion two = builder2.build(); + one.addDebugInfo("Debug info 1"); + two.addDebugInfo("Debug info 2"); + assertEquals(one, two); + } + } + + @Test(expected = RuntimeException.class) + public void testBuilderValidates_emptyZone_badMatchType() { + PhoneTimeZoneSuggestion.Builder builder = new PhoneTimeZoneSuggestion.Builder(PHONE_ID); + // No zone ID, so match type should be left unset. + builder.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET); + builder.build(); + } + + @Test(expected = RuntimeException.class) + public void testBuilderValidates_zoneSet_badMatchType() { + PhoneTimeZoneSuggestion.Builder builder = new PhoneTimeZoneSuggestion.Builder(PHONE_ID); + builder.setZoneId("Europe/London"); + builder.setQuality(PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE); + builder.build(); + } + + @Test + public void testParcelable() { + PhoneTimeZoneSuggestion.Builder builder = new PhoneTimeZoneSuggestion.Builder(PHONE_ID); + assertRoundTripParcelable(builder.build()); + + builder.setZoneId("Europe/London"); + builder.setMatchType(PhoneTimeZoneSuggestion.MATCH_TYPE_EMULATOR_ZONE_ID); + builder.setQuality(PhoneTimeZoneSuggestion.QUALITY_SINGLE_ZONE); + PhoneTimeZoneSuggestion suggestion1 = builder.build(); + assertRoundTripParcelable(suggestion1); + + // DebugInfo should also be stored (but is not checked by equals() + String debugString = "This is debug info"; + suggestion1.addDebugInfo(debugString); + PhoneTimeZoneSuggestion suggestion1_2 = roundTripParcelable(suggestion1); + assertEquals(suggestion1, suggestion1_2); + assertTrue(suggestion1_2.getDebugInfo().contains(debugString)); + } + + private static void assertRoundTripParcelable(PhoneTimeZoneSuggestion instance) { + assertEquals(instance, roundTripParcelable(instance)); + } + + @SuppressWarnings("unchecked") + private static <T extends Parcelable> T roundTripParcelable(T one) { + Parcel parcel = Parcel.obtain(); + parcel.writeTypedObject(one, 0); + parcel.setDataPosition(0); + + T toReturn = (T) parcel.readTypedObject(PhoneTimeZoneSuggestion.CREATOR); + parcel.recycle(); + return toReturn; + } +} diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java new file mode 100644 index 000000000000..23746aca4c87 --- /dev/null +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorCallbackImpl.java @@ -0,0 +1,79 @@ +/* + * 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 android.annotation.Nullable; +import android.app.AlarmManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.os.SystemProperties; +import android.os.UserHandle; +import android.provider.Settings; + +import com.android.internal.telephony.TelephonyIntents; + +/** + * The real implementation of {@link TimeZoneDetectorStrategy.Callback}. + */ +public final class TimeZoneDetectorCallbackImpl implements TimeZoneDetectorStrategy.Callback { + + private static final String TIMEZONE_PROPERTY = "persist.sys.timezone"; + + private final Context mContext; + private final ContentResolver mCr; + + TimeZoneDetectorCallbackImpl(Context context) { + mContext = context; + mCr = context.getContentResolver(); + } + + @Override + public boolean isTimeZoneDetectionEnabled() { + return Settings.Global.getInt(mCr, Settings.Global.AUTO_TIME_ZONE, 1 /* default */) > 0; + } + + @Override + public boolean isDeviceTimeZoneInitialized() { + // timezone.equals("GMT") will be true and only true if the time zone was + // set to a default value by the system server (when starting, system server + // sets the persist.sys.timezone to "GMT" if it's not set). "GMT" is not used by + // any code that sets it explicitly (in case where something sets GMT explicitly, + // "Etc/GMT" Olson ID would be used). + + String timeZoneId = getDeviceTimeZone(); + return timeZoneId != null && timeZoneId.length() > 0 && !timeZoneId.equals("GMT"); + } + + @Override + @Nullable + public String getDeviceTimeZone() { + return SystemProperties.get(TIMEZONE_PROPERTY); + } + + @Override + public void setDeviceTimeZone(String zoneId) { + AlarmManager alarmManager = mContext.getSystemService(AlarmManager.class); + alarmManager.setTimeZone(zoneId); + + // TODO Nothing in the platform appears to listen for this. Remove it. + Intent intent = new Intent(TelephonyIntents.ACTION_NETWORK_SET_TIMEZONE); + intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING); + intent.putExtra("time-zone", zoneId); + mContext.sendStickyBroadcastAsUser(intent, UserHandle.ALL); + } +} diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java new file mode 100644 index 000000000000..558aa9e98f43 --- /dev/null +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 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 android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.timezonedetector.ITimeZoneDetectorService; +import android.app.timezonedetector.PhoneTimeZoneSuggestion; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.os.Handler; +import android.provider.Settings; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.DumpUtils; +import com.android.internal.util.IndentingPrintWriter; +import com.android.server.FgThread; +import com.android.server.SystemService; + +import java.io.FileDescriptor; +import java.io.PrintWriter; +import java.util.Objects; + +/** + * The implementation of ITimeZoneDetectorService.aidl. + */ +public final class TimeZoneDetectorService extends ITimeZoneDetectorService.Stub { + private static final String TAG = "TimeZoneDetectorService"; + + /** + * Handles the lifecycle for {@link TimeZoneDetectorService}. + */ + public static class Lifecycle extends SystemService { + + public Lifecycle(@NonNull Context context) { + super(context); + } + + @Override + public void onStart() { + TimeZoneDetectorService service = TimeZoneDetectorService.create(getContext()); + + // Publish the binder service so it can be accessed from other (appropriately + // permissioned) processes. + publishBinderService(Context.TIME_ZONE_DETECTOR_SERVICE, service); + } + } + + @NonNull private final Context mContext; + @NonNull private final Handler mHandler; + @NonNull private final TimeZoneDetectorStrategy mTimeZoneDetectorStrategy; + + private static TimeZoneDetectorService create(@NonNull Context context) { + final TimeZoneDetectorStrategy timeZoneDetectorStrategy = + TimeZoneDetectorStrategy.create(context); + + Handler handler = FgThread.getHandler(); + ContentResolver contentResolver = context.getContentResolver(); + contentResolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.AUTO_TIME_ZONE), true, + new ContentObserver(handler) { + public void onChange(boolean selfChange) { + timeZoneDetectorStrategy.handleTimeZoneDetectionChange(); + } + }); + + return new TimeZoneDetectorService(context, handler, timeZoneDetectorStrategy); + } + + @VisibleForTesting + public TimeZoneDetectorService(@NonNull Context context, @NonNull Handler handler, + @NonNull TimeZoneDetectorStrategy timeZoneDetectorStrategy) { + mContext = Objects.requireNonNull(context); + mHandler = Objects.requireNonNull(handler); + mTimeZoneDetectorStrategy = Objects.requireNonNull(timeZoneDetectorStrategy); + } + + @Override + public void suggestPhoneTimeZone(@NonNull PhoneTimeZoneSuggestion timeZoneSuggestion) { + enforceSetTimeZonePermission(); + Objects.requireNonNull(timeZoneSuggestion); + + mHandler.post(() -> mTimeZoneDetectorStrategy.suggestPhoneTimeZone(timeZoneSuggestion)); + } + + @Override + protected void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter pw, + @Nullable String[] args) { + if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return; + + mTimeZoneDetectorStrategy.dumpState(pw); + mTimeZoneDetectorStrategy.dumpLogs(new IndentingPrintWriter(pw, " ")); + } + + private void enforceSetTimeZonePermission() { + mContext.enforceCallingPermission( + android.Manifest.permission.SET_TIME_ZONE, "set time zone"); + } +} + diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java new file mode 100644 index 000000000000..e24c089ae862 --- /dev/null +++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorStrategy.java @@ -0,0 +1,507 @@ +/* + * 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.NonNull; +import android.annotation.Nullable; +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.util.LinkedList; +import java.util.Map; +import java.util.Objects; + +/** + * A singleton, stateful time zone detection strategy 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 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. + */ + @VisibleForTesting + public interface Callback { + + /** + * 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 isDeviceTimeZoneInitialized(); + + /** + * Returns the device's currently configured time zone. + */ + String getDeviceTimeZone(); + + /** + * Sets the device's time zone. + */ + void setDeviceTimeZone(@NonNull String zoneId); + } + + static final String LOG_TAG = "TimeZoneDetectorStrategy"; + static final boolean DBG = false; + + /** + * 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 number of previous phone suggestions to keep for each ID (for use during debugging). */ + private static final int KEEP_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 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; + + /** + * 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); + } + + /** + * 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) { + Slog.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) { + score = 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 = SCORE_HIGHEST; + } else if (suggestion.getQuality() == QUALITY_SINGLE_ZONE) { + score = 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 = SCORE_MEDIUM; + } else if (suggestion.getQuality() == QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS) { + // The suggestion has a good chance of being wrong. + score = 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 service becomes / remains un-opinionated and nothing is set. + */ + @GuardedBy("this") + private void doTimeZoneDetection() { + QualifiedPhoneTimeZoneSuggestion bestSuggestion = findBestSuggestion(); + boolean timeZoneDetectionEnabled = mCallback.isTimeZoneDetectionEnabled(); + + // Work out what to do with the best suggestion. + if (bestSuggestion == null) { + // There is no suggestion. Become un-opinionated. + if (DBG) { + Slog.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 && !mCallback.isDeviceTimeZoneInitialized()) { + Slog.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) { + setDeviceTimeZone(bestSuggestion.suggestion); + } + return; + } + + boolean suggestionGoodEnough = bestSuggestion.score >= SCORE_USAGE_THRESHOLD; + if (!suggestionGoodEnough) { + if (DBG) { + Slog.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) { + Slog.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) { + Slog.d(LOG_TAG, "doTimeZoneDetection: Not setting the time zone because time zone" + + " detection is disabled." + + " bestSuggestion=" + bestSuggestion); + } + return; + } + PhoneTimeZoneSuggestion suggestion = bestSuggestion.suggestion; + setDeviceTimeZone(suggestion); + } + + private void setDeviceTimeZone(@NonNull PhoneTimeZoneSuggestion suggestion) { + String currentZoneId = mCallback.getDeviceTimeZone(); + String newZoneId = suggestion.getZoneId(); + + // Paranoia: This should never happen. + if (newZoneId == null) { + Slog.w(LOG_TAG, "setDeviceTimeZone: Suggested zone is null." + + " timeZoneSuggestion=" + suggestion); + return; + } + + // 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, "setDeviceTimeZone: No need to change the time zone;" + + " device is already set to the suggested zone." + + " timeZoneSuggestion=" + suggestion); + } + return; + } + + String msg = "Changing device time zone. currentZoneId=" + currentZoneId + + ", timeZoneSuggestion=" + suggestion; + if (DBG) { + Slog.d(LOG_TAG, msg); + } + mTimeZoneChangesLog.log(msg); + mCallback.setDeviceTimeZone(newZoneId); + } + + @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(); + } + + /** + * Called when the has been a change to the automatic time zone detection setting. + */ + @VisibleForTesting + public synchronized void handleTimeZoneDetectionChange() { + if (DBG) { + Slog.d(LOG_TAG, "handleTimeZoneDetectionChange() called"); + } + if (mCallback.isTimeZoneDetectionEnabled()) { + // 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 synchronized void dumpLogs(IndentingPrintWriter ipw) { + ipw.println("TimeZoneDetectorStrategy:"); + + ipw.increaseIndent(); // level 1 + + 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 + } + + /** + * Dumps internal state such as field values. + */ + public synchronized void dumpState(PrintWriter pw) { + pw.println("mCurrentSuggestion=" + mCurrentSuggestion); + pw.println("mCallback.isTimeZoneDetectionEnabled()=" + + mCallback.isTimeZoneDetectionEnabled()); + pw.println("mCallback.isDeviceTimeZoneInitialized()=" + + mCallback.isDeviceTimeZoneInitialized()); + pw.println("mCallback.getDeviceTimeZone()=" + + mCallback.getDeviceTimeZone()); + 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 + + '}'; + } + } +} diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java index f46857a26482..80d6f77fe47d 100644 --- a/services/java/com/android/server/SystemServer.java +++ b/services/java/com/android/server/SystemServer.java @@ -268,6 +268,8 @@ public final class SystemServer { "com.android.internal.car.CarServiceHelperService"; private static final String TIME_DETECTOR_SERVICE_CLASS = "com.android.server.timedetector.TimeDetectorService$Lifecycle"; + private static final String TIME_ZONE_DETECTOR_SERVICE_CLASS = + "com.android.server.timezonedetector.TimeZoneDetectorService$Lifecycle"; private static final String ACCESSIBILITY_MANAGER_SERVICE_CLASS = "com.android.server.accessibility.AccessibilityManagerService$Lifecycle"; private static final String ADB_SERVICE_CLASS = @@ -1480,6 +1482,14 @@ public final class SystemServer { } t.traceEnd(); + t.traceBegin("StartTimeZoneDetectorService"); + try { + mSystemServiceManager.startService(TIME_ZONE_DETECTOR_SERVICE_CLASS); + } catch (Throwable e) { + reportWtf("starting StartTimeZoneDetectorService service", e); + } + t.traceEnd(); + if (!isWatch) { t.traceBegin("StartSearchManagerService"); try { diff --git a/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyTest.java b/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyTest.java new file mode 100644 index 000000000000..f9f23c3605ce --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/timezonedetector/TimeZoneDetectorStrategyTest.java @@ -0,0 +1,592 @@ +/* + * 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_NETWORK_COUNTRY_AND_OFFSET; +import static android.app.timezonedetector.PhoneTimeZoneSuggestion.MATCH_TYPE_NETWORK_COUNTRY_ONLY; +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 static com.android.server.timezonedetector.TimeZoneDetectorStrategy.SCORE_HIGH; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.SCORE_HIGHEST; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.SCORE_LOW; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.SCORE_MEDIUM; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.SCORE_NONE; +import static com.android.server.timezonedetector.TimeZoneDetectorStrategy.SCORE_USAGE_THRESHOLD; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +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 org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; + +/** + * White-box unit tests for {@link TimeZoneDetectorStrategy}. + */ +public class TimeZoneDetectorStrategyTest { + + /** A time zone used for initialization that does not occur elsewhere in tests. */ + private static final String ARBITRARY_TIME_ZONE_ID = "Etc/UTC"; + private static final int PHONE1_ID = 10000; + private static final int PHONE2_ID = 20000; + + // Suggestion test cases are ordered so that each successive one is of the same or higher score + // than the previous. + private static final SuggestionTestCase[] TEST_CASES = new SuggestionTestCase[] { + newTestCase(MATCH_TYPE_NETWORK_COUNTRY_ONLY, + QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS, SCORE_LOW), + newTestCase(MATCH_TYPE_NETWORK_COUNTRY_ONLY, QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET, + SCORE_MEDIUM), + newTestCase(MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET, + QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET, SCORE_MEDIUM), + newTestCase(MATCH_TYPE_NETWORK_COUNTRY_ONLY, QUALITY_SINGLE_ZONE, SCORE_HIGH), + newTestCase(MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET, QUALITY_SINGLE_ZONE, SCORE_HIGH), + newTestCase(MATCH_TYPE_TEST_NETWORK_OFFSET_ONLY, + QUALITY_MULTIPLE_ZONES_WITH_SAME_OFFSET, SCORE_HIGHEST), + newTestCase(MATCH_TYPE_EMULATOR_ZONE_ID, QUALITY_SINGLE_ZONE, SCORE_HIGHEST), + }; + + private TimeZoneDetectorStrategy mTimeZoneDetectorStrategy; + private FakeTimeZoneDetectorStrategyCallback mFakeTimeZoneDetectorStrategyCallback; + + @Before + public void setUp() { + mFakeTimeZoneDetectorStrategyCallback = new FakeTimeZoneDetectorStrategyCallback(); + mTimeZoneDetectorStrategy = + new TimeZoneDetectorStrategy(mFakeTimeZoneDetectorStrategyCallback); + } + + @Test + public void testEmptySuggestions() { + PhoneTimeZoneSuggestion phone1TimeZoneSuggestion = createEmptyPhone1Suggestion(); + PhoneTimeZoneSuggestion phone2TimeZoneSuggestion = createEmptyPhone2Suggestion(); + Script script = new Script() + .initializeTimeZoneDetectionEnabled(true) + .initializeTimeZoneSetting(ARBITRARY_TIME_ZONE_ID); + + script.suggestPhoneTimeZone(phone1TimeZoneSuggestion) + .verifyTimeZoneNotSet(); + + // Assert internal service state. + QualifiedPhoneTimeZoneSuggestion expectedPhone1ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(phone1TimeZoneSuggestion, SCORE_NONE); + assertEquals(expectedPhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertNull(mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE2_ID)); + assertEquals(expectedPhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + script.suggestPhoneTimeZone(phone2TimeZoneSuggestion) + .verifyTimeZoneNotSet(); + + // Assert internal service state. + QualifiedPhoneTimeZoneSuggestion expectedPhone2ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(phone2TimeZoneSuggestion, SCORE_NONE); + assertEquals(expectedPhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedPhone2ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE2_ID)); + // Phone 1 should always beat phone 2, all other things being equal. + assertEquals(expectedPhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + } + + @Test + public void testFirstPlausibleSuggestionAcceptedWhenTimeZoneUninitialized() { + SuggestionTestCase testCase = newTestCase(MATCH_TYPE_NETWORK_COUNTRY_ONLY, + QUALITY_MULTIPLE_ZONES_WITH_DIFFERENT_OFFSETS, SCORE_LOW); + PhoneTimeZoneSuggestion lowQualitySuggestion = + testCase.createSuggestion(PHONE1_ID, "America/New_York"); + + // The device time zone setting is left uninitialized. + Script script = new Script() + .initializeTimeZoneDetectionEnabled(true); + + // The very first suggestion will be taken. + script.suggestPhoneTimeZone(lowQualitySuggestion) + .verifyTimeZoneSetAndReset(lowQualitySuggestion); + + // Assert internal service state. + QualifiedPhoneTimeZoneSuggestion expectedScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(lowQualitySuggestion, testCase.expectedScore); + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + // Another low quality suggestion will be ignored now that the setting is initialized. + PhoneTimeZoneSuggestion lowQualitySuggestion2 = + testCase.createSuggestion(PHONE1_ID, "America/Los_Angeles"); + script.suggestPhoneTimeZone(lowQualitySuggestion2) + .verifyTimeZoneNotSet(); + + // Assert internal service state. + QualifiedPhoneTimeZoneSuggestion expectedScoredSuggestion2 = + new QualifiedPhoneTimeZoneSuggestion(lowQualitySuggestion2, testCase.expectedScore); + assertEquals(expectedScoredSuggestion2, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedScoredSuggestion2, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + } + + /** + * Confirms that toggling the auto time zone detection setting has the expected behavior when + * the strategy is "opinionated". + */ + @Test + public void testTogglingTimeZoneDetection() { + Script script = new Script(); + + for (SuggestionTestCase testCase : TEST_CASES) { + // Start with the device in a known state. + script.initializeTimeZoneDetectionEnabled(false) + .initializeTimeZoneSetting(ARBITRARY_TIME_ZONE_ID); + + PhoneTimeZoneSuggestion suggestion = + testCase.createSuggestion(PHONE1_ID, "Europe/London"); + script.suggestPhoneTimeZone(suggestion); + + // When time zone detection is not enabled, the time zone suggestion will not be set + // regardless of the score. + script.verifyTimeZoneNotSet(); + + // Assert internal service state. + QualifiedPhoneTimeZoneSuggestion expectedScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(suggestion, testCase.expectedScore); + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + // Toggling the time zone setting on should cause the device setting to be set. + script.timeZoneDetectionEnabled(true); + + // When time zone detection is already enabled the suggestion (if it scores highly + // enough) should be set immediately. + if (testCase.expectedScore >= SCORE_USAGE_THRESHOLD) { + script.verifyTimeZoneSetAndReset(suggestion); + } else { + script.verifyTimeZoneNotSet(); + } + + // Assert internal service state. + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + // Toggling the time zone setting should off should do nothing. + script.timeZoneDetectionEnabled(false) + .verifyTimeZoneNotSet(); + + // Assert internal service state. + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + } + } + + @Test + public void testSuggestionsSinglePhone() { + Script script = new Script() + .initializeTimeZoneDetectionEnabled(true) + .initializeTimeZoneSetting(ARBITRARY_TIME_ZONE_ID); + + for (SuggestionTestCase testCase : TEST_CASES) { + makePhone1SuggestionAndCheckState(script, testCase); + } + + /* + * This is the same test as above but the test cases are in + * reverse order of their expected score. New suggestions always replace previous ones: + * there's effectively no history and so ordering shouldn't make any difference. + */ + + // Each test case will have the same or lower score than the last. + ArrayList<SuggestionTestCase> descendingCasesByScore = + new ArrayList<>(Arrays.asList(TEST_CASES)); + Collections.reverse(descendingCasesByScore); + + for (SuggestionTestCase testCase : descendingCasesByScore) { + makePhone1SuggestionAndCheckState(script, testCase); + } + } + + private void makePhone1SuggestionAndCheckState(Script script, SuggestionTestCase testCase) { + // Give the next suggestion a different zone from the currently set device time zone; + String currentZoneId = mFakeTimeZoneDetectorStrategyCallback.getDeviceTimeZone(); + String suggestionZoneId = + "Europe/London".equals(currentZoneId) ? "Europe/Paris" : "Europe/London"; + PhoneTimeZoneSuggestion zonePhone1Suggestion = + testCase.createSuggestion(PHONE1_ID, suggestionZoneId); + QualifiedPhoneTimeZoneSuggestion expectedZonePhone1ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(zonePhone1Suggestion, testCase.expectedScore); + + script.suggestPhoneTimeZone(zonePhone1Suggestion); + if (testCase.expectedScore >= SCORE_USAGE_THRESHOLD) { + script.verifyTimeZoneSetAndReset(zonePhone1Suggestion); + } else { + script.verifyTimeZoneNotSet(); + } + + // Assert internal service state. + assertEquals(expectedZonePhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedZonePhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + } + + /** + * Tries a set of test cases to see if the phone with the lowest ID is given preference. This + * test also confirms that the time zone setting would only be set if a suggestion is of + * sufficient quality. + */ + @Test + public void testMultiplePhoneSuggestionScoringAndPhoneIdBias() { + String[] zoneIds = { "Europe/London", "Europe/Paris" }; + PhoneTimeZoneSuggestion emptyPhone1Suggestion = createEmptyPhone1Suggestion(); + PhoneTimeZoneSuggestion emptyPhone2Suggestion = createEmptyPhone2Suggestion(); + QualifiedPhoneTimeZoneSuggestion expectedEmptyPhone1ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(emptyPhone1Suggestion, SCORE_NONE); + QualifiedPhoneTimeZoneSuggestion expectedEmptyPhone2ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(emptyPhone2Suggestion, SCORE_NONE); + + Script script = new Script() + .initializeTimeZoneDetectionEnabled(true) + .initializeTimeZoneSetting(ARBITRARY_TIME_ZONE_ID) + // Initialize the latest suggestions as empty so we don't need to worry about nulls + // below for the first loop. + .suggestPhoneTimeZone(emptyPhone1Suggestion) + .suggestPhoneTimeZone(emptyPhone2Suggestion) + .resetState(); + + for (SuggestionTestCase testCase : TEST_CASES) { + PhoneTimeZoneSuggestion zonePhone1Suggestion = + testCase.createSuggestion(PHONE1_ID, zoneIds[0]); + PhoneTimeZoneSuggestion zonePhone2Suggestion = + testCase.createSuggestion(PHONE2_ID, zoneIds[1]); + QualifiedPhoneTimeZoneSuggestion expectedZonePhone1ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(zonePhone1Suggestion, + testCase.expectedScore); + QualifiedPhoneTimeZoneSuggestion expectedZonePhone2ScoredSuggestion = + new QualifiedPhoneTimeZoneSuggestion(zonePhone2Suggestion, + testCase.expectedScore); + + // Start the test by making a suggestion for phone 1. + script.suggestPhoneTimeZone(zonePhone1Suggestion); + if (testCase.expectedScore >= SCORE_USAGE_THRESHOLD) { + script.verifyTimeZoneSetAndReset(zonePhone1Suggestion); + } else { + script.verifyTimeZoneNotSet(); + } + + // Assert internal service state. + assertEquals(expectedZonePhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedEmptyPhone2ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE2_ID)); + assertEquals(expectedZonePhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + // Phone 2 then makes an alternative suggestion with an identical score. Phone 1's + // suggestion should still "win" if it is above the required threshold. + script.suggestPhoneTimeZone(zonePhone2Suggestion); + script.verifyTimeZoneNotSet(); + + // Assert internal service state. + assertEquals(expectedZonePhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedZonePhone2ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE2_ID)); + // Phone 1 should always beat phone 2, all other things being equal. + assertEquals(expectedZonePhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + // Withdrawing phone 1's suggestion should leave phone 2 as the new winner. Since the + // zoneId is different, the time zone setting should be updated if the score is high + // enough. + script.suggestPhoneTimeZone(emptyPhone1Suggestion); + if (testCase.expectedScore >= SCORE_USAGE_THRESHOLD) { + script.verifyTimeZoneSetAndReset(zonePhone2Suggestion); + } else { + script.verifyTimeZoneNotSet(); + } + + // Assert internal service state. + assertEquals(expectedEmptyPhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedZonePhone2ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE2_ID)); + assertEquals(expectedZonePhone2ScoredSuggestion, + mTimeZoneDetectorStrategy.findBestSuggestionForTests()); + + // Reset the state for the next loop. + script.suggestPhoneTimeZone(emptyPhone2Suggestion) + .verifyTimeZoneNotSet(); + assertEquals(expectedEmptyPhone1ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE1_ID)); + assertEquals(expectedEmptyPhone2ScoredSuggestion, + mTimeZoneDetectorStrategy.getLatestPhoneSuggestion(PHONE2_ID)); + } + } + + /** + * The {@link TimeZoneDetectorStrategy.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. + */ + @Test + public void testTimeZoneDetectorStrategyDoesNotAssumeCurrentSetting() { + Script script = new Script() + .initializeTimeZoneDetectionEnabled(true); + + SuggestionTestCase testCase = + newTestCase(MATCH_TYPE_NETWORK_COUNTRY_AND_OFFSET, QUALITY_SINGLE_ZONE, SCORE_HIGH); + PhoneTimeZoneSuggestion losAngelesSuggestion = + testCase.createSuggestion(PHONE1_ID, "America/Los_Angeles"); + PhoneTimeZoneSuggestion newYorkSuggestion = + testCase.createSuggestion(PHONE1_ID, "America/New_York"); + + // Initialization. + script.suggestPhoneTimeZone(losAngelesSuggestion) + .verifyTimeZoneSetAndReset(losAngelesSuggestion); + // Suggest it again - it should not be set because it is already set. + script.suggestPhoneTimeZone(losAngelesSuggestion) + .verifyTimeZoneNotSet(); + + // Toggling time zone detection should set the device time zone only if the current setting + // value is different from the most recent phone suggestion. + script.timeZoneDetectionEnabled(false) + .verifyTimeZoneNotSet() + .timeZoneDetectionEnabled(true) + .verifyTimeZoneNotSet(); + + // Simulate a user turning auto detection off, a new suggestion being made while auto + // detection is off, and the user turning it on again. + script.timeZoneDetectionEnabled(false) + .suggestPhoneTimeZone(newYorkSuggestion) + .verifyTimeZoneNotSet(); + // Latest suggestion should be used. + script.timeZoneDetectionEnabled(true) + .verifyTimeZoneSetAndReset(newYorkSuggestion); + } + + private static PhoneTimeZoneSuggestion createEmptyPhone1Suggestion() { + return new PhoneTimeZoneSuggestion.Builder(PHONE1_ID).build(); + } + + private static PhoneTimeZoneSuggestion createEmptyPhone2Suggestion() { + return new PhoneTimeZoneSuggestion.Builder(PHONE2_ID).build(); + } + + class FakeTimeZoneDetectorStrategyCallback implements TimeZoneDetectorStrategy.Callback { + + private boolean mTimeZoneDetectionEnabled; + private TestState<String> mTimeZoneId = new TestState<>(); + + @Override + public boolean isTimeZoneDetectionEnabled() { + return mTimeZoneDetectionEnabled; + } + + @Override + public boolean isDeviceTimeZoneInitialized() { + return mTimeZoneId.getLatest() != null; + } + + @Override + public String getDeviceTimeZone() { + return mTimeZoneId.getLatest(); + } + + @Override + public void setDeviceTimeZone(String zoneId) { + mTimeZoneId.set(zoneId); + } + + void initializeTimeZoneDetectionEnabled(boolean enabled) { + mTimeZoneDetectionEnabled = enabled; + } + + void initializeTimeZone(String zoneId) { + mTimeZoneId.init(zoneId); + } + + void setTimeZoneDetectionEnabled(boolean enabled) { + mTimeZoneDetectionEnabled = enabled; + } + + void assertTimeZoneNotSet() { + mTimeZoneId.assertHasNotBeenSet(); + } + + void assertTimeZoneSet(String timeZoneId) { + mTimeZoneId.assertHasBeenSet(); + mTimeZoneId.assertChangeCount(1); + mTimeZoneId.assertLatestEquals(timeZoneId); + } + + void commitAllChanges() { + mTimeZoneId.commitLatest(); + } + } + + /** Some piece of state that tests want to track. */ + private static class TestState<T> { + private T mInitialValue; + private LinkedList<T> mValues = new LinkedList<>(); + + void init(T value) { + mValues.clear(); + mInitialValue = value; + } + + void set(T value) { + mValues.addFirst(value); + } + + boolean hasBeenSet() { + return mValues.size() > 0; + } + + void assertHasNotBeenSet() { + assertFalse(hasBeenSet()); + } + + void assertHasBeenSet() { + assertTrue(hasBeenSet()); + } + + void commitLatest() { + if (hasBeenSet()) { + mInitialValue = mValues.getLast(); + mValues.clear(); + } + } + + void assertLatestEquals(T expected) { + assertEquals(expected, getLatest()); + } + + void assertChangeCount(int expectedCount) { + assertEquals(expectedCount, mValues.size()); + } + + public T getLatest() { + if (hasBeenSet()) { + return mValues.getFirst(); + } + return mInitialValue; + } + } + + /** + * A "fluent" class allows reuse of code in tests: initialization, simulation and verification + * logic. + */ + private class Script { + + Script initializeTimeZoneDetectionEnabled(boolean enabled) { + mFakeTimeZoneDetectorStrategyCallback.initializeTimeZoneDetectionEnabled(enabled); + return this; + } + + Script initializeTimeZoneSetting(String zoneId) { + mFakeTimeZoneDetectorStrategyCallback.initializeTimeZone(zoneId); + return this; + } + + Script timeZoneDetectionEnabled(boolean enabled) { + mFakeTimeZoneDetectorStrategyCallback.setTimeZoneDetectionEnabled(enabled); + mTimeZoneDetectorStrategy.handleTimeZoneDetectionChange(); + return this; + } + + /** Simulates the time zone detection service receiving a phone-originated suggestion. */ + Script suggestPhoneTimeZone(PhoneTimeZoneSuggestion phoneTimeZoneSuggestion) { + mTimeZoneDetectorStrategy.suggestPhoneTimeZone(phoneTimeZoneSuggestion); + return this; + } + + /** Simulates the user manually setting the time zone. */ + Script manuallySetTimeZone(String timeZoneId) { + // Assert the test code is correct to call this method. + assertFalse(mFakeTimeZoneDetectorStrategyCallback.isTimeZoneDetectionEnabled()); + + mFakeTimeZoneDetectorStrategyCallback.initializeTimeZone(timeZoneId); + return this; + } + + Script verifyTimeZoneNotSet() { + mFakeTimeZoneDetectorStrategyCallback.assertTimeZoneNotSet(); + return this; + } + + Script verifyTimeZoneSetAndReset(PhoneTimeZoneSuggestion timeZoneSuggestion) { + mFakeTimeZoneDetectorStrategyCallback.assertTimeZoneSet(timeZoneSuggestion.getZoneId()); + mFakeTimeZoneDetectorStrategyCallback.commitAllChanges(); + return this; + } + + Script resetState() { + mFakeTimeZoneDetectorStrategyCallback.commitAllChanges(); + return this; + } + } + + private static class SuggestionTestCase { + public final int matchType; + public final int quality; + public final int expectedScore; + + SuggestionTestCase(int matchType, int quality, int expectedScore) { + this.matchType = matchType; + this.quality = quality; + this.expectedScore = expectedScore; + } + + private PhoneTimeZoneSuggestion createSuggestion(int phoneId, String zoneId) { + return new PhoneTimeZoneSuggestion.Builder(phoneId) + .setZoneId(zoneId) + .setMatchType(matchType) + .setQuality(quality) + .build(); + } + } + + private static SuggestionTestCase newTestCase( + @MatchType int matchType, @Quality int quality, int expectedScore) { + return new SuggestionTestCase(matchType, quality, expectedScore); + } +} |