Update Telecom to use new Projection State APIs.

Bug: 134997071
Bug: 169702986
Test: Code builds, works on device, unit tests written and pass
Change-Id: Ia5032f87ea218c754687fba39a90f983cfd8fb5d
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index e3eb2f3..45e3151 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -47,6 +47,7 @@
     <uses-permission android:name="android.permission.READ_CALL_LOG"/>
     <uses-permission android:name="android.permission.READ_DEVICE_CONFIG"/>
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+    <uses-permission android:name="android.permission.READ_PROJECTION_STATE"/>
     <uses-permission android:name="android.permission.SEND_SMS"/>
     <uses-permission android:name="android.permission.STOP_APP_SWITCHES"/>
     <uses-permission android:name="android.permission.VIBRATE"/>
diff --git a/src/com/android/server/telecom/CarModeTracker.java b/src/com/android/server/telecom/CarModeTracker.java
index e64ef5d..737ce5a 100644
--- a/src/com/android/server/telecom/CarModeTracker.java
+++ b/src/com/android/server/telecom/CarModeTracker.java
@@ -30,6 +30,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.PriorityQueue;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
@@ -40,30 +41,40 @@
      * Data class holding information about apps which have requested to enter car mode.
      */
     private class CarModeApp {
-        private @IntRange(from = 0) int mPriority;
+        private final boolean mAutomotiveProjection;
+        private final @IntRange(from = 0) int mPriority;
         private @NonNull String mPackageName;
 
+        public CarModeApp(@NonNull String packageName) {
+            this(true, 0, packageName);
+        }
+
         public CarModeApp(int priority, @NonNull String packageName) {
+            this(false, priority, packageName);
+        }
+
+        private CarModeApp(boolean automotiveProjection, int priority, @NonNull String packageName) {
+            mAutomotiveProjection = automotiveProjection;
             mPriority = priority;
             mPackageName = Objects.requireNonNull(packageName);
         }
 
+        public boolean hasSetAutomotiveProjection() {
+            return mAutomotiveProjection;
+        }
+
         /**
          * The priority at which the app requested to enter car mode.
          * Will be the same as the one specified when {@link UiModeManager#enableCarMode(int, int)}
-         * was called, or {@link UiModeManager#DEFAULT_PRIORITY} if no priority was specifeid.
+         * was called, or {@link UiModeManager#DEFAULT_PRIORITY} if no priority was specified.
          * @return The priority.
          */
         public int getPriority() {
             return mPriority;
         }
 
-        public void setPriority(int priority) {
-            mPriority = priority;
-        }
-
         /**
-         * @return The package name of the app which requested to enter car mode.
+         * @return The package name of the app which requested to enter car mode/set projection.
          */
         public String getPackageName() {
             return mPackageName;
@@ -72,26 +83,24 @@
         public void setPackageName(String packageName) {
             mPackageName = packageName;
         }
-    }
 
-    /**
-     * Comparator used to maintain the car mode priority queue ordering.
-     */
-    private class CarModeAppComparator implements Comparator<CarModeApp> {
-        @Override
-        public int compare(CarModeApp o1, CarModeApp o2) {
-            // highest priority takes precedence.
-            return Integer.compare(o2.getPriority(), o1.getPriority());
+        public String toString() {
+            return String.format("[%s, %s]",
+                    mAutomotiveProjection ? "PROJECTION SET" : mPriority,
+                    mPackageName);
         }
     }
 
     /**
-     * Priority list of apps which have entered or exited car mode, ordered with the highest
-     * priority app at the top of the queue.  Where items have the same priority, they are ordered
-     * by insertion time.
+     * Priority list of apps which have entered or exited car mode, ordered first by whether the app
+     * has set automotive projection, and then by highest priority.  Where items have the same
+     * priority, order is arbitrary, but we only allow one item in the queue per priority.
      */
     private PriorityQueue<CarModeApp> mCarModeApps = new PriorityQueue<>(2,
-            new CarModeAppComparator());
+            // Natural ordering of booleans is False, True. Natural ordering of ints is increasing.
+            Comparator.comparing(CarModeApp::hasSetAutomotiveProjection)
+                    .thenComparing(CarModeApp::getPriority)
+                    .reversed());
 
     private final LocalLog mCarModeChangeLog = new LocalLog(20);
 
@@ -144,6 +153,47 @@
         mCarModeApps.removeIf(c -> c.getPriority() == priority);
     }
 
