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

Commit f79c1602 authored by helencheuk's avatar helencheuk
Browse files

[Hot Corner] Add cursor position repository

Add cursor position repository to listen to display changes and subscribe to the input events.
It converts the input events to cursor positions.

Bug: 397182595
Flag: com.android.systemui.shared.cursor_hot_corner
Test: MultiDisplayCursorPositionRepositoryTest

Change-Id: I0eee7f974fc5ec026ccfe1f84f9b422dcd83d97a
parent 470f5318
Loading
Loading
Loading
Loading
+195 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.cursorposition.data.repository

import android.os.Handler
import android.testing.TestableLooper
import android.testing.TestableLooper.RunWithLooper
import android.view.Display.DEFAULT_DISPLAY
import android.view.Display.TYPE_EXTERNAL
import android.view.Display.TYPE_INTERNAL
import android.view.InputDevice.SOURCE_MOUSE
import android.view.InputDevice.SOURCE_TOUCHPAD
import android.view.MotionEvent
import androidx.test.filters.SmallTest
import com.android.app.displaylib.PerDisplayInstanceRepositoryImpl
import com.android.systemui.SysuiTestCase
import com.android.systemui.cursorposition.data.model.CursorPosition
import com.android.systemui.cursorposition.data.repository.SingleDisplayCursorPositionRepositoryImpl.Companion.defaultInputEventListenerBuilder
import com.android.systemui.cursorposition.domain.data.repository.TestCursorPositionRepositoryInstanceProvider
import com.android.systemui.display.data.repository.display
import com.android.systemui.display.data.repository.displayRepository
import com.android.systemui.display.data.repository.fakeDisplayInstanceLifecycleManager
import com.android.systemui.display.data.repository.perDisplayDumpHelper
import com.android.systemui.kosmos.backgroundScope
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.shared.system.InputChannelCompat
import com.android.systemui.shared.system.InputMonitorCompat
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import kotlinx.coroutines.launch
import org.junit.Before
import org.junit.Rule
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.any
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@SmallTest
@RunWith(ParameterizedAndroidJunit4::class)
@RunWithLooper
@kotlinx.coroutines.ExperimentalCoroutinesApi
class MultiDisplayCursorPositionRepositoryTest(private val cursorEventSource: Int) :
    SysuiTestCase() {

    @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
    private lateinit var underTest: MultiDisplayCursorPositionRepository
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val displayRepository = kosmos.displayRepository
    private val displayLifecycleManager = kosmos.fakeDisplayInstanceLifecycleManager

    private lateinit var listener: InputChannelCompat.InputEventListener
    private var emittedCursorPosition: CursorPosition? = null

    @Mock private lateinit var inputMonitor: InputMonitorCompat
    @Mock private lateinit var inputReceiver: InputChannelCompat.InputEventReceiver

    private val x = 100f
    private val y = 200f

    private lateinit var testableLooper: TestableLooper

    @Before
    fun setup() {
        testableLooper = TestableLooper.get(this)
        val testHandler = Handler(testableLooper.looper)
        whenever(inputMonitor.getInputReceiver(any(), any(), any())).thenReturn(inputReceiver)
        displayLifecycleManager.displayIds.value = setOf(DEFAULT_DISPLAY, DISPLAY_2)

        val cursorPerDisplayRepository =
            PerDisplayInstanceRepositoryImpl(
                debugName = "testCursorPositionPerDisplayInstanceRepository",
                instanceProvider =
                    TestCursorPositionRepositoryInstanceProvider(
                        testHandler,
                        { channel ->
                            listener = defaultInputEventListenerBuilder.build(channel)
                            listener
                        },
                    ) { _: String, _: Int ->
                        inputMonitor
                    },
                displayLifecycleManager,
                kosmos.backgroundScope,
                displayRepository,
                kosmos.perDisplayDumpHelper,
            )

        underTest =
            MultiDisplayCursorPositionRepositoryImpl(
                displayRepository,
                backgroundScope = kosmos.backgroundScope,
                cursorPerDisplayRepository,
            )
    }

    @Test
    fun getCursorPositionFromDefaultDisplay() = setUpAndRunTest {
        val event = getMotionEvent(x, y, 0)
        listener.onInputEvent(event)

        assertThat(emittedCursorPosition).isEqualTo(CursorPosition(x, y, 0))
    }

    @Test
    fun getCursorPositionFromAdditionDisplay() = setUpAndRunTest {
        addDisplay(id = DISPLAY_2, type = TYPE_EXTERNAL)

        val event = getMotionEvent(x, y, DISPLAY_2)
        listener.onInputEvent(event)

        assertThat(emittedCursorPosition).isEqualTo(CursorPosition(x, y, DISPLAY_2))
    }

    @Test
    fun noCursorPositionFromRemovedDisplay() = setUpAndRunTest {
        addDisplay(id = DISPLAY_2, type = TYPE_EXTERNAL)
        removeDisplay(DISPLAY_2)

        val event = getMotionEvent(x, y, DISPLAY_2)
        listener.onInputEvent(event)

        assertThat(emittedCursorPosition).isEqualTo(null)
    }

    @Test
    fun disposeInputMonitorAndInputReceiver() = setUpAndRunTest {
        addDisplay(DISPLAY_2, TYPE_EXTERNAL)
        removeDisplay(DISPLAY_2)

        verify(inputMonitor).dispose()
        verify(inputReceiver).dispose()
    }

    private fun setUpAndRunTest(block: suspend () -> Unit) =
        kosmos.runTest {
            // Add default display before creating cursor repository
            displayRepository.addDisplays(display(id = DEFAULT_DISPLAY, type = TYPE_INTERNAL))

            backgroundScope.launch {
                underTest.cursorPositions.collect { emittedCursorPosition = it }
            }
            // Run all tasks received by TestHandler to create input monitors
            testableLooper.processAllMessages()

            block()
        }

    private suspend fun addDisplay(id: Int, type: Int) {
        displayRepository.addDisplays(display(id = id, type = type))
        testableLooper.processAllMessages()
    }

    private suspend fun removeDisplay(id: Int) {
        displayRepository.removeDisplay(id)
        testableLooper.processAllMessages()
    }

    private fun getMotionEvent(x: Float, y: Float, displayId: Int): MotionEvent {
        val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, x, y, 0)
        event.source = cursorEventSource
        event.displayId = displayId
        return event
    }

    private companion object {
        const val DISPLAY_2 = DEFAULT_DISPLAY + 1

        @JvmStatic
        @Parameters(name = "source = {0}")
        fun data(): List<Int> {
            return listOf(SOURCE_MOUSE, SOURCE_TOUCHPAD)
        }
    }
}
+30 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.cursorposition.data.model

