[multi-shade] Bouncer touch integration.

Connects multi-shade's touch interaction to the bouncer.

On the lock screen such that dragging up when shades are collapsed brings up the bouncer.

https://drive.google.com/file/d/1AiRelfhAg2QFY-EoHvHtG8V6KIzcST_g/view?usp=sharing

Bug: 274159734
Flag: DUAL_SHADE
Test: unit tests expanded for MultiShadeEventInteractor
Test: manually verified that:
1. dragging up when shades are collapsed brings up the bouncer.
2. back gesture hides the bouncer, allowing a drag down to show the dual
   shades again.
3. unlocking the bouncer works as expected.

Change-Id: I81118cb2ccba3bb2887def2568403198e7852c97
This commit is contained in:
Alejandro Nijamkin
2023-03-23 14:43:58 -07:00
parent 1197d0e142
commit 58cd612694
5 changed files with 286 additions and 36 deletions

View File

@@ -19,13 +19,20 @@ package com.android.systemui.multishade.domain.interactor
import android.content.Context
import android.view.MotionEvent
import android.view.ViewConfiguration
import com.android.systemui.classifier.Classifier
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.domain.interactor.PrimaryBouncerInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.multishade.shared.math.isZero
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import com.android.systemui.plugins.FalsingManager
import javax.inject.Inject
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
@@ -39,15 +46,27 @@ class MultiShadeMotionEventInteractor
constructor(
@Application private val applicationContext: Context,
@Application private val applicationScope: CoroutineScope,
private val interactor: MultiShadeInteractor,
private val multiShadeInteractor: MultiShadeInteractor,
keyguardTransitionInteractor: KeyguardTransitionInteractor,
private val bouncerInteractor: PrimaryBouncerInteractor,
private val falsingManager: FalsingManager,
) {
private val isAnyShadeExpanded: StateFlow<Boolean> =
interactor.isAnyShadeExpanded.stateIn(
multiShadeInteractor.isAnyShadeExpanded.stateIn(
scope = applicationScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
private val isBouncerShowing: StateFlow<Boolean> =
keyguardTransitionInteractor
.transitionValue(state = KeyguardState.PRIMARY_BOUNCER)
.map { !it.isZero() }
.stateIn(
scope = applicationScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
private var interactionState: InteractionState? = null
@@ -65,6 +84,10 @@ constructor(
return false
}
if (isBouncerShowing.value) {
return false
}
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// Record where the pointer was placed and which pointer it was.
@@ -75,7 +98,7 @@ constructor(
currentY = event.y,
pointerId = event.getPointerId(0),
isDraggingHorizontally = false,
isDraggingVertically = false,
draggedVertically = Dragged.NONE,
)
false
@@ -85,16 +108,25 @@ constructor(
val pointerIndex = event.findPointerIndex(it.pointerId)
val currentX = event.getX(pointerIndex)
val currentY = event.getY(pointerIndex)
if (!it.isDraggingHorizontally && !it.isDraggingVertically) {
val xDistanceTravelled = abs(currentX - it.initialX)
val yDistanceTravelled = abs(currentY - it.initialY)
if (!it.isDraggingHorizontally && it.draggedVertically == Dragged.NONE) {
val xDistanceTravelled = currentX - it.initialX
val yDistanceTravelled = currentY - it.initialY
val touchSlop = ViewConfiguration.get(applicationContext).scaledTouchSlop
interactionState =
when {
yDistanceTravelled > touchSlop ->
it.copy(isDraggingVertically = true)
xDistanceTravelled > touchSlop ->
it.copy(isDraggingHorizontally = true)
abs(yDistanceTravelled) > touchSlop ->
it.copy(
draggedVertically =
if (yDistanceTravelled > 0) {
Dragged.SHADE
} else {
Dragged.BOUNCER
}
)
abs(xDistanceTravelled) > touchSlop ->
it.copy(
isDraggingHorizontally = true,
)
else -> interactionState
}
}
@@ -124,7 +156,7 @@ constructor(
return when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
interactionState?.let {
if (it.isDraggingVertically) {
if (it.draggedVertically != Dragged.NONE) {
val pointerIndex = event.findPointerIndex(it.pointerId)
val previousY = it.currentY
val currentY = event.getY(pointerIndex)
@@ -133,32 +165,48 @@ constructor(
currentY = currentY,
)
val yDragAmountPx = currentY - previousY
if (yDragAmountPx != 0f) {
interactor.sendProxiedInput(
ProxiedInputModel.OnDrag(
xFraction = event.x / viewWidthPx,
yDragAmountPx = yDragAmountPx,
)
)
when (it.draggedVertically) {
Dragged.SHADE -> {
val yDragAmountPx = currentY - previousY
if (yDragAmountPx != 0f) {
multiShadeInteractor.sendProxiedInput(
ProxiedInputModel.OnDrag(
xFraction = event.x / viewWidthPx,
yDragAmountPx = yDragAmountPx,
)
)
}
true
}
Dragged.BOUNCER -> {
bouncerInteractor.show(isScrimmed = true)
false
}
else -> false
}
} else {
false
}
}
true
?: false
}
MotionEvent.ACTION_UP -> {
if (interactionState.isDraggingVertically()) {
if (interactionState?.draggedVertically == Dragged.SHADE) {
// We finished dragging. Record that so the multi-shade framework can issue a
// fling, if the velocity reached in the drag was high enough, for example.
interactor.sendProxiedInput(ProxiedInputModel.OnDragEnd)
multiShadeInteractor.sendProxiedInput(ProxiedInputModel.OnDragEnd)
if (falsingManager.isFalseTouch(Classifier.SHADE_DRAG)) {
multiShadeInteractor.collapseAll()
}
}
interactionState = null
true
}
MotionEvent.ACTION_CANCEL -> {
if (interactionState.isDraggingVertically()) {
if (interactionState?.draggedVertically == Dragged.SHADE) {
// Our drag gesture was canceled by the system. This happens primarily in one of
// two occasions: (a) the parent view has decided to intercept the gesture
// itself and/or route it to a different child view or (b) the pointer has
@@ -166,7 +214,15 @@ constructor(
// we pass the cancellation event to the multi-shade framework to record it.
// Doing that allows the multi-shade framework to know that the gesture ended to
// allow new gestures to be accepted.
interactor.sendProxiedInput(ProxiedInputModel.OnDragCancel)
multiShadeInteractor.sendProxiedInput(ProxiedInputModel.OnDragCancel)
if (falsingManager.isFalseTouch(Classifier.SHADE_DRAG)) {
multiShadeInteractor.collapseAll()
}
} else if (interactionState?.draggedVertically == Dragged.BOUNCER) {
if (falsingManager.isFalseTouch(Classifier.BOUNCER_UNLOCK)) {
bouncerInteractor.hide()
}
}
interactionState = null
@@ -181,11 +237,23 @@ constructor(
val initialY: Float,
val currentY: Float,
val pointerId: Int,
/** Whether the current gesture is dragging horizontally. */
val isDraggingHorizontally: Boolean,
val isDraggingVertically: Boolean,
/** The UI component that is being dragged vertically, if any. */
val draggedVertically: Dragged,
)
/** Enumerates the UI components that can be dragged by the user. */
private enum class Dragged {
/** The bouncer is being dragged by the user. */
BOUNCER,
/** A shade is being dragged by the user. */
SHADE,
/** No UI component is being dragged by the user. */
NONE,
}
private fun InteractionState?.isDraggingVertically(): Boolean {
return this?.isDraggingVertically == true
return this?.draggedVertically != Dragged.NONE
}
}

View File

@@ -233,6 +233,8 @@ import com.android.systemui.util.Utils;
import com.android.systemui.util.time.SystemClock;
import com.android.wm.shell.animation.FlingAnimationUtils;
import kotlin.Unit;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
@@ -243,7 +245,6 @@ import java.util.function.Consumer;
import javax.inject.Inject;
import javax.inject.Provider;
import kotlin.Unit;
import kotlinx.coroutines.CoroutineDispatcher;
@CentralSurfacesComponent.CentralSurfacesScope
@@ -387,6 +388,7 @@ public final class NotificationPanelViewController implements Dumpable {
private KeyguardBottomAreaView mKeyguardBottomArea;
private boolean mExpanding;
private boolean mSplitShadeEnabled;
private final boolean mMultiShadeEnabled;
/** The bottom padding reserved for elements of the keyguard measuring notifications. */
private float mKeyguardNotificationBottomPadding;
/**
@@ -851,6 +853,7 @@ public final class NotificationPanelViewController implements Dumpable {
mFeatureFlags = featureFlags;
mAnimateBack = mFeatureFlags.isEnabled(Flags.WM_SHADE_ANIMATE_BACK_GESTURE);
mTrackpadGestureBack = mFeatureFlags.isEnabled(Flags.TRACKPAD_GESTURE_FEATURES);
mMultiShadeEnabled = mFeatureFlags.isEnabled(Flags.DUAL_SHADE);
mFalsingCollector = falsingCollector;
mPowerManager = powerManager;
mWakeUpCoordinator = coordinator;
@@ -4018,8 +4021,11 @@ public final class NotificationPanelViewController implements Dumpable {
* {@link #updateVisibility()}? That would allow us to make this method private.
*/
public void updatePanelExpansionAndVisibility() {
mShadeExpansionStateManager.onPanelExpansionChanged(
mExpandedFraction, isExpanded(), mTracking, mExpansionDragDownAmountPx);
if (!mMultiShadeEnabled) {
mShadeExpansionStateManager.onPanelExpansionChanged(
mExpandedFraction, isExpanded(), mTracking, mExpansionDragDownAmountPx);
}
updateVisibility();
}

View File

@@ -20,7 +20,14 @@ import android.view.MotionEvent
import android.view.ViewConfiguration
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.classifier.FalsingManagerFake
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.domain.interactor.PrimaryBouncerInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
import com.android.systemui.multishade.data.repository.MultiShadeRepository
import com.android.systemui.multishade.shared.model.ProxiedInputModel
@@ -36,12 +43,18 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
@Mock private lateinit var bouncerInteractor: PrimaryBouncerInteractor
private lateinit var underTest: MultiShadeMotionEventInteractor
private lateinit var testScope: TestScope
@@ -49,9 +62,13 @@ class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
private lateinit var repository: MultiShadeRepository
private lateinit var interactor: MultiShadeInteractor
private val touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop
private lateinit var keyguardTransitionRepository: FakeKeyguardTransitionRepository
private lateinit var falsingManager: FalsingManagerFake
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
testScope = TestScope()
motionEvents = mutableSetOf()
@@ -67,11 +84,19 @@ class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
repository = repository,
inputProxy = inputProxy,
)
keyguardTransitionRepository = FakeKeyguardTransitionRepository()
falsingManager = FalsingManagerFake()
underTest =
MultiShadeMotionEventInteractor(
applicationContext = context,
applicationScope = testScope.backgroundScope,
interactor = interactor,
multiShadeInteractor = interactor,
keyguardTransitionInteractor =
KeyguardTransitionInteractor(
repository = keyguardTransitionRepository,
),
bouncerInteractor = bouncerInteractor,
falsingManager = falsingManager,
)
}
@@ -201,6 +226,60 @@ class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
assertThat(underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_CANCEL))).isFalse()
}
@Test
fun shouldIntercept_moveAboveTouchSlopAndUp_butBouncerShowing_returnsFalse() =
testScope.runTest {
keyguardTransitionRepository.sendTransitionStep(
TransitionStep(
from = KeyguardState.LOCKSCREEN,
to = KeyguardState.PRIMARY_BOUNCER,
value = 0.1f,
transitionState = TransitionState.STARTED,
)
)
runCurrent()
underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN))
assertThat(
underTest.shouldIntercept(
motionEvent(
MotionEvent.ACTION_MOVE,
y = touchSlop + 1f,
)
)
)
.isFalse()
assertThat(underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_UP))).isFalse()
}
@Test
fun shouldIntercept_moveAboveTouchSlopAndCancel_butBouncerShowing_returnsFalse() =
testScope.runTest {
keyguardTransitionRepository.sendTransitionStep(
TransitionStep(
from = KeyguardState.LOCKSCREEN,
to = KeyguardState.PRIMARY_BOUNCER,
value = 0.1f,
transitionState = TransitionState.STARTED,
)
)
runCurrent()
underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN))
assertThat(
underTest.shouldIntercept(
motionEvent(
MotionEvent.ACTION_MOVE,
y = touchSlop + 1f,
)
)
)
.isFalse()
assertThat(underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_CANCEL))).isFalse()
}
@Test
fun tap_doesNotSendProxiedInput() =
testScope.runTest {
@@ -233,7 +312,7 @@ class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
}
@Test
fun dragAboveTouchSlopAndUp() =
fun dragShadeAboveTouchSlopAndUp() =
testScope.runTest {
val leftShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.LEFT))
val rightShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.RIGHT))
@@ -277,7 +356,7 @@ class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
}
@Test
fun dragAboveTouchSlopAndCancel() =
fun dragShadeAboveTouchSlopAndCancel() =
testScope.runTest {
val leftShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.LEFT))
val rightShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.RIGHT))
@@ -320,6 +399,87 @@ class MultiShadeMotionEventInteractorTest : SysuiTestCase() {
assertThat(singleShadeProxiedInput).isNull()
}
@Test
fun dragBouncerAboveTouchSlopAndUp_showsBouncer() =
testScope.runTest {
val leftShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.LEFT))
val rightShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.RIGHT))
val singleShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.SINGLE))
underTest.shouldIntercept(
motionEvent(
MotionEvent.ACTION_DOWN,
x = 100f, // left shade
)
)
assertThat(leftShadeProxiedInput).isNull()
assertThat(rightShadeProxiedInput).isNull()
assertThat(singleShadeProxiedInput).isNull()
val yDragAmountPx = -(touchSlop + 1f) // dragging up
val moveEvent =
motionEvent(
MotionEvent.ACTION_MOVE,
x = 100f, // left shade
y = yDragAmountPx,
)
assertThat(underTest.shouldIntercept(moveEvent)).isTrue()
underTest.onTouchEvent(moveEvent, viewWidthPx = 1000)
verify(bouncerInteractor).show(isScrimmed = true)
assertThat(leftShadeProxiedInput).isNull()
assertThat(rightShadeProxiedInput).isNull()
assertThat(singleShadeProxiedInput).isNull()
val upEvent = motionEvent(MotionEvent.ACTION_UP)
assertThat(underTest.shouldIntercept(upEvent)).isTrue()
underTest.onTouchEvent(upEvent, viewWidthPx = 1000)
verify(bouncerInteractor, never()).hide()
assertThat(leftShadeProxiedInput).isNull()
assertThat(rightShadeProxiedInput).isNull()
assertThat(singleShadeProxiedInput).isNull()
}
@Test
fun dragBouncerAboveTouchSlopAndCancel_falseTouch_showsThenHidesBouncer() =
testScope.runTest {
val leftShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.LEFT))
val rightShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.RIGHT))
val singleShadeProxiedInput by collectLastValue(interactor.proxiedInput(ShadeId.SINGLE))
underTest.shouldIntercept(
motionEvent(
MotionEvent.ACTION_DOWN,
x = 900f, // right shade
)
)
assertThat(leftShadeProxiedInput).isNull()
assertThat(rightShadeProxiedInput).isNull()
assertThat(singleShadeProxiedInput).isNull()
val yDragAmountPx = -(touchSlop + 1f) // drag up
val moveEvent =
motionEvent(
MotionEvent.ACTION_MOVE,
x = 900f, // right shade
y = yDragAmountPx,
)
assertThat(underTest.shouldIntercept(moveEvent)).isTrue()
underTest.onTouchEvent(moveEvent, viewWidthPx = 1000)
verify(bouncerInteractor).show(isScrimmed = true)
assertThat(leftShadeProxiedInput).isNull()
assertThat(rightShadeProxiedInput).isNull()
assertThat(singleShadeProxiedInput).isNull()
falsingManager.setIsFalseTouch(true)
val cancelEvent = motionEvent(MotionEvent.ACTION_CANCEL)
assertThat(underTest.shouldIntercept(cancelEvent)).isTrue()
underTest.onTouchEvent(cancelEvent, viewWidthPx = 1000)
verify(bouncerInteractor).hide()
assertThat(leftShadeProxiedInput).isNull()
assertThat(rightShadeProxiedInput).isNull()
assertThat(singleShadeProxiedInput).isNull()
}
private fun TestScope.motionEvent(
action: Int,
downTime: Long = currentTime,

View File

@@ -27,10 +27,12 @@ import com.android.keyguard.dagger.KeyguardBouncerComponent
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.classifier.FalsingCollectorFake
import com.android.systemui.classifier.FalsingManagerFake
import com.android.systemui.dock.DockManager
import com.android.systemui.flags.FakeFeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.keyguard.KeyguardUnlockAnimationController
import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.keyguard.ui.viewmodel.KeyguardBouncerViewModel
@@ -67,8 +69,8 @@ import org.mockito.Mockito.anyFloat
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@@ -170,7 +172,13 @@ class NotificationShadeWindowViewControllerTest : SysuiTestCase() {
MultiShadeMotionEventInteractor(
applicationContext = context,
applicationScope = testScope.backgroundScope,
interactor = multiShadeInteractor,
multiShadeInteractor = multiShadeInteractor,
keyguardTransitionInteractor =
KeyguardTransitionInteractor(
repository = FakeKeyguardTransitionRepository(),
),
bouncerInteractor = com.android.systemui.util.mockito.mock(),
falsingManager = FalsingManagerFake(),
)
},
)

View File

@@ -27,10 +27,12 @@ import com.android.keyguard.dagger.KeyguardBouncerComponent
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.classifier.FalsingCollectorFake
import com.android.systemui.classifier.FalsingManagerFake
import com.android.systemui.dock.DockManager
import com.android.systemui.flags.FakeFeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.keyguard.KeyguardUnlockAnimationController
import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.ui.viewmodel.KeyguardBouncerViewModel
import com.android.systemui.keyguard.ui.viewmodel.PrimaryBouncerToGoneTransitionViewModel
@@ -182,7 +184,13 @@ class NotificationShadeWindowViewTest : SysuiTestCase() {
MultiShadeMotionEventInteractor(
applicationContext = context,
applicationScope = testScope.backgroundScope,
interactor = multiShadeInteractor,
multiShadeInteractor = multiShadeInteractor,
keyguardTransitionInteractor =
KeyguardTransitionInteractor(
repository = FakeKeyguardTransitionRepository(),
),
bouncerInteractor = mock(),
falsingManager = FalsingManagerFake(),
)
},
)