Add projection state APIs to UiModeManager and UiModeManagerService.

Bug: 134997071
Bug: 169702986
Test: Code builds, runs, unit tests added for new functionality.
Change-Id: I29cfb198101cdc5d1f4de84f8701928643b756c3
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java
index e2c8f64..3c4961a 100644
--- a/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java
@@ -16,6 +16,8 @@
 
 package com.android.server.job.controllers.idle;
 
+import static android.app.UiModeManager.PROJECTION_TYPE_NONE;
+
 import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
 
 import android.app.AlarmManager;
@@ -34,7 +36,9 @@
 import com.android.server.job.StateControllerProto;
 
 import java.io.PrintWriter;
+import java.util.Set;
 
+/** Class to track device idle state. */
 public final class DeviceIdlenessTracker extends BroadcastReceiver implements IdlenessTracker {
     private static final String TAG = "JobScheduler.DeviceIdlenessTracker";
     private static final boolean DEBUG = JobSchedulerService.DEBUG
@@ -50,8 +54,10 @@
     private boolean mIdle;
     private boolean mScreenOn;
     private boolean mDockIdle;
-    private boolean mInCarMode;
+    private boolean mProjectionActive;
     private IdlenessListener mIdleListener;
+    private final UiModeManager.OnProjectionStateChangeListener mOnProjectionStateChangeListener =
+            this::onProjectionStateChanged;
 
     private AlarmManager.OnAlarmListener mIdleAlarmListener = () -> {
         handleIdleTrigger();
@@ -60,10 +66,7 @@
     public DeviceIdlenessTracker() {
         // At boot we presume that the user has just "interacted" with the
         // device in some meaningful way.
-        mIdle = false;
         mScreenOn = true;
-        mDockIdle = false;
-        mInCarMode = false;
     }
 
     @Override
@@ -98,11 +101,34 @@
         filter.addAction(Intent.ACTION_DOCK_IDLE);
         filter.addAction(Intent.ACTION_DOCK_ACTIVE);
 
-        // Car mode
-        filter.addAction(UiModeManager.ACTION_ENTER_CAR_MODE_PRIORITIZED);
-        filter.addAction(UiModeManager.ACTION_EXIT_CAR_MODE_PRIORITIZED);
-
         context.registerReceiver(this, filter);
+
+        // TODO(b/172579710): Move the callbacks off the main executor and on to
+        //  JobSchedulerBackgroundThread.getExecutor() once synchronization is fixed in this class.
+        context.getSystemService(UiModeManager.class).addOnProjectionStateChangeListener(
+                UiModeManager.PROJECTION_TYPE_ALL, context.getMainExecutor(),
+                mOnProjectionStateChangeListener);
+    }
+
+    private void onProjectionStateChanged(@UiModeManager.ProjectionType int activeProjectionTypes,
+            Set<String> projectingPackages) {
+        boolean projectionActive = activeProjectionTypes != PROJECTION_TYPE_NONE;
+        if (mProjectionActive == projectionActive) {
+            return;
+        }
+        if (DEBUG) {
+            Slog.v(TAG, "Projection state changed: " + projectionActive);
+        }
+        mProjectionActive = projectionActive;
+        if (mProjectionActive) {
+            cancelIdlenessCheck();
+            if (mIdle) {
+                mIdle = false;
+                mIdleListener.reportNewIdleState(mIdle);
+            }
+        } else {
+            maybeScheduleIdlenessCheck("Projection ended");
+        }
     }
 
     @Override
@@ -110,8 +136,7 @@
         pw.print("  mIdle: "); pw.println(mIdle);
         pw.print("  mScreenOn: "); pw.println(mScreenOn);
         pw.print("  mDockIdle: "); pw.println(mDockIdle);
-        pw.print("  mInCarMode: ");
-        pw.println(mInCarMode);
+        pw.print("  mProjectionActive: "); pw.println(mProjectionActive);
     }
 
     @Override
@@ -129,8 +154,9 @@
                 StateControllerProto.IdleController.IdlenessTracker.DeviceIdlenessTracker.IS_DOCK_IDLE,
                 mDockIdle);
         proto.write(
-                StateControllerProto.IdleController.IdlenessTracker.DeviceIdlenessTracker.IN_CAR_MODE,
-                mInCarMode);
+                StateControllerProto.IdleController.IdlenessTracker.DeviceIdlenessTracker
+                        .PROJECTION_ACTIVE,
+                mProjectionActive);
 
         proto.end(diToken);
         proto.end(token);
@@ -186,18 +212,6 @@
                 }
                 maybeScheduleIdlenessCheck(action);
                 break;
-            case UiModeManager.ACTION_ENTER_CAR_MODE_PRIORITIZED:
-                mInCarMode = true;
-                cancelIdlenessCheck();
-                if (mIdle) {
-                    mIdle = false;
-                    mIdleListener.reportNewIdleState(mIdle);
-                }
-                break;
-            case UiModeManager.ACTION_EXIT_CAR_MODE_PRIORITIZED:
-                mInCarMode = false;
-                maybeScheduleIdlenessCheck(action);
-                break;
             case ActivityManagerService.ACTION_TRIGGER_IDLE:
                 handleIdleTrigger();
                 break;