/**
 * Represents the position of cursor hotspot on the screen. Hotspot is the specific pixel that
 * signifies the location of the pointer's interaction with the user interface. By default, hotspot
 * of a cursor is the tip of arrow.
 *
 * @property x The x-coordinate of the cursor hotspot, relative to the top-left corner of the
 *   screen.
 * @property y The y-coordinate of the cursor hotspot, relative to the top-left corner of the
 *   screen.
 * @property displayId The display on which the cursor is located.
 */
data class CursorPosition(val x: Float, val y: Float, val displayId: Int)
+71 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.cursorposition.data.repository

import com.android.app.displaylib.PerDisplayRepository
import com.android.systemui.cursorposition.data.model.CursorPosition
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.display.data.repository.DisplayRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn

/** Repository for cursor position of multi displays. */
interface MultiDisplayCursorPositionRepository {
    val cursorPositions: Flow<CursorPosition?>
}

/**
 * Implementation of [MultiDisplayCursorPositionRepository] that aggregates cursor position updates
 * from multiple displays.
 *
 * This class uses a [DisplayRepository] to track added displays and a [PerDisplayRepository] to
 * manage [SingleDisplayCursorPositionRepository] instances for each display. [PerDisplayRepository]
 * would destroy the instance if the display is removed. This class combines the cursor position
 * from all displays into a single cursorPositions StateFlow.
 */
@SysUISingleton
class MultiDisplayCursorPositionRepositoryImpl
@Inject
constructor(
    private val displayRepository: DisplayRepository,
    @Background private val backgroundScope: CoroutineScope,
    private val cursorRepositories: PerDisplayRepository<SingleDisplayCursorPositionRepository>,
) : MultiDisplayCursorPositionRepository {

    private val allDisplaysCursorPositions: Flow<CursorPosition> =
        displayRepository.displayAdditionEvent
            .mapNotNull { c -> c?.displayId }
            .onStart { emitAll(displayRepository.displayIds.value.asFlow()) }
            .flatMapMerge {
                val repo = cursorRepositories[it]
                repo?.cursorPositions ?: emptyFlow()
            }

    override val cursorPositions: StateFlow<CursorPosition?> =
        allDisplaysCursorPositions.stateIn(backgroundScope, SharingStarted.WhileSubscribed(), null)
}
+154 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.cursorposition.data.repository

import android.os.Handler
import android.os.Looper
import android.view.Choreographer
import android.view.InputDevice.SOURCE_MOUSE
import android.view.InputDevice.SOURCE_TOUCHPAD
import android.view.MotionEvent
import com.android.app.displaylib.PerDisplayInstanceProviderWithTeardown
import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
import com.android.systemui.cursorposition.data.model.CursorPosition
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.shared.system.InputChannelCompat
import com.android.systemui.shared.system.InputChannelCompat.InputEventReceiver
import com.android.systemui.shared.system.InputMonitorCompat
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import javax.inject.Inject
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn

/** Repository for cursor position in single display. */
interface SingleDisplayCursorPositionRepository {
    /** Flow of [CursorPosition] for the display. */
    val cursorPositions: Flow<CursorPosition>

    /** Destroys the repository. */
    fun destroy()
}

