Merge "Clean up special key handling in CarInputService."
diff --git a/service/src/com/android/car/CarInputService.java b/service/src/com/android/car/CarInputService.java
index c95674e..ab3ca3a 100644
--- a/service/src/com/android/car/CarInputService.java
+++ b/service/src/com/android/car/CarInputService.java
@@ -18,6 +18,7 @@
 import static android.hardware.input.InputManager.INJECT_INPUT_EVENT_MODE_ASYNC;
 import static android.service.voice.VoiceInteractionSession.SHOW_SOURCE_PUSH_TO_TALK;
 
+import android.annotation.Nullable;
 import android.app.ActivityManager;
 import android.car.input.CarInputHandlingService;
 import android.car.input.CarInputHandlingService.InputFilter;
@@ -30,10 +31,11 @@
 import android.net.Uri;
 import android.os.Binder;
 import android.os.Bundle;
+import android.os.Handler;
 import android.os.IBinder;
+import android.os.Looper;
 import android.os.Parcel;
 import android.os.RemoteException;
-import android.os.SystemClock;
 import android.os.UserHandle;
 import android.provider.CallLog.Calls;
 import android.telecom.TelecomManager;
@@ -42,84 +44,126 @@
 import android.view.KeyEvent;
 
 import com.android.car.hal.InputHalService;
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.app.AssistUtils;
 import com.android.internal.app.IVoiceInteractionSessionShowCallback;
 
 import java.io.PrintWriter;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