+    public void handleSetAutomotiveProjection(@NonNull String packageName) {
+        Optional<CarModeApp> projectingApp = mCarModeApps.stream()
+                .filter(CarModeApp::hasSetAutomotiveProjection)
+                .findAny();
+        // No app with automotive projection? Easy peasy, just add it.
+        if (!projectingApp.isPresent()) {
+            Log.i(this, "handleSetAutomotiveProjection: %s", packageName);
+            mCarModeChangeLog.log("setAutomotiveProjection: packageName=" + packageName);
+            mCarModeApps.add(new CarModeApp(packageName));
+            return;
+        }
+        // Otherwise an app already has automotive projection set. Is it the same app?
+        if (packageName.equals(projectingApp.get().getPackageName())) {
+            Log.w(this, "handleSetAutomotiveProjection: %s already the automotive projection app",
+                    packageName);
+            return;
+        }
+        // We have a new app for automotive projection. As a shortcut just reuse the same object by
+        // overwriting the package name.
+        Log.i(this, "handleSetAutomotiveProjection: %s replacing %s as automotive projection app",
+                packageName, projectingApp.get().getPackageName());
+        mCarModeChangeLog.log("setAutomotiveProjection: " + packageName + " replaces "
+                + projectingApp.get().getPackageName());
+        projectingApp.get().setPackageName(packageName);
+    }
+
+    public void handleReleaseAutomotiveProjection() {
+        Optional<String> projectingPackage = mCarModeApps.stream()
+                .filter(CarModeApp::hasSetAutomotiveProjection)
+                .map(CarModeApp::getPackageName)
+                .findAny();
+        if (!projectingPackage.isPresent()) {
+            Log.w(this, "handleReleaseAutomotiveProjection: no current automotive projection app");
+            return;
+        }
+        Log.i(this, "handleReleaseAutomotiveProjection: %s", projectingPackage.get());
+        mCarModeChangeLog.log("releaseAutomotiveProjection: packageName="
+                + projectingPackage.get());
+        mCarModeApps.removeIf(CarModeApp::hasSetAutomotiveProjection);
+    }
+
     /**
      * Force-removes a package from the car mode tracking list, no matter at which priority.
      *
@@ -151,19 +201,21 @@
      * from the tracking list so they don't cause a leak.
      * @param packageName Package name of the app to force-remove
      */
