Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 61a425a0 authored by Vania Januar's avatar Vania Januar Committed by Android (Google) Code Review
Browse files

Merge "Detecting first stylus usage." into tm-qpr-dev

parents 2ea53262 6f2ecace
Loading
Loading
Loading
Loading
+136 −0
Original line number Diff line number Diff line
/*
 * 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.content.Context
import android.hardware.BatteryState
import android.hardware.input.InputManager
import android.os.Handler
import android.util.Log
import android.view.InputDevice
import androidx.annotation.VisibleForTesting
import com.android.systemui.CoreStartable
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags
import java.util.concurrent.Executor
import javax.inject.Inject

/**
 * A listener that detects when a stylus has first been used, by detecting 1) the presence of an
 * internal SOURCE_STYLUS with a battery, or 2) any added SOURCE_STYLUS device with a bluetooth
 * address.
 */
@SysUISingleton
class StylusFirstUsageListener
@Inject
constructor(
    private val context: Context,
    private val inputManager: InputManager,
    private val stylusManager: StylusManager,
    private val featureFlags: FeatureFlags,
    @Background private val executor: Executor,
    @Background private val handler: Handler,
) : CoreStartable, StylusManager.StylusCallback, InputManager.InputDeviceBatteryListener {

    // Set must be only accessed from the background handler, which is the same handler that
    // runs the StylusManager callbacks.
    private val internalStylusDeviceIds: MutableSet<Int> = mutableSetOf()
    @VisibleForTesting var hasStarted = false

    override fun start() {
        if (true) return // TODO(b/261826950): remove on main
        if (hasStarted) return
        if (!featureFlags.isEnabled(Flags.TRACK_STYLUS_EVER_USED)) return
        if (inputManager.isStylusEverUsed(context)) return
        if (!hostDeviceSupportsStylusInput()) return

        hasStarted = true
        inputManager.inputDeviceIds.forEach(this::onStylusAdded)
        stylusManager.registerCallback(this)
        stylusManager.startListener()
    }

    override fun onStylusAdded(deviceId: Int) {
        if (!hasStarted) return

        val device = inputManager.getInputDevice(deviceId) ?: return
        if (device.isExternal || !device.supportsSource(InputDevice.SOURCE_STYLUS)) return

        try {
            inputManager.addInputDeviceBatteryListener(deviceId, executor, this)
            internalStylusDeviceIds += deviceId
        } catch (e: SecurityException) {
            Log.e(TAG, "$e: Failed to register battery listener for $deviceId ${device.name}.")
        }
    }

    override fun onStylusRemoved(deviceId: Int) {
        if (!hasStarted) return

        if (!internalStylusDeviceIds.contains(deviceId)) return
        try {
            inputManager.removeInputDeviceBatteryListener(deviceId, this)
            internalStylusDeviceIds.remove(deviceId)
        } catch (e: SecurityException) {
            Log.e(TAG, "$e: Failed to remove registered battery listener for $deviceId.")
        }
    }

    override fun onStylusBluetoothConnected(deviceId: Int, btAddress: String) {
        if (!hasStarted) return

        onRemoteDeviceFound()
    }

    override fun onBatteryStateChanged(
        deviceId: Int,
        eventTimeMillis: Long,
        batteryState: BatteryState
    ) {
        if (!hasStarted) return

        if (batteryState.isPresent) {
            onRemoteDeviceFound()
        }
    }

    private fun onRemoteDeviceFound() {
        inputManager.setStylusEverUsed(context, true)
        cleanupListeners()
    }

    private fun cleanupListeners() {
        stylusManager.unregisterCallback(this)
        handler.post {
            internalStylusDeviceIds.forEach {
                inputManager.removeInputDeviceBatteryListener(it, this)
            }
        }
    }

    private fun hostDeviceSupportsStylusInput(): Boolean {
        return inputManager.inputDeviceIds
            .asSequence()
            .mapNotNull { inputManager.getInputDevice(it) }
            .any { it.supportsSource(InputDevice.SOURCE_STYLUS) && !it.isExternal }
    }

    companion object {
        private val TAG = StylusFirstUsageListener::class.simpleName.orEmpty()
    }
}
+289 −0
Original line number Diff line number Diff line
/*
 * 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.content.Context
import android.hardware.BatteryState
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.flags.FeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.time.FakeSystemClock
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.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.verifyZeroInteractions
import org.mockito.MockitoAnnotations

@RunWith(AndroidTestingRunner::class)
@SmallTest
@Ignore("TODO(b/20579491): unignore on main")
class StylusFirstUsageListenerTest : SysuiTestCase() {
    @Mock lateinit var context: Context
    @Mock lateinit var inputManager: InputManager
    @Mock lateinit var stylusManager: StylusManager
    @Mock lateinit var featureFlags: FeatureFlags
    @Mock lateinit var internalStylusDevice: InputDevice
    @Mock lateinit var otherDevice: InputDevice
    @Mock lateinit var externalStylusDevice: InputDevice
    @Mock lateinit var batteryState: BatteryState
    @Mock lateinit var handler: Handler

    private lateinit var stylusListener: StylusFirstUsageListener

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        whenever(featureFlags.isEnabled(Flags.TRACK_STYLUS_EVER_USED)).thenReturn(true)
        whenever(inputManager.isStylusEverUsed(context)).thenReturn(false)

        stylusListener =
            StylusFirstUsageListener(
                context,
                inputManager,
                stylusManager,
                featureFlags,
                EXECUTOR,
                handler
            )
        stylusListener.hasStarted = false

        whenever(handler.post(any())).thenAnswer {
            (it.arguments[0] as Runnable).run()
            true
        }

        whenever(otherDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(false)
        whenever(internalStylusDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true)
        whenever(internalStylusDevice.isExternal).thenReturn(false)
        whenever(externalStylusDevice.supportsSource(InputDevice.SOURCE_STYLUS)).thenReturn(true)
        whenever(externalStylusDevice.isExternal).thenReturn(true)

        whenever(inputManager.inputDeviceIds).thenReturn(intArrayOf())
        whenever(inputManager.getInputDevice(OTHER_DEVICE_ID)).thenReturn(otherDevice)
        whenever(inputManager.getInputDevice(INTERNAL_STYLUS_DEVICE_ID))
            .thenReturn(internalStylusDevice)
        whenever(inputManager.getInputDevice(EXTERNAL_STYLUS_DEVICE_ID))
            .thenReturn(externalStylusDevice)
    }

    @Test
    fun start_flagDisabled_doesNotRegister() {
        whenever(featureFlags.isEnabled(Flags.TRACK_STYLUS_EVER_USED)).thenReturn(false)

        stylusListener.start()

        verify(stylusManager, never()).registerCallback(any())
        verify(inputManager, never()).setStylusEverUsed(context, true)
    }

    @Test
    fun start_toggleHasStarted() {
        stylusListener.start()

        assert(stylusListener.hasStarted)
    }

    @Test
    fun start_hasStarted_doesNotRegister() {
        stylusListener.hasStarted = true

        stylusListener.start()

        verify(stylusManager, never()).registerCallback(any())
    }

    @Test
    fun start_hostDeviceDoesNotSupportStylus_doesNotRegister() {
        whenever(inputManager.inputDeviceIds).thenReturn(intArrayOf(OTHER_DEVICE_ID))

        stylusListener.start()

        verify(stylusManager, never()).registerCallback(any())
        verify(inputManager, never()).setStylusEverUsed(context, true)
    }

    @Test
    fun start_stylusEverUsed_doesNotRegister() {
        whenever(inputManager.inputDeviceIds)
            .thenReturn(intArrayOf(OTHER_DEVICE_ID, INTERNAL_STYLUS_DEVICE_ID))
        whenever(inputManager.isStylusEverUsed(context)).thenReturn(true)

        stylusListener.start()

        verify(stylusManager, never()).registerCallback(any())
        verify(inputManager, never()).setStylusEverUsed(context, true)
    }

    @Test
    fun start_hostDeviceSupportsStylus_registersListener() {
        whenever(inputManager.inputDeviceIds)
            .thenReturn(intArrayOf(OTHER_DEVICE_ID, INTERNAL_STYLUS_DEVICE_ID))

        stylusListener.start()

        verify(stylusManager).registerCallback(any())
        verify(inputManager, never()).setStylusEverUsed(context, true)
    }

    @Test
    fun onStylusAdded_hasNotStarted_doesNotRegisterListener() {
        stylusListener.hasStarted = false

        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)

        verifyZeroInteractions(inputManager)
    }

    @Test
    fun onStylusAdded_internalStylus_registersListener() {
        stylusListener.hasStarted = true

        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)

        verify(inputManager, times(1))
            .addInputDeviceBatteryListener(INTERNAL_STYLUS_DEVICE_ID, EXECUTOR, stylusListener)
    }

    @Test
    fun onStylusAdded_externalStylus_doesNotRegisterListener() {
        stylusListener.hasStarted = true

        stylusListener.onStylusAdded(EXTERNAL_STYLUS_DEVICE_ID)

        verify(inputManager, never()).addInputDeviceBatteryListener(any(), any(), any())
    }

    @Test
    fun onStylusAdded_otherDevice_doesNotRegisterListener() {
        stylusListener.onStylusAdded(OTHER_DEVICE_ID)

        verify(inputManager, never()).addInputDeviceBatteryListener(any(), any(), any())
    }

    @Test
    fun onStylusRemoved_registeredDevice_unregistersListener() {
        stylusListener.hasStarted = true
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)

        stylusListener.onStylusRemoved(INTERNAL_STYLUS_DEVICE_ID)

        verify(inputManager, times(1))
            .removeInputDeviceBatteryListener(INTERNAL_STYLUS_DEVICE_ID, stylusListener)
    }

    @Test
    fun onStylusRemoved_hasNotStarted_doesNotUnregisterListener() {
        stylusListener.hasStarted = false
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)

        stylusListener.onStylusRemoved(INTERNAL_STYLUS_DEVICE_ID)

        verifyZeroInteractions(inputManager)
    }

    @Test
    fun onStylusRemoved_unregisteredDevice_doesNotUnregisterListener() {
        stylusListener.hasStarted = true

        stylusListener.onStylusRemoved(INTERNAL_STYLUS_DEVICE_ID)

        verifyNoMoreInteractions(inputManager)
    }

    @Test
    fun onStylusBluetoothConnected_updateStylusFlagAndUnregisters() {
        stylusListener.hasStarted = true
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)

        stylusListener.onStylusBluetoothConnected(EXTERNAL_STYLUS_DEVICE_ID, "ANY")

        verify(inputManager).setStylusEverUsed(context, true)
        verify(inputManager, times(1))
            .removeInputDeviceBatteryListener(INTERNAL_STYLUS_DEVICE_ID, stylusListener)
        verify(stylusManager).unregisterCallback(stylusListener)
    }

    @Test
    fun onStylusBluetoothConnected_hasNotStarted_doesNoting() {
        stylusListener.hasStarted = false
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)

        stylusListener.onStylusBluetoothConnected(EXTERNAL_STYLUS_DEVICE_ID, "ANY")

        verifyZeroInteractions(inputManager)
        verifyZeroInteractions(stylusManager)
    }

    @Test
    fun onBatteryStateChanged_batteryPresent_updateStylusFlagAndUnregisters() {
        stylusListener.hasStarted = true
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)
        whenever(batteryState.isPresent).thenReturn(true)

        stylusListener.onBatteryStateChanged(0, 1, batteryState)

        verify(inputManager).setStylusEverUsed(context, true)
        verify(inputManager, times(1))
            .removeInputDeviceBatteryListener(INTERNAL_STYLUS_DEVICE_ID, stylusListener)
        verify(stylusManager).unregisterCallback(stylusListener)
    }

    @Test
    fun onBatteryStateChanged_batteryNotPresent_doesNotUpdateFlagOrUnregister() {
        stylusListener.hasStarted = true
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)
        whenever(batteryState.isPresent).thenReturn(false)

        stylusListener.onBatteryStateChanged(0, 1, batteryState)

        verifyZeroInteractions(stylusManager)
        verify(inputManager, never())
            .removeInputDeviceBatteryListener(INTERNAL_STYLUS_DEVICE_ID, stylusListener)
    }

    @Test
    fun onBatteryStateChanged_hasNotStarted_doesNothing() {
        stylusListener.hasStarted = false
        stylusListener.onStylusAdded(INTERNAL_STYLUS_DEVICE_ID)
        whenever(batteryState.isPresent).thenReturn(false)

        stylusListener.onBatteryStateChanged(0, 1, batteryState)

        verifyZeroInteractions(inputManager)
        verifyZeroInteractions(stylusManager)
    }

    companion object {
        private const val OTHER_DEVICE_ID = 0
        private const val INTERNAL_STYLUS_DEVICE_ID = 1
        private const val EXTERNAL_STYLUS_DEVICE_ID = 2
        private val EXECUTOR = FakeExecutor(FakeSystemClock())
    }
}