+import java.util.function.Supplier;
 
 public class CarInputService implements CarServiceBase, InputHalService.InputListener {
 
+    /** An interface to receive {@link KeyEvent}s as they occur. */
     public interface KeyEventListener {
-        boolean onKeyEvent(KeyEvent event);
+        /** Called when a key event occurs. */
+        void onKeyEvent(KeyEvent event);
     }
 
     private static final class KeyPressTimer {
-        private static final long LONG_PRESS_TIME_MS = 1000;
-
+        private final Handler mHandler;
+        private final Runnable mLongPressRunnable;
+        private final Runnable mCallback = this::onTimerExpired;
+        @GuardedBy("this")
         private boolean mDown = false;
-        private long mDuration = -1;
+        @GuardedBy("this")
+        private boolean mLongPress = false;
 
+        KeyPressTimer(Handler handler, Runnable longPressRunnable) {
+            mHandler = handler;
+            mLongPressRunnable = longPressRunnable;
+        }
+
+        /** Marks that a key was pressed, and starts the long-press timer. */
         synchronized void keyDown() {
             mDown = true;
-            mDuration = SystemClock.elapsedRealtime();
+            mLongPress = false;
+            mHandler.removeCallbacks(mCallback);
+            mHandler.postDelayed(mCallback, LONG_PRESS_TIME_MS);
         }
 
-        synchronized void keyUp() {
-            if (!mDown) {
-                throw new IllegalStateException("key can't go up without being down");
-            }
-            mDuration = SystemClock.elapsedRealtime() - mDuration;
+        /**
+         * Marks that a key was released, and stops the long-press timer.
+         *
+         * Returns true if the press was a long-press.
+         */
+        synchronized boolean keyUp() {
+            mHandler.removeCallbacks(mCallback);
             mDown = false;
+            return mLongPress;
         }
 
-        synchronized boolean isLongPress() {
-            if (mDown) {
-                throw new IllegalStateException("can't query press length during key down");
+        private void onTimerExpired() {
+            synchronized (this) {
+                // If the timer expires after key-up, don't retroactively make the press long.
+                if (!mDown) {
+                    return;
+                }
+                mLongPress = true;
             }
-            return mDuration >= LONG_PRESS_TIME_MS;
+
+            mLongPressRunnable.run();
         }
     }
 
-    private IVoiceInteractionSessionShowCallback mShowCallback =
+    private final IVoiceInteractionSessionShowCallback mShowCallback =
             new IVoiceInteractionSessionShowCallback.Stub() {
-        @Override
-        public void onFailed() {
-            Log.w(CarLog.TAG_INPUT, "Failed to show VoiceInteractionSession");
-        }
+                @Override
+                public void onFailed() {
+                    Log.w(CarLog.TAG_INPUT, "Failed to show VoiceInteractionSession");
+                }
 
-        @Override
-        public void onShown() {
-            if (DBG) {
-                Log.d(CarLog.TAG_INPUT, "IVoiceInteractionSessionShowCallback onShown()");
-            }
-        }
-    };
+                @Override
+                public void onShown() {
+                    if (DBG) {
+                        Log.d(CarLog.TAG_INPUT, "IVoiceInteractionSessionShowCallback onShown()");
+                    }
+                }
+            };
 
     private static final boolean DBG = false;
-    private static final String EXTRA_CAR_PUSH_TO_TALK =
+    @VisibleForTesting
+    static final String EXTRA_CAR_PUSH_TO_TALK =
             "com.android.car.input.EXTRA_CAR_PUSH_TO_TALK";
 
+    private static final int LONG_PRESS_TIME_MS = 1000;
+
     private final Context mContext;
     private final InputHalService mInputHalService;
     private final TelecomManager mTelecomManager;
-    private final InputManager mInputManager;
     private final AssistUtils mAssistUtils;
+    // The ComponentName of the CarInputListener service. Can be changed via resource overlay,
+    // or overridden directly for testing.
+    @Nullable
+    private final ComponentName mCustomInputServiceComponent;
+    // The default handler for main-display input events. By default, injects the events into
+    // the input queue via InputManager, but can be overridden for testing.
+    private final KeyEventListener mMainDisplayHandler;
+    // The supplier for the last-called number. By default, gets the number from the call log.
+    // May be overridden for testing.
+    private final Supplier<String> mLastCalledNumberSupplier;
 
-    private KeyEventListener mVoiceAssistantKeyListener;
-    private KeyEventListener mLongVoiceAssistantKeyListener;
+    @GuardedBy("this")
+    private Runnable mVoiceAssistantKeyListener;
+    @GuardedBy("this")
+    private Runnable mLongVoiceAssistantKeyListener;
 
-    private final KeyPressTimer mVoiceKeyTimer = new KeyPressTimer();
-    private final KeyPressTimer mCallKeyTimer = new KeyPressTimer();
+    private final KeyPressTimer mVoiceKeyTimer;
+    private final KeyPressTimer mCallKeyTimer;
 
+    @GuardedBy("this")
     private KeyEventListener mInstrumentClusterKeyListener;
 
-    private ICarInputListener mCarInputListener;
+    @GuardedBy("this")
+    @VisibleForTesting
+    ICarInputListener mCarInputListener;
+
+    @GuardedBy("this")
     private boolean mCarInputListenerBound = false;
-    private final Map<Integer, Set<Integer>> mHandledKeys = new HashMap<>();
+
+    // Maps display -> keycodes handled.
+    @GuardedBy("this")
+    private final SetMultimap<Integer, Integer> mHandledKeys = new SetMultimap<>();
 
     private final Binder mCallback = new Binder() {
         @Override
@@ -144,14 +188,18 @@
                 Log.d(CarLog.TAG_INPUT, "onServiceConnected, name: "
                         + name + ", binder: " + binder);
             }
-            mCarInputListener = ICarInputListener.Stub.asInterface(binder);
+            synchronized (this) {
+                mCarInputListener = ICarInputListener.Stub.asInterface(binder);
+            }
 
             try {
                 binder.linkToDeath(() -> CarServiceUtils.runOnMainSync(() -> {
                     Log.w(CarLog.TAG_INPUT, "Input service died. Trying to rebind...");
-                    mCarInputListener = null;
-                    // Try to rebind with input service.
-                    mCarInputListenerBound = bindCarInputService();
+                    synchronized (this) {
+                        mCarInputListener = null;
+                        // Try to rebind with input service.
+                        mCarInputListenerBound = bindCarInputService();
+                    }
                 }), 0);
             } catch (RemoteException e) {
                 Log.e(CarLog.TAG_INPUT, e.getMessage(), e);
@@ -161,29 +209,56 @@
         @Override
         public void onServiceDisconnected(ComponentName name) {
             Log.d(CarLog.TAG_INPUT, "onServiceDisconnected, name: " + name);
-            mCarInputListener = null;
-            // Try to rebind with input service.
-            mCarInputListenerBound = bindCarInputService();
+            synchronized (this) {
+                mCarInputListener = null;
+                // Try to rebind with input service.
+                mCarInputListenerBound = bindCarInputService();
+            }
         }
     };
 
-    public CarInputService(Context context, InputHalService inputHalService) {
-        mContext = context;
-        mInputHalService = inputHalService;
-        mTelecomManager = context.getSystemService(TelecomManager.class);
-        mInputManager = context.getSystemService(InputManager.class);
-        mAssistUtils = new AssistUtils(context);
+    @Nullable
+    private static ComponentName getDefaultInputComponent(Context context) {
+        String carInputService = context.getString(R.string.inputService);
+        if (TextUtils.isEmpty(carInputService)) {
+            return null;
+        }
+
+        return ComponentName.unflattenFromString(carInputService);
     }
 
-    private synchronized void setHandledKeys(InputFilter[] handledKeys) {
+    public CarInputService(Context context, InputHalService inputHalService) {
+        this(context, inputHalService, new Handler(Looper.getMainLooper()),
+                context.getSystemService(TelecomManager.class), new AssistUtils(context),
+                event ->
+                        context.getSystemService(InputManager.class)
+                                .injectInputEvent(event, INJECT_INPUT_EVENT_MODE_ASYNC),
+                () -> Calls.getLastOutgoingCall(context),
+                getDefaultInputComponent(context));
+    }
+
+    @VisibleForTesting
+    CarInputService(Context context, InputHalService inputHalService, Handler handler,
+            TelecomManager telecomManager, AssistUtils assistUtils,
+            KeyEventListener mainDisplayHandler, Supplier<String> lastCalledNumberSupplier,
+            @Nullable ComponentName customInputServiceComponent) {
+        mContext = context;
+        mInputHalService = inputHalService;
+        mTelecomManager = telecomManager;
+        mAssistUtils = assistUtils;
+        mMainDisplayHandler = mainDisplayHandler;
+        mLastCalledNumberSupplier = lastCalledNumberSupplier;
+        mCustomInputServiceComponent = customInputServiceComponent;
+
+        mVoiceKeyTimer = new KeyPressTimer(handler, this::handleVoiceAssistLongPress);
+        mCallKeyTimer = new KeyPressTimer(handler, this::handleCallLongPress);
+    }
+
+    @VisibleForTesting
+    synchronized void setHandledKeys(InputFilter[] handledKeys) {
         mHandledKeys.clear();
         for (InputFilter handledKey : handledKeys) {
-            Set<Integer> displaySet = mHandledKeys.get(handledKey.mTargetDisplay);
-            if (displaySet == null) {
-                displaySet = new HashSet<Integer>();
-                mHandledKeys.put(handledKey.mTargetDisplay, displaySet);
-            }
-            displaySet.add(handledKey.mKeyCode);
+            mHandledKeys.put(handledKey.mTargetDisplay, handledKey.mKeyCode);
         }
     }
 
@@ -191,9 +266,8 @@
      * 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.
-     * @param listener
      */
-    public void setVoiceAssistantKeyListener(KeyEventListener listener) {
+    public void setVoiceAssistantKeyListener(Runnable listener) {
         synchronized (this) {
             mVoiceAssistantKeyListener = listener;
         }
@@ -203,9 +277,8 @@
      * 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.
-     * @param listener
      */
-    public void setLongVoiceAssistantKeyListener(KeyEventListener listener) {
+    public void setLongVoiceAssistantKeyListener(Runnable listener) {
         synchronized (this) {
             mLongVoiceAssistantKeyListener = listener;
         }
@@ -228,7 +301,9 @@
 
 
         mInputHalService.setInputListener(this);
-        mCarInputListenerBound = bindCarInputService();
+        synchronized (this) {
+            mCarInputListenerBound = bindCarInputService();
+        }
     }
 
     @Override
@@ -247,9 +322,13 @@
     @Override
     public void onKeyEvent(KeyEvent event, int targetDisplay) {
         // Give a car specific input listener the opportunity to intercept any input from the car
-        if (mCarInputListener != null && isCustomEventHandler(event, targetDisplay)) {
+        ICarInputListener carInputListener;
+        synchronized (this) {
+            carInputListener = mCarInputListener;
+        }
+        if (carInputListener != null && isCustomEventHandler(event, targetDisplay)) {
             try {
-                mCarInputListener.onKeyEvent(event, targetDisplay);
+                carInputListener.onKeyEvent(event, targetDisplay);
             } catch (RemoteException e) {
                 Log.e(CarLog.TAG_INPUT, "Error while calling car input service", e);
             }
@@ -273,58 +352,79 @@
         if (targetDisplay == InputHalService.DISPLAY_INSTRUMENT_CLUSTER) {
             handleInstrumentClusterKey(event);
         } else {
-            handleMainDisplayKey(event);
+            mMainDisplayHandler.onKeyEvent(event);
         }
     }
 
     private synchronized boolean isCustomEventHandler(KeyEvent event, int targetDisplay) {
-        Set<Integer> displaySet = mHandledKeys.get(targetDisplay);
-        if (displaySet == null) {
-            return false;
-        }
-        return displaySet.contains(event.getKeyCode());
+        return mHandledKeys.containsEntry(targetDisplay, event.getKeyCode());
     }
 
     private void handleVoiceAssistKey(KeyEvent event) {
         int action = event.getAction();
-        if (action == KeyEvent.ACTION_DOWN) {
+        if (action == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
             mVoiceKeyTimer.keyDown();
         } else if (action == KeyEvent.ACTION_UP) {
-            mVoiceKeyTimer.keyUp();
-            final KeyEventListener listener;
+            if (mVoiceKeyTimer.keyUp()) {
+                // Long press already handled by handleVoiceAssistLongPress(), nothing more to do.
+                return;
+            }
 
+            final Runnable listener;
             synchronized (this) {
-                listener = (mVoiceKeyTimer.isLongPress()
-                    ? mLongVoiceAssistantKeyListener : mVoiceAssistantKeyListener);
+                listener = mVoiceAssistantKeyListener;
             }
 
             if (listener != null) {
-                listener.onKeyEvent(event);
+                listener.run();
             } else {
                 launchDefaultVoiceAssistantHandler();
             }
         }
     }
 
+    private void handleVoiceAssistLongPress() {
+        Runnable listener;
+
+        synchronized (this) {
+            listener = mLongVoiceAssistantKeyListener;
+        }
+
+        if (listener != null) {
+            listener.run();
+        } else {
+            launchDefaultVoiceAssistantHandler();
+        }
+    }
+
     private void handleCallKey(KeyEvent event) {
         int action = event.getAction();
-        if (action == KeyEvent.ACTION_DOWN) {
+        if (action == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
             mCallKeyTimer.keyDown();
         } else if (action == KeyEvent.ACTION_UP) {
-            mCallKeyTimer.keyUp();
-
-            // Handle a phone call regardless of press length.
-            if (mTelecomManager != null && mTelecomManager.isRinging()) {
-                Log.i(CarLog.TAG_INPUT, "call key while ringing. Answer the call!");
-                mTelecomManager.acceptRingingCall();
-            } else if (mCallKeyTimer.isLongPress()) {
-                dialLastCallHandler();
-            } else {
-                launchDialerHandler();
+            if (mCallKeyTimer.keyUp()) {
+                // Long press already handled by handleCallLongPress(), nothing more to do.
+                return;
             }
+
+            if (acceptCallIfRinging()) {
+                // Ringing call answered, nothing more to do.
+                return;
+            }
+
+            launchDialerHandler();
         }
     }
 
+    private void handleCallLongPress() {
+        // Long-press answers call if ringing, same as short-press.
+        if (acceptCallIfRinging()) {
+            return;
+        }
+
+        dialLastCallHandler();
+    }
+
     private void launchDialerHandler() {
         Log.i(CarLog.TAG_INPUT, "call key, launch dialer intent");
         Intent dialerIntent = new Intent(Intent.ACTION_DIAL);
@@ -334,8 +434,8 @@
     private void dialLastCallHandler() {
         Log.i(CarLog.TAG_INPUT, "call key, dialing last call");
 
-        String lastNumber = Calls.getLastOutgoingCall(mContext);
-        if (lastNumber != null && !lastNumber.isEmpty()) {
+        String lastNumber = mLastCalledNumberSupplier.get();
+        if (!TextUtils.isEmpty(lastNumber)) {
             Intent callLastNumberIntent = new Intent(Intent.ACTION_CALL)
                     .setData(Uri.fromParts("tel", lastNumber, null))
                     .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@@ -343,6 +443,16 @@
         }
     }
 
+    private boolean acceptCallIfRinging() {
+        if (mTelecomManager != null && mTelecomManager.isRinging()) {
+            Log.i(CarLog.TAG_INPUT, "call key while ringing. Answer the call!");
+            mTelecomManager.acceptRingingCall();
+            return true;
+        }
+
+        return false;
+    }
+
     private void launchDefaultVoiceAssistantHandler() {
         Log.i(CarLog.TAG_INPUT, "voice key, invoke AssistUtils");
 
@@ -369,31 +479,26 @@
         listener.onKeyEvent(event);
     }
 
-    private void handleMainDisplayKey(KeyEvent event) {
-        mInputManager.injectInputEvent(event, INJECT_INPUT_EVENT_MODE_ASYNC);
-    }
-
     @Override
-    public void dump(PrintWriter writer) {
+    public synchronized void dump(PrintWriter writer) {
         writer.println("*Input Service*");
         writer.println("mCarInputListenerBound:" + mCarInputListenerBound);
         writer.println("mCarInputListener:" + mCarInputListener);
     }
 
     private boolean bindCarInputService() {
-        String carInputService = mContext.getString(R.string.inputService);
-        if (TextUtils.isEmpty(carInputService)) {
+        if (mCustomInputServiceComponent == null) {
             Log.i(CarLog.TAG_INPUT, "Custom input service was not configured");
             return false;
         }
 
-        Log.d(CarLog.TAG_INPUT, "bindCarInputService, component: " + carInputService);
+        Log.d(CarLog.TAG_INPUT, "bindCarInputService, component: " + mCustomInputServiceComponent);
 
         Intent intent = new Intent();
         Bundle extras = new Bundle();
         extras.putBinder(CarInputHandlingService.INPUT_CALLBACK_BINDER_KEY, mCallback);
         intent.putExtras(extras);
-        intent.setComponent(ComponentName.unflattenFromString(carInputService));
+        intent.setComponent(mCustomInputServiceComponent);
         return mContext.bindService(intent, mInputServiceConnection, Context.BIND_AUTO_CREATE);
     }
 }
diff --git a/service/src/com/android/car/CarProjectionService.java b/service/src/com/android/car/CarProjectionService.java
index 5bc8352..d673eab 100644
--- a/service/src/com/android/car/CarProjectionService.java
+++ b/service/src/com/android/car/CarProjectionService.java
@@ -54,7 +54,6 @@
 import android.os.RemoteException;
 import android.os.UserHandle;
 import android.util.Log;
-import android.view.KeyEvent;
 
 import com.android.internal.annotations.GuardedBy;
 
@@ -110,23 +109,11 @@
 
     private final WifiConfiguration mProjectionWifiConfiguration;
 
-    private final CarInputService.KeyEventListener mVoiceAssistantKeyListener =
-            new CarInputService.KeyEventListener() {
-                @Override
-                public boolean onKeyEvent(KeyEvent event) {
-                    handleVoiceAssitantRequest(false);
-                    return true;
-                }
-            };
+    private final Runnable mVoiceAssistantKeyListener =
+            () -> handleVoiceAssistantRequest(false);
 
-    private final CarInputService.KeyEventListener mLongVoiceAssistantKeyListener =
-            new CarInputService.KeyEventListener() {
-                @Override
-                public boolean onKeyEvent(KeyEvent event) {
-                    handleVoiceAssitantRequest(true);
-                    return true;
-                }
-            };
+    private final Runnable mLongVoiceAssistantKeyListener =
+            () -> handleVoiceAssistantRequest(true);
 
     private final ServiceConnection mConnection = new ServiceConnection() {
             @Override
@@ -208,7 +195,8 @@
         mContext.unbindService(mConnection);
     }
 
-    private void handleVoiceAssitantRequest(boolean isTriggeredByLongPress) {
+    private void handleVoiceAssistantRequest(boolean isTriggeredByLongPress) {
+        Log.i(TAG, "Voice assistant request, long press = " + isTriggeredByLongPress);
         synchronized (mLock) {
             for (BinderInterfaceContainer.BinderInterface<ICarProjectionCallback> listener :
                     mProjectionCallbacks.getInterfaces()) {
diff --git a/service/src/com/android/car/cluster/InstrumentClusterService.java b/service/src/com/android/car/cluster/InstrumentClusterService.java
index 16c0e94..b491350 100644
--- a/service/src/com/android/car/cluster/InstrumentClusterService.java
+++ b/service/src/com/android/car/cluster/InstrumentClusterService.java
@@ -239,7 +239,7 @@
     }
 
     @Override
-    public boolean onKeyEvent(KeyEvent event) {
+    public void onKeyEvent(KeyEvent event) {
         if (Log.isLoggable(TAG, Log.DEBUG)) {
             Log.d(TAG, "InstrumentClusterService#onKeyEvent: " + event);
         }
@@ -252,7 +252,6 @@
                 Log.e(TAG, "onKeyEvent", e);
             }
         }
-        return true;
     }
 
     private IInstrumentCluster getInstrumentClusterRendererService() {
diff --git a/tests/carservice_unit_test/Android.mk b/tests/carservice_unit_test/Android.mk
index 1045b52..ccc97b5 100644
--- a/tests/carservice_unit_test/Android.mk
+++ b/tests/carservice_unit_test/Android.mk
@@ -42,6 +42,7 @@
 
 LOCAL_STATIC_JAVA_LIBRARIES := \
     junit \
+    androidx.test.core \
     androidx.test.rules \
     mockito-target-minus-junit4 \
     com.android.car.test.utils \
diff --git a/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java b/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java
new file mode 100644
index 0000000..2db29db
--- /dev/null
+++ b/tests/carservice_unit_test/src/com/android/car/CarInputServiceTest.java
@@ -0,0 +1,360 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.ignoreStubs;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.car.input.CarInputHandlingService.InputFilter;
+import android.car.input.ICarInputListener;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.service.voice.VoiceInteractionSession;
+import android.telecom.TelecomManager;
+import android.view.KeyEvent;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.car.hal.InputHalService;
+import com.android.internal.app.AssistUtils;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.util.function.Supplier;
+
+@RunWith(AndroidJUnit4.class)
+public class CarInputServiceTest {
+    @Rule public MockitoRule rule = MockitoJUnit.rule();
+
+    @Mock InputHalService mInputHalService;
+    @Mock TelecomManager mTelecomManager;
+    @Mock AssistUtils mAssistUtils;
+    @Mock CarInputService.KeyEventListener mDefaultMainListener;
+    @Mock Supplier<String> mLastCallSupplier;
+
+    @Spy Context mContext = ApplicationProvider.getApplicationContext();
+    @Spy Handler mHandler = new Handler(Looper.getMainLooper());
+
+    private CarInputService mCarInputService;
+
+    @Before
+    public void setUp() {
+        mCarInputService = new CarInputService(mContext, mInputHalService, mHandler,
+                mTelecomManager, mAssistUtils, mDefaultMainListener, mLastCallSupplier,
+                /* customInputServiceComponent= */ null);
+
+        when(mInputHalService.isKeyInputSupported()).thenReturn(true);
+        mCarInputService.init();
+
+        // Delay Handler callbacks until flushHandler() is called.
+        doReturn(true).when(mHandler).sendMessageAtTime(any(), anyLong());
+    }
+
+    @Test
+    public void ordinaryEvents_onMainDisplay_routedToInputManager() {
+        KeyEvent event = send(Key.DOWN, KeyEvent.KEYCODE_ENTER, Display.MAIN);
+
+        verify(mDefaultMainListener).onKeyEvent(event);
+    }
+
+    @Test
+    public void ordinaryEvents_onInstrumentClusterDisplay_notRoutedToInputManager() {
+        send(Key.DOWN, KeyEvent.KEYCODE_ENTER, Display.INSTRUMENT_CLUSTER);
+
+        verify(mDefaultMainListener, never()).onKeyEvent(any());
+    }
+
+    @Test
+    public void ordinaryEvents_onInstrumentClusterDisplay_routedToListener() {
+        CarInputService.KeyEventListener listener = mock(CarInputService.KeyEventListener.class);
+        mCarInputService.setInstrumentClusterKeyListener(listener);
+
+        KeyEvent event = send(Key.DOWN, KeyEvent.KEYCODE_ENTER, Display.INSTRUMENT_CLUSTER);
+        verify(listener).onKeyEvent(event);
+    }
+
+    @Test
+    public void customEventHandler_capturesRegisteredEvents_ignoresUnregisteredEvents()
+            throws RemoteException {
+        KeyEvent event;
+        ICarInputListener listener = registerInputListener(
+                new InputFilter(KeyEvent.KEYCODE_ENTER, InputHalService.DISPLAY_MAIN),
+                new InputFilter(KeyEvent.KEYCODE_ENTER, InputHalService.DISPLAY_INSTRUMENT_CLUSTER),
+                new InputFilter(KeyEvent.KEYCODE_MENU, InputHalService.DISPLAY_MAIN));
+
+        CarInputService.KeyEventListener instrumentClusterListener =
+                mock(CarInputService.KeyEventListener.class);
+        mCarInputService.setInstrumentClusterKeyListener(instrumentClusterListener);
+
+        event = send(Key.DOWN, KeyEvent.KEYCODE_ENTER, Display.MAIN);
+        verify(listener).onKeyEvent(event, InputHalService.DISPLAY_MAIN);
+        verify(mDefaultMainListener, never()).onKeyEvent(any());
+
+        event = send(Key.DOWN, KeyEvent.KEYCODE_ENTER, Display.INSTRUMENT_CLUSTER);
+        verify(listener).onKeyEvent(event, InputHalService.DISPLAY_INSTRUMENT_CLUSTER);
+        verify(instrumentClusterListener, never()).onKeyEvent(any());
+
+        event = send(Key.DOWN, KeyEvent.KEYCODE_MENU, Display.MAIN);
+        verify(listener).onKeyEvent(event, InputHalService.DISPLAY_MAIN);
+        verify(mDefaultMainListener, never()).onKeyEvent(any());
+
+        event = send(Key.DOWN, KeyEvent.KEYCODE_MENU, Display.INSTRUMENT_CLUSTER);
+        verify(listener, never()).onKeyEvent(event, InputHalService.DISPLAY_INSTRUMENT_CLUSTER);
+        verify(instrumentClusterListener).onKeyEvent(event);
+    }
+
+    @Test
+    public void voiceKey_shortPress_withRegisteredListener_triggersListener() {
+        Runnable listener = mock(Runnable.class);
+        mCarInputService.setVoiceAssistantKeyListener(listener);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+
+        verify(listener).run();
+    }
+
+    @Test
+    public void voiceKey_longPress_withRegisteredListener_triggersListener() {
+        Runnable shortPressListener = mock(Runnable.class);
+        Runnable longPressListener = mock(Runnable.class);
+        mCarInputService.setVoiceAssistantKeyListener(shortPressListener);
+        mCarInputService.setLongVoiceAssistantKeyListener(longPressListener);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        verify(shortPressListener, never()).run();
+        verify(longPressListener, never()).run();
+
+        // Simulate the long-press timer expiring.
+        flushHandler();
+        verify(longPressListener).run();
+
+        // Ensure that the short-press listener is *not* called.
+        send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        flushHandler();
+        verify(shortPressListener, never()).run();
+    }
+
+    @Test
+    public void voiceKey_shortPress_withoutRegisteredListener_triggersAssistUtils() {
+        when(mAssistUtils.getAssistComponentForUser(anyInt()))
+                .thenReturn(new ComponentName("pkg", "cls"));
+
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+
+        ArgumentCaptor<Bundle> bundleCaptor = ArgumentCaptor.forClass(Bundle.class);
+        verify(mAssistUtils).showSessionForActiveService(
+                bundleCaptor.capture(),
+                eq(VoiceInteractionSession.SHOW_SOURCE_PUSH_TO_TALK),
+                any(),
+                isNull());
+        assertThat(bundleCaptor.getValue().getBoolean(CarInputService.EXTRA_CAR_PUSH_TO_TALK))
+                .isTrue();
+    }
+
+    @Test
+    public void voiceKey_longPress_withoutRegisteredListener_triggersAssistUtils() {
+        when(mAssistUtils.getAssistComponentForUser(anyInt()))
+                .thenReturn(new ComponentName("pkg", "cls"));
+
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        flushHandler();
+
+        ArgumentCaptor<Bundle> bundleCaptor = ArgumentCaptor.forClass(Bundle.class);
+        verify(mAssistUtils).showSessionForActiveService(
+                bundleCaptor.capture(),
+                eq(VoiceInteractionSession.SHOW_SOURCE_PUSH_TO_TALK),
+                any(),
+                isNull());
+        assertThat(bundleCaptor.getValue().getBoolean(CarInputService.EXTRA_CAR_PUSH_TO_TALK))
+                .isTrue();
+
+        send(Key.UP, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        verifyNoMoreInteractions(ignoreStubs(mAssistUtils));
+    }
+
+    @Test
+    public void voiceKey_repeatedEvents_ignored() {
+        // Pressing a key starts the long-press timer.
+        send(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN);
+        verify(mHandler).sendMessageAtTime(any(), anyLong());
+        clearInvocations(mHandler);
+
+        // Repeated KEY_DOWN events don't reset the timer.
+        sendWithRepeat(Key.DOWN, KeyEvent.KEYCODE_VOICE_ASSIST, Display.MAIN, 1);
+        verify(mHandler, never()).sendMessageAtTime(any(), anyLong());
+    }
+
+    @Test
+    public void callKey_shortPress_launchesDialer() {
+        ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+
+        doNothing().when(mContext).startActivityAsUser(any(), any(), any());
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+
+        verify(mContext).startActivityAsUser(
+                intentCaptor.capture(), any(), eq(UserHandle.CURRENT_OR_SELF));
+        assertThat(intentCaptor.getValue().getAction()).isEqualTo(Intent.ACTION_DIAL);
+    }
+
+    @Test
+    public void callKey_shortPress_whenCallRinging_answersCall() {
+        when(mTelecomManager.isRinging()).thenReturn(true);
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+
+        verify(mTelecomManager).acceptRingingCall();
+        // Ensure default handler does not run.
+        verify(mContext, never()).startActivityAsUser(any(), any(), any());
+    }
+
+    @Test
+    public void callKey_longPress_redialsLastCall() {
+        ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+
+        when(mLastCallSupplier.get()).thenReturn("1234567890");
+        doNothing().when(mContext).startActivityAsUser(any(), any(), any());
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        flushHandler();
+
+        verify(mContext).startActivityAsUser(
+                intentCaptor.capture(), any(), eq(UserHandle.CURRENT_OR_SELF));
+
+        Intent intent = intentCaptor.getValue();
+        assertThat(intent.getAction()).isEqualTo(Intent.ACTION_CALL);
+        assertThat(intent.getData()).isEqualTo(Uri.parse("tel:1234567890"));
+
+        clearInvocations(mContext);
+        send(Key.UP, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        verify(mContext, never()).startActivityAsUser(any(), any(), any());
+    }
+
+    @Test
+    public void callKey_longPress_withNoLastCall_doesNothing() {
+        when(mLastCallSupplier.get()).thenReturn("");
+
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        flushHandler();
+
+        verify(mContext, never()).startActivityAsUser(any(), any(), any());
+    }
+
+    @Test
+    public void callKey_longPress_whenCallRinging_answersCall() {
+        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 default handler does not run, either after accepting ringing call,
+        // or as a result of key-up.
+        verify(mContext, never()).startActivityAsUser(any(), any(), any());
+    }
+
+    @Test
+    public void callKey_repeatedEvents_ignored() {
+        // Pressing a key starts the long-press timer.
+        send(Key.DOWN, KeyEvent.KEYCODE_CALL, Display.MAIN);
+        verify(mHandler).sendMessageAtTime(any(), anyLong());
+        clearInvocations(mHandler);
+
+        // Repeated KEY_DOWN events don't reset the timer.
+        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}
+
+    private KeyEvent send(Key action, int keyCode, Display display) {
+        return sendWithRepeat(action, keyCode, display, 0);
+    }
+
+    private KeyEvent sendWithRepeat(Key action, int keyCode, Display display, int repeatCount) {
+        KeyEvent event = new KeyEvent(
+                /* downTime= */ 0L,
+                /* eventTime= */ 0L,
+                action == Key.DOWN ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP,
+                keyCode,
+                repeatCount);
+        mCarInputService.onKeyEvent(
+                event,
+                display == Display.MAIN
+                        ? InputHalService.DISPLAY_MAIN
+                        : InputHalService.DISPLAY_INSTRUMENT_CLUSTER);
+        return event;
+    }
+
+    private ICarInputListener registerInputListener(InputFilter... handledKeys) {
+        ICarInputListener listener = mock(ICarInputListener.class);
+        mCarInputService.mCarInputListener = listener;
+        mCarInputService.setHandledKeys(handledKeys);
+        return listener;
+    }
+
+    private void flushHandler() {
+        ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
+
+        verify(mHandler, atLeast(0)).sendMessageAtTime(messageCaptor.capture(), anyLong());
+
+        for (Message message : messageCaptor.getAllValues()) {
+            mHandler.dispatchMessage(message);
+        }
+
+        clearInvocations(mHandler);
+    }
+}