diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt index b9e38cf3cc60e..99fe26ce1f3bb 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt @@ -53,6 +53,7 @@ fun MultiShade( modifier: Modifier = Modifier, ) { val isScrimEnabled: Boolean by viewModel.isScrimEnabled.collectAsState() + val scrimAlpha: Float by viewModel.scrimAlpha.collectAsState() // TODO(b/273298030): find a different way to get the height constraint from its parent. BoxWithConstraints(modifier = modifier) { @@ -61,7 +62,7 @@ fun MultiShade( Scrim( modifier = Modifier.fillMaxSize(), remoteTouch = viewModel::onScrimTouched, - alpha = { viewModel.scrimAlpha.value }, + alpha = { scrimAlpha }, isScrimEnabled = isScrimEnabled, ) Shade( diff --git a/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt index b9f6d83d8406f..ebb8639b8922a 100644 --- a/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt @@ -23,6 +23,7 @@ import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.multishade.data.model.MultiShadeInteractionModel import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy import com.android.systemui.multishade.data.repository.MultiShadeRepository +import com.android.systemui.multishade.shared.math.isZero import com.android.systemui.multishade.shared.model.ProxiedInputModel import com.android.systemui.multishade.shared.model.ShadeConfig import com.android.systemui.multishade.shared.model.ShadeId @@ -63,6 +64,10 @@ constructor( } } + /** Whether any shade is expanded, even a little bit. */ + val isAnyShadeExpanded: Flow = + maxShadeExpansion.map { maxExpansion -> !maxExpansion.isZero() }.distinctUntilChanged() + /** * A _processed_ version of the proxied input flow. * diff --git a/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeMotionEventInteractor.kt b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeMotionEventInteractor.kt new file mode 100644 index 0000000000000..ff7c9015eef45 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeMotionEventInteractor.kt @@ -0,0 +1,191 @@ +/* + * 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.multishade.domain.interactor + +import android.content.Context +import android.view.MotionEvent +import android.view.ViewConfiguration +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.multishade.shared.model.ProxiedInputModel +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.stateIn + +/** + * Encapsulates business logic to handle [MotionEvent]-based user input. + * + * This class is meant purely for the legacy `View`-based system to be able to pass `MotionEvent`s + * into the newer multi-shade framework for processing. + */ +class MultiShadeMotionEventInteractor +@Inject +constructor( + @Application private val applicationContext: Context, + @Application private val applicationScope: CoroutineScope, + private val interactor: MultiShadeInteractor, +) { + + private val isAnyShadeExpanded: StateFlow = + interactor.isAnyShadeExpanded.stateIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + + private var interactionState: InteractionState? = null + + /** + * Returns `true` if the given [MotionEvent] and the rest of events in this gesture should be + * passed to this interactor's [onTouchEvent] method. + * + * Note: the caller should continue to pass [MotionEvent] instances into this method, even if it + * returns `false` as the gesture may be intercepted mid-stream. + */ + fun shouldIntercept(event: MotionEvent): Boolean { + if (isAnyShadeExpanded.value) { + // If any shade is expanded, we assume that touch handling outside the shades is handled + // by the scrim that appears behind the shades. No need to intercept anything here. + return false + } + + return when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + // Record where the pointer was placed and which pointer it was. + interactionState = + InteractionState( + initialX = event.x, + initialY = event.y, + currentY = event.y, + pointerId = event.getPointerId(0), + isDraggingHorizontally = false, + isDraggingVertically = false, + ) + + false + } + MotionEvent.ACTION_MOVE -> { + interactionState?.let { + 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) + val touchSlop = ViewConfiguration.get(applicationContext).scaledTouchSlop + interactionState = + when { + yDistanceTravelled > touchSlop -> + it.copy(isDraggingVertically = true) + xDistanceTravelled > touchSlop -> + it.copy(isDraggingHorizontally = true) + else -> interactionState + } + } + } + + // We want to intercept the rest of the gesture if we're dragging. + interactionState.isDraggingVertically() + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> + // Make sure that we intercept the up or cancel if we're dragging, to handle drag + // end and cancel. + interactionState.isDraggingVertically() + else -> false + } + } + + /** + * Notifies that a [MotionEvent] in a series of events of a gesture that was intercepted due to + * the result of [shouldIntercept] has been received. + * + * @param event The [MotionEvent] to handle. + * @param viewWidthPx The width of the view, in pixels. + * @return `true` if the event was consumed, `false` otherwise. + */ + fun onTouchEvent(event: MotionEvent, viewWidthPx: Int): Boolean { + return when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + interactionState?.let { + if (it.isDraggingVertically) { + val pointerIndex = event.findPointerIndex(it.pointerId) + val previousY = it.currentY + val currentY = event.getY(pointerIndex) + interactionState = + it.copy( + currentY = currentY, + ) + + val yDragAmountPx = currentY - previousY + if (yDragAmountPx != 0f) { + interactor.sendProxiedInput( + ProxiedInputModel.OnDrag( + xFraction = event.x / viewWidthPx, + yDragAmountPx = yDragAmountPx, + ) + ) + } + } + } + + true + } + MotionEvent.ACTION_UP -> { + if (interactionState.isDraggingVertically()) { + // 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) + } + + interactionState = null + true + } + MotionEvent.ACTION_CANCEL -> { + if (interactionState.isDraggingVertically()) { + // 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 + // traveled beyond the bounds of our view and/or the touch display. Either way, + // 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) + } + + interactionState = null + true + } + else -> false + } + } + + private data class InteractionState( + val initialX: Float, + val initialY: Float, + val currentY: Float, + val pointerId: Int, + val isDraggingHorizontally: Boolean, + val isDraggingVertically: Boolean, + ) + + private fun InteractionState?.isDraggingVertically(): Boolean { + return this?.isDraggingVertically == true + } +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/math/Math.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/math/Math.kt new file mode 100644 index 0000000000000..c2eaf72a841a2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/math/Math.kt @@ -0,0 +1,27 @@ +/* + * 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.multishade.shared.math + +import androidx.annotation.VisibleForTesting +import kotlin.math.abs + +/** Returns `true` if this [Float] is within [epsilon] of `0`. */ +fun Float.isZero(epsilon: Float = EPSILON): Boolean { + return abs(this) < epsilon +} + +@VisibleForTesting private const val EPSILON = 0.0001f diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt index ce6ab977dea26..ed92c5469d236 100644 --- a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt @@ -26,7 +26,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -87,10 +86,7 @@ class MultiShadeViewModel( when (shadeConfig) { // In the dual shade configuration, the scrim is enabled when the expansion is // greater than zero on any one of the shades. - is ShadeConfig.DualShadeConfig -> - interactor.maxShadeExpansion - .map { expansion -> expansion > 0 } - .distinctUntilChanged() + is ShadeConfig.DualShadeConfig -> interactor.isAnyShadeExpanded // No scrim in the single shade configuration. is ShadeConfig.SingleShadeConfig -> flowOf(false) } diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java index 5f6f158277d72..0318fa570a787 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java +++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowViewController.java @@ -32,6 +32,8 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; import com.android.keyguard.AuthKeyguardMessageArea; import com.android.keyguard.LockIconViewController; @@ -50,6 +52,7 @@ import com.android.systemui.keyguard.ui.binder.KeyguardBouncerViewBinder; import com.android.systemui.keyguard.ui.viewmodel.KeyguardBouncerViewModel; import com.android.systemui.keyguard.ui.viewmodel.PrimaryBouncerToGoneTransitionViewModel; import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor; +import com.android.systemui.multishade.domain.interactor.MultiShadeMotionEventInteractor; import com.android.systemui.multishade.ui.view.MultiShadeView; import com.android.systemui.statusbar.DragDownHelper; import com.android.systemui.statusbar.LockscreenShadeTransitionController; @@ -118,6 +121,7 @@ public class NotificationShadeWindowViewController { step.getTransitionState() == TransitionState.RUNNING; }; private final SystemClock mClock; + private final @Nullable MultiShadeMotionEventInteractor mMultiShadeMotionEventInteractor; @Inject public NotificationShadeWindowViewController( @@ -145,7 +149,8 @@ public class NotificationShadeWindowViewController { PrimaryBouncerToGoneTransitionViewModel primaryBouncerToGoneTransitionViewModel, FeatureFlags featureFlags, Provider multiShadeInteractorProvider, - SystemClock clock) { + SystemClock clock, + Provider multiShadeMotionEventInteractorProvider) { mLockscreenShadeTransitionController = transitionController; mFalsingCollector = falsingCollector; mStatusBarStateController = statusBarStateController; @@ -180,21 +185,17 @@ public class NotificationShadeWindowViewController { mClock = clock; if (ComposeFacade.INSTANCE.isComposeAvailable() && featureFlags.isEnabled(Flags.DUAL_SHADE)) { + mMultiShadeMotionEventInteractor = multiShadeMotionEventInteractorProvider.get(); final ViewStub multiShadeViewStub = mView.findViewById(R.id.multi_shade_stub); if (multiShadeViewStub != null) { final MultiShadeView multiShadeView = (MultiShadeView) multiShadeViewStub.inflate(); multiShadeView.init(multiShadeInteractorProvider.get(), clock); } + } else { + mMultiShadeMotionEventInteractor = null; } } - /** - * @return Location where to place the KeyguardBouncer - */ - public ViewGroup getBouncerContainer() { - return mView.findViewById(R.id.keyguard_bouncer_container); - } - /** * @return Location where to place the KeyguardMessageArea */ @@ -349,16 +350,17 @@ public class NotificationShadeWindowViewController { return true; } - boolean intercept = false; - if (mNotificationPanelViewController.isFullyExpanded() + if (mMultiShadeMotionEventInteractor != null) { + // This interactor is not null only if the dual shade feature is enabled. + return mMultiShadeMotionEventInteractor.shouldIntercept(ev); + } else if (mNotificationPanelViewController.isFullyExpanded() && mDragDownHelper.isDragDownEnabled() && !mService.isBouncerShowing() && !mStatusBarStateController.isDozing()) { - intercept = mDragDownHelper.onInterceptTouchEvent(ev); + return mDragDownHelper.onInterceptTouchEvent(ev); + } else { + return false; } - - return intercept; - } @Override @@ -381,13 +383,20 @@ public class NotificationShadeWindowViewController { return true; } - if ((mDragDownHelper.isDragDownEnabled() && !handled) - || mDragDownHelper.isDraggingDown()) { - // we still want to finish our drag down gesture when locking the screen - handled = mDragDownHelper.onTouchEvent(ev); + if (handled) { + return true; } - return handled; + if (mMultiShadeMotionEventInteractor != null) { + // This interactor is not null only if the dual shade feature is enabled. + return mMultiShadeMotionEventInteractor.onTouchEvent(ev, mView.getWidth()); + } else if (mDragDownHelper.isDragDownEnabled() + || mDragDownHelper.isDraggingDown()) { + // we still want to finish our drag down gesture when locking the screen + return mDragDownHelper.onTouchEvent(ev); + } else { + return false; + } } @Override diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java index 55fa47951fe1a..7f8c1351aa7a5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfaces.java @@ -217,8 +217,6 @@ public interface CentralSurfaces extends Dumpable, ActivityStarter, LifecycleOwn NotificationPanelViewController getNotificationPanelViewController(); - ViewGroup getBouncerContainer(); - /** Get the Keyguard Message Area that displays auth messages. */ AuthKeyguardMessageArea getKeyguardMessageArea(); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java index 5e3c1c59666d8..2f404873dc7a4 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java @@ -1721,11 +1721,6 @@ public class CentralSurfacesImpl implements CoreStartable, CentralSurfaces { return mNotificationPanelViewController; } - @Override - public ViewGroup getBouncerContainer() { - return mNotificationShadeWindowViewController.getBouncerContainer(); - } - @Override public AuthKeyguardMessageArea getKeyguardMessageArea() { return mNotificationShadeWindowViewController.getKeyguardMessageArea(); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java index 06d0758c90eb4..f06b5db845885 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java @@ -381,7 +381,6 @@ public class StatusBarKeyguardViewManager implements RemoteInputController.Callb mCentralSurfaces = centralSurfaces; mBiometricUnlockController = biometricUnlockController; - ViewGroup container = mCentralSurfaces.getBouncerContainer(); mPrimaryBouncerCallbackInteractor.addBouncerExpansionCallback(mExpansionCallback); mNotificationPanelViewController = notificationPanelViewController; if (shadeExpansionStateManager != null) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt index 415e68f6013df..bcc99bc8dd0ca 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt @@ -72,6 +72,28 @@ class MultiShadeInteractorTest : SysuiTestCase() { assertThat(maxShadeExpansion).isEqualTo(0f) } + @Test + fun isAnyShadeExpanded() = + testScope.runTest { + val underTest = create() + val isAnyShadeExpanded: Boolean? by collectLastValue(underTest.isAnyShadeExpanded) + assertWithMessage("isAnyShadeExpanded must start with false!") + .that(isAnyShadeExpanded) + .isFalse() + + underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0.441f) + assertThat(isAnyShadeExpanded).isTrue() + + underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0.442f) + assertThat(isAnyShadeExpanded).isTrue() + + underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0f) + assertThat(isAnyShadeExpanded).isTrue() + + underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0f) + assertThat(isAnyShadeExpanded).isFalse() + } + @Test fun isVisible_dualShadeConfig() = testScope.runTest { diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeMotionEventInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeMotionEventInteractorTest.kt new file mode 100644 index 0000000000000..f807146cdf121 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeMotionEventInteractorTest.kt @@ -0,0 +1,334 @@ +/* + * 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.multishade.domain.interactor + +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.data.repository.MultiShadeRepository +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeId +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class MultiShadeMotionEventInteractorTest : SysuiTestCase() { + + private lateinit var underTest: MultiShadeMotionEventInteractor + + private lateinit var testScope: TestScope + private lateinit var motionEvents: MutableSet + private lateinit var repository: MultiShadeRepository + private lateinit var interactor: MultiShadeInteractor + private val touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop + + @Before + fun setUp() { + testScope = TestScope() + motionEvents = mutableSetOf() + + val inputProxy = MultiShadeInputProxy() + repository = + MultiShadeRepository( + applicationContext = context, + inputProxy = inputProxy, + ) + interactor = + MultiShadeInteractor( + applicationScope = testScope.backgroundScope, + repository = repository, + inputProxy = inputProxy, + ) + underTest = + MultiShadeMotionEventInteractor( + applicationContext = context, + applicationScope = testScope.backgroundScope, + interactor = interactor, + ) + } + + @After + fun tearDown() { + motionEvents.forEach { motionEvent -> motionEvent.recycle() } + } + + @Test + fun shouldIntercept_initialDown_returnsFalse() = + testScope.runTest { + assertThat(underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN))).isFalse() + } + + @Test + fun shouldIntercept_moveBelowTouchSlop_returnsFalse() = + testScope.runTest { + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN)) + + assertThat( + underTest.shouldIntercept( + motionEvent( + MotionEvent.ACTION_MOVE, + y = touchSlop - 1f, + ) + ) + ) + .isFalse() + } + + @Test + fun shouldIntercept_moveAboveTouchSlop_returnsTrue() = + testScope.runTest { + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN)) + + assertThat( + underTest.shouldIntercept( + motionEvent( + MotionEvent.ACTION_MOVE, + y = touchSlop + 1f, + ) + ) + ) + .isTrue() + } + + @Test + fun shouldIntercept_moveAboveTouchSlop_butHorizontalFirst_returnsFalse() = + testScope.runTest { + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN)) + + assertThat( + underTest.shouldIntercept( + motionEvent( + MotionEvent.ACTION_MOVE, + x = touchSlop + 1f, + ) + ) + ) + .isFalse() + assertThat( + underTest.shouldIntercept( + motionEvent( + MotionEvent.ACTION_MOVE, + y = touchSlop + 1f, + ) + ) + ) + .isFalse() + } + + @Test + fun shouldIntercept_up_afterMovedAboveTouchSlop_returnsTrue() = + testScope.runTest { + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN)) + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_MOVE, y = touchSlop + 1f)) + + assertThat(underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_UP))).isTrue() + } + + @Test + fun shouldIntercept_cancel_afterMovedAboveTouchSlop_returnsTrue() = + testScope.runTest { + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_DOWN)) + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_MOVE, y = touchSlop + 1f)) + + assertThat(underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_CANCEL))).isTrue() + } + + @Test + fun shouldIntercept_moveAboveTouchSlopAndUp_butShadeExpanded_returnsFalse() = + testScope.runTest { + repository.setExpansion(ShadeId.LEFT, 0.1f) + 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_butShadeExpanded_returnsFalse() = + testScope.runTest { + repository.setExpansion(ShadeId.LEFT, 0.1f) + 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 { + 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)) + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_UP)) + + assertThat(leftShadeProxiedInput).isNull() + assertThat(rightShadeProxiedInput).isNull() + assertThat(singleShadeProxiedInput).isNull() + } + + @Test + fun dragBelowTouchSlop_doesNotSendProxiedInput() = + 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)) + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_MOVE, y = touchSlop - 1f)) + underTest.shouldIntercept(motionEvent(MotionEvent.ACTION_UP)) + + assertThat(leftShadeProxiedInput).isNull() + assertThat(rightShadeProxiedInput).isNull() + assertThat(singleShadeProxiedInput).isNull() + } + + @Test + fun dragAboveTouchSlopAndUp() = + 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 + val moveEvent = + motionEvent( + MotionEvent.ACTION_MOVE, + x = 100f, // left shade + y = yDragAmountPx, + ) + assertThat(underTest.shouldIntercept(moveEvent)).isTrue() + underTest.onTouchEvent(moveEvent, viewWidthPx = 1000) + assertThat(leftShadeProxiedInput) + .isEqualTo( + ProxiedInputModel.OnDrag( + xFraction = 0.1f, + yDragAmountPx = yDragAmountPx, + ) + ) + assertThat(rightShadeProxiedInput).isNull() + assertThat(singleShadeProxiedInput).isNull() + + val upEvent = motionEvent(MotionEvent.ACTION_UP) + assertThat(underTest.shouldIntercept(upEvent)).isTrue() + underTest.onTouchEvent(upEvent, viewWidthPx = 1000) + assertThat(leftShadeProxiedInput).isNull() + assertThat(rightShadeProxiedInput).isNull() + assertThat(singleShadeProxiedInput).isNull() + } + + @Test + fun dragAboveTouchSlopAndCancel() = + 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 + val moveEvent = + motionEvent( + MotionEvent.ACTION_MOVE, + x = 900f, // right shade + y = yDragAmountPx, + ) + assertThat(underTest.shouldIntercept(moveEvent)).isTrue() + underTest.onTouchEvent(moveEvent, viewWidthPx = 1000) + assertThat(leftShadeProxiedInput).isNull() + assertThat(rightShadeProxiedInput) + .isEqualTo( + ProxiedInputModel.OnDrag( + xFraction = 0.9f, + yDragAmountPx = yDragAmountPx, + ) + ) + assertThat(singleShadeProxiedInput).isNull() + + val cancelEvent = motionEvent(MotionEvent.ACTION_CANCEL) + assertThat(underTest.shouldIntercept(cancelEvent)).isTrue() + underTest.onTouchEvent(cancelEvent, viewWidthPx = 1000) + assertThat(leftShadeProxiedInput).isNull() + assertThat(rightShadeProxiedInput).isNull() + assertThat(singleShadeProxiedInput).isNull() + } + + private fun TestScope.motionEvent( + action: Int, + downTime: Long = currentTime, + eventTime: Long = currentTime, + x: Float = 0f, + y: Float = 0f, + ): MotionEvent { + val motionEvent = MotionEvent.obtain(downTime, eventTime, action, x, y, 0) + motionEvents.add(motionEvent) + return motionEvent + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/shared/math/MathTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/shared/math/MathTest.kt new file mode 100644 index 0000000000000..8935309829267 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/shared/math/MathTest.kt @@ -0,0 +1,68 @@ +/* + * 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.multishade.shared.math + +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@SmallTest +@RunWith(JUnit4::class) +class MathTest : SysuiTestCase() { + + @Test + fun isZero_zero_true() { + assertThat(0f.isZero(epsilon = EPSILON)).isTrue() + } + + @Test + fun isZero_belowPositiveEpsilon_true() { + assertThat((EPSILON * 0.999999f).isZero(epsilon = EPSILON)).isTrue() + } + + @Test + fun isZero_aboveNegativeEpsilon_true() { + assertThat((EPSILON * -0.999999f).isZero(epsilon = EPSILON)).isTrue() + } + + @Test + fun isZero_positiveEpsilon_false() { + assertThat(EPSILON.isZero(epsilon = EPSILON)).isFalse() + } + + @Test + fun isZero_negativeEpsilon_false() { + assertThat((-EPSILON).isZero(epsilon = EPSILON)).isFalse() + } + + @Test + fun isZero_positive_false() { + assertThat(1f.isZero(epsilon = EPSILON)).isFalse() + } + + @Test + fun isZero_negative_false() { + assertThat((-1f).isZero(epsilon = EPSILON)).isFalse() + } + + companion object { + private const val EPSILON = 0.0001f + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt index 629208e130afb..5f34b2f0f87ff 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewControllerTest.kt @@ -38,6 +38,7 @@ import com.android.systemui.keyguard.ui.viewmodel.PrimaryBouncerToGoneTransition import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy import com.android.systemui.multishade.data.repository.MultiShadeRepository import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.domain.interactor.MultiShadeMotionEventInteractor import com.android.systemui.shade.NotificationShadeWindowView.InteractionEventHandler import com.android.systemui.statusbar.LockscreenShadeTransitionController import com.android.systemui.statusbar.NotificationInsetsController @@ -62,7 +63,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Mock -import org.mockito.Mockito import org.mockito.Mockito.anyFloat import org.mockito.Mockito.mock import org.mockito.Mockito.never @@ -129,6 +129,16 @@ class NotificationShadeWindowViewControllerTest : SysuiTestCase() { val inputProxy = MultiShadeInputProxy() testScope = TestScope() + val multiShadeInteractor = + MultiShadeInteractor( + applicationScope = testScope.backgroundScope, + repository = + MultiShadeRepository( + applicationContext = context, + inputProxy = inputProxy, + ), + inputProxy = inputProxy, + ) underTest = NotificationShadeWindowViewController( lockscreenShadeTransitionController, @@ -154,18 +164,15 @@ class NotificationShadeWindowViewControllerTest : SysuiTestCase() { keyguardTransitionInteractor, primaryBouncerToGoneTransitionViewModel, featureFlags, + { multiShadeInteractor }, + FakeSystemClock(), { - MultiShadeInteractor( + MultiShadeMotionEventInteractor( + applicationContext = context, applicationScope = testScope.backgroundScope, - repository = - MultiShadeRepository( - applicationContext = context, - inputProxy = inputProxy, - ), - inputProxy = inputProxy, + interactor = multiShadeInteractor, ) }, - FakeSystemClock(), ) underTest.setupExpandedStatusBar() @@ -308,21 +315,13 @@ class NotificationShadeWindowViewControllerTest : SysuiTestCase() { fun shouldInterceptTouchEvent_statusBarKeyguardViewManagerShouldIntercept() { // down event should be intercepted by keyguardViewManager whenever(statusBarKeyguardViewManager.shouldInterceptTouchEvent(DOWN_EVENT)) - .thenReturn(true) + .thenReturn(true) // Then touch should not be intercepted val shouldIntercept = interactionEventHandler.shouldInterceptTouchEvent(DOWN_EVENT) assertThat(shouldIntercept).isTrue() } - @Test - fun testGetBouncerContainer() = - testScope.runTest { - Mockito.clearInvocations(view) - underTest.bouncerContainer - verify(view).findViewById(R.id.keyguard_bouncer_container) - } - @Test fun testGetKeyguardMessageArea() = testScope.runTest { diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewTest.kt index b4b5ec1262346..b40181e24e6d1 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationShadeWindowViewTest.kt @@ -37,6 +37,7 @@ import com.android.systemui.keyguard.ui.viewmodel.PrimaryBouncerToGoneTransition import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy import com.android.systemui.multishade.data.repository.MultiShadeRepository import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.domain.interactor.MultiShadeMotionEventInteractor import com.android.systemui.shade.NotificationShadeWindowView.InteractionEventHandler import com.android.systemui.statusbar.DragDownHelper import com.android.systemui.statusbar.LockscreenShadeTransitionController @@ -140,6 +141,16 @@ class NotificationShadeWindowViewTest : SysuiTestCase() { featureFlags.set(Flags.DUAL_SHADE, false) val inputProxy = MultiShadeInputProxy() testScope = TestScope() + val multiShadeInteractor = + MultiShadeInteractor( + applicationScope = testScope.backgroundScope, + repository = + MultiShadeRepository( + applicationContext = context, + inputProxy = inputProxy, + ), + inputProxy = inputProxy, + ) controller = NotificationShadeWindowViewController( lockscreenShadeTransitionController, @@ -165,18 +176,15 @@ class NotificationShadeWindowViewTest : SysuiTestCase() { keyguardTransitionInteractor, primaryBouncerToGoneTransitionViewModel, featureFlags, + { multiShadeInteractor }, + FakeSystemClock(), { - MultiShadeInteractor( + MultiShadeMotionEventInteractor( + applicationContext = context, applicationScope = testScope.backgroundScope, - repository = - MultiShadeRepository( - applicationContext = context, - inputProxy = inputProxy, - ), - inputProxy = inputProxy, + interactor = multiShadeInteractor, ) }, - FakeSystemClock(), ) controller.setupExpandedStatusBar() diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManagerTest.java index d9546877a861d..14aee4e13a8f1 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManagerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManagerTest.java @@ -154,7 +154,6 @@ public class StatusBarKeyguardViewManagerTest extends SysuiTestCase { @Before public void setUp() { MockitoAnnotations.initMocks(this); - when(mCentralSurfaces.getBouncerContainer()).thenReturn(mContainer); when(mContainer.findViewById(anyInt())).thenReturn(mKeyguardMessageArea); when(mKeyguardMessageAreaFactory.create(any(KeyguardMessageArea.class))) .thenReturn(mKeyguardMessageAreaController);