/**
 * Implementation of [SingleDisplayCursorPositionRepository].
 *
 * @param displayId the display id
 * @param backgroundHandler the background handler
 * @param listenerBuilder the builder for [InputChannelCompat.InputEventListener]
 * @param inputMonitorBuilder the builder for [InputMonitorCompat]
 */
class SingleDisplayCursorPositionRepositoryImpl
@AssistedInject
constructor(
    @Assisted displayId: Int,
    @Background private val backgroundHandler: Handler,
    @Assisted
    private val listenerBuilder: InputEventListenerBuilder = defaultInputEventListenerBuilder,
    @Assisted private val inputMonitorBuilder: InputMonitorBuilder = defaultInputMonitorBuilder,
) : SingleDisplayCursorPositionRepository {

    private var scope: ProducerScope<CursorPosition>? = null

    private fun createInputMonitorCallbackFlow(displayId: Int): Flow<CursorPosition> =
        conflatedCallbackFlow {
                val inputMonitor: InputMonitorCompat = inputMonitorBuilder.build(TAG, displayId)
                val inputReceiver: InputEventReceiver =
                    inputMonitor.getInputReceiver(
                        Looper.myLooper(),
                        Choreographer.getInstance(),
                        listenerBuilder.build(this),
                    )
                scope = this
                awaitClose {
                    inputMonitor.dispose()
                    inputReceiver.dispose()
                }
            }
            // Use backgroundHandler as dispatcher because it has a looper (unlike
            // "backgroundDispatcher" which does not have a looper) and input receiver could use
            // its background looper and choreographer
            .flowOn(backgroundHandler.asCoroutineDispatcher())

    override val cursorPositions: Flow<CursorPosition> = createInputMonitorCallbackFlow(displayId)

    override fun destroy() {
        scope?.close()
    }

    @AssistedFactory
    interface Factory {
        /**
         * Creates a new instance of [SingleDisplayCursorPositionRepositoryImpl] for a given
         * [displayId].
         */
        fun create(
            displayId: Int,
            listenerBuilder: InputEventListenerBuilder = defaultInputEventListenerBuilder,
            inputMonitorBuilder: InputMonitorBuilder = defaultInputMonitorBuilder,
        ): SingleDisplayCursorPositionRepositoryImpl
    }

    companion object {
        private const val TAG = "CursorPositionPerDisplayRepositoryImpl"

        private val defaultInputMonitorBuilder = InputMonitorBuilder { name, displayId ->
            InputMonitorCompat(name, displayId)
        }

        val defaultInputEventListenerBuilder = InputEventListenerBuilder { channel ->
            InputChannelCompat.InputEventListener { event ->
                if (
                    event is MotionEvent &&
                        (event.source == SOURCE_MOUSE || event.source == SOURCE_TOUCHPAD)
                ) {
                    val cursorEvent = CursorPosition(event.x, event.y, event.displayId)
                    channel.trySendWithFailureLogging(cursorEvent, TAG)
                }
            }
        }
    }
}

fun interface InputEventListenerBuilder {
    fun build(channel: SendChannel<CursorPosition>): InputChannelCompat.InputEventListener
}

fun interface InputMonitorBuilder {
    fun build(name: String, displayId: Int): InputMonitorCompat
}

@SysUISingleton
class SingleDisplayCursorPositionRepositoryFactory
@Inject
constructor(private val factory: SingleDisplayCursorPositionRepositoryImpl.Factory) :
    PerDisplayInstanceProviderWithTeardown<SingleDisplayCursorPositionRepository> {
    override fun createInstance(displayId: Int): SingleDisplayCursorPositionRepository {
        return factory.create(displayId)
    }

    override fun destroyInstance(instance: SingleDisplayCursorPositionRepository) {
        instance.destroy()
    }
}
+44 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.cursorposition.domain.data.repository

import android.os.Handler
import com.android.app.displaylib.PerDisplayInstanceProviderWithTeardown
import com.android.systemui.cursorposition.data.repository.InputEventListenerBuilder
import com.android.systemui.cursorposition.data.repository.InputMonitorBuilder
import com.android.systemui.cursorposition.data.repository.SingleDisplayCursorPositionRepository
import com.android.systemui.cursorposition.data.repository.SingleDisplayCursorPositionRepositoryImpl

class TestCursorPositionRepositoryInstanceProvider(
    private val handler: Handler,
    private val listenerBuilder: InputEventListenerBuilder,
    private val inputMonitorBuilder: InputMonitorBuilder,
) : PerDisplayInstanceProviderWithTeardown<SingleDisplayCursorPositionRepository> {

    override fun destroyInstance(instance: SingleDisplayCursorPositionRepository) {
        instance.destroy()
    }

    override fun createInstance(displayId: Int): SingleDisplayCursorPositionRepository {
        return SingleDisplayCursorPositionRepositoryImpl(
            displayId,
            handler,
            listenerBuilder,
            inputMonitorBuilder,
        )
    }
}