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);
+ }
+}