diff --git a/core/api/test-current.txt b/core/api/test-current.txt index 43d4530cbf1d0..3d30c0f6d0b04 100644 --- a/core/api/test-current.txt +++ b/core/api/test-current.txt @@ -38,6 +38,7 @@ package android { field public static final String READ_CELL_BROADCASTS = "android.permission.READ_CELL_BROADCASTS"; field public static final String READ_PRIVILEGED_PHONE_STATE = "android.permission.READ_PRIVILEGED_PHONE_STATE"; field public static final String RECORD_BACKGROUND_AUDIO = "android.permission.RECORD_BACKGROUND_AUDIO"; + field public static final String REMAP_MODIFIER_KEYS = "android.permission.REMAP_MODIFIER_KEYS"; field public static final String REMOVE_TASKS = "android.permission.REMOVE_TASKS"; field public static final String REQUEST_UNIQUE_ID_ATTESTATION = "android.permission.REQUEST_UNIQUE_ID_ATTESTATION"; field public static final String RESET_APP_ERRORS = "android.permission.RESET_APP_ERRORS"; @@ -1294,8 +1295,11 @@ package android.hardware.input { public final class InputManager { method public void addUniqueIdAssociation(@NonNull String, @NonNull String); + method @RequiresPermission(android.Manifest.permission.REMAP_MODIFIER_KEYS) public void clearAllModifierKeyRemappings(); method @Nullable public String getCurrentKeyboardLayoutForInputDevice(@NonNull android.hardware.input.InputDeviceIdentifier); method @NonNull public java.util.List getKeyboardLayoutDescriptorsForInputDevice(@NonNull android.view.InputDevice); + method @NonNull @RequiresPermission(android.Manifest.permission.REMAP_MODIFIER_KEYS) public java.util.Map getModifierKeyRemapping(); + method @RequiresPermission(android.Manifest.permission.REMAP_MODIFIER_KEYS) public void remapModifierKey(int, int); method @RequiresPermission(android.Manifest.permission.SET_KEYBOARD_LAYOUT) public void removeKeyboardLayoutForInputDevice(@NonNull android.hardware.input.InputDeviceIdentifier, @NonNull String); method public void removeUniqueIdAssociation(@NonNull String); method @RequiresPermission(android.Manifest.permission.SET_KEYBOARD_LAYOUT) public void setCurrentKeyboardLayoutForInputDevice(@NonNull android.hardware.input.InputDeviceIdentifier, @NonNull String); diff --git a/core/java/android/hardware/input/IInputManager.aidl b/core/java/android/hardware/input/IInputManager.aidl index b26c0a289c21c..eef0f421d29f2 100644 --- a/core/java/android/hardware/input/IInputManager.aidl +++ b/core/java/android/hardware/input/IInputManager.aidl @@ -123,6 +123,22 @@ interface IInputManager { String[] getKeyboardLayoutListForInputDevice(in InputDeviceIdentifier identifier, int userId, in InputMethodInfo imeInfo, in InputMethodSubtype imeSubtype); + // Modifier key remapping APIs. + @EnforcePermission("REMAP_MODIFIER_KEYS") + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(value = " + + "android.Manifest.permission.REMAP_MODIFIER_KEYS)") + void remapModifierKey(int fromKey, int toKey); + + @EnforcePermission("REMAP_MODIFIER_KEYS") + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(value = " + + "android.Manifest.permission.REMAP_MODIFIER_KEYS)") + void clearAllModifierKeyRemappings(); + + @EnforcePermission("REMAP_MODIFIER_KEYS") + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(value = " + + "android.Manifest.permission.REMAP_MODIFIER_KEYS)") + Map getModifierKeyRemapping(); + // Registers an input devices changed listener. void registerInputDevicesChangedListener(IInputDevicesChangedListener listener); diff --git a/core/java/android/hardware/input/InputManager.java b/core/java/android/hardware/input/InputManager.java index cea3fa1a7fb0d..373541734be0a 100644 --- a/core/java/android/hardware/input/InputManager.java +++ b/core/java/android/hardware/input/InputManager.java @@ -78,6 +78,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.Executor; @@ -253,6 +254,31 @@ public final class InputManager { }) public @interface SwitchState {} + /** @hide */ + @Retention(RetentionPolicy.SOURCE) + @IntDef(prefix = { "REMAPPABLE_MODIFIER_KEY_" }, value = { + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_CTRL_LEFT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_CTRL_RIGHT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_META_LEFT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_META_RIGHT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_ALT_LEFT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_ALT_RIGHT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_SHIFT_LEFT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_SHIFT_RIGHT, + RemappableModifierKey.REMAPPABLE_MODIFIER_KEY_CAPS_LOCK, + }) + public @interface RemappableModifierKey { + int REMAPPABLE_MODIFIER_KEY_CTRL_LEFT = KeyEvent.KEYCODE_CTRL_LEFT; + int REMAPPABLE_MODIFIER_KEY_CTRL_RIGHT = KeyEvent.KEYCODE_CTRL_RIGHT; + int REMAPPABLE_MODIFIER_KEY_META_LEFT = KeyEvent.KEYCODE_META_LEFT; + int REMAPPABLE_MODIFIER_KEY_META_RIGHT = KeyEvent.KEYCODE_META_RIGHT; + int REMAPPABLE_MODIFIER_KEY_ALT_LEFT = KeyEvent.KEYCODE_ALT_LEFT; + int REMAPPABLE_MODIFIER_KEY_ALT_RIGHT = KeyEvent.KEYCODE_ALT_RIGHT; + int REMAPPABLE_MODIFIER_KEY_SHIFT_LEFT = KeyEvent.KEYCODE_SHIFT_LEFT; + int REMAPPABLE_MODIFIER_KEY_SHIFT_RIGHT = KeyEvent.KEYCODE_SHIFT_RIGHT; + int REMAPPABLE_MODIFIER_KEY_CAPS_LOCK = KeyEvent.KEYCODE_CAPS_LOCK; + } + /** * Switch State: Unknown. * @@ -853,6 +879,60 @@ public final class InputManager { } } + /** + * Remaps modifier keys. Remapping a modifier key to itself will clear any previous remappings + * for that key. + * + * @param fromKey The modifier key getting remapped. + * @param toKey The modifier key that it is remapped to. + * + * @hide + */ + @TestApi + @RequiresPermission(Manifest.permission.REMAP_MODIFIER_KEYS) + public void remapModifierKey(@RemappableModifierKey int fromKey, + @RemappableModifierKey int toKey) { + try { + mIm.remapModifierKey(fromKey, toKey); + } catch (RemoteException ex) { + throw ex.rethrowFromSystemServer(); + } + } + + /** + * Clears all existing modifier key remappings + * + * @hide + */ + @TestApi + @RequiresPermission(Manifest.permission.REMAP_MODIFIER_KEYS) + public void clearAllModifierKeyRemappings() { + try { + mIm.clearAllModifierKeyRemappings(); + } catch (RemoteException ex) { + throw ex.rethrowFromSystemServer(); + } + } + + /** + * Provides the current modifier key remapping + * + * @return a {fromKey, toKey} map that contains the existing modifier key remappings.. + * {@link RemappableModifierKey} + * + * @hide + */ + @TestApi + @NonNull + @RequiresPermission(Manifest.permission.REMAP_MODIFIER_KEYS) + public Map getModifierKeyRemapping() { + try { + return mIm.getModifierKeyRemapping(); + } catch (RemoteException ex) { + throw ex.rethrowFromSystemServer(); + } + } + /** * Gets the TouchCalibration applied to the specified input device's coordinates. * diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index 71a02dd64b9c5..dc24b0f927b17 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -6776,6 +6776,14 @@ @hide --> + + + + + + + diff --git a/services/core/java/com/android/server/input/InputManagerService.java b/services/core/java/com/android/server/input/InputManagerService.java index 81d782e9b8637..0da04a22d31f6 100644 --- a/services/core/java/com/android/server/input/InputManagerService.java +++ b/services/core/java/com/android/server/input/InputManagerService.java @@ -294,6 +294,9 @@ public class InputManagerService extends IInputManager.Stub // Manages Keyboard backlight private final KeyboardBacklightController mKeyboardBacklightController; + // Manages Keyboard modifier keys remapping + private final KeyRemapper mKeyRemapper; + // Maximum number of milliseconds to wait for input event injection. private static final int INJECTION_TIMEOUT_MILLIS = 30 * 1000; @@ -408,6 +411,7 @@ public class InputManagerService extends IInputManager.Stub mBatteryController = new BatteryController(mContext, mNative, injector.getLooper()); mKeyboardBacklightController = new KeyboardBacklightController(mContext, mNative, mDataStore, injector.getLooper()); + mKeyRemapper = new KeyRemapper(mContext, mNative, mDataStore, injector.getLooper()); mUseDevInputEventForAudioJack = mContext.getResources().getBoolean(R.bool.config_useDevInputEventForAudioJack); @@ -536,6 +540,7 @@ public class InputManagerService extends IInputManager.Stub mKeyboardLayoutManager.systemRunning(); mBatteryController.systemRunning(); mKeyboardBacklightController.systemRunning(); + mKeyRemapper.systemRunning(); } private void reloadDeviceAliases() { @@ -2738,6 +2743,27 @@ public class InputManagerService extends IInputManager.Stub return mKeyboardLayoutManager.getKeyboardLayoutOverlay(identifier); } + @EnforcePermission(Manifest.permission.REMAP_MODIFIER_KEYS) + @Override // Binder call + public void remapModifierKey(int fromKey, int toKey) { + super.remapModifierKey_enforcePermission(); + mKeyRemapper.remapKey(fromKey, toKey); + } + + @EnforcePermission(Manifest.permission.REMAP_MODIFIER_KEYS) + @Override // Binder call + public void clearAllModifierKeyRemappings() { + super.clearAllModifierKeyRemappings_enforcePermission(); + mKeyRemapper.clearAllKeyRemappings(); + } + + @EnforcePermission(Manifest.permission.REMAP_MODIFIER_KEYS) + @Override // Binder call + public Map getModifierKeyRemapping() { + super.getModifierKeyRemapping_enforcePermission(); + return mKeyRemapper.getKeyRemapping(); + } + // Native callback. @SuppressWarnings("unused") private String getDeviceAlias(String uniqueId) { diff --git a/services/core/java/com/android/server/input/KeyRemapper.java b/services/core/java/com/android/server/input/KeyRemapper.java new file mode 100644 index 0000000000000..950e094a205d6 --- /dev/null +++ b/services/core/java/com/android/server/input/KeyRemapper.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2022 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.server.input; + +import android.content.Context; +import android.hardware.input.InputManager; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.InputDevice; + +import com.android.internal.annotations.GuardedBy; + +import java.util.Map; +import java.util.Objects; + +/** + * A component of {@link InputManagerService} responsible for managing key remappings. + * + * @hide + */ +final class KeyRemapper implements InputManager.InputDeviceListener { + + private static final int MSG_UPDATE_EXISTING_DEVICES = 1; + private static final int MSG_REMAP_KEY = 2; + private static final int MSG_CLEAR_ALL_REMAPPING = 3; + + private final Context mContext; + private final NativeInputManagerService mNative; + // The PersistentDataStore should be locked before use. + @GuardedBy("mDataStore") + private final PersistentDataStore mDataStore; + private final Handler mHandler; + + KeyRemapper(Context context, NativeInputManagerService nativeService, + PersistentDataStore dataStore, Looper looper) { + mContext = context; + mNative = nativeService; + mDataStore = dataStore; + mHandler = new Handler(looper, this::handleMessage); + } + + public void systemRunning() { + InputManager inputManager = Objects.requireNonNull( + mContext.getSystemService(InputManager.class)); + inputManager.registerInputDeviceListener(this, mHandler); + + Message msg = Message.obtain(mHandler, MSG_UPDATE_EXISTING_DEVICES, + inputManager.getInputDeviceIds()); + mHandler.sendMessage(msg); + } + + public void remapKey(int fromKey, int toKey) { + Message msg = Message.obtain(mHandler, MSG_REMAP_KEY, fromKey, toKey); + mHandler.sendMessage(msg); + } + + public void clearAllKeyRemappings() { + Message msg = Message.obtain(mHandler, MSG_CLEAR_ALL_REMAPPING); + mHandler.sendMessage(msg); + } + + public Map getKeyRemapping() { + synchronized (mDataStore) { + return mDataStore.getKeyRemapping(); + } + } + + private void addKeyRemapping(int fromKey, int toKey) { + InputManager inputManager = Objects.requireNonNull( + mContext.getSystemService(InputManager.class)); + for (int deviceId : inputManager.getInputDeviceIds()) { + InputDevice inputDevice = inputManager.getInputDevice(deviceId); + if (inputDevice != null && !inputDevice.isVirtual() && inputDevice.isFullKeyboard()) { + mNative.addKeyRemapping(deviceId, fromKey, toKey); + } + } + } + + private void remapKeyInternal(int fromKey, int toKey) { + addKeyRemapping(fromKey, toKey); + synchronized (mDataStore) { + try { + if (fromKey == toKey) { + mDataStore.clearMappedKey(fromKey); + } else { + mDataStore.remapKey(fromKey, toKey); + } + } finally { + mDataStore.saveIfNeeded(); + } + } + } + + private void clearAllRemappingsInternal() { + synchronized (mDataStore) { + try { + Map keyRemapping = mDataStore.getKeyRemapping(); + for (int fromKey : keyRemapping.keySet()) { + mDataStore.clearMappedKey(fromKey); + + // Remapping to itself will clear the remapping on native side + addKeyRemapping(fromKey, fromKey); + } + } finally { + mDataStore.saveIfNeeded(); + } + } + } + + @Override + public void onInputDeviceAdded(int deviceId) { + InputManager inputManager = Objects.requireNonNull( + mContext.getSystemService(InputManager.class)); + InputDevice inputDevice = inputManager.getInputDevice(deviceId); + if (inputDevice != null && !inputDevice.isVirtual() && inputDevice.isFullKeyboard()) { + Map remapping = getKeyRemapping(); + remapping.forEach( + (fromKey, toKey) -> mNative.addKeyRemapping(deviceId, fromKey, toKey)); + } + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + } + + @Override + public void onInputDeviceChanged(int deviceId) { + } + + private boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_UPDATE_EXISTING_DEVICES: + for (int deviceId : (int[]) msg.obj) { + onInputDeviceAdded(deviceId); + } + return true; + case MSG_REMAP_KEY: + remapKeyInternal(msg.arg1, msg.arg2); + return true; + case MSG_CLEAR_ALL_REMAPPING: + clearAllRemappingsInternal(); + return true; + } + return false; + } +} diff --git a/services/core/java/com/android/server/input/NativeInputManagerService.java b/services/core/java/com/android/server/input/NativeInputManagerService.java index cfa7fb141be19..8781c6e2b934f 100644 --- a/services/core/java/com/android/server/input/NativeInputManagerService.java +++ b/services/core/java/com/android/server/input/NativeInputManagerService.java @@ -47,6 +47,8 @@ interface NativeInputManagerService { int getSwitchState(int deviceId, int sourceMask, int sw); + void addKeyRemapping(int deviceId, int fromKeyCode, int toKeyCode); + boolean hasKeys(int deviceId, int sourceMask, int[] keyCodes, boolean[] keyExists); int getKeyCodeForKeyLocation(int deviceId, int locationKeyCode); @@ -234,6 +236,9 @@ interface NativeInputManagerService { @Override public native int getSwitchState(int deviceId, int sourceMask, int sw); + @Override + public native void addKeyRemapping(int deviceId, int fromKeyCode, int toKeyCode); + @Override public native boolean hasKeys(int deviceId, int sourceMask, int[] keyCodes, boolean[] keyExists); diff --git a/services/core/java/com/android/server/input/PersistentDataStore.java b/services/core/java/com/android/server/input/PersistentDataStore.java index 1bb10c710839f..375377a7510d8 100644 --- a/services/core/java/com/android/server/input/PersistentDataStore.java +++ b/services/core/java/com/android/server/input/PersistentDataStore.java @@ -27,14 +27,13 @@ import android.view.Surface; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.ArrayUtils; import com.android.internal.util.XmlUtils; +import com.android.modules.utils.TypedXmlPullParser; +import com.android.modules.utils.TypedXmlSerializer; import libcore.io.IoUtils; import org.xmlpull.v1.XmlPullParserException; -import com.android.modules.utils.TypedXmlPullParser; -import com.android.modules.utils.TypedXmlSerializer; - import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -64,6 +63,8 @@ import java.util.Set; final class PersistentDataStore { static final String TAG = "InputManager"; + private static final int INVALID_VALUE = -1; + // Input device state by descriptor. private final HashMap mInputDevices = new HashMap(); @@ -77,6 +78,9 @@ final class PersistentDataStore { // True if there are changes to be saved. private boolean mDirty; + // Storing key remapping + private Map mKeyRemapping = new HashMap<>(); + public PersistentDataStore() { this(new Injector()); } @@ -187,6 +191,30 @@ final class PersistentDataStore { return state.getKeyboardBacklightBrightness(lightId); } + public boolean remapKey(int fromKey, int toKey) { + loadIfNeeded(); + if (mKeyRemapping.getOrDefault(fromKey, INVALID_VALUE) == toKey) { + return false; + } + mKeyRemapping.put(fromKey, toKey); + setDirty(); + return true; + } + + public boolean clearMappedKey(int key) { + loadIfNeeded(); + if (mKeyRemapping.containsKey(key)) { + mKeyRemapping.remove(key); + setDirty(); + } + return true; + } + + public Map getKeyRemapping() { + loadIfNeeded(); + return new HashMap<>(mKeyRemapping); + } + public boolean removeUninstalledKeyboardLayouts(Set availableKeyboardLayouts) { boolean changed = false; for (InputDeviceState state : mInputDevices.values()) { @@ -229,6 +257,7 @@ final class PersistentDataStore { } private void clearState() { + mKeyRemapping.clear(); mInputDevices.clear(); } @@ -280,7 +309,9 @@ final class PersistentDataStore { XmlUtils.beginDocument(parser, "input-manager-state"); final int outerDepth = parser.getDepth(); while (XmlUtils.nextElementWithin(parser, outerDepth)) { - if (parser.getName().equals("input-devices")) { + if (parser.getName().equals("key-remapping")) { + loadKeyRemappingFromXml(parser); + } else if (parser.getName().equals("input-devices")) { loadInputDevicesFromXml(parser); } } @@ -307,10 +338,31 @@ final class PersistentDataStore { } } + private void loadKeyRemappingFromXml(TypedXmlPullParser parser) + throws IOException, XmlPullParserException { + final int outerDepth = parser.getDepth(); + while (XmlUtils.nextElementWithin(parser, outerDepth)) { + if (parser.getName().equals("remap")) { + int fromKey = parser.getAttributeInt(null, "from-key"); + int toKey = parser.getAttributeInt(null, "to-key"); + mKeyRemapping.put(fromKey, toKey); + } + } + } + private void saveToXml(TypedXmlSerializer serializer) throws IOException { serializer.startDocument(null, true); serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true); serializer.startTag(null, "input-manager-state"); + serializer.startTag(null, "key-remapping"); + for (int fromKey : mKeyRemapping.keySet()) { + int toKey = mKeyRemapping.get(fromKey); + serializer.startTag(null, "remap"); + serializer.attributeInt(null, "from-key", fromKey); + serializer.attributeInt(null, "to-key", toKey); + serializer.endTag(null, "remap"); + } + serializer.endTag(null, "key-remapping"); serializer.startTag(null, "input-devices"); for (Map.Entry entry : mInputDevices.entrySet()) { final String descriptor = entry.getKey(); @@ -329,7 +381,6 @@ final class PersistentDataStore { private static final String[] CALIBRATION_NAME = { "x_scale", "x_ymix", "x_offset", "y_xmix", "y_scale", "y_offset" }; - private static final int INVALID_VALUE = -1; private final TouchCalibration[] mTouchCalibration = new TouchCalibration[4]; @Nullable private String mCurrentKeyboardLayout; diff --git a/services/core/jni/com_android_server_input_InputManagerService.cpp b/services/core/jni/com_android_server_input_InputManagerService.cpp index 969056e7c8b41..145e0885b105f 100644 --- a/services/core/jni/com_android_server_input_InputManagerService.cpp +++ b/services/core/jni/com_android_server_input_InputManagerService.cpp @@ -1578,6 +1578,12 @@ static std::vector getIntArray(JNIEnv* env, jintArray arr) { return vec; } +static void nativeAddKeyRemapping(JNIEnv* env, jobject nativeImplObj, jint deviceId, + jint fromKeyCode, jint toKeyCode) { + NativeInputManager* im = getNativeInputManager(env, nativeImplObj); + im->getInputManager()->getReader().addKeyRemapping(deviceId, fromKeyCode, toKeyCode); +} + static jboolean nativeHasKeys(JNIEnv* env, jobject nativeImplObj, jint deviceId, jint sourceMask, jintArray keyCodes, jbooleanArray outFlags) { NativeInputManager* im = getNativeInputManager(env, nativeImplObj); @@ -2360,6 +2366,7 @@ static const JNINativeMethod gInputManagerMethods[] = { {"getScanCodeState", "(III)I", (void*)nativeGetScanCodeState}, {"getKeyCodeState", "(III)I", (void*)nativeGetKeyCodeState}, {"getSwitchState", "(III)I", (void*)nativeGetSwitchState}, + {"addKeyRemapping", "(III)V", (void*)nativeAddKeyRemapping}, {"hasKeys", "(II[I[Z)Z", (void*)nativeHasKeys}, {"getKeyCodeForKeyLocation", "(II)I", (void*)nativeGetKeyCodeForKeyLocation}, {"createInputChannel", "(Ljava/lang/String;)Landroid/view/InputChannel;", diff --git a/services/tests/servicestests/src/com/android/server/input/KeyRemapperTests.kt b/services/tests/servicestests/src/com/android/server/input/KeyRemapperTests.kt new file mode 100644 index 0000000000000..c22782c91bdf4 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/input/KeyRemapperTests.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2022 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.server.input + +import android.content.Context +import android.content.ContextWrapper +import android.hardware.input.IInputManager +import android.hardware.input.InputManager +import android.os.test.TestLooper +import android.platform.test.annotations.Presubmit +import android.view.InputDevice +import android.view.KeyEvent +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnit +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream + +private fun createKeyboard(deviceId: Int): InputDevice = + InputDevice.Builder() + .setId(deviceId) + .setName("Device $deviceId") + .setDescriptor("descriptor $deviceId") + .setSources(InputDevice.SOURCE_KEYBOARD) + .setKeyboardType(InputDevice.KEYBOARD_TYPE_ALPHABETIC) + .setExternal(true) + .build() + +/** + * Tests for {@link KeyRemapper}. + * + * Build/Install/Run: + * atest FrameworksServicesTests:KeyRemapperTests + */ +@Presubmit +class KeyRemapperTests { + + companion object { + const val DEVICE_ID = 1 + val REMAPPABLE_KEYS = intArrayOf( + KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT, + KeyEvent.KEYCODE_META_LEFT, KeyEvent.KEYCODE_META_RIGHT, + KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT, + KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT, + KeyEvent.KEYCODE_CAPS_LOCK + ) + } + + @get:Rule + val rule = MockitoJUnit.rule()!! + + @Mock + private lateinit var iInputManager: IInputManager + @Mock + private lateinit var native: NativeInputManagerService + private lateinit var mKeyRemapper: KeyRemapper + private lateinit var context: Context + private lateinit var dataStore: PersistentDataStore + private lateinit var testLooper: TestLooper + + @Before + fun setup() { + context = Mockito.spy(ContextWrapper(ApplicationProvider.getApplicationContext())) + dataStore = PersistentDataStore(object : PersistentDataStore.Injector() { + override fun openRead(): InputStream? { + throw FileNotFoundException() + } + + override fun startWrite(): FileOutputStream? { + throw IOException() + } + + override fun finishWrite(fos: FileOutputStream?, success: Boolean) {} + }) + testLooper = TestLooper() + mKeyRemapper = KeyRemapper( + context, + native, + dataStore, + testLooper.looper + ) + val inputManager = InputManager.resetInstance(iInputManager) + Mockito.`when`(context.getSystemService(Mockito.eq(Context.INPUT_SERVICE))) + .thenReturn(inputManager) + Mockito.`when`(iInputManager.inputDeviceIds).thenReturn(intArrayOf(DEVICE_ID)) + } + + @After + fun tearDown() { + InputManager.clearInstance() + } + + @Test + fun testKeyRemapping() { + val keyboard = createKeyboard(DEVICE_ID) + Mockito.`when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboard) + + for (i in REMAPPABLE_KEYS.indices) { + val fromKeyCode = REMAPPABLE_KEYS[i] + val toKeyCode = REMAPPABLE_KEYS[(i + 1) % REMAPPABLE_KEYS.size] + mKeyRemapper.remapKey(fromKeyCode, toKeyCode) + testLooper.dispatchNext() + } + + val remapping = mKeyRemapper.keyRemapping + val expectedSize = REMAPPABLE_KEYS.size + assertEquals("Remapping size should be $expectedSize", expectedSize, remapping.size) + + for (i in REMAPPABLE_KEYS.indices) { + val fromKeyCode = REMAPPABLE_KEYS[i] + val toKeyCode = REMAPPABLE_KEYS[(i + 1) % REMAPPABLE_KEYS.size] + assertEquals( + "Remapping should include mapping from $fromKeyCode to $toKeyCode", + toKeyCode, + remapping.getOrDefault(fromKeyCode, -1) + ) + } + + mKeyRemapper.clearAllKeyRemappings() + testLooper.dispatchNext() + + assertEquals( + "Remapping size should be 0 after clearAllModifierKeyRemappings", + 0, + mKeyRemapper.keyRemapping.size + ) + } +} \ No newline at end of file