diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt index 72012b181c..f882edeba4 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt @@ -17,7 +17,7 @@ object DeviceDisplay { } @JvmStatic - fun getScreenSizeInPX(): FloatArray? { + fun getScreenSizeInPX(): FloatArray { val metrics = getDisplayMetrics() return floatArrayOf(metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat()) } 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 ac08100841..bef721c984 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 @@ -1,14 +1,18 @@ package com.wix.detox.espresso.scroll; import android.content.Context; +import android.graphics.Insets; import android.graphics.Point; +import android.os.Build; import android.util.Log; import android.view.View; import android.view.ViewConfiguration; +import android.view.WindowInsets; 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; @@ -51,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); @@ -115,25 +119,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[] getScrollStartOffsetInView(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; @@ -171,8 +182,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); - point.offset(offsetX, offsetY); - return point; + 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 = getScrollStartOffsetInView(view, direction, startOffsetPercentX, startOffsetPercentY); + + // 2. Make sure that the start point is within the scrollable area, taking into account the system gesture insets. + coordinates = applyScreenInsets(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[] applyScreenInsets(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 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; + + // 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: + y = (int) Math.max(y, minY); + break; + case MOTION_DIR_DOWN: + y = (int) Math.min(y, maxY); + break; + case MOTION_DIR_LEFT: + x = (int) Math.max(x, minX); + break; + case MOTION_DIR_RIGHT: + x = (int) Math.min(x, maxX); + break; + } + + return new int[]{x, y}; } private static Point getScrollEndPoint(Point startPoint, @MotionDir int direction, int userAmountPx, Float startOffsetPercentX, Float startOffsetPercentY) { @@ -211,13 +301,18 @@ 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); return new Point(pos[0], pos[1]); } - private static ViewConfiguration getViewConfiguration() { + public static ViewConfiguration getViewConfiguration() { if (viewConfiguration == null) { final Context applicationContext = InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext(); viewConfiguration = ViewConfiguration.get(applicationContext); 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 new file mode 100644 index 0000000000..4eeee3a823 --- /dev/null +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt @@ -0,0 +1,188 @@ +package com.wix.detox.espresso.scroll + +import android.graphics.Insets +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import androidx.test.espresso.UiController +import androidx.test.platform.app.InstrumentationRegistry +import com.wix.detox.action.common.MOTION_DIR_DOWN +import com.wix.detox.action.common.MOTION_DIR_LEFT +import com.wix.detox.action.common.MOTION_DIR_RIGHT +import com.wix.detox.action.common.MOTION_DIR_UP +import com.wix.detox.espresso.DeviceDisplay +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +private const val INSETS_SIZE = 100 +private const val SCROLL_RANGE_SAFE_PERCENT = 0.9f // ScrollHelper.SCROLL_RANGE_SAFE_PERCENT + +@Config( + qualifiers = "xxxhdpi", // 1280x1880 + sdk = [33] +) +@RunWith(RobolectricTestRunner::class) +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 uiControllerMock = mock() + private val viewMock = mockViewWithGestureNavigation(displayWidth, displayHeight) + + @Test + fun `should scrolling down by 200 when gesture navigation enabled`() { + val amountInDp = 200.0 + val amountInPx = amountInDp * DeviceDisplay.getDensity() + + ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null) + + val upEvent = getUpEvent() + // Verify that the scroll started at the center of the view + assertEquals(displayWidth / 2.0, upEvent.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, upEvent.y.toDouble(), 0.0) + } + + @Test + fun `should scrolling down by 200 when gesture navigation disabled`() { + val amountInDp = 200.0 + val amountInPx = amountInDp * DeviceDisplay.getDensity() + + val viewMock = mockViewWithoutGestureNavigation(displayWidth, displayHeight) + ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null) + + val upEvent = getUpEvent() + // Verify that the scroll started at the center of the view + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + // Verify that the scroll ended at the center of the view minus the requested amount + assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx, upEvent.y.toDouble(), 0.0) + } + + @Test + fun `should scroll down to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_DOWN, null, null) + val upEvent = getUpEvent() + val amountInPx = displayHeight * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetY = displayHeight - amountInPx - + touchSlopPx - + safetyMarginPx - + INSETS_SIZE + + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + assertEquals(targetY, upEvent.y, 0.0f) + } + + @Test + fun `should scroll left to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_LEFT, null, null) + val upEvent = getUpEvent() + val amountInPx = displayWidth * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetX = amountInPx + + touchSlopPx + + INSETS_SIZE + + assertEquals(targetX, upEvent.x, 0.0f) + assertEquals(displayHeight / 2.0, upEvent.y.toDouble(), 0.0) + } + + @Test + fun `should scroll up to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_UP,null, null) + val upEvent = getUpEvent() + val amountInPx = displayHeight * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetY = amountInPx + + touchSlopPx + + INSETS_SIZE + + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + assertEquals(targetY, upEvent.y, 0.0f) + } + + @Test + fun `should scroll right to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_RIGHT, null, null) + val upEvent = getUpEvent() + val amountInPx = displayWidth * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetX = displayWidth - amountInPx - + touchSlopPx - + safetyMarginPx - + INSETS_SIZE + + assertEquals(targetX, upEvent.x, 0.0f) + assertEquals(displayHeight / 2.0, upEvent.y.toDouble(), 0.0) + } + + /** + * Get the performed UP event from the ui controller + */ + private fun getUpEvent(): MotionEvent { + val capture = argumentCaptor>() + // Capture the events from the ui controller + verify(uiControllerMock).injectMotionEventSequence(capture.capture()) + + val listOfCapturedEvents = capture.firstValue.toList() + // The last event is the UP event with the target coordinates. All of the rest are not interesting + return listOfCapturedEvents.last() + } + + private fun mockViewWithoutGestureNavigation(displayWidth: Int, displayHeight: Int): View { + // This is how we disable gesture navigation + val windowInsets = mock() { + whenever(it.systemGestureInsets).thenReturn( + Insets.of(0, 0, 0, 0) + ) + } + + return mockView(displayWidth, displayHeight, windowInsets) + } + + /** + * 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(INSETS_SIZE, INSETS_SIZE, INSETS_SIZE, INSETS_SIZE) + ) + } + + return mockView(displayWidth, displayHeight, windowInsets) + } + + private fun mockView( + displayWidth: Int, + displayHeight: Int, + windowInsets: WindowInsets + ): View { + val view = mock() { + whenever(it.width).thenReturn(displayWidth) + whenever(it.height).thenReturn(displayHeight) + whenever(it.canScrollVertically(any())).thenReturn(true) // We allow endless scroll + whenever(it.canScrollHorizontally(any())).thenReturn(true) // We allow endless scroll + whenever(it.context).thenReturn(InstrumentationRegistry.getInstrumentation().targetContext) + whenever(it.rootWindowInsets).thenReturn(windowInsets) + } + return view + } +}