[Media TTT] Hide the chip if the user taps the screen.

This CL defines a generic gesture detector class since we now have
multiple cases where we want to detect gestures across the whole
display.

Still TODO: Don't hide the chip if the user taps the chip itself.

Bug: 214274529
Bug: 211487971
Test: manual: verify tapping the screen hides the chip
Test: atest GenericGestureDetectorTest
Test: atest media.taptotransfer
Change-Id: I3951d0bbb3a04d5b82a54339a548b4e293bbf527
Merged-In: I3951d0bbb3a04d5b82a54339a548b4e293bbf527
This commit is contained in:
Caitlin Cassidy
2022-02-16 22:01:11 +00:00
parent 9492d508d8
commit 3aa5b9ed5a
10 changed files with 355 additions and 69 deletions

View File

@@ -29,6 +29,7 @@ import androidx.annotation.VisibleForTesting
import com.android.internal.widget.CachingIconView
import com.android.systemui.R
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.gesture.TapGestureDetector
import com.android.systemui.util.concurrency.DelayableExecutor
/**
@@ -42,6 +43,7 @@ abstract class MediaTttChipControllerCommon<T : MediaTttChipState>(
internal val context: Context,
private val windowManager: WindowManager,
@Main private val mainExecutor: DelayableExecutor,
private val tapGestureDetector: TapGestureDetector,
@LayoutRes private val chipLayoutRes: Int
) {
/** The window layout parameters we'll use when attaching the view to a window. */
@@ -82,6 +84,7 @@ abstract class MediaTttChipControllerCommon<T : MediaTttChipState>(
// Add view if necessary
if (oldChipView == null) {
tapGestureDetector.addOnGestureDetectedCallback(TAG, this::removeChip)
windowManager.addView(chipView, windowLayoutParams)
}
@@ -96,6 +99,7 @@ abstract class MediaTttChipControllerCommon<T : MediaTttChipState>(
// TransferTriggered state: Once the user has initiated the transfer, they should be able
// to move away from the receiver device but still see the status of the transfer.
if (chipView == null) { return }
tapGestureDetector.removeOnGestureDetectedCallback(TAG)
windowManager.removeView(chipView)
chipView = null
}
@@ -128,5 +132,6 @@ abstract class MediaTttChipControllerCommon<T : MediaTttChipState>(
// Used in CTS tests UpdateMediaTapToTransferSenderDisplayTest and
// UpdateMediaTapToTransferReceiverDisplayTest
private const val WINDOW_TITLE = "Media Transfer Chip View"
private val TAG = MediaTttChipControllerCommon::class.simpleName!!
@VisibleForTesting
const val TIMEOUT_MILLIS = 3000L

View File

@@ -29,6 +29,7 @@ import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.media.taptotransfer.common.MediaTttChipControllerCommon
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.gesture.TapGestureDetector
import com.android.systemui.util.concurrency.DelayableExecutor
import javax.inject.Inject
@@ -43,9 +44,10 @@ class MediaTttChipControllerReceiver @Inject constructor(
context: Context,
windowManager: WindowManager,
mainExecutor: DelayableExecutor,
tapGestureDetector: TapGestureDetector,
@Main private val mainHandler: Handler,
) : MediaTttChipControllerCommon<ChipStateReceiver>(
context, windowManager, mainExecutor, R.layout.media_ttt_chip_receiver
context, windowManager, mainExecutor, tapGestureDetector, R.layout.media_ttt_chip_receiver
) {
private val commandQueueCallbacks = object : CommandQueue.Callbacks {
override fun updateMediaTapToTransferReceiverDisplay(

View File

@@ -30,6 +30,7 @@ import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.media.taptotransfer.common.MediaTttChipControllerCommon
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.gesture.TapGestureDetector
import com.android.systemui.util.concurrency.DelayableExecutor
import javax.inject.Inject
@@ -42,9 +43,10 @@ class MediaTttChipControllerSender @Inject constructor(
commandQueue: CommandQueue,
context: Context,
windowManager: WindowManager,
@Main private val mainExecutor: DelayableExecutor,
@Main mainExecutor: DelayableExecutor,
tapGestureDetector: TapGestureDetector,
) : MediaTttChipControllerCommon<ChipStateSender>(
context, windowManager, mainExecutor, R.layout.media_ttt_chip
context, windowManager, mainExecutor, tapGestureDetector, R.layout.media_ttt_chip
) {
private val commandQueueCallbacks = object : CommandQueue.Callbacks {
override fun updateMediaTapToTransferSenderDisplay(

View File

@@ -0,0 +1,102 @@
/*
* 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.statusbar.gesture
import android.annotation.CallSuper
import android.os.Looper
import android.view.Choreographer
import android.view.Display
import android.view.InputEvent
import com.android.systemui.shared.system.InputChannelCompat
import com.android.systemui.shared.system.InputMonitorCompat
/**
* An abstract class to help detect gestures that occur anywhere on the display (not specific to a
* certain view).
*
* This class handles starting/stopping the gesture detection system as well as
* registering/unregistering callbacks for when gestures occur. Note that the class will only listen
* for gestures when there's at least one callback registered.
*
* Subclasses should implement [onInputEvent] to detect their specific gesture. Once a specific
* gesture is detected, they should call [onGestureDetected] (which will notify the callbacks).
*/
abstract class GenericGestureDetector(
private val tag: String
) {
/**
* Active callbacks, each associated with a tag. Gestures will only be monitored if
* [callbacks.size] > 0.
*/
private val callbacks: MutableMap<String, () -> Unit> = mutableMapOf()
private var inputMonitor: InputMonitorCompat? = null
private var inputReceiver: InputChannelCompat.InputEventReceiver? = null
/** Adds a callback that will be triggered when the tap gesture is detected. */
fun addOnGestureDetectedCallback(tag: String, callback: () -> Unit) {
val callbacksWasEmpty = callbacks.isEmpty()
callbacks[tag] = callback
if (callbacksWasEmpty) {
startGestureListening()
}
}
/** Removes the callback. */
fun removeOnGestureDetectedCallback(tag: String) {
callbacks.remove(tag)
if (callbacks.isEmpty()) {
stopGestureListening()
}
}
/** Triggered each time a touch event occurs (and at least one callback is registered). */
abstract fun onInputEvent(ev: InputEvent)
/** Should be called by subclasses when their specific gesture is detected. */
internal fun onGestureDetected() {
callbacks.values.forEach { it.invoke() }
}
/** Start listening to touch events. */
@CallSuper
internal open fun startGestureListening() {
stopGestureListening()
inputMonitor = InputMonitorCompat(tag, Display.DEFAULT_DISPLAY).also {
inputReceiver = it.getInputReceiver(
Looper.getMainLooper(),
Choreographer.getInstance(),
this::onInputEvent
)
}
}
/** Stop listening to touch events. */
@CallSuper
internal open fun stopGestureListening() {
inputMonitor?.let {
inputMonitor = null
it.dispose()
}
inputReceiver?.let {
inputReceiver = null
it.dispose()
}
}
}

View File

@@ -17,15 +17,13 @@
package com.android.systemui.statusbar.gesture
import android.content.Context
import android.os.Looper
import android.view.Choreographer
import android.view.Display
import android.view.InputEvent
import android.view.MotionEvent
import android.view.MotionEvent.*
import android.view.MotionEvent.ACTION_CANCEL
import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_MOVE
import android.view.MotionEvent.ACTION_UP
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.shared.system.InputChannelCompat
import com.android.systemui.shared.system.InputMonitorCompat
import com.android.systemui.statusbar.window.StatusBarWindowController
import javax.inject.Inject
@@ -38,43 +36,17 @@ open class SwipeStatusBarAwayGestureHandler @Inject constructor(
context: Context,
private val statusBarWindowController: StatusBarWindowController,
private val logger: SwipeStatusBarAwayGestureLogger
) {
/**
* Active callbacks, each associated with a tag. Gestures will only be monitored if
* [callbacks.size] > 0.
*/
private val callbacks: MutableMap<String, () -> Unit> = mutableMapOf()
) : GenericGestureDetector(SwipeStatusBarAwayGestureHandler::class.simpleName!!) {
private var startY: Float = 0f
private var startTime: Long = 0L
private var monitoringCurrentTouch: Boolean = false
private var inputMonitor: InputMonitorCompat? = null
private var inputReceiver: InputChannelCompat.InputEventReceiver? = null
private var swipeDistanceThreshold: Int = context.resources.getDimensionPixelSize(
com.android.internal.R.dimen.system_gestures_start_threshold
)
/** Adds a callback that will be triggered when the swipe away gesture is detected. */
fun addOnGestureDetectedCallback(tag: String, callback: () -> Unit) {
val callbacksWasEmpty = callbacks.isEmpty()
callbacks[tag] = callback
if (callbacksWasEmpty) {
startGestureListening()
}
}
/** Removes the callback. */
fun removeOnGestureDetectedCallback(tag: String) {
callbacks.remove(tag)
if (callbacks.isEmpty()) {
stopGestureListening()
}
}
private fun onInputEvent(ev: InputEvent) {
override fun onInputEvent(ev: InputEvent) {
if (ev !is MotionEvent) {
return
}
@@ -108,7 +80,7 @@ open class SwipeStatusBarAwayGestureHandler @Inject constructor(
) {
monitoringCurrentTouch = false
logger.logGestureDetected(ev.y.toInt())
callbacks.values.forEach { it.invoke() }
onGestureDetected()
}
}
ACTION_CANCEL, ACTION_UP -> {
@@ -120,33 +92,15 @@ open class SwipeStatusBarAwayGestureHandler @Inject constructor(
}
}
/** Start listening for the swipe gesture. */
private fun startGestureListening() {
stopGestureListening()
override fun startGestureListening() {
super.startGestureListening()
logger.logInputListeningStarted()
inputMonitor = InputMonitorCompat(TAG, Display.DEFAULT_DISPLAY).also {
inputReceiver = it.getInputReceiver(
Looper.getMainLooper(),
Choreographer.getInstance(),
this::onInputEvent
)
}
}
/** Stop listening for the swipe gesture. */
private fun stopGestureListening() {
inputMonitor?.let {
logger.logInputListeningStopped()
inputMonitor = null
it.dispose()
}
inputReceiver?.let {
inputReceiver = null
it.dispose()
}
override fun stopGestureListening() {
super.stopGestureListening()
logger.logInputListeningStopped()
}
}
private const val SWIPE_TIMEOUT_MS: Long = 500
private val TAG = SwipeStatusBarAwayGestureHandler::class.simpleName

View File

@@ -0,0 +1,64 @@
/*
* 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.statusbar.gesture
import android.content.Context
import android.view.GestureDetector
import android.view.InputEvent
import android.view.MotionEvent
import com.android.systemui.dagger.SysUISingleton
import javax.inject.Inject
/**
* A class to detect when a user taps the screen. To be notified when the tap is detected, add a
* callback via [addOnGestureDetectedCallback].
*/
@SysUISingleton
class TapGestureDetector @Inject constructor(
private val context: Context
) : GenericGestureDetector(TapGestureDetector::class.simpleName!!) {
private val gestureListener = object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent?): Boolean {
onGestureDetected()
return true
}
}
private var gestureDetector: GestureDetector? = null
override fun onInputEvent(ev: InputEvent) {
if (ev !is MotionEvent) {
return
}
// Pass all events to [gestureDetector], which will then notify [gestureListener] when a tap
// is detected.
gestureDetector!!.onTouchEvent(ev)
}
/** Start listening for the tap gesture. */
override fun startGestureListening() {
super.startGestureListening()
gestureDetector = GestureDetector(context, gestureListener)
}
/** Stop listening for the swipe gesture. */
override fun stopGestureListening() {
super.stopGestureListening()
gestureDetector = null
}
}

View File

@@ -26,6 +26,7 @@ import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.gesture.TapGestureDetector
import com.android.systemui.util.concurrency.DelayableExecutor
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
@@ -50,6 +51,8 @@ class MediaTttChipControllerCommonTest : SysuiTestCase() {
private lateinit var appIconDrawable: Drawable
@Mock
private lateinit var windowManager: WindowManager
@Mock
private lateinit var tapGestureDetector: TapGestureDetector
@Before
fun setUp() {
@@ -58,23 +61,28 @@ class MediaTttChipControllerCommonTest : SysuiTestCase() {
fakeClock = FakeSystemClock()
fakeExecutor = FakeExecutor(fakeClock)
controllerCommon = TestControllerCommon(context, windowManager, fakeExecutor)
controllerCommon = TestControllerCommon(
context, windowManager, fakeExecutor, tapGestureDetector
)
}
@Test
fun displayChip_chipAdded() {
fun displayChip_chipAddedAndGestureDetectionStarted() {
controllerCommon.displayChip(getState())
verify(windowManager).addView(any(), any())
verify(tapGestureDetector).addOnGestureDetectedCallback(any(), any())
}
@Test
fun displayChip_twice_chipNotAddedTwice() {
fun displayChip_twice_chipAndGestureDetectionNotAddedTwice() {
controllerCommon.displayChip(getState())
reset(windowManager)
reset(tapGestureDetector)
controllerCommon.displayChip(getState())
verify(windowManager, never()).addView(any(), any())
verify(tapGestureDetector, never()).addOnGestureDetectedCallback(any(), any())
}
@Test
@@ -130,7 +138,7 @@ class MediaTttChipControllerCommonTest : SysuiTestCase() {
}
@Test
fun removeChip_chipRemoved() {
fun removeChip_chipRemovedAndGestureDetectionStopped() {
// First, add the chip
controllerCommon.displayChip(getState())
@@ -138,6 +146,7 @@ class MediaTttChipControllerCommonTest : SysuiTestCase() {
controllerCommon.removeChip()
verify(windowManager).removeView(any())
verify(tapGestureDetector).removeOnGestureDetectedCallback(any())
}
@Test
@@ -174,8 +183,9 @@ class MediaTttChipControllerCommonTest : SysuiTestCase() {
context: Context,
windowManager: WindowManager,
@Main mainExecutor: DelayableExecutor,
) : MediaTttChipControllerCommon<MediaTttChipState>(
context, windowManager, mainExecutor, R.layout.media_ttt_chip
tapGestureDetector: TapGestureDetector,
) : MediaTttChipControllerCommon<MediaTttChipState>(
context, windowManager, mainExecutor, tapGestureDetector, R.layout.media_ttt_chip
) {
override fun updateChipView(chipState: MediaTttChipState, currentChipView: ViewGroup) {
}

View File

@@ -22,6 +22,8 @@ import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.media.MediaRoute2Info
import android.os.Handler
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
@@ -30,6 +32,7 @@ import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.gesture.TapGestureDetector
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
@@ -37,6 +40,7 @@ import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.never
@@ -45,6 +49,8 @@ import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class MediaTttChipControllerReceiverTest : SysuiTestCase() {
private lateinit var controllerReceiver: MediaTttChipControllerReceiver
@@ -76,7 +82,8 @@ class MediaTttChipControllerReceiverTest : SysuiTestCase() {
context,
windowManager,
FakeExecutor(FakeSystemClock()),
Handler.getMain()
TapGestureDetector(context),
Handler.getMain(),
)
val callbackCaptor = ArgumentCaptor.forClass(CommandQueue.Callbacks::class.java)

View File

@@ -21,6 +21,8 @@ import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.media.MediaRoute2Info
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
@@ -31,6 +33,7 @@ import com.android.internal.statusbar.IUndoMediaTransferCallback
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.gesture.TapGestureDetector
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
@@ -38,6 +41,7 @@ import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.never
@@ -46,6 +50,8 @@ import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class MediaTttChipControllerSenderTest : SysuiTestCase() {
private lateinit var controllerSender: MediaTttChipControllerSender
@@ -73,7 +79,11 @@ class MediaTttChipControllerSenderTest : SysuiTestCase() {
context.setMockPackageManager(packageManager)
controllerSender = MediaTttChipControllerSender(
commandQueue, context, windowManager, FakeExecutor(FakeSystemClock())
commandQueue,
context,
windowManager,
FakeExecutor(FakeSystemClock()),
TapGestureDetector(context)
)
val callbackCaptor = ArgumentCaptor.forClass(CommandQueue.Callbacks::class.java)

View File

@@ -0,0 +1,130 @@
package com.android.systemui.statusbar.gesture
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.InputEvent
import android.view.MotionEvent
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class GenericGestureDetectorTest : SysuiTestCase() {
private lateinit var gestureDetector: TestGestureDetector
@Before
fun setUp() {
gestureDetector = TestGestureDetector()
}
@Test
fun noCallbacksRegistered_notGestureListening() {
assertThat(gestureDetector.isGestureListening).isFalse()
}
@Test
fun callbackRegistered_isGestureListening() {
gestureDetector.addOnGestureDetectedCallback("tag"){}
assertThat(gestureDetector.isGestureListening).isTrue()
}
@Test
fun multipleCallbacksRegistered_isGestureListening() {
gestureDetector.addOnGestureDetectedCallback("tag"){}
gestureDetector.addOnGestureDetectedCallback("tag2"){}
assertThat(gestureDetector.isGestureListening).isTrue()
}
@Test
fun allCallbacksUnregistered_notGestureListening() {
gestureDetector.addOnGestureDetectedCallback("tag"){}
gestureDetector.addOnGestureDetectedCallback("tag2"){}
gestureDetector.removeOnGestureDetectedCallback("tag")
gestureDetector.removeOnGestureDetectedCallback("tag2")
assertThat(gestureDetector.isGestureListening).isFalse()
}
@Test
fun someButNotAllCallbacksUnregistered_isGestureListening() {
gestureDetector.addOnGestureDetectedCallback("tag"){}
gestureDetector.addOnGestureDetectedCallback("tag2"){}
gestureDetector.removeOnGestureDetectedCallback("tag2")
assertThat(gestureDetector.isGestureListening).isTrue()
}
@Test
fun onInputEvent_meetsGestureCriteria_allCallbacksNotified() {
var callbackNotified = false
gestureDetector.addOnGestureDetectedCallback("tag"){
callbackNotified = true
}
gestureDetector.onInputEvent(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, CORRECT_X, 0f, 0)
)
assertThat(callbackNotified).isTrue()
}
@Test
fun onInputEvent_doesNotMeetGestureCriteria_callbackNotNotified() {
var callbackNotified = false
gestureDetector.addOnGestureDetectedCallback("tag"){
callbackNotified = true
}
gestureDetector.onInputEvent(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, CORRECT_X - 5, 0f, 0)
)
assertThat(callbackNotified).isFalse()
}
@Test
fun callbackUnregisteredThenGestureDetected_oldCallbackNotNotified() {
var oldCallbackNotified = false
gestureDetector.addOnGestureDetectedCallback("tag"){
oldCallbackNotified = true
}
gestureDetector.addOnGestureDetectedCallback("tag2"){}
gestureDetector.removeOnGestureDetectedCallback("tag")
gestureDetector.onInputEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, CORRECT_X, 0f, 0))
assertThat(oldCallbackNotified).isFalse()
}
inner class TestGestureDetector : GenericGestureDetector("fakeTag") {
var isGestureListening = false
override fun onInputEvent(ev: InputEvent) {
if (ev is MotionEvent && ev.x == CORRECT_X) {
onGestureDetected()
}
}
override fun startGestureListening() {
super.startGestureListening()
isGestureListening = true
}
override fun stopGestureListening() {
super.stopGestureListening()
isGestureListening = false
}
}
}
private const val CORRECT_X = 1234f