-    public void forceExitCarMode(@NonNull String packageName) {
-        Optional<CarModeApp> forcedApp = mCarModeApps.stream()
+    public void forceRemove(@NonNull String packageName) {
+        // We must account for the possibility that the app has set both car mode AND projection.
+        List<CarModeApp> forcedApp = mCarModeApps.stream()
                 .filter(c -> c.getPackageName().equals(packageName))
-                .findAny();
-        if (forcedApp.isPresent()) {
-            String logString = String.format("forceExitCarMode: packageName=%s, was at priority=%s",
-                    packageName, forcedApp.get().getPriority());
+                .collect(Collectors.toList());
+        if (forcedApp.isEmpty()) {
+            Log.i(this, "Package %s is not tracked.", packageName);
+            return;
+        }
+        for (CarModeApp app : forcedApp) {
+            String logString = "forceRemove: " + app;
             Log.i(this, logString);
             mCarModeChangeLog.log(logString);
-            mCarModeApps.removeIf(c -> c.getPackageName().equals(packageName));
-        } else {
-            Log.i(this, "Package %s is not tracked as requesting car mode", packageName);
         }
+        mCarModeApps.removeIf(c -> c.getPackageName().equals(packageName));
     }
 
     /**
@@ -175,7 +227,7 @@
         return mCarModeApps
                 .stream()
                 .sorted(mCarModeApps.comparator())
-                .map(cma -> cma.getPackageName())
+                .map(CarModeApp::getPackageName)
                 .collect(Collectors.toList());
     }
 
@@ -183,7 +235,7 @@
         return mCarModeApps
                 .stream()
                 .sorted(mCarModeApps.comparator())
-                .map(cma -> "[" + cma.getPriority() + ", " + cma.getPackageName() + "]")
+                .map(CarModeApp::toString)
                 .collect(Collectors.joining(", "));
     }
 
@@ -216,7 +268,7 @@
         pw.increaseIndent();
         for (CarModeApp app : mCarModeApps) {
             pw.print("[");
-            pw.print(app.getPriority());
+            pw.print(app.hasSetAutomotiveProjection() ? "PROJECTION SET" : app.getPriority());
             pw.print("] ");
             pw.println(app.getPackageName());
         }
diff --git a/src/com/android/server/telecom/InCallController.java b/src/com/android/server/telecom/InCallController.java
index 4beff9f..9f5923a 100644
--- a/src/com/android/server/telecom/InCallController.java
+++ b/src/com/android/server/telecom/InCallController.java
@@ -913,8 +913,18 @@
         }
 
         @Override
+        public void onAutomotiveProjectionStateSet(String automotiveProjectionPackage) {
+            InCallController.this.handleSetAutomotiveProjection(automotiveProjectionPackage);
+        }
+
+        @Override
+        public void onAutomotiveProjectionStateReleased() {
+            InCallController.this.handleReleaseAutomotiveProjection();
+        }
+
+        @Override
         public void onPackageUninstalled(String packageName) {
-            mCarModeTracker.forceExitCarMode(packageName);
+            mCarModeTracker.forceRemove(packageName);
             updateCarModeForSwitchingConnection();
         }
     };
@@ -1962,6 +1972,25 @@
         updateCarModeForSwitchingConnection();
     }
 
+    public void handleSetAutomotiveProjection(@NonNull String packageName) {
+        Log.i(this, "handleSetAutomotiveProjection: packageName=%s", packageName);
+        if (!isCarModeInCallService(packageName)) {
+            Log.i(this, "handleSetAutomotiveProjection: not a valid InCallService: packageName=%s",
+                    packageName);
+            return;
+        }
+        mCarModeTracker.handleSetAutomotiveProjection(packageName);
+
+        updateCarModeForSwitchingConnection();
+    }
+
+    public void handleReleaseAutomotiveProjection() {
+        Log.i(this, "handleReleaseAutomotiveProjection");
+        mCarModeTracker.handleReleaseAutomotiveProjection();
+
+        updateCarModeForSwitchingConnection();
+    }
+
     public void updateCarModeForSwitchingConnection() {
         if (mInCallServiceConnection != null) {
             Log.i(this, "updateCarModeForSwitchingConnection: car mode apps: %s",
diff --git a/src/com/android/server/telecom/SystemStateHelper.java b/src/com/android/server/telecom/SystemStateHelper.java
index 3be3d5e..8fb6bc5 100644
--- a/src/com/android/server/telecom/SystemStateHelper.java
+++ b/src/com/android/server/telecom/SystemStateHelper.java
@@ -16,6 +16,7 @@
 
 package com.android.server.telecom;
 
+import android.annotation.NonNull;
 import android.app.UiModeManager;
 import android.content.BroadcastReceiver;
 import android.content.Context;
@@ -38,7 +39,7 @@
 /**
  * Provides various system states to the rest of the telecom codebase.
  */
-public class SystemStateHelper {
+public class SystemStateHelper implements UiModeManager.OnProjectionStateChangeListener {
     public interface SystemStateListener {
         /**
          * Listener method to inform interested parties when a package name requests to enter or
@@ -51,6 +52,19 @@
         void onCarModeChanged(int priority, String packageName, boolean isCarMode);
 
         /**
+         * Listener method to inform interested parties when a package has set automotive projection
+         * state.
+         * @param automotiveProjectionPackage the package that set automotive projection.
+         */
+        void onAutomotiveProjectionStateSet(String automotiveProjectionPackage);
+
+        /**
+         * Listener method to inform interested parties when automotive projection state has been
+         * cleared.
+         */
+        void onAutomotiveProjectionStateReleased();
+
+        /**
          * Notifies when a package has been uninstalled.
          * @param packageName the package name of the uninstalled package
          */
@@ -99,8 +113,18 @@
         }
     };
 
+    @Override
+    public void onProjectionStateChanged(int activeProjectionTypes,
+            @NonNull Set<String> projectingPackages) {
+        if (projectingPackages.isEmpty()) {
+            onReleaseAutomotiveProjection();
+        } else {
+            onSetAutomotiveProjection(projectingPackages.iterator().next());
+        }
+    }
+
     private Set<SystemStateListener> mListeners = new CopyOnWriteArraySet<>();
-    private boolean mIsCarMode;
+    private boolean mIsCarModeOrProjectionActive;
 
     public SystemStateHelper(Context context) {
         mContext = context;
@@ -116,7 +140,9 @@
         Log.i(this, "Registering broadcast receiver: %s", intentFilter1);
         Log.i(this, "Registering broadcast receiver: %s", intentFilter2);
 
-        mIsCarMode = getSystemCarMode();
+        mContext.getSystemService(UiModeManager.class).addOnProjectionStateChangeListener(
+                UiModeManager.PROJECTION_TYPE_AUTOMOTIVE, mContext.getMainExecutor(), this);
+        mIsCarModeOrProjectionActive = getSystemCarModeOrProjectionState();
     }
 
     public void addListener(SystemStateListener listener) {
@@ -129,8 +155,8 @@
         return mListeners.remove(listener);
     }
 
-    public boolean isCarMode() {
-        return mIsCarMode;
+    public boolean isCarModeOrProjectionActive() {
+        return mIsCarModeOrProjectionActive;
     }
 
     public boolean isDeviceAtEar() {
@@ -215,7 +241,7 @@
 
     private void onEnterCarMode(int priority, String packageName) {
         Log.i(this, "Entering carmode");
-        mIsCarMode = getSystemCarMode();
+        mIsCarModeOrProjectionActive = getSystemCarModeOrProjectionState();
         for (SystemStateListener listener : mListeners) {
             listener.onCarModeChanged(priority, packageName, true /* isCarMode */);
         }
@@ -223,25 +249,44 @@
 
     private void onExitCarMode(int priority, String packageName) {
         Log.i(this, "Exiting carmode");
-        mIsCarMode = getSystemCarMode();
+        mIsCarModeOrProjectionActive = getSystemCarModeOrProjectionState();
         for (SystemStateListener listener : mListeners) {
             listener.onCarModeChanged(priority, packageName, false /* isCarMode */);
         }
     }
 
-    /**
-     * Checks the system for the current car mode.
-     *
-     * @return True if in car mode, false otherwise.
-     */
-    private boolean getSystemCarMode() {
-        UiModeManager uiModeManager =
-                (UiModeManager) mContext.getSystemService(Context.UI_MODE_SERVICE);
-
-        if (uiModeManager != null) {
-            return uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR;
+    private void onSetAutomotiveProjection(String packageName) {
+        Log.i(this, "Automotive projection set.");
+        mIsCarModeOrProjectionActive = getSystemCarModeOrProjectionState();
+        for (SystemStateListener listener : mListeners) {
+            listener.onAutomotiveProjectionStateSet(packageName);
         }
 
+    }
+
+    private void onReleaseAutomotiveProjection() {
+        Log.i(this, "Automotive projection released.");
+        mIsCarModeOrProjectionActive = getSystemCarModeOrProjectionState();
+        for (SystemStateListener listener : mListeners) {
+            listener.onAutomotiveProjectionStateReleased();
+        }
+    }
+
+    /**
+     * Checks the system for the current car projection state.
+     *
+     * @return True if projection is active, false otherwise.
+     */
+    private boolean getSystemCarModeOrProjectionState() {
+        UiModeManager uiModeManager = mContext.getSystemService(UiModeManager.class);
+
+        if (uiModeManager != null) {
+            return uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR
+                    || (uiModeManager.getActiveProjectionTypes()
+                            & UiModeManager.PROJECTION_TYPE_AUTOMOTIVE) != 0;
+        }
+
+        Log.w(this, "Got null UiModeManager, returning false.");
         return false;
     }
 }
diff --git a/src/com/android/server/telecom/callredirection/CallRedirectionProcessor.java b/src/com/android/server/telecom/callredirection/CallRedirectionProcessor.java
index e93ef22..a1f357b 100644
--- a/src/com/android/server/telecom/callredirection/CallRedirectionProcessor.java
+++ b/src/com/android/server/telecom/callredirection/CallRedirectionProcessor.java
@@ -294,7 +294,7 @@
          * The current rule to decide whether the implemented {@link CallRedirectionService} should
          * allow interactive responses with users is only based on whether it is in car mode.
          */
-        mAllowInteractiveResponse = !callsManager.getSystemStateHelper().isCarMode();
+        mAllowInteractiveResponse = !callsManager.getSystemStateHelper().isCarModeOrProjectionActive();
         mCallRedirectionProcessorHelper = new CallRedirectionProcessorHelper(
                 context, callsManager, phoneAccountRegistrar);
         mProcessedDestinationUri = mCallRedirectionProcessorHelper.formatNumberForRedirection(
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
index 22f5348..caaf4d6 100644
--- a/tests/AndroidManifest.xml
+++ b/tests/AndroidManifest.xml
@@ -36,6 +36,9 @@
     <uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
     <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
 
+    <!-- Used to access Projection State APIs -->
+    <uses-permission android:name="android.permission.READ_PROJECTION_STATE"/>
+
     <application android:label="@string/app_name"
                  android:debuggable="true">
         <uses-library android:name="android.test.runner" />
diff --git a/tests/src/com/android/server/telecom/tests/CallRedirectionProcessorTest.java b/tests/src/com/android/server/telecom/tests/CallRedirectionProcessorTest.java
index ff16880..0a896a8 100644
--- a/tests/src/com/android/server/telecom/tests/CallRedirectionProcessorTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallRedirectionProcessorTest.java
@@ -65,8 +65,6 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
-import java.util.ArrayList;
-
 @RunWith(JUnit4.class)
 public class CallRedirectionProcessorTest extends TelecomTestCase {
     @Mock private Context mContext;
@@ -151,7 +149,7 @@
     }
 
     private void setIsInCarMode(boolean isInCarMode) {
-        when(mSystemStateHelper.isCarMode()).thenReturn(isInCarMode);
+        when(mSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(isInCarMode);
     }
 
     private void enableUserDefinedCallRedirectionService() {
diff --git a/tests/src/com/android/server/telecom/tests/CarModeTrackerTest.java b/tests/src/com/android/server/telecom/tests/CarModeTrackerTest.java
index dbfcdb1..4ad46ae 100644
--- a/tests/src/com/android/server/telecom/tests/CarModeTrackerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CarModeTrackerTest.java
@@ -21,9 +21,6 @@
 import static junit.framework.Assert.assertTrue;
 import static junit.framework.TestCase.assertNull;
 
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.when;
-
 import android.app.UiModeManager;
 
 import com.android.server.telecom.CarModeTracker;
@@ -33,7 +30,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
-import org.mockito.Mock;
 
 @RunWith(JUnit4.class)
 public class CarModeTrackerTest extends TelecomTestCase {
@@ -110,7 +106,7 @@
     @Test
     public void testForceExitCarMode() {
         testEnterCarModeBasic();
-        mCarModeTracker.forceExitCarMode(CAR_MODE_APP1_PACKAGE_NAME);
+        mCarModeTracker.forceRemove(CAR_MODE_APP1_PACKAGE_NAME);
         assertFalse(mCarModeTracker.isInCarMode());
         assertNull(mCarModeTracker.getCurrentCarModePackage());
     }
@@ -226,4 +222,109 @@
                 CAR_MODE_APP3_PACKAGE_NAME);
         assertNull(mCarModeTracker.getCurrentCarModePackage());
     }
+
+    /**
+     * Verifies that setting automotive projection by itself works.
+     */
+    @Test
+    public void testSetAutomotiveProjectionBasic() {
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP1_PACKAGE_NAME);
+        assertEquals(CAR_MODE_APP1_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+        // We should be tracking our car mode app.
+        assertEquals(1, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+    }
+
+    /**
+     * Verifies that if we set automotive projection more than once with the same package, nothing
+     * changes.
+     */
+    @Test
+    public void testSetAutomotiveProjectionMultipleTimes() {
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP1_PACKAGE_NAME);
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP1_PACKAGE_NAME);
+        // Should still only have one app.
+        assertEquals(1, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+        // It should be the same one.
+        assertEquals(CAR_MODE_APP1_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+    }
+
+    /**
+     * Verifies that if we set automotive projection more than once, the new package overrides.
+     */
+    @Test
+    public void testSetAutomotiveProjectionMultipleTimesDifferentPackages() {
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP1_PACKAGE_NAME);
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP2_PACKAGE_NAME);
+        // Should still only have one app.
+        assertEquals(1, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+        // It should be the newer one.
+        assertEquals(CAR_MODE_APP2_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+    }
+
+    /**
+     * Verifies that releasing automotive projection works as expected.
+     */
+    @Test
+    public void testReleaseAutomotiveProjectionBasic() {
+        // Releasing before something's set shouldn't break anything.
+        mCarModeTracker.handleReleaseAutomotiveProjection();
+        assertEquals(0, mCarModeTracker.getCarModeApps().size());
+        assertFalse(mCarModeTracker.isInCarMode());
+
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP1_PACKAGE_NAME);
+        mCarModeTracker.handleReleaseAutomotiveProjection();
+        // Should be gone now.
+        assertEquals(0, mCarModeTracker.getCarModeApps().size());
+        assertFalse(mCarModeTracker.isInCarMode());
+    }
+
+    /**
+     * Verifies that setting automotive projection overrides but doesn't overwrite car mode apps.
+     */
+    @Test
+    public void testAutomotiveProjectionOverridesCarMode() {
+        mCarModeTracker.handleEnterCarMode(50, CAR_MODE_APP1_PACKAGE_NAME);
+        mCarModeTracker.handleSetAutomotiveProjection(CAR_MODE_APP4_PACKAGE_NAME);
+
+        // Should have two apps now, the car mode and the automotive projection one.
+        assertEquals(2, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+
+        // Automotive projection takes priority.
+        assertEquals(CAR_MODE_APP4_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+
+        // If we add another car mode app, automotive projection still has priority.
+        mCarModeTracker.handleEnterCarMode(Integer.MAX_VALUE, CAR_MODE_APP2_PACKAGE_NAME);
+        assertEquals(3, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+        assertEquals(CAR_MODE_APP4_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+
+        // If we release automotive projection, we go back to the prioritized list of plain car
+        // mode apps.
+        mCarModeTracker.handleReleaseAutomotiveProjection();
+        assertEquals(2, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+        assertEquals(CAR_MODE_APP2_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+
+        // Make sure we didn't mess with the first app that was added.
+        mCarModeTracker.handleExitCarMode(Integer.MAX_VALUE, CAR_MODE_APP2_PACKAGE_NAME);
+        assertEquals(1, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+        assertEquals(CAR_MODE_APP1_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+    }
+
+    /**
+     * Verifies that releasing automotive projection doesn't interfere with plain car mode apps.
+     */
+    @Test
+    public void testReleaseAutomotiveProjectionNoopForCarModeApps() {
+        mCarModeTracker.handleEnterCarMode(50, CAR_MODE_APP1_PACKAGE_NAME);
+        mCarModeTracker.handleReleaseAutomotiveProjection();
+        assertEquals(1, mCarModeTracker.getCarModeApps().size());
+        assertTrue(mCarModeTracker.isInCarMode());
+        assertEquals(CAR_MODE_APP1_PACKAGE_NAME, mCarModeTracker.getCurrentCarModePackage());
+    }
 }
diff --git a/tests/src/com/android/server/telecom/tests/ComponentContextFixture.java b/tests/src/com/android/server/telecom/tests/ComponentContextFixture.java
index a4302b6..a44d90b 100644
--- a/tests/src/com/android/server/telecom/tests/ComponentContextFixture.java
+++ b/tests/src/com/android/server/telecom/tests/ComponentContextFixture.java
@@ -30,6 +30,7 @@
 import android.app.AppOpsManager;
 import android.app.NotificationManager;
 import android.app.StatusBarManager;
+import android.app.UiModeManager;
 import android.app.role.RoleManager;
 import android.content.BroadcastReceiver;
 import android.content.ComponentName;
@@ -206,6 +207,8 @@
                     return mRoleManager;
                 case Context.TELEPHONY_REGISTRY_SERVICE:
                     return mTelephonyRegistryManager;
+                case Context.UI_MODE_SERVICE:
+                    return mUiModeManager;
                 default:
                     return null;
             }
@@ -227,6 +230,8 @@
                 return Context.TELEPHONY_SUBSCRIPTION_SERVICE;
             } else if (svcClass == TelephonyRegistryManager.class) {
                 return Context.TELEPHONY_REGISTRY_SERVICE;
+            } else if (svcClass == UiModeManager.class) {
+                return Context.UI_MODE_SERVICE;
             }
             throw new UnsupportedOperationException();
         }
@@ -486,6 +491,7 @@
     private final RoleManager mRoleManager = mock(RoleManager.class);
     private final TelephonyRegistryManager mTelephonyRegistryManager =
             mock(TelephonyRegistryManager.class);
+    private final UiModeManager mUiModeManager = mock(UiModeManager.class);
     private final PermissionInfo mPermissionInfo = mock(PermissionInfo.class);
 
     private TelecomManager mTelecomManager = mock(TelecomManager.class);
diff --git a/tests/src/com/android/server/telecom/tests/InCallControllerTests.java b/tests/src/com/android/server/telecom/tests/InCallControllerTests.java
index 59f75fc..3307a93 100644
--- a/tests/src/com/android/server/telecom/tests/InCallControllerTests.java
+++ b/tests/src/com/android/server/telecom/tests/InCallControllerTests.java
@@ -243,14 +243,32 @@
         when(mMockCallsManager.getCurrentUserHandle()).thenReturn(mUserHandle);
         when(mMockContext.getPackageManager()).thenReturn(mMockPackageManager);
 
-        when(mMockSystemStateHelper.isCarMode()).thenReturn(true);
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
 
         mSystemStateListener.onCarModeChanged(666, CAR_PKG, true);
         verify(mCarModeTracker).handleEnterCarMode(666, CAR_PKG);
         assertTrue(mCarModeTracker.isInCarMode());
 
         mSystemStateListener.onPackageUninstalled(CAR_PKG);
-        verify(mCarModeTracker).forceExitCarMode(CAR_PKG);
+        verify(mCarModeTracker).forceRemove(CAR_PKG);
+        assertFalse(mCarModeTracker.isInCarMode());
+    }
+
+    @SmallTest
+    @Test
+    public void testAutomotiveProjectionAppRemoval() {
+        setupMockPackageManager(true /* default */, true /* system */, true /* external calls */);
+        when(mMockCallsManager.getCurrentUserHandle()).thenReturn(mUserHandle);
+        when(mMockContext.getPackageManager()).thenReturn(mMockPackageManager);
+
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
+
+        mSystemStateListener.onAutomotiveProjectionStateSet(CAR_PKG);
+        verify(mCarModeTracker).handleSetAutomotiveProjection(CAR_PKG);
+        assertTrue(mCarModeTracker.isInCarMode());
+
+        mSystemStateListener.onPackageUninstalled(CAR_PKG);
+        verify(mCarModeTracker).forceRemove(CAR_PKG);
         assertFalse(mCarModeTracker.isInCarMode());
     }
 
@@ -786,7 +804,7 @@
         setupMockPackageManager(true /* default */, true /* system */, true /* external calls */);
 
         // Enable car mode
-        when(mMockSystemStateHelper.isCarMode()).thenReturn(true);
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
         mInCallController.handleCarModeChange(UiModeManager.DEFAULT_PRIORITY, CAR_PKG, true);
 
         // Now bind; we should only bind to one app.
@@ -816,7 +834,7 @@
                 matches(Manifest.permission.CONTROL_INCALL_EXPERIENCE),
                 matches(CAR_PKG))).thenReturn(PackageManager.PERMISSION_DENIED);
         // Enable car mode
-        when(mMockSystemStateHelper.isCarMode()).thenReturn(true);
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
 
         // Register the fact that the invalid app entered car mode.
         mInCallController.handleCarModeChange(UiModeManager.DEFAULT_PRIORITY, CAR_PKG, true);
@@ -928,7 +946,7 @@
         mInCallController.bindToServices(mMockCall);
 
         // Enable car mode and enter car mode at default priority.
-        when(mMockSystemStateHelper.isCarMode()).thenReturn(true);
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
         mInCallController.handleCarModeChange(UiModeManager.DEFAULT_PRIORITY, CAR_PKG, true);
 
         // And change to the second car mode app.
@@ -1075,7 +1093,7 @@
 
         // Now switch to car mode.
         // Enable car mode and enter car mode at default priority.
-        when(mMockSystemStateHelper.isCarMode()).thenReturn(true);
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
         mInCallController.handleCarModeChange(UiModeManager.DEFAULT_PRIORITY, CAR_PKG, true);
 
         ArgumentCaptor<Intent> bindIntentCaptor = ArgumentCaptor.forClass(Intent.class);
@@ -1115,7 +1133,7 @@
 
         // Now switch to car mode.
         // Enable car mode and enter car mode at default priority.
-        when(mMockSystemStateHelper.isCarMode()).thenReturn(true);
+        when(mMockSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(true);
         mInCallController.handleCarModeChange(UiModeManager.DEFAULT_PRIORITY, CAR_PKG, true);
 
         // We currently will bind to the car-mode InCallService even if there are no calls available
diff --git a/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java b/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java
index a5b78b7..e6c6bac 100644
--- a/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java
+++ b/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java
@@ -110,7 +110,7 @@
         when(mPhoneAccountRegistrar.getPhoneAccountUnchecked(
             any(PhoneAccountHandle.class))).thenReturn(mPhoneAccount);
         when(mPhoneAccount.isSelfManaged()).thenReturn(true);
-        when(mSystemStateHelper.isCarMode()).thenReturn(false);
+        when(mSystemStateHelper.isCarModeOrProjectionActive()).thenReturn(false);
     }
 
     @Override
diff --git a/tests/src/com/android/server/telecom/tests/SystemStateHelperTest.java b/tests/src/com/android/server/telecom/tests/SystemStateHelperTest.java
index 893ae3d..ad52625 100644
--- a/tests/src/com/android/server/telecom/tests/SystemStateHelperTest.java
+++ b/tests/src/com/android/server/telecom/tests/SystemStateHelperTest.java
@@ -60,6 +60,7 @@
 import org.mockito.internal.util.reflection.FieldSetter;
 
 import java.util.List;
+import java.util.Set;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
@@ -91,6 +92,8 @@
         when(mSensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)).thenReturn(mGravitySensor);
         when(mSensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)).thenReturn(mProxSensor);
 
+        doReturn(mUiModeManager).when(mContext).getSystemService(UiModeManager.class);
+
         mComponentContextFixture.putFloatResource(
                 R.dimen.device_on_ear_xy_gravity_threshold, 5.5f);
         mComponentContextFixture.putFloatResource(
@@ -117,17 +120,53 @@
     @SmallTest
     @Test
     public void testQuerySystemForCarMode_True() {
-        when(mContext.getSystemService(Context.UI_MODE_SERVICE)).thenReturn(mUiModeManager);
         when(mUiModeManager.getCurrentModeType()).thenReturn(Configuration.UI_MODE_TYPE_CAR);
-        assertTrue(new SystemStateHelper(mContext).isCarMode());
+        assertTrue(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
     }
 
     @SmallTest
     @Test
     public void testQuerySystemForCarMode_False() {
-        when(mContext.getSystemService(Context.UI_MODE_SERVICE)).thenReturn(mUiModeManager);
         when(mUiModeManager.getCurrentModeType()).thenReturn(Configuration.UI_MODE_TYPE_NORMAL);
-        assertFalse(new SystemStateHelper(mContext).isCarMode());
+        assertFalse(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
+    }
+
+    @SmallTest
+    @Test
+    public void testQuerySystemForAutomotiveProjection_True() {
+        when(mUiModeManager.getActiveProjectionTypes())
+                .thenReturn(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE);
+        assertTrue(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
+
+        when(mUiModeManager.getActiveProjectionTypes())
+                .thenReturn(UiModeManager.PROJECTION_TYPE_ALL);
+        assertTrue(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
+    }
+
+    @SmallTest
+    @Test
+    public void testQuerySystemForAutomotiveProjection_False() {
+        when(mUiModeManager.getActiveProjectionTypes())
+                .thenReturn(UiModeManager.PROJECTION_TYPE_NONE);
+        assertFalse(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
+    }
+
+    @SmallTest
+    @Test
+    public void testQuerySystemForAutomotiveProjectionAndCarMode_True() {
+        when(mUiModeManager.getCurrentModeType()).thenReturn(Configuration.UI_MODE_TYPE_CAR);
+        when(mUiModeManager.getActiveProjectionTypes())
+                .thenReturn(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE);
+        assertTrue(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
+    }
+
+    @SmallTest
+    @Test
+    public void testQuerySystemForAutomotiveProjectionOrCarMode_nullService() {
+        when(mContext.getSystemService(UiModeManager.class))
+                .thenReturn(mUiModeManager)  // Without this, class construction will throw NPE.
+                .thenReturn(null);
+        assertFalse(new SystemStateHelper(mContext).isCarModeOrProjectionActive());
     }
 
     @SmallTest
@@ -204,6 +243,40 @@
 
     @SmallTest
     @Test
+    public void testOnSetReleaseAutomotiveProjection() {
+        SystemStateHelper systemStateHelper = new SystemStateHelper(mContext);
+        // We don't care what listener is registered, that's an implementation detail, but we need
+        // to call methods on whatever it is.
+        ArgumentCaptor<UiModeManager.OnProjectionStateChangeListener> listenerCaptor =
+                ArgumentCaptor.forClass(UiModeManager.OnProjectionStateChangeListener.class);
+        verify(mUiModeManager).addOnProjectionStateChangeListener(
+                eq(UiModeManager.PROJECTION_TYPE_AUTOMOTIVE), any(), listenerCaptor.capture());
+        systemStateHelper.addListener(mSystemStateListener);
+
+        String packageName1 = "Sufjan Stevens";
+        String packageName2 = "The Ascension";
+
+        // Should pay attention to automotive projection, though.
+        listenerCaptor.getValue().onProjectionStateChanged(
+                UiModeManager.PROJECTION_TYPE_AUTOMOTIVE, Set.of(packageName2));
+        verify(mSystemStateListener).onAutomotiveProjectionStateSet(packageName2);
+
+        // Without any automotive projection, it should see it as released.
+        listenerCaptor.getValue().onProjectionStateChanged(
+                UiModeManager.PROJECTION_TYPE_NONE, Set.of());
+        verify(mSystemStateListener).onAutomotiveProjectionStateReleased();
+
+        // Try the whole thing again, with different values.
+        listenerCaptor.getValue().onProjectionStateChanged(
+                UiModeManager.PROJECTION_TYPE_AUTOMOTIVE, Set.of(packageName1));
+        verify(mSystemStateListener).onAutomotiveProjectionStateSet(packageName1);
+        listenerCaptor.getValue().onProjectionStateChanged(
+                UiModeManager.PROJECTION_TYPE_AUTOMOTIVE, Set.of());
+        verify(mSystemStateListener, times(2)).onAutomotiveProjectionStateReleased();
+    }
+
+    @SmallTest
+    @Test
     public void testDeviceOnEarCorrectlyDetected() {
         doAnswer(invocation -> {
             SensorEventListener listener = invocation.getArgument(0);