@@ -205,7 +219,7 @@
     }
 
     private void maybeScheduleIdlenessCheck(String reason) {
-        if ((!mScreenOn || mDockIdle) && !mInCarMode) {
+        if ((!mScreenOn || mDockIdle) && !mProjectionActive) {
             final long nowElapsed = sElapsedRealtimeClock.millis();
             final long when = nowElapsed + mInactivityIdleThreshold;
             if (DEBUG) {
@@ -222,7 +236,7 @@
 
     private void handleIdleTrigger() {
         // idle time starts now. Do not set mIdle if screen is on.
-        if (!mIdle && (!mScreenOn || mDockIdle) && !mInCarMode) {
+        if (!mIdle && (!mScreenOn || mDockIdle) && !mProjectionActive) {
             if (DEBUG) {
                 Slog.v(TAG, "Idle trigger fired @ " + sElapsedRealtimeClock.millis());
             }
@@ -231,7 +245,7 @@
         } else {
             if (DEBUG) {
                 Slog.v(TAG, "TRIGGER_IDLE received but not changing state; idle="
-                        + mIdle + " screen=" + mScreenOn + " car=" + mInCarMode);
+                        + mIdle + " screen=" + mScreenOn + " projection=" + mProjectionActive);
             }
         }
     }
diff --git a/api/system-current.txt b/api/system-current.txt
index b28e7af..d4dafe7 100644
--- a/api/system-current.txt
+++ b/api/system-current.txt
@@ -182,6 +182,7 @@
     field public static final String READ_PRINT_SERVICES = "android.permission.READ_PRINT_SERVICES";
     field public static final String READ_PRINT_SERVICE_RECOMMENDATIONS = "android.permission.READ_PRINT_SERVICE_RECOMMENDATIONS";
     field public static final String READ_PRIVILEGED_PHONE_STATE = "android.permission.READ_PRIVILEGED_PHONE_STATE";
+    field public static final String READ_PROJECTION_STATE = "android.permission.READ_PROJECTION_STATE";
     field public static final String READ_RUNTIME_PROFILES = "android.permission.READ_RUNTIME_PROFILES";
     field public static final String READ_SEARCH_INDEXABLES = "android.permission.READ_SEARCH_INDEXABLES";
     field public static final String READ_SYSTEM_UPDATE_INFO = "android.permission.READ_SYSTEM_UPDATE_INFO";
@@ -234,6 +235,7 @@
     field public static final String SUSPEND_APPS = "android.permission.SUSPEND_APPS";
     field public static final String SYSTEM_CAMERA = "android.permission.SYSTEM_CAMERA";
     field public static final String TETHER_PRIVILEGED = "android.permission.TETHER_PRIVILEGED";
+    field public static final String TOGGLE_AUTOMOTIVE_PROJECTION = "android.permission.TOGGLE_AUTOMOTIVE_PROJECTION";
     field public static final String TV_INPUT_HARDWARE = "android.permission.TV_INPUT_HARDWARE";
     field public static final String TV_VIRTUAL_REMOTE_CONTROLLER = "android.permission.TV_VIRTUAL_REMOTE_CONTROLLER";
     field public static final String UNLIMITED_SHORTCUTS_API_CALLS = "android.permission.UNLIMITED_SHORTCUTS_API_CALLS";
@@ -796,7 +798,11 @@
   }
 
   public class UiModeManager {
+    method @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public void addOnProjectionStateChangeListener(int, @NonNull java.util.concurrent.Executor, @NonNull android.app.UiModeManager.OnProjectionStateChangeListener);
     method @RequiresPermission(android.Manifest.permission.ENTER_CAR_MODE_PRIORITIZED) public void enableCarMode(@IntRange(from=0) int, int);
+    method @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public int getActiveProjectionTypes();
+    method @NonNull @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public java.util.Set<java.lang.String> getProjectingPackages(int);
+    method @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public void removeOnProjectionStateChangeListener(@NonNull android.app.UiModeManager.OnProjectionStateChangeListener);
     field public static final String ACTION_ENTER_CAR_MODE_PRIORITIZED = "android.app.action.ENTER_CAR_MODE_PRIORITIZED";
     field public static final String ACTION_EXIT_CAR_MODE_PRIORITIZED = "android.app.action.EXIT_CAR_MODE_PRIORITIZED";
     field public static final int DEFAULT_PRIORITY = 0; // 0x0
@@ -804,6 +810,10 @@
     field public static final String EXTRA_PRIORITY = "android.app.extra.PRIORITY";
   }
 
+  public static interface UiModeManager.OnProjectionStateChangeListener {
+    method public void onProjectionStateChanged(int, @NonNull java.util.Set<java.lang.String>);
+  }
+
   public final class Vr2dDisplayProperties implements android.os.Parcelable {
     ctor public Vr2dDisplayProperties(int, int, int);
     method public int describeContents();
diff --git a/core/api/system-current.txt b/core/api/system-current.txt
index 8b15ec3..ac4b369 100644
--- a/core/api/system-current.txt
+++ b/core/api/system-current.txt
@@ -182,6 +182,7 @@
     field public static final String READ_PRINT_SERVICES = "android.permission.READ_PRINT_SERVICES";
     field public static final String READ_PRINT_SERVICE_RECOMMENDATIONS = "android.permission.READ_PRINT_SERVICE_RECOMMENDATIONS";
     field public static final String READ_PRIVILEGED_PHONE_STATE = "android.permission.READ_PRIVILEGED_PHONE_STATE";
+    field public static final String READ_PROJECTION_STATE = "android.permission.READ_PROJECTION_STATE";
     field public static final String READ_RUNTIME_PROFILES = "android.permission.READ_RUNTIME_PROFILES";
     field public static final String READ_SEARCH_INDEXABLES = "android.permission.READ_SEARCH_INDEXABLES";
     field public static final String READ_SYSTEM_UPDATE_INFO = "android.permission.READ_SYSTEM_UPDATE_INFO";
@@ -234,6 +235,7 @@
     field public static final String SUSPEND_APPS = "android.permission.SUSPEND_APPS";
     field public static final String SYSTEM_CAMERA = "android.permission.SYSTEM_CAMERA";
     field public static final String TETHER_PRIVILEGED = "android.permission.TETHER_PRIVILEGED";
+    field public static final String TOGGLE_AUTOMOTIVE_PROJECTION = "android.permission.TOGGLE_AUTOMOTIVE_PROJECTION";
     field public static final String TV_INPUT_HARDWARE = "android.permission.TV_INPUT_HARDWARE";
     field public static final String TV_VIRTUAL_REMOTE_CONTROLLER = "android.permission.TV_VIRTUAL_REMOTE_CONTROLLER";
     field public static final String UNLIMITED_SHORTCUTS_API_CALLS = "android.permission.UNLIMITED_SHORTCUTS_API_CALLS";
@@ -744,7 +746,11 @@
   }
 
   public class UiModeManager {
+    method @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public void addOnProjectionStateChangeListener(int, @NonNull java.util.concurrent.Executor, @NonNull android.app.UiModeManager.OnProjectionStateChangeListener);
     method @RequiresPermission(android.Manifest.permission.ENTER_CAR_MODE_PRIORITIZED) public void enableCarMode(@IntRange(from=0) int, int);
+    method @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public int getActiveProjectionTypes();
+    method @NonNull @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public java.util.Set<java.lang.String> getProjectingPackages(int);
+    method @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE) public void removeOnProjectionStateChangeListener(@NonNull android.app.UiModeManager.OnProjectionStateChangeListener);
     field public static final String ACTION_ENTER_CAR_MODE_PRIORITIZED = "android.app.action.ENTER_CAR_MODE_PRIORITIZED";
     field public static final String ACTION_EXIT_CAR_MODE_PRIORITIZED = "android.app.action.EXIT_CAR_MODE_PRIORITIZED";
     field public static final int DEFAULT_PRIORITY = 0; // 0x0
@@ -752,6 +758,10 @@
     field public static final String EXTRA_PRIORITY = "android.app.extra.PRIORITY";
   }
 
+  public static interface UiModeManager.OnProjectionStateChangeListener {
+    method public void onProjectionStateChanged(int, @NonNull java.util.Set<java.lang.String>);
+  }
+
   public final class Vr2dDisplayProperties implements android.os.Parcelable {
     ctor public Vr2dDisplayProperties(int, int, int);
     method public int describeContents();
diff --git a/core/api/test-current.txt b/core/api/test-current.txt
index 67b496b..1fdb3e4a6 100644
--- a/core/api/test-current.txt
+++ b/core/api/test-current.txt
@@ -308,6 +308,11 @@
   public class UiModeManager {
     method public boolean isNightModeLocked();
     method public boolean isUiModeLocked();
+    method @RequiresPermission(value=android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION, conditional=true) public boolean releaseProjection(int);
+    method @RequiresPermission(value=android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION, conditional=true) public boolean requestProjection(int);
+    field public static final int PROJECTION_TYPE_ALL = 65535; // 0xffff
+    field public static final int PROJECTION_TYPE_AUTOMOTIVE = 1; // 0x1
+    field public static final int PROJECTION_TYPE_NONE = 0; // 0x0
   }
 
   public class WallpaperManager {
diff --git a/core/java/android/app/IOnProjectionStateChangeListener.aidl b/core/java/android/app/IOnProjectionStateChangeListener.aidl
new file mode 100644
index 0000000..f154985
--- /dev/null
+++ b/core/java/android/app/IOnProjectionStateChangeListener.aidl
@@ -0,0 +1,22 @@
+/*
+ * 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 android.app;
+
+/** {@hide} */
+oneway interface IOnProjectionStateChangeListener {
+  void onProjectionStateChanged(int activeProjectionTypes, in List<String> projectingPackages);
+}
\ No newline at end of file
diff --git a/core/java/android/app/IUiModeManager.aidl b/core/java/android/app/IUiModeManager.aidl
index 41e2ec9..0ba5bec 100644
--- a/core/java/android/app/IUiModeManager.aidl
+++ b/core/java/android/app/IUiModeManager.aidl
@@ -16,6 +16,8 @@
 
 package android.app;
 
+import android.app.IOnProjectionStateChangeListener;
+
 /**
  * Interface used to control special UI modes.
  * @hide
@@ -93,4 +95,34 @@
     * Sets custom end clock time
     */
     void setCustomNightModeEnd(long time);
+
+    /**
+    * Sets projection state for the caller for the given projection type.
+    */
+    boolean requestProjection(in IBinder binder, int projectionType, String callingPackage);
+
+    /**
+    * Releases projection state for the caller for the given projection type.
+    */
+    boolean releaseProjection(int projectionType, String callingPackage);
+
+    /**
+    * Registers a listener for changes to projection state.
+    */
+    void addOnProjectionStateChangeListener(in IOnProjectionStateChangeListener listener, int projectionType);
+
+    /**
+    * Unregisters a listener for changes to projection state.
+    */
+    void removeOnProjectionStateChangeListener(in IOnProjectionStateChangeListener listener);
+
+    /**
+    * Returns packages that have currently set the given projection type.
+    */
+    List<String> getProjectingPackages(int projectionType);
+
+    /**
+    * Returns currently set projection types.
+    */
+    int getActiveProjectionTypes();
 }
diff --git a/core/java/android/app/UiModeManager.java b/core/java/android/app/UiModeManager.java
index e2fc5dbf..8a6871f 100644
--- a/core/java/android/app/UiModeManager.java
+++ b/core/java/android/app/UiModeManager.java
@@ -16,6 +16,7 @@
 
 package android.app;
 
+import android.annotation.CallbackExecutor;
 import android.annotation.IntDef;
 import android.annotation.IntRange;
 import android.annotation.NonNull;
@@ -27,20 +28,32 @@
 import android.compat.annotation.UnsupportedAppUsage;
 import android.content.Context;
 import android.content.res.Configuration;
+import android.os.Binder;
 import android.os.RemoteException;
 import android.os.ServiceManager;
 import android.os.ServiceManager.ServiceNotFoundException;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Slog;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.function.pooled.PooledLambda;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
 import java.time.LocalTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
 
 /**
  * This class provides access to the system uimode services.  These services
  * allow applications to control UI modes of the device.
  * It provides functionality to disable the car mode and it gives access to the
  * night mode settings.
- * 
+ *
  * <p>These facilities are built on top of the underlying
  * {@link android.content.Intent#ACTION_DOCK_EVENT} broadcasts that are sent when the user
  * physical places the device into and out of a dock.  When that happens,
@@ -49,7 +62,7 @@
  * starts the corresponding mode activity if appropriate.  See the
  * broadcasts {@link #ACTION_ENTER_CAR_MODE} and
  * {@link #ACTION_ENTER_DESK_MODE} for more information.
- * 
+ *
  * <p>In addition, the user may manually switch the system to car mode without
  * physically being in a dock.  While in car mode -- whether by manual action
  * from the user or being physically placed in a dock -- a notification is
@@ -59,6 +72,25 @@
  */
 @SystemService(Context.UI_MODE_SERVICE)
 public class UiModeManager {
+    /**
+     * A listener with a single method that is invoked whenever the packages projecting using the
+     * {@link ProjectionType}s for which it is registered change.
+     *
+     * @hide
+     */
+    @SystemApi
+    public interface OnProjectionStateChangeListener {
+        /**
+         * Callback invoked when projection state changes for a {@link ProjectionType} for which
+         * this listener was added.
+         * @param projectionType the listened-for {@link ProjectionType}s that have changed
+         * @param packageNames the {@link Set} of package names that have currently set those
+         *     {@link ProjectionType}s.
+         */
+        void onProjectionStateChanged(@ProjectionType int projectionType,
+                @NonNull Set<String> packageNames);
+    }
+
     private static final String TAG = "UiModeManager";
 
     /**
@@ -100,7 +132,7 @@
     @SystemApi
     public static final String ACTION_ENTER_CAR_MODE_PRIORITIZED =
             "android.app.action.ENTER_CAR_MODE_PRIORITIZED";
-    
+
     /**
      * Broadcast sent when the device's UI has switch away from car mode back
      * to normal mode.  Typically used by a car mode app, to dismiss itself
@@ -137,7 +169,7 @@
     @SystemApi
     public static final String ACTION_EXIT_CAR_MODE_PRIORITIZED =
             "android.app.action.EXIT_CAR_MODE_PRIORITIZED";
-    
+
     /**
      * Broadcast sent when the device's UI has switched to desk mode,
      * by being placed in a desk dock.  After
@@ -151,7 +183,7 @@
      * of the broadcast to {@link Activity#RESULT_CANCELED}.
      */
     public static String ACTION_ENTER_DESK_MODE = "android.app.action.ENTER_DESK_MODE";
-    
+
     /**
      * Broadcast sent when the device's UI has switched away from desk mode back
      * to normal mode.  Typically used by a desk mode app, to dismiss itself
@@ -198,13 +230,13 @@
      * automatically switch night mode on and off based on the time.
      */
     public static final int MODE_NIGHT_CUSTOM = 3;
-    
+
     /**
      * Constant for {@link #setNightMode(int)} and {@link #getNightMode()}:
      * never run in night mode.
      */
     public static final int MODE_NIGHT_NO = 1;
-    
+
     /**
      * Constant for {@link #setNightMode(int)} and {@link #getNightMode()}:
      * always run in night mode.
@@ -219,6 +251,24 @@
      */
     private @Nullable Context mContext;
 
+    private final Object mLock = new Object();
+    /**
+     * Map that stores internally created {@link InnerListener} objects keyed by their corresponding
+     * externally provided {@link OnProjectionStateChangeListener} objects.
+     */
+    @GuardedBy("mLock")
+    private final Map<OnProjectionStateChangeListener, InnerListener>
+            mProjectionStateListenerMap = new ArrayMap<>();
+
+    /**
+     * Resource manager that prevents memory leakage of Contexts via binder objects if clients
+     * fail to remove listeners.
+     */
+    @GuardedBy("mLock")
+    private final OnProjectionStateChangeListenerResourceManager
+            mOnProjectionStateChangeListenerResourceManager =
+            new OnProjectionStateChangeListenerResourceManager();
+
     @UnsupportedAppUsage
     /*package*/ UiModeManager() throws ServiceNotFoundException {
         this(null /* context */);
@@ -251,7 +301,7 @@
     public static final int ENABLE_CAR_MODE_ALLOW_SLEEP = 0x0002;
 
     /** @hide */
-    @IntDef(prefix = { "ENABLE_CAR_MODE_" }, value = {
+    @IntDef(prefix = {"ENABLE_CAR_MODE_"}, value = {
             ENABLE_CAR_MODE_GO_CAR_HOME,
             ENABLE_CAR_MODE_ALLOW_SLEEP
     })
@@ -362,7 +412,7 @@
      */
     @SystemApi
     public static final int DEFAULT_PRIORITY = 0;
-    
+
     /**
      * Turn off special mode if currently in car mode.
      * @param flags One of the disable car mode flags.
@@ -595,4 +645,264 @@
         }
     }
 
+    /**
+     * Indicates no projection type. Can be used to compare with the {@link ProjectionType} in
+     * {@link OnProjectionStateChangeListener#onProjectionStateChanged(int, Set)}.
+     *
+     * @hide
+     */
+    @TestApi
+    public static final int PROJECTION_TYPE_NONE = 0x0000;
+    /**
+     * Automotive projection prevents degradation of GPS to save battery, routes incoming calls to
+     * the automotive role holder, etc. For use with {@link #requestProjection(int)} and
+     * {@link #clearProjectionState(int)}.
+     *
+     * @hide
+     */
+    @TestApi
+    public static final int PROJECTION_TYPE_AUTOMOTIVE = 0x0001;
+    /**
+     * Indicates all projection types. For use with
+     * {@link #addOnProjectionStateChangeListener(int, Executor, OnProjectionStateChangeListener)}
+     * and {@link #getProjectingPackages(int)}.
+     *
+     * @hide
+     */
+    @TestApi
+    public static final int PROJECTION_TYPE_ALL = 0xffff;
+
+    /** @hide */
+    @IntDef(prefix = {"PROJECTION_TYPE_"}, value = {
+            PROJECTION_TYPE_NONE,
+            PROJECTION_TYPE_AUTOMOTIVE,
+            PROJECTION_TYPE_ALL,
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface ProjectionType {
+    }
+
+    /**
+     * Sets the given {@link ProjectionType}.
+     *
+     * Caller must have {@link android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION} if
+     * argument is {@link #PROJECTION_TYPE_AUTOMOTIVE}.
+     * @param projectionType the type of projection to request. This must be a single
+     * {@link ProjectionType} and cannot be a bitmask.
+     * @return true if the projection was successfully set
+     * @throws IllegalArgumentException if passed {@link #PROJECTION_TYPE_NONE},
+     * {@link #PROJECTION_TYPE_ALL}, or any combination of more than one {@link ProjectionType}.
+     *
+     * @hide
+     */
+    @TestApi
+    @RequiresPermission(value = android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION,
+            conditional = true)
+    public boolean requestProjection(@ProjectionType int projectionType) {
+        if (mService != null) {
+            try {
+                return mService.requestProjection(new Binder(), projectionType,
+                        mContext.getOpPackageName());
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Releases the given {@link ProjectionType}.
+     *
+     * Caller must have {@link android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION} if
+     * argument is {@link #PROJECTION_TYPE_AUTOMOTIVE}.
+     * @param projectionType the type of projection to release. This must be a single
+     * {@link ProjectionType} and cannot be a bitmask.
+     * @return true if the package had set projection and it was successfully released
+     * @throws IllegalArgumentException if passed {@link #PROJECTION_TYPE_NONE},
+     * {@link #PROJECTION_TYPE_ALL}, or any combination of more than one {@link ProjectionType}.
+     *
+     * @hide
+     */
+    @TestApi
+    @RequiresPermission(value = android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION,
+            conditional = true)
+    public boolean releaseProjection(@ProjectionType int projectionType) {
+        if (mService != null) {
+            try {
+                return mService.releaseProjection(projectionType, mContext.getOpPackageName());
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Gets the packages that are currently projecting.
+     *
+     * @param projectionType the {@link ProjectionType}s to consider when computing which packages
+     *                       are projecting. Use {@link #PROJECTION_TYPE_ALL} to get all projecting
+     *                       packages.
+     *
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE)
+    @NonNull
+    public Set<String> getProjectingPackages(@ProjectionType int projectionType) {
+        if (mService != null) {
+            try {
+                return new ArraySet<>(mService.getProjectingPackages(projectionType));
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+        return Set.of();
+    }
+
+    /**
+     * Gets the {@link ProjectionType}s that are currently active.
+     *
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE)
+    public @ProjectionType int getActiveProjectionTypes() {
+        if (mService != null) {
+            try {
+                return mService.getActiveProjectionTypes();
+            } catch (RemoteException e) {
+                throw e.rethrowFromSystemServer();
+            }
+        }
+        return PROJECTION_TYPE_NONE;
+    }
+
+    /**
+     * Configures the listener to receive callbacks when the packages projecting using the given
+     * {@link ProjectionType}s change.
+     *
+     * @param projectionType one or more {@link ProjectionType}s to listen for changes regarding
+     * @param executor an {@link Executor} on which to invoke the callbacks
+     * @param listener the {@link OnProjectionStateChangeListener} to add
+     *
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE)
+    public void addOnProjectionStateChangeListener(@ProjectionType int projectionType,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OnProjectionStateChangeListener listener) {
+        synchronized (mLock) {
+            if (mProjectionStateListenerMap.containsKey(listener)) {
+                Slog.i(TAG, "Attempted to add listener that was already added.");
+                return;
+            }
+            if (mService != null) {
+                InnerListener innerListener = new InnerListener(executor, listener,
+                        mOnProjectionStateChangeListenerResourceManager);
+                try {
+                    mService.addOnProjectionStateChangeListener(innerListener, projectionType);
+                    mProjectionStateListenerMap.put(listener, innerListener);
+                } catch (RemoteException e) {
+                    mOnProjectionStateChangeListenerResourceManager.remove(innerListener);
+                    throw e.rethrowFromSystemServer();
+                }
+            }
+        }
+    }
+
+    /**
+     * Removes the listener so it stops receiving updates for all {@link ProjectionType}s.
+     *
+     * @param listener the {@link OnProjectionStateChangeListener} to remove
+     *
+     * @hide
+     */
+    @SystemApi
+    @RequiresPermission(android.Manifest.permission.READ_PROJECTION_STATE)
+    public void removeOnProjectionStateChangeListener(
+            @NonNull OnProjectionStateChangeListener listener) {
+        synchronized (mLock) {
+            InnerListener innerListener = mProjectionStateListenerMap.get(listener);
+            if (innerListener == null) {
+                Slog.i(TAG, "Attempted to remove listener that was not added.");
+                return;
+            }
+            if (mService != null) {
+                try {
+                    mService.removeOnProjectionStateChangeListener(innerListener);
+                } catch (RemoteException e) {
+                    throw e.rethrowFromSystemServer();
+                }
+            }
+            mProjectionStateListenerMap.remove(listener);
+            mOnProjectionStateChangeListenerResourceManager.remove(innerListener);
+        }
+    }
+
+    private static class InnerListener extends IOnProjectionStateChangeListener.Stub {
+        private final WeakReference<OnProjectionStateChangeListenerResourceManager>
+                mResourceManager;
+
+        private InnerListener(@NonNull Executor executor,
+                @NonNull OnProjectionStateChangeListener outerListener,
+                @NonNull OnProjectionStateChangeListenerResourceManager resourceManager) {
+            resourceManager.put(this, executor, outerListener);
+            mResourceManager = new WeakReference<>(resourceManager);
+        }
+
+        @Override
+        public void onProjectionStateChanged(int activeProjectionTypes,
+                List<String> projectingPackages) {
+            OnProjectionStateChangeListenerResourceManager resourceManager = mResourceManager.get();
+            if (resourceManager == null) {
+                Slog.w(TAG, "Can't execute onProjectionStateChanged, resource manager is gone.");
+                return;
+            }
+
+            OnProjectionStateChangeListener outerListener = resourceManager.getOuterListener(this);
+            Executor executor = resourceManager.getExecutor(this);
+            if (outerListener == null || executor == null) {
+                Slog.w(TAG, "Can't execute onProjectionStatechanged, references are null.");
+                return;
+            }
+
+            executor.execute(PooledLambda.obtainRunnable(
+                    OnProjectionStateChangeListener::onProjectionStateChanged,
+                    outerListener,
+                    activeProjectionTypes,
+                    new ArraySet<>(projectingPackages)).recycleOnUse());
+        }
+    }
+
+    /**
+     * Wrapper class that ensures we don't leak {@link Activity} or other large {@link Context} in
+     * which this {@link UiModeManager} resides if/when it ends without unregistering associated
+     * {@link OnProjectionStateChangeListener}s.
+     */
+    private static class OnProjectionStateChangeListenerResourceManager {
+        private final Map<InnerListener, OnProjectionStateChangeListener> mOuterListenerMap =
+                new ArrayMap<>(1);
+        private final Map<InnerListener, Executor> mExecutorMap = new ArrayMap<>(1);
+
+        void put(@NonNull InnerListener innerListener, @NonNull Executor executor,
+                OnProjectionStateChangeListener outerListener) {
+            mOuterListenerMap.put(innerListener, outerListener);
+            mExecutorMap.put(innerListener, executor);
+        }
+
+        void remove(InnerListener innerListener) {
+            mOuterListenerMap.remove(innerListener);
+            mExecutorMap.remove(innerListener);
+        }
+
+        OnProjectionStateChangeListener getOuterListener(@NonNull InnerListener innerListener) {
+            return mOuterListenerMap.get(innerListener);
+        }
+
+        Executor getExecutor(@NonNull InnerListener innerListener) {
+            return mExecutorMap.get(innerListener);
+        }
+    }
 }
diff --git a/core/proto/android/server/jobscheduler.proto b/core/proto/android/server/jobscheduler.proto
index 76b7fc0..0e2bd26 100644
--- a/core/proto/android/server/jobscheduler.proto
+++ b/core/proto/android/server/jobscheduler.proto
@@ -527,7 +527,8 @@
                 optional bool is_idle = 1;
                 optional bool is_screen_on = 2;
                 optional bool is_dock_idle = 3;
-                optional bool in_car_mode = 4;
+                reserved 4; // in_car_mode
+                optional bool projection_active = 5;
             }
 
             oneof active_tracker {
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index a7cf0cf..1550763 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -2674,6 +2674,21 @@
     <permission android:name="android.permission.COMPANION_APPROVE_WIFI_CONNECTIONS"
                 android:protectionLevel="signature|privileged" />
 
+    <!-- Allows an app to read and listen to projection state.
+         @hide
+         @SystemApi
+    -->
+    <permission android:name="android.permission.READ_PROJECTION_STATE"
+                android:protectionLevel="signature" />
+
+    <!-- Allows an app to set and release automotive projection.
+         <p>Once permissions can be granted via role-only, this needs to be changed to
+          protectionLevel="role" and added to the SYSTEM_AUTOMOTIVE_PROJECTION role.
+         @hide
+         @SystemApi
+    -->
+    <permission android:name="android.permission.TOGGLE_AUTOMOTIVE_PROJECTION"
+                android:protectionLevel="signature|privileged" />
 
     <!-- ================================== -->
     <!-- Permissions affecting the system wallpaper -->
diff --git a/data/etc/privapp-permissions-platform.xml b/data/etc/privapp-permissions-platform.xml
index 4c3b36f..42ebfc5 100644
--- a/data/etc/privapp-permissions-platform.xml
+++ b/data/etc/privapp-permissions-platform.xml
@@ -164,7 +164,6 @@
         <permission name="android.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS"/>
         <permission name="android.permission.CONTROL_INCALL_EXPERIENCE"/>
         <permission name="android.permission.DUMP"/>
-        <permission name="android.permission.HANDLE_CAR_MODE_CHANGES"/>
         <permission name="android.permission.INTERACT_ACROSS_USERS"/>
         <permission name="android.permission.LOCAL_MAC_ADDRESS"/>
         <permission name="android.permission.MANAGE_USERS"/>
@@ -175,6 +174,7 @@
         <permission name="android.permission.READ_CARRIER_APP_INFO"/>
         <permission name="android.permission.READ_NETWORK_USAGE_HISTORY"/>
         <permission name="android.permission.READ_PRIVILEGED_PHONE_STATE"/>
+        <permission name="android.permission.READ_PROJECTION_STATE"/>
         <permission name="android.permission.READ_SEARCH_INDEXABLES"/>
         <permission name="android.permission.REBOOT"/>
         <permission name="android.permission.REGISTER_CALL_PROVIDER"/>
@@ -376,6 +376,8 @@
         <permission name="android.permission.STOP_APP_SWITCHES"/>
         <permission name="android.permission.SUBSTITUTE_NOTIFICATION_APP_NAME"/>
         <permission name="android.permission.SUSPEND_APPS" />
+        <!-- Permissions required for UiModeManager and Telecom car mode CTS tests -->
+        <permission name="android.permission.TOGGLE_AUTOMOTIVE_PROJECTION" />
         <permission name="android.permission.UPDATE_APP_OPS_STATS"/>
         <permission name="android.permission.USE_RESERVED_DISK"/>
         <permission name="android.permission.WIFI_UPDATE_USABILITY_STATS_SCORE"/>
diff --git a/packages/Shell/AndroidManifest.xml b/packages/Shell/AndroidManifest.xml
index ec47c71..20b4451 100644
--- a/packages/Shell/AndroidManifest.xml
+++ b/packages/Shell/AndroidManifest.xml
@@ -250,6 +250,9 @@
     <!-- Permission required for CTS test - UiModeManagerTest -->
     <uses-permission android:name="android.permission.ENTER_CAR_MODE_PRIORITIZED"/>
 
+    <!-- Permission required for CTS tests - UiModeManagerTest, CarModeInCallServiceTest -->
+    <uses-permission android:name="android.permission.TOGGLE_AUTOMOTIVE_PROJECTION"/>
+
     <!-- Permission required for CTS test - SystemConfigTest -->
     <uses-permission android:name="android.permission.READ_CARRIER_APP_INFO"/>
 
diff --git a/services/core/java/com/android/server/UiModeManagerService.java b/services/core/java/com/android/server/UiModeManagerService.java
index 7051452..f49f1b1 100644
--- a/services/core/java/com/android/server/UiModeManagerService.java
+++ b/services/core/java/com/android/server/UiModeManagerService.java
@@ -20,6 +20,8 @@
 import static android.app.UiModeManager.MODE_NIGHT_AUTO;
 import static android.app.UiModeManager.MODE_NIGHT_CUSTOM;
 import static android.app.UiModeManager.MODE_NIGHT_YES;
+import static android.app.UiModeManager.PROJECTION_TYPE_AUTOMOTIVE;
+import static android.app.UiModeManager.PROJECTION_TYPE_NONE;
 import static android.os.UserHandle.USER_SYSTEM;
 import static android.util.TimeUtils.isTimeBetween;
 
@@ -30,6 +32,7 @@
 import android.app.ActivityManager;
 import android.app.ActivityTaskManager;
 import android.app.AlarmManager;
+import android.app.IOnProjectionStateChangeListener;
 import android.app.IUiModeManager;
 import android.app.Notification;
 import android.app.NotificationManager;
@@ -48,10 +51,12 @@
 import android.os.BatteryManager;
 import android.os.Binder;
 import android.os.Handler;
+import android.os.IBinder;
 import android.os.PowerManager;
 import android.os.PowerManager.ServiceType;
 import android.os.PowerManagerInternal;
 import android.os.Process;
+import android.os.RemoteCallbackList;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
 import android.os.ServiceManager;
@@ -65,8 +70,10 @@
 import android.service.vr.IVrStateCallbacks;
 import android.util.ArraySet;
 import android.util.Slog;
+import android.util.SparseArray;
 
 import com.android.internal.R;
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.app.DisableCarModeActivity;
 import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
@@ -83,7 +90,9 @@
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.time.ZoneId;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -97,7 +106,9 @@
     private static final boolean ENABLE_LAUNCH_DESK_DOCK_APP = true;
     private static final String SYSTEM_PROPERTY_DEVICE_THEME = "persist.sys.theme";
 
-    final Object mLock = new Object();
+    private final Injector mInjector;
+    private final Object mLock = new Object();
+
     private int mDockState = Intent.EXTRA_DOCK_STATE_UNDOCKED;
 
     private int mLastBroadcastState = Intent.EXTRA_DOCK_STATE_UNDOCKED;
@@ -162,17 +173,25 @@
     private final LocalService mLocalService = new LocalService();
     private PowerManagerInternal mLocalPowerManager;
 
+    @GuardedBy("mLock")
+    @Nullable
+    private SparseArray<List<ProjectionHolder>> mProjectionHolders;
+    @GuardedBy("mLock")
+    @Nullable
+    private SparseArray<RemoteCallbackList<IOnProjectionStateChangeListener>> mProjectionListeners;
+
     public UiModeManagerService(Context context) {
-        super(context);
-        mConfiguration.setToDefaults();
+        this(context, /* setupWizardComplete= */ false, /* tm= */ null, new Injector());
     }
 
     @VisibleForTesting
     protected UiModeManagerService(Context context, boolean setupWizardComplete,
-            TwilightManager tm) {
-        this(context);
+            TwilightManager tm, Injector injector) {
+        super(context);
+        mConfiguration.setToDefaults();
         mSetupWizardComplete = setupWizardComplete;
         mTwilightManager = tm;
+        mInjector = injector;
     }
 
     private static Intent buildHomeIntent(String category) {
@@ -639,7 +658,7 @@
             // If the caller is the system, we will allow the DISABLE_CAR_MODE_ALL_PRIORITIES car
             // mode flag to be specified; this is so that the user can disable car mode at all
             // priorities using the persistent notification.
-            boolean isSystemCaller = Binder.getCallingUid() == Process.SYSTEM_UID;
+            boolean isSystemCaller = mInjector.getCallingUid() == Process.SYSTEM_UID;
             final int carModeFlags =
                     isSystemCaller ? flags : flags & ~UiModeManager.DISABLE_CAR_MODE_ALL_PRIORITIES;
 
@@ -853,8 +872,308 @@
                 Binder.restoreCallingIdentity(ident);
             }
         }
+
+        @Override
+        public boolean requestProjection(IBinder binder,
+                @UiModeManager.ProjectionType int projectionType,
+                @NonNull String callingPackage) {
+            assertLegit(callingPackage);
+            assertSingleProjectionType(projectionType);
+            enforceProjectionTypePermissions(projectionType);
+            synchronized (mLock) {
+                if (mProjectionHolders == null) {
+                    mProjectionHolders = new SparseArray<>(1);
+                }
+                if (!mProjectionHolders.contains(projectionType)) {
+                    mProjectionHolders.put(projectionType, new ArrayList<>(1));
+                }
+                List<ProjectionHolder> currentHolders = mProjectionHolders.get(projectionType);
+
+                // For all projection types, it's a noop if already held.
+                for (int i = 0; i < currentHolders.size(); ++i) {
+                    if (callingPackage.equals(currentHolders.get(i).mPackageName)) {
+                        return true;
+                    }
+                }
+
+                // Enforce projection type-specific restrictions here.
+
+                // Automotive projection can only be set if it is currently unset. The case where it
+                // is already set by the calling package is taken care of above.
+                if (projectionType == PROJECTION_TYPE_AUTOMOTIVE && !currentHolders.isEmpty()) {
+                    return false;
+                }
+
+                ProjectionHolder projectionHolder = new ProjectionHolder(callingPackage,
+                        projectionType, binder,
+                        UiModeManagerService.this::releaseProjectionUnchecked);
+                if (!projectionHolder.linkToDeath()) {
+                    return false;
+                }
+                currentHolders.add(projectionHolder);
+                Slog.d(TAG, "Package " + callingPackage + " set projection type "
+                        + projectionType + ".");
+                onProjectionStateChangedLocked(projectionType);
+            }
+            return true;
+        }
+
+        @Override
+        public boolean releaseProjection(@UiModeManager.ProjectionType int projectionType,
+                @NonNull String callingPackage) {
+            assertLegit(callingPackage);
+            assertSingleProjectionType(projectionType);
+            enforceProjectionTypePermissions(projectionType);
+            return releaseProjectionUnchecked(projectionType, callingPackage);
+        }
+
+        @Override
+        public @UiModeManager.ProjectionType int getActiveProjectionTypes() {
+            getContext().enforceCallingOrSelfPermission(
+                    android.Manifest.permission.READ_PROJECTION_STATE, "getActiveProjectionTypes");
+            @UiModeManager.ProjectionType int projectionTypeFlag = PROJECTION_TYPE_NONE;
+            synchronized (mLock) {
+                if (mProjectionHolders != null) {
+                    for (int i = 0; i < mProjectionHolders.size(); ++i) {
+                        if (!mProjectionHolders.valueAt(i).isEmpty()) {
+                            projectionTypeFlag = projectionTypeFlag | mProjectionHolders.keyAt(i);
+                        }
+                    }
+                }
+            }
+            return projectionTypeFlag;
+        }
+
+        @Override
+        public List<String> getProjectingPackages(
+                @UiModeManager.ProjectionType int projectionType) {
+            getContext().enforceCallingOrSelfPermission(
+                    android.Manifest.permission.READ_PROJECTION_STATE, "getProjectionState");
+            synchronized (mLock) {
+                List<String> packageNames = new ArrayList<>();
+                populateWithRelevantActivePackageNames(projectionType, packageNames);
+                return packageNames;
+            }
+        }
+
+        public void addOnProjectionStateChangeListener(IOnProjectionStateChangeListener listener,
+                @UiModeManager.ProjectionType int projectionType) {
+            getContext().enforceCallingOrSelfPermission(
+                    android.Manifest.permission.READ_PROJECTION_STATE,
+                    "registerProjectionStateListener");
+            if (projectionType == PROJECTION_TYPE_NONE) {
+                return;
+            }
+            synchronized (mLock) {
+                if (mProjectionListeners == null) {
+                    mProjectionListeners = new SparseArray<>(1);
+                }
+                if (!mProjectionListeners.contains(projectionType)) {
+                    mProjectionListeners.put(projectionType, new RemoteCallbackList<>());
+                }
+                if (mProjectionListeners.get(projectionType).register(listener)) {
+                    // If any of those types are active, send a callback immediately.
+                    List<String> packageNames = new ArrayList<>();
+                    @UiModeManager.ProjectionType int activeProjectionTypes =
+                            populateWithRelevantActivePackageNames(projectionType, packageNames);
+                    if (!packageNames.isEmpty()) {
+                        try {
+                            listener.onProjectionStateChanged(activeProjectionTypes, packageNames);
+                        } catch (RemoteException e) {
+                            Slog.w(TAG,
+                                    "Failed a call to onProjectionStateChanged() during listener "
+                                            + "registration.");
+                        }
+                    }
+                }
+            }
+        }
+
+
+        public void removeOnProjectionStateChangeListener(
+                IOnProjectionStateChangeListener listener) {
+            getContext().enforceCallingOrSelfPermission(
+                    android.Manifest.permission.READ_PROJECTION_STATE,
+                    "unregisterProjectionStateListener");
+            synchronized (mLock) {
+                if (mProjectionListeners != null) {
+                    for (int i = 0; i < mProjectionListeners.size(); ++i) {
+                        mProjectionListeners.valueAt(i).unregister(listener);
+                    }
+                }
+            }
+        }
     };
 
+    private void enforceProjectionTypePermissions(@UiModeManager.ProjectionType int p) {
+        if ((p & PROJECTION_TYPE_AUTOMOTIVE) != 0) {
+            getContext().enforceCallingPermission(
+                    android.Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION,
+                    "toggleProjection");
+        }
+    }
+
+    private static void assertSingleProjectionType(@UiModeManager.ProjectionType int p) {
+        // To be a single projection type it must be greater than zero and an exact power of two.
+        boolean projectionTypeIsPowerOfTwoOrZero = (p & p - 1) == 0;
+        if (p <= 0 || !projectionTypeIsPowerOfTwoOrZero) {
+            throw new IllegalArgumentException("Must specify exactly one projection type.");
+        }
+    }
+
+    private static List<String> toPackageNameList(Collection<ProjectionHolder> c) {
+        List<String> packageNames = new ArrayList<>();
+        for (ProjectionHolder p : c) {
+            packageNames.add(p.mPackageName);
+        }
+        return packageNames;
+    }
+
+    /**
+     * Populates a list with the package names that have set any of the given projection types.
+     * @param projectionType the projection types to include
+     * @param packageNames the list to populate with package names
+     * @return the active projection types
+     */
+    @GuardedBy("mLock")
+    @UiModeManager.ProjectionType
+    private int populateWithRelevantActivePackageNames(
+            @UiModeManager.ProjectionType int projectionType, List<String> packageNames) {
+        packageNames.clear();
+        @UiModeManager.ProjectionType int projectionTypeFlag = PROJECTION_TYPE_NONE;
+        if (mProjectionHolders != null) {
+            for (int i = 0; i < mProjectionHolders.size(); ++i) {
+                int key = mProjectionHolders.keyAt(i);
+                List<ProjectionHolder> holders = mProjectionHolders.valueAt(i);
+                if ((projectionType & key) != 0) {
+                    if (packageNames.addAll(toPackageNameList(holders))) {
+                        projectionTypeFlag = projectionTypeFlag | key;
+                    }
+                }
+            }
+        }
+        return projectionTypeFlag;
+    }
+
+    private boolean releaseProjectionUnchecked(@UiModeManager.ProjectionType int projectionType,
+            @NonNull String pkg) {
+        synchronized (mLock) {
+            boolean removed = false;
+            if (mProjectionHolders != null) {
+                List<ProjectionHolder> holders = mProjectionHolders.get(projectionType);
+                if (holders != null) {
+                    // Iterate backward so we can safely remove while iterating.
+                    for (int i = holders.size() - 1; i >= 0; --i) {
+                        ProjectionHolder holder = holders.get(i);
+                        if (pkg.equals(holder.mPackageName)) {
+                            holder.unlinkToDeath();
+                            Slog.d(TAG, "Projection type " + projectionType + " released by "
+                                    + pkg + ".");
+                            holders.remove(i);
+                            removed = true;
+                        }
+                    }
+                }
+            }
+            if (removed) {
+                onProjectionStateChangedLocked(projectionType);
+            } else {
+                Slog.w(TAG, pkg + " tried to release projection type " + projectionType
+                        + " but was not set by that package.");
+            }
+            return removed;
+        }
+    }
+
+    private static class ProjectionHolder implements IBinder.DeathRecipient {
+        private final String mPackageName;
+        private final @UiModeManager.ProjectionType int mProjectionType;
+        private final IBinder mBinder;
+        private final ProjectionReleaser mProjectionReleaser;
+
+        private ProjectionHolder(String packageName,
+                @UiModeManager.ProjectionType int projectionType, IBinder binder,
+                ProjectionReleaser projectionReleaser) {
+            mPackageName = packageName;
+            mProjectionType = projectionType;
+            mBinder = binder;
+            mProjectionReleaser = projectionReleaser;
+        }
+
+        private boolean linkToDeath() {
+            try {
+                mBinder.linkToDeath(this, 0);
+            } catch (RemoteException e) {
+                Slog.e(TAG, "linkToDeath failed for projection requester: " + mPackageName + ".",
+                        e);
+                return false;
+            }
+            return true;
+        }
+
+        private void unlinkToDeath() {
+            mBinder.unlinkToDeath(this, 0);
+        }
+
+        @Override
+        public void binderDied() {
+            Slog.w(TAG, "Projection holder " + mPackageName
+                    + " died. Releasing projection type " + mProjectionType + ".");
+            mProjectionReleaser.release(mProjectionType, mPackageName);
+        }
+
+        private interface ProjectionReleaser {
+            boolean release(@UiModeManager.ProjectionType int projectionType,
+                    @NonNull String packageName);
+        }
+    }
+
+    private void assertLegit(@NonNull String packageName) {
+        if (!doesPackageHaveCallingUid(packageName)) {
+            throw new SecurityException("Caller claimed bogus packageName: " + packageName + ".");
+        }
+    }
+
+    private boolean doesPackageHaveCallingUid(@NonNull String packageName) {
+        try {
+            return getContext().getPackageManager().getPackageUid(packageName, 0)
+                    == mInjector.getCallingUid();
+        } catch (PackageManager.NameNotFoundException e) {
+            return false;
+        }
+    }
+
+    @GuardedBy("mLock")
+    private void onProjectionStateChangedLocked(
+            @UiModeManager.ProjectionType int changedProjectionType) {
+        if (mProjectionListeners == null) {
+            return;
+        }
+        for (int i = 0; i < mProjectionListeners.size(); ++i) {
+            int listenerProjectionType = mProjectionListeners.keyAt(i);
+            // Every listener that is affected must be called back with all the state they are
+            // listening for.
+            if ((changedProjectionType & listenerProjectionType) != 0) {
+                RemoteCallbackList<IOnProjectionStateChangeListener> listeners =
+                        mProjectionListeners.valueAt(i);
+                List<String> packageNames = new ArrayList<>();
+                @UiModeManager.ProjectionType int activeProjectionTypes =
+                        populateWithRelevantActivePackageNames(listenerProjectionType,
+                                packageNames);
+                int listenerCount = listeners.beginBroadcast();
+                for (int j = 0; j < listenerCount; ++j) {
+                    try {
+                        listeners.getBroadcastItem(j).onProjectionStateChanged(
+                                activeProjectionTypes, packageNames);
+                    } catch (RemoteException e) {
+                        Slog.w(TAG, "Failed a call to onProjectionStateChanged().");
+                    }
+                }
+                listeners.finishBroadcast();
+            }
+        }
+    }
+
     private void onCustomTimeUpdated(int user) {
         persistNightMode(user);
         if (mNightMode != MODE_NIGHT_CUSTOM) return;
@@ -1656,4 +1975,11 @@
             }
         }
     }
+
+    @VisibleForTesting
+    public static class Injector {
+        public int getCallingUid() {
+            return Binder.getCallingUid();
+        }
+    }
 }
diff --git a/services/core/java/com/android/server/power/batterysaver/BatterySaverPolicy.java b/services/core/java/com/android/server/power/batterysaver/BatterySaverPolicy.java
index 701197e..1883f4e 100644
--- a/services/core/java/com/android/server/power/batterysaver/BatterySaverPolicy.java
+++ b/services/core/java/com/android/server/power/batterysaver/BatterySaverPolicy.java
@@ -17,12 +17,8 @@
 
 import android.annotation.IntDef;
 import android.app.UiModeManager;
-import android.content.BroadcastReceiver;
 import android.content.ContentResolver;
 import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.res.Configuration;
 import android.database.ContentObserver;
 import android.net.Uri;
 import android.os.BatterySaverPolicyConfig;
@@ -190,12 +186,12 @@
     /**
      * Whether accessibility is currently enabled or not.
      */
-    @GuardedBy("mLock")
-    private boolean mAccessibilityEnabled;
+    @VisibleForTesting
+    final PolicyBoolean mAccessibilityEnabled = new PolicyBoolean("accessibility");
 
-    /** Whether the phone is projecting in car mode or not. */
-    @GuardedBy("mLock")
-    private boolean mCarModeEnabled;
+    /** Whether the phone has set automotive projection or not. */
+    @VisibleForTesting
+    final PolicyBoolean mAutomotiveProjectionActive = new PolicyBoolean("automotiveProjection");
 
     /** The current default adaptive policy. */
     @GuardedBy("mLock")
@@ -235,19 +231,8 @@
     private final ContentResolver mContentResolver;
     private final BatterySavingStats mBatterySavingStats;
 
-    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            switch (intent.getAction()) {
-                case UiModeManager.ACTION_ENTER_CAR_MODE_PRIORITIZED:
-                    setCarModeEnabled(true);
-                    break;
-                case UiModeManager.ACTION_EXIT_CAR_MODE_PRIORITIZED:
-                    setCarModeEnabled(false);
-                    break;
-            }
-        }
-    };
+    private final UiModeManager.OnProjectionStateChangeListener mOnProjectionStateChangeListener =
+            (t, pkgs) -> mAutomotiveProjectionActive.update(!pkgs.isEmpty());
 
     @GuardedBy("mLock")
     private final List<BatterySaverPolicyListener> mListeners = new ArrayList<>();
@@ -282,24 +267,14 @@
 
         final AccessibilityManager acm = mContext.getSystemService(AccessibilityManager.class);
 
-        acm.addAccessibilityStateChangeListener((enabled) -> setAccessibilityEnabled(enabled));
-        final boolean accessibilityEnabled = acm.isEnabled();
-        synchronized (mLock) {
-            mAccessibilityEnabled = accessibilityEnabled;
-        }
+        acm.addAccessibilityStateChangeListener(enabled -> mAccessibilityEnabled.update(enabled));
+        mAccessibilityEnabled.initialize(acm.isEnabled());
 
-        final IntentFilter filter = new IntentFilter(
-                UiModeManager.ACTION_ENTER_CAR_MODE_PRIORITIZED);
-        filter.addAction(UiModeManager.ACTION_EXIT_CAR_MODE_PRIORITIZED);
-        // The ENTER/EXIT_CAR_MODE_PRIORITIZED intents are sent to UserHandle.ALL, so no need to
-        // register as all users here.
-        mContext.registerReceiver(mBroadcastReceiver, filter);
-        final boolean carModeEnabled =
-                mContext.getSystemService(UiModeManager.class).getCurrentModeType()
-                        == Configuration.UI_MODE_TYPE_CAR;
-        synchronized (mLock) {
-            mCarModeEnabled = carModeEnabled;
-        }
+        UiModeManager uiModeManager = mContext.getSystemService(UiModeManager.class);
+        uiModeManager.addOnProjectionStateChangeListener(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE,
+                mContext.getMainExecutor(), mOnProjectionStateChangeListener);
+        mAutomotiveProjectionActive.initialize(
+                uiModeManager.getActiveProjectionTypes() != UiModeManager.PROJECTION_TYPE_NONE);
 
         onChange(true, null);
     }
@@ -450,7 +425,7 @@
         final int locationMode;
 
         invalidatePowerSaveModeCaches();
-        if (mCarModeEnabled
+        if (mAutomotiveProjectionActive.get()
                 && rawPolicy.locationMode != PowerManager.LOCATION_MODE_NO_CHANGE
                 && rawPolicy.locationMode != PowerManager.LOCATION_MODE_FOREGROUND_ONLY) {
             // If car projection is enabled, ensure that navigation works.
@@ -470,12 +445,12 @@
                 rawPolicy.disableOptionalSensors,
                 rawPolicy.disableSoundTrigger,
                 // Don't disable vibration when accessibility is on.
-                rawPolicy.disableVibration && !mAccessibilityEnabled,
+                rawPolicy.disableVibration && !mAccessibilityEnabled.get(),
                 rawPolicy.enableAdjustBrightness,
                 rawPolicy.enableDataSaver,
                 rawPolicy.enableFirewall,
                 // Don't force night mode when car projection is enabled.
-                rawPolicy.enableNightMode && !mCarModeEnabled,
+                rawPolicy.enableNightMode && !mAutomotiveProjectionActive.get(),
                 rawPolicy.enableQuickDoze,
                 rawPolicy.filesForInteractive,
                 rawPolicy.filesForNoninteractive,
@@ -1073,8 +1048,8 @@
                     + Settings.Global.BATTERY_SAVER_ADAPTIVE_DEVICE_SPECIFIC_CONSTANTS);
             pw.println("    value: " + mAdaptiveDeviceSpecificSettings);
 
-            pw.println("  mAccessibilityEnabled=" + mAccessibilityEnabled);
-            pw.println("  mCarModeEnabled=" + mCarModeEnabled);
+            pw.println("  mAccessibilityEnabled=" + mAccessibilityEnabled.get());
+            pw.println("  mAutomotiveProjectionActive=" + mAutomotiveProjectionActive.get());
             pw.println("  mPolicyLevel=" + mPolicyLevel);
 
             dumpPolicyLocked(pw, "  ", "full", mFullPolicy);
@@ -1147,24 +1122,42 @@
         }
     }
 
+    /**
+     * A boolean value which should trigger a policy update when it changes.
+     */
     @VisibleForTesting
-    void setAccessibilityEnabled(boolean enabled) {
-        synchronized (mLock) {
-            if (mAccessibilityEnabled != enabled) {
-                mAccessibilityEnabled = enabled;
-                updatePolicyDependenciesLocked();
-                maybeNotifyListenersOfPolicyChange();
+    class PolicyBoolean {
+        private final String mDebugName;
+        @GuardedBy("mLock")
+        private boolean mValue;
+
+        private PolicyBoolean(String debugName) {
+            mDebugName = debugName;
+        }
+
+        /** Sets the initial value without triggering a policy update. */
+        private void initialize(boolean initialValue) {
+            synchronized (mLock) {
+                mValue = initialValue;
             }
         }
-    }
 
-    @VisibleForTesting
-    void setCarModeEnabled(boolean enabled) {
-        synchronized (mLock) {
-            if (mCarModeEnabled != enabled) {
-                mCarModeEnabled = enabled;
-                updatePolicyDependenciesLocked();
-                maybeNotifyListenersOfPolicyChange();
+        private boolean get() {
+            synchronized (mLock) {
+                return mValue;
+            }
+        }
+
+        /** Sets a value, which if different from the current value, triggers a policy update. */
+        @VisibleForTesting
+        void update(boolean newValue) {
+            synchronized (mLock) {
+                if (mValue != newValue) {
+                    Slog.d(TAG, mDebugName + " changed to " + newValue + ", updating policy.");
+                    mValue = newValue;
+                    updatePolicyDependenciesLocked();
+                    maybeNotifyListenersOfPolicyChange();
+                }
             }
         }
     }
diff --git a/services/tests/mockingservicestests/src/com/android/server/job/JobSchedulerServiceTest.java b/services/tests/mockingservicestests/src/com/android/server/job/JobSchedulerServiceTest.java
index 043ca9e..dcbf8c0 100644
--- a/services/tests/mockingservicestests/src/com/android/server/job/JobSchedulerServiceTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/job/JobSchedulerServiceTest.java
@@ -38,6 +38,7 @@
 import android.app.ActivityManager;
 import android.app.ActivityManagerInternal;
 import android.app.IActivityManager;
+import android.app.UiModeManager;
 import android.app.job.JobInfo;
 import android.app.job.JobScheduler;
 import android.app.usage.UsageStatsManagerInternal;
@@ -147,6 +148,8 @@
         JobSchedulerService.sSystemClock = Clock.fixed(Clock.systemUTC().instant(), ZoneOffset.UTC);
         JobSchedulerService.sElapsedRealtimeClock =
                 Clock.fixed(SystemClock.elapsedRealtimeClock().instant(), ZoneOffset.UTC);
+        // Called by DeviceIdlenessTracker
+        when(mContext.getSystemService(UiModeManager.class)).thenReturn(mock(UiModeManager.class));
 
         mService = new TestJobSchedulerService(mContext);
     }
diff --git a/services/tests/servicestests/AndroidManifest.xml b/services/tests/servicestests/AndroidManifest.xml
index 6ead95c..b9aa554 100644
--- a/services/tests/servicestests/AndroidManifest.xml
+++ b/services/tests/servicestests/AndroidManifest.xml
@@ -86,6 +86,7 @@
     <uses-permission android:name="android.permission.ACCESS_VIBRATOR_STATE"/>
     <uses-permission android:name="android.permission.VIBRATE_ALWAYS_ON"/>
     <uses-permission android:name="android.permission.CONTROL_DEVICE_STATE"/>
+    <uses-permission android:name="android.permission.READ_PROJECTION_STATE"/>
 
     <!-- Uses API introduced in O (26) -->
     <uses-sdk android:minSdkVersion="1"
diff --git a/services/tests/servicestests/src/com/android/server/power/batterysaver/BatterySaverPolicyTest.java b/services/tests/servicestests/src/com/android/server/power/batterysaver/BatterySaverPolicyTest.java
index dc30add..fb6b29e 100644
--- a/services/tests/servicestests/src/com/android/server/power/batterysaver/BatterySaverPolicyTest.java
+++ b/services/tests/servicestests/src/com/android/server/power/batterysaver/BatterySaverPolicyTest.java
@@ -117,7 +117,7 @@
 
     @SmallTest
     public void testGetBatterySaverPolicy_PolicyVibration_WithAccessibilityEnabled() {
-        mBatterySaverPolicy.setAccessibilityEnabled(true);
+        mBatterySaverPolicy.mAccessibilityEnabled.update(true);
         testServiceDefaultValue_Off(ServiceType.VIBRATION);
     }
 
@@ -340,7 +340,7 @@
         verifyBatterySaverConstantsUpdated();
     }
 
-    public void testCarModeChanges_Full() {
+    public void testAutomotiveProjectionChanges_Full() {
         mBatterySaverPolicy.updateConstantsLocked(
                 "gps_mode=" + PowerManager.LOCATION_MODE_ALL_DISABLED_WHEN_SCREEN_OFF
                         + ",enable_night_mode=true", "");
@@ -350,7 +350,7 @@
         assertTrue(mBatterySaverPolicy.getBatterySaverPolicy(
                 ServiceType.NIGHT_MODE).batterySaverEnabled);
 
-        mBatterySaverPolicy.setCarModeEnabled(true);
+        mBatterySaverPolicy.mAutomotiveProjectionActive.update(true);
 
         assertThat(mBatterySaverPolicy.getBatterySaverPolicy(ServiceType.LOCATION).locationMode)
                 .isAnyOf(PowerManager.LOCATION_MODE_NO_CHANGE,
@@ -358,7 +358,7 @@
         assertFalse(mBatterySaverPolicy.getBatterySaverPolicy(
                 ServiceType.NIGHT_MODE).batterySaverEnabled);
 
-        mBatterySaverPolicy.setCarModeEnabled(false);
+        mBatterySaverPolicy.mAutomotiveProjectionActive.update(false);
 
         assertThat(mBatterySaverPolicy.getBatterySaverPolicy(ServiceType.LOCATION).locationMode)
                 .isEqualTo(PowerManager.LOCATION_MODE_ALL_DISABLED_WHEN_SCREEN_OFF);
@@ -366,7 +366,7 @@
                 ServiceType.NIGHT_MODE).batterySaverEnabled);
     }
 
-    public void testCarModeChanges_Adaptive() {
+    public void testAutomotiveProjectionChanges_Adaptive() {
         mBatterySaverPolicy.setAdaptivePolicyLocked(
                 Policy.fromSettings(
                         "gps_mode=" + PowerManager.LOCATION_MODE_ALL_DISABLED_WHEN_SCREEN_OFF
@@ -377,7 +377,7 @@
         assertTrue(mBatterySaverPolicy.getBatterySaverPolicy(
                 ServiceType.NIGHT_MODE).batterySaverEnabled);
 
-        mBatterySaverPolicy.setCarModeEnabled(true);
+        mBatterySaverPolicy.mAutomotiveProjectionActive.update(true);
 
         assertThat(mBatterySaverPolicy.getBatterySaverPolicy(ServiceType.LOCATION).locationMode)
                 .isAnyOf(PowerManager.LOCATION_MODE_NO_CHANGE,
@@ -385,7 +385,7 @@
         assertFalse(mBatterySaverPolicy.getBatterySaverPolicy(
                 ServiceType.NIGHT_MODE).batterySaverEnabled);
 
-        mBatterySaverPolicy.setCarModeEnabled(false);
+        mBatterySaverPolicy.mAutomotiveProjectionActive.update(false);
 
         assertThat(mBatterySaverPolicy.getBatterySaverPolicy(ServiceType.LOCATION).locationMode)
                 .isEqualTo(PowerManager.LOCATION_MODE_ALL_DISABLED_WHEN_SCREEN_OFF);
diff --git a/services/tests/uiservicestests/Android.bp b/services/tests/uiservicestests/Android.bp
index 4439f99..e5646db 100644
--- a/services/tests/uiservicestests/Android.bp
+++ b/services/tests/uiservicestests/Android.bp
@@ -25,6 +25,9 @@
         "hamcrest-library",
         "testables",
         "truth-prebuilt",
+        // TODO: remove once Android migrates to JUnit 4.12,
+        // which provides assertThrows
+        "testng",
     ],
 
     libs: [
diff --git a/services/tests/uiservicestests/src/com/android/server/UiModeManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/UiModeManagerServiceTest.java
index 88b1d19..4df469e 100644
--- a/services/tests/uiservicestests/src/com/android/server/UiModeManagerServiceTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/UiModeManagerServiceTest.java
@@ -16,48 +16,21 @@
 
 package com.android.server;
 
-import android.Manifest;
-import android.app.AlarmManager;
-import android.app.IUiModeManager;
-import android.content.BroadcastReceiver;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.pm.PackageManager;
-import android.content.pm.UserInfo;
-import android.content.res.Configuration;
-import android.content.res.Resources;
-import android.os.Handler;
-import android.os.PowerManager;
-import android.os.PowerManagerInternal;
-import android.os.PowerSaveState;
-import android.os.RemoteException;
-import android.testing.AndroidTestingRunner;
-import android.testing.TestableLooper;
-
-import com.android.server.twilight.TwilightListener;
-import com.android.server.twilight.TwilightManager;
-import com.android.server.twilight.TwilightState;
-import com.android.server.wm.WindowManagerInternal;
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.time.ZoneId;
-import java.util.function.Consumer;
-
 import static android.app.UiModeManager.MODE_NIGHT_AUTO;
 import static android.app.UiModeManager.MODE_NIGHT_CUSTOM;
 import static android.app.UiModeManager.MODE_NIGHT_NO;
 import static android.app.UiModeManager.MODE_NIGHT_YES;
+import static android.app.UiModeManager.PROJECTION_TYPE_ALL;
+import static android.app.UiModeManager.PROJECTION_TYPE_AUTOMOTIVE;
+import static android.app.UiModeManager.PROJECTION_TYPE_NONE;
+
 import static junit.framework.TestCase.assertFalse;
 import static junit.framework.TestCase.assertTrue;
+
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyLong;
@@ -69,15 +42,60 @@
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
 import static org.mockito.MockitoAnnotations.initMocks;
+import static org.testng.Assert.assertThrows;
+
+import android.Manifest;
+import android.app.AlarmManager;
+import android.app.IOnProjectionStateChangeListener;
+import android.app.IUiModeManager;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.PowerManager;
+import android.os.PowerManagerInternal;
+import android.os.PowerSaveState;
+import android.os.RemoteException;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+
+import com.android.server.twilight.TwilightListener;
+import com.android.server.twilight.TwilightManager;
+import com.android.server.twilight.TwilightState;
+import com.android.server.wm.WindowManagerInternal;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.function.Consumer;
 
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
 public class UiModeManagerServiceTest extends UiServiceTestCase {
+    private static final String PACKAGE_NAME = "Diane Coffee";
     private UiModeManagerService mUiManagerService;
     private IUiModeManager mService;
     @Mock
@@ -100,6 +118,10 @@
     private TwilightState mTwilightState;
     @Mock
     PowerManagerInternal mLocalPowerManager;
+    @Mock
+    private PackageManager mPackageManager;
+    @Mock
+    private IBinder mBinder;
 
     private BroadcastReceiver mScreenOffCallback;
     private BroadcastReceiver mTimeChangedCallback;
@@ -124,6 +146,7 @@
                 .thenReturn(new PowerSaveState.Builder().setBatterySaverEnabled(false).build());
         when(mContext.getResources()).thenReturn(mResources);
         when(mContext.getContentResolver()).thenReturn(mContentResolver);
+        when(mContext.getPackageManager()).thenReturn(mPackageManager);
         when(mPowerManager.isInteractive()).thenReturn(true);
         when(mPowerManager.newWakeLock(anyInt(), anyString())).thenReturn(mWakeLock);
         when(mTwilightManager.getLastTwilightState()).thenReturn(mTwilightState);
@@ -156,8 +179,8 @@
         addLocalService(PowerManagerInternal.class, mLocalPowerManager);
         addLocalService(TwilightManager.class, mTwilightManager);
         
-        mUiManagerService = new UiModeManagerService(mContext, true,
-                mTwilightManager);
+        mUiManagerService = new UiModeManagerService(mContext, /* setupWizardComplete= */ true,
+                mTwilightManager, new TestInjector());
         try {
             mUiManagerService.onBootPhase(SystemService.PHASE_SYSTEM_SERVICES_READY);
         } catch (SecurityException e) {/* ignore for permission denial */}
@@ -449,4 +472,353 @@
         return (mUiManagerService.getConfiguration().uiMode
                 & Configuration.UI_MODE_NIGHT_YES) != 0;
     }
+
+    @Test
+    public void requestProjection_failsForBogusPackageName() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0))
+                .thenReturn(TestInjector.CALLING_UID + 1);
+
+        assertThrows(SecurityException.class, () -> mService.requestProjection(mBinder,
+                PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void requestProjection_failsIfNameNotFound() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0))
+                .thenThrow(new PackageManager.NameNotFoundException());
+
+        assertThrows(SecurityException.class, () -> mService.requestProjection(mBinder,
+                PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void requestProjection_failsIfNoProjectionTypes() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> mService.requestProjection(mBinder, PROJECTION_TYPE_NONE, PACKAGE_NAME));
+        verify(mContext, never()).enforceCallingPermission(
+                eq(Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION), any());
+        verifyZeroInteractions(mBinder);
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void requestProjection_failsIfMultipleProjectionTypes() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+
+        // Don't use PROJECTION_TYPE_ALL because that's actually == -1 and will fail the > 0 check.
+        int multipleProjectionTypes = PROJECTION_TYPE_AUTOMOTIVE | 0x0002 | 0x0004;
+
+        assertThrows(IllegalArgumentException.class,
+                () -> mService.requestProjection(mBinder, multipleProjectionTypes, PACKAGE_NAME));
+        verify(mContext, never()).enforceCallingPermission(
+                eq(Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION), any());
+        verifyZeroInteractions(mBinder);
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void requestProjection_enforcesToggleAutomotiveProjectionPermission() throws Exception {
+        doThrow(new SecurityException()).when(mPackageManager).getPackageUid(PACKAGE_NAME, 0);
+
+        assertThrows(SecurityException.class, () -> mService.requestProjection(mBinder,
+                PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void requestProjection_automotive_failsIfAlreadySetByOtherPackage() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+
+        String otherPackage = "Raconteurs";
+        when(mPackageManager.getPackageUid(otherPackage, 0)).thenReturn(TestInjector.CALLING_UID);
+        assertFalse(mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, otherPackage));
+        assertThat(mService.getProjectingPackages(PROJECTION_TYPE_AUTOMOTIVE),
+                contains(PACKAGE_NAME));
+    }
+
+    @Test
+    public void requestProjection_failsIfCannotLinkToDeath() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        doThrow(new RemoteException()).when(mBinder).linkToDeath(any(), anyInt());
+
+        assertFalse(mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void requestProjection() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        // Should work for all powers of two.
+        for (int p = 1; p < PROJECTION_TYPE_ALL; p = p * 2) {
+            assertTrue(mService.requestProjection(mBinder, p, PACKAGE_NAME));
+            assertTrue((mService.getActiveProjectionTypes() & p) != 0);
+            assertThat(mService.getProjectingPackages(p), contains(PACKAGE_NAME));
+            // Subsequent calls should still succeed.
+            assertTrue(mService.requestProjection(mBinder, p, PACKAGE_NAME));
+        }
+        assertEquals(PROJECTION_TYPE_ALL, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void releaseProjection_failsForBogusPackageName() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0))
+                .thenReturn(TestInjector.CALLING_UID + 1);
+
+        assertThrows(SecurityException.class, () -> mService.releaseProjection(
+                PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void releaseProjection_failsIfNameNotFound() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0))
+                .thenThrow(new PackageManager.NameNotFoundException());
+
+        assertThrows(SecurityException.class, () -> mService.releaseProjection(
+                PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void releaseProjection_enforcesToggleAutomotiveProjectionPermission() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+        doThrow(new SecurityException()).when(mContext).enforceCallingPermission(
+                eq(Manifest.permission.TOGGLE_AUTOMOTIVE_PROJECTION), any());
+
+        // Should not be enforced for other types of projection.
+        int nonAutomotiveProjectionType = PROJECTION_TYPE_AUTOMOTIVE * 2;
+        mService.releaseProjection(nonAutomotiveProjectionType, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+
+        assertThrows(SecurityException.class, () -> mService.requestProjection(mBinder,
+                PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void releaseProjection() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        // Should work for all powers of two.
+        for (int p = 1; p < PROJECTION_TYPE_ALL; p = p * 2) {
+            mService.requestProjection(mBinder, p, PACKAGE_NAME);
+        }
+        assertEquals(PROJECTION_TYPE_ALL, mService.getActiveProjectionTypes());
+
+        assertTrue(mService.releaseProjection(PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME));
+        int everythingButAutomotive = PROJECTION_TYPE_ALL & ~PROJECTION_TYPE_AUTOMOTIVE;
+        assertEquals(everythingButAutomotive, mService.getActiveProjectionTypes());
+
+        for (int p = 1; p < PROJECTION_TYPE_ALL; p = p * 2) {
+            assertEquals(p != PROJECTION_TYPE_AUTOMOTIVE,
+                    (boolean) mService.releaseProjection(p, PACKAGE_NAME));
+        }
+
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void binderDeath_releasesProjection() throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        for (int p = 1; p < PROJECTION_TYPE_ALL; p = p * 2) {
+            mService.requestProjection(mBinder, p, PACKAGE_NAME);
+        }
+        assertEquals(PROJECTION_TYPE_ALL, mService.getActiveProjectionTypes());
+        ArgumentCaptor<IBinder.DeathRecipient> deathRecipientCaptor = ArgumentCaptor.forClass(
+                IBinder.DeathRecipient.class);
+        verify(mBinder, atLeastOnce()).linkToDeath(deathRecipientCaptor.capture(), anyInt());
+
+        // Wipe them out. All of them.
+        deathRecipientCaptor.getAllValues().forEach(IBinder.DeathRecipient::binderDied);
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void getActiveProjectionTypes() throws Exception {
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+        mService.releaseProjection(PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_NONE, mService.getActiveProjectionTypes());
+    }
+
+    @Test
+    public void getProjectingPackages() throws Exception {
+        assertTrue(mService.getProjectingPackages(PROJECTION_TYPE_ALL).isEmpty());
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(1, mService.getProjectingPackages(PROJECTION_TYPE_AUTOMOTIVE).size());
+        assertEquals(1, mService.getProjectingPackages(PROJECTION_TYPE_ALL).size());
+        assertThat(mService.getProjectingPackages(PROJECTION_TYPE_AUTOMOTIVE),
+                contains(PACKAGE_NAME));
+        assertThat(mService.getProjectingPackages(PROJECTION_TYPE_ALL), contains(PACKAGE_NAME));
+        mService.releaseProjection(PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertThat(mService.getProjectingPackages(PROJECTION_TYPE_ALL), empty());
+    }
+
+    @Test
+    public void addOnProjectionStateChangeListener_enforcesReadProjStatePermission() {
+        doThrow(new SecurityException()).when(mContext).enforceCallingOrSelfPermission(
+                eq(android.Manifest.permission.READ_PROJECTION_STATE), any());
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+
+        assertThrows(SecurityException.class, () -> mService.addOnProjectionStateChangeListener(
+                listener, PROJECTION_TYPE_ALL));
+    }
+
+    @Test
+    public void addOnProjectionStateChangeListener_callsListenerIfProjectionActive()
+            throws Exception {
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        assertEquals(PROJECTION_TYPE_AUTOMOTIVE, mService.getActiveProjectionTypes());
+
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+        when(listener.asBinder()).thenReturn(mBinder);  // Any binder will do
+        mService.addOnProjectionStateChangeListener(listener, PROJECTION_TYPE_ALL);
+        verify(listener).onProjectionStateChanged(eq(PROJECTION_TYPE_AUTOMOTIVE),
+                eq(List.of(PACKAGE_NAME)));
+    }
+
+    @Test
+    public void removeOnProjectionStateChangeListener_enforcesReadProjStatePermission() {
+        doThrow(new SecurityException()).when(mContext).enforceCallingOrSelfPermission(
+                eq(android.Manifest.permission.READ_PROJECTION_STATE), any());
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+
+        assertThrows(SecurityException.class, () -> mService.removeOnProjectionStateChangeListener(
+                listener));
+    }
+
+    @Test
+    public void removeOnProjectionStateChangeListener() throws Exception {
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+        when(listener.asBinder()).thenReturn(mBinder); // Any binder will do.
+        mService.addOnProjectionStateChangeListener(listener, PROJECTION_TYPE_ALL);
+
+        mService.removeOnProjectionStateChangeListener(listener);
+        // Now set automotive projection, should not call back.
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        verify(listener, never()).onProjectionStateChanged(anyInt(), any());
+    }
+
+    @Test
+    public void projectionStateChangeListener_calledWhenStateChanges() throws Exception {
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+        when(listener.asBinder()).thenReturn(mBinder); // Any binder will do.
+        mService.addOnProjectionStateChangeListener(listener, PROJECTION_TYPE_ALL);
+        verify(listener, atLeastOnce()).asBinder(); // Called twice during register.
+
+        // No calls initially, no projection state set.
+        verifyNoMoreInteractions(listener);
+
+        // Now set automotive projection, should call back.
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        verify(listener).onProjectionStateChanged(eq(PROJECTION_TYPE_AUTOMOTIVE),
+                eq(List.of(PACKAGE_NAME)));
+
+        // Subsequent calls that are noops do nothing.
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        int unsetProjectionType = 0x0002;
+        mService.releaseProjection(unsetProjectionType, PACKAGE_NAME);
+        verifyNoMoreInteractions(listener);
+
+        // Release should call back though.
+        mService.releaseProjection(PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        verify(listener).onProjectionStateChanged(eq(PROJECTION_TYPE_NONE),
+                eq(List.of()));
+
+        // But only the first time.
+        mService.releaseProjection(PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        verifyNoMoreInteractions(listener);
+    }
+
+    @Test
+    public void projectionStateChangeListener_calledForAnyRelevantStateChange() throws Exception {
+        int fakeProjectionType = 0x0002;
+        int otherFakeProjectionType = 0x0004;
+        String otherPackageName = "Internet Arms";
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        when(mPackageManager.getPackageUid(otherPackageName, 0))
+                .thenReturn(TestInjector.CALLING_UID);
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+        when(listener.asBinder()).thenReturn(mBinder); // Any binder will do.
+        IOnProjectionStateChangeListener listener2 = mock(IOnProjectionStateChangeListener.class);
+        when(listener2.asBinder()).thenReturn(mBinder); // Any binder will do.
+        mService.addOnProjectionStateChangeListener(listener, fakeProjectionType);
+        mService.addOnProjectionStateChangeListener(listener2,
+                fakeProjectionType | otherFakeProjectionType);
+        verify(listener, atLeastOnce()).asBinder(); // Called twice during register.
+        verify(listener2, atLeastOnce()).asBinder(); // Called twice during register.
+
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        verifyNoMoreInteractions(listener, listener2);
+
+        // fakeProjectionType should trigger both.
+        mService.requestProjection(mBinder, fakeProjectionType, PACKAGE_NAME);
+        verify(listener).onProjectionStateChanged(eq(fakeProjectionType),
+                eq(List.of(PACKAGE_NAME)));
+        verify(listener2).onProjectionStateChanged(eq(fakeProjectionType),
+                eq(List.of(PACKAGE_NAME)));
+
+        // otherFakeProjectionType should only trigger the second listener.
+        mService.requestProjection(mBinder, otherFakeProjectionType, otherPackageName);
+        verifyNoMoreInteractions(listener);
+        verify(listener2).onProjectionStateChanged(
+                eq(fakeProjectionType | otherFakeProjectionType),
+                eq(List.of(PACKAGE_NAME, otherPackageName)));
+
+        // Turning off fakeProjectionType should trigger both again.
+        mService.releaseProjection(fakeProjectionType, PACKAGE_NAME);
+        verify(listener).onProjectionStateChanged(eq(PROJECTION_TYPE_NONE), eq(List.of()));
+        verify(listener2).onProjectionStateChanged(eq(otherFakeProjectionType),
+                eq(List.of(otherPackageName)));
+
+        // Turning off otherFakeProjectionType should only trigger the second listener.
+        mService.releaseProjection(otherFakeProjectionType, otherPackageName);
+        verifyNoMoreInteractions(listener);
+        verify(listener2).onProjectionStateChanged(eq(PROJECTION_TYPE_NONE), eq(List.of()));
+    }
+
+    @Test
+    public void projectionStateChangeListener_unregisteredOnDeath() throws Exception {
+        IOnProjectionStateChangeListener listener = mock(IOnProjectionStateChangeListener.class);
+        IBinder listenerBinder = mock(IBinder.class);
+        when(listener.asBinder()).thenReturn(listenerBinder);
+        mService.addOnProjectionStateChangeListener(listener, PROJECTION_TYPE_ALL);
+        ArgumentCaptor<IBinder.DeathRecipient> listenerDeathRecipient = ArgumentCaptor.forClass(
+                IBinder.DeathRecipient.class);
+        verify(listenerBinder).linkToDeath(listenerDeathRecipient.capture(), anyInt());
+
+        // Now kill the binder for the listener. This should remove it from the list of listeners.
+        listenerDeathRecipient.getValue().binderDied();
+        when(mPackageManager.getPackageUid(PACKAGE_NAME, 0)).thenReturn(TestInjector.CALLING_UID);
+        mService.requestProjection(mBinder, PROJECTION_TYPE_AUTOMOTIVE, PACKAGE_NAME);
+        verify(listener, never()).onProjectionStateChanged(anyInt(), any());
+    }
+
+    private static class TestInjector extends UiModeManagerService.Injector {
+        private static final int CALLING_UID = 8675309;
+
+        public int getCallingUid() {
+            return CALLING_UID;
+        }
+    }
 }