Merge "Migrate from SkCanvas::getGrContext to SkCanvas::recordingContext"
diff --git a/core/java/android/app/TEST_MAPPING b/core/java/android/app/TEST_MAPPING
index 4139b2f..b0307d1 100644
--- a/core/java/android/app/TEST_MAPPING
+++ b/core/java/android/app/TEST_MAPPING
@@ -70,7 +70,7 @@
             "name": "CtsAutoFillServiceTestCases",
             "options": [
                 {
-                    "include-filter": "android.autofillservice.cts.PreSimpleSaveActivityTest"
+                    "include-filter": "android.autofillservice.cts.saveui.PreSimpleSaveActivityTest"
                 },
                 {
                     "exclude-annotation": "androidx.test.filters.FlakyTest"
@@ -82,7 +82,7 @@
             "name": "CtsAutoFillServiceTestCases",
             "options": [
                 {
-                    "include-filter": "android.autofillservice.cts.SimpleSaveActivityTest"
+                    "include-filter": "android.autofillservice.cts.saveui.SimpleSaveActivityTest"
                 },
                 {
                     "exclude-annotation": "androidx.test.filters.FlakyTest"
diff --git a/core/java/android/service/notification/ZenModeConfig.java b/core/java/android/service/notification/ZenModeConfig.java
index 0827fef..49bc65b 100644
--- a/core/java/android/service/notification/ZenModeConfig.java
+++ b/core/java/android/service/notification/ZenModeConfig.java
@@ -1326,7 +1326,8 @@
             line2 = res.getString(R.string.zen_mode_until, formattedTime);
         } else {
             // display as day/time
-            summary = line1 = line2 = res.getString(R.string.zen_mode_until, formattedTime);
+            summary = line1 = line2 = res.getString(R.string.zen_mode_until_next_day,
+                    formattedTime);
         }
         final Uri id = toCountdownConditionId(time, false);
         return new Condition(id, summary, line1, line2, 0, Condition.STATE_TRUE,
diff --git a/core/java/com/android/internal/infra/AbstractRemoteService.java b/core/java/com/android/internal/infra/AbstractRemoteService.java
index b2852ea..722e5c1 100644
--- a/core/java/com/android/internal/infra/AbstractRemoteService.java
+++ b/core/java/com/android/internal/infra/AbstractRemoteService.java
@@ -225,6 +225,7 @@
         if (mService != null) {
             mService.asBinder().unlinkToDeath(this, 0);
         }
+        mBinding = false;
         mService = null;
         mServiceDied = true;
         cancelScheduledUnbind();
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index a5e5fba..03975da 100644
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -4888,6 +4888,9 @@
         <item quantity="other">For %d hr</item>
     </plurals>
 
+    <!-- Zen mode condition - line two: ending time indicating the next day. [CHAR LIMIT=NONE] -->
+    <string name="zen_mode_until_next_day">Until <xliff:g id="formattedTime" example="Tue, 10 PM">%1$s</xliff:g></string>
+
     <!-- Zen mode condition - line two: ending time. [CHAR LIMIT=NONE] -->
     <string name="zen_mode_until">Until <xliff:g id="formattedTime" example="10:00 PM">%1$s</xliff:g></string>
 
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index fdcd39a..8cd4b48 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -2393,6 +2393,7 @@
   <java-symbol type="plurals" name="zen_mode_duration_hours_short" />
   <java-symbol type="plurals" name="zen_mode_duration_minutes_summary_short" />
   <java-symbol type="plurals" name="zen_mode_duration_hours_summary_short" />
+  <java-symbol type="string" name="zen_mode_until_next_day" />
   <java-symbol type="string" name="zen_mode_until" />
   <java-symbol type="string" name="zen_mode_feature_name" />
   <java-symbol type="string" name="zen_mode_downtime_feature_name" />
diff --git a/media/TEST_MAPPING b/media/TEST_MAPPING
index 6dc0c6f..cf2f0f0 100644
--- a/media/TEST_MAPPING
+++ b/media/TEST_MAPPING
@@ -4,21 +4,13 @@
       "name": "GtsMediaTestCases",
       "options" : [
         {
-	  "include-annotation": "android.platform.test.annotations.Presubmit"
+          "include-annotation": "android.platform.test.annotations.Presubmit"
         },
         {
           "include-filter": "com.google.android.media.gts.WidevineGenericOpsTests"
-        }
-      ]
-    },
-    {
-      "name": "GtsExoPlayerTestCases",
-      "options" : [
-        {
-	  "include-annotation": "android.platform.test.annotations.SocPresubmit"
         },
         {
-          "include-filter": "com.google.android.exoplayer.gts.DashTest#testWidevine23FpsH264Fixed"
+          "include-filter": "com.google.android.media.gts.WidevineYouTubePerformanceTests"
         }
       ]
     }
diff --git a/media/jni/android_media_tv_Tuner.cpp b/media/jni/android_media_tv_Tuner.cpp
index 5db6729..5daf8b0 100644
--- a/media/jni/android_media_tv_Tuner.cpp
+++ b/media/jni/android_media_tv_Tuner.cpp
@@ -171,6 +171,12 @@
 
 void DestroyCallback(const C2Buffer * /* buf */, void *arg) {
     android::sp<android::MediaEvent> event = (android::MediaEvent *)arg;
+    if (event->mLinearBlockObj != NULL) {
+        JNIEnv *env = android::AndroidRuntime::getJNIEnv();
+        env->DeleteWeakGlobalRef(event->mLinearBlockObj);
+        event->mLinearBlockObj = NULL;
+    }
+
     event->mAvHandleRefCnt--;
     event->finalize();
 }
@@ -182,6 +188,12 @@
     mLnb = env->NewWeakGlobalRef(lnbObj);
 }
 
+LnbCallback::~LnbCallback() {
+    JNIEnv *env = AndroidRuntime::getJNIEnv();
+    env->DeleteWeakGlobalRef(mLnb);
+    mLnb = NULL;
+}
+
 Return<void> LnbCallback::onEvent(LnbEventType lnbEventType) {
     ALOGD("LnbCallback::onEvent, type=%d", lnbEventType);
     JNIEnv *env = AndroidRuntime::getJNIEnv();
@@ -305,6 +317,7 @@
     JNIEnv *env = AndroidRuntime::getJNIEnv();
     mMediaEventObj = env->NewWeakGlobalRef(obj);
     mAvHandle = native_handle_clone(avHandle.getNativeHandle());
+    mLinearBlockObj = NULL;
 }
 
 MediaEvent::~MediaEvent() {
@@ -367,7 +380,7 @@
                 true);
         mLinearBlockObj = env->NewWeakGlobalRef(linearBlock);
         mAvHandleRefCnt++;
-        return mLinearBlockObj;
+        return linearBlock;
     } else {
         native_handle_close(const_cast<native_handle_t*>(
                     reinterpret_cast<const native_handle_t*>(mIonHandle)));
diff --git a/media/jni/android_media_tv_Tuner.h b/media/jni/android_media_tv_Tuner.h
index fd29959..c4deeaf 100644
--- a/media/jni/android_media_tv_Tuner.h
+++ b/media/jni/android_media_tv_Tuner.h
@@ -73,6 +73,7 @@
 
 struct LnbCallback : public ILnbCallback {
     LnbCallback(jweak tunerObj, LnbId id);
+    ~LnbCallback();
     virtual Return<void> onEvent(LnbEventType lnbEventType);
     virtual Return<void> onDiseqcMessage(const hidl_vec<uint8_t>& diseqcMessage);
     jweak mLnb;
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/qs/QSTile.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/qs/QSTile.java
index e246917..26cead2 100644
--- a/packages/SystemUI/plugin/src/com/android/systemui/plugins/qs/QSTile.java
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/qs/QSTile.java
@@ -86,6 +86,10 @@
      */
     InstanceId getInstanceId();
 
+    default boolean isTileReady() {
+        return false;
+    }
+
     @ProvidesInterface(version = Callback.VERSION)
     public interface Callback {
         public static final int VERSION = 1;
diff --git a/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java b/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java
index a3339f6..0fd4765 100644
--- a/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/appops/AppOpsControllerImpl.java
@@ -16,8 +16,13 @@
 
 package com.android.systemui.appops;
 
+import static android.media.AudioManager.ACTION_MICROPHONE_MUTE_CHANGED;
+
 import android.app.AppOpsManager;
+import android.content.BroadcastReceiver;
 import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.pm.PackageManager;
 import android.location.LocationManager;
 import android.media.AudioManager;
@@ -34,6 +39,7 @@
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.systemui.Dumpable;
+import com.android.systemui.broadcast.BroadcastDispatcher;
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.dagger.qualifiers.Background;
 import com.android.systemui.dump.DumpManager;
@@ -54,7 +60,7 @@
  * NotificationPresenter to be displayed to the user.
  */
 @SysUISingleton
-public class AppOpsControllerImpl implements AppOpsController,
+public class AppOpsControllerImpl extends BroadcastReceiver implements AppOpsController,
         AppOpsManager.OnOpActiveChangedInternalListener,
         AppOpsManager.OnOpNotedListener, Dumpable {
 
@@ -65,6 +71,7 @@
     private static final String TAG = "AppOpsControllerImpl";
     private static final boolean DEBUG = false;
 
+    private final BroadcastDispatcher mDispatcher;
     private final AppOpsManager mAppOps;
     private final AudioManager mAudioManager;
     private final LocationManager mLocationManager;
@@ -79,6 +86,7 @@
     private final SparseArray<Set<Callback>> mCallbacksByCode = new SparseArray<>();
     private final PermissionFlagsCache mFlagsCache;
     private boolean mListening;
+    private boolean mMicMuted;
 
     @GuardedBy("mActiveItems")
     private final List<AppOpItem> mActiveItems = new ArrayList<>();
@@ -105,8 +113,10 @@
             @Background Looper bgLooper,
             DumpManager dumpManager,
             PermissionFlagsCache cache,
-            AudioManager audioManager
+            AudioManager audioManager,
+            BroadcastDispatcher dispatcher
     ) {
+        mDispatcher = dispatcher;
         mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
         mFlagsCache = cache;
         mBGHandler = new H(bgLooper);
@@ -115,6 +125,7 @@
             mCallbacksByCode.put(OPS[i], new ArraySet<>());
         }
         mAudioManager = audioManager;
+        mMicMuted = audioManager.isMicrophoneMute();
         mLocationManager = context.getSystemService(LocationManager.class);
         dumpManager.registerDumpable(TAG, this);
     }
@@ -133,6 +144,8 @@
             mAudioManager.registerAudioRecordingCallback(mAudioRecordingCallback, mBGHandler);
             mBGHandler.post(() -> mAudioRecordingCallback.onRecordingConfigChanged(
                     mAudioManager.getActiveRecordingConfigurations()));
+            mDispatcher.registerReceiverWithHandler(this,
+                    new IntentFilter(ACTION_MICROPHONE_MUTE_CHANGED), mBGHandler);
 
         } else {
             mAppOps.stopWatchingActive(this);
@@ -140,6 +153,7 @@
             mAudioManager.unregisterAudioRecordingCallback(mAudioRecordingCallback);
 
             mBGHandler.removeCallbacksAndMessages(null); // null removes all
+            mDispatcher.unregisterReceiver(this);
             synchronized (mActiveItems) {
                 mActiveItems.clear();
                 mRecordingsByUid.clear();
@@ -468,6 +482,9 @@
     }
 
     private boolean isAnyRecordingPausedLocked(int uid) {
+        if (mMicMuted) {
+            return true;
+        }
         List<AudioRecordingConfiguration> configs = mRecordingsByUid.get(uid);
         if (configs == null) return false;
         int configsNum = configs.size();
@@ -522,6 +539,12 @@
         }
     };
 
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        mMicMuted = mAudioManager.isMicrophoneMute();
+        updateRecordingPausedStatus();
+    }
+
     protected class H extends Handler {
         H(Looper looper) {
             super(looper);
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/UdfpsController.java b/packages/SystemUI/src/com/android/systemui/biometrics/UdfpsController.java
index 06c190f..ba78485 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/UdfpsController.java
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/UdfpsController.java
@@ -16,17 +16,16 @@
 
 package com.android.systemui.biometrics;
 
-import android.annotation.NonNull;
+import static com.android.internal.util.Preconditions.checkNotNull;
+
 import android.annotation.SuppressLint;
-import android.content.ContentResolver;
 import android.content.Context;
+import android.content.res.Resources;
 import android.content.res.TypedArray;
 import android.graphics.PixelFormat;
 import android.graphics.Point;
 import android.hardware.fingerprint.FingerprintManager;
 import android.hardware.fingerprint.IUdfpsOverlayController;
-import android.os.Handler;
-import android.os.Looper;
 import android.os.PowerManager;
 import android.os.UserHandle;
 import android.provider.Settings;
@@ -38,10 +37,16 @@
 import android.view.MotionEvent;
 import android.view.WindowManager;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
 import com.android.internal.BrightnessSynchronizer;
 import com.android.systemui.R;
+import com.android.systemui.dagger.qualifiers.Main;
 import com.android.systemui.doze.DozeReceiver;
 import com.android.systemui.plugins.statusbar.StatusBarStateController;
+import com.android.systemui.util.concurrency.DelayableExecutor;
+import com.android.systemui.util.settings.SystemSettings;
 
 import java.io.FileWriter;
 import java.io.IOException;
@@ -60,8 +65,8 @@
 
     private final FingerprintManager mFingerprintManager;
     private final WindowManager mWindowManager;
-    private final ContentResolver mContentResolver;
-    private final Handler mHandler;
+    private final SystemSettings mSystemSettings;
+    private final DelayableExecutor mFgExecutor;
     private final WindowManager.LayoutParams mLayoutParams;
     private final UdfpsView mView;
     // Debugfs path to control the high-brightness mode.
@@ -88,7 +93,7 @@
     // interrupt is being tracked and a timeout is used as a last resort to turn off high brightness
     // mode.
     private boolean mIsAodInterruptActive;
-    private final Runnable mAodInterruptTimeoutAction = this::onCancelAodInterrupt;
+    @Nullable private Runnable mCancelAodTimeoutAction;
 
     public class UdfpsOverlayController extends IUdfpsOverlayController.Stub {
         @Override
@@ -138,18 +143,27 @@
 
     @Inject
     UdfpsController(@NonNull Context context,
-            @NonNull StatusBarStateController statusBarStateController) {
-        mFingerprintManager = context.getSystemService(FingerprintManager.class);
-        mWindowManager = context.getSystemService(WindowManager.class);
-        mContentResolver = context.getContentResolver();
-        mHandler = new Handler(Looper.getMainLooper());
+            @Main Resources resources,
+            LayoutInflater inflater,
+            @Nullable FingerprintManager fingerprintManager,
+            PowerManager powerManager,
+            WindowManager windowManager,
+            SystemSettings systemSettings,
+            @NonNull StatusBarStateController statusBarStateController,
+            @Main DelayableExecutor fgExecutor) {
+        // The fingerprint manager is queried for UDFPS before this class is constructed, so the
+        // fingerprint manager should never be null.
+        mFingerprintManager = checkNotNull(fingerprintManager);
+        mWindowManager = windowManager;
+        mSystemSettings = systemSettings;
+        mFgExecutor = fgExecutor;
         mLayoutParams = createLayoutParams(context);
 
-        mView = (UdfpsView) LayoutInflater.from(context).inflate(R.layout.udfps_view, null, false);
+        mView = (UdfpsView) inflater.inflate(R.layout.udfps_view, null, false);
 
-        mHbmPath = context.getResources().getString(R.string.udfps_hbm_sysfs_path);
-        mHbmEnableCommand = context.getResources().getString(R.string.udfps_hbm_enable_command);
-        mHbmDisableCommand = context.getResources().getString(R.string.udfps_hbm_disable_command);
+        mHbmPath = resources.getString(R.string.udfps_hbm_sysfs_path);
+        mHbmEnableCommand = resources.getString(R.string.udfps_hbm_enable_command);
+        mHbmDisableCommand = resources.getString(R.string.udfps_hbm_disable_command);
 
         mHbmSupported = !TextUtils.isEmpty(mHbmPath);
         mView.setHbmSupported(mHbmSupported);
@@ -157,11 +171,11 @@
 
         // This range only consists of the minimum and maximum values, which only cover
         // non-high-brightness mode.
-        float[] nitsRange = toFloatArray(context.getResources().obtainTypedArray(
+        float[] nitsRange = toFloatArray(resources.obtainTypedArray(
                 com.android.internal.R.array.config_screenBrightnessNits));
 
         // The last value of this range corresponds to the high-brightness mode.
-        float[] nitsAutoBrightnessValues = toFloatArray(context.getResources().obtainTypedArray(
+        float[] nitsAutoBrightnessValues = toFloatArray(resources.obtainTypedArray(
                 com.android.internal.R.array.config_autoBrightnessDisplayValuesNits));
 
         mHbmNits = nitsAutoBrightnessValues[nitsAutoBrightnessValues.length - 1];
@@ -170,12 +184,12 @@
         // This range only consists of the minimum and maximum backlight values, which only apply
         // in non-high-brightness mode.
         float[] normalizedBacklightRange = normalizeBacklightRange(
-                context.getResources().getIntArray(
+                resources.getIntArray(
                         com.android.internal.R.array.config_screenBrightnessBacklight));
 
         mBacklightToNitsSpline = Spline.createSpline(normalizedBacklightRange, nitsRange);
         mNitsToHbmBacklightSpline = Spline.createSpline(hbmNitsRange, normalizedBacklightRange);
-        mDefaultBrightness = obtainDefaultBrightness(context);
+        mDefaultBrightness = obtainDefaultBrightness(powerManager);
 
         // TODO(b/160025856): move to the "dump" method.
         Log.v(TAG, String.format("ctor | mNitsRange: [%f, %f]", nitsRange[0], nitsRange[1]));
@@ -194,7 +208,7 @@
     }
 
     private void showUdfpsOverlay() {
-        mHandler.post(() -> {
+        mFgExecutor.execute(() -> {
             if (!mIsOverlayShowing) {
                 try {
                     Log.v(TAG, "showUdfpsOverlay | adding window");
@@ -211,7 +225,7 @@
     }
 
     private void hideUdfpsOverlay() {
-        mHandler.post(() -> {
+        mFgExecutor.execute(() -> {
             if (mIsOverlayShowing) {
                 Log.v(TAG, "hideUdfpsOverlay | removing window");
                 mView.setOnTouchListener(null);
@@ -228,7 +242,7 @@
     // Returns a value in the range of [0, 255].
     private int computeScrimOpacity() {
         // Backlight setting can be NaN, -1.0f, and [0.0f, 1.0f].
-        float backlightSetting = Settings.System.getFloatForUser(mContentResolver,
+        float backlightSetting = mSystemSettings.getFloatForUser(
                 Settings.System.SCREEN_BRIGHTNESS_FLOAT, mDefaultBrightness,
                 UserHandle.USER_CURRENT);
 
@@ -265,7 +279,8 @@
         // Since the sensor that triggers the AOD interrupt doesn't provide ACTION_UP/ACTION_CANCEL,
         // we need to be careful about not letting the screen accidentally remain in high brightness
         // mode. As a mitigation, queue a call to cancel the fingerprint scan.
-        mHandler.postDelayed(mAodInterruptTimeoutAction, AOD_INTERRUPT_TIMEOUT_MILLIS);
+        mCancelAodTimeoutAction = mFgExecutor.executeDelayed(this::onCancelAodInterrupt,
+                AOD_INTERRUPT_TIMEOUT_MILLIS);
         // using a hard-coded value for major and minor until it is available from the sensor
         onFingerDown(screenX, screenY, 13.0f, 13.0f);
     }
@@ -280,7 +295,10 @@
         if (!mIsAodInterruptActive) {
             return;
         }
-        mHandler.removeCallbacks(mAodInterruptTimeoutAction);
+        if (mCancelAodTimeoutAction != null) {
+            mCancelAodTimeoutAction.run();
+            mCancelAodTimeoutAction = null;
+        }
         mIsAodInterruptActive = false;
         onFingerUp();
     }
@@ -338,8 +356,7 @@
         return lp;
     }
 
-    private static float obtainDefaultBrightness(Context context) {
-        PowerManager powerManager = context.getSystemService(PowerManager.class);
+    private static float obtainDefaultBrightness(PowerManager powerManager) {
         if (powerManager == null) {
             Log.e(TAG, "PowerManager is unavailable. Can't obtain default brightness.");
             return 0f;
diff --git a/packages/SystemUI/src/com/android/systemui/qs/customize/TileQueryHelper.java b/packages/SystemUI/src/com/android/systemui/qs/customize/TileQueryHelper.java
index db77e08..73c6504 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/customize/TileQueryHelper.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/customize/TileQueryHelper.java
@@ -80,8 +80,6 @@
         mFinished = false;
         // Enqueue jobs to fetch every system tile and then ever package tile.
         addCurrentAndStockTiles(host);
-
-        addPackageTiles(host);
     }
 
     public boolean isFinished() {
@@ -122,23 +120,86 @@
                 tile.destroy();
                 continue;
             }
-            tile.setListening(this, true);
-            tile.refreshState();
-            tile.setListening(this, false);
             tile.setTileSpec(spec);
             tilesToAdd.add(tile);
         }
 
-        mBgExecutor.execute(() -> {
-            for (QSTile tile : tilesToAdd) {
-                final QSTile.State state = tile.getState().copy();
-                // Ignore the current state and get the generic label instead.
-                state.label = tile.getTileLabel();
-                tile.destroy();
-                addTile(tile.getTileSpec(), null, state, true);
+        new TileCollector(tilesToAdd, host).startListening();
+    }
+
+    private static class TilePair {
+        QSTile mTile;
+        boolean mReady = false;
+    }
+
+    private class TileCollector implements QSTile.Callback {
+
+        private final List<TilePair> mQSTileList = new ArrayList<>();
+        private final QSTileHost mQSTileHost;
+
+        TileCollector(List<QSTile> tilesToAdd, QSTileHost host) {
+            for (QSTile tile: tilesToAdd) {
+                TilePair pair = new TilePair();
+                pair.mTile = tile;
+                mQSTileList.add(pair);
             }
+            mQSTileHost = host;
+            if (tilesToAdd.isEmpty()) {
+                mBgExecutor.execute(this::finished);
+            }
+        }
+
+        private void finished() {
             notifyTilesChanged(false);
-        });
+            addPackageTiles(mQSTileHost);
+        }
+
+        private void startListening() {
+            for (TilePair pair: mQSTileList) {
+                pair.mTile.addCallback(this);
+                pair.mTile.setListening(this, true);
+                // Make sure that at least one refresh state happens
+                pair.mTile.refreshState();
+            }
+        }
+
+        // This is called in the Bg thread
+        @Override
+        public void onStateChanged(State s) {
+            boolean allReady = true;
+            for (TilePair pair: mQSTileList) {
+                if (!pair.mReady && pair.mTile.isTileReady()) {
+                    pair.mTile.removeCallback(this);
+                    pair.mTile.setListening(this, false);
+                    pair.mReady = true;
+                } else if (!pair.mReady) {
+                    allReady = false;
+                }
+            }
+            if (allReady) {
+                for (TilePair pair : mQSTileList) {
+                    QSTile tile = pair.mTile;
+                    final QSTile.State state = tile.getState().copy();
+                    // Ignore the current state and get the generic label instead.
+                    state.label = tile.getTileLabel();
+                    tile.destroy();
+                    addTile(tile.getTileSpec(), null, state, true);
+                }
+                finished();
+            }
+        }
+
+        @Override
+        public void onShowDetail(boolean show) {}
+
+        @Override
+        public void onToggleStateChanged(boolean state) {}
+
+        @Override
+        public void onScanStateChanged(boolean state) {}
+
+        @Override
+        public void onAnnouncementRequested(CharSequence announcement) {}
     }
 
     private void addPackageTiles(final QSTileHost host) {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileImpl.java b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileImpl.java
index 255513a..dfd7e2c 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileImpl.java
@@ -90,6 +90,10 @@
     private static final long DEFAULT_STALE_TIMEOUT = 10 * DateUtils.MINUTE_IN_MILLIS;
     protected static final Object ARG_SHOW_TRANSIENT_ENABLING = new Object();
 
+    private static final int READY_STATE_NOT_READY = 0;
+    private static final int READY_STATE_READYING = 1;
+    private static final int READY_STATE_READY = 2;
+
     protected final QSHost mHost;
     protected final Context mContext;
     // @NonFinalForTesting
@@ -101,6 +105,7 @@
     protected final ActivityStarter mActivityStarter;
     private final UiEventLogger mUiEventLogger;
     private final QSLogger mQSLogger;
+    private volatile int mReadyState;
 
     private final ArrayList<Callback> mCallbacks = new ArrayList<>();
     private final Object mStaleListener = new Object();
@@ -386,7 +391,11 @@
 
     protected void handleRefreshState(Object arg) {
         handleUpdateState(mTmpState, arg);
-        final boolean changed = mTmpState.copyTo(mState);
+        boolean changed = mTmpState.copyTo(mState);
+        if (mReadyState == READY_STATE_READYING) {
+            mReadyState = READY_STATE_READY;
+            changed = true;
+        }
         if (changed) {
             mQSLogger.logTileUpdated(mTileSpec, mState);
             handleStateChanged();
@@ -459,6 +468,9 @@
                     // should not refresh it anymore.
                     if (mLifecycle.getCurrentState().equals(DESTROYED)) return;
                     mLifecycle.setCurrentState(RESUMED);
+                    if (mReadyState == READY_STATE_NOT_READY) {
+                        mReadyState = READY_STATE_READYING;
+                    }
                     refreshState(); // Ensure we get at least one refresh after listening.
                 });
             }
@@ -531,6 +543,15 @@
      */
     public abstract CharSequence getTileLabel();
 
+    /**
+     * @return {@code true} if the tile has refreshed state at least once after having set its
+     *         lifecycle to {@link Lifecycle.State#RESUMED}.
+     */
+    @Override
+    public boolean isTileReady() {
+        return mReadyState == READY_STATE_READY;
+    }
+
     public static int getColorForState(Context context, int state) {
         switch (state) {
             case Tile.STATE_UNAVAILABLE:
diff --git a/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java
index 8f082c1..ade3290 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/appops/AppOpsControllerTest.java
@@ -47,6 +47,7 @@
 import androidx.test.filters.SmallTest;
 
 import com.android.systemui.SysuiTestCase;
+import com.android.systemui.broadcast.BroadcastDispatcher;
 import com.android.systemui.dump.DumpManager;
 
 import org.junit.Before;
@@ -82,6 +83,8 @@
     private PackageManager mPackageManager;
     @Mock(stubOnly = true)
     private AudioManager mAudioManager;
+    @Mock()
+    private BroadcastDispatcher mDispatcher;
     @Mock(stubOnly = true)
     private AudioManager.AudioRecordingCallback mRecordingCallback;
     @Mock(stubOnly = true)
@@ -120,7 +123,8 @@
                 mTestableLooper.getLooper(),
                 mDumpManager,
                 mFlagsCache,
-                mAudioManager
+                mAudioManager,
+                mDispatcher
         );
     }
 
@@ -128,12 +132,14 @@
     public void testOnlyListenForFewOps() {
         mController.setListening(true);
         verify(mAppOpsManager, times(1)).startWatchingActive(AppOpsControllerImpl.OPS, mController);
+        verify(mDispatcher, times(1)).registerReceiverWithHandler(eq(mController), any(), any());
     }
 
     @Test
     public void testStopListening() {
         mController.setListening(false);
         verify(mAppOpsManager, times(1)).stopWatchingActive(mController);
+        verify(mDispatcher, times(1)).unregisterReceiver(mController);
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/UdfpsControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/biometrics/UdfpsControllerTest.java
new file mode 100644
index 0000000..87ec72f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/UdfpsControllerTest.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.biometrics;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.hardware.fingerprint.FingerprintManager;
+import android.hardware.fingerprint.IUdfpsOverlayController;
+import android.os.PowerManager;
+import android.os.RemoteException;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper.RunWithLooper;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.WindowManager;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.plugins.statusbar.StatusBarStateController;
+import com.android.systemui.util.concurrency.FakeExecutor;
+import com.android.systemui.util.settings.FakeSettings;
+import com.android.systemui.util.time.FakeSystemClock;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@RunWithLooper
+public class UdfpsControllerTest extends SysuiTestCase {
+
+    @Rule
+    public MockitoRule rule = MockitoJUnit.rule();
+
+    // Unit under test
+    private UdfpsController mUdfpsController;
+
+    // Dependencies
+    @Mock
+    private Resources mResources;
+    @Mock
+    private LayoutInflater mLayoutInflater;
+    @Mock
+    private FingerprintManager mFingerprintManager;
+    @Mock
+    private PowerManager mPowerManager;
+    @Mock
+    private WindowManager mWindowManager;
+    @Mock
+    private StatusBarStateController mStatusBarStateController;
+    private FakeSettings mSystemSettings;
+    private FakeExecutor mFgExecutor;
+
+    // Stuff for configuring mocks
+    @Mock
+    private UdfpsView mUdfpsView;
+    @Mock
+    private TypedArray mBrightnessValues;
+    @Mock
+    private TypedArray mBrightnessBacklight;
+
+    // Capture listeners so that they can be used to send events
+    @Captor private ArgumentCaptor<IUdfpsOverlayController> mOverlayCaptor;
+    private IUdfpsOverlayController mOverlayController;
+    @Captor private ArgumentCaptor<UdfpsView.OnTouchListener> mTouchListenerCaptor;
+
+    @Before
+    public void setUp() {
+        setUpResources();
+        when(mLayoutInflater.inflate(R.layout.udfps_view, null, false)).thenReturn(mUdfpsView);
+        mSystemSettings = new FakeSettings();
+        mFgExecutor = new FakeExecutor(new FakeSystemClock());
+        mUdfpsController = new UdfpsController(
+                mContext,
+                mResources,
+                mLayoutInflater,
+                mFingerprintManager,
+                mPowerManager,
+                mWindowManager,
+                mSystemSettings,
+                mStatusBarStateController,
+                mFgExecutor);
+        verify(mFingerprintManager).setUdfpsOverlayController(mOverlayCaptor.capture());
+        mOverlayController = mOverlayCaptor.getValue();
+    }
+
+    private void setUpResources() {
+        when(mBrightnessValues.length()).thenReturn(2);
+        when(mBrightnessValues.getFloat(0, PowerManager.BRIGHTNESS_OFF_FLOAT)).thenReturn(1f);
+        when(mBrightnessValues.getFloat(1, PowerManager.BRIGHTNESS_OFF_FLOAT)).thenReturn(2f);
+        when(mResources.obtainTypedArray(com.android.internal.R.array.config_screenBrightnessNits))
+                .thenReturn(mBrightnessValues);
+        when(mBrightnessBacklight.length()).thenReturn(2);
+        when(mBrightnessBacklight.getFloat(0, PowerManager.BRIGHTNESS_OFF_FLOAT)).thenReturn(1f);
+        when(mBrightnessBacklight.getFloat(1, PowerManager.BRIGHTNESS_OFF_FLOAT)).thenReturn(2f);
+        when(mResources.obtainTypedArray(
+                com.android.internal.R.array.config_autoBrightnessDisplayValuesNits))
+                .thenReturn(mBrightnessBacklight);
+        when(mResources.getIntArray(com.android.internal.R.array.config_screenBrightnessBacklight))
+                .thenReturn(new int[]{1, 2});
+    }
+
+    @Test
+    public void dozeTimeTick() {
+        mUdfpsController.dozeTimeTick();
+        verify(mUdfpsView).dozeTimeTick();
+    }
+
+    @Test
+    public void showUdfpsOverlay_addsViewToWindow() throws RemoteException {
+        mOverlayController.showUdfpsOverlay();
+        mFgExecutor.runAllReady();
+        verify(mWindowManager).addView(eq(mUdfpsView), any());
+    }
+
+    @Test
+    public void hideUdfpsOverlay_removesViewFromWindow() throws RemoteException {
+        mOverlayController.showUdfpsOverlay();
+        mOverlayController.hideUdfpsOverlay();
+        mFgExecutor.runAllReady();
+        verify(mWindowManager).removeView(eq(mUdfpsView));
+    }
+
+    @Test
+    public void fingerDown() throws RemoteException {
+        // Configure UdfpsView to accept the ACTION_DOWN event
+        when(mUdfpsView.isScrimShowing()).thenReturn(false);
+        when(mUdfpsView.isValidTouch(anyFloat(), anyFloat(), anyFloat())).thenReturn(true);
+
+        // GIVEN that the overlay is showing
+        mOverlayController.showUdfpsOverlay();
+        mFgExecutor.runAllReady();
+        // WHEN ACTION_DOWN is received
+        verify(mUdfpsView).setOnTouchListener(mTouchListenerCaptor.capture());
+        MotionEvent event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
+        mTouchListenerCaptor.getValue().onTouch(mUdfpsView, event);
+        event.recycle();
+        // THEN the event is passed to the FingerprintManager
+        verify(mFingerprintManager).onFingerDown(eq(0), eq(0), eq(0f), eq(0f));
+        // AND the scrim and dot is shown
+        verify(mUdfpsView).showScrimAndDot();
+    }
+
+    @Test
+    public void aodInterrupt() throws RemoteException {
+        // GIVEN that the overlay is showing
+        mOverlayController.showUdfpsOverlay();
+        mFgExecutor.runAllReady();
+        // WHEN fingerprint is requested because of AOD interrupt
+        mUdfpsController.onAodInterrupt(0, 0);
+        // THEN the event is passed to the FingerprintManager
+        verify(mFingerprintManager).onFingerDown(eq(0), eq(0), anyFloat(), anyFloat());
+        // AND the scrim and dot is shown
+        verify(mUdfpsView).showScrimAndDot();
+    }
+
+    @Test
+    public void cancelAodInterrupt() throws RemoteException {
+        // GIVEN AOD interrupt
+        mOverlayController.showUdfpsOverlay();
+        mFgExecutor.runAllReady();
+        mUdfpsController.onAodInterrupt(0, 0);
+        // WHEN it is cancelled
+        mUdfpsController.onCancelAodInterrupt();
+        // THEN the scrim and dot is hidden
+        verify(mUdfpsView).hideScrimAndDot();
+    }
+
+    @Test
+    public void aodInterruptTimeout() throws RemoteException {
+        // GIVEN AOD interrupt
+        mOverlayController.showUdfpsOverlay();
+        mFgExecutor.runAllReady();
+        mUdfpsController.onAodInterrupt(0, 0);
+        // WHEN it times out
+        mFgExecutor.advanceClockToNext();
+        mFgExecutor.runAllReady();
+        // THEN the scrim and dot is hidden
+        verify(mUdfpsView).hideScrimAndDot();
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/customize/TileQueryHelperTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/customize/TileQueryHelperTest.java
index 53ef866..2ef7c65 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/customize/TileQueryHelperTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/customize/TileQueryHelperTest.java
@@ -34,6 +34,7 @@
 import static org.mockito.Mockito.when;
 
 import android.Manifest;
+import android.content.Context;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
@@ -47,8 +48,11 @@
 
 import androidx.test.filters.SmallTest;
 
+import com.android.internal.logging.InstanceId;
 import com.android.systemui.R;
 import com.android.systemui.SysuiTestCase;
+import com.android.systemui.plugins.qs.DetailAdapter;
+import com.android.systemui.plugins.qs.QSIconView;
 import com.android.systemui.plugins.qs.QSTile;
 import com.android.systemui.qs.QSTileHost;
 import com.android.systemui.util.concurrency.FakeExecutor;
@@ -68,6 +72,7 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.Executor;
 
 @SmallTest
 @RunWith(AndroidTestingRunner.class)
@@ -116,11 +121,9 @@
         doAnswer(invocation -> {
                     String spec = (String) invocation.getArguments()[0];
                     if (FACTORY_TILES.contains(spec)) {
-                        QSTile m = mock(QSTile.class);
-                        when(m.isAvailable()).thenReturn(true);
-                        when(m.getTileSpec()).thenReturn(spec);
-                        when(m.getState()).thenReturn(mState);
-                        return m;
+                        FakeQSTile tile = new FakeQSTile(mBgExecutor, mMainExecutor);
+                        tile.setState(mState);
+                        return tile;
                     } else {
                         return null;
                     }
@@ -292,4 +295,132 @@
         verifier.verify(t).setTileSpec("hotspot");
         verifier.verify(t).destroy();
     }
+
+    private static class FakeQSTile implements QSTile {
+
+        private String mSpec = "";
+        private List<Callback> mCallbacks = new ArrayList<>();
+        private boolean mRefreshed;
+        private boolean mListening;
+        private State mState = new State();
+        private final Executor mBgExecutor;
+        private final Executor mMainExecutor;
+
+        FakeQSTile(Executor bgExecutor, Executor mainExecutor) {
+            mBgExecutor = bgExecutor;
+            mMainExecutor = mainExecutor;
+        }
+
+        @Override
+        public String getTileSpec() {
+            return mSpec;
+        }
+
+        @Override
+        public boolean isAvailable() {
+            return true;
+        }
+
+        @Override
+        public void setTileSpec(String tileSpec) {
+            mSpec = tileSpec;
+        }
+
+        public void setState(State state) {
+            mState = state;
+            notifyChangedState(mState);
+        }
+
+        @Override
+        public void refreshState() {
+            mBgExecutor.execute(() -> {
+                mRefreshed = true;
+                notifyChangedState(mState);
+            });
+        }
+
+        private void notifyChangedState(State state) {
+            List<Callback> callbacks = new ArrayList<>(mCallbacks);
+            callbacks.forEach(callback -> callback.onStateChanged(state));
+        }
+
+        @Override
+        public void addCallback(Callback callback) {
+            mCallbacks.add(callback);
+        }
+
+        @Override
+        public void removeCallback(Callback callback) {
+            mCallbacks.remove(callback);
+        }
+
+        @Override
+        public void removeCallbacks() {
+            mCallbacks.clear();
+        }
+
+        @Override
+        public void setListening(Object client, boolean listening) {
+            if (listening) {
+                mMainExecutor.execute(() -> {
+                    mListening = true;
+                    refreshState();
+                });
+            }
+        }
+
+        @Override
+        public CharSequence getTileLabel() {
+            return mSpec;
+        }
+
+        @Override
+        public State getState() {
+            return mState;
+        }
+
+        @Override
+        public boolean isTileReady() {
+            return mListening && mRefreshed;
+        }
+
+        @Override
+        public QSIconView createTileView(Context context) {
+            return null;
+        }
+
+        @Override
+        public void click() {}
+
+        @Override
+        public void secondaryClick() {}
+
+        @Override
+        public void longClick() {}
+
+        @Override
+        public void userSwitch(int currentUser) {}
+
+        @Override
+        public int getMetricsCategory() {
+            return 0;
+        }
+
+        @Override
+        public InstanceId getInstanceId() {
+            return null;
+        }
+
+        @Override
+        public void setDetailListening(boolean show) {}
+
+        @Override
+        public void destroy() {}
+
+
+        @Override
+        public DetailAdapter getDetailAdapter() {
+            return null;
+        }
+    }
 }
diff --git a/services/core/java/com/android/server/am/CachedAppOptimizer.java b/services/core/java/com/android/server/am/CachedAppOptimizer.java
index 9660389..8112bb8 100644
--- a/services/core/java/com/android/server/am/CachedAppOptimizer.java
+++ b/services/core/java/com/android/server/am/CachedAppOptimizer.java
@@ -22,6 +22,7 @@
 
 import android.app.ActivityManager;
 import android.app.ActivityThread;
+import android.app.ApplicationExitInfo;
 import android.os.Debug;
 import android.os.Handler;
 import android.os.Message;
@@ -132,11 +133,15 @@
     static final int REPORT_UNFREEZE_MSG = 4;
 
     //TODO:change this static definition into a configurable flag.
-    static final int FREEZE_TIMEOUT_MS = 500;
+    static final int FREEZE_TIMEOUT_MS = 10000;
 
     static final int DO_FREEZE = 1;
     static final int REPORT_UNFREEZE = 2;
 
+    // Bitfield values for sync/async transactions reveived by frozen processes
+    static final int SYNC_RECEIVED_WHILE_FROZEN = 1;
+    static final int ASYNC_RECEIVED_WHILE_FROZEN = 2;
+
     /**
      * This thread must be moved to the system background cpuset.
      * If that doesn't happen, it's probably going to draw a lot of power.
@@ -494,6 +499,15 @@
     private static native void freezeBinder(int pid, boolean freeze);
 
     /**
+     * Retrieves binder freeze info about a process.
+     * @param pid the pid for which binder freeze info is to be retrieved.
+     *
+     * @throws RuntimeException if the operation could not complete successfully.
+     * @return a bit field reporting the binder freeze info for the process.
+     */
+    private static native int getBinderFreezeInfo(int pid);
+
+    /**
      * Determines whether the freezer is supported by this system
      */
     public static boolean isFreezerSupported() {
@@ -729,6 +743,37 @@
             return;
         }
 
+        boolean processKilled = false;
+
+        try {
+            int freezeInfo = getBinderFreezeInfo(app.pid);
+
+            if ((freezeInfo & SYNC_RECEIVED_WHILE_FROZEN) != 0) {
+                Slog.d(TAG_AM, "pid " + app.pid + " " + app.processName + " "
+                        + " received sync transactions while frozen, killing");
+                app.kill("Sync transaction while in frozen state",
+                        ApplicationExitInfo.REASON_OTHER,
+                        ApplicationExitInfo.SUBREASON_INVALID_STATE, true);
+                processKilled = true;
+            }
+
+            if ((freezeInfo & ASYNC_RECEIVED_WHILE_FROZEN) != 0) {
+                Slog.d(TAG_AM, "pid " + app.pid + " " + app.processName + " "
+                        + " received async transactions while frozen");
+            }
+        } catch (Exception e) {
+            Slog.d(TAG_AM, "Unable to query binder frozen info for pid " + app.pid + " "
+                    + app.processName + ". Killing it. Exception: " + e);
+            app.kill("Unable to query binder frozen stats",
+                    ApplicationExitInfo.REASON_OTHER,
+                    ApplicationExitInfo.SUBREASON_INVALID_STATE, true);
+            processKilled = true;
+        }
+
+        if (processKilled) {
+            return;
+        }
+
         long freezeTime = app.freezeUnfreezeTime;
 
         try {
@@ -745,8 +790,12 @@
             try {
                 freezeBinder(app.pid, false);
             } catch (RuntimeException e) {
-                // TODO: it might be preferable to kill the target pid in this case
-                Slog.e(TAG_AM, "Unable to unfreeze binder for " + app.pid + " " + app.processName);
+                Slog.e(TAG_AM, "Unable to unfreeze binder for " + app.pid + " " + app.processName
+                        + ". Killing it");
+                app.kill("Unable to unfreeze",
+                        ApplicationExitInfo.REASON_OTHER,
+                        ApplicationExitInfo.SUBREASON_INVALID_STATE, true);
+                return;
             }
 
             if (DEBUG_FREEZER) {
diff --git a/services/core/java/com/android/server/location/timezone/BinderLocationTimeZoneProvider.java b/services/core/java/com/android/server/location/timezone/BinderLocationTimeZoneProvider.java
new file mode 100644
index 0000000..92dabe3
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/BinderLocationTimeZoneProvider.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import static com.android.server.location.timezone.LocationTimeZoneManagerService.debugLog;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.location.timezone.LocationTimeZoneEvent;
+import android.util.IndentingPrintWriter;
+import android.util.Slog;
+
+import com.android.internal.location.timezone.LocationTimeZoneProviderRequest;
+
+import java.util.Objects;
+
+/**
+ * The real, system-server side implementation of a binder call backed {@link
+ * LocationTimeZoneProvider}. It handles keeping track of current state, timeouts and ensuring
+ * events are passed to the {@link LocationTimeZoneProviderController} on the required thread.
+ */
+class BinderLocationTimeZoneProvider extends LocationTimeZoneProvider {
+
+    private static final String TAG = LocationTimeZoneManagerService.TAG;
+
+    @NonNull private final LocationTimeZoneProviderProxy mProxy;
+
+    BinderLocationTimeZoneProvider(
+            @NonNull ThreadingDomain threadingDomain,
+            @NonNull String providerName,
+            @NonNull LocationTimeZoneProviderProxy proxy) {
+        super(threadingDomain, providerName);
+        mProxy = Objects.requireNonNull(proxy);
+    }
+
+    @Override
+    void onInitialize() {
+        mProxy.setListener(new LocationTimeZoneProviderProxy.Listener() {
+            @Override
+            public void onReportLocationTimeZoneEvent(
+                    @NonNull LocationTimeZoneEvent locationTimeZoneEvent) {
+                handleLocationTimeZoneEvent(locationTimeZoneEvent);
+            }
+
+            @Override
+            public void onProviderBound() {
+                handleOnProviderBound();
+            }
+
+            @Override
+            public void onProviderUnbound() {
+                handleProviderLost("onProviderUnbound()");
+            }
+        });
+    }
+
+    private void handleProviderLost(String reason) {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            ProviderState currentState = mCurrentState.get();
+            switch (currentState.stateEnum) {
+                case PROVIDER_STATE_ENABLED: {
+                    // Losing a remote provider is treated as becoming uncertain.
+                    String msg = "handleProviderLost reason=" + reason
+                            + ", mProviderName=" + mProviderName
+                            + ", currentState=" + currentState;
+                    debugLog(msg);
+                    // This is an unusual PROVIDER_STATE_ENABLED state because event == null
+                    ProviderState newState = currentState.newState(
+                            PROVIDER_STATE_ENABLED, null, currentState.currentUserConfiguration,
+                            msg);
+                    setCurrentState(newState, true);
+                    break;
+                }
+                case PROVIDER_STATE_DISABLED: {
+                    debugLog("handleProviderLost reason=" + reason
+                            + ", mProviderName=" + mProviderName
+                            + ", currentState=" + currentState
+                            + ": No state change required, provider is disabled.");
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    debugLog("handleProviderLost reason=" + reason
+                            + ", mProviderName=" + mProviderName
+                            + ", currentState=" + currentState
+                            + ": No state change required, provider is perm failed.");
+                    break;
+                }
+                default: {
+                    throw new IllegalStateException("Unknown currentState=" + currentState);
+                }
+            }
+        }
+    }
+
+    private void handleOnProviderBound() {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            ProviderState currentState = mCurrentState.get();
+            switch (currentState.stateEnum) {
+                case PROVIDER_STATE_ENABLED: {
+                    debugLog("handleOnProviderBound mProviderName=" + mProviderName
+                            + ", currentState=" + currentState + ": Provider is enabled.");
+                    break;
+                }
+                case PROVIDER_STATE_DISABLED: {
+                    debugLog("handleOnProviderBound mProviderName=" + mProviderName
+                            + ", currentState=" + currentState + ": Provider is disabled.");
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    debugLog("handleOnProviderBound"
+                            + ", mProviderName=" + mProviderName
+                            + ", currentState=" + currentState
+                            + ": No state change required, provider is perm failed.");
+                    break;
+                }
+                default: {
+                    throw new IllegalStateException("Unknown currentState=" + currentState);
+                }
+            }
+        }
+    }
+
+    @Override
+    void onEnable() {
+        // Set a request on the proxy - it will be sent immediately if the service is bound,
+        // or will be sent as soon as the service becomes bound.
+        // TODO(b/152744911): Decide whether to send a timeout so the provider knows how long
+        //  it has to generate the first event before it could be bypassed.
+        LocationTimeZoneProviderRequest request =
+                new LocationTimeZoneProviderRequest.Builder()
+                        .setReportLocationTimeZone(true)
+                        .build();
+        mProxy.setRequest(request);
+    }
+
+    @Override
+    void onDisable() {
+        LocationTimeZoneProviderRequest request =
+                new LocationTimeZoneProviderRequest.Builder()
+                        .setReportLocationTimeZone(false)
+                        .build();
+        mProxy.setRequest(request);
+    }
+
+    @Override
+    void logWarn(String msg) {
+        Slog.w(TAG, msg);
+    }
+
+    @Override
+    public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) {
+        synchronized (mSharedLock) {
+            ipw.println("{BinderLocationTimeZoneProvider}");
+            ipw.println("mProviderName=" + mProviderName);
+            ipw.println("mCurrentState=" + mCurrentState);
+            ipw.println("mProxy=" + mProxy);
+
+            ipw.println("State history:");
+            ipw.increaseIndent();
+            mCurrentState.dump(ipw);
+            ipw.decreaseIndent();
+
+            ipw.println("Proxy details:");
+            ipw.increaseIndent();
+            mProxy.dump(ipw, args);
+            ipw.decreaseIndent();
+        }
+    }
+
+    @Override
+    public String toString() {
+        synchronized (mSharedLock) {
+            return "BinderLocationTimeZoneProvider{"
+                    + "mProviderName=" + mProviderName
+                    + "mCurrentState=" + mCurrentState
+                    + "mProxy=" + mProxy
+                    + '}';
+        }
+    }
+
+    /**
+     * Passes the supplied simulation / testing event to the current proxy iff the proxy is a
+     * {@link SimulatedLocationTimeZoneProviderProxy}. If not, the event is logged but discarded.
+     */
+    void simulateBinderProviderEvent(SimulatedBinderProviderEvent event) {
+        if (!(mProxy instanceof SimulatedLocationTimeZoneProviderProxy)) {
+            Slog.w(TAG, mProxy + " is not a " + SimulatedLocationTimeZoneProviderProxy.class
+                    + ", event=" + event);
+            return;
+        }
+        ((SimulatedLocationTimeZoneProviderProxy) mProxy).simulate(event);
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/ControllerCallbackImpl.java b/services/core/java/com/android/server/location/timezone/ControllerCallbackImpl.java
new file mode 100644
index 0000000..cd9aa2f
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/ControllerCallbackImpl.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+
+import com.android.server.LocalServices;
+import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion;
+import com.android.server.timezonedetector.TimeZoneDetectorInternal;
+
+/**
+ * The real implementation of {@link LocationTimeZoneProviderController.Callback} used by
+ * {@link ControllerImpl} to interact with other server components.
+ */
+class ControllerCallbackImpl extends LocationTimeZoneProviderController.Callback {
+
+    ControllerCallbackImpl(@NonNull ThreadingDomain threadingDomain) {
+        super(threadingDomain);
+    }
+
+    @Override
+    void suggest(@NonNull GeolocationTimeZoneSuggestion suggestion) {
+        mThreadingDomain.assertCurrentThread();
+
+        TimeZoneDetectorInternal timeZoneDetector =
+                LocalServices.getService(TimeZoneDetectorInternal.class);
+        timeZoneDetector.suggestGeolocationTimeZone(suggestion);
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/ControllerEnvironmentImpl.java b/services/core/java/com/android/server/location/timezone/ControllerEnvironmentImpl.java
new file mode 100644
index 0000000..2e2481c
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/ControllerEnvironmentImpl.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+
+import com.android.server.LocalServices;
+import com.android.server.timezonedetector.ConfigurationInternal;
+import com.android.server.timezonedetector.TimeZoneDetectorInternal;
+
+import java.util.Objects;
+
+/**
+ * The real implementation of {@link LocationTimeZoneProviderController.Environment} used by
+ * {@link ControllerImpl} to interact with other server components.
+ */
+class ControllerEnvironmentImpl extends LocationTimeZoneProviderController.Environment {
+
+    @NonNull private final TimeZoneDetectorInternal mTimeZoneDetectorInternal;
+    @NonNull private final LocationTimeZoneProviderController mController;
+
+    ControllerEnvironmentImpl(@NonNull ThreadingDomain threadingDomain,
+            @NonNull LocationTimeZoneProviderController controller) {
+        super(threadingDomain);
+        mController = Objects.requireNonNull(controller);
+        mTimeZoneDetectorInternal = LocalServices.getService(TimeZoneDetectorInternal.class);
+
+        // Listen for configuration changes.
+        mTimeZoneDetectorInternal.addConfigurationListener(
+                () -> mThreadingDomain.post(mController::onConfigChanged));
+    }
+
+    @Override
+    ConfigurationInternal getCurrentUserConfigurationInternal() {
+        return mTimeZoneDetectorInternal.getCurrentUserConfigurationInternal();
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/ControllerImpl.java b/services/core/java/com/android/server/location/timezone/ControllerImpl.java
new file mode 100644
index 0000000..e31cfc4
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/ControllerImpl.java
@@ -0,0 +1,420 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_PERMANENT_FAILURE;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN;
+
+import static com.android.server.location.timezone.LocationTimeZoneManagerService.debugLog;
+import static com.android.server.location.timezone.LocationTimeZoneManagerService.warnLog;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.location.timezone.LocationTimeZoneEvent;
+import android.util.IndentingPrintWriter;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.location.timezone.ThreadingDomain.SingleRunnableQueue;
+import com.android.server.timezonedetector.ConfigurationInternal;
+import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion;
+
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * A real implementation of {@link LocationTimeZoneProviderController} that supports a single
+ * {@link LocationTimeZoneProvider}.
+ *
+ * TODO(b/152744911): This implementation currently only supports a single ("primary") provider.
+ *  Support for a secondary provider will be added in a later commit.
+ */
+class ControllerImpl extends LocationTimeZoneProviderController {
+
+    @VisibleForTesting
+    static final Duration UNCERTAINTY_DELAY = Duration.ofMinutes(5);
+
+    @NonNull private final LocationTimeZoneProvider mProvider;
+    @NonNull private final SingleRunnableQueue mDelayedSuggestionQueue;
+
+    @GuardedBy("mSharedLock")
+    // Non-null after initialize()
+    private ConfigurationInternal mCurrentUserConfiguration;
+
+    @GuardedBy("mSharedLock")
+    // Non-null after initialize()
+    private Environment mEnvironment;
+
+    @GuardedBy("mSharedLock")
+    // Non-null after initialize()
+    private Callback mCallback;
+
+    /**
+     * Contains any currently pending suggestion on {@link #mDelayedSuggestionQueue}, if there is
+     * one.
+     */
+    @GuardedBy("mSharedLock")
+    @Nullable
+    private GeolocationTimeZoneSuggestion mPendingSuggestion;
+
+    /** Contains the last suggestion actually made, if there is one. */
+    @GuardedBy("mSharedLock")
+    @Nullable
+    private GeolocationTimeZoneSuggestion mLastSuggestion;
+
+    ControllerImpl(@NonNull ThreadingDomain threadingDomain,
+            @NonNull LocationTimeZoneProvider provider) {
+        super(threadingDomain);
+        mDelayedSuggestionQueue = threadingDomain.createSingleRunnableQueue();
+        mProvider = Objects.requireNonNull(provider);
+    }
+
+    @Override
+    void initialize(@NonNull Environment environment, @NonNull Callback callback) {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            debugLog("initialize()");
+            mEnvironment = Objects.requireNonNull(environment);
+            mCallback = Objects.requireNonNull(callback);
+            mCurrentUserConfiguration = environment.getCurrentUserConfigurationInternal();
+
+            mProvider.initialize(ControllerImpl.this::onProviderStateChange);
+            enableOrDisableProvider(mCurrentUserConfiguration);
+        }
+    }
+
+    @Override
+    void onConfigChanged() {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            debugLog("onEnvironmentConfigChanged()");
+
+            ConfigurationInternal oldConfig = mCurrentUserConfiguration;
+            ConfigurationInternal newConfig = mEnvironment.getCurrentUserConfigurationInternal();
+            mCurrentUserConfiguration = newConfig;
+
+            if (!newConfig.equals(oldConfig)) {
+                if (newConfig.getUserId() != oldConfig.getUserId()) {
+                    // If the user changed, disable the provider if needed. It may be re-enabled for
+                    // the new user below if their settings allow.
+                    debugLog("User changed. old=" + oldConfig.getUserId()
+                            + ", new=" + newConfig.getUserId());
+                    debugLog("Disabling LocationTimeZoneProviders as needed");
+                    if (mProvider.getCurrentState().stateEnum == PROVIDER_STATE_ENABLED) {
+                        mProvider.disable();
+                    }
+                }
+
+                enableOrDisableProvider(newConfig);
+            }
+        }
+    }
+
+    @GuardedBy("mSharedLock")
+    private void enableOrDisableProvider(@NonNull ConfigurationInternal configuration) {
+        ProviderState providerState = mProvider.getCurrentState();
+        boolean geoDetectionEnabled = configuration.getGeoDetectionEnabledBehavior();
+        boolean providerWasEnabled = providerState.stateEnum == PROVIDER_STATE_ENABLED;
+        if (geoDetectionEnabled) {
+            switch (providerState.stateEnum) {
+                case PROVIDER_STATE_DISABLED: {
+                    debugLog("Enabling " + mProvider);
+                    mProvider.enable(configuration);
+                    break;
+                }
+                case PROVIDER_STATE_ENABLED: {
+                    debugLog("No need to enable " + mProvider + ": already enabled");
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    debugLog("Unable to enable " + mProvider + ": it is perm failed");
+                    break;
+                }
+                default:
+                    warnLog("Unknown provider state: " + mProvider);
+                    break;
+            }
+        } else {
+            switch (providerState.stateEnum) {
+                case PROVIDER_STATE_DISABLED: {
+                    debugLog("No need to disable " + mProvider + ": already enabled");
+                    break;
+                }
+                case PROVIDER_STATE_ENABLED: {
+                    debugLog("Disabling " + mProvider);
+                    mProvider.disable();
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    debugLog("Unable to disable " + mProvider + ": it is perm failed");
+                    break;
+                }
+                default: {
+                    warnLog("Unknown provider state: " + mProvider);
+                    break;
+                }
+            }
+        }
+
+        boolean isProviderEnabled =
+                mProvider.getCurrentState().stateEnum == PROVIDER_STATE_ENABLED;
+
+        if (isProviderEnabled) {
+            if (!providerWasEnabled) {
+                // When a provider has first been enabled, we allow it some time for it to
+                // initialize.
+                // This sets up an empty suggestion to trigger if no explicit "certain" or
+                // "uncertain" suggestion preempts it within UNCERTAINTY_DELAY. If, for some reason,
+                // the provider does provide any events then this scheduled suggestion will ensure
+                // the controller makes at least an uncertain suggestion.
+                suggestDelayed(createEmptySuggestion(
+                        "No event received in delay=" + UNCERTAINTY_DELAY), UNCERTAINTY_DELAY);
+            }
+        } else {
+            // Clear any queued suggestions.
+            clearDelayedSuggestion();
+
+            // If the provider is now not enabled, and a previous "certain" suggestion has been
+            // made, then a new "uncertain" suggestion must be made to indicate the provider no
+            // longer has an opinion and will not be sending updates.
+            if (mLastSuggestion != null && mLastSuggestion.getZoneIds() != null) {
+                suggestImmediate(createEmptySuggestion(""));
+            }
+        }
+    }
+
+    void onProviderStateChange(@NonNull ProviderState providerState) {
+        mThreadingDomain.assertCurrentThread();
+        assertProviderKnown(providerState.provider);
+
+        synchronized (mSharedLock) {
+            switch (providerState.stateEnum) {
+                case PROVIDER_STATE_DISABLED: {
+                    // This should never happen: entering disabled does not trigger an event.
+                    warnLog("onProviderStateChange: Unexpected state change for disabled provider,"
+                            + " providerState=" + providerState);
+                    break;
+                }
+                case PROVIDER_STATE_ENABLED: {
+                    // Entering enabled does not trigger an event, so this only happens if an event
+                    // is received while the provider is enabled.
+                    debugLog("onProviderStateChange: Received notification of an event while"
+                            + " enabled, providerState=" + providerState);
+                    providerEnabledProcessEvent(providerState);
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    debugLog("Received notification of permanent failure for"
+                            + " provider=" + providerState);
+                    GeolocationTimeZoneSuggestion suggestion = createEmptySuggestion(
+                            "provider=" + providerState.provider
+                                    + " permanently failed: " + providerState);
+                    suggestImmediate(suggestion);
+                    break;
+                }
+                default: {
+                    warnLog("onProviderStateChange: Unexpected providerState=" + providerState);
+                }
+            }
+        }
+    }
+
+    private void assertProviderKnown(LocationTimeZoneProvider provider) {
+        if (provider != mProvider) {
+            throw new IllegalArgumentException("Unknown provider: " + provider);
+        }
+    }
+
+    /**
+     * Called when a provider has changed state but just moved from a PROVIDER_STATE_ENABLED state
+     * to another PROVIDER_STATE_ENABLED state, usually as a result of a new {@link
+     * LocationTimeZoneEvent} being received. There are some cases where event can be null.
+     */
+    private void providerEnabledProcessEvent(@NonNull ProviderState providerState) {
+        LocationTimeZoneEvent event = providerState.event;
+        if (event == null) {
+            // Implicit uncertainty, i.e. where the provider is enabled, but a problem has been
+            // detected without having received an event. For example, if the process has detected
+            // the loss of a binder-based provider. This is treated like explicit uncertainty, i.e.
+            // where the provider has explicitly told this process it is uncertain.
+            scheduleUncertainSuggestionIfNeeded(null);
+            return;
+        }
+
+        // Consistency check for user. This may be possible as there are various races around
+        // current user switches.
+        if (!Objects.equals(event.getUserHandle(), mCurrentUserConfiguration.getUserHandle())) {
+            warnLog("Using event=" + event + " from a different user="
+                    + mCurrentUserConfiguration);
+        }
+
+        if (!mCurrentUserConfiguration.getGeoDetectionEnabledBehavior()) {
+            // This should not happen: the provider should not be in an enabled state if the user
+            // does not have geodetection enabled.
+            warnLog("Provider=" + providerState + " is enabled, but currentUserConfiguration="
+                    + mCurrentUserConfiguration + " suggests it shouldn't be.");
+        }
+
+        switch (event.getEventType()) {
+            case EVENT_TYPE_PERMANENT_FAILURE: {
+                // This shouldn't happen. Providers cannot be enabled and have this event.
+                warnLog("Provider=" + providerState
+                        + " is enabled, but event suggests it shouldn't be");
+                break;
+            }
+            case EVENT_TYPE_UNCERTAIN: {
+                scheduleUncertainSuggestionIfNeeded(event);
+                break;
+            }
+            case EVENT_TYPE_SUCCESS: {
+                GeolocationTimeZoneSuggestion suggestion =
+                        new GeolocationTimeZoneSuggestion(event.getTimeZoneIds());
+                suggestion.addDebugInfo("Event received provider=" + mProvider.getName()
+                        + ", event=" + event);
+                // Rely on the receiver to dedupe events. It is better to over-communicate.
+                suggestImmediate(suggestion);
+                break;
+            }
+            default: {
+                warnLog("Unknown eventType=" + event.getEventType());
+                break;
+            }
+        }
+    }
+
+    /**
+     * Indicates a provider has become uncertain with the event (if any) received that indicates
+     * that.
+     *
+     * <p>Providers are expected to report their uncertainty as soon as they become uncertain, as
+     * this enables the most flexibility for the controller to enable other providers when there are
+     * multiple ones. The controller is therefore responsible for deciding when to make a
+     * "uncertain" suggestion.
+     *
+     * <p>This method schedules an "uncertain" suggestion (if one isn't already scheduled) to be
+     * made later if nothing else preempts it. It can be preempted if the provider becomes certain
+     * (or does anything else that calls {@link #suggestImmediate(GeolocationTimeZoneSuggestion)})
+     * within UNCERTAINTY_DELAY. Preemption causes the scheduled "uncertain" event to be cancelled.
+     * If the provider repeatedly sends uncertainty events within UNCERTAINTY_DELAY, those events
+     * are effectively ignored (i.e. the timer is not reset each time).
+     */
+    private void scheduleUncertainSuggestionIfNeeded(@Nullable LocationTimeZoneEvent event) {
+        if (mPendingSuggestion == null || mPendingSuggestion.getZoneIds() != null) {
+            GeolocationTimeZoneSuggestion suggestion = createEmptySuggestion(
+                    "provider=" + mProvider + " became uncertain, event=" + event);
+            suggestDelayed(suggestion, UNCERTAINTY_DELAY);
+        }
+    }
+
+    @Override
+    public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) {
+        synchronized (mSharedLock) {
+            ipw.println("LocationTimeZoneProviderController:");
+
+            ipw.increaseIndent(); // level 1
+            ipw.println("mCurrentUserConfiguration=" + mCurrentUserConfiguration);
+            ipw.println("mPendingSuggestion=" + mPendingSuggestion);
+            ipw.println("mLastSuggestion=" + mLastSuggestion);
+
+            ipw.println("Provider:");
+            ipw.increaseIndent(); // level 2
+            mProvider.dump(ipw, args);
+            ipw.decreaseIndent(); // level 2
+
+            ipw.decreaseIndent(); // level 1
+        }
+    }
+
+    /** Sends an immediate suggestion, cancelling any pending suggestion. */
+    @GuardedBy("mSharedLock")
+    private void suggestImmediate(@NonNull GeolocationTimeZoneSuggestion suggestion) {
+        debugLog("suggestImmediate: Executing suggestion=" + suggestion);
+        mDelayedSuggestionQueue.runSynchronously(() -> mCallback.suggest(suggestion));
+        mPendingSuggestion = null;
+        mLastSuggestion = suggestion;
+    }
+
+    /** Clears any pending suggestion. */
+    @GuardedBy("mSharedLock")
+    private void clearDelayedSuggestion() {
+        mDelayedSuggestionQueue.cancel();
+        mPendingSuggestion = null;
+    }
+
+
+    /**
+     * Schedules a delayed suggestion. There can only be one delayed suggestion at a time.
+     * If there is a pending scheduled suggestion equal to the one passed, it will not be replaced.
+     * Replacing a previous delayed suggestion has the effect of cancelling the timeout associated
+     * with that previous suggestion.
+     */
+    @GuardedBy("mSharedLock")
+    private void suggestDelayed(@NonNull GeolocationTimeZoneSuggestion suggestion,
+            @NonNull Duration delay) {
+        Objects.requireNonNull(suggestion);
+        Objects.requireNonNull(delay);
+
+        if (Objects.equals(mPendingSuggestion, suggestion)) {
+            // Do not reset the timer.
+            debugLog("suggestDelayed: Suggestion=" + suggestion + " is equal to existing."
+                    + " Not scheduled.");
+            return;
+        }
+
+        debugLog("suggestDelayed: Scheduling suggestion=" + suggestion);
+        mPendingSuggestion = suggestion;
+
+        mDelayedSuggestionQueue.runDelayed(() -> {
+            debugLog("suggestDelayed: Executing suggestion=" + suggestion);
+            mCallback.suggest(suggestion);
+            mPendingSuggestion = null;
+            mLastSuggestion = suggestion;
+        }, delay.toMillis());
+    }
+
+    private static GeolocationTimeZoneSuggestion createEmptySuggestion(String reason) {
+        GeolocationTimeZoneSuggestion suggestion = new GeolocationTimeZoneSuggestion(null);
+        suggestion.addDebugInfo(reason);
+        return suggestion;
+    }
+
+    /**
+     * Asynchronously passes a {@link SimulatedBinderProviderEvent] to the appropriate provider.
+     * If the provider name does not match a known provider, then the event is logged and discarded.
+     */
+    void simulateBinderProviderEvent(SimulatedBinderProviderEvent event) {
+        if (!Objects.equals(mProvider.getName(), event.getProviderName())) {
+            warnLog("Unable to process simulated binder provider event,"
+                    + " unknown providerName in event=" + event);
+            return;
+        }
+        if (!(mProvider instanceof BinderLocationTimeZoneProvider)) {
+            warnLog("Unable to process simulated binder provider event,"
+                    + " provider is not a " + BinderLocationTimeZoneProvider.class
+                    + ", event=" + event);
+            return;
+        }
+        ((BinderLocationTimeZoneProvider) mProvider).simulateBinderProviderEvent(event);
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/HandlerThreadingDomain.java b/services/core/java/com/android/server/location/timezone/HandlerThreadingDomain.java
new file mode 100644
index 0000000..17e719e
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/HandlerThreadingDomain.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+import android.os.Handler;
+
+import java.util.Objects;
+
+/**
+ * The real implementation of {@link ThreadingDomain} that uses a {@link Handler}.
+ */
+final class HandlerThreadingDomain extends ThreadingDomain {
+
+    @NonNull private final Handler mHandler;
+
+    HandlerThreadingDomain(Handler handler) {
+        mHandler = Objects.requireNonNull(handler);
+    }
+
+    /**
+     * Returns the {@link Handler} associated with this threading domain. The same {@link Handler}
+     * may be associated with multiple threading domains, e.g. multiple threading domains could
+     * choose to use the {@link com.android.server.FgThread} handler.
+     *
+     * <p>If you find yourself making this public because you need a {@link Handler}, then it may
+     * cause problems with testability. Try to avoid using this method and use methods like {@link
+     * #post(Runnable)} instead.
+     */
+    @NonNull
+    Handler getHandler() {
+        return mHandler;
+    }
+
+    @NonNull
+    Thread getThread() {
+        return getHandler().getLooper().getThread();
+    }
+
+    @Override
+    void post(@NonNull Runnable r) {
+        getHandler().post(r);
+    }
+
+    @Override
+    void postDelayed(@NonNull Runnable r, long delayMillis) {
+        getHandler().postDelayed(r, delayMillis);
+    }
+
+    @Override
+    void postDelayed(Runnable r, Object token, long delayMillis) {
+        getHandler().postDelayed(r, token, delayMillis);
+    }
+
+    @Override
+    void removeQueuedRunnables(Object token) {
+        getHandler().removeCallbacksAndMessages(token);
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerService.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerService.java
new file mode 100644
index 0000000..238f999
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerService.java
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.os.Binder;
+import android.os.ResultReceiver;
+import android.os.ShellCallback;
+import android.os.SystemProperties;
+import android.util.IndentingPrintWriter;
+import android.util.Log;
+import android.util.Slog;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.DumpUtils;
+import com.android.server.FgThread;
+import com.android.server.SystemService;
+import com.android.server.timezonedetector.TimeZoneDetectorInternal;
+import com.android.server.timezonedetector.TimeZoneDetectorService;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.Objects;
+
+/**
+ * A service class that acts as a container for the {@link LocationTimeZoneProviderController},
+ * which determines what {@link com.android.server.timezonedetector.GeolocationTimeZoneSuggestion}
+ * are made to the {@link TimeZoneDetectorInternal}, and the {@link LocationTimeZoneProvider}s that
+ * offer {@link android.location.timezone.LocationTimeZoneEvent}s.
+ *
+ * TODO(b/152744911): This implementation currently only supports a primary provider. Support for a
+ *  secondary provider must be added in a later commit.
+ *
+ * <p>Implementation details:
+ *
+ * <p>For simplicity, with the exception of a few outliers like {@link #dump}, all processing in
+ * this service (and package-private helper objects) takes place on a single thread / handler, the
+ * one indicated by {@link ThreadingDomain}. Because methods like {@link #dump} can be invoked on
+ * another thread, the service and its related objects must still be thread-safe.
+ *
+ * <p>For testing / reproduction of bugs, it is possible to put providers into "simulation
+ * mode" where the real binder clients are replaced by {@link
+ * SimulatedLocationTimeZoneProviderProxy}. This means that the real client providers are never
+ * bound (ensuring no real location events will be received) and simulated events / behaviors
+ * can be injected via the command line. To enter simulation mode for a provider, use
+ * "{@code adb shell setprop persist.sys.location_tz_simulation_mode.<provider name> 1}" and reboot.
+ * e.g. "{@code adb shell setprop persist.sys.location_tz_simulation_mode.primary 1}}"
+ * Then use "{@code adb shell cmd location_time_zone_manager help}" for injection. Set the system
+ * properties to "0" and reboot to return to exit simulation mode.
+ */
+public class LocationTimeZoneManagerService extends Binder {
+
+    /**
+     * Controls lifecycle of the {@link LocationTimeZoneManagerService}.
+     */
+    public static class Lifecycle extends SystemService {
+
+        private LocationTimeZoneManagerService mService;
+
+        public Lifecycle(@NonNull Context context) {
+            super(Objects.requireNonNull(context));
+        }
+
+        @Override
+        public void onStart() {
+            if (TimeZoneDetectorService.GEOLOCATION_TIME_ZONE_DETECTION_ENABLED) {
+                Context context = getContext();
+                mService = new LocationTimeZoneManagerService(context);
+
+                // The service currently exposes no LocalService or Binder API, but it extends
+                // Binder and is registered as a binder service so it can receive shell commands.
+                publishBinderService("location_time_zone_manager", mService);
+            } else {
+                Slog.i(TAG, getClass() + " is compile-time disabled");
+            }
+        }
+
+        @Override
+        public void onBootPhase(int phase) {
+            if (TimeZoneDetectorService.GEOLOCATION_TIME_ZONE_DETECTION_ENABLED) {
+                if (phase == PHASE_SYSTEM_SERVICES_READY) {
+                    // The location service must be functioning after this boot phase.
+                    mService.onSystemReady();
+                } else if (phase == PHASE_THIRD_PARTY_APPS_CAN_START) {
+                    // Some providers rely on non-platform code (e.g. gcore), so we wait to
+                    // initialize providers until third party code is allowed to run.
+                    mService.onSystemThirdPartyAppsCanStart();
+                }
+            }
+        }
+    }
+
+    static final String TAG = "LocationTZDetector";
+
+    static final String PRIMARY_PROVIDER_NAME = "primary";
+
+    private static final String SIMULATION_MODE_SYSTEM_PROPERTY_PREFIX =
+            "persist.sys.location_tz_simulation_mode.";
+
+    private static final String ATTRIBUTION_TAG = "LocationTimeZoneService";
+
+    private static final String PRIMARY_LOCATION_TIME_ZONE_SERVICE_ACTION =
+            "com.android.location.timezone.service.v1.PrimaryLocationTimeZoneProvider";
+
+
+    @NonNull private final Context mContext;
+
+    /**
+     * The {@link ThreadingDomain} used to supply the {@link android.os.Handler} and shared lock
+     * object used by the controller and related components.
+     *
+     * <p>Most operations are executed on the associated handler thread <em>but not all</em>, hence
+     * the requirement for additional synchronization using a shared lock.
+     */
+    @NonNull private final ThreadingDomain mThreadingDomain;
+
+    /** The shared lock from {@link #mThreadingDomain}. */
+    @NonNull private final Object mSharedLock;
+
+    // Lazily initialized. Non-null and effectively final after onSystemThirdPartyAppsCanStart().
+    @GuardedBy("mSharedLock")
+    private ControllerImpl mLocationTimeZoneDetectorController;
+
+    LocationTimeZoneManagerService(Context context) {
+        mContext = context.createAttributionContext(ATTRIBUTION_TAG);
+        mThreadingDomain = new HandlerThreadingDomain(FgThread.getHandler());
+        mSharedLock = mThreadingDomain.getLockObject();
+    }
+
+    void onSystemReady() {
+        // Called on an arbitrary thread during initialization.
+        synchronized (mSharedLock) {
+            // TODO(b/152744911): LocationManagerService watches for packages disappearing. Need to
+            //  do anything here?
+
+            // TODO(b/152744911): LocationManagerService watches for foreground app changes. Need to
+            //  do anything here?
+            // TODO(b/152744911): LocationManagerService watches screen state. Need to do anything
+            //  here?
+        }
+    }
+
+    void onSystemThirdPartyAppsCanStart() {
+        // Called on an arbitrary thread during initialization.
+        synchronized (mSharedLock) {
+            LocationTimeZoneProvider primary = createPrimaryProvider();
+            mLocationTimeZoneDetectorController = new ControllerImpl(mThreadingDomain, primary);
+            ControllerCallbackImpl callback = new ControllerCallbackImpl(mThreadingDomain);
+            ControllerEnvironmentImpl environment = new ControllerEnvironmentImpl(
+                    mThreadingDomain, mLocationTimeZoneDetectorController);
+
+            // Initialize the controller on the mThreadingDomain thread: this ensures that the
+            // ThreadingDomain requirements for the controller / environment methods are honored.
+            mThreadingDomain.post(() ->
+                    mLocationTimeZoneDetectorController.initialize(environment, callback));
+        }
+    }
+
+    private LocationTimeZoneProvider createPrimaryProvider() {
+        LocationTimeZoneProviderProxy proxy;
+        if (isInSimulationMode(PRIMARY_PROVIDER_NAME)) {
+            proxy = new SimulatedLocationTimeZoneProviderProxy(mContext, mThreadingDomain);
+        } else {
+            // TODO Uncomment this code in a later commit.
+            throw new UnsupportedOperationException("Not implemented");
+            /*
+            proxy = RealLocationTimeZoneProviderProxy.createAndRegister(
+                    mContext,
+                    mThreadingDomain,
+                    PRIMARY_LOCATION_TIME_ZONE_SERVICE_ACTION,
+                    com.android.internal.R.bool.config_enablePrimaryLocationTimeZoneOverlay,
+                    com.android.internal.R.string.config_primaryLocationTimeZoneProviderPackageName
+            );
+            */
+        }
+        return createLocationTimeZoneProvider(PRIMARY_PROVIDER_NAME, proxy);
+    }
+
+    private boolean isInSimulationMode(String providerName) {
+        return SystemProperties.getBoolean(
+                SIMULATION_MODE_SYSTEM_PROPERTY_PREFIX + providerName, false);
+    }
+
+    private LocationTimeZoneProvider createLocationTimeZoneProvider(
+            @NonNull String providerName, @NonNull LocationTimeZoneProviderProxy proxy) {
+        LocationTimeZoneProvider provider;
+        if (proxy != null) {
+            debugLog("LocationTimeZoneProvider found for providerName=" + providerName);
+            provider = new BinderLocationTimeZoneProvider(mThreadingDomain,
+                    providerName, proxy);
+        } else {
+            debugLog("No LocationTimeZoneProvider found for providerName=" + providerName
+                    + ": stubbing");
+            provider = new NullLocationTimeZoneProvider(mThreadingDomain, providerName);
+        }
+        return provider;
+    }
+
+    @Override
+    public void onShellCommand(FileDescriptor in, FileDescriptor out,
+            FileDescriptor err, String[] args, ShellCallback callback,
+            ResultReceiver resultReceiver) {
+        (new LocationTimeZoneManagerShellCommand(this)).exec(
+                this, in, out, err, args, callback, resultReceiver);
+    }
+
+    /**
+     * Asynchronously passes a {@link SimulatedBinderProviderEvent] to the appropriate provider.
+     * The device must be in simulation mode, otherwise an {@link IllegalStateException} will be
+     * thrown.
+     */
+    void simulateBinderProviderEvent(SimulatedBinderProviderEvent event)
+            throws IllegalStateException {
+        if (!isInSimulationMode(event.getProviderName())) {
+            throw new IllegalStateException("Use \"setprop "
+                    + SIMULATION_MODE_SYSTEM_PROPERTY_PREFIX + event.getProviderName()
+                    + " 1\" and reboot before injecting simulated binder events.");
+        }
+        mThreadingDomain.post(
+                () -> mLocationTimeZoneDetectorController.simulateBinderProviderEvent(event));
+    }
+
+    @Override
+    protected void dump(@NonNull FileDescriptor fd, @NonNull PrintWriter pw,
+            @Nullable String[] args) {
+        if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return;
+
+        IndentingPrintWriter ipw = new IndentingPrintWriter(pw);
+        // Called on an arbitrary thread at any time.
+        synchronized (mSharedLock) {
+            ipw.println("LocationTimeZoneManagerService:");
+            ipw.increaseIndent();
+            if (mLocationTimeZoneDetectorController == null) {
+                ipw.println("{Uninitialized}");
+            } else {
+                mLocationTimeZoneDetectorController.dump(ipw, args);
+            }
+            ipw.decreaseIndent();
+        }
+    }
+
+    static void debugLog(String msg) {
+        if (Log.isLoggable(TAG, Log.DEBUG)) {
+            Slog.d(TAG, msg);
+        }
+    }
+
+    static void warnLog(String msg) {
+        if (Log.isLoggable(TAG, Log.WARN)) {
+            Slog.w(TAG, msg);
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerShellCommand.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerShellCommand.java
new file mode 100644
index 0000000..7c3b891
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneManagerShellCommand.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.location.timezone;
+
+import android.os.ShellCommand;
+
+import java.io.PrintWriter;
+
+/** Implements the shell command interface for {@link LocationTimeZoneManagerService}. */
+class LocationTimeZoneManagerShellCommand extends ShellCommand {
+
+    private final LocationTimeZoneManagerService mService;
+
+    LocationTimeZoneManagerShellCommand(LocationTimeZoneManagerService service) {
+        mService = service;
+    }
+
+    @Override
+    public int onCommand(String cmd) {
+        if (cmd == null) {
+            return handleDefaultCommands(cmd);
+        }
+
+        switch (cmd) {
+            case "simulate_binder": {
+                return runSimulateBinderEvent();
+            }
+            default: {
+                return handleDefaultCommands(cmd);
+            }
+        }
+    }
+
+    private int runSimulateBinderEvent() {
+        PrintWriter outPrintWriter = getOutPrintWriter();
+
+        SimulatedBinderProviderEvent simulatedProviderBinderEvent;
+        try {
+            simulatedProviderBinderEvent = SimulatedBinderProviderEvent.createFromArgs(this);
+        } catch (IllegalArgumentException e) {
+            outPrintWriter.println("Error: " + e.getMessage());
+            return 1;
+        }
+
+        outPrintWriter.println("Injecting: " + simulatedProviderBinderEvent);
+        try {
+            mService.simulateBinderProviderEvent(simulatedProviderBinderEvent);
+        } catch (IllegalStateException e) {
+            outPrintWriter.println("Error: " + e.getMessage());
+            return 2;
+        }
+        return 0;
+    }
+
+    @Override
+    public void onHelp() {
+        final PrintWriter pw = getOutPrintWriter();
+        pw.println("Location Time Zone Manager (location_time_zone_manager) commands:");
+        pw.println("  help");
+        pw.println("    Print this help text.");
+        pw.println("  simulate_binder");
+        pw.println("    <simulated provider binder event>");
+        pw.println();
+        SimulatedBinderProviderEvent.printCommandLineOpts(pw);
+        pw.println();
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneProvider.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProvider.java
new file mode 100644
index 0000000..3743779
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProvider.java
@@ -0,0 +1,511 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_PERMANENT_FAILURE;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN;
+
+import static com.android.server.location.timezone.LocationTimeZoneManagerService.debugLog;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.location.timezone.LocationTimeZoneEvent;
+import android.os.Handler;
+import android.os.SystemClock;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.server.timezonedetector.ConfigurationInternal;
+import com.android.server.timezonedetector.Dumpable;
+import com.android.server.timezonedetector.ReferenceWithHistory;
+
+import java.util.Objects;
+
+/**
+ * A facade used by the {@link LocationTimeZoneProviderController} to interact with a location time
+ * zone provider. The provider could have a binder implementation with logic running in another
+ * process, or could be a stubbed instance when no real provider is registered.
+ *
+ * <p>The provider is supplied with a {@link ProviderListener} via {@link
+ * #initialize(ProviderListener)}. This enables it to communicates asynchronous detection / error
+ * events back to the {@link LocationTimeZoneProviderController} via the {@link
+ * ProviderListener#onProviderStateChange} method. This call must be made on the
+ * {@link Handler} thread from the {@link ThreadingDomain} passed to the constructor.
+ *
+ * <p>All incoming calls from the controller except for {@link
+ * LocationTimeZoneProvider#dump(android.util.IndentingPrintWriter, String[])} will be made on the
+ * {@link Handler} thread of the {@link ThreadingDomain} passed to the constructor.
+ */
+abstract class LocationTimeZoneProvider implements Dumpable {
+
+    /**
+     * Listener interface used by the {@link LocationTimeZoneProviderController} to register an
+     * interest in provider events.
+     */
+    interface ProviderListener {
+        /**
+         * Indicated that a provider changed states. The {@code providerState} indicates which one
+         */
+        void onProviderStateChange(@NonNull ProviderState providerState);
+    }
+
+    /**
+     * Information about the provider's current state.
+     */
+    static class ProviderState {
+
+        @IntDef({ PROVIDER_STATE_UNKNOWN, PROVIDER_STATE_ENABLED, PROVIDER_STATE_DISABLED,
+                PROVIDER_STATE_PERM_FAILED })
+        @interface ProviderStateEnum {}
+
+        /**
+         * Uninitialized value. Must not be used afte {@link LocationTimeZoneProvider#initialize}.
+         */
+        static final int PROVIDER_STATE_UNKNOWN = 0;
+
+        /**
+         * The provider is currently enabled.
+         */
+        static final int PROVIDER_STATE_ENABLED = 1;
+
+        /**
+         * The provider is currently disabled.
+         * This is the state after {@link #initialize} is called.
+         */
+        static final int PROVIDER_STATE_DISABLED = 2;
+
+        /**
+         * The provider has failed and cannot be re-enabled.
+         *
+         * Providers may enter this state after a provider is enabled.
+         */
+        static final int PROVIDER_STATE_PERM_FAILED = 3;
+
+        /** The {@link LocationTimeZoneProvider} the state is for. */
+        public final @NonNull LocationTimeZoneProvider provider;
+
+        /** The state enum value of the current state. */
+        public final @ProviderStateEnum int stateEnum;
+
+        /**
+         * The last {@link LocationTimeZoneEvent} received. Only populated when {@link #stateEnum}
+         * is {@link #PROVIDER_STATE_ENABLED}, but it can be {@code null} then too if no event has
+         * yet been received.
+         */
+        @Nullable public final LocationTimeZoneEvent event;
+
+        /**
+         * The user configuration associated with the current state. Only and always present when
+         * {@link #stateEnum} is {@link #PROVIDER_STATE_ENABLED}.
+         */
+        @Nullable public final ConfigurationInternal currentUserConfiguration;
+
+        /**
+         * The time according to the elapsed realtime clock when the provider entered the current
+         * state. Included for debugging, not used for equality.
+         */
+        private final long mStateEntryTimeMillis;
+
+        /**
+         * Debug information providing context for the transition to this state. Included for
+         * debugging, not used for equality.
+         */
+        @Nullable private final String mDebugInfo;
+
+
+        private ProviderState(@NonNull LocationTimeZoneProvider provider,
+                @ProviderStateEnum int stateEnum, @Nullable LocationTimeZoneEvent event,
+                @Nullable ConfigurationInternal currentUserConfiguration,
+                @Nullable String debugInfo) {
+            this.provider = Objects.requireNonNull(provider);
+            this.stateEnum = stateEnum;
+            this.event = event;
+            this.currentUserConfiguration = currentUserConfiguration;
+            this.mStateEntryTimeMillis = SystemClock.elapsedRealtime();
+            this.mDebugInfo = debugInfo;
+        }
+
+        /** Creates the bootstrap state, uses {@link #PROVIDER_STATE_UNKNOWN}. */
+        static ProviderState createStartingState(
+                @NonNull LocationTimeZoneProvider provider) {
+            return new ProviderState(
+                    provider, PROVIDER_STATE_UNKNOWN, null, null, "Initial state");
+        }
+
+        /**
+         * Create a new state from this state. Validates that the state transition is valid
+         * and that the required parameters for the new state are present / absent.
+         */
+        ProviderState newState(@ProviderStateEnum int newStateEnum,
+                @Nullable LocationTimeZoneEvent event,
+                @Nullable ConfigurationInternal currentUserConfig,
+                @Nullable String debugInfo) {
+
+            // Check valid "from" transitions.
+            switch (this.stateEnum) {
+                case PROVIDER_STATE_UNKNOWN: {
+                    if (newStateEnum != PROVIDER_STATE_DISABLED) {
+                        throw new IllegalArgumentException(
+                                "Must transition from " + prettyPrintStateEnum(
+                                        PROVIDER_STATE_UNKNOWN)
+                                        + " to " + prettyPrintStateEnum(PROVIDER_STATE_DISABLED));
+                    }
+                    break;
+                }
+                case PROVIDER_STATE_DISABLED:
+                case PROVIDER_STATE_ENABLED: {
+                    // These can go to each other or PROVIDER_STATE_PERM_FAILED.
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    throw new IllegalArgumentException("Illegal transition out of "
+                            + prettyPrintStateEnum(PROVIDER_STATE_UNKNOWN));
+                }
+                default: {
+                    throw new IllegalArgumentException("Invalid this.stateEnum=" + this.stateEnum);
+                }
+            }
+
+            // Validate "to" transitions / arguments.
+            switch (newStateEnum) {
+                case PROVIDER_STATE_UNKNOWN: {
+                    throw new IllegalArgumentException("Cannot transition to "
+                            + prettyPrintStateEnum(PROVIDER_STATE_UNKNOWN));
+                }
+                case PROVIDER_STATE_DISABLED: {
+                    if (event != null || currentUserConfig != null) {
+                        throw new IllegalArgumentException(
+                                "Disabled state: event and currentUserConfig must be null"
+                                        + ", event=" + event
+                                        + ", currentUserConfig=" + currentUserConfig);
+                    }
+                    break;
+                }
+                case PROVIDER_STATE_ENABLED: {
+                    if (currentUserConfig == null) {
+                        throw new IllegalArgumentException(
+                                "Enabled state: currentUserConfig must not be null");
+                    }
+                    break;
+                }
+                case PROVIDER_STATE_PERM_FAILED: {
+                    if (event != null || currentUserConfig != null) {
+                        throw new IllegalArgumentException(
+                                "Perf failed state: event and currentUserConfig must be null"
+                                        + ", event=" + event
+                                        + ", currentUserConfig=" + currentUserConfig);
+                    }
+                    break;
+                }
+                default: {
+                    throw new IllegalArgumentException("Unknown newStateEnum=" + newStateEnum);
+                }
+            }
+            return new ProviderState(provider, newStateEnum, event, currentUserConfig, debugInfo);
+        }
+
+        @Override
+        public String toString() {
+            return "State{"
+                    + "stateEnum=" + prettyPrintStateEnum(stateEnum)
+                    + ", event=" + event
+                    + ", currentUserConfiguration=" + currentUserConfiguration
+                    + ", mStateEntryTimeMillis=" + mStateEntryTimeMillis
+                    + ", mDebugInfo=" + mDebugInfo
+                    + '}';
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+            ProviderState state = (ProviderState) o;
+            return stateEnum == state.stateEnum
+                    && Objects.equals(event, state.event)
+                    && Objects.equals(currentUserConfiguration, state.currentUserConfiguration);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(stateEnum, event, currentUserConfiguration);
+        }
+
+        private static String prettyPrintStateEnum(@ProviderStateEnum int state) {
+            switch (state) {
+                case PROVIDER_STATE_DISABLED:
+                    return "Disabled (" + PROVIDER_STATE_DISABLED + ")";
+                case PROVIDER_STATE_ENABLED:
+                    return "Enabled (" + PROVIDER_STATE_ENABLED + ")";
+                case PROVIDER_STATE_PERM_FAILED:
+                    return "Perm failure (" + PROVIDER_STATE_PERM_FAILED + ")";
+                case PROVIDER_STATE_UNKNOWN:
+                default:
+                    return "Unknown (" + state + ")";
+            }
+        }
+    }
+
+    @NonNull final ThreadingDomain mThreadingDomain;
+    @NonNull final Object mSharedLock;
+    @NonNull final String mProviderName;
+
+    /**
+     * The current state (with history for debugging).
+     */
+    @GuardedBy("mSharedLock")
+    final ReferenceWithHistory<ProviderState> mCurrentState =
+            new ReferenceWithHistory<>(10);
+
+    // Non-null and effectively final after initialize() is called.
+    ProviderListener mProviderListener;
+
+    /** Creates the instance. */
+    LocationTimeZoneProvider(@NonNull ThreadingDomain threadingDomain,
+            @NonNull String providerName) {
+        mThreadingDomain = Objects.requireNonNull(threadingDomain);
+        mSharedLock = threadingDomain.getLockObject();
+        mProviderName = Objects.requireNonNull(providerName);
+    }
+
+    /**
+     * Called before the provider is first used.
+     */
+    final void initialize(@NonNull ProviderListener providerListener) {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            if (mProviderListener != null) {
+                throw new IllegalStateException("initialize already called");
+            }
+            mProviderListener = Objects.requireNonNull(providerListener);
+            ProviderState currentState = ProviderState.createStartingState(this);
+            ProviderState newState = currentState.newState(
+                    PROVIDER_STATE_DISABLED, null, null, "initialize() called");
+            setCurrentState(newState, false);
+
+            onInitialize();
+        }
+    }
+
+    /**
+     * Implemented by subclasses to do work during {@link #initialize}.
+     */
+    abstract void onInitialize();
+
+    /**
+     * Set the current state, for use by this class and subclasses only. If {@code #notifyChanges}
+     * is {@code true} and {@code newState} is not equal to the old state, then {@link
+     * ProviderListener#onProviderStateChange(ProviderState)} must be called on
+     * {@link #mProviderListener}.
+     */
+    final void setCurrentState(@NonNull ProviderState newState, boolean notifyChanges) {
+        mThreadingDomain.assertCurrentThread();
+        synchronized (mSharedLock) {
+            ProviderState oldState = mCurrentState.get();
+            mCurrentState.set(newState);
+            onSetCurrentState(newState);
+            if (notifyChanges) {
+                if (!Objects.equals(newState, oldState)) {
+                    mProviderListener.onProviderStateChange(newState);
+                }
+            }
+        }
+    }
+
+    /**
+     * Overridden by subclasses to do work during {@link #setCurrentState}.
+     */
+    @GuardedBy("mSharedLock")
+    void onSetCurrentState(ProviderState newState) {
+        // Default no-op.
+    }
+
+    /**
+     * Returns the current state of the provider. This method must be called using the handler
+     * thread from the {@link ThreadingDomain}.
+     */
+    @NonNull
+    final ProviderState getCurrentState() {
+        mThreadingDomain.assertCurrentThread();
+        synchronized (mSharedLock) {
+            return mCurrentState.get();
+        }
+    }
+
+    /**
+     * Returns the name of the provider. This method must be called using the handler thread from
+     * the {@link ThreadingDomain}.
+     */
+    final String getName() {
+        mThreadingDomain.assertCurrentThread();
+        return mProviderName;
+    }
+
+    /**
+     * Enables the provider. It is an error to call this method except when the {@link
+     * #getCurrentState()} is at {@link ProviderState#PROVIDER_STATE_DISABLED}. This method must be
+     * called using the handler thread from the {@link ThreadingDomain}.
+     */
+    final void enable(@NonNull ConfigurationInternal currentUserConfiguration) {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            assertCurrentState(PROVIDER_STATE_DISABLED);
+
+            ProviderState currentState = getCurrentState();
+            ProviderState newState = currentState.newState(
+                    PROVIDER_STATE_ENABLED, null, currentUserConfiguration, "enable() called");
+            setCurrentState(newState, false);
+            onEnable();
+        }
+    }
+
+    /**
+     * Implemented by subclasses to do work during {@link #enable}.
+     */
+    abstract void onEnable();
+
+    /**
+     * Disables the provider. It is an error* to call this method except when the {@link
+     * #getCurrentState()} is at {@link ProviderState#PROVIDER_STATE_ENABLED}. This method must be
+     * called using the handler thread from the {@link ThreadingDomain}.
+     */
+    final void disable() {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            assertCurrentState(PROVIDER_STATE_ENABLED);
+
+            ProviderState currentState = getCurrentState();
+            ProviderState newState =
+                    currentState.newState(PROVIDER_STATE_DISABLED, null, null, "disable() called");
+            setCurrentState(newState, false);
+
+            onDisable();
+        }
+    }
+
+    /**
+     * Implemented by subclasses to do work during {@link #disable}.
+     */
+    abstract void onDisable();
+
+    /** For subclasses to invoke when a {@link LocationTimeZoneEvent} has been received. */
+    final void handleLocationTimeZoneEvent(
+            @NonNull LocationTimeZoneEvent locationTimeZoneEvent) {
+        mThreadingDomain.assertCurrentThread();
+        Objects.requireNonNull(locationTimeZoneEvent);
+
+        synchronized (mSharedLock) {
+            debugLog("handleLocationTimeZoneEvent: mProviderName=" + mProviderName
+                    + ", locationTimeZoneEvent=" + locationTimeZoneEvent);
+
+            ProviderState currentState = getCurrentState();
+            int eventType = locationTimeZoneEvent.getEventType();
+            switch (currentState.stateEnum) {
+                case PROVIDER_STATE_PERM_FAILED: {
+                    // After entering perm failed, there is nothing to do. The remote peer is
+                    // supposed to stop sending events after it has reported perm failure.
+                    logWarn("handleLocationTimeZoneEvent: Event=" + locationTimeZoneEvent
+                            + " received for provider=" + this + " when in failed state");
+                    return;
+                }
+                case PROVIDER_STATE_DISABLED: {
+                    switch (eventType) {
+                        case EVENT_TYPE_PERMANENT_FAILURE: {
+                            String msg = "handleLocationTimeZoneEvent:"
+                                    + " Failure event=" + locationTimeZoneEvent
+                                    + " received for disabled provider=" + this
+                                    + ", entering permanently failed state";
+                            logWarn(msg);
+                            ProviderState newState = currentState.newState(
+                                    PROVIDER_STATE_PERM_FAILED, null, null, msg);
+                            setCurrentState(newState, true);
+                            return;
+                        }
+                        case EVENT_TYPE_SUCCESS:
+                        case EVENT_TYPE_UNCERTAIN: {
+                            // Any geolocation-related events received for a disabled provider are
+                            // ignored: they should not happen.
+                            logWarn("handleLocationTimeZoneEvent:"
+                                    + " event=" + locationTimeZoneEvent
+                                    + " received for disabled provider=" + this
+                                    + ", ignoring");
+
+                            return;
+                        }
+                        default: {
+                            throw new IllegalStateException(
+                                    "Unknown eventType=" + locationTimeZoneEvent);
+                        }
+                    }
+                }
+                case PROVIDER_STATE_ENABLED: {
+                    switch (eventType) {
+                        case EVENT_TYPE_PERMANENT_FAILURE: {
+                            String msg = "handleLocationTimeZoneEvent:"
+                                    + " Failure event=" + locationTimeZoneEvent
+                                    + " received for provider=" + this
+                                    + ", entering permanently failed state";
+                            logWarn(msg);
+                            ProviderState newState = currentState.newState(
+                                    PROVIDER_STATE_PERM_FAILED, null, null, msg);
+                            setCurrentState(newState, true);
+                            return;
+                        }
+                        case EVENT_TYPE_UNCERTAIN:
+                        case EVENT_TYPE_SUCCESS: {
+                            ProviderState newState = currentState.newState(PROVIDER_STATE_ENABLED,
+                                    locationTimeZoneEvent, currentState.currentUserConfiguration,
+                                    "handleLocationTimeZoneEvent() when enabled");
+                            setCurrentState(newState, true);
+                            return;
+                        }
+                        default: {
+                            throw new IllegalStateException(
+                                    "Unknown eventType=" + locationTimeZoneEvent);
+                        }
+                    }
+                }
+                default: {
+                    throw new IllegalStateException("Unknown providerType=" + currentState);
+                }
+            }
+        }
+    }
+
+    /**
+     * Implemented by subclasses.
+     */
+    abstract void logWarn(String msg);
+
+    private void assertCurrentState(@ProviderState.ProviderStateEnum int requiredState) {
+        ProviderState currentState = getCurrentState();
+        if (currentState.stateEnum != requiredState) {
+            throw new IllegalStateException(
+                    "Required stateEnum=" + requiredState + ", but was " + currentState);
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderController.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderController.java
new file mode 100644
index 0000000..2f75c43
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderController.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+import android.os.Handler;
+
+import com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState;
+import com.android.server.timezonedetector.ConfigurationInternal;
+import com.android.server.timezonedetector.Dumpable;
+import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion;
+
+import java.util.Objects;
+
+/**
+ * An base class for the component responsible handling events from {@link
+ * LocationTimeZoneProvider}s and synthesizing time zone ID suggestions for sending to the time zone
+ * detector. This interface primarily exists to extract testable detection logic, i.e. with
+ * a minimal number of threading considerations or dependencies on Android infrastructure.
+ *
+ * <p>The controller interacts with the following components:
+ * <ul>
+ *     <li>The surrounding service, which calls {@link #initialize(Environment, Callback)} and
+ *     {@link #onConfigChanged()}.</li>
+ *     <li>The {@link Environment} through which obtains information it needs.</li>
+ *     <li>The {@link Callback} through which it makes time zone suggestions.</li>
+ *     <li>Any {@link LocationTimeZoneProvider} instances it owns, which communicate via the
+ *     {@link LocationTimeZoneProvider.ProviderListener#onProviderStateChange(ProviderState)}
+ *     method.</li>
+ * </ul>
+ *
+ * <p>All incoming calls except for {@link
+ * LocationTimeZoneProviderController#dump(android.util.IndentingPrintWriter, String[])} must be
+ * made on the {@link Handler} thread of the {@link ThreadingDomain} passed to {@link
+ * #LocationTimeZoneProviderController(ThreadingDomain)}.
+ *
+ * <p>Provider / controller integration notes:
+ *
+ * <p>Providers distinguish between "unknown unknowns" ("uncertain") and "known unknowns"
+ * ("certain"), i.e. a provider can be uncertain and not know what the time zone is, which is
+ * different from the certainty that there are no time zone IDs for the current location. A provider
+ * can be certain about there being no time zone IDs for a location for good reason, e.g. for
+ * disputed areas and oceans. Distinguishing uncertainty allows the controller to try other
+ * providers (or give up), where as certainty means it should not.
+ *
+ * <p>A provider can fail permanently. A permanent failure will disable the provider until next
+ * boot.
+ */
+abstract class LocationTimeZoneProviderController implements Dumpable {
+
+    @NonNull protected final ThreadingDomain mThreadingDomain;
+    @NonNull protected final Object mSharedLock;
+
+    LocationTimeZoneProviderController(@NonNull ThreadingDomain threadingDomain) {
+        mThreadingDomain = Objects.requireNonNull(threadingDomain);
+        mSharedLock = threadingDomain.getLockObject();
+    }
+
+    /**
+     * Called to initialize the controller during boot. Called once only.
+     * {@link LocationTimeZoneProvider#initialize} must be called by this method.
+     */
+    abstract void initialize(@NonNull Environment environment, @NonNull Callback callback);
+
+    /**
+     * Called when any settings or other device state that affect location-based time zone detection
+     * have changed. The receiver should call {@link
+     * Environment#getCurrentUserConfigurationInternal()} to get the current user's config. This
+     * call must be made on the {@link ThreadingDomain} handler thread.
+     */
+    abstract void onConfigChanged();
+
+    /**
+     * Used by {@link LocationTimeZoneProviderController} to obtain information from the surrounding
+     * service. It can easily be faked for tests.
+     */
+    abstract static class Environment {
+
+        @NonNull protected final ThreadingDomain mThreadingDomain;
+        @NonNull protected final Object mSharedLock;
+
+        Environment(@NonNull ThreadingDomain threadingDomain) {
+            mThreadingDomain = Objects.requireNonNull(threadingDomain);
+            mSharedLock = threadingDomain.getLockObject();
+        }
+
+        /** Returns the {@link ConfigurationInternal} for the current user of the device. */
+        abstract ConfigurationInternal getCurrentUserConfigurationInternal();
+    }
+
+    /**
+     * Used by {@link LocationTimeZoneProviderController} to interact with the surrounding service.
+     * It can easily be faked for tests.
+     */
+    abstract static class Callback {
+
+        @NonNull protected final ThreadingDomain mThreadingDomain;
+        @NonNull protected final Object mSharedLock;
+
+        Callback(@NonNull ThreadingDomain threadingDomain) {
+            mThreadingDomain = Objects.requireNonNull(threadingDomain);
+            mSharedLock = threadingDomain.getLockObject();
+        }
+
+        /**
+         * Suggests the latest time zone state for the device.
+         */
+        abstract void suggest(@NonNull GeolocationTimeZoneSuggestion suggestion);
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderProxy.java b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderProxy.java
new file mode 100644
index 0000000..3d889ae
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/LocationTimeZoneProviderProxy.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.location.timezone.LocationTimeZoneEvent;
+import android.os.Handler;
+import android.util.IndentingPrintWriter;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.location.timezone.LocationTimeZoneProviderRequest;
+import com.android.server.timezonedetector.Dumpable;
+
+import java.util.Objects;
+
+/**
+ * System server-side proxy for ILocationTimeZoneProvider implementations, i.e. this provides the
+ * system server object used to communicate with a remote LocationTimeZoneProvider over Binder,
+ * which could be running in a different process. As LocationTimeZoneProviders are bound / unbound
+ * this proxy will rebind to the "best" available remote process.
+ *
+ * <p>Threading guarantees provided / required by this interface:
+ * <ul>
+ *     <li>All public methods defined by this class must be invoked using the {@link Handler} thread
+ *     from the {@link ThreadingDomain} passed to the constructor, excluding
+ *     {@link #dump(IndentingPrintWriter, String[])}</li>
+ *     <li>Non-static public methods that make binder calls to remote processes (e.g.
+ *     {@link #setRequest(LocationTimeZoneProviderRequest)}) are executed asynchronously and will
+ *     return immediately.</li>
+ *     <li>Callbacks received via binder are delivered via {@link Listener} are delivered on the
+ *     {@link Handler} thread from the {@link ThreadingDomain} passed to the constructor.
+ * </ul>
+ *
+ * <p>This class exists to enable the introduction of test implementations of {@link
+ * LocationTimeZoneProviderProxy} that can be used when a device is in a test mode to inject test
+ * events / behavior that are otherwise difficult to simulate.
+ */
+abstract class LocationTimeZoneProviderProxy implements Dumpable {
+
+    @NonNull protected final Context mContext;
+    @NonNull protected final ThreadingDomain mThreadingDomain;
+    @NonNull protected final Object mSharedLock;
+
+    // Non-null and effectively final after setListener() is called.
+    @GuardedBy("mSharedLock")
+    @Nullable
+    protected Listener mListener;
+
+    LocationTimeZoneProviderProxy(
+            @NonNull Context context, @NonNull ThreadingDomain threadingDomain) {
+        mContext = Objects.requireNonNull(context);
+        mThreadingDomain = Objects.requireNonNull(threadingDomain);
+        mSharedLock = threadingDomain.getLockObject();
+    }
+
+    /**
+     * Sets the listener. The listener can expect to receive all events after this point.
+     */
+    void setListener(@NonNull Listener listener) {
+        Objects.requireNonNull(listener);
+        synchronized (mSharedLock) {
+            if (mListener != null) {
+                throw new IllegalStateException("listener already set");
+            }
+            this.mListener = listener;
+        }
+    }
+
+    /**
+     * Sets a new request for the provider.
+     */
+    abstract void setRequest(@NonNull LocationTimeZoneProviderRequest request);
+
+    /**
+     * Handles a {@link LocationTimeZoneEvent} from a remote process.
+     */
+    final void handleLocationTimeZoneEvent(
+            @NonNull LocationTimeZoneEvent locationTimeZoneEvent) {
+        // These calls are invoked on a binder thread. Move to the mThreadingDomain thread as
+        // required by the guarantees for this class.
+        mThreadingDomain.post(() -> mListener.onReportLocationTimeZoneEvent(locationTimeZoneEvent));
+    }
+
+    /**
+     * Interface for listening to location time zone providers. See {@link
+     * LocationTimeZoneProviderProxy} for threading guarantees.
+     */
+    interface Listener {
+
+        /**
+         * Called when a provider receives a {@link LocationTimeZoneEvent}.
+         */
+        void onReportLocationTimeZoneEvent(@NonNull LocationTimeZoneEvent locationTimeZoneEvent);
+
+        /**
+         * Called when a provider is (re)bound.
+         */
+        void onProviderBound();
+
+        /** Called when a provider is unbound. */
+        void onProviderUnbound();
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/NullLocationTimeZoneProvider.java b/services/core/java/com/android/server/location/timezone/NullLocationTimeZoneProvider.java
new file mode 100644
index 0000000..79e2b97
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/NullLocationTimeZoneProvider.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.IndentingPrintWriter;
+import android.util.Slog;
+
+/**
+ * A {@link LocationTimeZoneProvider} that provides minimal responses needed for the {@link
+ * LocationTimeZoneProviderController} to operate correctly when there is no "real" provider
+ * configured. This can be used during development / testing, or in a production build when the
+ * platform supports more providers than are needed for an Android deployment.
+ *
+ * <p>For example, if the {@link LocationTimeZoneProviderController} supports a primary
+ * and a secondary {@link LocationTimeZoneProvider}, but only a primary is configured, the secondary
+ * config will be left null and the {@link LocationTimeZoneProvider} implementation will be
+ * defaulted to a {@link NullLocationTimeZoneProvider}. The {@link NullLocationTimeZoneProvider}
+ * enters a {@link ProviderState#PROVIDER_STATE_PERM_FAILED} state immediately after being enabled
+ * for the first time and sends the appropriate event, which ensures the {@link
+ * LocationTimeZoneProviderController} won't expect any further {@link
+ * android.location.timezone.LocationTimeZoneEvent}s to come from it, and won't attempt to use it
+ * again.
+ */
+class NullLocationTimeZoneProvider extends LocationTimeZoneProvider {
+
+    private static final String TAG = "NullLocationTimeZoneProvider";
+
+    /** Creates the instance. */
+    NullLocationTimeZoneProvider(@NonNull ThreadingDomain threadingDomain,
+            @NonNull String providerName) {
+        super(threadingDomain, providerName);
+    }
+
+    @Override
+    void onInitialize() {
+        // No-op
+    }
+
+    @Override
+    void onEnable() {
+        // Report a failure (asynchronously using the mThreadingDomain thread to avoid recursion).
+        mThreadingDomain.post(()-> {
+            // Enter the perm-failed state.
+            ProviderState currentState = mCurrentState.get();
+            ProviderState failedState = currentState.newState(
+                    PROVIDER_STATE_PERM_FAILED, null, null, "Stubbed provider");
+            setCurrentState(failedState, true);
+        });
+    }
+
+    @Override
+    void onDisable() {
+        // Ignored - NullLocationTimeZoneProvider is always permanently failed.
+    }
+
+    @Override
+    void logWarn(String msg) {
+        Slog.w(TAG, msg);
+    }
+
+    @Override
+    public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) {
+        synchronized (mSharedLock) {
+            ipw.println("{Stubbed LocationTimeZoneProvider}");
+            ipw.println("mProviderName=" + mProviderName);
+            ipw.println("mCurrentState=" + mCurrentState);
+        }
+    }
+
+    @Override
+    public String toString() {
+        synchronized (mSharedLock) {
+            return "NullLocationTimeZoneProvider{"
+                    + "mProviderName='" + mProviderName + '\''
+                    + "mCurrentState='" + mCurrentState + '\''
+                    + '}';
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/SimulatedBinderProviderEvent.java b/services/core/java/com/android/server/location/timezone/SimulatedBinderProviderEvent.java
new file mode 100644
index 0000000..ef2e349
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/SimulatedBinderProviderEvent.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_PERMANENT_FAILURE;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN;
+
+import static com.android.server.location.timezone.LocationTimeZoneManagerService.PRIMARY_PROVIDER_NAME;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.location.timezone.LocationTimeZoneEvent;
+import android.os.ShellCommand;
+import android.os.SystemClock;
+import android.os.UserHandle;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * An event used for simulating real binder proxy behavior using a {@link
+ * SimulatedLocationTimeZoneProviderProxy}.
+ */
+final class SimulatedBinderProviderEvent {
+
+    private static final List<String> VALID_PROVIDER_NAMES = Arrays.asList(PRIMARY_PROVIDER_NAME);
+
+    static final int INJECTED_EVENT_TYPE_ON_BIND = 1;
+    static final int INJECTED_EVENT_TYPE_ON_UNBIND = 2;
+    static final int INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT = 3;
+
+
+    @NonNull private final String mProviderName;
+    private final int mEventType;
+    @Nullable private final LocationTimeZoneEvent mLocationTimeZoneEvent;
+
+    private SimulatedBinderProviderEvent(@NonNull String providerName, int eventType,
+            @Nullable LocationTimeZoneEvent locationTimeZoneEvent) {
+        this.mProviderName = Objects.requireNonNull(providerName);
+        this.mEventType = eventType;
+        this.mLocationTimeZoneEvent = locationTimeZoneEvent;
+    }
+
+    @NonNull
+    String getProviderName() {
+        return mProviderName;
+    }
+
+    @Nullable
+    LocationTimeZoneEvent getLocationTimeZoneEvent() {
+        return mLocationTimeZoneEvent;
+    }
+
+    int getEventType() {
+        return mEventType;
+    }
+
+    /** Prints the command line options that {@link #createFromArgs(ShellCommand)} understands. */
+    static void printCommandLineOpts(PrintWriter pw) {
+        pw.println("Simulated provider binder event:");
+        pw.println();
+        pw.println("<provider name> [onBind|onUnbind|locationTimeZoneEvent"
+                + " <location time zone event args>]");
+        pw.println();
+        pw.println("<provider name> = " + VALID_PROVIDER_NAMES);
+        pw.println("<location time zone event args> ="
+                + " [PERMANENT_FAILURE|UNCERTAIN|SUCCESS <time zone ids>*]");
+    }
+
+    /**
+     * Constructs a {@link SimulatedBinderProviderEvent} from the arguments of {@code shellCommand}.
+     */
+    static SimulatedBinderProviderEvent createFromArgs(ShellCommand shellCommand) {
+        String providerName = shellCommand.getNextArgRequired();
+        if (!VALID_PROVIDER_NAMES.contains(providerName)) {
+            throw new IllegalArgumentException("Unknown provider name=" + providerName);
+        }
+        String injectedEvent = shellCommand.getNextArgRequired();
+        switch (injectedEvent) {
+            case "onBind": {
+                return new SimulatedBinderProviderEvent(
+                        providerName, INJECTED_EVENT_TYPE_ON_BIND, null);
+            }
+            case "onUnbind": {
+                return new SimulatedBinderProviderEvent(
+                        providerName, INJECTED_EVENT_TYPE_ON_UNBIND, null);
+            }
+            case "locationTimeZoneEvent": {
+                LocationTimeZoneEvent event = parseLocationTimeZoneEventArgs(shellCommand);
+                return new SimulatedBinderProviderEvent(providerName,
+                        INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT, event);
+            }
+            default: {
+                throw new IllegalArgumentException("Unknown simulated event type=" + injectedEvent);
+            }
+        }
+    }
+
+    private static LocationTimeZoneEvent parseLocationTimeZoneEventArgs(ShellCommand shellCommand) {
+        LocationTimeZoneEvent.Builder eventBuilder = new LocationTimeZoneEvent.Builder()
+                .setElapsedRealtimeNanos(SystemClock.elapsedRealtime())
+                .setUserHandle(UserHandle.of(ActivityManager.getCurrentUser()));
+
+        String eventTypeString = shellCommand.getNextArgRequired();
+        switch (eventTypeString.toUpperCase()) {
+            case "PERMANENT_FAILURE": {
+                eventBuilder.setEventType(EVENT_TYPE_PERMANENT_FAILURE);
+                break;
+            }
+            case "UNCERTAIN": {
+                eventBuilder.setEventType(EVENT_TYPE_UNCERTAIN);
+                break;
+            }
+            case "SUCCESS": {
+                eventBuilder.setEventType(EVENT_TYPE_SUCCESS)
+                        .setTimeZoneIds(parseTimeZoneArgs(shellCommand));
+                break;
+            }
+            default: {
+                throw new IllegalArgumentException("Error: Unknown eventType: " + eventTypeString);
+            }
+        }
+        return eventBuilder.build();
+    }
+
+    private static List<String> parseTimeZoneArgs(ShellCommand shellCommand) {
+        List<String> timeZoneIds = new ArrayList<>();
+        String timeZoneId;
+        while ((timeZoneId = shellCommand.getNextArg()) != null) {
+            timeZoneIds.add(timeZoneId);
+        }
+        return timeZoneIds;
+    }
+
+    @Override
+    public String toString() {
+        return "SimulatedBinderProviderEvent{"
+                + "mProviderName=" + mProviderName
+                + ", mEventType=" + mEventType
+                + ", mLocationTimeZoneEvent=" + mLocationTimeZoneEvent
+                + '}';
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/SimulatedLocationTimeZoneProviderProxy.java b/services/core/java/com/android/server/location/timezone/SimulatedLocationTimeZoneProviderProxy.java
new file mode 100644
index 0000000..462bcab
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/SimulatedLocationTimeZoneProviderProxy.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import static com.android.server.location.timezone.SimulatedBinderProviderEvent.INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT;
+import static com.android.server.location.timezone.SimulatedBinderProviderEvent.INJECTED_EVENT_TYPE_ON_BIND;
+import static com.android.server.location.timezone.SimulatedBinderProviderEvent.INJECTED_EVENT_TYPE_ON_UNBIND;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.util.IndentingPrintWriter;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.location.timezone.LocationTimeZoneProviderRequest;
+import com.android.server.timezonedetector.ReferenceWithHistory;
+
+import java.util.Objects;
+
+/**
+ * A replacement for a real binder proxy for use during integration testing
+ * that can be used to inject simulated {@link LocationTimeZoneProviderProxy} behavior.
+ */
+class SimulatedLocationTimeZoneProviderProxy extends LocationTimeZoneProviderProxy {
+
+    @GuardedBy("mProxyLock")
+    @NonNull private LocationTimeZoneProviderRequest mRequest;
+
+    @NonNull private ReferenceWithHistory<String> mLastEvent = new ReferenceWithHistory<>(50);
+
+    SimulatedLocationTimeZoneProviderProxy(
+            @NonNull Context context, @NonNull ThreadingDomain threadingDomain) {
+        super(context, threadingDomain);
+        mRequest = LocationTimeZoneProviderRequest.EMPTY_REQUEST;
+    }
+
+    void simulate(@NonNull SimulatedBinderProviderEvent event) {
+        switch (event.getEventType()) {
+            case INJECTED_EVENT_TYPE_ON_BIND: {
+                mLastEvent.set("Simulating onProviderBound(), event=" + event);
+                mThreadingDomain.post(this::onBindOnHandlerThread);
+                break;
+            }
+            case INJECTED_EVENT_TYPE_ON_UNBIND: {
+                mLastEvent.set("Simulating onProviderUnbound(), event=" + event);
+                mThreadingDomain.post(this::onUnbindOnHandlerThread);
+                break;
+            }
+            case INJECTED_EVENT_TYPE_LOCATION_TIME_ZONE_EVENT: {
+                if (!mRequest.getReportLocationTimeZone()) {
+                    mLastEvent.set("Test event=" + event + " is testing an invalid case:"
+                            + " reporting is off. mRequest=" + mRequest);
+                }
+                mLastEvent.set("Simulating LocationTimeZoneEvent, event=" + event);
+                handleLocationTimeZoneEvent(event.getLocationTimeZoneEvent());
+                break;
+            }
+            default: {
+                mLastEvent.set("Unknown simulated event type. event=" + event);
+                throw new IllegalArgumentException("Unknown simulated event type. event=" + event);
+            }
+        }
+    }
+
+    private void onBindOnHandlerThread() {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            mListener.onProviderBound();
+        }
+    }
+
+    private void onUnbindOnHandlerThread() {
+        mThreadingDomain.assertCurrentThread();
+
+        synchronized (mSharedLock) {
+            mListener.onProviderUnbound();
+        }
+    }
+
+    @Override
+    final void setRequest(@NonNull LocationTimeZoneProviderRequest request) {
+        mThreadingDomain.assertCurrentThread();
+
+        Objects.requireNonNull(request);
+        synchronized (mSharedLock) {
+            mLastEvent.set("Request received: " + request);
+            mRequest = request;
+        }
+    }
+
+    @Override
+    public void dump(@NonNull IndentingPrintWriter ipw, @Nullable String[] args) {
+        synchronized (mSharedLock) {
+            ipw.println("mRequest=" + mRequest);
+            ipw.println("mLastEvent=" + mLastEvent);
+
+            ipw.increaseIndent();
+            ipw.println("Last event history:");
+            mLastEvent.dump(ipw);
+            ipw.decreaseIndent();
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/location/timezone/ThreadingDomain.java b/services/core/java/com/android/server/location/timezone/ThreadingDomain.java
new file mode 100644
index 0000000..9b9c823
--- /dev/null
+++ b/services/core/java/com/android/server/location/timezone/ThreadingDomain.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.location.timezone;
+
+import android.annotation.NonNull;
+
+import com.android.internal.util.Preconditions;
+
+/**
+ * A class that can be used to enforce / indicate a set of components that need to share threading
+ * behavior such as a shared lock object and a common thread, with async execution support.
+ *
+ * <p>It is <em>not</em> essential that the object returned by {@link #getLockObject()} is only used
+ * when executing on the domain's thread, but users should be careful to avoid deadlocks when
+ * multiple locks / threads are in use. Generally sticking to a single thread / lock is safest.
+ */
+abstract class ThreadingDomain {
+
+    @NonNull private final Object mLockObject;
+
+    ThreadingDomain() {
+        mLockObject = new Object();
+    }
+
+    /**
+     * Returns the common lock object for this threading domain that can be used for synchronized ()
+     * blocks. The lock is unique to this threading domain.
+     */
+    @NonNull
+    Object getLockObject() {
+        return mLockObject;
+    }
+
+    /**
+     * Returns the Thread associated with this threading domain.
+     */
+    @NonNull
+    abstract Thread getThread();
+
+    /**
+     * Asserts the currently executing thread is the one associated with this threading domain.
+     * Generally useful for documenting expectations in the code. By asserting a single thread is
+     * being used within a set of components, a lot of races can be avoided.
+     */
+    void assertCurrentThread() {
+        Preconditions.checkArgument(Thread.currentThread() == getThread());
+    }
+
+    /**
+     * Execute the supplied runnable on the threading domain's thread.
+     */
+    abstract void post(@NonNull Runnable runnable);
+
+    /**
+     * Execute the supplied runnable on the threading domain's thread with a delay.
+     */
+    abstract void postDelayed(@NonNull Runnable runnable, long delayMillis);
+
+    abstract void postDelayed(Runnable r, Object token, long delayMillis);
+
+    abstract void removeQueuedRunnables(Object token);
+
+    /**
+     * Creates a new {@link SingleRunnableQueue} that can be used to ensure that (at most) a
+     * single runnable for a given purpose is ever queued. Create new ones for different purposes.
+     */
+    SingleRunnableQueue createSingleRunnableQueue() {
+        return new SingleRunnableQueue();
+    }
+
+    /**
+     * A class that allows up to one {@link Runnable} to be queued on the handler, i.e. calling any
+     * of the methods will cancel the execution of any previously queued / delayed runnable. All
+     * methods must be called from the {@link ThreadingDomain}'s thread.
+     */
+    final class SingleRunnableQueue {
+
+        /**
+         * Runs the supplied {@link Runnable} synchronously on the threading domain's thread,
+         * cancelling any queued but not-yet-executed {@link Runnable} previously added by this.
+         * This method must be called from the threading domain's thread.
+         */
+        void runSynchronously(Runnable r) {
+            cancel();
+            r.run();
+        }
+
+        /**
+         * Posts the supplied {@link Runnable} asynchronously and delayed on the threading domain
+         * handler thread, cancelling any queued but not-yet-executed {@link Runnable} previously
+         * added by this. This method must be called from the threading domain's thread.
+         */
+        void runDelayed(Runnable r, long delayMillis) {
+            cancel();
+            ThreadingDomain.this.postDelayed(r, this, delayMillis);
+        }
+
+        /**
+         * Cancels any queued but not-yet-executed {@link Runnable} previously added by this.
+         */
+        public void cancel() {
+            assertCurrentThread();
+            removeQueuedRunnables(this);
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java
index 7501d9f..73322a6 100644
--- a/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java
+++ b/services/core/java/com/android/server/timezonedetector/TimeZoneDetectorService.java
@@ -57,8 +57,12 @@
 
     private static final String TAG = "TimeZoneDetectorService";
 
-    /** A compile time switch for enabling / disabling geolocation-based time zone detection. */
-    private static final boolean GEOLOCATION_TIME_ZONE_DETECTION_ENABLED = false;
+    /**
+     * A compile time constant "feature switch" for enabling / disabling location-based time zone
+     * detection on Android. If this is {@code false}, there should be few / little changes in
+     * behavior with previous releases and little overhead associated with geolocation components.
+     */
+    public static final boolean GEOLOCATION_TIME_ZONE_DETECTION_ENABLED = false;
 
     /**
      * Handles the service lifecycle for {@link TimeZoneDetectorService} and
diff --git a/services/core/jni/com_android_server_am_CachedAppOptimizer.cpp b/services/core/jni/com_android_server_am_CachedAppOptimizer.cpp
index 95d4ba7..678308a 100644
--- a/services/core/jni/com_android_server_am_CachedAppOptimizer.cpp
+++ b/services/core/jni/com_android_server_am_CachedAppOptimizer.cpp
@@ -36,6 +36,9 @@
 using android::base::StringPrintf;
 using android::base::WriteStringToFile;
 
+#define SYNC_RECEIVED_WHILE_FROZEN (1)
+#define ASYNC_RECEIVED_WHILE_FROZEN (2)
+
 namespace android {
 
 // This performs per-process reclaim on all processes belonging to non-app UIDs.
@@ -99,12 +102,37 @@
     }
 }
 
+static jint com_android_server_am_CachedAppOptimizer_getBinderFreezeInfo(JNIEnv *env,
+        jobject clazz, jint pid) {
+    bool syncReceived = false, asyncReceived = false;
+
+    int error = IPCThreadState::getProcessFreezeInfo(pid, &syncReceived, &asyncReceived);
+
+    if (error < 0) {
+        jniThrowException(env, "java/lang/RuntimeException", strerror(error));
+    }
+
+    jint retVal = 0;
+
+    if(syncReceived) {
+        retVal |= SYNC_RECEIVED_WHILE_FROZEN;;
+    }
+
+    if(asyncReceived) {
+        retVal |= ASYNC_RECEIVED_WHILE_FROZEN;
+    }
+
+    return retVal;
+}
+
 static const JNINativeMethod sMethods[] = {
     /* name, signature, funcPtr */
     {"compactSystem", "()V", (void*)com_android_server_am_CachedAppOptimizer_compactSystem},
     {"enableFreezerInternal", "(Z)V",
         (void*)com_android_server_am_CachedAppOptimizer_enableFreezerInternal},
-    {"freezeBinder", "(IZ)V", (void*)com_android_server_am_CachedAppOptimizer_freezeBinder}
+    {"freezeBinder", "(IZ)V", (void*)com_android_server_am_CachedAppOptimizer_freezeBinder},
+    {"getBinderFreezeInfo", "(I)I",
+        (void*)com_android_server_am_CachedAppOptimizer_getBinderFreezeInfo}
 };
 
 int register_android_server_am_CachedAppOptimizer(JNIEnv* env)
diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java
index ddd2377..eca9f15 100644
--- a/services/java/com/android/server/SystemServer.java
+++ b/services/java/com/android/server/SystemServer.java
@@ -293,6 +293,8 @@
             "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 LOCATION_TIME_ZONE_MANAGER_SERVICE_CLASS =
+            "com.android.server.location.timezone.LocationTimeZoneManagerService$Lifecycle";
     private static final String ACCESSIBILITY_MANAGER_SERVICE_CLASS =
             "com.android.server.accessibility.AccessibilityManagerService$Lifecycle";
     private static final String ADB_SERVICE_CLASS =
@@ -1621,7 +1623,7 @@
             try {
                 mSystemServiceManager.startService(TIME_DETECTOR_SERVICE_CLASS);
             } catch (Throwable e) {
-                reportWtf("starting StartTimeDetectorService service", e);
+                reportWtf("starting TimeDetectorService service", e);
             }
             t.traceEnd();
 
@@ -1629,7 +1631,15 @@
             try {
                 mSystemServiceManager.startService(TIME_ZONE_DETECTOR_SERVICE_CLASS);
             } catch (Throwable e) {
-                reportWtf("starting StartTimeZoneDetectorService service", e);
+                reportWtf("starting TimeZoneDetectorService service", e);
+            }
+            t.traceEnd();
+
+            t.traceBegin("StartLocationTimeZoneManagerService");
+            try {
+                mSystemServiceManager.startService(LOCATION_TIME_ZONE_MANAGER_SERVICE_CLASS);
+            } catch (Throwable e) {
+                reportWtf("starting LocationTimeZoneManagerService service", e);
             }
             t.traceEnd();
 
diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/ControllerImplTest.java b/services/tests/servicestests/src/com/android/server/location/timezone/ControllerImplTest.java
new file mode 100644
index 0000000..dbaad66
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/location/timezone/ControllerImplTest.java
@@ -0,0 +1,528 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.location.timezone;
+
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_SUCCESS;
+import static android.location.timezone.LocationTimeZoneEvent.EVENT_TYPE_UNCERTAIN;
+
+import static com.android.server.location.timezone.ControllerImpl.UNCERTAINTY_DELAY;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED;
+import static com.android.server.location.timezone.TestSupport.USER1_CONFIG_GEO_DETECTION_DISABLED;
+import static com.android.server.location.timezone.TestSupport.USER1_CONFIG_GEO_DETECTION_ENABLED;
+import static com.android.server.location.timezone.TestSupport.USER1_ID;
+import static com.android.server.location.timezone.TestSupport.USER2_CONFIG_GEO_DETECTION_ENABLED;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import static java.util.Arrays.asList;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+import android.location.timezone.LocationTimeZoneEvent;
+import android.os.UserHandle;
+import android.platform.test.annotations.Presubmit;
+import android.util.IndentingPrintWriter;
+
+import com.android.server.timezonedetector.ConfigurationInternal;
+import com.android.server.timezonedetector.GeolocationTimeZoneSuggestion;
+import com.android.server.timezonedetector.TestState;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/** Tests for {@link ControllerImpl}. */
+@Presubmit
+public class ControllerImplTest {
+
+    private static final long ARBITRARY_TIME = 12345L;
+
+    private static final LocationTimeZoneEvent USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1 =
+            createLocationTimeZoneEvent(USER1_ID, EVENT_TYPE_SUCCESS, asList("Europe/London"));
+    private static final LocationTimeZoneEvent USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2 =
+            createLocationTimeZoneEvent(USER1_ID, EVENT_TYPE_SUCCESS, asList("Europe/Paris"));
+    private static final LocationTimeZoneEvent USER1_UNCERTAIN_LOCATION_TIME_ZONE_EVENT =
+            createLocationTimeZoneEvent(USER1_ID, EVENT_TYPE_UNCERTAIN, null);
+
+    private TestThreadingDomain mTestThreadingDomain;
+    private TestCallback mTestCallback;
+    private TestLocationTimeZoneProvider mTestLocationTimeZoneProvider;
+
+    @Before
+    public void setUp() {
+        // For simplicity, the TestThreadingDomain uses the test's main thread. To execute posted
+        // runnables, the test must call methods on mTestThreadingDomain otherwise those runnables
+        // will never get a chance to execute.
+        mTestThreadingDomain = new TestThreadingDomain();
+        mTestCallback = new TestCallback(mTestThreadingDomain);
+        mTestLocationTimeZoneProvider =
+                new TestLocationTimeZoneProvider(mTestThreadingDomain, "primary");
+    }
+
+    @Test
+    public void initialState_enabled() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertInitialized();
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertNextQueueItemIsDelayed(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+    }
+
+    @Test
+    public void initialState_disabled() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_DISABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertInitialized();
+
+        mTestLocationTimeZoneProvider.assertIsDisabled();
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertNoSuggestionMade();
+    }
+
+    @Test
+    public void enabled_uncertaintySuggestionSentIfNoEventReceived() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestCallback.assertNoSuggestionMade();
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+
+        // Simulate time passing with no event being received.
+        mTestThreadingDomain.executeNext();
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestCallback.assertUncertainSuggestionMadeAndCommit();
+        mTestThreadingDomain.assertQueueEmpty();
+    }
+
+    @Test
+    public void enabled_uncertaintySuggestionCancelledIfEventReceived() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Simulate a location event being received by the provider. This should cause a suggestion
+        // to be made, and the timeout to be cleared.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds());
+    }
+
+    @Test
+    public void enabled_repeatedCertainty() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Simulate a location event being received by the provider. This should cause a suggestion
+        // to be made, and the timeout to be cleared.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds());
+
+        // A second, identical event should not cause another suggestion.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertNoSuggestionMade();
+
+        // And a third, different event should cause another suggestion.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2.getTimeZoneIds());
+    }
+
+    @Test
+    public void enabled_briefUncertaintyTriggersNoSuggestion() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Simulate a location event being received by the provider. This should cause a suggestion
+        // to be made, and the timeout to be cleared.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds());
+
+        // Uncertainty should cause
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_UNCERTAIN_LOCATION_TIME_ZONE_EVENT);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // And a third event should cause yet another suggestion.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT2.getTimeZoneIds());
+    }
+
+    @Test
+    public void configChanges_enableAndDisable() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_DISABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertIsDisabled();
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertNoSuggestionMade();
+
+        // Now signal a config change so that geo detection is enabled.
+        testEnvironment.simulateConfigChange(USER1_CONFIG_GEO_DETECTION_ENABLED);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertNextQueueItemIsDelayed(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Now signal a config change so that geo detection is disabled.
+        testEnvironment.simulateConfigChange(USER1_CONFIG_GEO_DETECTION_DISABLED);
+
+        mTestLocationTimeZoneProvider.assertIsDisabled();
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertNoSuggestionMade();
+    }
+
+    @Test
+    public void configChanges_disableWithPreviousSuggestion() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Simulate a location event being received by the provider. This should cause a suggestion
+        // to be made, and the timeout to be cleared.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1);
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds());
+
+        // Simulate the user disabling the provider.
+        testEnvironment.simulateConfigChange(USER1_CONFIG_GEO_DETECTION_DISABLED);
+
+        // Because there had been a previous suggestion, the controller should withdraw it
+        // immediately to let the downstream components know that the provider can no longer be sure
+        // of the time zone.
+        mTestLocationTimeZoneProvider.assertIsDisabled();
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(null);
+    }
+
+    @Test
+    public void configChanges_userSwitch_enabledToEnabled() {
+        ControllerImpl controllerImpl =
+                new ControllerImpl(mTestThreadingDomain, mTestLocationTimeZoneProvider);
+        TestEnvironment testEnvironment = new TestEnvironment(
+                mTestThreadingDomain, controllerImpl, USER1_CONFIG_GEO_DETECTION_ENABLED);
+        controllerImpl.initialize(testEnvironment, mTestCallback);
+
+        // There should be a runnable scheduled to suggest uncertainty if no event is received.
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Have the provider suggest a time zone.
+        mTestLocationTimeZoneProvider.simulateLocationTimeZoneEvent(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1);
+
+        // Receiving a "success" provider event should cause a suggestion to be made synchronously,
+        // and also clear the scheduled uncertainty suggestion.
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER1_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertSuggestionMadeAndCommit(
+                USER1_SUCCESS_LOCATION_TIME_ZONE_EVENT1.getTimeZoneIds());
+
+        // Simulate the user change (but geo detection still enabled).
+        testEnvironment.simulateConfigChange(USER2_CONFIG_GEO_DETECTION_ENABLED);
+
+        // We expect the provider to end up in PROVIDER_STATE_ENABLED, but it should have been
+        // disabled when the user changed.
+        // The controller should schedule a runnable to make a suggestion if the provider doesn't
+        // send a success event.
+        int[] expectedStateTransitions = { PROVIDER_STATE_DISABLED, PROVIDER_STATE_ENABLED };
+        mTestLocationTimeZoneProvider.assertStateChangesAndCommit(expectedStateTransitions);
+        mTestLocationTimeZoneProvider.assertConfig(USER2_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertSingleDelayedQueueItem(UNCERTAINTY_DELAY);
+        mTestCallback.assertNoSuggestionMade();
+
+        // Simulate no event being received, and time passing.
+        mTestThreadingDomain.executeNext();
+
+        mTestLocationTimeZoneProvider.assertIsEnabled(USER2_CONFIG_GEO_DETECTION_ENABLED);
+        mTestThreadingDomain.assertQueueEmpty();
+        mTestCallback.assertUncertainSuggestionMadeAndCommit();
+    }
+
+    private static LocationTimeZoneEvent createLocationTimeZoneEvent(@UserIdInt int userId,
+            int eventType, @Nullable List<String> timeZoneIds) {
+        LocationTimeZoneEvent.Builder builder = new LocationTimeZoneEvent.Builder()
+                .setElapsedRealtimeNanos(ARBITRARY_TIME)
+                .setUserHandle(UserHandle.of(userId))
+                .setEventType(eventType);
+        if (timeZoneIds != null) {
+            builder.setTimeZoneIds(timeZoneIds);
+        }
+        return builder.build();
+    }
+
+    private static class TestEnvironment extends LocationTimeZoneProviderController.Environment {
+
+        private final LocationTimeZoneProviderController mController;
+        private ConfigurationInternal mConfigurationInternal;
+
+        TestEnvironment(ThreadingDomain threadingDomain,
+                LocationTimeZoneProviderController controller,
+                ConfigurationInternal configurationInternal) {
+            super(threadingDomain);
+            mController = Objects.requireNonNull(controller);
+            mConfigurationInternal = Objects.requireNonNull(configurationInternal);
+        }
+
+        @Override
+        ConfigurationInternal getCurrentUserConfigurationInternal() {
+            return mConfigurationInternal;
+        }
+
+        void simulateConfigChange(ConfigurationInternal newConfig) {
+            ConfigurationInternal oldConfig = mConfigurationInternal;
+            mConfigurationInternal = Objects.requireNonNull(newConfig);
+            if (Objects.equals(oldConfig, newConfig)) {
+                fail("Bad test? No config change when one was expected");
+            }
+            mController.onConfigChanged();
+        }
+    }
+
+    private static class TestCallback extends LocationTimeZoneProviderController.Callback {
+
+        private TestState<GeolocationTimeZoneSuggestion> mLatestSuggestion = new TestState<>();
+
+        TestCallback(ThreadingDomain threadingDomain) {
+            super(threadingDomain);
+        }
+
+        @Override
+        void suggest(GeolocationTimeZoneSuggestion suggestion) {
+            mLatestSuggestion.set(suggestion);
+        }
+
+        void assertSuggestionMadeAndCommit(@Nullable List<String> expectedZoneIds) {
+            mLatestSuggestion.assertHasBeenSet();
+            assertEquals(expectedZoneIds, mLatestSuggestion.getLatest().getZoneIds());
+            mLatestSuggestion.commitLatest();
+        }
+
+        void assertNoSuggestionMade() {
+            mLatestSuggestion.assertHasNotBeenSet();
+        }
+
+        void assertUncertainSuggestionMadeAndCommit() {
+            // An "uncertain" suggestion has null time zone IDs.
+            assertSuggestionMadeAndCommit(null);
+        }
+    }
+
+    private static class TestLocationTimeZoneProvider extends LocationTimeZoneProvider {
+
+        /** Used to track historic provider states for tests. */
+        private final TestState<ProviderState> mTestProviderState = new TestState<>();
+        private boolean mInitialized;
+
+        /**
+         * Creates the instance.
+         */
+        TestLocationTimeZoneProvider(ThreadingDomain threadingDomain, String providerName) {
+            super(threadingDomain, providerName);
+        }
+
+        @Override
+        void onInitialize() {
+            mInitialized = true;
+        }
+
+        @Override
+        void onSetCurrentState(ProviderState newState) {
+            mTestProviderState.set(newState);
+        }
+
+        @Override
+        void onEnable() {
+            // Nothing needed for tests.
+        }
+
+        @Override
+        void onDisable() {
+            // Nothing needed for tests.
+        }
+
+        @Override
+        void logWarn(String msg) {
+            System.out.println(msg);
+        }
+
+        @Override
+        public void dump(IndentingPrintWriter pw, String[] args) {
+            // Nothing needed for tests.
+        }
+
+        /** Asserts that {@link #initialize(ProviderListener)} has been called. */
+        void assertInitialized() {
+            assertTrue(mInitialized);
+        }
+
+        void assertIsDisabled() {
+            // Disabled providers don't hold config.
+            assertConfig(null);
+            assertIsEnabledAndCommit(false);
+        }
+
+        /**
+         * Asserts the provider's config matches the expected, and the current state is set
+         * accordinly. Commits the latest changes to the state.
+         */
+        void assertIsEnabled(@NonNull ConfigurationInternal expectedConfig) {
+            assertConfig(expectedConfig);
+
+            boolean expectIsEnabled = expectedConfig.getAutoDetectionEnabledBehavior();
+            assertIsEnabledAndCommit(expectIsEnabled);
+        }
+
+        private void assertIsEnabledAndCommit(boolean enabled) {
+            ProviderState currentState = mCurrentState.get();
+            if (enabled) {
+                assertEquals(PROVIDER_STATE_ENABLED, currentState.stateEnum);
+            } else {
+                assertEquals(PROVIDER_STATE_DISABLED, currentState.stateEnum);
+            }
+            mTestProviderState.commitLatest();
+        }
+
+        void assertConfig(@NonNull ConfigurationInternal expectedConfig) {
+            ProviderState currentState = mCurrentState.get();
+            assertEquals(expectedConfig, currentState.currentUserConfiguration);
+        }
+
+        void simulateLocationTimeZoneEvent(@NonNull LocationTimeZoneEvent event) {
+            handleLocationTimeZoneEvent(event);
+        }
+
+        /**
+         * Asserts the most recent state changes. The ordering is such that the last element in the
+         * provided array is expected to be the current state.
+         */
+        void assertStateChangesAndCommit(int... expectedProviderStates) {
+            if (expectedProviderStates.length == 0) {
+                mTestProviderState.assertHasNotBeenSet();
+            } else {
+                mTestProviderState.assertChangeCount(expectedProviderStates.length);
+
+                List<ProviderState> previousProviderStates = new ArrayList<>();
+                for (int i = 0; i < expectedProviderStates.length; i++) {
+                    previousProviderStates.add(mTestProviderState.getPrevious(i));
+                }
+                // The loop above will produce a list with the most recent state in element 0. So,
+                // reverse the list as the arguments to this method are expected to be in order
+                // oldest...latest.
+                Collections.reverse(previousProviderStates);
+
+                boolean allMatch = true;
+                for (int i = 0; i < expectedProviderStates.length; i++) {
+                    allMatch = allMatch && expectedProviderStates[i]
+                            == previousProviderStates.get(i).stateEnum;
+                }
+                if (!allMatch) {
+                    fail("Provider state enums expected=" + Arrays.toString(expectedProviderStates)
+                            + " but states were"
+                            + " actually=" + previousProviderStates);
+                }
+            }
+            mTestProviderState.commitLatest();
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/HandlerThreadingDomainTest.java b/services/tests/servicestests/src/com/android/server/location/timezone/HandlerThreadingDomainTest.java
new file mode 100644
index 0000000..cbaf0f3
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/location/timezone/HandlerThreadingDomainTest.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.location.timezone;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.location.timezone.ThreadingDomain.SingleRunnableQueue;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Tests for {@link HandlerThreadingDomain}. */
+@Presubmit
+public class HandlerThreadingDomainTest {
+
+    private HandlerThread mHandlerThread;
+    private Handler mTestHandler;
+
+    @Before
+    public void setUp() {
+        mHandlerThread = new HandlerThread("HandlerThreadingDomainTest");
+        mHandlerThread.start();
+        mTestHandler = new Handler(mHandlerThread.getLooper());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mHandlerThread.quit();
+        mHandlerThread.join();
+    }
+
+    @Test
+    public void getLockObject() {
+        ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler);
+        assertSame("LockObject must be consistent", domain.getLockObject(), domain.getLockObject());
+    }
+
+    @Test
+    public void assertCurrentThread() throws Exception {
+        ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler);
+
+        // Expect an exception (current thread != handler thread)
+        try {
+            domain.assertCurrentThread();
+            fail("Expected exception");
+        } catch (RuntimeException expected) {
+            // Expected
+        }
+
+        // Expect no exception (current thread == handler thread)
+        AtomicBoolean exceptionThrown = new AtomicBoolean(true);
+        LatchedRunnable testCode = new LatchedRunnable(() -> {
+            domain.assertCurrentThread();
+            exceptionThrown.set(false);
+        });
+        mTestHandler.post(testCode);
+        testCode.assertCompletesWithin(60, TimeUnit.SECONDS);
+        assertFalse(exceptionThrown.get());
+    }
+
+    @Test
+    public void post() throws Exception {
+        ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler);
+        AtomicBoolean ranOnExpectedThread = new AtomicBoolean(false);
+        LatchedRunnable testLogic = new LatchedRunnable(() -> {
+            ranOnExpectedThread.set(Thread.currentThread() == mTestHandler.getLooper().getThread());
+        });
+        domain.post(testLogic);
+        testLogic.assertCompletesWithin(60, TimeUnit.SECONDS);
+        assertTrue(ranOnExpectedThread.get());
+    }
+
+    @Test
+    public void postDelayed() throws Exception {
+        ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler);
+
+        long beforeExecutionNanos = System.nanoTime();
+        AtomicBoolean ranOnExpectedThread = new AtomicBoolean(false);
+        LatchedRunnable testLogic = new LatchedRunnable(() -> {
+            ranOnExpectedThread.set(Thread.currentThread() == mTestHandler.getLooper().getThread());
+        });
+        domain.postDelayed(testLogic, 5000);
+
+        testLogic.assertCompletesWithin(60, TimeUnit.SECONDS);
+        assertTrue(ranOnExpectedThread.get());
+
+        long afterExecutionNanos = System.nanoTime();
+        assertTrue(afterExecutionNanos - beforeExecutionNanos >= TimeUnit.SECONDS.toNanos(5));
+    }
+
+    @Test
+    public void singleRunnableHandler_runSynchronously() throws Exception {
+        ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler);
+        SingleRunnableQueue singleRunnableQueue = domain.createSingleRunnableQueue();
+
+        AtomicBoolean testPassed = new AtomicBoolean(false);
+        // Calls to SingleRunnableQueue must be made on the handler thread it is associated with,
+        // so this uses runWithScissors() to block until the lambda has completed.
+        mTestHandler.runWithScissors(() -> {
+            Thread testThread = Thread.currentThread();
+            CountDownLatch latch = new CountDownLatch(1);
+            singleRunnableQueue.runSynchronously(() -> {
+                assertSame(Thread.currentThread(), testThread);
+                latch.countDown();
+            });
+            assertTrue(awaitWithRuntimeException(latch, 60, TimeUnit.SECONDS));
+            testPassed.set(true);
+        }, TimeUnit.SECONDS.toMillis(60));
+        assertTrue(testPassed.get());
+    }
+
+    @Test
+    public void singleRunnableHandler_runDelayed() throws Exception {
+        ThreadingDomain domain = new HandlerThreadingDomain(mTestHandler);
+        SingleRunnableQueue singleRunnableQueue = domain.createSingleRunnableQueue();
+
+        long beforeExecutionNanos = System.nanoTime();
+
+        Runnable noOpRunnable = () -> {
+            // Deliberately do nothing
+        };
+        LatchedRunnable firstRunnable = new LatchedRunnable(noOpRunnable);
+        LatchedRunnable secondRunnable = new LatchedRunnable(noOpRunnable);
+
+        // Calls to SingleRunnableQueue must be made on the handler thread it is associated with,
+        // so this uses runWithScissors() to block until the runDelayedTestRunnable has completed.
+        Runnable runDelayedTestRunnable = () -> {
+            singleRunnableQueue.runDelayed(firstRunnable, TimeUnit.SECONDS.toMillis(10));
+
+            // The second runnable posted must clear the first.
+            singleRunnableQueue.runDelayed(secondRunnable, TimeUnit.SECONDS.toMillis(10));
+        };
+        mTestHandler.runWithScissors(runDelayedTestRunnable, TimeUnit.SECONDS.toMillis(60));
+
+        // Now wait for the second runnable to complete
+        secondRunnable.assertCompletesWithin(60, TimeUnit.SECONDS);
+        assertFalse(firstRunnable.isComplete());
+
+        long afterExecutionNanos = System.nanoTime();
+        assertTrue(afterExecutionNanos - beforeExecutionNanos >= TimeUnit.SECONDS.toNanos(10));
+    }
+
+    private static boolean awaitWithRuntimeException(
+            CountDownLatch latch, long timeout, TimeUnit timeUnit) {
+        try {
+            return latch.await(timeout, timeUnit);
+        } catch (InterruptedException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static class LatchedRunnable implements Runnable {
+
+        private final CountDownLatch mLatch = new CountDownLatch(1);
+        private final Runnable mRunnable;
+
+        LatchedRunnable(Runnable mRunnable) {
+            this.mRunnable = Objects.requireNonNull(mRunnable);
+        }
+
+        @Override
+        public void run() {
+            try {
+                mRunnable.run();
+            } finally {
+                mLatch.countDown();
+            }
+        }
+
+        boolean isComplete() {
+            return mLatch.getCount() == 0;
+        }
+
+        boolean waitForCompletion(long timeout, TimeUnit unit) {
+            return awaitWithRuntimeException(mLatch, timeout, unit);
+        }
+
+        void assertCompletesWithin(long timeout, TimeUnit unit) {
+            assertTrue("Runnable did not execute in time", waitForCompletion(timeout, unit));
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/NullLocationTimeZoneProviderTest.java b/services/tests/servicestests/src/com/android/server/location/timezone/NullLocationTimeZoneProviderTest.java
new file mode 100644
index 0000000..7c882fc
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/location/timezone/NullLocationTimeZoneProviderTest.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.location.timezone;
+
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_DISABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_ENABLED;
+import static com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState.PROVIDER_STATE_PERM_FAILED;
+import static com.android.server.location.timezone.TestSupport.USER1_CONFIG_GEO_DETECTION_ENABLED;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+
+import android.platform.test.annotations.Presubmit;
+import android.util.IndentingPrintWriter;
+
+import com.android.server.location.timezone.LocationTimeZoneProvider.ProviderState;
+import com.android.server.timezonedetector.ConfigurationInternal;
+import com.android.server.timezonedetector.TestState;
+
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for {@link NullLocationTimeZoneProvider} and, indirectly, the class it extends
+ * {@link LocationTimeZoneProvider}.
+ */
+@Presubmit
+public class NullLocationTimeZoneProviderTest {
+
+    private TestThreadingDomain mTestThreadingDomain;
+
+    private TestController mTestController;
+
+    @Before
+    public void setUp() {
+        mTestThreadingDomain = new TestThreadingDomain();
+        mTestController = new TestController(mTestThreadingDomain);
+    }
+
+    @Test
+    public void initialization() {
+        String providerName = "primary";
+        NullLocationTimeZoneProvider provider =
+                new NullLocationTimeZoneProvider(mTestThreadingDomain, providerName);
+        provider.initialize(providerState -> mTestController.onProviderStateChange(providerState));
+
+        ProviderState currentState = provider.getCurrentState();
+        assertEquals(PROVIDER_STATE_DISABLED, currentState.stateEnum);
+        assertNull(currentState.currentUserConfiguration);
+        assertSame(provider, currentState.provider);
+        mTestThreadingDomain.assertQueueEmpty();
+    }
+
+    @Test
+    public void enableSchedulesPermFailure() {
+        String providerName = "primary";
+        NullLocationTimeZoneProvider provider =
+                new NullLocationTimeZoneProvider(mTestThreadingDomain, providerName);
+        provider.initialize(providerState -> mTestController.onProviderStateChange(providerState));
+
+        ConfigurationInternal config = USER1_CONFIG_GEO_DETECTION_ENABLED;
+        provider.enable(config);
+
+        // The StubbedProvider should enters enabled state, but immediately schedule a runnable to
+        // switch to perm failure.
+        ProviderState currentState = provider.getCurrentState();
+        assertSame(provider, currentState.provider);
+        assertEquals(PROVIDER_STATE_ENABLED, currentState.stateEnum);
+        assertEquals(config, currentState.currentUserConfiguration);
+        mTestThreadingDomain.assertSingleImmediateQueueItem();
+        // Entering enabled() does not trigger an onProviderStateChanged() as it is requested by the
+        // controller.
+        mTestController.assertProviderChangeNotTriggered();
+
+        // Check the queued runnable causes the provider to go into perm failed state.
+        mTestThreadingDomain.executeNext();
+
+        // Entering perm failed triggers an onProviderStateChanged() as it is asynchronously
+        // triggered.
+        mTestController.assertProviderChangeTriggered(PROVIDER_STATE_PERM_FAILED);
+    }
+
+    /** A test stand-in for the {@link LocationTimeZoneProviderController}. */
+    private static class TestController extends LocationTimeZoneProviderController {
+
+        private TestState<ProviderState> mProviderState = new TestState<>();
+
+        TestController(ThreadingDomain threadingDomain) {
+            super(threadingDomain);
+        }
+
+        @Override
+        void initialize(Environment environment, Callback callback) {
+            // Not needed for provider testing.
+        }
+
+        @Override
+        void onConfigChanged() {
+            // Not needed for provider testing.
+        }
+
+        void onProviderStateChange(ProviderState providerState) {
+            this.mProviderState.set(providerState);
+        }
+
+        @Override
+        public void dump(IndentingPrintWriter pw, String[] args) {
+            // Not needed for provider testing.
+        }
+
+        void assertProviderChangeTriggered(int expectedStateEnum) {
+            assertEquals(expectedStateEnum, mProviderState.getLatest().stateEnum);
+            mProviderState.commitLatest();
+        }
+
+        public void assertProviderChangeNotTriggered() {
+            mProviderState.assertHasNotBeenSet();
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/TestSupport.java b/services/tests/servicestests/src/com/android/server/location/timezone/TestSupport.java
new file mode 100644
index 0000000..192ade7
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/location/timezone/TestSupport.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.location.timezone;
+
+import android.annotation.UserIdInt;
+
+import com.android.server.timezonedetector.ConfigurationInternal;
+
+/** Shared test support code for this package. */
+final class TestSupport {
+    static final @UserIdInt int USER1_ID = 9999;
+
+    static final ConfigurationInternal USER1_CONFIG_GEO_DETECTION_ENABLED =
+            createUserConfig(USER1_ID, true);
+
+    static final ConfigurationInternal USER1_CONFIG_GEO_DETECTION_DISABLED =
+            createUserConfig(USER1_ID, false);
+
+    static final @UserIdInt int USER2_ID = 1234567890;
+
+    static final ConfigurationInternal USER2_CONFIG_GEO_DETECTION_ENABLED =
+            createUserConfig(USER2_ID, true);
+
+    static final ConfigurationInternal USER2_CONFIG_GEO_DETECTION_DISABLED =
+            createUserConfig(USER2_ID, false);
+
+    private TestSupport() {
+    }
+
+    private static ConfigurationInternal createUserConfig(
+            @UserIdInt int userId, boolean geoDetectionEnabled) {
+        return new ConfigurationInternal.Builder(userId)
+                .setUserConfigAllowed(true)
+                .setAutoDetectionSupported(true)
+                .setAutoDetectionEnabled(true)
+                .setLocationEnabled(true)
+                .setGeoDetectionEnabled(geoDetectionEnabled)
+                .build();
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/location/timezone/TestThreadingDomain.java b/services/tests/servicestests/src/com/android/server/location/timezone/TestThreadingDomain.java
new file mode 100644
index 0000000..70ff22d
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/location/timezone/TestThreadingDomain.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.location.timezone;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.LinkedList;
+import java.util.Objects;
+
+/**
+ * A ThreadingDomain that simulates idealized post() semantics. Execution takes place in zero time,
+ * exactly when scheduled, when the test code explicitly requests it. Execution takes place on the
+ * test's main thread.
+ */
+class TestThreadingDomain extends ThreadingDomain {
+
+    static class QueuedRunnable {
+        @NonNull public final Runnable runnable;
+        @Nullable public final Object token;
+        public final long executionTimeMillis;
+
+        QueuedRunnable(@NonNull Runnable runnable, @Nullable Object token,
+                long executionTimeMillis) {
+            this.runnable = Objects.requireNonNull(runnable);
+            this.token = token;
+            this.executionTimeMillis = executionTimeMillis;
+        }
+
+        @Override
+        public String toString() {
+            return "QueuedRunnable{"
+                    + "runnable=" + runnable
+                    + ", token=" + token
+                    + ", executionTimeMillis=" + executionTimeMillis
+                    + '}';
+        }
+    }
+
+    private long mCurrentTimeMillis;
+    private LinkedList<QueuedRunnable> mQueue = new LinkedList<>();
+
+    TestThreadingDomain() {
+        // Pick an arbitrary time.
+        mCurrentTimeMillis = 123456L;
+    }
+
+    @Override
+    Thread getThread() {
+        return Thread.currentThread();
+    }
+
+    @Override
+    void post(Runnable r) {
+        mQueue.add(new QueuedRunnable(r, null, mCurrentTimeMillis));
+    }
+
+    @Override
+    void postDelayed(Runnable r, long delayMillis) {
+        mQueue.add(new QueuedRunnable(r, null, mCurrentTimeMillis + delayMillis));
+    }
+
+    @Override
+    void postDelayed(Runnable r, Object token, long delayMillis) {
+        mQueue.add(new QueuedRunnable(r, token, mCurrentTimeMillis + delayMillis));
+    }
+
+    @Override
+    void removeQueuedRunnables(Object token) {
+        mQueue.removeIf(runnable -> runnable.token != null && runnable.token.equals(token));
+    }
+
+    void assertSingleDelayedQueueItem(Duration expectedDelay) {
+        assertQueueLength(1);
+        assertNextQueueItemIsDelayed(expectedDelay);
+    }
+
+    void assertSingleImmediateQueueItem() {
+        assertQueueLength(1);
+        assertNextQueueItemIsImmediate();
+    }
+
+    void assertQueueLength(int expectedLength) {
+        assertEquals(expectedLength, mQueue.size());
+    }
+
+    void assertNextQueueItemIsImmediate() {
+        assertTrue(getNextQueueItemDelayMillis() == 0);
+    }
+
+    void assertNextQueueItemIsDelayed(Duration expectedDelay) {
+        assertTrue(getNextQueueItemDelayMillis() == expectedDelay.toMillis());
+    }
+
+    void assertQueueEmpty() {
+        assertTrue(mQueue.isEmpty());
+    }
+
+    long getNextQueueItemDelayMillis() {
+        assertQueueLength(1);
+        return mQueue.getFirst().executionTimeMillis - mCurrentTimeMillis;
+    }
+
+    void executeNext() {
+        assertQueueLength(1);
+
+        QueuedRunnable queued = mQueue.removeFirst();
+        mCurrentTimeMillis = queued.executionTimeMillis;
+        queued.runnable.run();
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java b/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java
index 7049efa1..97b8360 100644
--- a/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java
+++ b/services/tests/servicestests/src/com/android/server/timezonedetector/TestState.java
@@ -76,7 +76,7 @@
 
     /** Asserts the number of times {@link #set} has been called. */
     public void assertChangeCount(int expectedCount) {
-        assertEquals(expectedCount, mValues.size());
+        assertEquals(expectedCount, getChangeCount());
     }
 
     /**
@@ -89,4 +89,25 @@
         }
         return mInitialValue;
     }
+
+    /** Returns the number of times {@link #set} has been called. */
+    public int getChangeCount() {
+        return mValues.size();
+    }
+
+    /**
+     * Returns an historic value of the state. Values for {@code age} can be from {@code 0}, the
+     * latest value, through {@code getChangeCount() - 1}, which returns the oldest change, to
+     * {@code getChangeCount()}, which returns the initial value. Values outside of this range will
+     * cause {@link IndexOutOfBoundsException} to be thrown.
+     */
+    public T getPrevious(int age) {
+        int size = mValues.size();
+        if (age < size) {
+            return mValues.get(size - 1 - age);
+        } else if (age == size) {
+            return mInitialValue;
+        }
+        throw new IndexOutOfBoundsException("age=" + age + " is too big.");
+    }
 }
diff --git a/tests/FlickerTests/Android.bp b/tests/FlickerTests/Android.bp
index a53ea16..d430db5 100644
--- a/tests/FlickerTests/Android.bp
+++ b/tests/FlickerTests/Android.bp
@@ -29,7 +29,8 @@
         "flickerlib",
         "truth-prebuilt",
         "launcher-helper-lib",
-        "launcher-aosp-tapl"
+        "launcher-aosp-tapl",
+        "platform-test-annotations",
     ],
 }
 
@@ -50,6 +51,7 @@
         "truth-prebuilt",
         "app-helpers-core",
         "launcher-helper-lib",
-        "launcher-aosp-tapl"
+        "launcher-aosp-tapl",
+        "platform-test-annotations",
     ],
 }
\ No newline at end of file
diff --git a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeAutoOpenWindowToHomeTest.kt b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeAutoOpenWindowToHomeTest.kt
index b64811b..c1ba21a 100644
--- a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeAutoOpenWindowToHomeTest.kt
+++ b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeAutoOpenWindowToHomeTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.wm.flicker.ime
 
+import android.platform.test.annotations.Presubmit
 import android.view.Surface
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.dsl.flicker
@@ -39,6 +40,7 @@
  * Test IME window closing back to app window transitions.
  * To run this test: `atest FlickerTests:CloseImeWindowToAppTest`
  */
+@Presubmit
 @RequiresDevice
 @RunWith(Parameterized::class)
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
diff --git a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToAppTest.kt b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToAppTest.kt
index 0940c19..2c00722 100644
--- a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToAppTest.kt
+++ b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToAppTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.wm.flicker.ime
 
+import android.platform.test.annotations.Presubmit
 import android.view.Surface
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.NonRotationTestBase
@@ -39,6 +40,7 @@
  * Test IME window closing back to app window transitions.
  * To run this test: `atest FlickerTests:CloseImeWindowToAppTest`
  */
+@Presubmit
 @RequiresDevice
 @RunWith(Parameterized::class)
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
diff --git a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToHomeTest.kt b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToHomeTest.kt
index c2e87db..4697adc 100644
--- a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToHomeTest.kt
+++ b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/CloseImeWindowToHomeTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.wm.flicker.ime
 
+import android.platform.test.annotations.Presubmit
 import android.view.Surface
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.NonRotationTestBase
@@ -40,6 +41,7 @@
  * Test IME window closing to home transitions.
  * To run this test: `atest FlickerTests:CloseImeWindowToHomeTest`
  */
+@Presubmit
 @RequiresDevice
 @RunWith(Parameterized::class)
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
diff --git a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/OpenImeWindowTest.kt b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/OpenImeWindowTest.kt
index 11ccb69..2caa8f3 100644
--- a/tests/FlickerTests/src/com/android/server/wm/flicker/ime/OpenImeWindowTest.kt
+++ b/tests/FlickerTests/src/com/android/server/wm/flicker/ime/OpenImeWindowTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.wm.flicker.ime
 
+import android.platform.test.annotations.Presubmit
 import android.view.Surface
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.NonRotationTestBase
@@ -39,6 +40,7 @@
  * Test IME window opening transitions.
  * To run this test: `atest FlickerTests:OpenImeWindowTest`
  */
+@Presubmit
 @RequiresDevice
 @RunWith(Parameterized::class)
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
diff --git a/tests/FlickerTests/src/com/android/server/wm/flicker/launch/OpenAppColdTest.kt b/tests/FlickerTests/src/com/android/server/wm/flicker/launch/OpenAppColdTest.kt
index 1759072..2c9c8ba 100644
--- a/tests/FlickerTests/src/com/android/server/wm/flicker/launch/OpenAppColdTest.kt
+++ b/tests/FlickerTests/src/com/android/server/wm/flicker/launch/OpenAppColdTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.wm.flicker.launch
 
+import android.platform.test.annotations.Presubmit
 import android.view.Surface
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.dsl.flicker
@@ -38,6 +39,7 @@
  * Test cold launch app from launcher.
  * To run this test: `atest FlickerTests:OpenAppColdTest`
  */
+@Presubmit
 @RequiresDevice
 @RunWith(Parameterized::class)
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
diff --git a/tests/FlickerTests/src/com/android/server/wm/flicker/splitscreen/SplitScreenToLauncherTest.kt b/tests/FlickerTests/src/com/android/server/wm/flicker/splitscreen/SplitScreenToLauncherTest.kt
index 87c8633..7447bda 100644
--- a/tests/FlickerTests/src/com/android/server/wm/flicker/splitscreen/SplitScreenToLauncherTest.kt
+++ b/tests/FlickerTests/src/com/android/server/wm/flicker/splitscreen/SplitScreenToLauncherTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.wm.flicker.splitscreen
 
+import android.platform.test.annotations.Presubmit
 import android.view.Surface
 import androidx.test.filters.RequiresDevice
 import com.android.server.wm.flicker.NonRotationTestBase
@@ -43,6 +44,7 @@
  * Test open app to split screen.
  * To run this test: `atest FlickerTests:SplitScreenToLauncherTest`
  */
+@Presubmit
 @RequiresDevice
 @RunWith(Parameterized::class)
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)