CarProjectionManager: Introduce ProjectionKeyEventHandler

This API will be used in the place of CarProjectionListener to allow
projection clients to register to handle specific input events from the
system. Unlike the previous CarProjectionListener API, this API can be
extended to additional input events as needed in the future.

This new API also supports the following new events:
- Immediate notification of key-down for the VOICE_ASSIST key
- Key-up after a long-press of the VOICE_ASSIST key
- Short-press, long-press, key-down, and long-press-key-up events
  for the CALL key

Test: Manual test, atest CarServiceUnitTest CarLibTests
Bug: 129706517
Change-Id: I93a2c1551738e44e9240b2a42f04a804e0d13543
diff --git a/car-lib/api/system-current.txt b/car-lib/api/system-current.txt
index 6e94915..618becb 100644
--- a/car-lib/api/system-current.txt
+++ b/car-lib/api/system-current.txt
@@ -54,12 +54,15 @@
   }
 
   public final class CarProjectionManager {
+    method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void addKeyEventHandler(@NonNull java.util.Set<java.lang.Integer>, @NonNull android.car.CarProjectionManager.ProjectionKeyEventHandler);
+    method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void addKeyEventHandler(@NonNull java.util.Set<java.lang.Integer>, @Nullable java.util.concurrent.Executor, @NonNull android.car.CarProjectionManager.ProjectionKeyEventHandler);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) @NonNull public java.util.List<java.lang.Integer> getAvailableWifiChannels(int);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) @NonNull public android.os.Bundle getProjectionOptions();
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void registerProjectionListener(@NonNull android.car.CarProjectionManager.CarProjectionListener, int);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void registerProjectionRunner(@NonNull android.content.Intent);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION_STATUS) public void registerProjectionStatusListener(@NonNull android.car.CarProjectionManager.ProjectionStatusListener);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public boolean releaseBluetoothProfileInhibit(@NonNull android.bluetooth.BluetoothDevice, int);
+    method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void removeKeyEventHandler(@NonNull android.car.CarProjectionManager.ProjectionKeyEventHandler);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public boolean requestBluetoothProfileInhibit(@NonNull android.bluetooth.BluetoothDevice, int);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void startProjectionAccessPoint(@NonNull android.car.CarProjectionManager.ProjectionAccessPointCallback);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void stopProjectionAccessPoint();
@@ -67,8 +70,16 @@
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void unregisterProjectionRunner(@NonNull android.content.Intent);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION_STATUS) public void unregisterProjectionStatusListener(@NonNull android.car.CarProjectionManager.ProjectionStatusListener);
     method @RequiresPermission(android.car.Car.PERMISSION_CAR_PROJECTION) public void updateProjectionStatus(@NonNull android.car.projection.ProjectionStatus);
-    field public static final int PROJECTION_LONG_PRESS_VOICE_SEARCH = 2; // 0x2
-    field public static final int PROJECTION_VOICE_SEARCH = 1; // 0x1
+    field public static final int KEY_EVENT_CALL_KEY_DOWN = 4; // 0x4
+    field public static final int KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN = 6; // 0x6
+    field public static final int KEY_EVENT_CALL_LONG_PRESS_KEY_UP = 7; // 0x7
+    field public static final int KEY_EVENT_CALL_SHORT_PRESS_KEY_UP = 5; // 0x5
+    field public static final int KEY_EVENT_VOICE_SEARCH_KEY_DOWN = 0; // 0x0
+    field public static final int KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN = 2; // 0x2
+    field public static final int KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP = 3; // 0x3
+    field public static final int KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP = 1; // 0x1
+    field @Deprecated public static final int PROJECTION_LONG_PRESS_VOICE_SEARCH = 2; // 0x2
+    field @Deprecated public static final int PROJECTION_VOICE_SEARCH = 1; // 0x1
   }
 
   public static interface CarProjectionManager.CarProjectionListener {
@@ -86,6 +97,10 @@
     field public static final int ERROR_TETHERING_DISALLOWED = 4; // 0x4
   }
 
+  public static interface CarProjectionManager.ProjectionKeyEventHandler {
+    method public void onKeyEvent(int);
+  }
+
   public static interface CarProjectionManager.ProjectionStatusListener {
     method public void onProjectionStatusChanged(int, @Nullable String, @NonNull java.util.List<android.car.projection.ProjectionStatus>);
   }
diff --git a/car-lib/src/android/car/CarProjectionManager.java b/car-lib/src/android/car/CarProjectionManager.java
index 8b5bc82..4c177f7 100644
--- a/car-lib/src/android/car/CarProjectionManager.java
+++ b/car-lib/src/android/car/CarProjectionManager.java
@@ -16,6 +16,8 @@
 
 package android.car;
 
+import android.annotation.CallbackExecutor;
+import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
@@ -34,16 +36,28 @@
 import android.os.Message;
 import android.os.Messenger;
 import android.os.RemoteException;
+import android.util.ArraySet;
 import android.util.Log;
+import android.util.Pair;
+import android.view.KeyEvent;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.Preconditions;
 
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
+import java.util.BitSet;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.Executor;
 
 /**
  * CarProjectionManager allows applications implementing projection to register/unregister itself
@@ -74,14 +88,104 @@
     }
 
     /**
-     * Flag for voice search request.
+     * Interface for projection apps to receive and handle key events from the system.
      */
+    public interface ProjectionKeyEventHandler {
+        /**
+         * Called when a projection key event occurs.
+         *
+         * @param event The projection key event that occurred.
+         */
+        void onKeyEvent(@KeyEventNum int event);
+    }
+    /**
+     * Flag for {@link #registerProjectionListener(CarProjectionListener, int)}: subscribe to
+     * voice-search short-press requests.
+     *
+     * @deprecated Use {@link #addKeyEventHandler(Set, ProjectionKeyEventHandler)} with the
+     * {@link #KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP} event instead.
+     */
+    @Deprecated
     public static final int PROJECTION_VOICE_SEARCH = 0x1;
     /**
-     * Flag for long press voice search request.
+     * Flag for {@link #registerProjectionListener(CarProjectionListener, int)}: subscribe to
+     * voice-search long-press requests.
+     *
+     * @deprecated Use {@link #addKeyEventHandler(Set, ProjectionKeyEventHandler)} with the
+     * {@link #KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN} event instead.
      */
