Move framework input tests into the InputTests module
Bug: 290274722
Test: atest InputTests
Change-Id: Ie064d77f563c56455b03bf919b22b2f64e39318a
diff --git a/tests/Input/Android.bp b/tests/Input/Android.bp
index 4fa6fbe..96b685d 100644
--- a/tests/Input/Android.bp
+++ b/tests/Input/Android.bp
@@ -19,13 +19,26 @@
platform_apis: true,
certificate: "platform",
static_libs: [
+ "androidx.test.core",
"androidx.test.ext.junit",
+ "androidx.test.ext.truth",
"androidx.test.rules",
+ "androidx.test.runner",
+ "androidx.test.uiautomator_uiautomator",
+ "servicestests-utils",
+ "frameworks-base-testutils",
+ "hamcrest-library",
+ "kotlin-test",
"mockito-target-minus-junit4",
+ "platform-test-annotations",
"services.core.unboosted",
"testables",
+ "testng",
"truth-prebuilt",
- "androidx.test.uiautomator_uiautomator",
+ ],
+ libs: [
+ "android.test.mock",
+ "android.test.base",
],
test_suites: ["device-tests"],
}
diff --git a/tests/Input/AndroidManifest.xml b/tests/Input/AndroidManifest.xml
index 20f564e..3b723dd 100644
--- a/tests/Input/AndroidManifest.xml
+++ b/tests/Input/AndroidManifest.xml
@@ -16,11 +16,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.test.input">
- <uses-permission android:name="android.permission.MONITOR_INPUT"/>
- <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"/>
- <uses-permission android:name="android.permission.INJECT_EVENTS"/>
- <application android:label="InputTest">
+ <uses-permission android:name="android.permission.INJECT_EVENTS"/>
+ <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL"/>
+ <uses-permission android:name="android.permission.MONITOR_INPUT"/>
+ <uses-permission android:name="android.permission.READ_DEVICE_CONFIG"/>
+ <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"/>
+
+ <application android:label="InputTest" android:debuggable="true">
<activity android:name=".UnresponsiveGestureMonitorActivity"
android:label="Unresponsive gesture monitor"
diff --git a/tests/Input/res/raw/dummy_keyboard_layout.kcm b/tests/Input/res/raw/dummy_keyboard_layout.kcm
new file mode 100644
index 0000000..ea6bc98
--- /dev/null
+++ b/tests/Input/res/raw/dummy_keyboard_layout.kcm
@@ -0,0 +1,311 @@
+# Copyright (C) 2023 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.
+
+#
+# English (US) keyboard layout.
+# Unlike the default (generic) keyboard layout, English (US) does not contain any
+# special ALT characters.
+#
+
+type OVERLAY
+
+### ROW 1
+
+key GRAVE {
+ label: '`'
+ base: '`'
+ shift: '~'
+}
+
+key 1 {
+ label: '1'
+ base: '1'
+ shift: '!'
+}
+
+key 2 {
+ label: '2'
+ base: '2'
+ shift: '@'
+}
+
+key 3 {
+ label: '3'
+ base: '3'
+ shift: '#'
+}
+
+key 4 {
+ label: '4'
+ base: '4'
+ shift: '$'
+}
+
+key 5 {
+ label: '5'
+ base: '5'
+ shift: '%'
+}
+
+key 6 {
+ label: '6'
+ base: '6'
+ shift: '^'
+}
+
+key 7 {
+ label: '7'
+ base: '7'
+ shift: '&'
+}
+
+key 8 {
+ label: '8'
+ base: '8'
+ shift: '*'
+}
+
+key 9 {
+ label: '9'
+ base: '9'
+ shift: '('
+}
+
+key 0 {
+ label: '0'
+ base: '0'
+ shift: ')'
+}
+
+key MINUS {
+ label: '-'
+ base: '-'
+ shift: '_'
+}
+
+key EQUALS {
+ label: '='
+ base: '='
+ shift: '+'
+}
+
+### ROW 2
+
+key Q {
+ label: 'Q'
+ base: 'q'
+ shift, capslock: 'Q'
+}
+
+key W {
+ label: 'W'
+ base: 'w'
+ shift, capslock: 'W'
+}
+
+key E {
+ label: 'E'
+ base: 'e'
+ shift, capslock: 'E'
+}
+
+key R {
+ label: 'R'
+ base: 'r'
+ shift, capslock: 'R'
+}
+
+key T {
+ label: 'T'
+ base: 't'
+ shift, capslock: 'T'
+}
+
+key Y {
+ label: 'Y'
+ base: 'y'
+ shift, capslock: 'Y'
+}
+
+key U {
+ label: 'U'
+ base: 'u'
+ shift, capslock: 'U'
+}
+
+key I {
+ label: 'I'
+ base: 'i'
+ shift, capslock: 'I'
+}
+
+key O {
+ label: 'O'
+ base: 'o'
+ shift, capslock: 'O'
+}
+
+key P {
+ label: 'P'
+ base: 'p'
+ shift, capslock: 'P'
+}
+
+key LEFT_BRACKET {
+ label: '['
+ base: '['
+ shift: '{'
+}
+
+key RIGHT_BRACKET {
+ label: ']'
+ base: ']'
+ shift: '}'
+}
+
+key BACKSLASH {
+ label: '\\'
+ base: '\\'
+ shift: '|'
+}
+
+### ROW 3
+
+key A {
+ label: 'A'
+ base: 'a'
+ shift, capslock: 'A'
+}
+
+key S {
+ label: 'S'
+ base: 's'
+ shift, capslock: 'S'
+}
+
+key D {
+ label: 'D'
+ base: 'd'
+ shift, capslock: 'D'
+}
+
+key F {
+ label: 'F'
+ base: 'f'
+ shift, capslock: 'F'
+}
+
+key G {
+ label: 'G'
+ base: 'g'
+ shift, capslock: 'G'
+}
+
+key H {
+ label: 'H'
+ base: 'h'
+ shift, capslock: 'H'
+}
+
+key J {
+ label: 'J'
+ base: 'j'
+ shift, capslock: 'J'
+}
+
+key K {
+ label: 'K'
+ base: 'k'
+ shift, capslock: 'K'
+}
+
+key L {
+ label: 'L'
+ base: 'l'
+ shift, capslock: 'L'
+}
+
+key SEMICOLON {
+ label: ';'
+ base: ';'
+ shift: ':'
+}
+
+key APOSTROPHE {
+ label: '\''
+ base: '\''
+ shift: '"'
+}
+
+### ROW 4
+
+key Z {
+ label: 'Z'
+ base: 'z'
+ shift, capslock: 'Z'
+}
+
+key X {
+ label: 'X'
+ base: 'x'
+ shift, capslock: 'X'
+}
+
+key C {
+ label: 'C'
+ base: 'c'
+ shift, capslock: 'C'
+}
+
+key V {
+ label: 'V'
+ base: 'v'
+ shift, capslock: 'V'
+}
+
+key B {
+ label: 'B'
+ base: 'b'
+ shift, capslock: 'B'
+}
+
+key N {
+ label: 'N'
+ base: 'n'
+ shift, capslock: 'N'
+}
+
+key M {
+ label: 'M'
+ base: 'm'
+ shift, capslock: 'M'
+}
+
+key COMMA {
+ label: ','
+ base: ','
+ shift: '<'
+}
+
+key PERIOD {
+ label: '.'
+ base: '.'
+ shift: '>'
+}
+
+key SLASH {
+ label: '/'
+ base: '/'
+ shift: '?'
+}
diff --git a/tests/Input/res/raw/input_port_associations.xml b/tests/Input/res/raw/input_port_associations.xml
new file mode 100644
index 0000000..b10d541
--- /dev/null
+++ b/tests/Input/res/raw/input_port_associations.xml
@@ -0,0 +1,4 @@
+<ports>
+ <port display="0" input="USB1" />
+ <port display="1" input="USB2" />
+</ports>
\ No newline at end of file
diff --git a/tests/Input/res/raw/input_port_associations_bad_displayport.xml b/tests/Input/res/raw/input_port_associations_bad_displayport.xml
new file mode 100644
index 0000000..8eeb1f5
--- /dev/null
+++ b/tests/Input/res/raw/input_port_associations_bad_displayport.xml
@@ -0,0 +1,3 @@
+<ports>
+ <port display="a" input="USB1" />
+</ports>
\ No newline at end of file
diff --git a/tests/Input/res/raw/input_port_associations_bad_xml.xml b/tests/Input/res/raw/input_port_associations_bad_xml.xml
new file mode 100644
index 0000000..cf6e124
--- /dev/null
+++ b/tests/Input/res/raw/input_port_associations_bad_xml.xml
@@ -0,0 +1,3 @@
+<ports>
+ <port Garbage data inside xml>
+</ports>
\ No newline at end of file
diff --git a/tests/Input/res/xml/keyboard_layouts.xml b/tests/Input/res/xml/keyboard_layouts.xml
new file mode 100644
index 0000000..5f3fcd6
--- /dev/null
+++ b/tests/Input/res/xml/keyboard_layouts.xml
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2023 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.
+ -->
+
+<keyboard-layouts xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
+ <keyboard-layout
+ android:name="keyboard_layout_english_uk"
+ android:label="English (UK)"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="en-Latn-GB"
+ android:keyboardLayoutType="qwerty" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_english_us"
+ android:label="English (US)"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="en-Latn,en-Latn-US"
+ android:keyboardLayoutType="qwerty" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_english_us_intl"
+ android:label="English (International)"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="en-Latn-US"
+ android:keyboardLayoutType="extended" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_english_us_dvorak"
+ android:label="English (Dvorak)"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="en-Latn-US"
+ android:keyboardLayoutType="dvorak" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_german"
+ android:label="German"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="de-Latn"
+ android:keyboardLayoutType="qwertz" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_french"
+ android:label="French"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="fr-Latn-FR"
+ android:keyboardLayoutType="azerty" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_russian_qwerty"
+ android:label="Russian"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="ru-Cyrl"
+ android:keyboardLayoutType="qwerty" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_russian"
+ android:label="Russian"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="ru-Cyrl" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_english_without_script_code"
+ android:label="English(No script code)"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ android:keyboardLocale="en"
+ android:keyboardLayoutType="qwerty" />
+
+ <keyboard-layout
+ android:name="keyboard_layout_vendorId:1,productId:1"
+ android:label="vendorId:1,productId:1"
+ android:keyboardLayout="@raw/dummy_keyboard_layout"
+ androidprv:vendorId="1"
+ androidprv:productId="1" />
+
+</keyboard-layouts>
diff --git a/tests/Input/src/android/hardware/input/InputDeviceBatteryListenerTest.kt b/tests/Input/src/android/hardware/input/InputDeviceBatteryListenerTest.kt
new file mode 100644
index 0000000..b09bf64
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/InputDeviceBatteryListenerTest.kt
@@ -0,0 +1,243 @@
+/*
+ * 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 android.hardware.input
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.hardware.BatteryState
+import android.os.Handler
+import android.os.HandlerExecutor
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import androidx.test.core.app.ApplicationProvider
+import com.android.server.testutils.any
+import java.util.concurrent.Executor
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoJUnitRunner
+
+/**
+ * Tests for [InputManager.InputDeviceBatteryListener].
+ *
+ * Build/Install/Run:
+ * atest InputTests:InputDeviceBatteryListenerTest
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner::class)
+class InputDeviceBatteryListenerTest {
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ private lateinit var testLooper: TestLooper
+ private var registeredListener: IInputDeviceBatteryListener? = null
+ private val monitoredDevices = mutableListOf<Int>()
+ private lateinit var executor: Executor
+ private lateinit var context: Context
+ private lateinit var inputManager: InputManager
+
+ @Mock
+ private lateinit var iInputManagerMock: IInputManager
+
+ @Before
+ fun setUp() {
+ context = Mockito.spy(ContextWrapper(ApplicationProvider.getApplicationContext()))
+ testLooper = TestLooper()
+ executor = HandlerExecutor(Handler(testLooper.looper))
+ registeredListener = null
+ monitoredDevices.clear()
+ InputManagerGlobal.resetInstance(iInputManagerMock)
+ inputManager = InputManager(context)
+ `when`(context.getSystemService(Mockito.eq(Context.INPUT_SERVICE)))
+ .thenReturn(inputManager)
+
+ // Handle battery listener registration.
+ doAnswer {
+ val deviceId = it.getArgument(0) as Int
+ val listener = it.getArgument(1) as IInputDeviceBatteryListener
+ if (registeredListener != null &&
+ registeredListener!!.asBinder() != listener.asBinder()) {
+ // There can only be one registered battery listener per process.
+ fail("Trying to register a new listener when one already exists")
+ }
+ if (monitoredDevices.contains(deviceId)) {
+ fail("Trying to start monitoring a device that was already being monitored")
+ }
+ monitoredDevices.add(deviceId)
+ registeredListener = listener
+ null
+ }.`when`(iInputManagerMock).registerBatteryListener(anyInt(), any())
+
+ // Handle battery listener being unregistered.
+ doAnswer {
+ val deviceId = it.getArgument(0) as Int
+ val listener = it.getArgument(1) as IInputDeviceBatteryListener
+ if (registeredListener == null ||
+ registeredListener!!.asBinder() != listener.asBinder()) {
+ fail("Trying to unregister a listener that is not registered")
+ }
+ if (!monitoredDevices.remove(deviceId)) {
+ fail("Trying to stop monitoring a device that is not being monitored")
+ }
+ if (monitoredDevices.isEmpty()) {
+ registeredListener = null
+ }
+ }.`when`(iInputManagerMock).unregisterBatteryListener(anyInt(), any())
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ private fun notifyBatteryStateChanged(
+ deviceId: Int,
+ isPresent: Boolean = true,
+ status: Int = BatteryState.STATUS_FULL,
+ capacity: Float = 1.0f,
+ eventTime: Long = 12345L
+ ) {
+ registeredListener!!.onBatteryStateChanged(IInputDeviceBatteryState().apply {
+ this.deviceId = deviceId
+ this.updateTime = eventTime
+ this.isPresent = isPresent
+ this.status = status
+ this.capacity = capacity
+ })
+ }
+
+ @Test
+ fun testListenerIsNotifiedCorrectly() {
+ var callbackCount = 0
+
+ // Add a battery listener to monitor battery changes.
+ inputManager.addInputDeviceBatteryListener(1 /*deviceId*/, executor) {
+ deviceId: Int, eventTime: Long, batteryState: BatteryState ->
+ callbackCount++
+ assertEquals(1, deviceId)
+ assertEquals(true, batteryState.isPresent)
+ assertEquals(BatteryState.STATUS_DISCHARGING, batteryState.status)
+ assertEquals(0.5f, batteryState.capacity)
+ assertEquals(8675309L, eventTime)
+ }
+
+ // Adding the listener should register the callback with InputManagerService.
+ assertNotNull(registeredListener)
+ assertTrue(monitoredDevices.contains(1))
+
+ // Notifying battery change for a different device should not trigger the listener.
+ notifyBatteryStateChanged(deviceId = 2)
+ testLooper.dispatchAll()
+ assertEquals(0, callbackCount)
+
+ // Notifying battery change for the registered device will notify the listener.
+ notifyBatteryStateChanged(1 /*deviceId*/, true /*isPresent*/,
+ BatteryState.STATUS_DISCHARGING, 0.5f /*capacity*/, 8675309L /*eventTime*/)
+ testLooper.dispatchNext()
+ assertEquals(1, callbackCount)
+ }
+
+ @Test
+ fun testMultipleListeners() {
+ // Set up two callbacks.
+ var callbackCount1 = 0
+ var callbackCount2 = 0
+ val callback1 = InputManager.InputDeviceBatteryListener { _, _, _ -> callbackCount1++ }
+ val callback2 = InputManager.InputDeviceBatteryListener { _, _, _ -> callbackCount2++ }
+
+ // Monitor battery changes for three devices. The first callback monitors devices 1 and 3,
+ // while the second callback monitors devices 2 and 3.
+ inputManager.addInputDeviceBatteryListener(1 /*deviceId*/, executor, callback1)
+ assertEquals(1, monitoredDevices.size)
+ inputManager.addInputDeviceBatteryListener(2 /*deviceId*/, executor, callback2)
+ assertEquals(2, monitoredDevices.size)
+ inputManager.addInputDeviceBatteryListener(3 /*deviceId*/, executor, callback1)
+ assertEquals(3, monitoredDevices.size)
+ inputManager.addInputDeviceBatteryListener(3 /*deviceId*/, executor, callback2)
+ assertEquals(3, monitoredDevices.size)
+
+ // Notifying battery change for each of the devices should trigger the registered callbacks.
+ notifyBatteryStateChanged(deviceId = 1)
+ testLooper.dispatchNext()
+ assertEquals(1, callbackCount1)
+ assertEquals(0, callbackCount2)
+
+ notifyBatteryStateChanged(deviceId = 2)
+ testLooper.dispatchNext()
+ assertEquals(1, callbackCount1)
+ assertEquals(1, callbackCount2)
+
+ notifyBatteryStateChanged(deviceId = 3)
+ testLooper.dispatchNext()
+ testLooper.dispatchNext()
+ assertEquals(2, callbackCount1)
+ assertEquals(2, callbackCount2)
+
+ // Stop monitoring devices 1 and 2.
+ inputManager.removeInputDeviceBatteryListener(1 /*deviceId*/, callback1)
+ assertEquals(2, monitoredDevices.size)
+ inputManager.removeInputDeviceBatteryListener(2 /*deviceId*/, callback2)
+ assertEquals(1, monitoredDevices.size)
+
+ // Ensure device 3 continues to be monitored.
+ notifyBatteryStateChanged(deviceId = 3)
+ testLooper.dispatchNext()
+ testLooper.dispatchNext()
+ assertEquals(3, callbackCount1)
+ assertEquals(3, callbackCount2)
+
+ // Stop monitoring all devices.
+ inputManager.removeInputDeviceBatteryListener(3 /*deviceId*/, callback1)
+ assertEquals(1, monitoredDevices.size)
+ inputManager.removeInputDeviceBatteryListener(3 /*deviceId*/, callback2)
+ assertEquals(0, monitoredDevices.size)
+ }
+
+ @Test
+ fun testAdditionalListenersNotifiedImmediately() {
+ var callbackCount1 = 0
+ var callbackCount2 = 0
+ val callback1 = InputManager.InputDeviceBatteryListener { _, _, _ -> callbackCount1++ }
+ val callback2 = InputManager.InputDeviceBatteryListener { _, _, _ -> callbackCount2++ }
+
+ // Add a battery listener and send the latest battery state.
+ inputManager.addInputDeviceBatteryListener(1 /*deviceId*/, executor, callback1)
+ assertEquals(1, monitoredDevices.size)
+ notifyBatteryStateChanged(deviceId = 1)
+ testLooper.dispatchNext()
+ assertEquals(1, callbackCount1)
+
+ // Add a second listener for the same device that already has the latest battery state.
+ inputManager.addInputDeviceBatteryListener(1 /*deviceId*/, executor, callback2)
+ assertEquals(1, monitoredDevices.size)
+
+ // Ensure that this listener is notified immediately.
+ testLooper.dispatchNext()
+ assertEquals(1, callbackCount2)
+ }
+}
diff --git a/tests/Input/src/android/hardware/input/InputDeviceLightsManagerTest.java b/tests/Input/src/android/hardware/input/InputDeviceLightsManagerTest.java
new file mode 100644
index 0000000..e2bae6f
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/InputDeviceLightsManagerTest.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2021 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 android.hardware.input;
+
+import static android.hardware.lights.LightsRequest.Builder;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static junit.framework.TestCase.assertEquals;
+import static junit.framework.TestCase.assertNotNull;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.hardware.lights.Light;
+import android.hardware.lights.LightState;
+import android.hardware.lights.LightsManager;
+import android.hardware.lights.LightsRequest;
+import android.os.IBinder;
+import android.platform.test.annotations.Presubmit;
+import android.util.ArrayMap;
+import android.view.InputDevice;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.junit.MockitoRule;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Tests for {@link InputDeviceLightsManager}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:InputDeviceLightsManagerTest
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner.class)
+public class InputDeviceLightsManagerTest {
+ private static final String TAG = "InputDeviceLightsManagerTest";
+
+ private static final int DEVICE_ID = 1000;
+ private static final int PLAYER_ID = 3;
+
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+ private InputManager mInputManager;
+
+ @Mock private IInputManager mIInputManagerMock;
+
+ @Before
+ public void setUp() throws Exception {
+ final Context context = spy(
+ new ContextWrapper(InstrumentationRegistry.getInstrumentation().getContext()));
+ when(mIInputManagerMock.getInputDeviceIds()).thenReturn(new int[]{DEVICE_ID});
+
+ when(mIInputManagerMock.getInputDevice(eq(DEVICE_ID))).thenReturn(
+ createInputDevice(DEVICE_ID));
+
+ InputManagerGlobal.resetInstance(mIInputManagerMock);
+ mInputManager = new InputManager(context);
+ when(context.getSystemService(eq(Context.INPUT_SERVICE))).thenReturn(mInputManager);
+
+ ArrayMap<Integer, LightState> lightStatesById = new ArrayMap<>();
+ doAnswer(invocation -> {
+ final int[] lightIds = (int[]) invocation.getArguments()[1];
+ final LightState[] lightStates =
+ (LightState[]) invocation.getArguments()[2];
+ for (int i = 0; i < lightIds.length; i++) {
+ lightStatesById.put(lightIds[i], lightStates[i]);
+ }
+ return null;
+ }).when(mIInputManagerMock).setLightStates(eq(DEVICE_ID),
+ any(int[].class), any(LightState[].class), any(IBinder.class));
+
+ doAnswer(invocation -> {
+ int lightId = (int) invocation.getArguments()[1];
+ if (lightStatesById.containsKey(lightId)) {
+ return lightStatesById.get(lightId);
+ }
+ return new LightState(0);
+ }).when(mIInputManagerMock).getLightState(eq(DEVICE_ID), anyInt());
+ }
+
+ @After
+ public void tearDown() {
+ InputManagerGlobal.clearInstance();
+ }
+
+ private InputDevice createInputDevice(int id) {
+ return new InputDevice.Builder()
+ .setId(id)
+ .setName("Test Device " + id)
+ .build();
+ }
+
+ private void mockLights(Light[] lights) throws Exception {
+ // Mock the Lights returned form InputManagerService
+ when(mIInputManagerMock.getLights(eq(DEVICE_ID))).thenReturn(
+ new ArrayList(Arrays.asList(lights)));
+ }
+
+ @Test
+ public void testGetInputDeviceLights() throws Exception {
+ InputDevice device = mInputManager.getInputDevice(DEVICE_ID);
+ assertNotNull(device);
+
+ Light[] mockedLights = {
+ new Light(1 /* id */, "Light1", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_BRIGHTNESS),
+ new Light(2 /* id */, "Light2", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB),
+ new Light(3 /* id */, "Light3", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ 0 /* capabilities */)
+ };
+ mockLights(mockedLights);
+
+ LightsManager lightsManager = device.getLightsManager();
+ List<Light> lights = lightsManager.getLights();
+ verify(mIInputManagerMock).getLights(eq(DEVICE_ID));
+ assertEquals(lights, Arrays.asList(mockedLights));
+ }
+
+ @Test
+ public void testControlMultipleLights() throws Exception {
+ InputDevice device = mInputManager.getInputDevice(DEVICE_ID);
+ assertNotNull(device);
+
+ Light[] mockedLights = {
+ new Light(1 /* id */, "Light1", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB),
+ new Light(2 /* id */, "Light2", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB),
+ new Light(3 /* id */, "Light3", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB),
+ new Light(4 /* id */, "Light4", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB)
+ };
+ mockLights(mockedLights);
+
+ LightsManager lightsManager = device.getLightsManager();
+ List<Light> lightList = lightsManager.getLights();
+ LightState[] states = new LightState[]{new LightState(0xf1), new LightState(0xf2),
+ new LightState(0xf3)};
+ // Open a session to request turn 3/4 lights on:
+ LightsManager.LightsSession session = lightsManager.openSession();
+ session.requestLights(new Builder()
+ .addLight(lightsManager.getLights().get(0), states[0])
+ .addLight(lightsManager.getLights().get(1), states[1])
+ .addLight(lightsManager.getLights().get(2), states[2])
+ .build());
+ IBinder token = session.getToken();
+
+ verify(mIInputManagerMock).openLightSession(eq(DEVICE_ID),
+ any(String.class), eq(token));
+ verify(mIInputManagerMock).setLightStates(eq(DEVICE_ID), eq(new int[]{1, 2, 3}),
+ eq(states), eq(token));
+
+ // Then all 3 should turn on.
+ assertThat(lightsManager.getLightState(lightsManager.getLights().get(0)).getColor())
+ .isEqualTo(0xf1);
+ assertThat(lightsManager.getLightState(lightsManager.getLights().get(1)).getColor())
+ .isEqualTo(0xf2);
+ assertThat(lightsManager.getLightState(lightsManager.getLights().get(2)).getColor())
+ .isEqualTo(0xf3);
+
+ // And the 4th should remain off.
+ assertThat(lightsManager.getLightState(lightsManager.getLights().get(3)).getColor())
+ .isEqualTo(0x00);
+
+ // close session
+ session.close();
+ verify(mIInputManagerMock).closeLightSession(eq(DEVICE_ID), eq(token));
+ }
+
+ @Test
+ public void testControlPlayerIdLight() throws Exception {
+ InputDevice device = mInputManager.getInputDevice(DEVICE_ID);
+ assertNotNull(device);
+
+ Light[] mockedLights = {
+ new Light(1 /* id */, "Light1", 0 /* ordinal */, Light.LIGHT_TYPE_PLAYER_ID,
+ 0 /* capabilities */),
+ new Light(2 /* id */, "Light2", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB | Light.LIGHT_CAPABILITY_BRIGHTNESS),
+ new Light(3 /* id */, "Light3", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_BRIGHTNESS)
+ };
+ mockLights(mockedLights);
+
+ LightsManager lightsManager = device.getLightsManager();
+ List<Light> lightList = lightsManager.getLights();
+ LightState[] states = new LightState[]{new LightState(0xf1, PLAYER_ID)};
+ // Open a session to request set Player ID light:
+ LightsManager.LightsSession session = lightsManager.openSession();
+ session.requestLights(new Builder()
+ .addLight(lightsManager.getLights().get(0), states[0])
+ .build());
+ IBinder token = session.getToken();
+
+ verify(mIInputManagerMock).openLightSession(eq(DEVICE_ID),
+ any(String.class), eq(token));
+ verify(mIInputManagerMock).setLightStates(eq(DEVICE_ID), eq(new int[]{1}),
+ eq(states), eq(token));
+
+ // Verify the light state
+ assertThat(lightsManager.getLightState(lightsManager.getLights().get(0)).getColor())
+ .isEqualTo(0xf1);
+ assertThat(lightsManager.getLightState(lightsManager.getLights().get(0)).getPlayerId())
+ .isEqualTo(PLAYER_ID);
+
+ // close session
+ session.close();
+ verify(mIInputManagerMock).closeLightSession(eq(DEVICE_ID), eq(token));
+ }
+
+ @Test
+ public void testLightCapabilities() throws Exception {
+ Light light = new Light(1 /* id */, "Light1", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ Light.LIGHT_CAPABILITY_COLOR_RGB | Light.LIGHT_CAPABILITY_BRIGHTNESS);
+ assertThat(light.getType()).isEqualTo(Light.LIGHT_TYPE_INPUT);
+ assertThat(light.getCapabilities()).isEqualTo(Light.LIGHT_CAPABILITY_COLOR_RGB
+ | Light.LIGHT_CAPABILITY_BRIGHTNESS);
+ assertTrue(light.hasBrightnessControl());
+ assertTrue(light.hasRgbControl());
+ }
+
+ @Test
+ public void testLightsRequest() throws Exception {
+ Light light1 = new Light(1 /* id */, "Light1", 0 /* ordinal */, Light.LIGHT_TYPE_INPUT,
+ 0 /* capabilities */);
+ Light light2 = new Light(2 /* id */, "Light2", 0 /* ordinal */, Light.LIGHT_TYPE_PLAYER_ID,
+ 0 /* capabilities */);
+ LightState state1 = new LightState(0xf1);
+ LightState state2 = new LightState(0xf2, PLAYER_ID);
+ LightsRequest request = new Builder().addLight(light1, state1)
+ .addLight(light2, state2).build();
+
+ // Covers the LightsRequest.getLights
+ assertThat(request.getLights().size()).isEqualTo(2);
+ assertThat(request.getLights().get(0)).isEqualTo(1);
+ assertThat(request.getLights().get(1)).isEqualTo(2);
+
+ // Covers the LightsRequest.getLightStates
+ assertThat(request.getLightStates().size()).isEqualTo(2);
+ assertThat(request.getLightStates().get(0)).isEqualTo(state1);
+ assertThat(request.getLightStates().get(1)).isEqualTo(state2);
+
+ // Covers the LightsRequest.getLightsAndStates
+ assertThat(request.getLightsAndStates().size()).isEqualTo(2);
+ assertThat(request.getLightsAndStates().containsKey(light1)).isTrue();
+ assertThat(request.getLightsAndStates().get(light1)).isEqualTo(state1);
+ assertThat(request.getLightsAndStates().get(light2)).isEqualTo(state2);
+ }
+
+}
diff --git a/tests/Input/src/android/hardware/input/InputDeviceSensorManagerTest.java b/tests/Input/src/android/hardware/input/InputDeviceSensorManagerTest.java
new file mode 100644
index 0000000..17aff50
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/InputDeviceSensorManagerTest.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2020 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 android.hardware.input;
+
+import static junit.framework.TestCase.assertEquals;
+import static junit.framework.TestCase.assertNotNull;
+import static junit.framework.TestCase.assertTrue;
+import static junit.framework.TestCase.fail;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.platform.test.annotations.Presubmit;
+import android.view.InputDevice;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.internal.annotations.GuardedBy;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.junit.MockitoRule;
+
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Tests for {@link InputDeviceSensorManager}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:InputDeviceSensorManagerTest
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner.class)
+public class InputDeviceSensorManagerTest {
+ private static final String TAG = "InputDeviceSensorManagerTest";
+
+ private static final int DEVICE_ID = 1000;
+
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+ private InputManager mInputManager;
+ private IInputSensorEventListener mIInputSensorEventListener;
+ private final Object mLock = new Object();
+
+ @Mock private IInputManager mIInputManagerMock;
+
+ @Before
+ public void setUp() throws Exception {
+ final Context context = spy(
+ new ContextWrapper(InstrumentationRegistry.getInstrumentation().getContext()));
+ InputManagerGlobal.resetInstance(mIInputManagerMock);
+ mInputManager = new InputManager(context);
+ when(context.getSystemService(eq(Context.INPUT_SERVICE))).thenReturn(mInputManager);
+
+ when(mIInputManagerMock.getInputDeviceIds()).thenReturn(new int[]{DEVICE_ID});
+
+ when(mIInputManagerMock.getInputDevice(eq(DEVICE_ID))).thenReturn(
+ createInputDeviceWithSensor(DEVICE_ID));
+
+ when(mIInputManagerMock.getSensorList(eq(DEVICE_ID))).thenReturn(new InputSensorInfo[] {
+ createInputSensorInfo(DEVICE_ID, Sensor.TYPE_ACCELEROMETER),
+ createInputSensorInfo(DEVICE_ID, Sensor.TYPE_GYROSCOPE)});
+
+ when(mIInputManagerMock.enableSensor(eq(DEVICE_ID), anyInt(), anyInt(), anyInt()))
+ .thenReturn(true);
+
+ when(mIInputManagerMock.registerSensorListener(any())).thenReturn(true);
+ }
+
+ @After
+ public void tearDown() {
+ InputManagerGlobal.clearInstance();
+ }
+
+ private class InputTestSensorEventListener implements SensorEventListener {
+ @GuardedBy("mLock")
+ private final BlockingQueue<SensorEvent> mEvents = new LinkedBlockingQueue<>();
+ InputTestSensorEventListener() {
+ super();
+ }
+
+ public SensorEvent waitForSensorEvent() {
+ try {
+ return mEvents.poll(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ fail("unexpectedly interrupted while waiting for SensorEvent");
+ return null;
+ }
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ synchronized (mLock) {
+ try {
+ mEvents.put(event);
+ } catch (InterruptedException ex) {
+ fail("interrupted while adding a SensorEvent to the queue");
+ }
+ }
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ }
+ }
+
+ private InputDevice createInputDeviceWithSensor(int id) {
+ return new InputDevice.Builder()
+ .setId(id)
+ .setName("Test Device " + id)
+ .setHasSensor(true)
+ .build();
+ }
+
+ private InputSensorInfo createInputSensorInfo(int id, int type) {
+ InputSensorInfo info = new InputSensorInfo("name", "vendor", 0 /* version */,
+ 0 /* handle */, type, 100.0f /*maxRange */, 0.02f /* resolution */,
+ 0.8f /* power */, 1000 /* minDelay */, 0 /* fifoReservedEventCount */,
+ 0 /* fifoMaxEventCount */, "" /* stringType */, "" /* requiredPermission */,
+ 0 /* maxDelay */, 0 /* flags */, id);
+ return info;
+ }
+
+ private InputDevice getSensorDevice(int[] deviceIds) {
+ for (int deviceId : deviceIds) {
+ InputDevice device = mInputManager.getInputDevice(deviceId);
+ if (device.hasSensor()) {
+ return device;
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void getInputDeviceSensors_withExpectedType() throws Exception {
+ InputDevice device = getSensorDevice(mInputManager.getInputDeviceIds());
+ assertNotNull(device);
+
+ SensorManager sensorManager = device.getSensorManager();
+ List<Sensor> accelList = sensorManager.getSensorList(Sensor.TYPE_ACCELEROMETER);
+ verify(mIInputManagerMock).getSensorList(eq(DEVICE_ID));
+ assertEquals(1, accelList.size());
+ assertEquals(DEVICE_ID, accelList.get(0).getId());
+ assertEquals(Sensor.TYPE_ACCELEROMETER, accelList.get(0).getType());
+
+ List<Sensor> gyroList = sensorManager.getSensorList(Sensor.TYPE_GYROSCOPE);
+ verify(mIInputManagerMock).getSensorList(eq(DEVICE_ID));
+ assertEquals(1, gyroList.size());
+ assertEquals(DEVICE_ID, gyroList.get(0).getId());
+ assertEquals(Sensor.TYPE_GYROSCOPE, gyroList.get(0).getType());
+
+ }
+
+ @Test
+ public void getInputDeviceSensors_withUnexpectedType() throws Exception {
+ InputDevice device = getSensorDevice(mInputManager.getInputDeviceIds());
+
+ assertNotNull(device);
+ SensorManager sensorManager = device.getSensorManager();
+
+ List<Sensor> gameRotationList = sensorManager.getSensorList(
+ Sensor.TYPE_GAME_ROTATION_VECTOR);
+ verify(mIInputManagerMock).getSensorList(eq(DEVICE_ID));
+ assertEquals(0, gameRotationList.size());
+
+ List<Sensor> gravityList = sensorManager.getSensorList(Sensor.TYPE_GRAVITY);
+ verify(mIInputManagerMock).getSensorList(eq(DEVICE_ID));
+ assertEquals(0, gravityList.size());
+ }
+
+ @Test
+ public void testInputDeviceSensorListener() throws Exception {
+ InputDevice device = getSensorDevice(mInputManager.getInputDeviceIds());
+ assertNotNull(device);
+
+ SensorManager sensorManager = device.getSensorManager();
+ Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ assertEquals(Sensor.TYPE_ACCELEROMETER, sensor.getType());
+
+ doAnswer(invocation -> {
+ mIInputSensorEventListener = invocation.getArgument(0);
+ assertNotNull(mIInputSensorEventListener);
+ return true;
+ }).when(mIInputManagerMock).registerSensorListener(any());
+
+ InputTestSensorEventListener listener = new InputTestSensorEventListener();
+ assertTrue(sensorManager.registerListener(listener, sensor,
+ SensorManager.SENSOR_DELAY_NORMAL));
+ verify(mIInputManagerMock).registerSensorListener(any());
+ verify(mIInputManagerMock).enableSensor(eq(DEVICE_ID), eq(sensor.getType()),
+ anyInt(), anyInt());
+
+ float[] values = new float[] {0.12f, 9.8f, 0.2f};
+ mIInputSensorEventListener.onInputSensorChanged(DEVICE_ID, Sensor.TYPE_ACCELEROMETER,
+ SensorManager.SENSOR_STATUS_ACCURACY_HIGH, /* timestamp */ 0x1234abcd, values);
+
+ SensorEvent event = listener.waitForSensorEvent();
+ assertNotNull(event);
+ assertEquals(0x1234abcd, event.timestamp);
+ assertEquals(values.length, event.values.length);
+ for (int i = 0; i < values.length; i++) {
+ assertEquals(values[i], event.values[i], 0.001f);
+ }
+
+ sensorManager.unregisterListener(listener);
+ verify(mIInputManagerMock).disableSensor(eq(DEVICE_ID), eq(sensor.getType()));
+ }
+
+}
diff --git a/tests/Input/src/android/hardware/input/InputManagerTest.kt b/tests/Input/src/android/hardware/input/InputManagerTest.kt
new file mode 100644
index 0000000..f3f4f61
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/InputManagerTest.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2023 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 android.hardware.input
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Resources
+import android.platform.test.annotations.Presubmit
+import android.view.Display
+import android.view.DisplayInfo
+import android.view.InputDevice
+import androidx.test.core.app.ApplicationProvider
+import org.junit.After
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoJUnitRunner
+
+/**
+ * Tests for [InputManager].
+ *
+ * Build/Install/Run:
+ * atest InputTests:InputManagerTest
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner::class)
+class InputManagerTest {
+
+ companion object {
+ const val DEVICE_ID = 42
+ const val SECOND_DEVICE_ID = 96
+ const val THIRD_DEVICE_ID = 99
+ }
+
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ private lateinit var devicesChangedListener: IInputDevicesChangedListener
+ private val deviceGenerationMap = mutableMapOf<Int /*deviceId*/, Int /*generation*/>()
+ private lateinit var context: Context
+ private lateinit var inputManager: InputManager
+
+ @Mock
+ private lateinit var iInputManager: IInputManager
+
+ @Before
+ fun setUp() {
+ context = Mockito.spy(ContextWrapper(ApplicationProvider.getApplicationContext()))
+ InputManagerGlobal.resetInstance(iInputManager)
+ inputManager = InputManager(context)
+ `when`(context.getSystemService(eq(Context.INPUT_SERVICE))).thenReturn(inputManager)
+ `when`(iInputManager.inputDeviceIds).then {
+ deviceGenerationMap.keys.toIntArray()
+ }
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ private fun notifyDeviceChanged(
+ deviceId: Int,
+ associatedDisplayId: Int,
+ usiVersion: HostUsiVersion?,
+ ) {
+ val generation = deviceGenerationMap[deviceId]?.plus(1)
+ ?: throw IllegalArgumentException("Device $deviceId was never added!")
+ deviceGenerationMap[deviceId] = generation
+
+ `when`(iInputManager.getInputDevice(deviceId))
+ .thenReturn(createInputDevice(deviceId, associatedDisplayId, usiVersion, generation))
+ val list = deviceGenerationMap.flatMap { listOf(it.key, it.value) }
+ if (::devicesChangedListener.isInitialized) {
+ devicesChangedListener.onInputDevicesChanged(list.toIntArray())
+ }
+ }
+
+ private fun addInputDevice(
+ deviceId: Int,
+ associatedDisplayId: Int,
+ usiVersion: HostUsiVersion?,
+ ) {
+ deviceGenerationMap[deviceId] = 0
+ notifyDeviceChanged(deviceId, associatedDisplayId, usiVersion)
+ }
+
+ @Test
+ fun testUsiVersionDisplayAssociation() {
+ addInputDevice(DEVICE_ID, Display.DEFAULT_DISPLAY, null)
+ addInputDevice(SECOND_DEVICE_ID, Display.INVALID_DISPLAY, HostUsiVersion(9, 8))
+ addInputDevice(THIRD_DEVICE_ID, 42, HostUsiVersion(3, 1))
+
+ val usiVersion = inputManager.getHostUsiVersion(createDisplay(42))
+ assertNotNull(usiVersion)
+ assertEquals(3, usiVersion!!.majorVersion)
+ assertEquals(1, usiVersion.minorVersion)
+ }
+
+ @Test
+ fun testUsiVersionFallBackToDisplayConfig() {
+ addInputDevice(DEVICE_ID, Display.DEFAULT_DISPLAY, null)
+
+ `when`(iInputManager.getHostUsiVersionFromDisplayConfig(eq(42)))
+ .thenReturn(HostUsiVersion(9, 8))
+ val usiVersion = inputManager.getHostUsiVersion(createDisplay(42))
+ assertEquals(HostUsiVersion(9, 8), usiVersion)
+ }
+}
+
+private fun createInputDevice(
+ deviceId: Int,
+ associatedDisplayId: Int,
+ usiVersion: HostUsiVersion? = null,
+ generation: Int = -1,
+): InputDevice =
+ InputDevice.Builder()
+ .setId(deviceId)
+ .setName("Device $deviceId")
+ .setDescriptor("descriptor $deviceId")
+ .setAssociatedDisplayId(associatedDisplayId)
+ .setUsiVersion(usiVersion)
+ .setGeneration(generation)
+ .build()
+
+private fun createDisplay(displayId: Int): Display {
+ val res: Resources? = null
+ return Display(null /* global */, displayId, DisplayInfo(), res)
+}
diff --git a/tests/Input/src/android/hardware/input/KeyboardBacklightListenerTest.kt b/tests/Input/src/android/hardware/input/KeyboardBacklightListenerTest.kt
new file mode 100644
index 0000000..1b564cf
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/KeyboardBacklightListenerTest.kt
@@ -0,0 +1,173 @@
+/*
+ * 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 android.hardware.input
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Handler
+import android.os.HandlerExecutor
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import androidx.test.core.app.ApplicationProvider
+import com.android.server.testutils.any
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoJUnitRunner
+import java.util.concurrent.Executor
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.fail
+
+/**
+ * Tests for [InputManager.KeyboardBacklightListener].
+ *
+ * Build/Install/Run:
+ * atest InputTests:KeyboardBacklightListenerTest
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner::class)
+class KeyboardBacklightListenerTest {
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ private lateinit var testLooper: TestLooper
+ private var registeredListener: IKeyboardBacklightListener? = null
+ private lateinit var executor: Executor
+ private lateinit var context: Context
+ private lateinit var inputManager: InputManager
+
+ @Mock
+ private lateinit var iInputManagerMock: IInputManager
+
+ @Before
+ fun setUp() {
+ context = Mockito.spy(ContextWrapper(ApplicationProvider.getApplicationContext()))
+ InputManagerGlobal.resetInstance(iInputManagerMock)
+ testLooper = TestLooper()
+ executor = HandlerExecutor(Handler(testLooper.looper))
+ registeredListener = null
+ inputManager = InputManager(context)
+ `when`(context.getSystemService(Mockito.eq(Context.INPUT_SERVICE)))
+ .thenReturn(inputManager)
+
+ // Handle keyboard backlight listener registration.
+ doAnswer {
+ val listener = it.getArgument(0) as IKeyboardBacklightListener
+ if (registeredListener != null &&
+ registeredListener!!.asBinder() != listener.asBinder()) {
+ // There can only be one registered keyboard backlight listener per process.
+ fail("Trying to register a new listener when one already exists")
+ }
+ registeredListener = listener
+ null
+ }.`when`(iInputManagerMock).registerKeyboardBacklightListener(any())
+
+ // Handle keyboard backlight listener being unregistered.
+ doAnswer {
+ val listener = it.getArgument(0) as IKeyboardBacklightListener
+ if (registeredListener == null ||
+ registeredListener!!.asBinder() != listener.asBinder()) {
+ fail("Trying to unregister a listener that is not registered")
+ }
+ registeredListener = null
+ null
+ }.`when`(iInputManagerMock).unregisterKeyboardBacklightListener(any())
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ private fun notifyKeyboardBacklightChanged(
+ deviceId: Int,
+ brightnessLevel: Int,
+ maxBrightnessLevel: Int = 10,
+ isTriggeredByKeyPress: Boolean = true
+ ) {
+ registeredListener!!.onBrightnessChanged(deviceId, IKeyboardBacklightState().apply {
+ this.brightnessLevel = brightnessLevel
+ this.maxBrightnessLevel = maxBrightnessLevel
+ }, isTriggeredByKeyPress)
+ }
+
+ @Test
+ fun testListenerIsNotifiedCorrectly() {
+ var callbackCount = 0
+
+ // Add a keyboard backlight listener
+ inputManager.registerKeyboardBacklightListener(executor) {
+ deviceId: Int,
+ keyboardBacklightState: KeyboardBacklightState,
+ isTriggeredByKeyPress: Boolean ->
+ callbackCount++
+ assertEquals(1, deviceId)
+ assertEquals(2, keyboardBacklightState.brightnessLevel)
+ assertEquals(10, keyboardBacklightState.maxBrightnessLevel)
+ assertEquals(true, isTriggeredByKeyPress)
+ }
+
+ // Adding the listener should register the callback with InputManagerService.
+ assertNotNull(registeredListener)
+
+ // Notifying keyboard backlight change will notify the listener.
+ notifyKeyboardBacklightChanged(1 /*deviceId*/, 2 /* brightnessLevel */)
+ testLooper.dispatchNext()
+ assertEquals(1, callbackCount)
+ }
+
+ @Test
+ fun testMultipleListeners() {
+ // Set up two callbacks.
+ var callbackCount1 = 0
+ var callbackCount2 = 0
+ val callback1 = InputManager.KeyboardBacklightListener { _, _, _ -> callbackCount1++ }
+ val callback2 = InputManager.KeyboardBacklightListener { _, _, _ -> callbackCount2++ }
+
+ // Add both keyboard backlight listeners
+ inputManager.registerKeyboardBacklightListener(executor, callback1)
+ inputManager.registerKeyboardBacklightListener(executor, callback2)
+
+ // Adding the listeners should register the callback with InputManagerService.
+ assertNotNull(registeredListener)
+
+ // Notifying keyboard backlight change trigger the both callbacks.
+ notifyKeyboardBacklightChanged(1 /*deviceId*/, 1 /* brightnessLevel */)
+ testLooper.dispatchAll()
+ assertEquals(1, callbackCount1)
+ assertEquals(1, callbackCount2)
+
+ inputManager.unregisterKeyboardBacklightListener(callback2)
+ // Notifying keyboard backlight change should still trigger callback1.
+ notifyKeyboardBacklightChanged(1 /*deviceId*/, 2 /* brightnessLevel */)
+ testLooper.dispatchAll()
+ assertEquals(2, callbackCount1)
+
+ // Unregister all listeners, should remove registered listener from InputManagerService
+ inputManager.unregisterKeyboardBacklightListener(callback1)
+ assertNull(registeredListener)
+ }
+}
diff --git a/tests/Input/src/android/hardware/input/VirtualKeyEventTest.java b/tests/Input/src/android/hardware/input/VirtualKeyEventTest.java
new file mode 100644
index 0000000..37cc9b7
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/VirtualKeyEventTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2021 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 android.hardware.input;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.testng.Assert.assertThrows;
+
+import android.view.KeyEvent;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class VirtualKeyEventTest {
+
+ @Test
+ public void keyEvent_emptyBuilder() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualKeyEvent.Builder().build());
+ }
+
+ @Test
+ public void keyEvent_noKeyCode() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new VirtualKeyEvent.Builder().setAction(VirtualKeyEvent.ACTION_DOWN).build());
+ }
+
+ @Test
+ public void keyEvent_noAction() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new VirtualKeyEvent.Builder().setKeyCode(KeyEvent.KEYCODE_A).build());
+ }
+
+ @Test
+ public void keyEvent_created() {
+ final VirtualKeyEvent event = new VirtualKeyEvent.Builder()
+ .setAction(VirtualKeyEvent.ACTION_DOWN)
+ .setKeyCode(KeyEvent.KEYCODE_A).build();
+ assertWithMessage("Incorrect key code").that(event.getKeyCode()).isEqualTo(
+ KeyEvent.KEYCODE_A);
+ assertWithMessage("Incorrect action").that(event.getAction()).isEqualTo(
+ VirtualKeyEvent.ACTION_DOWN);
+ }
+}
diff --git a/tests/Input/src/android/hardware/input/VirtualMouseButtonEventTest.java b/tests/Input/src/android/hardware/input/VirtualMouseButtonEventTest.java
new file mode 100644
index 0000000..789e0bb
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/VirtualMouseButtonEventTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2021 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 android.hardware.input;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.testng.Assert.assertThrows;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class VirtualMouseButtonEventTest {
+
+ @Test
+ public void buttonEvent_emptyBuilder() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new VirtualMouseButtonEvent.Builder().build());
+ }
+
+ @Test
+ public void buttonEvent_noButtonCode() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualMouseButtonEvent.Builder()
+ .setAction(VirtualMouseButtonEvent.ACTION_BUTTON_RELEASE).build());
+ }
+
+ @Test
+ public void buttonEvent_noAction() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualMouseButtonEvent.Builder()
+ .setButtonCode(VirtualMouseButtonEvent.BUTTON_BACK).build());
+ }
+
+ @Test
+ public void buttonEvent_created() {
+ final VirtualMouseButtonEvent event = new VirtualMouseButtonEvent.Builder()
+ .setAction(VirtualMouseButtonEvent.ACTION_BUTTON_PRESS)
+ .setButtonCode(VirtualMouseButtonEvent.BUTTON_BACK).build();
+ assertWithMessage("Incorrect button code").that(event.getButtonCode()).isEqualTo(
+ VirtualMouseButtonEvent.BUTTON_BACK);
+ assertWithMessage("Incorrect action").that(event.getAction()).isEqualTo(
+ VirtualMouseButtonEvent.ACTION_BUTTON_PRESS);
+ }
+}
diff --git a/tests/Input/src/android/hardware/input/VirtualMouseRelativeEventTest.java b/tests/Input/src/android/hardware/input/VirtualMouseRelativeEventTest.java
new file mode 100644
index 0000000..c050816
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/VirtualMouseRelativeEventTest.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2021 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 android.hardware.input;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class VirtualMouseRelativeEventTest {
+
+ @Test
+ public void relativeEvent_created() {
+ final VirtualMouseRelativeEvent event = new VirtualMouseRelativeEvent.Builder()
+ .setRelativeX(-5f)
+ .setRelativeY(8f).build();
+ assertWithMessage("Incorrect x value").that(event.getRelativeX()).isEqualTo(-5f);
+ assertWithMessage("Incorrect y value").that(event.getRelativeY()).isEqualTo(8f);
+ }
+}
diff --git a/tests/Input/src/android/hardware/input/VirtualMouseScrollEventTest.java b/tests/Input/src/android/hardware/input/VirtualMouseScrollEventTest.java
new file mode 100644
index 0000000..2259c74
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/VirtualMouseScrollEventTest.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2021 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 android.hardware.input;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.testng.Assert.assertThrows;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class VirtualMouseScrollEventTest {
+
+ @Test
+ public void scrollEvent_xOutOfRange() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualMouseScrollEvent.Builder()
+ .setXAxisMovement(1.5f)
+ .setYAxisMovement(1.0f));
+ }
+
+ @Test
+ public void scrollEvent_yOutOfRange() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualMouseScrollEvent.Builder()
+ .setXAxisMovement(0.5f)
+ .setYAxisMovement(1.1f));
+ }
+
+ @Test
+ public void scrollEvent_created() {
+ final VirtualMouseScrollEvent event = new VirtualMouseScrollEvent.Builder()
+ .setXAxisMovement(-1f)
+ .setYAxisMovement(1f).build();
+ assertWithMessage("Incorrect x value").that(event.getXAxisMovement()).isEqualTo(-1f);
+ assertWithMessage("Incorrect y value").that(event.getYAxisMovement()).isEqualTo(1f);
+ }
+}
+
diff --git a/tests/Input/src/android/hardware/input/VirtualTouchEventTest.java b/tests/Input/src/android/hardware/input/VirtualTouchEventTest.java
new file mode 100644
index 0000000..100aba5
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/VirtualTouchEventTest.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2021 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 android.hardware.input;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.testng.Assert.assertThrows;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class VirtualTouchEventTest {
+
+ @Test
+ public void touchEvent_emptyBuilder() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder().build());
+ }
+
+ @Test
+ public void touchEvent_noAction() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .build());
+ }
+
+ @Test
+ public void touchEvent_noPointerId() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_DOWN)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setX(0f)
+ .setY(1f)
+ .build());
+ }
+
+ @Test
+ public void touchEvent_noToolType() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_DOWN)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .build());
+ }
+
+ @Test
+ public void touchEvent_noX() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_DOWN)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setY(1f)
+ .setPointerId(1)
+ .build());
+ }
+
+
+ @Test
+ public void touchEvent_noY() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_DOWN)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setX(0f)
+ .setPointerId(1)
+ .build());
+ }
+
+ @Test
+ public void touchEvent_created() {
+ final VirtualTouchEvent event = new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_DOWN)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .build();
+ assertWithMessage("Incorrect action").that(event.getAction()).isEqualTo(
+ VirtualTouchEvent.ACTION_DOWN);
+ assertWithMessage("Incorrect tool type").that(event.getToolType()).isEqualTo(
+ VirtualTouchEvent.TOOL_TYPE_FINGER);
+ assertWithMessage("Incorrect x").that(event.getX()).isEqualTo(0f);
+ assertWithMessage("Incorrect y").that(event.getY()).isEqualTo(1f);
+ assertWithMessage("Incorrect pointer id").that(event.getPointerId()).isEqualTo(1);
+ }
+
+ @Test
+ public void touchEvent_created_withPressureAndAxis() {
+ final VirtualTouchEvent event = new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_DOWN)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .setPressure(0.5f)
+ .setMajorAxisSize(10f)
+ .build();
+ assertWithMessage("Incorrect action").that(event.getAction()).isEqualTo(
+ VirtualTouchEvent.ACTION_DOWN);
+ assertWithMessage("Incorrect tool type").that(event.getToolType()).isEqualTo(
+ VirtualTouchEvent.TOOL_TYPE_FINGER);
+ assertWithMessage("Incorrect x").that(event.getX()).isEqualTo(0f);
+ assertWithMessage("Incorrect y").that(event.getY()).isEqualTo(1f);
+ assertWithMessage("Incorrect pointer id").that(event.getPointerId()).isEqualTo(1);
+ assertWithMessage("Incorrect pressure").that(event.getPressure()).isEqualTo(0.5f);
+ assertWithMessage("Incorrect major axis size").that(event.getMajorAxisSize()).isEqualTo(
+ 10f);
+ }
+
+ @Test
+ public void touchEvent_cancelUsedImproperly() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_CANCEL)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_FINGER)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .build());
+ }
+
+ /**
+ * The combination of TOOL_TYPE_PALM with anything else than ACTION_CANCEL should throw an
+ * exception. This is due to an underlying implementation detail. See documentation of {@link
+ * VirtualTouchEvent}
+ * for details.
+ */
+ @Test
+ public void touchEvent_palmUsedImproperly() {
+ assertThrows(IllegalArgumentException.class, () -> new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_MOVE)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_PALM)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .build());
+ }
+
+ @Test
+ public void touchEvent_palmAndCancelUsedProperly() {
+ final VirtualTouchEvent event = new VirtualTouchEvent.Builder()
+ .setAction(VirtualTouchEvent.ACTION_CANCEL)
+ .setToolType(VirtualTouchEvent.TOOL_TYPE_PALM)
+ .setX(0f)
+ .setY(1f)
+ .setPointerId(1)
+ .setPressure(0.5f)
+ .setMajorAxisSize(10f)
+ .build();
+ assertWithMessage("Incorrect action").that(event.getAction()).isEqualTo(
+ VirtualTouchEvent.ACTION_CANCEL);
+ assertWithMessage("Incorrect tool type").that(event.getToolType()).isEqualTo(
+ VirtualTouchEvent.TOOL_TYPE_PALM);
+ assertWithMessage("Incorrect x").that(event.getX()).isEqualTo(0f);
+ assertWithMessage("Incorrect y").that(event.getY()).isEqualTo(1f);
+ assertWithMessage("Incorrect pointer id").that(event.getPointerId()).isEqualTo(1);
+ assertWithMessage("Incorrect pressure").that(event.getPressure()).isEqualTo(0.5f);
+ assertWithMessage("Incorrect major axis size").that(event.getMajorAxisSize()).isEqualTo(
+ 10f);
+ }
+}
diff --git a/tests/Input/src/com/android/server/input/AmbientKeyboardBacklightControllerTests.kt b/tests/Input/src/com/android/server/input/AmbientKeyboardBacklightControllerTests.kt
new file mode 100644
index 0000000..ad481df
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/AmbientKeyboardBacklightControllerTests.kt
@@ -0,0 +1,355 @@
+/*
+ * Copyright 2023 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.content.res.Resources
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.hardware.display.DisplayManagerInternal
+import android.hardware.input.InputSensorInfo
+import android.os.Handler
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.util.TypedValue
+import android.view.Display
+import android.view.DisplayInfo
+import androidx.test.core.app.ApplicationProvider
+import com.android.internal.R
+import com.android.server.LocalServices
+import com.android.server.input.AmbientKeyboardBacklightController.HYSTERESIS_THRESHOLD
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.Mockito.any
+import org.mockito.Mockito.anyBoolean
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+
+/**
+ * Tests for {@link AmbientKeyboardBacklightController}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:AmbientKeyboardBacklightControllerTests
+ */
+@Presubmit
+class AmbientKeyboardBacklightControllerTests {
+
+ companion object {
+ const val DEFAULT_DISPLAY_UNIQUE_ID = "uniqueId_1"
+ const val SENSOR_NAME = "test_sensor_name"
+ const val SENSOR_TYPE = "test_sensor_type"
+ }
+
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ private lateinit var context: Context
+ private lateinit var testLooper: TestLooper
+ private lateinit var ambientController: AmbientKeyboardBacklightController
+
+ @Mock
+ private lateinit var resources: Resources
+
+ @Mock
+ private lateinit var lightSensorInfo: InputSensorInfo
+
+ @Mock
+ private lateinit var sensorManager: SensorManager
+
+ @Mock
+ private lateinit var displayManagerInternal: DisplayManagerInternal
+ private lateinit var lightSensor: Sensor
+
+ private var currentDisplayInfo = DisplayInfo()
+ private var lastBrightnessCallback: Int = 0
+ private var listenerRegistered: Boolean = false
+ private var listenerRegistrationCount: Int = 0
+
+ @Before
+ fun setup() {
+ context = spy(ContextWrapper(ApplicationProvider.getApplicationContext()))
+ `when`(context.resources).thenReturn(resources)
+ setupBrightnessSteps()
+ setupSensor()
+ testLooper = TestLooper()
+ ambientController = AmbientKeyboardBacklightController(context, testLooper.looper)
+ ambientController.systemRunning()
+ testLooper.dispatchAll()
+ }
+
+ private fun setupBrightnessSteps() {
+ val brightnessValues = intArrayOf(100, 200, 0)
+ val decreaseThresholds = intArrayOf(-1, 900, 1900)
+ val increaseThresholds = intArrayOf(1000, 2000, -1)
+ `when`(resources.getIntArray(R.array.config_autoKeyboardBacklightBrightnessValues))
+ .thenReturn(brightnessValues)
+ `when`(resources.getIntArray(R.array.config_autoKeyboardBacklightDecreaseLuxThreshold))
+ .thenReturn(decreaseThresholds)
+ `when`(resources.getIntArray(R.array.config_autoKeyboardBacklightIncreaseLuxThreshold))
+ .thenReturn(increaseThresholds)
+ `when`(
+ resources.getValue(
+ eq(R.dimen.config_autoKeyboardBrightnessSmoothingConstant),
+ any(TypedValue::class.java),
+ anyBoolean()
+ )
+ ).then {
+ val args = it.arguments
+ val outValue = args[1] as TypedValue
+ outValue.data = java.lang.Float.floatToRawIntBits(1.0f)
+ Unit
+ }
+ }
+
+ private fun setupSensor() {
+ LocalServices.removeServiceForTest(DisplayManagerInternal::class.java)
+ LocalServices.addService(DisplayManagerInternal::class.java, displayManagerInternal)
+ currentDisplayInfo.uniqueId = DEFAULT_DISPLAY_UNIQUE_ID
+ `when`(displayManagerInternal.getDisplayInfo(Display.DEFAULT_DISPLAY)).thenReturn(
+ currentDisplayInfo
+ )
+ val sensorData = DisplayManagerInternal.AmbientLightSensorData(SENSOR_NAME, SENSOR_TYPE)
+ `when`(displayManagerInternal.getAmbientLightSensorData(Display.DEFAULT_DISPLAY))
+ .thenReturn(sensorData)
+
+ `when`(lightSensorInfo.name).thenReturn(SENSOR_NAME)
+ `when`(lightSensorInfo.stringType).thenReturn(SENSOR_TYPE)
+ lightSensor = Sensor(lightSensorInfo)
+ `when`(context.getSystemService(eq(Context.SENSOR_SERVICE))).thenReturn(sensorManager)
+ `when`(sensorManager.getSensorList(anyInt())).thenReturn(listOf(lightSensor))
+ `when`(
+ sensorManager.registerListener(
+ any(),
+ eq(lightSensor),
+ anyInt(),
+ any(Handler::class.java)
+ )
+ ).then {
+ listenerRegistered = true
+ listenerRegistrationCount++
+ true
+ }
+ `when`(
+ sensorManager.unregisterListener(
+ any(SensorEventListener::class.java),
+ eq(lightSensor)
+ )
+ ).then {
+ listenerRegistered = false
+ Unit
+ }
+ }
+
+ private fun setupSensorWithInitialLux(luxValue: Float) {
+ ambientController.registerAmbientBacklightListener { brightnessValue: Int ->
+ lastBrightnessCallback = brightnessValue
+ }
+ sendAmbientLuxValue(luxValue)
+ testLooper.dispatchAll()
+ }
+
+ @Test
+ fun testInitialAmbientLux_sendsCallbackImmediately() {
+ setupSensorWithInitialLux(500F)
+
+ assertEquals(
+ "Should receive immediate callback for first lux change",
+ 100,
+ lastBrightnessCallback
+ )
+ }
+
+ @Test
+ fun testBrightnessIncrease_afterInitialLuxChanges() {
+ setupSensorWithInitialLux(500F)
+
+ // Current state: Step 1 [value = 100, increaseThreshold = 1000, decreaseThreshold = -1]
+ repeat(HYSTERESIS_THRESHOLD) {
+ sendAmbientLuxValue(1500F)
+ }
+ testLooper.dispatchAll()
+
+ assertEquals(
+ "Should receive brightness change callback for increasing lux change",
+ 200,
+ lastBrightnessCallback
+ )
+ }
+
+ @Test
+ fun testBrightnessDecrease_afterInitialLuxChanges() {
+ setupSensorWithInitialLux(1500F)
+
+ // Current state: Step 2 [value = 200, increaseThreshold = 2000, decreaseThreshold = 900]
+ repeat(HYSTERESIS_THRESHOLD) {
+ sendAmbientLuxValue(500F)
+ }
+ testLooper.dispatchAll()
+
+ assertEquals(
+ "Should receive brightness change callback for decreasing lux change",
+ 100,
+ lastBrightnessCallback
+ )
+ }
+
+ @Test
+ fun testRegisterAmbientListener_throwsExceptionOnRegisteringDuplicate() {
+ val ambientListener =
+ AmbientKeyboardBacklightController.AmbientKeyboardBacklightListener { }
+ ambientController.registerAmbientBacklightListener(ambientListener)
+
+ assertThrows(IllegalStateException::class.java) {
+ ambientController.registerAmbientBacklightListener(
+ ambientListener
+ )
+ }
+ }
+
+ @Test
+ fun testUnregisterAmbientListener_throwsExceptionOnUnregisteringNonExistent() {
+ val ambientListener =
+ AmbientKeyboardBacklightController.AmbientKeyboardBacklightListener { }
+ assertThrows(IllegalStateException::class.java) {
+ ambientController.unregisterAmbientBacklightListener(
+ ambientListener
+ )
+ }
+ }
+
+ @Test
+ fun testSensorListenerRegistered_onRegisterUnregisterAmbientListener() {
+ assertEquals(
+ "Should not have a sensor listener registered at init",
+ 0,
+ listenerRegistrationCount
+ )
+ assertFalse("Should not have a sensor listener registered at init", listenerRegistered)
+
+ val ambientListener1 =
+ AmbientKeyboardBacklightController.AmbientKeyboardBacklightListener { }
+ ambientController.registerAmbientBacklightListener(ambientListener1)
+ assertEquals(
+ "Should register a new sensor listener", 1, listenerRegistrationCount
+ )
+ assertTrue("Should have sensor listener registered", listenerRegistered)
+
+ val ambientListener2 =
+ AmbientKeyboardBacklightController.AmbientKeyboardBacklightListener { }
+ ambientController.registerAmbientBacklightListener(ambientListener2)
+ assertEquals(
+ "Should not register a new sensor listener when adding a second ambient listener",
+ 1,
+ listenerRegistrationCount
+ )
+ assertTrue("Should have sensor listener registered", listenerRegistered)
+
+ ambientController.unregisterAmbientBacklightListener(ambientListener1)
+ assertTrue("Should have sensor listener registered", listenerRegistered)
+
+ ambientController.unregisterAmbientBacklightListener(ambientListener2)
+ assertFalse(
+ "Should not have sensor listener registered if there are no ambient listeners",
+ listenerRegistered
+ )
+ }
+
+ @Test
+ fun testDisplayChange_shouldNotReRegisterListener_ifUniqueIdSame() {
+ setupSensorWithInitialLux(0F)
+
+ val count = listenerRegistrationCount
+ ambientController.onDisplayChanged(Display.DEFAULT_DISPLAY)
+ testLooper.dispatchAll()
+
+ assertEquals(
+ "Should not re-register listener on display change if unique is same",
+ count,
+ listenerRegistrationCount
+ )
+ }
+
+ @Test
+ fun testDisplayChange_shouldReRegisterListener_ifUniqueIdChanges() {
+ setupSensorWithInitialLux(0F)
+
+ val count = listenerRegistrationCount
+ currentDisplayInfo.uniqueId = "xyz"
+ ambientController.onDisplayChanged(Display.DEFAULT_DISPLAY)
+ testLooper.dispatchAll()
+
+ assertEquals(
+ "Should re-register listener on display change if unique id changed",
+ count + 1,
+ listenerRegistrationCount
+ )
+ }
+
+ @Test
+ fun testBrightnessDoesntChange_betweenIncreaseAndDecreaseThresholds() {
+ setupSensorWithInitialLux(1001F)
+
+ // Previous state: Step 1 [value = 100, increaseThreshold = 1000, decreaseThreshold = -1]
+ // Current state: Step 2 [value = 200, increaseThreshold = 2000, decreaseThreshold = 900]
+ lastBrightnessCallback = -1
+ repeat(HYSTERESIS_THRESHOLD) {
+ sendAmbientLuxValue(999F)
+ }
+ testLooper.dispatchAll()
+
+ assertEquals(
+ "Should not receive any callback for brightness change",
+ -1,
+ lastBrightnessCallback
+ )
+ }
+
+ @Test
+ fun testBrightnessDoesntChange_onChangeOccurringLessThanHysteresisThreshold() {
+ setupSensorWithInitialLux(1001F)
+
+ // Previous state: Step 1 [value = 100, increaseThreshold = 1000, decreaseThreshold = -1]
+ // Current state: Step 2 [value = 200, increaseThreshold = 2000, decreaseThreshold = 900]
+ lastBrightnessCallback = -1
+ repeat(HYSTERESIS_THRESHOLD - 1) {
+ sendAmbientLuxValue(2001F)
+ }
+ testLooper.dispatchAll()
+
+ assertEquals(
+ "Should not receive any callback for brightness change",
+ -1,
+ lastBrightnessCallback
+ )
+ }
+
+ private fun sendAmbientLuxValue(luxValue: Float) {
+ ambientController.onSensorChanged(SensorEvent(lightSensor, 0, 0, floatArrayOf(luxValue)))
+ }
+}
diff --git a/tests/Input/src/com/android/server/input/BatteryControllerTests.kt b/tests/Input/src/com/android/server/input/BatteryControllerTests.kt
new file mode 100644
index 0000000..ea76ac4f
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/BatteryControllerTests.kt
@@ -0,0 +1,895 @@
+/*
+ * 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.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.hardware.BatteryState.STATUS_CHARGING
+import android.hardware.BatteryState.STATUS_DISCHARGING
+import android.hardware.BatteryState.STATUS_FULL
+import android.hardware.BatteryState.STATUS_UNKNOWN
+import android.hardware.input.HostUsiVersion
+import android.hardware.input.IInputDeviceBatteryListener
+import android.hardware.input.IInputDeviceBatteryState
+import android.hardware.input.IInputDevicesChangedListener
+import android.hardware.input.IInputManager
+import android.hardware.input.InputManager
+import android.hardware.input.InputManagerGlobal
+import android.os.Binder
+import android.os.IBinder
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.testing.TestableContext
+import android.view.InputDevice
+import androidx.test.core.app.ApplicationProvider
+import com.android.server.input.BatteryController.BluetoothBatteryManager
+import com.android.server.input.BatteryController.BluetoothBatteryManager.BluetoothBatteryListener
+import com.android.server.input.BatteryController.POLLING_PERIOD_MILLIS
+import com.android.server.input.BatteryController.UEventBatteryListener
+import com.android.server.input.BatteryController.USI_BATTERY_VALIDITY_DURATION_MILLIS
+import org.hamcrest.Description
+import org.hamcrest.Matcher
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers
+import org.hamcrest.TypeSafeMatcher
+import org.hamcrest.core.IsEqual.equalTo
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.notNull
+import org.mockito.Mock
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.Mockito.`when`
+import org.mockito.hamcrest.MockitoHamcrest
+import org.mockito.junit.MockitoJUnit
+import org.mockito.verification.VerificationMode
+
+private fun createInputDevice(
+ deviceId: Int,
+ hasBattery: Boolean = true,
+ supportsUsi: Boolean = false,
+ generation: Int = -1,
+): InputDevice =
+ InputDevice.Builder()
+ .setId(deviceId)
+ .setName("Device $deviceId")
+ .setDescriptor("descriptor $deviceId")
+ .setExternal(true)
+ .setHasBattery(hasBattery)
+ .setUsiVersion(if (supportsUsi) HostUsiVersion(1, 0) else null)
+ .setGeneration(generation)
+ .build()
+
+// Returns a matcher that helps match member variables of a class.
+private fun <T, U> memberMatcher(
+ member: String,
+ memberProvider: (T) -> U,
+ match: Matcher<U>
+): TypeSafeMatcher<T> =
+ object : TypeSafeMatcher<T>() {
+
+ override fun matchesSafely(item: T?): Boolean {
+ return match.matches(memberProvider(item!!))
+ }
+
+ override fun describeMismatchSafely(item: T?, mismatchDescription: Description?) {
+ match.describeMismatch(item, mismatchDescription)
+ }
+
+ override fun describeTo(description: Description?) {
+ match.describeTo(description?.appendText("matches member $member"))
+ }
+ }
+
+// Returns a matcher for IInputDeviceBatteryState that optionally matches some arguments.
+private fun matchesState(
+ deviceId: Int,
+ isPresent: Boolean = true,
+ status: Int? = null,
+ capacity: Float? = null,
+ eventTime: Long? = null
+): Matcher<IInputDeviceBatteryState> {
+ val batteryStateMatchers = mutableListOf<Matcher<IInputDeviceBatteryState>>(
+ memberMatcher("deviceId", { it.deviceId }, equalTo(deviceId)),
+ memberMatcher("isPresent", { it.isPresent }, equalTo(isPresent))
+ )
+ if (eventTime != null) {
+ batteryStateMatchers.add(memberMatcher("updateTime", { it.updateTime }, equalTo(eventTime)))
+ }
+ if (status != null) {
+ batteryStateMatchers.add(memberMatcher("status", { it.status }, equalTo(status)))
+ }
+ if (capacity != null) {
+ batteryStateMatchers.add(memberMatcher("capacity", { it.capacity }, equalTo(capacity)))
+ }
+ return Matchers.allOf(batteryStateMatchers)
+}
+
+private fun isInvalidBatteryState(deviceId: Int): Matcher<IInputDeviceBatteryState> =
+ matchesState(deviceId, isPresent = false, status = STATUS_UNKNOWN, capacity = Float.NaN)
+
+// Helpers used to verify interactions with a mocked battery listener.
+private fun IInputDeviceBatteryListener.verifyNotified(
+ deviceId: Int,
+ mode: VerificationMode = times(1),
+ isPresent: Boolean = true,
+ status: Int? = null,
+ capacity: Float? = null,
+ eventTime: Long? = null
+) {
+ verifyNotified(matchesState(deviceId, isPresent, status, capacity, eventTime), mode)
+}
+
+private fun IInputDeviceBatteryListener.verifyNotified(
+ matcher: Matcher<IInputDeviceBatteryState>,
+ mode: VerificationMode = times(1)
+) {
+ verify(this, mode).onBatteryStateChanged(MockitoHamcrest.argThat(matcher))
+}
+
+private fun createMockListener(): IInputDeviceBatteryListener {
+ val listener = mock(IInputDeviceBatteryListener::class.java)
+ val binder = mock(Binder::class.java)
+ `when`(listener.asBinder()).thenReturn(binder)
+ return listener
+}
+
+/**
+ * Tests for {@link InputDeviceBatteryController}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:InputDeviceBatteryControllerTests
+ */
+@Presubmit
+class BatteryControllerTests {
+ companion object {
+ const val PID = 42
+ const val DEVICE_ID = 13
+ const val SECOND_DEVICE_ID = 11
+ const val USI_DEVICE_ID = 101
+ const val SECOND_USI_DEVICE_ID = 102
+ const val BT_DEVICE_ID = 100001
+ const val SECOND_BT_DEVICE_ID = 100002
+ const val TIMESTAMP = 123456789L
+ }
+
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ @Mock
+ private lateinit var native: NativeInputManagerService
+ @Mock
+ private lateinit var iInputManager: IInputManager
+ @Mock
+ private lateinit var uEventManager: UEventManager
+ @Mock
+ private lateinit var bluetoothBatteryManager: BluetoothBatteryManager
+
+ private lateinit var batteryController: BatteryController
+ private lateinit var context: TestableContext
+ private lateinit var testLooper: TestLooper
+ private lateinit var devicesChangedListener: IInputDevicesChangedListener
+ private val deviceGenerationMap = mutableMapOf<Int /*deviceId*/, Int /*generation*/>()
+
+ @Before
+ fun setup() {
+ context = TestableContext(ApplicationProvider.getApplicationContext())
+ testLooper = TestLooper()
+ InputManagerGlobal.resetInstance(iInputManager)
+ val inputManager = InputManager(context)
+ context.addMockSystemService(InputManager::class.java, inputManager)
+ `when`(iInputManager.inputDeviceIds).then {
+ deviceGenerationMap.keys.toIntArray()
+ }
+ addInputDevice(DEVICE_ID)
+ addInputDevice(SECOND_DEVICE_ID)
+
+ batteryController = BatteryController(context, native, testLooper.looper, uEventManager,
+ bluetoothBatteryManager)
+ batteryController.systemRunning()
+ val listenerCaptor = ArgumentCaptor.forClass(IInputDevicesChangedListener::class.java)
+ verify(iInputManager).registerInputDevicesChangedListener(listenerCaptor.capture())
+ devicesChangedListener = listenerCaptor.value
+ testLooper.dispatchAll()
+ }
+
+ private fun notifyDeviceChanged(
+ deviceId: Int,
+ hasBattery: Boolean = true,
+ supportsUsi: Boolean = false
+ ) {
+ val generation = deviceGenerationMap[deviceId]?.plus(1)
+ ?: throw IllegalArgumentException("Device $deviceId was never added!")
+ deviceGenerationMap[deviceId] = generation
+
+ `when`(iInputManager.getInputDevice(deviceId))
+ .thenReturn(createInputDevice(deviceId, hasBattery, supportsUsi, generation))
+ val list = deviceGenerationMap.flatMap { listOf(it.key, it.value) }
+ if (::devicesChangedListener.isInitialized) {
+ devicesChangedListener.onInputDevicesChanged(list.toIntArray())
+ }
+ }
+
+ private fun addInputDevice(
+ deviceId: Int,
+ hasBattery: Boolean = true,
+ supportsUsi: Boolean = false,
+ ) {
+ deviceGenerationMap[deviceId] = 0
+ notifyDeviceChanged(deviceId, hasBattery, supportsUsi)
+ }
+
+ private fun createBluetoothDevice(address: String): BluetoothDevice {
+ return context.getSystemService(BluetoothManager::class.java)!!
+ .adapter.getRemoteDevice(address)
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ @Test
+ fun testRegisterAndUnregisterBinderLifecycle() {
+ val listener = createMockListener()
+ // Ensure the binder lifecycle is tracked when registering a listener.
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ verify(listener.asBinder()).linkToDeath(notNull(), anyInt())
+ batteryController.registerBatteryListener(SECOND_DEVICE_ID, listener, PID)
+ verify(listener.asBinder(), times(1)).linkToDeath(notNull(), anyInt())
+
+ // Ensure the binder lifecycle stops being tracked when all devices stopped being monitored.
+ batteryController.unregisterBatteryListener(SECOND_DEVICE_ID, listener, PID)
+ verify(listener.asBinder(), never()).unlinkToDeath(notNull(), anyInt())
+ batteryController.unregisterBatteryListener(DEVICE_ID, listener, PID)
+ verify(listener.asBinder()).unlinkToDeath(notNull(), anyInt())
+ }
+
+ @Test
+ fun testOneListenerPerProcess() {
+ val listener1 = createMockListener()
+ batteryController.registerBatteryListener(DEVICE_ID, listener1, PID)
+ verify(listener1.asBinder()).linkToDeath(notNull(), anyInt())
+
+ // If a second listener is added for the same process, a security exception is thrown.
+ val listener2 = createMockListener()
+ try {
+ batteryController.registerBatteryListener(DEVICE_ID, listener2, PID)
+ fail("Expected security exception when registering more than one listener per process")
+ } catch (ignored: SecurityException) {
+ }
+ }
+
+ @Test
+ fun testProcessDeathRemovesListener() {
+ val deathRecipient = ArgumentCaptor.forClass(IBinder.DeathRecipient::class.java)
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ verify(listener.asBinder()).linkToDeath(deathRecipient.capture(), anyInt())
+
+ // When the binder dies, the callback is unregistered.
+ deathRecipient.value!!.binderDied(listener.asBinder())
+ verify(listener.asBinder()).unlinkToDeath(notNull(), anyInt())
+
+ // It is now possible to register the same listener again.
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ verify(listener.asBinder(), times(2)).linkToDeath(notNull(), anyInt())
+ }
+
+ @Test
+ fun testRegisteringListenerNotifiesStateImmediately() {
+ `when`(native.getBatteryStatus(DEVICE_ID)).thenReturn(STATUS_FULL)
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(100)
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ listener.verifyNotified(DEVICE_ID, status = STATUS_FULL, capacity = 1.0f)
+
+ `when`(native.getBatteryStatus(SECOND_DEVICE_ID)).thenReturn(STATUS_CHARGING)
+ `when`(native.getBatteryCapacity(SECOND_DEVICE_ID)).thenReturn(78)
+ batteryController.registerBatteryListener(SECOND_DEVICE_ID, listener, PID)
+ listener.verifyNotified(SECOND_DEVICE_ID, status = STATUS_CHARGING, capacity = 0.78f)
+ }
+
+ @Test
+ fun testListenersNotifiedOnUEventNotification() {
+ `when`(native.getBatteryDevicePath(DEVICE_ID)).thenReturn("/sys/dev/test/device1")
+ `when`(native.getBatteryStatus(DEVICE_ID)).thenReturn(STATUS_CHARGING)
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(78)
+ val listener = createMockListener()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ // The device paths for UEvent notifications do not include the "/sys" prefix, so verify
+ // that the added listener is configured to match the path without that prefix.
+ verify(uEventManager)
+ .addListener(uEventListener.capture(), eq("DEVPATH=/dev/test/device1"))
+ listener.verifyNotified(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.78f)
+
+ // If the battery state has changed when an UEvent is sent, the listeners are notified.
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(80)
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP)
+ listener.verifyNotified(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.80f,
+ eventTime = TIMESTAMP)
+
+ // If the battery state has not changed when an UEvent is sent, the listeners are not
+ // notified.
+ clearInvocations(listener)
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP + 1)
+ verifyNoMoreInteractions(listener)
+
+ batteryController.unregisterBatteryListener(DEVICE_ID, listener, PID)
+ verify(uEventManager).removeListener(uEventListener.capture())
+ assertEquals("The same observer must be registered and unregistered",
+ uEventListener.allValues[0], uEventListener.allValues[1])
+ }
+
+ @Test
+ fun testBatteryPresenceChanged() {
+ `when`(native.getBatteryDevicePath(DEVICE_ID)).thenReturn("/test/device1")
+ `when`(native.getBatteryStatus(DEVICE_ID)).thenReturn(STATUS_CHARGING)
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(78)
+ val listener = createMockListener()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ verify(uEventManager).addListener(uEventListener.capture(), eq("DEVPATH=/test/device1"))
+ listener.verifyNotified(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.78f)
+
+ // If the battery presence for the InputDevice changes, the listener is notified.
+ notifyDeviceChanged(DEVICE_ID, hasBattery = false)
+ testLooper.dispatchNext()
+ listener.verifyNotified(isInvalidBatteryState(DEVICE_ID))
+ // Since the battery is no longer present, the UEventListener should be removed.
+ verify(uEventManager).removeListener(uEventListener.value)
+
+ // If the battery becomes present again, the listener is notified.
+ notifyDeviceChanged(DEVICE_ID, hasBattery = true)
+ testLooper.dispatchNext()
+ listener.verifyNotified(DEVICE_ID, mode = times(2), status = STATUS_CHARGING,
+ capacity = 0.78f)
+ // Ensure that a new UEventListener was added.
+ verify(uEventManager, times(2))
+ .addListener(uEventListener.capture(), eq("DEVPATH=/test/device1"))
+ }
+
+ @Test
+ fun testStartPollingWhenListenerIsRegistered() {
+ val listener = createMockListener()
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(78)
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ listener.verifyNotified(DEVICE_ID, capacity = 0.78f)
+
+ // Assume there is a change in the battery state. Ensure the listener is not notified
+ // while the polling period has not elapsed.
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(80)
+ testLooper.moveTimeForward(1)
+ testLooper.dispatchAll()
+ listener.verifyNotified(DEVICE_ID, mode = never(), capacity = 0.80f)
+
+ // Move the time forward so that the polling period has elapsed.
+ // The listener should be notified.
+ testLooper.moveTimeForward(POLLING_PERIOD_MILLIS - 1)
+ assertTrue("There should be a polling callbacks posted to the handler", testLooper.isIdle)
+ testLooper.dispatchNext()
+ listener.verifyNotified(DEVICE_ID, capacity = 0.80f)
+
+ // Move the time forward so that another polling period has elapsed.
+ // The battery should still be polled, but there is no change so listeners are not notified.
+ testLooper.moveTimeForward(POLLING_PERIOD_MILLIS)
+ assertTrue("There should be a polling callbacks posted to the handler", testLooper.isIdle)
+ testLooper.dispatchNext()
+ listener.verifyNotified(DEVICE_ID, mode = times(1), capacity = 0.80f)
+ }
+
+ @Test
+ fun testNoPollingWhenTheDeviceIsNotInteractive() {
+ batteryController.onInteractiveChanged(false /*interactive*/)
+
+ val listener = createMockListener()
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(78)
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ listener.verifyNotified(DEVICE_ID, capacity = 0.78f)
+
+ // The battery state changed, but we should not be polling for battery changes when the
+ // device is not interactive.
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(80)
+ testLooper.moveTimeForward(POLLING_PERIOD_MILLIS)
+ assertFalse("There should be no polling callbacks posted to the handler", testLooper.isIdle)
+ testLooper.dispatchAll()
+ listener.verifyNotified(DEVICE_ID, mode = never(), capacity = 0.80f)
+
+ // The device is now interactive. Battery state polling begins immediately.
+ batteryController.onInteractiveChanged(true /*interactive*/)
+ testLooper.dispatchNext()
+ listener.verifyNotified(DEVICE_ID, capacity = 0.80f)
+
+ // Ensure that we continue to poll for battery changes.
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(90)
+ testLooper.moveTimeForward(POLLING_PERIOD_MILLIS)
+ assertTrue("There should be a polling callbacks posted to the handler", testLooper.isIdle)
+ testLooper.dispatchNext()
+ listener.verifyNotified(DEVICE_ID, capacity = 0.90f)
+ }
+
+ @Test
+ fun testGetBatteryState() {
+ `when`(native.getBatteryStatus(DEVICE_ID)).thenReturn(STATUS_CHARGING)
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(78)
+ val batteryState = batteryController.getBatteryState(DEVICE_ID)
+ assertThat("battery state matches", batteryState,
+ matchesState(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.78f))
+ }
+
+ @Test
+ fun testGetBatteryStateWithListener() {
+ val listener = createMockListener()
+ `when`(native.getBatteryStatus(DEVICE_ID)).thenReturn(STATUS_CHARGING)
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(78)
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ listener.verifyNotified(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.78f)
+
+ // If getBatteryState() is called when a listener is monitoring the device and there is a
+ // change in the battery state, the listener is also notified.
+ `when`(native.getBatteryCapacity(DEVICE_ID)).thenReturn(80)
+ val batteryState = batteryController.getBatteryState(DEVICE_ID)
+ assertThat("battery matches state", batteryState,
+ matchesState(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.80f))
+ listener.verifyNotified(DEVICE_ID, status = STATUS_CHARGING, capacity = 0.80f)
+ }
+
+ @Test
+ fun testUsiDeviceIsMonitoredPersistently() {
+ `when`(native.getBatteryDevicePath(USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device")
+ addInputDevice(USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+
+ // Even though there is no listener added for this device, it is being monitored.
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ verify(uEventManager)
+ .addListener(uEventListener.capture(), eq("DEVPATH=/dev/usi_device"))
+
+ // Add and remove a listener for the device.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(USI_DEVICE_ID, listener, PID)
+ batteryController.unregisterBatteryListener(USI_DEVICE_ID, listener, PID)
+
+ // The device is still being monitored.
+ verify(uEventManager, never()).removeListener(uEventListener.value)
+ }
+
+ @Test
+ fun testNoPollingWhenUsiDevicesAreMonitored() {
+ `when`(native.getBatteryDevicePath(USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device")
+ addInputDevice(USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+ `when`(native.getBatteryDevicePath(SECOND_USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device2")
+ addInputDevice(SECOND_USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+
+ testLooper.moveTimeForward(POLLING_PERIOD_MILLIS)
+ assertFalse("There should be no polling callbacks posted to the handler", testLooper.isIdle)
+
+ // Add a listener.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(USI_DEVICE_ID, listener, PID)
+
+ testLooper.moveTimeForward(POLLING_PERIOD_MILLIS)
+ assertFalse("There should be no polling callbacks posted to the handler", testLooper.isIdle)
+ }
+
+ @Test
+ fun testExpectedFlowForUsiBattery() {
+ `when`(native.getBatteryDevicePath(USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device")
+ `when`(native.getBatteryStatus(USI_DEVICE_ID)).thenReturn(STATUS_DISCHARGING)
+ `when`(native.getBatteryCapacity(USI_DEVICE_ID)).thenReturn(78)
+
+ addInputDevice(USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ verify(uEventManager)
+ .addListener(uEventListener.capture(), eq("DEVPATH=/dev/usi_device"))
+
+ // A USI device's battery state is not valid until the first UEvent notification.
+ // Add a listener, and ensure it is notified that the battery state is not present.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(USI_DEVICE_ID, listener, PID)
+ listener.verifyNotified(isInvalidBatteryState(USI_DEVICE_ID))
+
+ // Ensure that querying for battery state also returns the same invalid result.
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ isInvalidBatteryState(USI_DEVICE_ID))
+
+ // There is a UEvent signaling a battery change. The battery state is now valid.
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP)
+ listener.verifyNotified(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f)
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f))
+
+ // There is another UEvent notification. The battery state is now updated.
+ `when`(native.getBatteryCapacity(USI_DEVICE_ID)).thenReturn(64)
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP + 1)
+ listener.verifyNotified(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.64f)
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.64f))
+
+ // The battery state is still valid after a millisecond.
+ testLooper.moveTimeForward(1)
+ testLooper.dispatchAll()
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.64f))
+
+ // The battery is no longer present after the timeout expires.
+ testLooper.moveTimeForward(USI_BATTERY_VALIDITY_DURATION_MILLIS - 1)
+ testLooper.dispatchNext()
+ listener.verifyNotified(isInvalidBatteryState(USI_DEVICE_ID), times(2))
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ isInvalidBatteryState(USI_DEVICE_ID))
+ }
+
+ @Test
+ fun testStylusPresenceExtendsValidUsiBatteryState() {
+ `when`(native.getBatteryDevicePath(USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device")
+ `when`(native.getBatteryStatus(USI_DEVICE_ID)).thenReturn(STATUS_DISCHARGING)
+ `when`(native.getBatteryCapacity(USI_DEVICE_ID)).thenReturn(78)
+
+ addInputDevice(USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ verify(uEventManager)
+ .addListener(uEventListener.capture(), eq("DEVPATH=/dev/usi_device"))
+
+ // There is a UEvent signaling a battery change. The battery state is now valid.
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP)
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(USI_DEVICE_ID, listener, PID)
+ listener.verifyNotified(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f)
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f))
+
+ // Stylus presence is detected before the validity timeout expires.
+ testLooper.moveTimeForward(100)
+ testLooper.dispatchAll()
+ batteryController.notifyStylusGestureStarted(USI_DEVICE_ID, TIMESTAMP)
+
+ // Ensure that timeout was extended, and the battery state is now valid for longer.
+ testLooper.moveTimeForward(USI_BATTERY_VALIDITY_DURATION_MILLIS - 100)
+ testLooper.dispatchAll()
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f))
+
+ // Ensure the validity period expires after the expected amount of time.
+ testLooper.moveTimeForward(100)
+ testLooper.dispatchNext()
+ listener.verifyNotified(isInvalidBatteryState(USI_DEVICE_ID))
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ isInvalidBatteryState(USI_DEVICE_ID))
+ }
+
+ @Test
+ fun testStylusPresenceMakesUsiBatteryStateValid() {
+ `when`(native.getBatteryDevicePath(USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device")
+ `when`(native.getBatteryStatus(USI_DEVICE_ID)).thenReturn(STATUS_DISCHARGING)
+ `when`(native.getBatteryCapacity(USI_DEVICE_ID)).thenReturn(78)
+
+ addInputDevice(USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ verify(uEventManager)
+ .addListener(uEventListener.capture(), eq("DEVPATH=/dev/usi_device"))
+
+ // The USI battery state is initially invalid.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(USI_DEVICE_ID, listener, PID)
+ listener.verifyNotified(isInvalidBatteryState(USI_DEVICE_ID))
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ isInvalidBatteryState(USI_DEVICE_ID))
+
+ // A stylus presence is detected. This validates the battery state.
+ batteryController.notifyStylusGestureStarted(USI_DEVICE_ID, TIMESTAMP)
+
+ listener.verifyNotified(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f)
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_DISCHARGING, capacity = 0.78f))
+ }
+
+ @Test
+ fun testStylusPresenceDoesNotMakeUsiBatteryStateValidAtBoot() {
+ `when`(native.getBatteryDevicePath(USI_DEVICE_ID)).thenReturn("/sys/dev/usi_device")
+ // At boot, the USI device always reports a capacity value of 0.
+ `when`(native.getBatteryStatus(USI_DEVICE_ID)).thenReturn(STATUS_UNKNOWN)
+ `when`(native.getBatteryCapacity(USI_DEVICE_ID)).thenReturn(0)
+
+ addInputDevice(USI_DEVICE_ID, supportsUsi = true)
+ testLooper.dispatchNext()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+ verify(uEventManager)
+ .addListener(uEventListener.capture(), eq("DEVPATH=/dev/usi_device"))
+
+ // The USI battery state is initially invalid.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(USI_DEVICE_ID, listener, PID)
+ listener.verifyNotified(isInvalidBatteryState(USI_DEVICE_ID))
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ isInvalidBatteryState(USI_DEVICE_ID))
+
+ // Since the capacity reported is 0, stylus presence does not validate the battery state.
+ batteryController.notifyStylusGestureStarted(USI_DEVICE_ID, TIMESTAMP)
+
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ isInvalidBatteryState(USI_DEVICE_ID))
+
+ // However, if a UEvent reports a battery capacity of 0, the battery state is now valid.
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP)
+ listener.verifyNotified(USI_DEVICE_ID, status = STATUS_UNKNOWN, capacity = 0f)
+ assertThat("battery state matches", batteryController.getBatteryState(USI_DEVICE_ID),
+ matchesState(USI_DEVICE_ID, status = STATUS_UNKNOWN, capacity = 0f))
+ }
+
+ @Test
+ fun testRegisterBluetoothListenerForMonitoredBluetoothDevices() {
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(iInputManager.getInputDeviceBluetoothAddress(SECOND_BT_DEVICE_ID))
+ .thenReturn("11:22:33:44:55:66")
+ addInputDevice(BT_DEVICE_ID)
+ testLooper.dispatchNext()
+ addInputDevice(SECOND_BT_DEVICE_ID)
+ testLooper.dispatchNext()
+
+ // Listen to a non-Bluetooth device and ensure that the BT battery listener is not added
+ // when there are no monitored BT devices.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager, never()).addBatteryListener(any())
+
+ val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
+
+ // The BT battery listener is added when the first BT input device is monitored.
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager).addBatteryListener(bluetoothListener.capture())
+
+ // The BT listener is only added once for all BT devices.
+ batteryController.registerBatteryListener(SECOND_BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager, times(1)).addBatteryListener(any())
+
+ // The BT listener is only removed when there are no monitored BT devices.
+ batteryController.unregisterBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager, never()).removeBatteryListener(any())
+
+ `when`(iInputManager.getInputDeviceBluetoothAddress(SECOND_BT_DEVICE_ID))
+ .thenReturn(null)
+ notifyDeviceChanged(SECOND_BT_DEVICE_ID)
+ testLooper.dispatchNext()
+ verify(bluetoothBatteryManager).removeBatteryListener(bluetoothListener.value)
+ }
+
+ @Test
+ fun testNotifiesBluetoothBatteryChanges() {
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
+ addInputDevice(BT_DEVICE_ID)
+ val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager).addBatteryListener(bluetoothListener.capture())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f)
+
+ // When the state has not changed, the listener is not notified again.
+ bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF", 21)
+ listener.verifyNotified(BT_DEVICE_ID, mode = times(1), capacity = 0.21f)
+
+ bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF", 25)
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.25f)
+ }
+
+ @Test
+ fun testBluetoothBatteryIsPrioritized() {
+ `when`(native.getBatteryDevicePath(BT_DEVICE_ID)).thenReturn("/sys/dev/bt_device")
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
+ `when`(native.getBatteryCapacity(BT_DEVICE_ID)).thenReturn(98)
+ addInputDevice(BT_DEVICE_ID)
+ val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
+ val listener = createMockListener()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+
+ // When the device is first monitored and both native and BT battery is available,
+ // the latter is used.
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager).addBatteryListener(bluetoothListener.capture())
+ verify(uEventManager).addListener(uEventListener.capture(), any())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f)
+ assertThat("battery state matches", batteryController.getBatteryState(BT_DEVICE_ID),
+ matchesState(BT_DEVICE_ID, capacity = 0.21f))
+
+ // If only the native battery state changes the listener is not notified.
+ `when`(native.getBatteryCapacity(BT_DEVICE_ID)).thenReturn(97)
+ uEventListener.value!!.onBatteryUEvent(TIMESTAMP)
+ listener.verifyNotified(BT_DEVICE_ID, mode = times(1), capacity = 0.21f)
+ assertThat("battery state matches", batteryController.getBatteryState(BT_DEVICE_ID),
+ matchesState(BT_DEVICE_ID, capacity = 0.21f))
+ }
+
+ @Test
+ fun testFallBackToNativeBatteryStateWhenBluetoothStateInvalid() {
+ `when`(native.getBatteryDevicePath(BT_DEVICE_ID)).thenReturn("/sys/dev/bt_device")
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
+ `when`(native.getBatteryCapacity(BT_DEVICE_ID)).thenReturn(98)
+ addInputDevice(BT_DEVICE_ID)
+ val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
+ val listener = createMockListener()
+ val uEventListener = ArgumentCaptor.forClass(UEventBatteryListener::class.java)
+
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager).addBatteryListener(bluetoothListener.capture())
+ verify(uEventManager).addListener(uEventListener.capture(), any())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f)
+
+ // Fall back to the native state when BT is off.
+ bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF",
+ BluetoothDevice.BATTERY_LEVEL_BLUETOOTH_OFF)
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.98f)
+
+ bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF", 22)
+ verify(bluetoothBatteryManager).addBatteryListener(bluetoothListener.capture())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.22f)
+
+ // Fall back to the native state when BT battery is unknown.
+ bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF",
+ BluetoothDevice.BATTERY_LEVEL_UNKNOWN)
+ listener.verifyNotified(BT_DEVICE_ID, mode = times(2), capacity = 0.98f)
+ }
+
+ @Test
+ fun testRegisterBluetoothMetadataListenerForMonitoredBluetoothDevices() {
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(iInputManager.getInputDeviceBluetoothAddress(SECOND_BT_DEVICE_ID))
+ .thenReturn("11:22:33:44:55:66")
+ addInputDevice(BT_DEVICE_ID)
+ testLooper.dispatchNext()
+ addInputDevice(SECOND_BT_DEVICE_ID)
+ testLooper.dispatchNext()
+
+ // Listen to a non-Bluetooth device and ensure that the metadata listener is not added when
+ // there are no monitored BT devices.
+ val listener = createMockListener()
+ batteryController.registerBatteryListener(DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager, never()).addMetadataListener(any(), any())
+
+ val metadataListener1 = ArgumentCaptor.forClass(
+ BluetoothAdapter.OnMetadataChangedListener::class.java)
+ val metadataListener2 = ArgumentCaptor.forClass(
+ BluetoothAdapter.OnMetadataChangedListener::class.java)
+
+ // The metadata listener is added when the first BT input device is monitored.
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager)
+ .addMetadataListener(eq("AA:BB:CC:DD:EE:FF"), metadataListener1.capture())
+
+ // There is one metadata listener added for each BT device.
+ batteryController.registerBatteryListener(SECOND_BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager)
+ .addMetadataListener(eq("11:22:33:44:55:66"), metadataListener2.capture())
+
+ // The metadata listener is removed when the device is no longer monitored.
+ batteryController.unregisterBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager)
+ .removeMetadataListener("AA:BB:CC:DD:EE:FF", metadataListener1.value)
+
+ `when`(iInputManager.getInputDeviceBluetoothAddress(SECOND_BT_DEVICE_ID))
+ .thenReturn(null)
+ notifyDeviceChanged(SECOND_BT_DEVICE_ID)
+ testLooper.dispatchNext()
+ verify(bluetoothBatteryManager)
+ .removeMetadataListener("11:22:33:44:55:66", metadataListener2.value)
+ }
+
+ @Test
+ fun testNotifiesBluetoothMetadataBatteryChanges() {
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(bluetoothBatteryManager.getMetadata("AA:BB:CC:DD:EE:FF",
+ BluetoothDevice.METADATA_MAIN_BATTERY))
+ .thenReturn("21".toByteArray())
+ addInputDevice(BT_DEVICE_ID)
+ val metadataListener = ArgumentCaptor.forClass(
+ BluetoothAdapter.OnMetadataChangedListener::class.java)
+ val listener = createMockListener()
+ val bluetoothDevice = createBluetoothDevice("AA:BB:CC:DD:EE:FF")
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+ verify(bluetoothBatteryManager)
+ .addMetadataListener(eq("AA:BB:CC:DD:EE:FF"), metadataListener.capture())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.21f, status = STATUS_UNKNOWN)
+
+ // When the state has not changed, the listener is not notified again.
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_BATTERY, "21".toByteArray())
+ listener.verifyNotified(BT_DEVICE_ID, mode = times(1), capacity = 0.21f)
+
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_BATTERY, "25".toByteArray())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.25f, status = STATUS_UNKNOWN)
+
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_CHARGING, "true".toByteArray())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.25f, status = STATUS_CHARGING)
+
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_CHARGING, "false".toByteArray())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.25f, status = STATUS_DISCHARGING)
+
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_CHARGING, null)
+ listener.verifyNotified(BT_DEVICE_ID, mode = times(2), capacity = 0.25f,
+ status = STATUS_UNKNOWN)
+ }
+
+ @Test
+ fun testBluetoothMetadataBatteryIsPrioritized() {
+ `when`(iInputManager.getInputDeviceBluetoothAddress(BT_DEVICE_ID))
+ .thenReturn("AA:BB:CC:DD:EE:FF")
+ `when`(bluetoothBatteryManager.getBatteryLevel(eq("AA:BB:CC:DD:EE:FF"))).thenReturn(21)
+ `when`(bluetoothBatteryManager.getMetadata("AA:BB:CC:DD:EE:FF",
+ BluetoothDevice.METADATA_MAIN_BATTERY))
+ .thenReturn("22".toByteArray())
+ addInputDevice(BT_DEVICE_ID)
+ val bluetoothListener = ArgumentCaptor.forClass(BluetoothBatteryListener::class.java)
+ val metadataListener = ArgumentCaptor.forClass(
+ BluetoothAdapter.OnMetadataChangedListener::class.java)
+ val listener = createMockListener()
+ val bluetoothDevice = createBluetoothDevice("AA:BB:CC:DD:EE:FF")
+ batteryController.registerBatteryListener(BT_DEVICE_ID, listener, PID)
+
+ verify(bluetoothBatteryManager).addBatteryListener(bluetoothListener.capture())
+ verify(bluetoothBatteryManager)
+ .addMetadataListener(eq("AA:BB:CC:DD:EE:FF"), metadataListener.capture())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.22f)
+
+ // A change in the Bluetooth battery level has no effect while there is a valid battery
+ // level obtained through the metadata.
+ bluetoothListener.value!!.onBluetoothBatteryChanged(TIMESTAMP, "AA:BB:CC:DD:EE:FF", 23)
+ listener.verifyNotified(BT_DEVICE_ID, mode = never(), capacity = 0.23f)
+
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_BATTERY, "24".toByteArray())
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.24f)
+
+ // When the battery level from the metadata is no longer valid, we fall back to using the
+ // Bluetooth battery level.
+ metadataListener.value!!.onMetadataChanged(
+ bluetoothDevice, BluetoothDevice.METADATA_MAIN_BATTERY, null)
+ listener.verifyNotified(BT_DEVICE_ID, capacity = 0.23f)
+ }
+}
diff --git a/tests/Input/src/com/android/server/input/ConfigurationProcessorTest.java b/tests/Input/src/com/android/server/input/ConfigurationProcessorTest.java
new file mode 100644
index 0000000..9a49d91
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/ConfigurationProcessorTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2018 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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.InputStream;
+import java.util.Map;
+
+/**
+ * Build/Install/Run:
+ * atest ConfigurationProcessorTest
+ */
+@RunWith(AndroidJUnit4.class)
+public class ConfigurationProcessorTest {
+
+ private Context mContext;
+
+ @Before
+ public void setUp() throws Exception {
+ mContext = InstrumentationRegistry.getInstrumentation().getContext();
+ }
+
+ @Test
+ public void testGetInputPortAssociations() {
+ final int res = com.android.test.input.R.raw.input_port_associations;
+ InputStream xml = mContext.getResources().openRawResource(res);
+ Map<String, Integer> associations = null;
+ try {
+ associations = ConfigurationProcessor.processInputPortAssociations(xml);
+ } catch (Exception e) {
+ fail("Could not process xml file for input associations");
+ }
+ assertNotNull(associations);
+ assertEquals(2, associations.size());
+ assertEquals(0, associations.get("USB1").intValue());
+ assertEquals(1, associations.get("USB2").intValue());
+ }
+
+ @Test
+ public void testGetInputPortAssociationsBadDisplayport() {
+ final int res =
+ com.android.test.input.R.raw.input_port_associations_bad_displayport;
+ InputStream xml = mContext.getResources().openRawResource(res);
+ Map<String, Integer> associations = null;
+ try {
+ associations = ConfigurationProcessor.processInputPortAssociations(xml);
+ } catch (Exception e) {
+ fail("Could not process xml file for input associations");
+ }
+ assertNotNull(associations);
+ assertEquals(0, associations.size());
+ }
+
+ @Test
+ public void testGetInputPortAssociationsEmptyConfig() {
+ final int res = com.android.test.input.R.raw.input_port_associations_bad_xml;
+ InputStream xml = mContext.getResources().openRawResource(res);
+ try {
+ ConfigurationProcessor.processInputPortAssociations(xml);
+ fail("Parsing should fail, because xml contains bad data");
+ } catch (Exception e) {
+ // This is expected
+ }
+ }
+}
diff --git a/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt b/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt
new file mode 100644
index 0000000..2238e1b
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/InputManagerServiceTests.kt
@@ -0,0 +1,399 @@
+/*
+ * 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.display.DisplayViewport
+import android.hardware.input.InputManager
+import android.hardware.input.InputManagerGlobal
+import android.os.IInputConstants
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.provider.Settings
+import android.test.mock.MockContentResolver
+import android.view.Display
+import android.view.PointerIcon
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.internal.util.test.FakeSettingsProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyFloat
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.Mockito.verifyZeroInteractions
+import org.mockito.junit.MockitoJUnit
+import org.mockito.stubbing.OngoingStubbing
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/**
+ * Tests for {@link InputManagerService}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:InputManagerServiceTests
+ */
+@Presubmit
+class InputManagerServiceTests {
+
+ @get:Rule
+ val mockitoRule = MockitoJUnit.rule()!!
+
+ @get:Rule
+ val fakeSettingsProviderRule = FakeSettingsProvider.rule()!!
+
+ @Mock
+ private lateinit var native: NativeInputManagerService
+
+ @Mock
+ private lateinit var wmCallbacks: InputManagerService.WindowManagerCallbacks
+
+ @Mock
+ private lateinit var uEventManager: UEventManager
+
+ private lateinit var service: InputManagerService
+ private lateinit var localService: InputManagerInternal
+ private lateinit var context: Context
+ private lateinit var testLooper: TestLooper
+ private lateinit var contentResolver: MockContentResolver
+
+ @Before
+ fun setup() {
+ context = spy(ContextWrapper(InstrumentationRegistry.getInstrumentation().getContext()))
+ contentResolver = MockContentResolver(context)
+ contentResolver.addProvider(Settings.AUTHORITY, FakeSettingsProvider())
+ whenever(context.contentResolver).thenReturn(contentResolver)
+ testLooper = TestLooper()
+ service =
+ InputManagerService(object : InputManagerService.Injector(
+ context, testLooper.looper, uEventManager) {
+ override fun getNativeService(
+ service: InputManagerService?
+ ): NativeInputManagerService {
+ return native
+ }
+
+ override fun registerLocalService(service: InputManagerInternal?) {
+ localService = service!!
+ }
+ })
+ InputManagerGlobal.resetInstance(service)
+ val inputManager = InputManager(context)
+ whenever(context.getSystemService(InputManager::class.java)).thenReturn(inputManager)
+ whenever(context.getSystemService(Context.INPUT_SERVICE)).thenReturn(inputManager)
+
+ assertTrue("Local service must be registered", this::localService.isInitialized)
+ service.setWindowManagerCallbacks(wmCallbacks)
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ @Test
+ fun testStart() {
+ verifyZeroInteractions(native)
+
+ service.start()
+ verify(native).start()
+ }
+
+ @Test
+ fun testInputSettingsUpdatedOnSystemRunning() {
+ verifyZeroInteractions(native)
+
+ service.systemRunning()
+
+ verify(native).setPointerSpeed(anyInt())
+ verify(native).setTouchpadPointerSpeed(anyInt())
+ verify(native).setTouchpadNaturalScrollingEnabled(anyBoolean())
+ verify(native).setTouchpadTapToClickEnabled(anyBoolean())
+ verify(native).setTouchpadRightClickZoneEnabled(anyBoolean())
+ verify(native).setShowTouches(anyBoolean())
+ verify(native).reloadPointerIcons()
+ verify(native).setMotionClassifierEnabled(anyBoolean())
+ verify(native).setMaximumObscuringOpacityForTouch(anyFloat())
+ verify(native).setStylusPointerIconEnabled(anyBoolean())
+ verify(native).setKeyRepeatConfiguration(anyInt(), anyInt())
+ }
+
+ @Test
+ fun testPointerDisplayUpdatesWhenDisplayViewportsChanged() {
+ val displayId = 123
+ whenever(wmCallbacks.pointerDisplayId).thenReturn(displayId)
+ val viewports = listOf<DisplayViewport>()
+ localService.setDisplayViewports(viewports)
+ verify(native).setDisplayViewports(any(Array<DisplayViewport>::class.java))
+ verify(native).setPointerDisplayId(displayId)
+
+ val x = 42f
+ val y = 314f
+ service.onPointerDisplayIdChanged(displayId, x, y)
+ testLooper.dispatchNext()
+ verify(wmCallbacks).notifyPointerDisplayIdChanged(displayId, x, y)
+ }
+
+ @Test
+ fun testSetVirtualMousePointerDisplayId() {
+ // Set the virtual mouse pointer displayId, and ensure that the calling thread is blocked
+ // until the native callback happens.
+ var countDownLatch = CountDownLatch(1)
+ val overrideDisplayId = 123
+ Thread {
+ assertTrue("Setting virtual pointer display should succeed",
+ localService.setVirtualMousePointerDisplayId(overrideDisplayId))
+ countDownLatch.countDown()
+ }.start()
+ assertFalse("Setting virtual pointer display should block",
+ countDownLatch.await(100, TimeUnit.MILLISECONDS))
+
+ val x = 42f
+ val y = 314f
+ service.onPointerDisplayIdChanged(overrideDisplayId, x, y)
+ testLooper.dispatchNext()
+ verify(wmCallbacks).notifyPointerDisplayIdChanged(overrideDisplayId, x, y)
+ assertTrue("Native callback unblocks calling thread",
+ countDownLatch.await(100, TimeUnit.MILLISECONDS))
+ verify(native).setPointerDisplayId(overrideDisplayId)
+
+ // Ensure that setting the same override again succeeds immediately.
+ assertTrue("Setting the same virtual mouse pointer displayId again should succeed",
+ localService.setVirtualMousePointerDisplayId(overrideDisplayId))
+
+ // Ensure that we did not query WM for the pointerDisplayId when setting the override
+ verify(wmCallbacks, never()).pointerDisplayId
+
+ // Unset the virtual mouse pointer displayId, and ensure that we query WM for the new
+ // pointer displayId and the calling thread is blocked until the native callback happens.
+ countDownLatch = CountDownLatch(1)
+ val pointerDisplayId = 42
+ `when`(wmCallbacks.pointerDisplayId).thenReturn(pointerDisplayId)
+ Thread {
+ assertTrue("Unsetting virtual mouse pointer displayId should succeed",
+ localService.setVirtualMousePointerDisplayId(Display.INVALID_DISPLAY))
+ countDownLatch.countDown()
+ }.start()
+ assertFalse("Unsetting virtual mouse pointer displayId should block",
+ countDownLatch.await(100, TimeUnit.MILLISECONDS))
+
+ service.onPointerDisplayIdChanged(pointerDisplayId, x, y)
+ testLooper.dispatchNext()
+ verify(wmCallbacks).notifyPointerDisplayIdChanged(pointerDisplayId, x, y)
+ assertTrue("Native callback unblocks calling thread",
+ countDownLatch.await(100, TimeUnit.MILLISECONDS))
+ verify(native).setPointerDisplayId(pointerDisplayId)
+ }
+
+ @Test
+ fun testSetVirtualMousePointerDisplayId_unsuccessfulUpdate() {
+ // Set the virtual mouse pointer displayId, and ensure that the calling thread is blocked
+ // until the native callback happens.
+ val countDownLatch = CountDownLatch(1)
+ val overrideDisplayId = 123
+ Thread {
+ assertFalse("Setting virtual pointer display should be unsuccessful",
+ localService.setVirtualMousePointerDisplayId(overrideDisplayId))
+ countDownLatch.countDown()
+ }.start()
+ assertFalse("Setting virtual pointer display should block",
+ countDownLatch.await(100, TimeUnit.MILLISECONDS))
+
+ val x = 42f
+ val y = 314f
+ // Assume the native callback updates the pointerDisplayId to the incorrect value.
+ service.onPointerDisplayIdChanged(Display.INVALID_DISPLAY, x, y)
+ testLooper.dispatchNext()
+ verify(wmCallbacks).notifyPointerDisplayIdChanged(Display.INVALID_DISPLAY, x, y)
+ assertTrue("Native callback unblocks calling thread",
+ countDownLatch.await(100, TimeUnit.MILLISECONDS))
+ verify(native).setPointerDisplayId(overrideDisplayId)
+ }
+
+ @Test
+ fun testSetVirtualMousePointerDisplayId_competingRequests() {
+ val firstRequestSyncLatch = CountDownLatch(1)
+ doAnswer {
+ firstRequestSyncLatch.countDown()
+ }.`when`(native).setPointerDisplayId(anyInt())
+
+ val firstRequestLatch = CountDownLatch(1)
+ val firstOverride = 123
+ Thread {
+ assertFalse("Setting virtual pointer display from thread 1 should be unsuccessful",
+ localService.setVirtualMousePointerDisplayId(firstOverride))
+ firstRequestLatch.countDown()
+ }.start()
+ assertFalse("Setting virtual pointer display should block",
+ firstRequestLatch.await(100, TimeUnit.MILLISECONDS))
+
+ assertTrue("Wait for first thread's request should succeed",
+ firstRequestSyncLatch.await(100, TimeUnit.MILLISECONDS))
+
+ val secondRequestLatch = CountDownLatch(1)
+ val secondOverride = 42
+ Thread {
+ assertTrue("Setting virtual mouse pointer from thread 2 should be successful",
+ localService.setVirtualMousePointerDisplayId(secondOverride))
+ secondRequestLatch.countDown()
+ }.start()
+ assertFalse("Setting virtual mouse pointer should block",
+ secondRequestLatch.await(100, TimeUnit.MILLISECONDS))
+
+ val x = 42f
+ val y = 314f
+ // Assume the native callback updates directly to the second request.
+ service.onPointerDisplayIdChanged(secondOverride, x, y)
+ testLooper.dispatchNext()
+ verify(wmCallbacks).notifyPointerDisplayIdChanged(secondOverride, x, y)
+ assertTrue("Native callback unblocks first thread",
+ firstRequestLatch.await(100, TimeUnit.MILLISECONDS))
+ assertTrue("Native callback unblocks second thread",
+ secondRequestLatch.await(100, TimeUnit.MILLISECONDS))
+ verify(native, times(2)).setPointerDisplayId(anyInt())
+ }
+
+ @Test
+ fun onDisplayRemoved_resetAllAdditionalInputProperties() {
+ setVirtualMousePointerDisplayIdAndVerify(10)
+
+ localService.setPointerIconVisible(false, 10)
+ verify(native).setPointerIconType(eq(PointerIcon.TYPE_NULL))
+ localService.setPointerAcceleration(5f, 10)
+ verify(native).setPointerAcceleration(eq(5f))
+
+ service.onDisplayRemoved(10)
+ verify(native).displayRemoved(eq(10))
+ verify(native).setPointerIconType(eq(PointerIcon.TYPE_NOT_SPECIFIED))
+ verify(native).setPointerAcceleration(
+ eq(IInputConstants.DEFAULT_POINTER_ACCELERATION.toFloat()))
+ verifyNoMoreInteractions(native)
+
+ // This call should not block because the virtual mouse pointer override was never removed.
+ localService.setVirtualMousePointerDisplayId(10)
+
+ verify(native).setPointerDisplayId(eq(10))
+ verifyNoMoreInteractions(native)
+ }
+
+ @Test
+ fun updateAdditionalInputPropertiesForOverrideDisplay() {
+ setVirtualMousePointerDisplayIdAndVerify(10)
+
+ localService.setPointerIconVisible(false, 10)
+ verify(native).setPointerIconType(eq(PointerIcon.TYPE_NULL))
+ localService.setPointerAcceleration(5f, 10)
+ verify(native).setPointerAcceleration(eq(5f))
+
+ localService.setPointerIconVisible(true, 10)
+ verify(native).setPointerIconType(eq(PointerIcon.TYPE_NOT_SPECIFIED))
+ localService.setPointerAcceleration(1f, 10)
+ verify(native).setPointerAcceleration(eq(1f))
+
+ // Verify that setting properties on a different display is not propagated until the
+ // pointer is moved to that display.
+ localService.setPointerIconVisible(false, 20)
+ localService.setPointerAcceleration(6f, 20)
+ verifyNoMoreInteractions(native)
+
+ clearInvocations(native)
+ setVirtualMousePointerDisplayIdAndVerify(20)
+
+ verify(native).setPointerIconType(eq(PointerIcon.TYPE_NULL))
+ verify(native).setPointerAcceleration(eq(6f))
+ }
+
+ @Test
+ fun setAdditionalInputPropertiesBeforeOverride() {
+ localService.setPointerIconVisible(false, 10)
+ localService.setPointerAcceleration(5f, 10)
+
+ verifyNoMoreInteractions(native)
+
+ setVirtualMousePointerDisplayIdAndVerify(10)
+
+ verify(native).setPointerIconType(eq(PointerIcon.TYPE_NULL))
+ verify(native).setPointerAcceleration(eq(5f))
+ }
+
+ @Test
+ fun setDeviceTypeAssociation_setsDeviceTypeAssociation() {
+ val inputPort = "inputPort"
+ val type = "type"
+
+ localService.setTypeAssociation(inputPort, type)
+
+ assertThat(service.getDeviceTypeAssociations()).asList().containsExactly(inputPort, type)
+ .inOrder()
+ }
+
+ @Test
+ fun setAndUnsetDeviceTypeAssociation_deviceTypeAssociationIsMissing() {
+ val inputPort = "inputPort"
+ val type = "type"
+
+ localService.setTypeAssociation(inputPort, type)
+ localService.unsetTypeAssociation(inputPort)
+
+ assertTrue(service.getDeviceTypeAssociations().isEmpty())
+ }
+
+ @Test
+ fun testAddAndRemoveVirtualmKeyboardLayoutAssociation() {
+ val inputPort = "input port"
+ val languageTag = "language"
+ val layoutType = "layoutType"
+ localService.addKeyboardLayoutAssociation(inputPort, languageTag, layoutType)
+ verify(native).changeKeyboardLayoutAssociation()
+
+ localService.removeKeyboardLayoutAssociation(inputPort)
+ verify(native, times(2)).changeKeyboardLayoutAssociation()
+ }
+
+ private fun setVirtualMousePointerDisplayIdAndVerify(overrideDisplayId: Int) {
+ val thread = Thread { localService.setVirtualMousePointerDisplayId(overrideDisplayId) }
+ thread.start()
+
+ // Allow some time for the set override call to park while waiting for the native callback.
+ Thread.sleep(100 /*millis*/)
+ verify(native).setPointerDisplayId(overrideDisplayId)
+
+ service.onPointerDisplayIdChanged(overrideDisplayId, 0f, 0f)
+ testLooper.dispatchNext()
+ verify(wmCallbacks).notifyPointerDisplayIdChanged(overrideDisplayId, 0f, 0f)
+ thread.join(100 /*millis*/)
+ }
+}
+
+private fun <T> whenever(methodCall: T): OngoingStubbing<T> = `when`(methodCall)
diff --git a/tests/Input/src/com/android/server/input/KeyRemapperTests.kt b/tests/Input/src/com/android/server/input/KeyRemapperTests.kt
new file mode 100644
index 0000000..aeb8133
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/KeyRemapperTests.kt
@@ -0,0 +1,190 @@
+/*
+ * 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.hardware.input.InputManagerGlobal
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.provider.Settings
+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 InputTests: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
+ )
+ InputManagerGlobal.resetInstance(iInputManager)
+ val inputManager = InputManager(context)
+ Mockito.`when`(context.getSystemService(Mockito.eq(Context.INPUT_SERVICE)))
+ .thenReturn(inputManager)
+ Mockito.`when`(iInputManager.inputDeviceIds).thenReturn(intArrayOf(DEVICE_ID))
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ @Test
+ fun testKeyRemapping_whenRemappingEnabled() {
+ ModifierRemappingFlag(true).use {
+ 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
+ )
+ }
+ }
+
+ @Test
+ fun testKeyRemapping_whenRemappingDisabled() {
+ ModifierRemappingFlag(false).use {
+ val keyboard = createKeyboard(DEVICE_ID)
+ Mockito.`when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboard)
+
+ mKeyRemapper.remapKey(REMAPPABLE_KEYS[0], REMAPPABLE_KEYS[1])
+ testLooper.dispatchAll()
+
+ val remapping = mKeyRemapper.keyRemapping
+ assertEquals(
+ "Remapping should not be done if modifier key remapping is disabled",
+ 0,
+ remapping.size
+ )
+ }
+ }
+
+ private inner class ModifierRemappingFlag constructor(enabled: Boolean) : AutoCloseable {
+ init {
+ Settings.Global.putString(
+ context.contentResolver,
+ "settings_new_keyboard_modifier_key", enabled.toString()
+ )
+ }
+
+ override fun close() {
+ Settings.Global.putString(
+ context.contentResolver,
+ "settings_new_keyboard_modifier_key",
+ ""
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Input/src/com/android/server/input/KeyboardBacklightControllerTests.kt b/tests/Input/src/com/android/server/input/KeyboardBacklightControllerTests.kt
new file mode 100644
index 0000000..3344b7c
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/KeyboardBacklightControllerTests.kt
@@ -0,0 +1,846 @@
+/*
+ * 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.animation.ValueAnimator
+import android.content.Context
+import android.content.ContextWrapper
+import android.graphics.Color
+import android.hardware.input.IInputManager
+import android.hardware.input.IKeyboardBacklightListener
+import android.hardware.input.IKeyboardBacklightState
+import android.hardware.input.InputManager
+import android.hardware.input.InputManagerGlobal
+import android.hardware.lights.Light
+import android.os.UEventObserver
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.view.InputDevice
+import androidx.test.annotation.UiThreadTest
+import androidx.test.core.app.ApplicationProvider
+import com.android.server.input.KeyboardBacklightController.DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL
+import com.android.server.input.KeyboardBacklightController.MAX_BRIGHTNESS_CHANGE_STEPS
+import com.android.server.input.KeyboardBacklightController.USER_INACTIVITY_THRESHOLD_MILLIS
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.Mockito.any
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.`when`
+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()
+
+private fun createLight(lightId: Int, lightType: Int): Light =
+ createLight(
+ lightId,
+ lightType,
+ null
+ )
+
+private fun createLight(lightId: Int, lightType: Int, suggestedBrightnessLevels: IntArray?): Light =
+ Light(
+ lightId,
+ "Light $lightId",
+ 1,
+ lightType,
+ Light.LIGHT_CAPABILITY_BRIGHTNESS,
+ suggestedBrightnessLevels
+ )
+/**
+ * Tests for {@link KeyboardBacklightController}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:KeyboardBacklightControllerTests
+ */
+@Presubmit
+class KeyboardBacklightControllerTests {
+ companion object {
+ const val DEVICE_ID = 1
+ const val LIGHT_ID = 2
+ const val SECOND_LIGHT_ID = 3
+ const val MAX_BRIGHTNESS = 255
+ }
+
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ @Mock
+ private lateinit var iInputManager: IInputManager
+ @Mock
+ private lateinit var native: NativeInputManagerService
+ @Mock
+ private lateinit var uEventManager: UEventManager
+ private lateinit var keyboardBacklightController: KeyboardBacklightController
+ private lateinit var context: Context
+ private lateinit var dataStore: PersistentDataStore
+ private lateinit var testLooper: TestLooper
+ private var lightColorMap: HashMap<Int, Int> = HashMap()
+ private var lastBacklightState: KeyboardBacklightState? = null
+ private var sysfsNodeChanges = 0
+ private var lastAnimationValues = IntArray(2)
+
+ @Before
+ fun setup() {
+ context = 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()
+ keyboardBacklightController = KeyboardBacklightController(context, native, dataStore,
+ testLooper.looper, FakeAnimatorFactory(), uEventManager)
+ InputManagerGlobal.resetInstance(iInputManager)
+ val inputManager = InputManager(context)
+ `when`(context.getSystemService(eq(Context.INPUT_SERVICE))).thenReturn(inputManager)
+ `when`(iInputManager.inputDeviceIds).thenReturn(intArrayOf(DEVICE_ID))
+ `when`(native.setLightColor(anyInt(), anyInt(), anyInt())).then {
+ val args = it.arguments
+ lightColorMap.put(args[1] as Int, args[2] as Int)
+ }
+ `when`(native.getLightColor(anyInt(), anyInt())).thenAnswer {
+ val args = it.arguments
+ lightColorMap.getOrDefault(args[1] as Int, 0)
+ }
+ lightColorMap.clear()
+ `when`(native.sysfsNodeChanged(any())).then {
+ sysfsNodeChanges++
+ }
+ }
+
+ @After
+ fun tearDown() {
+ InputManagerGlobal.clearInstance()
+ }
+
+ @Test
+ fun testKeyboardBacklightIncrementDecrement() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ assertIncrementDecrementForLevels(keyboardWithBacklight, keyboardBacklight,
+ DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL)
+ }
+ }
+
+ @Test
+ fun testKeyboardWithoutBacklight() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithoutBacklight = createKeyboard(DEVICE_ID)
+ val keyboardInputLight = createLight(LIGHT_ID, Light.LIGHT_TYPE_INPUT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithoutBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardInputLight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ incrementKeyboardBacklight(DEVICE_ID)
+ assertTrue("Non Keyboard backlights should not change", lightColorMap.isEmpty())
+ }
+ }
+
+ @Test
+ fun testKeyboardWithMultipleLight() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ val keyboardInputLight = createLight(SECOND_LIGHT_ID, Light.LIGHT_TYPE_INPUT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(
+ listOf(
+ keyboardBacklight,
+ keyboardInputLight
+ )
+ )
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ incrementKeyboardBacklight(DEVICE_ID)
+ assertEquals("Only keyboard backlights should change", 1, lightColorMap.size)
+ assertNotNull("Keyboard backlight should change", lightColorMap[LIGHT_ID])
+ assertNull("Input lights should not change", lightColorMap[SECOND_LIGHT_ID])
+ }
+ }
+
+ @Test
+ fun testRestoreBacklightOnInputDeviceAdded() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+
+ for (level in 1 until DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL.size) {
+ dataStore.setKeyboardBacklightBrightness(
+ keyboardWithBacklight.descriptor,
+ LIGHT_ID,
+ DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[level] - 1
+ )
+
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchNext()
+ assertEquals(
+ "Keyboard backlight level should be restored to the level saved in the " +
+ "data store",
+ Color.argb(DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[level], 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+ keyboardBacklightController.onInputDeviceRemoved(DEVICE_ID)
+ }
+ }
+ }
+
+ @Test
+ fun testRestoreBacklightOnInputDeviceChanged() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ dataStore.setKeyboardBacklightBrightness(
+ keyboardWithBacklight.descriptor,
+ LIGHT_ID,
+ MAX_BRIGHTNESS
+ )
+
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchNext()
+ assertTrue(
+ "Keyboard backlight should not be changed until its added",
+ lightColorMap.isEmpty()
+ )
+
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceChanged(DEVICE_ID)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchNext()
+ assertEquals(
+ "Keyboard backlight level should be restored to the level saved in the data store",
+ Color.argb(MAX_BRIGHTNESS, 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklight_registerUnregisterListener() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ val maxLevel = DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL.size - 1
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ // Register backlight listener
+ val listener = KeyboardBacklightListener()
+ keyboardBacklightController.registerKeyboardBacklightListener(listener, 0)
+
+ lastBacklightState = null
+ keyboardBacklightController.incrementKeyboardBacklight(DEVICE_ID)
+ testLooper.dispatchNext()
+
+ assertEquals(
+ "Backlight state device Id should be $DEVICE_ID",
+ DEVICE_ID,
+ lastBacklightState!!.deviceId
+ )
+ assertEquals(
+ "Backlight state brightnessLevel should be 1",
+ 1,
+ lastBacklightState!!.brightnessLevel
+ )
+ assertEquals(
+ "Backlight state maxBrightnessLevel should be $maxLevel",
+ maxLevel,
+ lastBacklightState!!.maxBrightnessLevel
+ )
+ assertEquals(
+ "Backlight state isTriggeredByKeyPress should be true",
+ true,
+ lastBacklightState!!.isTriggeredByKeyPress
+ )
+
+ // Unregister listener
+ keyboardBacklightController.unregisterKeyboardBacklightListener(listener, 0)
+
+ lastBacklightState = null
+ incrementKeyboardBacklight(DEVICE_ID)
+
+ assertNull("Listener should not receive any updates", lastBacklightState)
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklight_userActivity() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ dataStore.setKeyboardBacklightBrightness(
+ keyboardWithBacklight.descriptor,
+ LIGHT_ID,
+ MAX_BRIGHTNESS
+ )
+
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchNext()
+ assertEquals(
+ "Keyboard backlight level should be restored to the level saved in the data store",
+ Color.argb(MAX_BRIGHTNESS, 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+
+ testLooper.moveTimeForward(USER_INACTIVITY_THRESHOLD_MILLIS + 1000)
+ testLooper.dispatchNext()
+ assertEquals(
+ "Keyboard backlight level should be turned off after inactivity",
+ 0,
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklight_displayOnOff() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ dataStore.setKeyboardBacklightBrightness(
+ keyboardWithBacklight.descriptor,
+ LIGHT_ID,
+ MAX_BRIGHTNESS
+ )
+
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ keyboardBacklightController.handleInteractiveStateChange(true /* isDisplayOn */)
+ assertEquals(
+ "Keyboard backlight level should be restored to the level saved in the data " +
+ "store when display turned on",
+ Color.argb(MAX_BRIGHTNESS, 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+
+ keyboardBacklightController.handleInteractiveStateChange(false /* isDisplayOn */)
+ assertEquals(
+ "Keyboard backlight level should be turned off after display is turned off",
+ 0,
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklightSysfsNodeAdded_AfterInputDeviceAdded() {
+ var counter = sysfsNodeChanges
+ keyboardBacklightController.onKeyboardBacklightUEvent(UEventObserver.UEvent(
+ "ACTION=add\u0000SUBSYSTEM=leds\u0000DEVPATH=/xyz/leds/abc::no_backlight\u0000"
+ ))
+ assertEquals(
+ "Should not reload sysfs node if UEvent path doesn't contain kbd_backlight",
+ counter,
+ sysfsNodeChanges
+ )
+
+ keyboardBacklightController.onKeyboardBacklightUEvent(UEventObserver.UEvent(
+ "ACTION=add\u0000SUBSYSTEM=power\u0000DEVPATH=/xyz/leds/abc::kbd_backlight\u0000"
+ ))
+ assertEquals(
+ "Should not reload sysfs node if UEvent doesn't belong to subsystem LED",
+ counter,
+ sysfsNodeChanges
+ )
+
+ keyboardBacklightController.onKeyboardBacklightUEvent(UEventObserver.UEvent(
+ "ACTION=remove\u0000SUBSYSTEM=leds\u0000DEVPATH=/xyz/leds/abc::kbd_backlight\u0000"
+ ))
+ assertEquals(
+ "Should not reload sysfs node if UEvent doesn't have ACTION(add)",
+ counter,
+ sysfsNodeChanges
+ )
+
+ keyboardBacklightController.onKeyboardBacklightUEvent(UEventObserver.UEvent(
+ "ACTION=add\u0000SUBSYSTEM=leds\u0000DEVPATH=/xyz/pqr/abc::kbd_backlight\u0000"
+ ))
+ assertEquals(
+ "Should not reload sysfs node if UEvent path doesn't belong to leds/ directory",
+ counter,
+ sysfsNodeChanges
+ )
+
+ keyboardBacklightController.onKeyboardBacklightUEvent(UEventObserver.UEvent(
+ "ACTION=add\u0000SUBSYSTEM=leds\u0000DEVPATH=/xyz/leds/abc::kbd_backlight\u0000"
+ ))
+ assertEquals(
+ "Should reload sysfs node if a valid Keyboard backlight LED UEvent occurs",
+ ++counter,
+ sysfsNodeChanges
+ )
+
+ keyboardBacklightController.onKeyboardBacklightUEvent(UEventObserver.UEvent(
+ "ACTION=add\u0000SUBSYSTEM=leds\u0000DEVPATH=/xyz/leds/abc:kbd_backlight:red\u0000"
+ ))
+ assertEquals(
+ "Should reload sysfs node if a valid Keyboard backlight LED UEvent occurs",
+ ++counter,
+ sysfsNodeChanges
+ )
+ }
+
+ @Test
+ @UiThreadTest
+ fun testKeyboardBacklightAnimation_onChangeLevels() {
+ KeyboardBacklightFlags(
+ animationEnabled = true,
+ customLevelsEnabled = false,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ incrementKeyboardBacklight(DEVICE_ID)
+ assertEquals(
+ "Should start animation from level 0",
+ DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[0],
+ lastAnimationValues[0]
+ )
+ assertEquals(
+ "Should start animation to level 1",
+ DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[1],
+ lastAnimationValues[1]
+ )
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklightPreferredLevels() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = true,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val suggestedLevels = intArrayOf(0, 22, 63, 135, 196, 255)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT,
+ suggestedLevels)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ assertIncrementDecrementForLevels(keyboardWithBacklight, keyboardBacklight,
+ suggestedLevels)
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklightPreferredLevels_moreThanMax_shouldUseDefault() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = true,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val suggestedLevels = IntArray(MAX_BRIGHTNESS_CHANGE_STEPS + 1) { 10 * (it + 1) }
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT,
+ suggestedLevels)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ assertIncrementDecrementForLevels(keyboardWithBacklight, keyboardBacklight,
+ DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL)
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklightPreferredLevels_mustHaveZeroAndMaxBrightnessAsBounds() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = true,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val suggestedLevels = intArrayOf(22, 63, 135, 196)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT,
+ suggestedLevels)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ // Framework will add the lowest and maximum levels if not provided via config
+ assertIncrementDecrementForLevels(keyboardWithBacklight, keyboardBacklight,
+ intArrayOf(0, 22, 63, 135, 196, 255))
+ }
+ }
+
+ @Test
+ fun testKeyboardBacklightPreferredLevels_dropsOutOfBoundsLevels() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = true,
+ ambientControlEnabled = false
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val suggestedLevels = intArrayOf(22, 63, 135, 400, 196, 1000)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT,
+ suggestedLevels)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+
+ // Framework will drop out of bound levels in the config
+ assertIncrementDecrementForLevels(keyboardWithBacklight, keyboardBacklight,
+ intArrayOf(0, 22, 63, 135, 196, 255))
+ }
+ }
+
+ @Test
+ fun testAmbientBacklightControl_doesntRestoreBacklightLevel() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = true
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+
+ dataStore.setKeyboardBacklightBrightness(
+ keyboardWithBacklight.descriptor,
+ LIGHT_ID,
+ DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[1]
+ )
+
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchNext()
+ assertNull(
+ "Keyboard backlight level should not be restored to the saved level",
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ @Test
+ fun testAmbientBacklightControl_doesntBackupBacklightLevel() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = true
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ incrementKeyboardBacklight(DEVICE_ID)
+ assertFalse(
+ "Light value should not be backed up if ambient control is enabled",
+ dataStore.getKeyboardBacklightBrightness(
+ keyboardWithBacklight.descriptor, LIGHT_ID
+ ).isPresent
+ )
+ }
+ }
+
+ @Test
+ fun testAmbientBacklightControl_incrementLevel_afterAmbientChange() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = true
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ sendAmbientBacklightValue(1)
+ assertEquals(
+ "Light value should be changed to ambient provided value",
+ Color.argb(1, 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+
+ incrementKeyboardBacklight(DEVICE_ID)
+
+ assertEquals(
+ "Light value for level after increment post Ambient change is mismatched",
+ Color.argb(DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[1], 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ @Test
+ fun testAmbientBacklightControl_decrementLevel_afterAmbientChange() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = true
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ sendAmbientBacklightValue(254)
+ assertEquals(
+ "Light value should be changed to ambient provided value",
+ Color.argb(254, 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+
+ decrementKeyboardBacklight(DEVICE_ID)
+
+ val numLevels = DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL.size
+ assertEquals(
+ "Light value for level after decrement post Ambient change is mismatched",
+ Color.argb(DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[numLevels - 2], 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ @Test
+ fun testAmbientBacklightControl_ambientChanges_afterManualChange() {
+ KeyboardBacklightFlags(
+ animationEnabled = false,
+ customLevelsEnabled = false,
+ ambientControlEnabled = true
+ ).use {
+ val keyboardWithBacklight = createKeyboard(DEVICE_ID)
+ val keyboardBacklight = createLight(LIGHT_ID, Light.LIGHT_TYPE_KEYBOARD_BACKLIGHT)
+ `when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardWithBacklight)
+ `when`(iInputManager.getLights(DEVICE_ID)).thenReturn(listOf(keyboardBacklight))
+ keyboardBacklightController.onInputDeviceAdded(DEVICE_ID)
+ incrementKeyboardBacklight(DEVICE_ID)
+ assertEquals(
+ "Light value should be changed to the first level",
+ Color.argb(DEFAULT_BRIGHTNESS_VALUE_FOR_LEVEL[1], 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+
+ sendAmbientBacklightValue(100)
+ assertNotEquals(
+ "Light value should not change based on ambient changes after manual changes",
+ Color.argb(100, 0, 0, 0),
+ lightColorMap[LIGHT_ID]
+ )
+ }
+ }
+
+ private fun assertIncrementDecrementForLevels(
+ device: InputDevice,
+ light: Light,
+ expectedLevels: IntArray
+ ) {
+ val deviceId = device.id
+ val lightId = light.id
+ for (level in 1 until expectedLevels.size) {
+ incrementKeyboardBacklight(deviceId)
+ assertEquals(
+ "Light value for level $level mismatched",
+ Color.argb(expectedLevels[level], 0, 0, 0),
+ lightColorMap[lightId]
+ )
+ assertEquals(
+ "Light value for level $level must be correctly stored in the datastore",
+ expectedLevels[level],
+ dataStore.getKeyboardBacklightBrightness(device.descriptor, lightId).asInt
+ )
+ }
+
+ // Increment above max level
+ incrementKeyboardBacklight(deviceId)
+ assertEquals(
+ "Light value for max level mismatched",
+ Color.argb(MAX_BRIGHTNESS, 0, 0, 0),
+ lightColorMap[lightId]
+ )
+ assertEquals(
+ "Light value for max level must be correctly stored in the datastore",
+ MAX_BRIGHTNESS,
+ dataStore.getKeyboardBacklightBrightness(device.descriptor, lightId).asInt
+ )
+
+ for (level in expectedLevels.size - 2 downTo 0) {
+ decrementKeyboardBacklight(deviceId)
+ assertEquals(
+ "Light value for level $level mismatched",
+ Color.argb(expectedLevels[level], 0, 0, 0),
+ lightColorMap[lightId]
+ )
+ assertEquals(
+ "Light value for level $level must be correctly stored in the datastore",
+ expectedLevels[level],
+ dataStore.getKeyboardBacklightBrightness(device.descriptor, lightId).asInt
+ )
+ }
+
+ // Decrement below min level
+ decrementKeyboardBacklight(deviceId)
+ assertEquals(
+ "Light value for min level mismatched",
+ Color.argb(0, 0, 0, 0),
+ lightColorMap[lightId]
+ )
+ assertEquals(
+ "Light value for min level must be correctly stored in the datastore",
+ 0,
+ dataStore.getKeyboardBacklightBrightness(device.descriptor, lightId).asInt
+ )
+ }
+
+ inner class KeyboardBacklightListener : IKeyboardBacklightListener.Stub() {
+ override fun onBrightnessChanged(
+ deviceId: Int,
+ state: IKeyboardBacklightState,
+ isTriggeredByKeyPress: Boolean
+ ) {
+ lastBacklightState = KeyboardBacklightState(
+ deviceId,
+ state.brightnessLevel,
+ state.maxBrightnessLevel,
+ isTriggeredByKeyPress
+ )
+ }
+ }
+
+ private fun incrementKeyboardBacklight(deviceId: Int) {
+ keyboardBacklightController.incrementKeyboardBacklight(deviceId)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchAll()
+ }
+
+ private fun decrementKeyboardBacklight(deviceId: Int) {
+ keyboardBacklightController.decrementKeyboardBacklight(deviceId)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchAll()
+ }
+
+ private fun sendAmbientBacklightValue(brightnessValue: Int) {
+ keyboardBacklightController.handleAmbientLightValueChanged(brightnessValue)
+ keyboardBacklightController.notifyUserActivity()
+ testLooper.dispatchAll()
+ }
+
+ class KeyboardBacklightState(
+ val deviceId: Int,
+ val brightnessLevel: Int,
+ val maxBrightnessLevel: Int,
+ val isTriggeredByKeyPress: Boolean
+ )
+
+ private inner class KeyboardBacklightFlags constructor(
+ animationEnabled: Boolean,
+ customLevelsEnabled: Boolean,
+ ambientControlEnabled: Boolean
+ ) : AutoCloseable {
+ init {
+ InputFeatureFlagProvider.setKeyboardBacklightAnimationEnabled(animationEnabled)
+ InputFeatureFlagProvider.setKeyboardBacklightCustomLevelsEnabled(customLevelsEnabled)
+ InputFeatureFlagProvider
+ .setAmbientKeyboardBacklightControlEnabled(ambientControlEnabled)
+ }
+
+ override fun close() {
+ InputFeatureFlagProvider.clearOverrides()
+ }
+ }
+
+ private inner class FakeAnimatorFactory : KeyboardBacklightController.AnimatorFactory {
+ override fun makeIntAnimator(from: Int, to: Int): ValueAnimator {
+ lastAnimationValues[0] = from
+ lastAnimationValues[1] = to
+ return ValueAnimator.ofInt(from, to)
+ }
+ }
+}
diff --git a/tests/Input/src/com/android/server/input/KeyboardLayoutManagerTests.kt b/tests/Input/src/com/android/server/input/KeyboardLayoutManagerTests.kt
new file mode 100644
index 0000000..499330a
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/KeyboardLayoutManagerTests.kt
@@ -0,0 +1,903 @@
+/*
+ * Copyright (C) 2023 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.content.pm.ActivityInfo
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.content.pm.ServiceInfo
+import android.hardware.input.IInputManager
+import android.hardware.input.InputManager
+import android.hardware.input.InputManagerGlobal
+import android.hardware.input.KeyboardLayout
+import android.icu.util.ULocale
+import android.os.Bundle
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.provider.Settings
+import android.view.InputDevice
+import android.view.inputmethod.InputMethodInfo
+import android.view.inputmethod.InputMethodSubtype
+import androidx.test.core.R
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.assertThrows
+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,
+ vendorId: Int,
+ productId: Int,
+ languageTag: String,
+ layoutType: String
+): InputDevice =
+ InputDevice.Builder()
+ .setId(deviceId)
+ .setName("Device $deviceId")
+ .setDescriptor("descriptor $deviceId")
+ .setSources(InputDevice.SOURCE_KEYBOARD)
+ .setKeyboardType(InputDevice.KEYBOARD_TYPE_ALPHABETIC)
+ .setExternal(true)
+ .setVendorId(vendorId)
+ .setProductId(productId)
+ .setKeyboardLanguageTag(languageTag)
+ .setKeyboardLayoutType(layoutType)
+ .build()
+
+/**
+ * Tests for {@link Default UI} and {@link New UI}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:KeyboardLayoutManagerTests
+ */
+@Presubmit
+class KeyboardLayoutManagerTests {
+ companion object {
+ const val DEVICE_ID = 1
+ const val VENDOR_SPECIFIC_DEVICE_ID = 2
+ const val ENGLISH_DVORAK_DEVICE_ID = 3
+ const val ENGLISH_QWERTY_DEVICE_ID = 4
+ const val DEFAULT_VENDOR_ID = 123
+ const val DEFAULT_PRODUCT_ID = 456
+ const val USER_ID = 4
+ const val IME_ID = "ime_id"
+ const val PACKAGE_NAME = "KeyboardLayoutManagerTests"
+ const val RECEIVER_NAME = "DummyReceiver"
+ private const val ENGLISH_US_LAYOUT_NAME = "keyboard_layout_english_us"
+ private const val ENGLISH_UK_LAYOUT_NAME = "keyboard_layout_english_uk"
+ private const val VENDOR_SPECIFIC_LAYOUT_NAME = "keyboard_layout_vendorId:1,productId:1"
+ }
+
+ private val ENGLISH_US_LAYOUT_DESCRIPTOR = createLayoutDescriptor(ENGLISH_US_LAYOUT_NAME)
+ private val ENGLISH_UK_LAYOUT_DESCRIPTOR = createLayoutDescriptor(ENGLISH_UK_LAYOUT_NAME)
+ private val VENDOR_SPECIFIC_LAYOUT_DESCRIPTOR =
+ createLayoutDescriptor(VENDOR_SPECIFIC_LAYOUT_NAME)
+
+ @get:Rule
+ val rule = MockitoJUnit.rule()!!
+
+ @Mock
+ private lateinit var iInputManager: IInputManager
+
+ @Mock
+ private lateinit var native: NativeInputManagerService
+
+ @Mock
+ private lateinit var packageManager: PackageManager
+ private lateinit var keyboardLayoutManager: KeyboardLayoutManager
+
+ private lateinit var imeInfo: InputMethodInfo
+ private var nextImeSubtypeId = 0
+ private lateinit var context: Context
+ private lateinit var dataStore: PersistentDataStore
+ private lateinit var testLooper: TestLooper
+
+ // Devices
+ private lateinit var keyboardDevice: InputDevice
+ private lateinit var vendorSpecificKeyboardDevice: InputDevice
+ private lateinit var englishDvorakKeyboardDevice: InputDevice
+ private lateinit var englishQwertyKeyboardDevice: InputDevice
+
+ @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()
+ keyboardLayoutManager = KeyboardLayoutManager(context, native, dataStore, testLooper.looper)
+ setupInputDevices()
+ setupBroadcastReceiver()
+ setupIme()
+ }
+
+ private fun setupInputDevices() {
+ InputManagerGlobal.resetInstance(iInputManager)
+ val inputManager = InputManager(context)
+ Mockito.`when`(context.getSystemService(Mockito.eq(Context.INPUT_SERVICE)))
+ .thenReturn(inputManager)
+
+ keyboardDevice = createKeyboard(DEVICE_ID, DEFAULT_VENDOR_ID, DEFAULT_PRODUCT_ID, "", "")
+ vendorSpecificKeyboardDevice = createKeyboard(VENDOR_SPECIFIC_DEVICE_ID, 1, 1, "", "")
+ englishDvorakKeyboardDevice = createKeyboard(ENGLISH_DVORAK_DEVICE_ID, DEFAULT_VENDOR_ID,
+ DEFAULT_PRODUCT_ID, "en", "dvorak")
+ englishQwertyKeyboardDevice = createKeyboard(ENGLISH_QWERTY_DEVICE_ID, DEFAULT_VENDOR_ID,
+ DEFAULT_PRODUCT_ID, "en", "qwerty")
+ Mockito.`when`(iInputManager.inputDeviceIds)
+ .thenReturn(intArrayOf(
+ DEVICE_ID,
+ VENDOR_SPECIFIC_DEVICE_ID,
+ ENGLISH_DVORAK_DEVICE_ID,
+ ENGLISH_QWERTY_DEVICE_ID
+ ))
+ Mockito.`when`(iInputManager.getInputDevice(DEVICE_ID)).thenReturn(keyboardDevice)
+ Mockito.`when`(iInputManager.getInputDevice(VENDOR_SPECIFIC_DEVICE_ID))
+ .thenReturn(vendorSpecificKeyboardDevice)
+ Mockito.`when`(iInputManager.getInputDevice(ENGLISH_DVORAK_DEVICE_ID))
+ .thenReturn(englishDvorakKeyboardDevice)
+ Mockito.`when`(iInputManager.getInputDevice(ENGLISH_QWERTY_DEVICE_ID))
+ .thenReturn(englishQwertyKeyboardDevice)
+ }
+
+ private fun setupBroadcastReceiver() {
+ Mockito.`when`(context.packageManager).thenReturn(packageManager)
+
+ val info = createMockReceiver()
+ Mockito.`when`(packageManager.queryBroadcastReceiversAsUser(Mockito.any(), Mockito.anyInt(),
+ Mockito.anyInt())).thenReturn(listOf(info))
+ Mockito.`when`(packageManager.getReceiverInfo(Mockito.any(), Mockito.anyInt()))
+ .thenReturn(info.activityInfo)
+
+ val resources = context.resources
+ Mockito.`when`(
+ packageManager.getResourcesForApplication(
+ Mockito.any(
+ ApplicationInfo::class.java
+ )
+ )
+ ).thenReturn(resources)
+ }
+
+ private fun setupIme() {
+ imeInfo = InputMethodInfo(PACKAGE_NAME, RECEIVER_NAME, "", "", 0)
+ }
+
+ @Test
+ fun testDefaultUi_getKeyboardLayouts() {
+ NewSettingsApiFlag(false).use {
+ val keyboardLayouts = keyboardLayoutManager.keyboardLayouts
+ assertNotEquals(
+ "Default UI: Keyboard layout API should not return empty array",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue(
+ "Default UI: Keyboard layout API should provide English(US) layout",
+ hasLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getKeyboardLayouts() {
+ NewSettingsApiFlag(true).use {
+ val keyboardLayouts = keyboardLayoutManager.keyboardLayouts
+ assertNotEquals(
+ "New UI: Keyboard layout API should not return empty array",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue(
+ "New UI: Keyboard layout API should provide English(US) layout",
+ hasLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_getKeyboardLayoutsForInputDevice() {
+ NewSettingsApiFlag(false).use {
+ val keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutsForInputDevice(keyboardDevice.identifier)
+ assertNotEquals(
+ "Default UI: getKeyboardLayoutsForInputDevice API should not return empty array",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue(
+ "Default UI: getKeyboardLayoutsForInputDevice API should provide English(US) " +
+ "layout",
+ hasLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+
+ val vendorSpecificKeyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutsForInputDevice(
+ vendorSpecificKeyboardDevice.identifier
+ )
+ assertEquals(
+ "Default UI: getKeyboardLayoutsForInputDevice API should return only vendor " +
+ "specific layout",
+ 1,
+ vendorSpecificKeyboardLayouts.size
+ )
+ assertEquals(
+ "Default UI: getKeyboardLayoutsForInputDevice API should return vendor specific " +
+ "layout",
+ VENDOR_SPECIFIC_LAYOUT_DESCRIPTOR,
+ vendorSpecificKeyboardLayouts[0].descriptor
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getKeyboardLayoutsForInputDevice() {
+ NewSettingsApiFlag(true).use {
+ val keyboardLayouts = keyboardLayoutManager.keyboardLayouts
+ assertNotEquals(
+ "New UI: getKeyboardLayoutsForInputDevice API should not return empty array",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue(
+ "New UI: getKeyboardLayoutsForInputDevice API should provide English(US) " +
+ "layout",
+ hasLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_getSetCurrentKeyboardLayoutForInputDevice() {
+ NewSettingsApiFlag(false).use {
+ assertNull(
+ "Default UI: getCurrentKeyboardLayoutForInputDevice API should return null if " +
+ "nothing was set",
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+
+ keyboardLayoutManager.setCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier,
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ val keyboardLayout =
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ assertEquals(
+ "Default UI: getCurrentKeyboardLayoutForInputDevice API should return the set " +
+ "layout",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayout
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getSetCurrentKeyboardLayoutForInputDevice() {
+ NewSettingsApiFlag(true).use {
+ keyboardLayoutManager.setCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier,
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ assertNull(
+ "New UI: getCurrentKeyboardLayoutForInputDevice API should always return null " +
+ "even after setCurrentKeyboardLayoutForInputDevice",
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_getEnabledKeyboardLayoutsForInputDevice() {
+ NewSettingsApiFlag(false).use {
+ keyboardLayoutManager.addKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+
+ val keyboardLayouts =
+ keyboardLayoutManager.getEnabledKeyboardLayoutsForInputDevice(
+ keyboardDevice.identifier
+ )
+ assertEquals(
+ "Default UI: getEnabledKeyboardLayoutsForInputDevice API should return added " +
+ "layout",
+ 1,
+ keyboardLayouts.size
+ )
+ assertEquals(
+ "Default UI: getEnabledKeyboardLayoutsForInputDevice API should return " +
+ "English(US) layout",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayouts[0]
+ )
+ assertEquals(
+ "Default UI: getCurrentKeyboardLayoutForInputDevice API should return " +
+ "English(US) layout (Auto select the first enabled layout)",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+
+ keyboardLayoutManager.removeKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ assertEquals(
+ "Default UI: getKeyboardLayoutsForInputDevice API should return 0 layouts",
+ 0,
+ keyboardLayoutManager.getEnabledKeyboardLayoutsForInputDevice(
+ keyboardDevice.identifier
+ ).size
+ )
+ assertNull(
+ "Default UI: getCurrentKeyboardLayoutForInputDevice API should return null after " +
+ "the enabled layout is removed",
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getEnabledKeyboardLayoutsForInputDevice() {
+ NewSettingsApiFlag(true).use {
+ keyboardLayoutManager.addKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+
+ assertEquals(
+ "New UI: getEnabledKeyboardLayoutsForInputDevice API should return always return " +
+ "an empty array",
+ 0,
+ keyboardLayoutManager.getEnabledKeyboardLayoutsForInputDevice(
+ keyboardDevice.identifier
+ ).size
+ )
+ assertNull(
+ "New UI: getCurrentKeyboardLayoutForInputDevice API should always return null",
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_switchKeyboardLayout() {
+ NewSettingsApiFlag(false).use {
+ keyboardLayoutManager.addKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ keyboardLayoutManager.addKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_UK_LAYOUT_DESCRIPTOR
+ )
+ assertEquals(
+ "Default UI: getCurrentKeyboardLayoutForInputDevice API should return " +
+ "English(US) layout",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+
+ keyboardLayoutManager.switchKeyboardLayout(DEVICE_ID, 1)
+
+ // Throws null pointer because trying to show toast using TestLooper
+ assertThrows(NullPointerException::class.java) { testLooper.dispatchAll() }
+ assertEquals("Default UI: getCurrentKeyboardLayoutForInputDevice API should return " +
+ "English(UK) layout",
+ ENGLISH_UK_LAYOUT_DESCRIPTOR,
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_switchKeyboardLayout() {
+ NewSettingsApiFlag(true).use {
+ keyboardLayoutManager.addKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ keyboardLayoutManager.addKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, ENGLISH_UK_LAYOUT_DESCRIPTOR
+ )
+
+ keyboardLayoutManager.switchKeyboardLayout(DEVICE_ID, 1)
+ testLooper.dispatchAll()
+
+ assertNull("New UI: getCurrentKeyboardLayoutForInputDevice API should always return " +
+ "null",
+ keyboardLayoutManager.getCurrentKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_getKeyboardLayout() {
+ NewSettingsApiFlag(false).use {
+ val keyboardLayout =
+ keyboardLayoutManager.getKeyboardLayout(ENGLISH_US_LAYOUT_DESCRIPTOR)
+ assertEquals("Default UI: getKeyboardLayout API should return correct Layout from " +
+ "available layouts",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayout!!.descriptor
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getKeyboardLayout() {
+ NewSettingsApiFlag(true).use {
+ val keyboardLayout =
+ keyboardLayoutManager.getKeyboardLayout(ENGLISH_US_LAYOUT_DESCRIPTOR)
+ assertEquals("New UI: getKeyboardLayout API should return correct Layout from " +
+ "available layouts",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayout!!.descriptor
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_getSetKeyboardLayoutForInputDevice_WithImeInfo() {
+ NewSettingsApiFlag(false).use {
+ val imeSubtype = createImeSubtype()
+ keyboardLayoutManager.setKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype,
+ ENGLISH_UK_LAYOUT_DESCRIPTOR
+ )
+ val keyboardLayout =
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype
+ )
+ assertNull(
+ "Default UI: getKeyboardLayoutForInputDevice API should always return null",
+ keyboardLayout
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getSetKeyboardLayoutForInputDevice_withImeInfo() {
+ NewSettingsApiFlag(true).use {
+ val imeSubtype = createImeSubtype()
+
+ keyboardLayoutManager.setKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype,
+ ENGLISH_UK_LAYOUT_DESCRIPTOR
+ )
+ assertEquals(
+ "New UI: getKeyboardLayoutForInputDevice API should return the set layout",
+ ENGLISH_UK_LAYOUT_DESCRIPTOR,
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype
+ )
+ )
+
+ // This should replace previously set layout
+ keyboardLayoutManager.setKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype,
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ assertEquals(
+ "New UI: getKeyboardLayoutForInputDevice API should return the last set layout",
+ ENGLISH_US_LAYOUT_DESCRIPTOR,
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testDefaultUi_getKeyboardLayoutListForInputDevice() {
+ NewSettingsApiFlag(false).use {
+ val keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtype()
+ )
+ assertEquals("Default UI: getKeyboardLayoutListForInputDevice API should always " +
+ "return empty array",
+ 0,
+ keyboardLayouts.size
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getKeyboardLayoutListForInputDevice() {
+ NewSettingsApiFlag(true).use {
+ // Check Layouts for "hi-Latn". It should return all 'Latn' keyboard layouts
+ var keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTag("hi-Latn")
+ )
+ assertNotEquals(
+ "New UI: getKeyboardLayoutListForInputDevice API should return the list of " +
+ "supported layouts with matching script code",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue("New UI: getKeyboardLayoutListForInputDevice API should return a list " +
+ "containing English(US) layout for hi-Latn",
+ containsLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+ assertTrue("New UI: getKeyboardLayoutListForInputDevice API should return a list " +
+ "containing English(No script code) layout for hi-Latn",
+ containsLayout(
+ keyboardLayouts,
+ createLayoutDescriptor("keyboard_layout_english_without_script_code")
+ )
+ )
+
+ // Check Layouts for "hi" which by default uses 'Deva' script.
+ keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTag("hi")
+ )
+ assertEquals("New UI: getKeyboardLayoutListForInputDevice API should return empty " +
+ "list if no supported layouts available",
+ 0,
+ keyboardLayouts.size
+ )
+
+ // If user manually selected some layout, always provide it in the layout list
+ val imeSubtype = createImeSubtypeForLanguageTag("hi")
+ keyboardLayoutManager.setKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, imeSubtype,
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ imeSubtype
+ )
+ assertEquals("New UI: getKeyboardLayoutListForInputDevice API should return user " +
+ "selected layout even if the script is incompatible with IME",
+ 1,
+ keyboardLayouts.size
+ )
+
+ // Special case Japanese: UScript ignores provided script code for certain language tags
+ // Should manually match provided script codes and then rely on Uscript to derive
+ // script from language tags and match those.
+ keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTag("ja-Latn-JP")
+ )
+ assertNotEquals(
+ "New UI: getKeyboardLayoutListForInputDevice API should return the list of " +
+ "supported layouts with matching script code for ja-Latn-JP",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue("New UI: getKeyboardLayoutListForInputDevice API should return a list " +
+ "containing English(US) layout for ja-Latn-JP",
+ containsLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+ assertTrue("New UI: getKeyboardLayoutListForInputDevice API should return a list " +
+ "containing English(No script code) layout for ja-Latn-JP",
+ containsLayout(
+ keyboardLayouts,
+ createLayoutDescriptor("keyboard_layout_english_without_script_code")
+ )
+ )
+
+ // If script code not explicitly provided for Japanese should rely on Uscript to find
+ // derived script code and hence no suitable layout will be found.
+ keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTag("ja-JP")
+ )
+ assertEquals(
+ "New UI: getKeyboardLayoutListForInputDevice API should return empty list of " +
+ "supported layouts with matching script code for ja-JP",
+ 0,
+ keyboardLayouts.size
+ )
+
+ // If IME doesn't have a corresponding language tag, then should show all available
+ // layouts no matter the script code.
+ keyboardLayouts =
+ keyboardLayoutManager.getKeyboardLayoutListForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo, null
+ )
+ assertNotEquals(
+ "New UI: getKeyboardLayoutListForInputDevice API should return all layouts if" +
+ "language tag or subtype not provided",
+ 0,
+ keyboardLayouts.size
+ )
+ assertTrue("New UI: getKeyboardLayoutListForInputDevice API should contain Latin " +
+ "layouts if language tag or subtype not provided",
+ containsLayout(keyboardLayouts, ENGLISH_US_LAYOUT_DESCRIPTOR)
+ )
+ assertTrue("New UI: getKeyboardLayoutListForInputDevice API should contain Cyrillic " +
+ "layouts if language tag or subtype not provided",
+ containsLayout(
+ keyboardLayouts,
+ createLayoutDescriptor("keyboard_layout_russian")
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getDefaultKeyboardLayoutForInputDevice_withImeLanguageTag() {
+ NewSettingsApiFlag(true).use {
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTag("en-US"),
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTag("en-GB"),
+ ENGLISH_UK_LAYOUT_DESCRIPTOR
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTag("de"),
+ createLayoutDescriptor("keyboard_layout_german")
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTag("fr-FR"),
+ createLayoutDescriptor("keyboard_layout_french")
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTag("ru"),
+ createLayoutDescriptor("keyboard_layout_russian")
+ )
+ assertNull(
+ "New UI: getDefaultKeyboardLayoutForInputDevice should return null when no " +
+ "layout available",
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTag("it")
+ )
+ )
+ assertNull(
+ "New UI: getDefaultKeyboardLayoutForInputDevice should return null when no " +
+ "layout for script code is available",
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTag("en-Deva")
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getDefaultKeyboardLayoutForInputDevice_withImeLanguageTagAndLayoutType() {
+ NewSettingsApiFlag(true).use {
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("en-US", "qwerty"),
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("en-US", "dvorak"),
+ createLayoutDescriptor("keyboard_layout_english_us_dvorak")
+ )
+ // Try to match layout type even if country doesn't match
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("en-GB", "dvorak"),
+ createLayoutDescriptor("keyboard_layout_english_us_dvorak")
+ )
+ // Choose layout based on layout type priority, if layout type is not provided by IME
+ // (Qwerty > Dvorak > Extended)
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("en-US", ""),
+ ENGLISH_US_LAYOUT_DESCRIPTOR
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("en-GB", "qwerty"),
+ ENGLISH_UK_LAYOUT_DESCRIPTOR
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("de", "qwertz"),
+ createLayoutDescriptor("keyboard_layout_german")
+ )
+ // Wrong layout type should match with language if provided layout type not available
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("de", "qwerty"),
+ createLayoutDescriptor("keyboard_layout_german")
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("fr-FR", "azerty"),
+ createLayoutDescriptor("keyboard_layout_french")
+ )
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("ru", "qwerty"),
+ createLayoutDescriptor("keyboard_layout_russian_qwerty")
+ )
+ // If layout type is empty then prioritize KCM with empty layout type
+ assertCorrectLayout(
+ keyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("ru", ""),
+ createLayoutDescriptor("keyboard_layout_russian")
+ )
+ assertNull("New UI: getDefaultKeyboardLayoutForInputDevice should return null when " +
+ "no layout for script code is available",
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ keyboardDevice.identifier, USER_ID, imeInfo,
+ createImeSubtypeForLanguageTagAndLayoutType("en-Deva-US", "")
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testNewUi_getDefaultKeyboardLayoutForInputDevice_withHwLanguageTagAndLayoutType() {
+ NewSettingsApiFlag(true).use {
+ val frenchSubtype = createImeSubtypeForLanguageTagAndLayoutType("fr", "azerty")
+ // Should return English dvorak even if IME current layout is French, since HW says the
+ // keyboard is a Dvorak keyboard
+ assertCorrectLayout(
+ englishDvorakKeyboardDevice,
+ frenchSubtype,
+ createLayoutDescriptor("keyboard_layout_english_us_dvorak")
+ )
+
+ // Back to back changing HW keyboards with same product and vendor ID but different
+ // language and layout type should configure the layouts correctly.
+ assertCorrectLayout(
+ englishQwertyKeyboardDevice,
+ frenchSubtype,
+ createLayoutDescriptor("keyboard_layout_english_us")
+ )
+
+ // Fallback to IME information if the HW provided layout script is incompatible with the
+ // provided IME subtype
+ assertCorrectLayout(
+ englishDvorakKeyboardDevice,
+ createImeSubtypeForLanguageTagAndLayoutType("ru", ""),
+ createLayoutDescriptor("keyboard_layout_russian")
+ )
+ }
+ }
+
+ private fun assertCorrectLayout(
+ device: InputDevice,
+ imeSubtype: InputMethodSubtype,
+ expectedLayout: String
+ ) {
+ assertEquals(
+ "New UI: getDefaultKeyboardLayoutForInputDevice should return $expectedLayout",
+ expectedLayout,
+ keyboardLayoutManager.getKeyboardLayoutForInputDevice(
+ device.identifier, USER_ID, imeInfo, imeSubtype
+ )
+ )
+ }
+
+ private fun createImeSubtype(): InputMethodSubtype =
+ InputMethodSubtype.InputMethodSubtypeBuilder().setSubtypeId(nextImeSubtypeId++).build()
+
+ private fun createImeSubtypeForLanguageTag(languageTag: String): InputMethodSubtype =
+ InputMethodSubtype.InputMethodSubtypeBuilder().setSubtypeId(nextImeSubtypeId++)
+ .setLanguageTag(languageTag).build()
+
+ private fun createImeSubtypeForLanguageTagAndLayoutType(
+ languageTag: String,
+ layoutType: String
+ ): InputMethodSubtype =
+ InputMethodSubtype.InputMethodSubtypeBuilder().setSubtypeId(nextImeSubtypeId++)
+ .setPhysicalKeyboardHint(ULocale.forLanguageTag(languageTag), layoutType).build()
+
+ private fun hasLayout(layoutList: Array<KeyboardLayout>, layoutDesc: String): Boolean {
+ for (kl in layoutList) {
+ if (kl.descriptor == layoutDesc) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun createLayoutDescriptor(keyboardName: String): String =
+ "$PACKAGE_NAME/$RECEIVER_NAME/$keyboardName"
+
+ private fun containsLayout(layoutList: Array<KeyboardLayout>, layoutDesc: String): Boolean {
+ for (kl in layoutList) {
+ if (kl.descriptor.equals(layoutDesc)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun createMockReceiver(): ResolveInfo {
+ val info = ResolveInfo()
+ info.activityInfo = ActivityInfo()
+ info.activityInfo.packageName = PACKAGE_NAME
+ info.activityInfo.name = RECEIVER_NAME
+ info.activityInfo.applicationInfo = ApplicationInfo()
+ info.activityInfo.metaData = Bundle()
+ info.activityInfo.metaData.putInt(
+ InputManager.META_DATA_KEYBOARD_LAYOUTS,
+ R.xml.keyboard_layouts
+ )
+ info.serviceInfo = ServiceInfo()
+ info.serviceInfo.packageName = PACKAGE_NAME
+ info.serviceInfo.name = RECEIVER_NAME
+ return info
+ }
+
+ private inner class NewSettingsApiFlag constructor(enabled: Boolean) : AutoCloseable {
+ init {
+ Settings.Global.putString(
+ context.contentResolver,
+ "settings_new_keyboard_ui", enabled.toString()
+ )
+ }
+
+ override fun close() {
+ Settings.Global.putString(
+ context.contentResolver,
+ "settings_new_keyboard_ui",
+ ""
+ )
+ }
+ }
+}
diff --git a/tests/Input/src/com/android/server/input/KeyboardMetricsCollectorTests.kt b/tests/Input/src/com/android/server/input/KeyboardMetricsCollectorTests.kt
new file mode 100644
index 0000000..e2dc131
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/KeyboardMetricsCollectorTests.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2023 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.hardware.input.KeyboardLayout
+import android.icu.util.ULocale
+import android.platform.test.annotations.Presubmit
+import android.view.InputDevice
+import android.view.inputmethod.InputMethodSubtype
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+private fun createKeyboard(
+ deviceId: Int,
+ vendorId: Int,
+ productId: Int,
+ languageTag: String?,
+ layoutType: String?
+): InputDevice =
+ InputDevice.Builder()
+ .setId(deviceId)
+ .setName("Device $deviceId")
+ .setDescriptor("descriptor $deviceId")
+ .setSources(InputDevice.SOURCE_KEYBOARD)
+ .setKeyboardType(InputDevice.KEYBOARD_TYPE_ALPHABETIC)
+ .setExternal(true)
+ .setVendorId(vendorId)
+ .setProductId(productId)
+ .setKeyboardLanguageTag(languageTag)
+ .setKeyboardLayoutType(layoutType)
+ .build()
+
+private fun createImeSubtype(
+ imeSubtypeId: Int,
+ languageTag: String,
+ layoutType: String
+): InputMethodSubtype =
+ InputMethodSubtype.InputMethodSubtypeBuilder().setSubtypeId(imeSubtypeId)
+ .setPhysicalKeyboardHint(ULocale.forLanguageTag(languageTag), layoutType).build()
+
+/**
+ * Tests for {@link KeyboardMetricsCollector}.
+ *
+ * Build/Install/Run:
+ * atest InputTests:KeyboardMetricsCollectorTests
+ */
+@Presubmit
+class KeyboardMetricsCollectorTests {
+
+ companion object {
+ const val DEVICE_ID = 1
+ const val DEFAULT_VENDOR_ID = 123
+ const val DEFAULT_PRODUCT_ID = 456
+ }
+
+ @Test
+ fun testCreateKeyboardConfigurationEvent_throwsExceptionWithoutAnyLayoutConfiguration() {
+ assertThrows(IllegalStateException::class.java) {
+ KeyboardMetricsCollector.KeyboardConfigurationEvent.Builder(
+ createKeyboard(
+ DEVICE_ID,
+ DEFAULT_VENDOR_ID,
+ DEFAULT_PRODUCT_ID,
+ null,
+ null
+ )
+ ).build()
+ }
+ }
+
+ @Test
+ fun testCreateKeyboardConfigurationEvent_throwsExceptionWithInvalidLayoutSelectionCriteria() {
+ assertThrows(IllegalStateException::class.java) {
+ KeyboardMetricsCollector.KeyboardConfigurationEvent.Builder(
+ createKeyboard(
+ DEVICE_ID,
+ DEFAULT_VENDOR_ID,
+ DEFAULT_PRODUCT_ID,
+ null,
+ null
+ )
+ ).addLayoutSelection(createImeSubtype(1, "en-US", "qwerty"), null, 123).build()
+ }
+ }
+
+ @Test
+ fun testCreateKeyboardConfigurationEvent_withMultipleConfigurations() {
+ val builder = KeyboardMetricsCollector.KeyboardConfigurationEvent.Builder(
+ createKeyboard(
+ DEVICE_ID,
+ DEFAULT_VENDOR_ID,
+ DEFAULT_PRODUCT_ID,
+ "de-CH",
+ "qwertz"
+ )
+ )
+ val event = builder.addLayoutSelection(
+ createImeSubtype(1, "en-US", "qwerty"),
+ KeyboardLayout(null, "English(US)(Qwerty)", null, 0, null, 0, 0, 0),
+ KeyboardMetricsCollector.LAYOUT_SELECTION_CRITERIA_VIRTUAL_KEYBOARD
+ ).addLayoutSelection(
+ createImeSubtype(2, "en-US", "azerty"),
+ null,
+ KeyboardMetricsCollector.LAYOUT_SELECTION_CRITERIA_USER
+ ).addLayoutSelection(
+ createImeSubtype(3, "en-US", "qwerty"),
+ KeyboardLayout(null, "German", null, 0, null, 0, 0, 0),
+ KeyboardMetricsCollector.LAYOUT_SELECTION_CRITERIA_DEVICE
+ ).setIsFirstTimeConfiguration(true).build()
+
+ assertEquals(
+ "KeyboardConfigurationEvent should pick vendor ID from provided InputDevice",
+ DEFAULT_VENDOR_ID,
+ event.vendorId
+ )
+ assertEquals(
+ "KeyboardConfigurationEvent should pick product ID from provided InputDevice",
+ DEFAULT_PRODUCT_ID,
+ event.productId
+ )
+ assertTrue(event.isFirstConfiguration)
+
+ assertEquals(
+ "KeyboardConfigurationEvent should contain 3 configurations provided",
+ 3,
+ event.layoutConfigurations.size
+ )
+ assertExpectedLayoutConfiguration(
+ event.layoutConfigurations[0],
+ "en-US",
+ KeyboardLayout.LayoutType.getLayoutTypeEnumValue("qwerty"),
+ "English(US)(Qwerty)",
+ KeyboardMetricsCollector.LAYOUT_SELECTION_CRITERIA_VIRTUAL_KEYBOARD
+ )
+ assertExpectedLayoutConfiguration(
+ event.layoutConfigurations[1],
+ "en-US",
+ KeyboardLayout.LayoutType.getLayoutTypeEnumValue("azerty"),
+ KeyboardMetricsCollector.DEFAULT_LAYOUT,
+ KeyboardMetricsCollector.LAYOUT_SELECTION_CRITERIA_USER
+ )
+ assertExpectedLayoutConfiguration(
+ event.layoutConfigurations[2],
+ "de-CH",
+ KeyboardLayout.LayoutType.getLayoutTypeEnumValue("qwertz"),
+ "German",
+ KeyboardMetricsCollector.LAYOUT_SELECTION_CRITERIA_DEVICE
+ )
+ }
+
+ private fun assertExpectedLayoutConfiguration(
+ configuration: KeyboardMetricsCollector.LayoutConfiguration,
+ expectedLanguageTag: String,
+ expectedLayoutType: Int,
+ expectedSelectedLayout: String,
+ expectedLayoutSelectionCriteria: Int
+ ) {
+ assertEquals(expectedLanguageTag, configuration.keyboardLanguageTag)
+ assertEquals(expectedLayoutType, configuration.keyboardLayoutType)
+ assertEquals(expectedSelectedLayout, configuration.keyboardLayoutName)
+ assertEquals(expectedLayoutSelectionCriteria, configuration.layoutSelectionCriteria)
+ }
+}
\ No newline at end of file