[Media TTT] Allow swiping up to dismiss the chipbar.

Bug: 262584940
Test: with flag off, verify that you can't swipe up to dismiss the
chipbar
Test: with flag on, verify that starting a swipe around the chipbar area
and swiping up will dismiss the chipbar
Test: atest ChipbarCoordinatorTest SwipeChipbarAwayGestureHandlerTest
Change-Id: I8440b9b2d7d715981bbf8ee9e44d9202068d4a88

Change-Id: I38aa257855eacaaae6708c796f3f91ea4584342a
This commit is contained in:
Caitlin Shkuratov
2022-12-22 19:12:23 +00:00
parent b7ec39e18d
commit b4314f1e4f
13 changed files with 351 additions and 3 deletions

View File

@@ -341,6 +341,10 @@ object Flags {
// TODO(b/263512203): Tracking Bug
val MEDIA_EXPLICIT_INDICATOR = unreleasedFlag(911, "media_explicit_indicator", teamfood = true)
// TODO(b/265813373): Tracking Bug
val MEDIA_TAP_TO_TRANSFER_DISMISS_GESTURE =
unreleasedFlag(912, "media_ttt_dismiss_gesture", teamfood = true)
// 1000 - dock
val SIMULATE_DOCK_THROUGH_CHARGING = releasedFlag(1000, "simulate_dock_through_charging")

View File

@@ -30,4 +30,8 @@ class MediaTttFlags @Inject constructor(private val featureFlags: FeatureFlags)
/** Check whether the flag for the receiver success state is enabled. */
fun isMediaTttReceiverSuccessRippleEnabled(): Boolean =
featureFlags.isEnabled(Flags.MEDIA_TTT_RECEIVER_SUCCESS_RIPPLE)
/** True if the media transfer chip can be dismissed via a gesture. */
fun isMediaTttDismissGestureEnabled(): Boolean =
featureFlags.isEnabled(Flags.MEDIA_TAP_TO_TRANSFER_DISMISS_GESTURE)
}

View File

@@ -185,6 +185,7 @@ constructor(
}
},
vibrationEffect = chipStateSender.transferStatus.vibrationEffect,
allowSwipeToDismiss = true,
windowTitle = MediaTttUtils.WINDOW_TITLE_SENDER,
wakeReason = MediaTttUtils.WAKE_REASON_SENDER,
timeoutMs = chipStateSender.timeout,

View File