+    @Deprecated
     public static final int PROJECTION_LONG_PRESS_VOICE_SEARCH = 0x2;
 
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
+     * key is pressed down.
+     *
+     * If the key is released before the long-press timeout,
+     * {@link #KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP} will be fired. If the key is held past the
+     * long-press timeout, {@link #KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN} will be fired,
+     * followed by {@link #KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP}.
+     */
+    public static final int KEY_EVENT_VOICE_SEARCH_KEY_DOWN = 0;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
+     * key is released after a short-press.
+     */
+    public static final int KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP = 1;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
+     * key is held down past the long-press timeout.
+     */
+    public static final int KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN = 2;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_VOICE_ASSIST}
+     * key is released after a long-press.
+     */
+    public static final int KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP = 3;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
+     * pressed down.
+     *
+     * If the key is released before the long-press timeout,
+     * {@link #KEY_EVENT_CALL_SHORT_PRESS_KEY_UP} will be fired. If the key is held past the
+     * long-press timeout, {@link #KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN} will be fired, followed by
+     * {@link #KEY_EVENT_CALL_LONG_PRESS_KEY_UP}.
+     */
+    public static final int KEY_EVENT_CALL_KEY_DOWN = 4;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
+     * released after a short-press.
+     */
+    public static final int KEY_EVENT_CALL_SHORT_PRESS_KEY_UP = 5;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
+     * held down past the long-press timeout.
+     */
+    public static final int KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN = 6;
+    /**
+     * Event for {@link #addKeyEventHandler}: fired when the {@link KeyEvent#KEYCODE_CALL} key is
+     * released after a long-press.
+     */
+    public static final int KEY_EVENT_CALL_LONG_PRESS_KEY_UP = 7;
+
+    /** @hide */
+    public static final int NUM_KEY_EVENTS = 8;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = "KEY_EVENT_", value = {
+            KEY_EVENT_VOICE_SEARCH_KEY_DOWN,
+            KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP,
+            KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN,
+            KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP,
+            KEY_EVENT_CALL_KEY_DOWN,
+            KEY_EVENT_CALL_SHORT_PRESS_KEY_UP,
+            KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN,
+            KEY_EVENT_CALL_LONG_PRESS_KEY_UP,
+    })
+    @Target({ElementType.TYPE_USE})
+    public @interface KeyEventNum {}
+
     /** @hide */
     public static final int PROJECTION_AP_STARTED = 0;
     /** @hide */
@@ -91,10 +195,23 @@
 
     private final ICarProjection mService;
     private final Handler mHandler;
-    private final ICarProjectionCallbackImpl mBinderListener;
+    private final Executor mHandlerExecutor;
 
+    @GuardedBy("mLock")
     private CarProjectionListener mListener;
+    @GuardedBy("mLock")
     private int mVoiceSearchFilter;
+    private final ProjectionKeyEventHandler mLegacyListenerTranslator =
+            this::translateKeyEventToLegacyListener;
+
+    private final ICarProjectionKeyEventHandlerImpl mBinderHandler =
+            new ICarProjectionKeyEventHandlerImpl(this);
+
+    @GuardedBy("mLock")
+    private final Map<ProjectionKeyEventHandler, KeyEventHandlerRecord> mKeyEventHandlers =
+            new HashMap<>();
+    @GuardedBy("mLock")
+    private BitSet mHandledEvents = new BitSet();
 
     private ProjectionAccessPointCallbackProxy mProjectionAccessPointCallbackProxy;
 
@@ -127,7 +244,7 @@
     public CarProjectionManager(IBinder service, Handler handler) {
         mService = ICarProjection.Stub.asInterface(service);
         mHandler = handler;
-        mBinderListener = new ICarProjectionCallbackImpl(this);
+        mHandlerExecutor = handler::post;
     }
 
     /**
@@ -150,11 +267,9 @@
         Preconditions.checkNotNull(listener, "listener cannot be null");
         synchronized (mLock) {
             if (mListener == null || mVoiceSearchFilter != voiceSearchFilter) {
-                try {
-                    mService.registerProjectionListener(mBinderListener, voiceSearchFilter);
-                } catch (RemoteException e) {
-                    throw e.rethrowFromSystemServer();
-                }
+                addKeyEventHandler(
+                        translateVoiceSearchFilter(voiceSearchFilter),
+                        mLegacyListenerTranslator);
             }
             mListener = listener;
             mVoiceSearchFilter = voiceSearchFilter;
@@ -175,16 +290,170 @@
     @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
     public void unregisterProjectionListener() {
         synchronized (mLock) {
-            try {
-                mService.unregisterProjectionListener(mBinderListener);
-            } catch (RemoteException e) {
-                throw e.rethrowFromSystemServer();
-            }
+            removeKeyEventHandler(mLegacyListenerTranslator);
             mListener = null;
             mVoiceSearchFilter = 0;
         }
     }
 
+    @SuppressWarnings("deprecation")
+    private static Set<Integer> translateVoiceSearchFilter(int voiceSearchFilter) {
+        Set<Integer> rv = new ArraySet<>(Integer.bitCount(voiceSearchFilter));
+        int i = 0;
+        if ((voiceSearchFilter & PROJECTION_VOICE_SEARCH) != 0) {
+            rv.add(KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        }
+        if ((voiceSearchFilter & PROJECTION_LONG_PRESS_VOICE_SEARCH) != 0) {
+            rv.add(KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
+        }
+        return rv;
+    }
+
+    private void translateKeyEventToLegacyListener(@KeyEventNum int keyEvent) {
+        CarProjectionListener legacyListener;
+        boolean fromLongPress;
+
+        synchronized (mLock) {
+            if (mListener == null) {
+                return;
+            }
+            legacyListener = mListener;
+
+            if (keyEvent == KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP) {
+                fromLongPress = false;
+            } else if (keyEvent == KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN) {
+                fromLongPress = true;
+            } else {
+                Log.e(TAG, "Unexpected key event " + keyEvent);
+                return;
+            }
+        }
+
+        Log.d(TAG, "Voice assistant request, long-press = " + fromLongPress);
+
+        legacyListener.onVoiceAssistantRequest(fromLongPress);
+    }
+
+    /**
+     * Adds a {@link ProjectionKeyEventHandler} to be called for the given set of key events.
+     *
+     * If the given event handler is already registered, the event set and {@link Executor} for that
+     * event handler will be replaced with those provided.
+     *
+     * For any event with a defined event handler, the system will suppress its default behavior for
+     * that event, and call the event handler instead. (For instance, if an event handler is defined
+     * for {@link #KEY_EVENT_CALL_SHORT_PRESS_KEY_UP}, the system will not open the dialer when the
+     * {@link KeyEvent#KEYCODE_CALL CALL} key is short-pressed.)
+     *
+     * Callbacks on the event handler will be run on the {@link Handler} designated to run callbacks
+     * from {@link Car}.
+     *
+     * @param events        The set of key events to which to subscribe.
+     * @param eventHandler  The {@link ProjectionKeyEventHandler} to call when those events occur.
+     */
+    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
+    public void addKeyEventHandler(
+            @NonNull Set<@KeyEventNum Integer> events,
+            @NonNull ProjectionKeyEventHandler eventHandler) {
+        addKeyEventHandler(events, null, eventHandler);
+    }
+
+    /**
+     * Adds a {@link ProjectionKeyEventHandler} to be called for the given set of key events.
+     *
+     * If the given event handler is already registered, the event set and {@link Executor} for that
+     * event handler will be replaced with those provided.
+     *
+     * For any event with a defined event handler, the system will suppress its default behavior for
+     * that event, and call the event handler instead. (For instance, if an event handler is defined
+     * for {@link #KEY_EVENT_CALL_SHORT_PRESS_KEY_UP}, the system will not open the dialer when the
+     * {@link KeyEvent#KEYCODE_CALL CALL} key is short-pressed.)
+     *
+     * Callbacks on the event handler will be run on the given {@link Executor}, or, if it is null,
+     * the {@link Handler} designated to run callbacks for {@link Car}.
+     *
+     * @param events        The set of key events to which to subscribe.
+     * @param executor      An {@link Executor} on which to run callbacks.
+     * @param eventHandler  The {@link ProjectionKeyEventHandler} to call when those events occur.
+     */
+    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
+    public void addKeyEventHandler(
+            @NonNull Set<@KeyEventNum Integer> events,
+            @CallbackExecutor @Nullable Executor executor,
+            @NonNull ProjectionKeyEventHandler eventHandler) {
+        BitSet eventMask = new BitSet();
+        for (int event : events) {
+            Preconditions.checkArgument(event >= 0 && event < NUM_KEY_EVENTS, "Invalid key event");
+            eventMask.set(event);
+        }
+
+        if (eventMask.isEmpty()) {
+            removeKeyEventHandler(eventHandler);
+            return;
+        }
+
+        if (executor == null) {
+            executor = mHandlerExecutor;
+        }
+
+        synchronized (mLock) {
+            KeyEventHandlerRecord record = mKeyEventHandlers.get(eventHandler);
+            if (record == null) {
+                record = new KeyEventHandlerRecord(executor, eventMask);
+                mKeyEventHandlers.put(eventHandler, record);
+            } else {
+                record.mExecutor = executor;
+                record.mSubscribedEvents = eventMask;
+            }
+
+            updateHandledEventsLocked();
+        }
+    }
+
+    /**
+     * Removes a previously registered {@link ProjectionKeyEventHandler}.
+     *
+     * @param eventHandler The listener to remove.
+     */
+    @RequiresPermission(Car.PERMISSION_CAR_PROJECTION)
+    public void removeKeyEventHandler(@NonNull ProjectionKeyEventHandler eventHandler) {
+        synchronized (mLock) {
+            KeyEventHandlerRecord record = mKeyEventHandlers.remove(eventHandler);
+            if (record != null) {
+                updateHandledEventsLocked();
+            }
+        }
+    }
+
+    @GuardedBy("mLock")
+    private void updateHandledEventsLocked() {
+        BitSet events = new BitSet();
+
+        for (KeyEventHandlerRecord record : mKeyEventHandlers.values()) {
+            events.or(record.mSubscribedEvents);
+        }
+
+        if (events.equals(mHandledEvents)) {
+            // No changes.
+            return;
+        }
+
+        try {
+            if (!events.isEmpty()) {
+                Log.d(TAG, "Registering handler with system for " + events);
+                byte[] eventMask = events.toByteArray();
+                mService.registerKeyEventHandler(mBinderHandler, eventMask);
+            } else {
+                Log.d(TAG, "Unregistering handler with system");
+                mService.unregisterKeyEventHandler(mBinderHandler);
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+
+        mHandledEvents = events;
+    }
+
     /**
      * Registers projection runner on projection start with projection service
      * to create reverse binding.
@@ -506,32 +775,47 @@
         }
     }
 
-    private void handleVoiceAssistantRequest(boolean fromLongPress) {
-        CarProjectionListener listener;
-        synchronized (mLock) {
-            if (mListener == null) {
-                return;
-            }
-            listener = mListener;
-        }
-        listener.onVoiceAssistantRequest(fromLongPress);
-    }
-
-    private static class ICarProjectionCallbackImpl extends ICarProjectionCallback.Stub {
+    private static class ICarProjectionKeyEventHandlerImpl
+            extends ICarProjectionKeyEventHandler.Stub {
 
         private final WeakReference<CarProjectionManager> mManager;
 
-        private ICarProjectionCallbackImpl(CarProjectionManager manager) {
+        private ICarProjectionKeyEventHandlerImpl(CarProjectionManager manager) {
             mManager = new WeakReference<>(manager);
         }
 
         @Override
-        public void onVoiceAssistantRequest(final boolean fromLongPress) {
+        public void onKeyEvent(@KeyEventNum int event) {
+            Log.d(TAG, "Received projection key event " + event);
             final CarProjectionManager manager = mManager.get();
             if (manager == null) {
                 return;
             }
-            manager.mHandler.post(() -> manager.handleVoiceAssistantRequest(fromLongPress));
+
+            List<Pair<ProjectionKeyEventHandler, Executor>> toDispatch = new ArrayList<>();
+            synchronized (manager.mLock) {
+                for (Map.Entry<ProjectionKeyEventHandler, KeyEventHandlerRecord> entry :
+                        manager.mKeyEventHandlers.entrySet()) {
+                    if (entry.getValue().mSubscribedEvents.get(event)) {
+                        toDispatch.add(Pair.create(entry.getKey(), entry.getValue().mExecutor));
+                    }
+                }
+            }
+
+            for (Pair<ProjectionKeyEventHandler, Executor> entry : toDispatch) {
+                ProjectionKeyEventHandler listener = entry.first;
+                entry.second.execute(() -> listener.onKeyEvent(event));
+            }
+        }
+    }
+
+    private static class KeyEventHandlerRecord {
+        @NonNull Executor mExecutor;
+        @NonNull BitSet mSubscribedEvents;
+
+        KeyEventHandlerRecord(@NonNull Executor executor, @NonNull BitSet subscribedEvents) {
+            mExecutor = executor;
+            mSubscribedEvents = subscribedEvents;
         }
     }
 
diff --git a/car-lib/src/android/car/ICarProjection.aidl b/car-lib/src/android/car/ICarProjection.aidl
index 6e85c45..5be9791 100644
--- a/car-lib/src/android/car/ICarProjection.aidl
+++ b/car-lib/src/android/car/ICarProjection.aidl
@@ -18,7 +18,7 @@
 
 import android.bluetooth.BluetoothDevice;
 import android.car.projection.ProjectionStatus;
-import android.car.ICarProjectionCallback;
+import android.car.ICarProjectionKeyEventHandler;
 import android.car.ICarProjectionStatusListener;
 import android.content.Intent;
 import android.os.Bundle;
@@ -44,15 +44,16 @@
     void unregisterProjectionRunner(in Intent serviceIntent) = 1;
 
     /**
-     * Registers projection callback.
-     * Re-registering same callback with different filter will cause only filter to update.
+     * Registers projection key event handler.
+     * Re-registering same event handler with different events will cause only events to update.
      */
-    void registerProjectionListener(ICarProjectionCallback callback, int filter) = 2;
+    void registerKeyEventHandler(
+            in ICarProjectionKeyEventHandler eventHandler, in byte[] eventMask) = 2;
 
     /**
-     * Unregisters projection callback.
+     * Unregisters projection key event handler.
      */
-    void unregisterProjectionListener(ICarProjectionCallback callback) = 3;
+    void unregisterKeyEventHandler(in ICarProjectionKeyEventHandler eventHandler) = 3;
 
     /**
      * Starts Wi-Fi access point if it hasn't been started yet for wireless projection and returns
diff --git a/car-lib/src/android/car/ICarProjectionCallback.aidl b/car-lib/src/android/car/ICarProjectionKeyEventHandler.aidl
similarity index 83%
rename from car-lib/src/android/car/ICarProjectionCallback.aidl
rename to car-lib/src/android/car/ICarProjectionKeyEventHandler.aidl
index 1c0dded..ad93f13 100644
--- a/car-lib/src/android/car/ICarProjectionCallback.aidl
+++ b/car-lib/src/android/car/ICarProjectionKeyEventHandler.aidl
@@ -16,9 +16,9 @@
 
 package android.car;
 
-/**
- * @hide
- */
-oneway interface ICarProjectionCallback {
-    void onVoiceAssistantRequest(boolean fromLongPress) = 0;
+import android.os.Bundle;
+
+/** @hide */
+oneway interface ICarProjectionKeyEventHandler {
+    void onKeyEvent(int event) = 0;
 }
diff --git a/car-test-lib/src/android/car/testapi/CarProjectionController.java b/car-test-lib/src/android/car/testapi/CarProjectionController.java
index c3fada6..19d8aeb 100644
--- a/car-test-lib/src/android/car/testapi/CarProjectionController.java
+++ b/car-test-lib/src/android/car/testapi/CarProjectionController.java
@@ -16,16 +16,24 @@
 
 package android.car.testapi;
 
+import android.car.CarProjectionManager;
 import android.car.projection.ProjectionOptions;
 import android.net.wifi.WifiConfiguration;
 
-/** Controller to change behavior of {@link android.car.CarProjectionManager} */
+/** Controller to change behavior of {@link CarProjectionManager} */
 public interface CarProjectionController {
     /** Set WifiConfiguration for wireless projection or null to simulate failure to start AP */
     void setWifiConfiguration(WifiConfiguration wifiConfiguration);
+
     /**
      * Sets {@link ProjectionOptions} object returns by
-     * {@link android.car.CarProjectionManager#getProjectionOptions()} call
+     * {@link CarProjectionManager#getProjectionOptions()} call
      */
     void setProjectionOptions(ProjectionOptions projectionOptions);
+
+    /**
+     * Fire a projection event to be received by registered
+     * {@link CarProjectionManager.ProjectionKeyEventHandler}s.
+     */
+    void fireKeyEvent(@CarProjectionManager.KeyEventNum int event);
 }
diff --git a/car-test-lib/src/android/car/testapi/FakeCarProjectionService.java b/car-test-lib/src/android/car/testapi/FakeCarProjectionService.java
index bba5a04..0408f5d 100644
--- a/car-test-lib/src/android/car/testapi/FakeCarProjectionService.java
+++ b/car-test-lib/src/android/car/testapi/FakeCarProjectionService.java
@@ -20,7 +20,7 @@
 import android.car.CarProjectionManager;
 import android.car.CarProjectionManager.ProjectionAccessPointCallback;
 import android.car.ICarProjection;
-import android.car.ICarProjectionCallback;
+import android.car.ICarProjectionKeyEventHandler;
 import android.car.ICarProjectionStatusListener;
 import android.car.projection.ProjectionOptions;
 import android.car.projection.ProjectionStatus;
@@ -36,6 +36,7 @@
 import android.os.RemoteException;
 
 import java.util.ArrayList;
+import java.util.BitSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -58,6 +59,7 @@
     private ProjectionStatus mCurrentProjectionStatus = ProjectionStatus.builder(
             "", ProjectionStatus.PROJECTION_STATE_INACTIVE).build();
     private ProjectionOptions mProjectionOptions;
+    private final Map<ICarProjectionKeyEventHandler, BitSet> mKeyEventListeners = new HashMap<>();
 
     private final ServiceConnection mServiceConnection = new ServiceConnection() {
         @Override
@@ -83,15 +85,27 @@
     }
 
     @Override
-    public void registerProjectionListener(ICarProjectionCallback callback, int filter)
-            throws RemoteException {
-        // Not yet implemented.
+    public void registerKeyEventHandler(ICarProjectionKeyEventHandler callback, byte[] events) {
+        mKeyEventListeners.put(callback, BitSet.valueOf(events));
     }
 
     @Override
-    public void unregisterProjectionListener(ICarProjectionCallback callback)
-            throws RemoteException {
-        // Not yet implemented.
+    public void unregisterKeyEventHandler(ICarProjectionKeyEventHandler callback) {
+        mKeyEventListeners.remove(callback);
+    }
+
+    @Override
+    public void fireKeyEvent(int event) {
+        for (Map.Entry<ICarProjectionKeyEventHandler, BitSet> entry :
+                mKeyEventListeners.entrySet()) {
+            if (entry.getValue().get(event)) {
+                try {
+                    entry.getKey().onKeyEvent(event);
+                } catch (RemoteException e) {
+                    throw e.rethrowFromSystemServer();
+                }
+            }
+        }
     }
 
     @Override
diff --git a/service/src/com/android/car/CarInputService.java b/service/src/com/android/car/CarInputService.java
index c86f707..c9463d8 100644
--- a/service/src/com/android/car/CarInputService.java
+++ b/service/src/com/android/car/CarInputService.java
@@ -24,6 +24,7 @@
 import android.bluetooth.BluetoothDevice;
 import android.bluetooth.BluetoothHeadsetClient;
 import android.bluetooth.BluetoothProfile;
+import android.car.CarProjectionManager;
 import android.car.input.CarInputHandlingService;
 import android.car.input.CarInputHandlingService.InputFilter;
 import android.car.input.ICarInputListener;
@@ -54,6 +55,7 @@
 import com.android.internal.app.IVoiceInteractionSessionShowCallback;
 
 import java.io.PrintWriter;
+import java.util.BitSet;
 import java.util.List;
 import java.util.function.Supplier;
 
@@ -149,9 +151,9 @@
     private final Supplier<String> mLastCalledNumberSupplier;
 
     @GuardedBy("this")
-    private Runnable mVoiceAssistantKeyListener;
+    private CarProjectionManager.ProjectionKeyEventHandler mProjectionKeyEventHandler;
     @GuardedBy("this")
-    private Runnable mLongVoiceAssistantKeyListener;
+    private final BitSet mProjectionKeyEventsSubscribed = new BitSet();
 
     private final KeyPressTimer mVoiceKeyTimer;
     private final KeyPressTimer mCallKeyTimer;
@@ -298,24 +300,17 @@
     }
 
     /**
-     * Set listener for listening voice assistant key event. Setting to null stops listening.
-     * If listener is not set, default behavior will be done for short press.
-     * If listener is set, short key press will lead into calling the listener.
+     * Set projection key event listener. If null, unregister listener.
      */
-    public void setVoiceAssistantKeyListener(Runnable listener) {
+    public void setProjectionKeyEventHandler(
+            @Nullable CarProjectionManager.ProjectionKeyEventHandler listener,
+            @Nullable BitSet events) {
         synchronized (this) {
-            mVoiceAssistantKeyListener = listener;
-        }
-    }
-
-    /**
-     * Set listener for listening long voice assistant key event. Setting to null stops listening.
-     * If listener is not set, default behavior will be done for long press.
-     * If listener is set, short long press will lead into calling the listener.
-     */
-    public void setLongVoiceAssistantKeyListener(Runnable listener) {
-        synchronized (this) {
-            mLongVoiceAssistantKeyListener = listener;
+            mProjectionKeyEventHandler = listener;
+            mProjectionKeyEventsSubscribed.clear();
+            if (events != null) {
+                mProjectionKeyEventsSubscribed.or(events);
+            }
         }
     }
 
@@ -348,8 +343,8 @@
     @Override
     public void release() {
         synchronized (this) {
-            mVoiceAssistantKeyListener = null;
-            mLongVoiceAssistantKeyListener = null;
+            mProjectionKeyEventHandler = null;
+            mProjectionKeyEventsSubscribed.clear();
             mInstrumentClusterKeyListener = null;
             if (mCarInputListenerBound) {
                 mContext.unbindService(mInputServiceConnection);
@@ -410,35 +405,29 @@
         int action = event.getAction();
         if (action == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
             mVoiceKeyTimer.keyDown();
+            dispatchProjectionKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_KEY_DOWN);
         } else if (action == KeyEvent.ACTION_UP) {
             if (mVoiceKeyTimer.keyUp()) {
                 // Long press already handled by handleVoiceAssistLongPress(), nothing more to do.
+                // Hand it off to projection, if it's interested, otherwise we're done.
+                dispatchProjectionKeyEvent(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP);
                 return;
             }
 
-            final Runnable listener;
-            synchronized (this) {
-                listener = mVoiceAssistantKeyListener;
+            if (dispatchProjectionKeyEvent(
+                    CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP)) {
+                return;
             }
 
-            if (listener != null) {
-                listener.run();
-            } else {
-                launchDefaultVoiceAssistantHandler();
-            }
+            launchDefaultVoiceAssistantHandler();
         }
     }
 
     private void handleVoiceAssistLongPress() {
-        Runnable listener;
-
-        synchronized (this) {
-            listener = mLongVoiceAssistantKeyListener;
-        }
-
-        // If there's a long press listener registered, let it handle the event.
-        if (listener != null) {
-            listener.run();
+        // If projection wants this event, let it take it.
+        if (dispatchProjectionKeyEvent(
+                CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN)) {
             return;
         }
         // Otherwise, try to launch voice recognition on a BT device.
@@ -453,9 +442,12 @@
         int action = event.getAction();
         if (action == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
             mCallKeyTimer.keyDown();
+            dispatchProjectionKeyEvent(CarProjectionManager.KEY_EVENT_CALL_KEY_DOWN);
         } else if (action == KeyEvent.ACTION_UP) {
             if (mCallKeyTimer.keyUp()) {
                 // Long press already handled by handleCallLongPress(), nothing more to do.
+                // Hand it off to projection, if it's interested, otherwise we're done.
+                dispatchProjectionKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_UP);
                 return;
             }
 
@@ -464,6 +456,11 @@
                 return;
             }
 
+            if (dispatchProjectionKeyEvent(
+                    CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP)) {
+                return;
+            }
+
             launchDialerHandler();
         }
     }
@@ -474,9 +471,27 @@
             return;
         }
 
+        if (dispatchProjectionKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN)) {
+            return;
+        }
+
         dialLastCallHandler();
     }
 
+    private boolean dispatchProjectionKeyEvent(@CarProjectionManager.KeyEventNum int event) {
+        CarProjectionManager.ProjectionKeyEventHandler projectionKeyEventHandler;
+        synchronized (this) {
+            projectionKeyEventHandler = mProjectionKeyEventHandler;
+            if (projectionKeyEventHandler == null || !mProjectionKeyEventsSubscribed.get(event)) {
+                // No event handler, or event handler doesn't want this event - we're done.
+                return false;
+            }
+        }
+
+        projectionKeyEventHandler.onKeyEvent(event);
+        return true;
+    }
+
     private void launchDialerHandler() {
         Log.i(CarLog.TAG_INPUT, "call key, launch dialer intent");
         Intent dialerIntent = new Intent(Intent.ACTION_DIAL);
diff --git a/service/src/com/android/car/CarProjectionService.java b/service/src/com/android/car/CarProjectionService.java
index 9570b2a..119c5c4 100644
--- a/service/src/com/android/car/CarProjectionService.java
+++ b/service/src/com/android/car/CarProjectionService.java
@@ -15,8 +15,6 @@
  */
 package com.android.car;
 
-import static android.car.CarProjectionManager.PROJECTION_LONG_PRESS_VOICE_SEARCH;
-import static android.car.CarProjectionManager.PROJECTION_VOICE_SEARCH;
 import static android.car.CarProjectionManager.ProjectionAccessPointCallback.ERROR_GENERIC;
 import static android.car.projection.ProjectionStatus.PROJECTION_STATE_INACTIVE;
 import static android.net.wifi.WifiManager.EXTRA_PREVIOUS_WIFI_AP_STATE;
@@ -34,7 +32,7 @@
 import android.car.CarProjectionManager;
 import android.car.CarProjectionManager.ProjectionAccessPointCallback;
 import android.car.ICarProjection;
-import android.car.ICarProjectionCallback;
+import android.car.ICarProjectionKeyEventHandler;
 import android.car.ICarProjectionStatusListener;
 import android.car.projection.ProjectionOptions;
 import android.car.projection.ProjectionStatus;
@@ -69,6 +67,7 @@
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
 
 import java.io.PrintWriter;
 import java.lang.ref.WeakReference;
@@ -76,6 +75,7 @@
 import java.net.SocketException;
 import java.security.SecureRandom;
 import java.util.ArrayList;
+import java.util.BitSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Random;
@@ -86,11 +86,11 @@
  * It also enables proejcted applications to handle voice action requests.
  */
 class CarProjectionService extends ICarProjection.Stub implements CarServiceBase,
-        BinderInterfaceContainer.BinderEventHandler<ICarProjectionCallback> {
+        BinderInterfaceContainer.BinderEventHandler<ICarProjectionKeyEventHandler>,
+        CarProjectionManager.ProjectionKeyEventHandler {
     private static final String TAG = CarLog.TAG_PROJECTION;
     private static final boolean DBG = true;
 
-    private final ProjectionCallbackHolder mProjectionCallbacks;
     private final CarInputService mCarInputService;
     private final CarBluetoothService mCarBluetoothService;
     private final Context mContext;
@@ -130,6 +130,8 @@
     private final List<ICarProjectionStatusListener> mProjectionStatusListeners =
             new CopyOnWriteArrayList<>();
 
+    @GuardedBy("mLock")
+    private final ProjectionKeyEventHandlerContainer mKeyEventHandlers;
 
     private static final int WIFI_MODE_TETHERED = 1;
     private static final int WIFI_MODE_LOCALONLY = 2;
@@ -143,12 +145,6 @@
 
     private final WifiConfiguration mProjectionWifiConfiguration;
 
-    private final Runnable mVoiceAssistantKeyListener =
-            () -> handleVoiceAssistantRequest(false);
-
-    private final Runnable mLongVoiceAssistantKeyListener =
-            () -> handleVoiceAssistantRequest(true);
-
     private final ServiceConnection mConnection = new ServiceConnection() {
             @Override
             public void onServiceConnected(ComponentName className, IBinder service) {
@@ -177,7 +173,7 @@
         mHandler = handler == null ? new Handler() : handler;
         mCarInputService = carInputService;
         mCarBluetoothService = carBluetoothService;
-        mProjectionCallbacks = new ProjectionCallbackHolder(this);
+        mKeyEventHandlers = new ProjectionKeyEventHandlerContainer(this);
         mWifiManager = context.getSystemService(WifiManager.class);
         mProjectionWifiConfiguration = createWifiConfiguration(context);
     }
@@ -233,45 +229,34 @@
         mContext.unbindService(mConnection);
     }
 
-    private void handleVoiceAssistantRequest(boolean isTriggeredByLongPress) {
-        Log.i(TAG, "Voice assistant request, long press = " + isTriggeredByLongPress);
-        synchronized (mLock) {
-            for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
-                    mProjectionCallbacks.getInterfaces()) {
-                ProjectionCallback projectionCallback = (ProjectionCallback) listener;
-                if ((projectionCallback.hasFilter(PROJECTION_LONG_PRESS_VOICE_SEARCH)
-                        && isTriggeredByLongPress)
-                        || (projectionCallback.hasFilter(PROJECTION_VOICE_SEARCH)
-                        && !isTriggeredByLongPress)) {
-                    dispatchVoiceAssistantRequest(
-                            projectionCallback.binderInterface, isTriggeredByLongPress);
-                }
-            }
-        }
-    }
-
     @Override
-    public void registerProjectionListener(ICarProjectionCallback callback, int filter) {
+    public void registerKeyEventHandler(
+            ICarProjectionKeyEventHandler eventHandler, byte[] eventMask) {
         ICarImpl.assertProjectionPermission(mContext);
+        BitSet events = BitSet.valueOf(eventMask);
+        Preconditions.checkArgument(
+                events.length() <= CarProjectionManager.NUM_KEY_EVENTS,
+                "Unknown handled event");
         synchronized (mLock) {
-            ProjectionCallback info = mProjectionCallbacks.get(callback);
+            ProjectionKeyEventHandler info = mKeyEventHandlers.get(eventHandler);
             if (info == null) {
-                info = new ProjectionCallback(mProjectionCallbacks, callback, filter);
-                mProjectionCallbacks.addBinderInterface(info);
+                info = new ProjectionKeyEventHandler(mKeyEventHandlers, eventHandler, events);
+                mKeyEventHandlers.addBinderInterface(info);
             } else {
-                info.setFilter(filter);
+                info.setHandledEvents(events);
             }
+
+            updateInputServiceHandlerLocked();
         }
-        updateCarInputServiceListeners();
     }
 
     @Override
-    public void unregisterProjectionListener(ICarProjectionCallback listener) {
+    public void unregisterKeyEventHandler(ICarProjectionKeyEventHandler eventHandler) {
         ICarImpl.assertProjectionPermission(mContext);
         synchronized (mLock) {
-            mProjectionCallbacks.removeBinder(listener);
+            mKeyEventHandlers.removeBinder(eventHandler);
+            updateInputServiceHandlerLocked();
         }
-        updateCarInputServiceListeners();
     }
 
     @Override
@@ -718,25 +703,6 @@
         }
     }
 
-    private void updateCarInputServiceListeners() {
-        boolean listenShortPress = false;
-        boolean listenLongPress = false;
-        synchronized (mLock) {
-            for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
-                         mProjectionCallbacks.getInterfaces()) {
-                ProjectionCallback projectionCallback = (ProjectionCallback) listener;
-                listenShortPress |= projectionCallback.hasFilter(
-                        PROJECTION_VOICE_SEARCH);
-                listenLongPress |= projectionCallback.hasFilter(
-                        PROJECTION_LONG_PRESS_VOICE_SEARCH);
-            }
-        }
-        mCarInputService.setVoiceAssistantKeyListener(listenShortPress
-                ? mVoiceAssistantKeyListener : null);
-        mCarInputService.setLongVoiceAssistantKeyListener(listenLongPress
-                ? mLongVoiceAssistantKeyListener : null);
-    }
-
     @Override
     public void init() {
         mContext.registerReceiver(
@@ -780,24 +746,27 @@
     @Override
     public void release() {
         synchronized (mLock) {
-            mProjectionCallbacks.clear();
+            mKeyEventHandlers.clear();
         }
     }
 
     @Override
     public void onBinderDeath(
-            BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> bInterface) {
-        unregisterProjectionListener(bInterface.binderInterface);
+            BinderInterfaceContainer.BinderInterface<ICarProjectionKeyEventHandler> iface) {
+        unregisterKeyEventHandler(iface.binderInterface);
     }
 
     @Override
     public void dump(PrintWriter writer) {
         writer.println("**CarProjectionService**");
         synchronized (mLock) {
-            for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
-                         mProjectionCallbacks.getInterfaces()) {
-                ProjectionCallback projectionCallback = (ProjectionCallback) listener;
-                writer.println(projectionCallback.toString());
+            writer.println("Registered key event handlers:");
+            for (BinderInterfaceContainer.BinderInterface<ICarProjectionKeyEventHandler>
+                    handler : mKeyEventHandlers.getInterfaces()) {
+                ProjectionKeyEventHandler
+                        projectionKeyEventHandler = (ProjectionKeyEventHandler) handler;
+                writer.print("  ");
+                writer.println(projectionKeyEventHandler.toString());
             }
 
             writer.println("Local-only hotspot reservation: " + mLocalOnlyHotspotReservation);
@@ -813,14 +782,48 @@
         }
     }
 
-    private void dispatchVoiceAssistantRequest(ICarProjectionCallback listener,
-            boolean fromLongPress) {
-        try {
-            listener.onVoiceAssistantRequest(fromLongPress);
-        } catch (RemoteException e) {
+    @Override
+    public void onKeyEvent(@CarProjectionManager.KeyEventNum int keyEvent) {
+        Log.d(TAG, "Dispatching key event: " + keyEvent);
+        synchronized (mLock) {
+            for (BinderInterfaceContainer.BinderInterface<ICarProjectionKeyEventHandler>
+                    eventHandlerInterface : mKeyEventHandlers.getInterfaces()) {
+                ProjectionKeyEventHandler eventHandler =
+                        (ProjectionKeyEventHandler) eventHandlerInterface;
+
+                if (eventHandler.canHandleEvent(keyEvent)) {
+                    try {
+                        // oneway
+                        eventHandler.binderInterface.onKeyEvent(keyEvent);
+                    } catch (RemoteException e) {
+                        Log.e(TAG, "Cannot dispatch event to client", e);
+                    }
+                }
+            }
         }
     }
 
+    @GuardedBy("mLock")
+    private void updateInputServiceHandlerLocked() {
+        BitSet newEvents = computeHandledEventsLocked();
+
+        if (!newEvents.isEmpty()) {
+            mCarInputService.setProjectionKeyEventHandler(this, newEvents);
+        } else {
+            mCarInputService.setProjectionKeyEventHandler(null, null);
+        }
+    }
+
+    @GuardedBy("mLock")
+    private BitSet computeHandledEventsLocked() {
+        BitSet rv = new BitSet();
+        for (BinderInterfaceContainer.BinderInterface<ICarProjectionKeyEventHandler>
+                handlerInterface : mKeyEventHandlers.getInterfaces()) {
+            rv.or(((ProjectionKeyEventHandler) handlerInterface).mHandledEvents);
+        }
+        return rv;
+    }
+
     void setUiMode(Integer uiMode) {
         synchronized (mLock) {
             mProjectionOptions = createProjectionOptionsBuilder()
@@ -829,42 +832,40 @@
         }
     }
 
-    private static class ProjectionCallbackHolder
-            extends BinderInterfaceContainer<ICarProjectionCallback> {
-        ProjectionCallbackHolder(CarProjectionService service) {
+    private static class ProjectionKeyEventHandlerContainer
+            extends BinderInterfaceContainer<ICarProjectionKeyEventHandler> {
+        ProjectionKeyEventHandlerContainer(CarProjectionService service) {
             super(service);
         }
 
-        ProjectionCallback get(ICarProjectionCallback projectionCallback) {
-            return (ProjectionCallback) getBinderInterface(projectionCallback);
+        ProjectionKeyEventHandler get(ICarProjectionKeyEventHandler projectionCallback) {
+            return (ProjectionKeyEventHandler) getBinderInterface(projectionCallback);
         }
     }
 
-    private static class ProjectionCallback extends
-            BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> {
-        private int mFilter;
+    private static class ProjectionKeyEventHandler extends
+            BinderInterfaceContainer.BinderInterface<ICarProjectionKeyEventHandler> {
+        private BitSet mHandledEvents;
 
-        private ProjectionCallback(ProjectionCallbackHolder holder, ICarProjectionCallback binder,
-                int filter) {
+        private ProjectionKeyEventHandler(
+                ProjectionKeyEventHandlerContainer holder,
+                ICarProjectionKeyEventHandler binder,
+                BitSet handledEvents) {
             super(holder, binder);
-            this.mFilter = filter;
+            mHandledEvents = handledEvents;
         }
 
-        private synchronized int getFilter() {
-            return mFilter;
+        private boolean canHandleEvent(int event) {
+            return mHandledEvents.get(event);
         }
 
-        private boolean hasFilter(int filter) {
-            return (getFilter() & filter) != 0;
-        }
-
-        private synchronized void setFilter(int filter) {
-            mFilter = filter;
+        private void setHandledEvents(BitSet handledEvents) {
+            mHandledEvents = handledEvents;
         }
 
         @Override
         public String toString() {
-            return "ListenerInfo{filter=" + Integer.toHexString(getFilter()) + "}";
+            return "ProjectionKeyEventHandler{events=" + mHandledEvents + "}";
         }
     }
 
diff --git a/tests/CarLibTests/src/android/car/CarProjectionManagerTest.java b/tests/CarLibTests/src/android/car/CarProjectionManagerTest.java
index 012bc96..970cac9 100644
--- a/tests/CarLibTests/src/android/car/CarProjectionManagerTest.java
+++ b/tests/CarLibTests/src/android/car/CarProjectionManagerTest.java
@@ -20,8 +20,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 
 import android.car.CarProjectionManager.ProjectionAccessPointCallback;
@@ -30,6 +35,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.net.wifi.WifiConfiguration;
+import android.util.ArraySet;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.runner.AndroidJUnit4;
@@ -40,11 +46,16 @@
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
+import org.mockito.InOrder;
 import org.mockito.Spy;
 import org.mockito.junit.MockitoJUnit;
 import org.mockito.junit.MockitoRule;
 
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
 import java.util.concurrent.TimeUnit;
 
 @RunWith(AndroidJUnit4.class)
@@ -60,6 +71,9 @@
 
     private static final int DEFAULT_TIMEOUT_MS = 1000;
 
+    /** An {@link Executor} that immediately runs its callbacks synchronously. */
+    private static final Executor DIRECT_EXECUTOR = Runnable::run;
+
     private CarProjectionManager mProjectionManager;
     private CarProjectionController mController;
     private ApCallback mApCallback;
@@ -109,6 +123,135 @@
         assertThat(mIntentArgumentCaptor.getValue()).isEqualTo(intent);
     }
 
+    @Test
+    public void keyEventListener_registerMultipleEventListeners() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler1 =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler2 =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+
+        mProjectionManager.addKeyEventHandler(
+                Collections.singleton(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP),
+                DIRECT_EXECUTOR,
+                eventHandler1);
+        mProjectionManager.addKeyEventHandler(
+                new ArraySet<>(
+                        Arrays.asList(
+                                CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP,
+                                CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN)),
+                DIRECT_EXECUTOR,
+                eventHandler2);
+
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        verify(eventHandler1).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        verify(eventHandler2).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN);
+        verify(eventHandler1, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN);
+        verify(eventHandler2).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN);
+
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_CALL_KEY_DOWN);
+        verify(eventHandler1, never()).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_KEY_DOWN);
+        verify(eventHandler2, never()).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_KEY_DOWN);
+    }
+
+    @Test
+    public void keyEventHandler_canRegisterAllEvents() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+
+        Set<Integer> events = new ArraySet<>(CarProjectionManager.NUM_KEY_EVENTS);
+        for (int evt = 0; evt < CarProjectionManager.NUM_KEY_EVENTS; evt++) {
+            events.add(evt);
+        }
+
+        mProjectionManager.addKeyEventHandler(events, DIRECT_EXECUTOR, eventHandler);
+
+        for (int evt : events) {
+            mController.fireKeyEvent(evt);
+            verify(eventHandler).onKeyEvent(evt);
+        }
+    }
+
+    @Test
+    public void keyEventHandler_eventsOutOfRange_throw() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+
+        try {
+            mProjectionManager.addKeyEventHandler(Collections.singleton(-1), eventHandler);
+            fail();
+        } catch (IllegalArgumentException expected) { }
+
+        try {
+            mProjectionManager.addKeyEventHandler(
+                    Collections.singleton(CarProjectionManager.NUM_KEY_EVENTS), eventHandler);
+            fail();
+        } catch (IllegalArgumentException expected) { }
+    }
+
+    @Test
+    public void keyEventHandler_whenRegisteredAgain_replacesEventList() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+        InOrder inOrder = inOrder(eventHandler);
+
+        mProjectionManager.addKeyEventHandler(
+                Collections.singleton(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP),
+                DIRECT_EXECUTOR,
+                eventHandler);
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        inOrder.verify(eventHandler)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+
+        mProjectionManager.addKeyEventHandler(
+                Collections.singleton(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN),
+                DIRECT_EXECUTOR,
+                eventHandler);
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        inOrder.verify(eventHandler, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+    }
+
+    @Test
+    public void keyEventHandler_removed_noLongerFires() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+
+        mProjectionManager.addKeyEventHandler(
+                Collections.singleton(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP),
+                DIRECT_EXECUTOR,
+                eventHandler);
+        mProjectionManager.removeKeyEventHandler(eventHandler);
+
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        verify(eventHandler, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+    }
+
+    @Test
+    public void keyEventHandler_withAlternateExecutor_usesExecutor() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+        Executor executor = mock(Executor.class);
+        ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
+
+        mProjectionManager.addKeyEventHandler(
+                Collections.singleton(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP),
+                executor,
+                eventHandler);
+
+        mController.fireKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        verify(eventHandler, never()).onKeyEvent(anyInt());
+        verify(executor).execute(runnableCaptor.capture());
+
+        runnableCaptor.getValue().run();
+        verify(eventHandler)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+    }
+
     private static class ApCallback extends ProjectionAccessPointCallback {
         CountDownLatch mStarted = new CountDownLatch(1);
         CountDownLatch mFailed = new CountDownLatch(1);
diff --git a/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java b/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java
index 2db29db..455ed7a 100644
--- a/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java
+++ b/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java
@@ -34,6 +34,7 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+import android.car.CarProjectionManager;
 import android.car.input.CarInputHandlingService.InputFilter;
 import android.car.input.ICarInputListener;
 import android.content.ComponentName;
@@ -66,6 +67,7 @@
 import org.mockito.junit.MockitoJUnit;
 import org.mockito.junit.MockitoRule;
 
+import java.util.BitSet;
 import java.util.function.Supplier;
 
 @RunWith(AndroidJUnit4.class)
@@ -150,39 +152,42 @@
     }
 
     @Test
-    public void voiceKey_shortPress_withRegisteredListener_triggersListener() {
-        Runnable listener = mock(Runnable.class);
-        mCarInputService.setVoiceAssistantKeyListener(listener);
+    public void voiceKey_shortPress_withRegisteredEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
 
         send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
         send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
 
-        verify(listener).run();
+        verify(eventHandler)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
     }
 
     @Test
-    public void voiceKey_longPress_withRegisteredListener_triggersListener() {
-        Runnable shortPressListener = mock(Runnable.class);
-        Runnable longPressListener = mock(Runnable.class);
-        mCarInputService.setVoiceAssistantKeyListener(shortPressListener);
-        mCarInputService.setLongVoiceAssistantKeyListener(longPressListener);
+    public void voiceKey_longPress_withRegisteredEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP,
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
 
         send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
-        verify(shortPressListener, never()).run();
-        verify(longPressListener, never()).run();
+        verify(eventHandler, never()).onKeyEvent(anyInt());
 
         // Simulate the long-press timer expiring.
         flushHandler();
-        verify(longPressListener).run();
+        verify(eventHandler)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
 
-        // Ensure that the short-press listener is *not* called.
+        // Ensure that the short-press handler is *not* called.
         send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
         flushHandler();
-        verify(shortPressListener, never()).run();
+        verify(eventHandler, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
     }
 
     @Test
-    public void voiceKey_shortPress_withoutRegisteredListener_triggersAssistUtils() {
+    public void voiceKey_shortPress_withoutRegisteredEventHandler_triggersAssistUtils() {
         when(mAssistUtils.getAssistComponentForUser(anyInt()))
                 .thenReturn(new ComponentName("pkg", "cls"));
 
@@ -200,7 +205,7 @@
     }
 
     @Test
-    public void voiceKey_longPress_withoutRegisteredListener_triggersAssistUtils() {
+    public void voiceKey_longPress_withoutRegisteredEventHandler_triggersAssistUtils() {
         when(mAssistUtils.getAssistComponentForUser(anyInt()))
                 .thenReturn(new ComponentName("pkg", "cls"));
 
@@ -221,6 +226,33 @@
     }
 
     @Test
+    public void voiceKey_keyDown_withEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_KEY_DOWN);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+
+        verify(eventHandler).onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_KEY_DOWN);
+    }
+
+    @Test
+    public void voiceKey_keyUp_afterLongPress_withEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        flushHandler();
+        verify(eventHandler, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP);
+
+        send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        verify(eventHandler)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_UP);
+    }
+
+    @Test
     public void voiceKey_repeatedEvents_ignored() {
         // Pressing a key starts the long-press timer.
         send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
@@ -233,7 +265,7 @@
     }
 
     @Test
-    public void callKey_shortPress_launchesDialer() {
+    public void callKey_shortPress_withoutEventHandler_launchesDialer() {
         ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
 
         doNothing().when(mContext).startActivityAsUser(any(), any(), any());
@@ -247,7 +279,7 @@
     }
 
     @Test
-    public void callKey_shortPress_whenCallRinging_answersCall() {
+    public void callKey_shortPress_withoutEventHandler_whenCallRinging_answersCall() {
         when(mTelecomManager.isRinging()).thenReturn(true);
 
         send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
@@ -259,7 +291,36 @@
     }
 
     @Test
-    public void callKey_longPress_redialsLastCall() {
+    public void callKey_shortPress_withEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+
+        verify(eventHandler).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        // Ensure default handlers do not run.
+        verify(mTelecomManager, never()).acceptRingingCall();
+        verify(mContext, never()).startActivityAsUser(any(), any(), any());
+    }
+
+    @Test
+    public void callKey_shortPress_withEventHandler_whenCallRinging_answersCall() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_CALL_SHORT_PRESS_KEY_UP);
+        when(mTelecomManager.isRinging()).thenReturn(true);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+
+        verify(mTelecomManager).acceptRingingCall();
+        verify(eventHandler, never()).onKeyEvent(anyInt());
+    }
+
+    @Test
+    public void callKey_longPress_withoutEventHandler_redialsLastCall() {
         ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
 
         when(mLastCallSupplier.get()).thenReturn("1234567890");
@@ -281,7 +342,7 @@
     }
 
     @Test
-    public void callKey_longPress_withNoLastCall_doesNothing() {
+    public void callKey_longPress_withoutEventHandler_withNoLastCall_doesNothing() {
         when(mLastCallSupplier.get()).thenReturn("");
 
         send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
@@ -291,7 +352,7 @@
     }
 
     @Test
-    public void callKey_longPress_whenCallRinging_answersCall() {
+    public void callKey_longPress_withoutEventHandler_whenCallRinging_answersCall() {
         when(mTelecomManager.isRinging()).thenReturn(true);
 
         send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
@@ -306,6 +367,63 @@
     }
 
     @Test
+    public void callKey_longPress_withEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        flushHandler();
+
+        verify(eventHandler).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN);
+        verify(mContext, never()).startActivityAsUser(any(), any(), any());
+    }
+
+    @Test
+    public void callKey_longPress_withEventHandler_whenCallRinging_answersCall() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_DOWN);
+        when(mTelecomManager.isRinging()).thenReturn(true);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        flushHandler();
+
+        verify(mTelecomManager).acceptRingingCall();
+
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        // Ensure that event handler does not run, either after accepting ringing call,
+        // or as a result of key-up.
+        verify(eventHandler, never()).onKeyEvent(anyInt());
+    }
+
+    @Test
+    public void callKey_keyDown_withEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_CALL_KEY_DOWN);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+
+        verify(eventHandler).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_KEY_DOWN);
+    }
+
+    @Test
+    public void callKey_keyUp_afterLongPress_withEventHandler_triggersEventHandler() {
+        CarProjectionManager.ProjectionKeyEventHandler eventHandler =
+                registerProjectionKeyEventHandler(
+                        CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_UP);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        flushHandler();
+        verify(eventHandler, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_UP);
+
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        verify(eventHandler).onKeyEvent(CarProjectionManager.KEY_EVENT_CALL_LONG_PRESS_KEY_UP);
+    }
+
+    @Test
     public void callKey_repeatedEvents_ignored() {
         // Pressing a key starts the long-press timer.
         send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
@@ -316,6 +434,7 @@
         sendWithRepeat(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN, 1);
         verify(mHandler, never()).sendMessageAtTime(any(), anyLong());
     }
+
     private enum Key {DOWN, UP}
 
     private enum Display {MAIN, INSTRUMENT_CLUSTER}
@@ -346,6 +465,19 @@
         return listener;
     }
 
+    private CarProjectionManager.ProjectionKeyEventHandler registerProjectionKeyEventHandler(
+            int... events) {
+        BitSet eventSet = new BitSet();
+        for (int event : events) {
+            eventSet.set(event);
+        }
+
+        CarProjectionManager.ProjectionKeyEventHandler projectionKeyEventHandler =
+                mock(CarProjectionManager.ProjectionKeyEventHandler.class);
+        mCarInputService.setProjectionKeyEventHandler(projectionKeyEventHandler, eventSet);
+        return projectionKeyEventHandler;
+    }
+
     private void flushHandler() {
         ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
 
diff --git a/tests/carservice_unit_test/src/com/android/car/CarProjectionServiceTest.java b/tests/carservice_unit_test/src/com/android/car/CarProjectionServiceTest.java
index 476c152..d0306e3 100644
--- a/tests/carservice_unit_test/src/com/android/car/CarProjectionServiceTest.java
+++ b/tests/carservice_unit_test/src/com/android/car/CarProjectionServiceTest.java
@@ -23,8 +23,16 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.car.CarProjectionManager;
+import android.car.ICarProjectionKeyEventHandler;
 import android.car.ICarProjectionStatusListener;
 import android.car.projection.ProjectionOptions;
 import android.car.projection.ProjectionStatus;
@@ -39,6 +47,7 @@
 import android.os.Handler;
 import android.os.IBinder;
 import android.os.Looper;
+import android.os.RemoteException;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.runner.AndroidJUnit4;
@@ -47,11 +56,14 @@
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.Spy;
 import org.mockito.junit.MockitoJUnit;
 import org.mockito.junit.MockitoRule;
 
+import java.util.BitSet;
 import java.util.List;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -240,6 +252,75 @@
         assertThat(wifiChannels).isNotEmpty();
     }
 
+    @Test
+    public void addedKeyEventHandler_getsDispatchedEvents() throws RemoteException {
+        ICarProjectionKeyEventHandler eventHandler = createMockKeyEventHandler();
+
+        BitSet eventSet = bitSetOf(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        mService.registerKeyEventHandler(eventHandler, eventSet.toByteArray());
+
+        mService.onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        verify(eventHandler)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+    }
+
+    @Test
+    public void addedKeyEventHandler_registersWithCarInputService() throws RemoteException {
+        ICarProjectionKeyEventHandler eventHandler1 = createMockKeyEventHandler();
+        ICarProjectionKeyEventHandler eventHandler2 = createMockKeyEventHandler();
+        InOrder inOrder = inOrder(mCarInputService);
+
+        BitSet bitSet = bitSetOf(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+
+        bitSet.set(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        mService.registerKeyEventHandler(eventHandler1, bitSet.toByteArray());
+
+        ArgumentCaptor<CarProjectionManager.ProjectionKeyEventHandler> eventListenerCaptor =
+                ArgumentCaptor.forClass(CarProjectionManager.ProjectionKeyEventHandler.class);
+        inOrder.verify(mCarInputService)
+                .setProjectionKeyEventHandler(
+                        eventListenerCaptor.capture(),
+                        eq(bitSetOf(
+                                CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP)));
+
+        mService.registerKeyEventHandler(
+                eventHandler2,
+                bitSetOf(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP,
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN
+                ).toByteArray());
+        inOrder.verify(mCarInputService).setProjectionKeyEventHandler(
+                eventListenerCaptor.getValue(),
+                bitSetOf(
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP,
+                        CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN
+                ));
+
+        // Fire handler interface sent to CarInputService, and ensure that correct events fire.
+        eventListenerCaptor.getValue()
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        verify(eventHandler1)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+        verify(eventHandler2)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP);
+
+        eventListenerCaptor.getValue()
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
+        verify(eventHandler1, never())
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
+        verify(eventHandler2)
+                .onKeyEvent(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_LONG_PRESS_KEY_DOWN);
+
+        // Deregister event handlers, and check that CarInputService is updated appropriately.
+        mService.unregisterKeyEventHandler(eventHandler2);
+        inOrder.verify(mCarInputService).setProjectionKeyEventHandler(
+                eventListenerCaptor.getValue(),
+                bitSetOf(CarProjectionManager.KEY_EVENT_VOICE_SEARCH_SHORT_PRESS_KEY_UP));
+
+        mService.unregisterKeyEventHandler(eventHandler1);
+        inOrder.verify(mCarInputService).setProjectionKeyEventHandler(eq(null), any());
+    }
+
     private ProjectionStatus createProjectionStatus() {
         Bundle statusExtra = new Bundle();
         statusExtra.putString(STATUS_EXTRA_KEY, STATUS_EXTRA_VALUE);
@@ -263,4 +344,18 @@
                         .build())
                 .build();
     }
+
+    private static ICarProjectionKeyEventHandler createMockKeyEventHandler() {
+        ICarProjectionKeyEventHandler listener = mock(ICarProjectionKeyEventHandler.Stub.class);
+        when(listener.asBinder()).thenCallRealMethod();
+        return listener;
+    }
+
+    private static BitSet bitSetOf(@CarProjectionManager.KeyEventNum int... events) {
+        BitSet bitSet = new BitSet();
+        for (int event : events) {
+            bitSet.set(event);
+        }
+        return bitSet;
+    }
 }