diff --git a/packages/SystemUI/src/com/android/systemui/stylus/StylusManager.kt b/packages/SystemUI/src/com/android/systemui/stylus/StylusManager.kt new file mode 100644 index 0000000000000..d9ede44faa2e1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/stylus/StylusManager.kt @@ -0,0 +1,131 @@ +/* + * 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.systemui.stylus + +import android.hardware.input.InputManager +import android.os.Handler +import android.util.ArrayMap +import android.view.InputDevice +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject + +/** + * A class which keeps track of InputDevice events related to stylus devices, and notifies + * registered callbacks of stylus events. + */ +@SysUISingleton +class StylusManager +@Inject +constructor( + private val inputManager: InputManager, + @Background private val handler: Handler, +) : InputManager.InputDeviceListener { + + private val stylusCallbacks: CopyOnWriteArrayList = CopyOnWriteArrayList() + // This map should only be accessed on the handler + private val inputDeviceAddressMap: MutableMap = ArrayMap() + + /** + * Starts listening to InputManager InputDevice events. Will also load the InputManager snapshot + * at time of starting. + */ + fun startListener() { + addExistingStylusToMap() + inputManager.registerInputDeviceListener(this, handler) + } + + /** Registers a StylusCallback to listen to stylus events. */ + fun registerCallback(callback: StylusCallback) { + stylusCallbacks.add(callback) + } + + /** Unregisters a StylusCallback. If StylusCallback is not registered, is a no-op. */ + fun unregisterCallback(callback: StylusCallback) { + stylusCallbacks.remove(callback) + } + + override fun onInputDeviceAdded(deviceId: Int) { + val device: InputDevice = inputManager.getInputDevice(deviceId) ?: return + if (!device.supportsSource(InputDevice.SOURCE_STYLUS)) return + + // TODO(b/257936830): get address once input api available + val btAddress: String? = null + inputDeviceAddressMap[deviceId] = btAddress + executeStylusCallbacks { cb -> cb.onStylusAdded(deviceId) } + + if (btAddress != null) { + executeStylusCallbacks { cb -> cb.onStylusBluetoothConnected(deviceId, btAddress) } + } + } + + override fun onInputDeviceChanged(deviceId: Int) { + val device: InputDevice = inputManager.getInputDevice(deviceId) ?: return + if (!device.supportsSource(InputDevice.SOURCE_STYLUS)) return + + // TODO(b/257936830): get address once input api available + val currAddress: String? = null + val prevAddress: String? = inputDeviceAddressMap[deviceId] + inputDeviceAddressMap[deviceId] = currAddress + + if (prevAddress == null && currAddress != null) { + executeStylusCallbacks { cb -> cb.onStylusBluetoothConnected(deviceId, currAddress) } + } + + if (prevAddress != null && currAddress == null) { + executeStylusCallbacks { cb -> cb.onStylusBluetoothDisconnected(deviceId, prevAddress) } + } + } + + override fun onInputDeviceRemoved(deviceId: Int) { + if (!inputDeviceAddressMap.contains(deviceId)) return + + val btAddress: String? = inputDeviceAddressMap[deviceId] + inputDeviceAddressMap.remove(deviceId) + if (btAddress != null) { + executeStylusCallbacks { cb -> cb.onStylusBluetoothDisconnected(deviceId, btAddress) } + } + executeStylusCallbacks { cb -> cb.onStylusRemoved(deviceId) } + } + + private fun executeStylusCallbacks(run: (cb: StylusCallback) -> Unit) { + stylusCallbacks.forEach(run) + } + + private fun addExistingStylusToMap() { + for (deviceId: Int in inputManager.inputDeviceIds) { + val device: InputDevice = inputManager.getInputDevice(deviceId) ?: continue + if (device.supportsSource(InputDevice.SOURCE_STYLUS)) { + // TODO(b/257936830): get address once input api available + inputDeviceAddressMap[deviceId] = null + } + } + } + + /** Callback interface to receive events from the StylusManager. */ + interface StylusCallback { + fun onStylusAdded(deviceId: Int) {} + fun onStylusRemoved(deviceId: Int) {} + fun onStylusBluetoothConnected(deviceId: Int, btAddress: String) {} + fun onStylusBluetoothDisconnected(deviceId: Int, btAddress: String) {} + } + + companion object { + private val TAG = StylusManager::class.simpleName.orEmpty() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/stylus/StylusManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/stylus/StylusManagerTest.kt new file mode 100644 index 0000000000000..e6ec20e0ea734 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/stylus/StylusManagerTest.kt @@ -0,0 +1,221 @@ +/* + * 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.systemui.stylus + +import android.hardware.input.InputManager +import android.os.Handler +import android.testing.AndroidTestingRunner +import android.view.InputDevice +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.whenever +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.MockitoAnnotations + +@RunWith(AndroidTestingRunner::class) +@SmallTest +@Ignore("b/257936830 until bt APIs") +class StylusManagerTest : SysuiTestCase() { + @Mock lateinit var inputManager: InputManager + + @Mock lateinit var stylusDevice: InputDevice + + @Mock lateinit var btStylusDevice: InputDevice + + @Mock lateinit var otherDevice: InputDevice + + @Mock lateinit var handler: Handler + + @Mock lateinit var stylusCallback: StylusManager.StylusCallback + + @Mock lateinit var otherStylusCallback: StylusManager.StylusCallback + + private lateinit var stylusManager: StylusManager + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + whenever(handler.post(any())).thenAnswer { + (it.arguments[0] as Runnable).run() + true + } + + stylusManager = StylusManager(inputManager, handler) + + stylusManager.registerCallback(stylusCallback) + + whenever(otherDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(false) + whenever(stylusDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true) + whenever(btStylusDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true) + + // whenever(stylusDevice.bluetoothAddress).thenReturn(null) + // whenever(btStylusDevice.bluetoothAddress).thenReturn(STYLUS_BT_ADDRESS) + + whenever(inputManager.getInputDevice(OTHER_DEVICE_ID)).thenReturn(otherDevice) + whenever(inputManager.getInputDevice(STYLUS_DEVICE_ID)).thenReturn(stylusDevice) + whenever(inputManager.getInputDevice(BT_STYLUS_DEVICE_ID)).thenReturn(btStylusDevice) + whenever(inputManager.inputDeviceIds).thenReturn(intArrayOf(STYLUS_DEVICE_ID)) + } + + @Test + fun startListener_registersInputDeviceListener() { + stylusManager.startListener() + + verify(inputManager, times(1)).registerInputDeviceListener(stylusManager, handler) + } + + @Test + fun onInputDeviceAdded_multipleRegisteredCallbacks_callsAll() { + stylusManager.registerCallback(otherStylusCallback) + + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)).onStylusAdded(STYLUS_DEVICE_ID) + verifyNoMoreInteractions(stylusCallback) + verify(otherStylusCallback, times(1)).onStylusAdded(STYLUS_DEVICE_ID) + verifyNoMoreInteractions(otherStylusCallback) + } + + @Test + fun onInputDeviceAdded_stylus_callsCallbacksOnStylusAdded() { + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)).onStylusAdded(STYLUS_DEVICE_ID) + verifyNoMoreInteractions(stylusCallback) + } + + @Test + fun onInputDeviceAdded_btStylus_callsCallbacksWithAddress() { + stylusManager.onInputDeviceAdded(BT_STYLUS_DEVICE_ID) + + inOrder(stylusCallback).let { + it.verify(stylusCallback, times(1)).onStylusAdded(BT_STYLUS_DEVICE_ID) + it.verify(stylusCallback, times(1)) + .onStylusBluetoothConnected(BT_STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + } + } + + @Test + fun onInputDeviceAdded_notStylus_doesNotCallCallbacks() { + stylusManager.onInputDeviceAdded(OTHER_DEVICE_ID) + + verifyNoMoreInteractions(stylusCallback) + } + + @Test + fun onInputDeviceChanged_multipleRegisteredCallbacks_callsAll() { + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + // whenever(stylusDevice.bluetoothAddress).thenReturn(STYLUS_BT_ADDRESS) + stylusManager.registerCallback(otherStylusCallback) + + stylusManager.onInputDeviceChanged(STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)) + .onStylusBluetoothConnected(STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + verify(otherStylusCallback, times(1)) + .onStylusBluetoothConnected(STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + } + + @Test + fun onInputDeviceChanged_stylusNewBtConnection_callsCallbacks() { + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + // whenever(stylusDevice.bluetoothAddress).thenReturn(STYLUS_BT_ADDRESS) + + stylusManager.onInputDeviceChanged(STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)) + .onStylusBluetoothConnected(STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + } + + @Test + fun onInputDeviceChanged_stylusLostBtConnection_callsCallbacks() { + stylusManager.onInputDeviceAdded(BT_STYLUS_DEVICE_ID) + // whenever(btStylusDevice.bluetoothAddress).thenReturn(null) + + stylusManager.onInputDeviceChanged(BT_STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)) + .onStylusBluetoothDisconnected(BT_STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + } + + @Test + fun onInputDeviceChanged_btConnection_stylusAlreadyBtConnected_onlyCallsListenersOnce() { + stylusManager.onInputDeviceAdded(BT_STYLUS_DEVICE_ID) + + stylusManager.onInputDeviceChanged(BT_STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)) + .onStylusBluetoothConnected(BT_STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + } + + @Test + fun onInputDeviceChanged_noBtConnection_stylusNeverBtConnected_doesNotCallCallbacks() { + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + + stylusManager.onInputDeviceChanged(STYLUS_DEVICE_ID) + + verify(stylusCallback, never()).onStylusBluetoothDisconnected(any(), any()) + } + + @Test + fun onInputDeviceRemoved_multipleRegisteredCallbacks_callsAll() { + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + stylusManager.registerCallback(otherStylusCallback) + + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)).onStylusRemoved(STYLUS_DEVICE_ID) + verify(otherStylusCallback, times(1)).onStylusRemoved(STYLUS_DEVICE_ID) + } + + @Test + fun onInputDeviceRemoved_stylus_callsCallbacks() { + stylusManager.onInputDeviceAdded(STYLUS_DEVICE_ID) + + stylusManager.onInputDeviceRemoved(STYLUS_DEVICE_ID) + + verify(stylusCallback, times(1)).onStylusRemoved(STYLUS_DEVICE_ID) + verify(stylusCallback, never()).onStylusBluetoothDisconnected(any(), any()) + } + + @Test + fun onInputDeviceRemoved_btStylus_callsCallbacks() { + stylusManager.onInputDeviceAdded(BT_STYLUS_DEVICE_ID) + + stylusManager.onInputDeviceRemoved(BT_STYLUS_DEVICE_ID) + + inOrder(stylusCallback).let { + it.verify(stylusCallback, times(1)) + .onStylusBluetoothDisconnected(BT_STYLUS_DEVICE_ID, STYLUS_BT_ADDRESS) + it.verify(stylusCallback, times(1)).onStylusRemoved(BT_STYLUS_DEVICE_ID) + } + } + + companion object { + private const val OTHER_DEVICE_ID = 0 + private const val STYLUS_DEVICE_ID = 1 + private const val BT_STYLUS_DEVICE_ID = 2 + + private const val STYLUS_BT_ADDRESS = "SOME:ADDRESS" + } +}