@@ -31,6 +31,7 @@ import android.view.accessibility.AccessibilityManager.FLAG_CONTENT_CONTROLS
import android.view.accessibility.AccessibilityManager.FLAG_CONTENT_ICONS
import android.view.accessibility.AccessibilityManager.FLAG_CONTENT_TEXT
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import com.android.systemui.CoreStartable
import com.android.systemui.Dumpable
import com.android.systemui.dagger.qualifiers.Main
@@ -108,9 +109,10 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
* Whenever the current view disappears, the next-priority view will be displayed if it's still
* valid.
*/
@VisibleForTesting
internal val activeViews: MutableList<DisplayInfo> = mutableListOf()
private fun getCurrentDisplayInfo(): DisplayInfo? {
internal fun getCurrentDisplayInfo(): DisplayInfo? {
return activeViews.getOrNull(0)
}

View File

@@ -80,6 +80,7 @@ constructor(
powerManager: PowerManager,
private val falsingManager: FalsingManager,
private val falsingCollector: FalsingCollector,
private val swipeChipbarAwayGestureHandler: SwipeChipbarAwayGestureHandler?,
private val viewUtil: ViewUtil,
private val vibratorHelper: VibratorHelper,
wakeLockBuilder: WakeLock.Builder,
@@ -105,6 +106,8 @@ constructor(
commonWindowLayoutParams.apply { gravity = Gravity.TOP.or(Gravity.CENTER_HORIZONTAL) }
override fun updateView(newInfo: ChipbarInfo, currentView: ViewGroup) {
updateGestureListening()
logger.logViewUpdate(
newInfo.windowTitle,
newInfo.text.loadText(context),
@@ -228,6 +231,42 @@ constructor(
includeMargins = true,
onAnimationEnd,
)
updateGestureListening()
}
private fun updateGestureListening() {
if (swipeChipbarAwayGestureHandler == null) {
return
}
val currentDisplayInfo = getCurrentDisplayInfo()
if (currentDisplayInfo != null && currentDisplayInfo.info.allowSwipeToDismiss) {
swipeChipbarAwayGestureHandler.setViewFetcher { currentDisplayInfo.view }
swipeChipbarAwayGestureHandler.addOnGestureDetectedCallback(TAG) {
onSwipeUpGestureDetected()
}
} else {
swipeChipbarAwayGestureHandler.resetViewFetcher()
swipeChipbarAwayGestureHandler.removeOnGestureDetectedCallback(TAG)
}
}
private fun onSwipeUpGestureDetected() {
val currentDisplayInfo = getCurrentDisplayInfo()
if (currentDisplayInfo == null) {
logger.logSwipeGestureError(id = null, errorMsg = "No info is being displayed")
return
}
if (!currentDisplayInfo.info.allowSwipeToDismiss) {
logger.logSwipeGestureError(
id = currentDisplayInfo.info.id,
errorMsg = "This view prohibits swipe-to-dismiss",
)
return
}
removeView(currentDisplayInfo.info.id, SWIPE_UP_GESTURE_REASON)
updateGestureListening()
}
private fun ViewGroup.getInnerView(): ViewGroup {
@@ -250,3 +289,5 @@ constructor(
private const val ANIMATION_IN_DURATION = 500L
private const val ANIMATION_OUT_DURATION = 250L
@IdRes private val INFO_TAG = R.id.tag_chipbar_info
private const val SWIPE_UP_GESTURE_REASON = "SWIPE_UP_GESTURE_DETECTED"
private const val TAG = "ChipbarCoordinator"

View File

@@ -33,12 +33,14 @@ import com.android.systemui.temporarydisplay.ViewPriority
* @property endItem an optional end item to display at the end of the chipbar (on the right in LTR
* locales; on the left in RTL locales).
* @property vibrationEffect an optional vibration effect when the chipbar is displayed
* @property allowSwipeToDismiss true if users are allowed to swipe up to dismiss this chipbar.
*/
data class ChipbarInfo(
val startIcon: TintedIcon,
val text: Text,
val endItem: ChipbarEndItem?,
val vibrationEffect: VibrationEffect? = null,
val allowSwipeToDismiss: Boolean = false,
override val windowTitle: String,
override val wakeReason: String,
override val timeoutMs: Int,

View File

@@ -46,4 +46,16 @@ constructor(
{ "Chipbar updated. window=$str1 text=$str2 endItem=$str3" }
)
}
fun logSwipeGestureError(id: String?, errorMsg: String) {
buffer.log(
tag,
LogLevel.WARNING,
{
str1 = id
str2 = errorMsg
},
{ "Chipbar swipe gesture detected for incorrect state. id=$str1 error=$str2" }
)
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.systemui.temporarydisplay.chipbar
import android.content.Context
import android.view.MotionEvent
import android.view.View
import com.android.systemui.statusbar.gesture.SwipeUpGestureHandler
import com.android.systemui.statusbar.gesture.SwipeUpGestureLogger
import com.android.systemui.util.boundsOnScreen
/**
* A class to detect when a user has swiped the chipbar away.
*
* Effectively [SysUISingleton]. But, this shouldn't be created if the gesture isn't enabled. See
* [TemporaryDisplayModule.provideSwipeChipbarAwayGestureHandler].
*/
class SwipeChipbarAwayGestureHandler(
context: Context,
logger: SwipeUpGestureLogger,
) : SwipeUpGestureHandler(context, logger, loggerTag = LOGGER_TAG) {
private var viewFetcher: () -> View? = { null }
override fun startOfGestureIsWithinBounds(ev: MotionEvent): Boolean {
val view = viewFetcher.invoke() ?: return false
// Since chipbar is in its own window, we need to use [boundsOnScreen] to get an accurate
// bottom. ([view.bottom] would be relative to its window, which would be too small.)
val viewBottom = view.boundsOnScreen.bottom
// Allow the gesture to start a bit below the chipbar
return ev.y <= 1.5 * viewBottom
}
/**
* Sets a fetcher that returns the current chipbar view. The fetcher will be invoked whenever a
* gesture starts to determine if the gesture is near the chipbar.
*/
fun setViewFetcher(fetcher: () -> View?) {
viewFetcher = fetcher
}
/** Removes the current view fetcher. */
fun resetViewFetcher() {
viewFetcher = { null }
}
}
private const val LOGGER_TAG = "SwipeChipbarAway"

View File

@@ -16,22 +16,38 @@
package com.android.systemui.temporarydisplay.dagger
import android.content.Context
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.log.LogBufferFactory
import com.android.systemui.media.taptotransfer.MediaTttFlags
import com.android.systemui.plugins.log.LogBuffer
import com.android.systemui.statusbar.gesture.SwipeUpGestureLogger
import com.android.systemui.temporarydisplay.chipbar.SwipeChipbarAwayGestureHandler
import dagger.Module
import dagger.Provides
@Module
interface TemporaryDisplayModule {
@Module
companion object {
@JvmStatic
@Provides
@SysUISingleton
@ChipbarLog
fun provideChipbarLogBuffer(factory: LogBufferFactory): LogBuffer {
return factory.create("ChipbarLog", 40)
}
@Provides
@SysUISingleton
fun provideSwipeChipbarAwayGestureHandler(
mediaTttFlags: MediaTttFlags,
context: Context,
logger: SwipeUpGestureLogger,
): SwipeChipbarAwayGestureHandler? {
return if (mediaTttFlags.isMediaTttDismissGestureEnabled()) {
SwipeChipbarAwayGestureHandler(context, logger)
} else {
null
}
}
}
}

View File

@@ -50,6 +50,7 @@ import com.android.systemui.temporarydisplay.chipbar.ChipbarCoordinator
import com.android.systemui.temporarydisplay.chipbar.ChipbarInfo
import com.android.systemui.temporarydisplay.chipbar.ChipbarLogger
import com.android.systemui.temporarydisplay.chipbar.FakeChipbarCoordinator
import com.android.systemui.temporarydisplay.chipbar.SwipeChipbarAwayGestureHandler
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
@@ -98,6 +99,7 @@ class MediaTttSenderCoordinatorTest : SysuiTestCase() {
@Mock private lateinit var viewUtil: ViewUtil
@Mock private lateinit var windowManager: WindowManager
@Mock private lateinit var vibratorHelper: VibratorHelper
@Mock private lateinit var swipeHandler: SwipeChipbarAwayGestureHandler
private lateinit var fakeWakeLockBuilder: WakeLockFake.Builder
private lateinit var fakeWakeLock: WakeLockFake
private lateinit var chipbarCoordinator: ChipbarCoordinator
@@ -148,6 +150,7 @@ class MediaTttSenderCoordinatorTest : SysuiTestCase() {
powerManager,
falsingManager,
falsingCollector,
swipeHandler,
viewUtil,
vibratorHelper,
fakeWakeLockBuilder,

View File

@@ -20,6 +20,7 @@ import android.os.PowerManager
import android.os.VibrationEffect
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
@@ -43,6 +44,8 @@ import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.temporarydisplay.ViewPriority
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.time.FakeSystemClock
import com.android.systemui.util.view.ViewUtil
@@ -54,6 +57,7 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations
@@ -74,6 +78,7 @@ class ChipbarCoordinatorTest : SysuiTestCase() {
@Mock private lateinit var falsingCollector: FalsingCollector
@Mock private lateinit var viewUtil: ViewUtil
@Mock private lateinit var vibratorHelper: VibratorHelper
@Mock private lateinit var swipeGestureHandler: SwipeChipbarAwayGestureHandler
private lateinit var fakeWakeLockBuilder: WakeLockFake.Builder
private lateinit var fakeWakeLock: WakeLockFake
private lateinit var fakeClock: FakeSystemClock
@@ -106,6 +111,7 @@ class ChipbarCoordinatorTest : SysuiTestCase() {
powerManager,
falsingManager,
falsingCollector,
swipeGestureHandler,
viewUtil,
vibratorHelper,
fakeWakeLockBuilder,
@@ -430,17 +436,101 @@ class ChipbarCoordinatorTest : SysuiTestCase() {
verify(logger).logViewUpdate(eq(WINDOW_TITLE), eq("new title text"), any())
}
@Test
fun swipeToDismiss_false_neverListensForGesture() {
underTest.displayView(
createChipbarInfo(
Icon.Resource(R.drawable.ic_cake, contentDescription = null),
Text.Loaded("title text"),
endItem = ChipbarEndItem.Loading,
allowSwipeToDismiss = false,
)
)
verify(swipeGestureHandler, never()).addOnGestureDetectedCallback(any(), any())
}
@Test
fun swipeToDismiss_true_listensForGesture() {
underTest.displayView(
createChipbarInfo(
Icon.Resource(R.drawable.ic_cake, contentDescription = null),
Text.Loaded("title text"),
endItem = ChipbarEndItem.Loading,
allowSwipeToDismiss = true,
)
)
verify(swipeGestureHandler).addOnGestureDetectedCallback(any(), any())
}
@Test
fun swipeToDismiss_swipeOccurs_viewDismissed() {
underTest.displayView(
createChipbarInfo(
Icon.Resource(R.drawable.ic_cake, contentDescription = null),
Text.Loaded("title text"),
endItem = ChipbarEndItem.Loading,
allowSwipeToDismiss = true,
)
)
val view = getChipbarView()
val callbackCaptor = argumentCaptor<(MotionEvent) -> Unit>()
verify(swipeGestureHandler).addOnGestureDetectedCallback(any(), capture(callbackCaptor))
callbackCaptor.value.invoke(MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0))
verify(windowManager).removeView(view)
}
@Test
fun swipeToDismiss_viewUpdatedToFalse_swipeOccurs_viewNotDismissed() {
underTest.displayView(
createChipbarInfo(
Icon.Resource(R.drawable.ic_cake, contentDescription = null),
Text.Loaded("title text"),
endItem = ChipbarEndItem.Loading,
allowSwipeToDismiss = true,
)
)
val view = getChipbarView()
val callbackCaptor = argumentCaptor<(MotionEvent) -> Unit>()
verify(swipeGestureHandler).addOnGestureDetectedCallback(any(), capture(callbackCaptor))
// WHEN the view is updated to not allow swipe-to-dismiss
underTest.displayView(
createChipbarInfo(
Icon.Resource(R.drawable.ic_cake, contentDescription = null),
Text.Loaded("title text"),
endItem = ChipbarEndItem.Loading,
allowSwipeToDismiss = false,
)
)
// THEN the callback is removed
verify(swipeGestureHandler).removeOnGestureDetectedCallback(any())
// And WHEN the old callback is invoked
callbackCaptor.value.invoke(MotionEvent.obtain(0L, 0L, 0, 0f, 0f, 0))
// THEN it is ignored and view isn't removed
verify(windowManager, never()).removeView(view)
}
private fun createChipbarInfo(
startIcon: Icon,
text: Text,
endItem: ChipbarEndItem?,
vibrationEffect: VibrationEffect? = null,
allowSwipeToDismiss: Boolean = false,
): ChipbarInfo {
return ChipbarInfo(
TintedIcon(startIcon, tintAttr = null),
text,
endItem,
vibrationEffect,
allowSwipeToDismiss,
windowTitle = WINDOW_TITLE,
wakeReason = WAKE_REASON,
timeoutMs = TIMEOUT,

View File

@@ -43,6 +43,7 @@ class FakeChipbarCoordinator(
powerManager: PowerManager,
falsingManager: FalsingManager,
falsingCollector: FalsingCollector,
swipeChipbarAwayGestureHandler: SwipeChipbarAwayGestureHandler,
viewUtil: ViewUtil,
vibratorHelper: VibratorHelper,
wakeLockBuilder: WakeLock.Builder,
@@ -59,6 +60,7 @@ class FakeChipbarCoordinator(
powerManager,
falsingManager,
falsingCollector,
swipeChipbarAwayGestureHandler,
viewUtil,
vibratorHelper,
wakeLockBuilder,

View File

@@ -0,0 +1,109 @@
/*
* 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.systemui.temporarydisplay.chipbar
import android.graphics.Rect
import android.view.MotionEvent
import android.view.View
import androidx.test.filters.SmallTest
import com.android.dx.mockito.inline.extended.ExtendedMockito.doAnswer
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
@SmallTest
class SwipeChipbarAwayGestureHandlerTest : SysuiTestCase() {
private lateinit var underTest: SwipeChipbarAwayGestureHandler
@Before
fun setUp() {
underTest = SwipeChipbarAwayGestureHandler(context, mock())
}
@Test
fun startOfGestureIsWithinBounds_noViewFetcher_returnsFalse() {
assertThat(underTest.startOfGestureIsWithinBounds(createMotionEvent())).isFalse()
}
@Test
fun startOfGestureIsWithinBounds_usesViewFetcher_aboveBottom_returnsTrue() {
val view = createMockView()
underTest.setViewFetcher { view }
val motionEvent = createMotionEvent(y = VIEW_BOTTOM - 100f)
assertThat(underTest.startOfGestureIsWithinBounds(motionEvent)).isTrue()
}
@Test
fun startOfGestureIsWithinBounds_usesViewFetcher_slightlyBelowBottom_returnsTrue() {
val view = createMockView()
underTest.setViewFetcher { view }
val motionEvent = createMotionEvent(y = VIEW_BOTTOM + 20f)
assertThat(underTest.startOfGestureIsWithinBounds(motionEvent)).isTrue()
}
@Test
fun startOfGestureIsWithinBounds_usesViewFetcher_tooFarDown_returnsFalse() {
val view = createMockView()
underTest.setViewFetcher { view }
val motionEvent = createMotionEvent(y = VIEW_BOTTOM * 4f)
assertThat(underTest.startOfGestureIsWithinBounds(motionEvent)).isFalse()
}
@Test
fun startOfGestureIsWithinBounds_viewFetcherReset_returnsFalse() {
val view = createMockView()
underTest.setViewFetcher { view }
val motionEvent = createMotionEvent(y = VIEW_BOTTOM - 100f)
assertThat(underTest.startOfGestureIsWithinBounds(motionEvent)).isTrue()
underTest.resetViewFetcher()
assertThat(underTest.startOfGestureIsWithinBounds(motionEvent)).isFalse()
}
private fun createMotionEvent(y: Float = 0f): MotionEvent {
return MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, y, 0)
}
private fun createMockView(): View {
return mock<View>().also {
doAnswer { invocation ->
val out: Rect = invocation.getArgument(0)
out.set(0, 0, 0, VIEW_BOTTOM)
null
}
.whenever(it)
.getBoundsOnScreen(any())
}
}
private companion object {
const val VIEW_BOTTOM = 455
}
}