From 18cdc360b2deafa10d50db896606328988fe9ad8 Mon Sep 17 00:00:00 2001 From: Georgy Steshin Date: Tue, 2 Jan 2024 14:56:44 +0200 Subject: [PATCH] Implementation of scrolling fix for Android 33/34 --- .../com/wix/detox/espresso/DeviceDisplay.kt | 2 +- .../detox/espresso/scroll/ScrollHelper.java | 49 +++++++++- .../detox/espresso/scroll/ScrollHelperTest.kt | 89 +++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt 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 bfca25cb1f..73973e4a79 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,10 +1,13 @@ 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; @@ -169,6 +172,50 @@ private static Point getScrollStartPoint(View view, @MotionDir int direction, Fl int offsetX = ((int) (view.getWidth() * offsetFactorX) + safetyOffsetX); int offsetY = ((int) (view.getHeight() * offsetFactorY) + safetyOffsetY); + 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; + 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); + } + } + + switch (direction) { + case MOTION_DIR_UP: + offsetY = (int) Math.max(offsetY, minY); + break; + case MOTION_DIR_DOWN: + offsetY = (int) Math.min(offsetY, maxY); + break; + case MOTION_DIR_LEFT: + offsetX = (int) Math.max(offsetX, minX); + break; + case MOTION_DIR_RIGHT: + offsetX = (int) Math.min(offsetX, maxX); + break; + } + point.offset(offsetX, offsetY); return point; } @@ -215,7 +262,7 @@ private static Point getGlobalViewLocation(View view) { 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..3ee94bd343 --- /dev/null +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt @@ -0,0 +1,89 @@ +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.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 + +@Config(qualifiers = "xxxhdpi", 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 uiController = mock() + private val view = mockView(displayWidth, displayHeight) + + @Test + fun `perform scroll down for 200 dp on full screen view`() { + 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()) + + 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) + } + + @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 + + ScrollHelper.perform(uiController, view, MOTION_DIR_DOWN, amountInDp, null, offsetPercent) + val capture = argumentCaptor>() + verify(uiController).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) + + } + + private fun mockView(displayWidth: Int, displayHeight: Int): View { + val windowInsets = mock() { + whenever(it.systemGestureInsets).thenReturn( + Insets.of(88, 88, 88, 100) + ) + } + + 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.context).thenReturn(InstrumentationRegistry.getInstrumentation().targetContext) + whenever(it.rootWindowInsets).thenReturn(windowInsets) + } + return view + } +}