From 60bb39418429dc957282fe74950c954ea4332a08 Mon Sep 17 00:00:00 2001 From: Georgy Steshin Date: Thu, 4 Jan 2024 16:56:37 +0200 Subject: [PATCH] #2787 $4312 Fixing scroll interfere with system gesture navigation on android 33-34 --- .../detox/espresso/scroll/ScrollHelper.java | 120 ++++++++++++------ .../detox/espresso/scroll/ScrollHelperTest.kt | 73 ++++++----- 2 files changed, 127 insertions(+), 66 deletions(-) diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java index 73973e4a79..e09a12154a 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java @@ -12,6 +12,7 @@ import com.wix.detox.action.common.MotionDir; import com.wix.detox.espresso.DeviceDisplay; +import androidx.annotation.VisibleForTesting; import androidx.test.espresso.UiController; import androidx.test.platform.app.InstrumentationRegistry; @@ -43,8 +44,8 @@ private ScrollHelper() { /** * Scrolls the View in a direction by the Density Independent Pixel amount. * - * @param direction Direction to scroll (see {@link MotionDir}) - * @param amountInDP Density Independent Pixels + * @param direction Direction to scroll (see {@link MotionDir}) + * @param amountInDP Density Independent Pixels * @param startOffsetPercentX Percentage denoting where X-swipe should start, with respect to the scrollable view. Null means select automatically. * @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. Null means select automatically. */ @@ -54,7 +55,7 @@ public static void perform(UiController uiController, View view, @MotionDir int final int times = amountInPx / safeScrollableRangePx; final int remainder = amountInPx % safeScrollableRangePx; - Log.d(LOG_TAG, "prescroll amountDP="+amountInDP + " amountPx="+amountInPx + " scrollableRangePx="+safeScrollableRangePx + " times="+times + " remainder="+remainder); + Log.d(LOG_TAG, "prescroll amountDP=" + amountInDP + " amountPx=" + amountInPx + " scrollableRangePx=" + safeScrollableRangePx + " times=" + times + " remainder=" + remainder); for (int i = 0; i < times; ++i) { scrollOnce(uiController, view, direction, safeScrollableRangePx, startOffsetPercentX, startOffsetPercentY); @@ -116,25 +117,32 @@ private static void waitForFlingToFinish(View view, UiController uiController) { } } - private static int getViewSafeScrollableRangePix(View view, @MotionDir int direction) { + @VisibleForTesting + public static int getViewSafeScrollableRangePix(View view, @MotionDir int direction) { final float[] screenSize = DeviceDisplay.getScreenSizeInPX(); final int[] pos = new int[2]; view.getLocationInWindow(pos); int range; switch (direction) { - case MOTION_DIR_LEFT: range = (int) ((screenSize[0] - pos[0]) * SCROLL_RANGE_SAFE_PERCENT); break; - case MOTION_DIR_RIGHT: range = (int) ((pos[0] + view.getWidth()) * SCROLL_RANGE_SAFE_PERCENT); break; - case MOTION_DIR_UP: range = (int) ((screenSize[1] - pos[1]) * SCROLL_RANGE_SAFE_PERCENT); break; - default: range = (int) ((pos[1] + view.getHeight()) * SCROLL_RANGE_SAFE_PERCENT); break; + case MOTION_DIR_LEFT: + range = (int) ((screenSize[0] - pos[0]) * SCROLL_RANGE_SAFE_PERCENT); + break; + case MOTION_DIR_RIGHT: + range = (int) ((pos[0] + view.getWidth()) * SCROLL_RANGE_SAFE_PERCENT); + break; + case MOTION_DIR_UP: + range = (int) ((screenSize[1] - pos[1]) * SCROLL_RANGE_SAFE_PERCENT); + break; + default: + range = (int) ((pos[1] + view.getHeight()) * SCROLL_RANGE_SAFE_PERCENT); + break; } return range; } - private static Point getScrollStartPoint(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) { + private static int[] getScrollStartCoordinatesInView(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) { final int safetyOffset = DeviceDisplay.convertDpiToPx(1); - - Point point = getGlobalViewLocation(view); float offsetFactorX; float offsetFactorY; int safetyOffsetX; @@ -172,52 +180,87 @@ private static Point getScrollStartPoint(View view, @MotionDir int direction, Fl int offsetX = ((int) (view.getWidth() * offsetFactorX) + safetyOffsetX); int offsetY = ((int) (view.getHeight() * offsetFactorY) + safetyOffsetY); + return new int[]{offsetX, offsetY}; + } + + /** + * Calculates the scroll start point, with respect to the global screen coordinates and gesture insets. + * @param view The view to scroll. + * @param direction The scroll direction. + * @param startOffsetPercentX The scroll start offset, as a percentage of the view's width. Null means select automatically. + * @param startOffsetPercentY The scroll start offset, as a percentage of the view's height. Null means select automatically. + * @return a Point object, denoting the scroll start point. + */ + private static Point getScrollStartPoint(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) { + Point result = getGlobalViewLocation(view); + + // 1. Calculate the scroll start point, with respect to the view's location. + int[] coordinates = getScrollStartCoordinatesInView(view, direction, startOffsetPercentX, startOffsetPercentY); + + // 2. Make sure that the start point is within the scrollable area, taking into account the system gesture insets. + coordinates = calculateScrollStartWithNavigationGestureInsets(view, direction, coordinates[0], coordinates[1]); + + result.offset(coordinates[0], coordinates[1]); + return result; + } + + /** + * Calculates the scroll start point, with respect to the system gesture insets. + * @param view + * @param direction The scroll direction. + * @param x The scroll start point, with respect to the view's location. + * @param y The scroll start point, with respect to the view's location. + * @return an array of two integers, denoting the scroll start point, with respect to the system gesture insets. + */ + private static int[] calculateScrollStartWithNavigationGestureInsets(View view, int direction, int x, int y) { + // System gesture insets are only available on Android Q (29) and above. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return new int[]{x, y}; + } + final float[] displaySize = DeviceDisplay.getScreenSizeInPX(); // Calculate the min/max scrollable area, taking into account the system gesture insets. - // By default we assume the scrollable area is the entire view. - int gestureSafeOffset = DeviceDisplay.convertDpiToPx(1) * 3; + // By default we assume the scrollable area is the entire screen. + // 2dp is a safety offset to make sure we don't hit the system gesture area. + int gestureSafeOffset = DeviceDisplay.convertDpiToPx(2); int minX = gestureSafeOffset; int minY = gestureSafeOffset; float maxX = displaySize[0] - gestureSafeOffset; float maxY = displaySize[1] - gestureSafeOffset; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // System gesture insets are only available on Android Q (29) and above. - WindowInsets rootWindowInsets = view.getRootWindowInsets(); - if (rootWindowInsets == null) { - Log.w(LOG_TAG, "Could not get root window insets. Using default calculation."); - } else { - Insets gestureInsets = rootWindowInsets.getSystemGestureInsets(); - minX = gestureInsets.left; - minY = gestureInsets.top; - maxX -= gestureInsets.right; - maxY -= gestureInsets.bottom; - - Log.d(LOG_TAG, - "System gesture insets: " + - gestureInsets + " minX=" + - minX + " minY=" + minY + " maxX=" + maxX + " maxY=" + maxY + " offsetX=" + offsetX + " offsetY=" + offsetY); - } + // Try to get the root window insets, and if available, use them to calculate the scrollable area. + WindowInsets rootWindowInsets = view.getRootWindowInsets(); + if (rootWindowInsets == null) { + Log.w(LOG_TAG, "Could not get root window insets"); + } else { + Insets gestureInsets = rootWindowInsets.getSystemGestureInsets(); + minX = gestureInsets.left; + minY = gestureInsets.top; + maxX -= gestureInsets.right; + maxY -= gestureInsets.bottom; + + Log.d(LOG_TAG, + "System gesture insets: " + + gestureInsets + " minX=" + minX + " minY=" + minY + " maxX=" + maxX + " maxY=" + maxY + " currentX=" + x + " currentY=" + y); } switch (direction) { case MOTION_DIR_UP: - offsetY = (int) Math.max(offsetY, minY); + y = (int) Math.max(y, minY); break; case MOTION_DIR_DOWN: - offsetY = (int) Math.min(offsetY, maxY); + y = (int) Math.min(y, maxY); break; case MOTION_DIR_LEFT: - offsetX = (int) Math.max(offsetX, minX); + x = (int) Math.max(x, minX); break; case MOTION_DIR_RIGHT: - offsetX = (int) Math.min(offsetX, maxX); + x = (int) Math.min(x, maxX); break; } - point.offset(offsetX, offsetY); - return point; + return new int[]{x, y}; } private static Point getScrollEndPoint(Point startPoint, @MotionDir int direction, int userAmountPx, Float startOffsetPercentX, Float startOffsetPercentY) { @@ -256,6 +299,11 @@ private static Point getScrollEndPoint(Point startPoint, @MotionDir int directio return point; } + /** + * Calculates the global location of the view on the screen. + * @param view The view to calculate. + * @return a Point object, denoting the global location of the view. + */ private static Point getGlobalViewLocation(View view) { int[] pos = new int[2]; view.getLocationInWindow(pos); diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt index 3ee94bd343..67bf17bdc2 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt @@ -8,6 +8,7 @@ import androidx.test.espresso.UiController import androidx.test.platform.app.InstrumentationRegistry import com.wix.detox.action.common.MOTION_DIR_DOWN import com.wix.detox.espresso.DeviceDisplay +import com.wix.detox.espresso.scroll.ScrollHelper.getViewSafeScrollableRangePix import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -19,6 +20,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import kotlin.test.assertEquals +private const val INSETS_SIZE = 100 + @Config(qualifiers = "xxxhdpi", sdk = [33]) @RunWith(RobolectricTestRunner::class) class ScrollHelperTest { @@ -26,54 +29,64 @@ class ScrollHelperTest { private val display = DeviceDisplay.getScreenSizeInPX() private val displayWidth = display[0].toInt() private val displayHeight = display[1].toInt() + private val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop + private val safetyMarginPx = DeviceDisplay.convertDpiToPx(2.0) - private val uiController = mock() - private val view = mockView(displayWidth, displayHeight) + private val uiControllerMock = mock() + private val viewMock = mockViewWithGestureNavigation(displayWidth, displayHeight) @Test - fun `perform scroll down for 200 dp on full screen view`() { + fun `perform scroll down for 200 dp on full screen view with gesture navigation enabled`() { val amountInDp = 200.0 val amountInPx = amountInDp * DeviceDisplay.getDensity() - val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop - ScrollHelper.perform(uiController, view, MOTION_DIR_DOWN, amountInDp, null, null) - val capture = argumentCaptor>() - verify(uiController).injectMotionEventSequence(capture.capture()) + // Perform the scroll + ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null) - val listOfCapturedEvents = capture.firstValue.toList() - val lastEvent = listOfCapturedEvents.last() // The last event is the UP event with the target coordinates - // the joinery of the swipe is not interesting - val lastEventX = lastEvent.x - val lastEventY = lastEvent.y - assertEquals(displayWidth / 2.0, lastEventX.toDouble(), 0.0) - assertEquals(displayHeight - amountInPx - touchSlopPx - DeviceDisplay.convertDpiToPx(1.0), lastEventY.toDouble(), 0.0) + // Verify start and end coordinates + val upEvent = getUpEvent() + val x = upEvent.x + val y = upEvent.y + // Verify that the scroll started at the center of the view + assertEquals(displayWidth / 2.0, x.toDouble(), 0.0) + // Verify that the scroll ended at the center of the view minus the requested amount + assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx - INSETS_SIZE, y.toDouble(), 0.0) } @Test - fun `perform scroll down for 200 dp on full screen view with offset y`() { - val amountInDp = 200.0 - val amountInPx = amountInDp * DeviceDisplay.getDensity() - val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop - val offsetPercent = 0.9f + fun `perform scroll down to edge on full screen view with gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_DOWN) + val upEvent = getUpEvent() + val x = upEvent.x + val y = upEvent.y + val amountInPx = getViewSafeScrollableRangePix(viewMock, MOTION_DIR_DOWN).toFloat() - ScrollHelper.perform(uiController, view, MOTION_DIR_DOWN, amountInDp, null, offsetPercent) + assertEquals(displayWidth / 2.0, x.toDouble(), 0.0) + assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx - INSETS_SIZE, y, 0.0f) + + } + + /** + * Get the performed UP event from the ui controller + */ + private fun getUpEvent(): MotionEvent { val capture = argumentCaptor>() - verify(uiController).injectMotionEventSequence(capture.capture()) + // Capture the events from the ui controller + verify(uiControllerMock).injectMotionEventSequence(capture.capture()) val listOfCapturedEvents = capture.firstValue.toList() - val lastEvent = listOfCapturedEvents.last() // The last event is the UP event with the target coordinates - // the joinery of the swipe is not interesting - val lastEventX = lastEvent.x - val lastEventY = lastEvent.y - assertEquals(displayWidth / 2.0, lastEventX.toDouble(), 0.0) - assertEquals(displayHeight - amountInPx - touchSlopPx - DeviceDisplay.convertDpiToPx(1.0), lastEventY.toDouble(), 0.0) - + // The last event is the UP event with the target coordinates. All of the rest are not interesting + return listOfCapturedEvents.last() } - private fun mockView(displayWidth: Int, displayHeight: Int): View { + /** + * Mock a view with gesture navigation enabled + */ + private fun mockViewWithGestureNavigation(displayWidth: Int, displayHeight: Int): View { + // This is how we enable gesture navigation val windowInsets = mock() { whenever(it.systemGestureInsets).thenReturn( - Insets.of(88, 88, 88, 100) + Insets.of(INSETS_SIZE, INSETS_SIZE, INSETS_SIZE, INSETS_SIZE) ) }