Add a new time zone detection service

Add a new time zone detection service. Much of the code is from
frameworks/opt/telephony with some changes for naming, threading and
to modify the interaction with the "Callback" class.

Overall goal:

Implementing the service in the system server means it will be easier to
add new time zone detection logic unrelated to telephony in future.

Bug: 140712361
Test: atest com.android.server.timezonedetector
Test: atest android.app.timezonedetector
Change-Id: I89505fc4fecbd3667b60f8e1479b8f177eaa60ae
diff --git a/core/java/android/app/SystemServiceRegistry.java b/core/java/android/app/SystemServiceRegistry.java
index d9b3d3b..1829f74 100644
--- a/core/java/android/app/SystemServiceRegistry.java
+++ b/core/java/android/app/SystemServiceRegistry.java
@@ -34,6 +34,7 @@
 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 @@
                         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 0000000..260c7df
--- /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 0000000..3ad903b
--- /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 0000000..e8162488
--- /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 0000000..909cbc2
--- /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 4a0fc66..fba647b 100644
--- a/core/java/android/content/Context.java
+++ b/core/java/android/content/Context.java
@@ -3383,6 +3383,7 @@
             CROSS_PROFILE_APPS_SERVICE,
             //@hide: SYSTEM_UPDATE_SERVICE,
             //@hide: TIME_DETECTOR_SERVICE,
+            //@hide: TIME_ZONE_DETECTOR_SERVICE,
             PERMISSION_SERVICE,
     })
     @Retention(RetentionPolicy.SOURCE)
@@ -4835,7 +4836,7 @@
 
     /**
      * Use with {@link #getSystemService(String)} to retrieve an
-     * {@link android.app.timedetector.ITimeDetectorService}.
+     * {@link android.app.timedetector.TimeDetector}.
      * @hide
      *
      * @see #getSystemService(String)
@@ -4843,6 +4844,15 @@
     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 99dfffe..936099f 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 0000000..ae91edc
--- /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 0000000..23746ac
--- /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 0000000..558aa9e
--- /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 0000000..e24c089
--- /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 f46857a..80d6f77 100644
--- a/services/java/com/android/server/SystemServer.java
+++ b/services/java/com/android/server/SystemServer.java
@@ -268,6 +268,8 @@
             "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 @@
             }
             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 0000000..f9f23c3
--- /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);
+    }
+}