diff --git a/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt b/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt new file mode 100644 index 00000000000..d3002473f67 --- /dev/null +++ b/components/concept/engine/src/main/java/mozilla/components/concept/engine/InputResultDetail.kt @@ -0,0 +1,267 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.engine + +import android.view.MotionEvent +import androidx.annotation.VisibleForTesting + +// The below top-level values are following the same from [org.mozilla.geckoview.PanZoomController] + +/** + * The the content has no scrollable element. + * + * @see [InputResultDetail.isTouchUnhandled] + */ +@VisibleForTesting +internal const val INPUT_UNHANDLED = 0 + +/** + * The touch event is consumed by the [EngineView] + * + * @see [InputResultDetail.isTouchHandledByBrowser] + */ +@VisibleForTesting +internal const val INPUT_HANDLED = 1 + +/** + * The touch event is consumed by the website through it's own touch listeners. + * + * @see [InputResultDetail.isTouchHandledByWebsite] + */ +@VisibleForTesting +internal const val INPUT_HANDLED_CONTENT = 2 + +/** + * The website content is not scrollable. + */ +@VisibleForTesting +internal const val SCROLL_DIRECTIONS_NONE = 0 + +/** + * The website content can be scrolled to the top. + * + * @see [InputResultDetail.canScrollToTop] + */ +@VisibleForTesting +internal const val SCROLL_DIRECTIONS_TOP = 1 shl 0 + +/** + * The website content can be scrolled to the right. + * + * @see [InputResultDetail.canScrollToRight] + */ +@VisibleForTesting +internal const val SCROLL_DIRECTIONS_RIGHT = 1 shl 1 + +/** + * The website content can be scrolled to the bottom. + * + * @see [InputResultDetail.canScrollToBottom] + */ +@VisibleForTesting +internal const val SCROLL_DIRECTIONS_BOTTOM = 1 shl 2 + +/** + * The website content can be scrolled to the left. + * + * @see [InputResultDetail.canScrollToLeft] + */ +@VisibleForTesting +internal const val SCROLL_DIRECTIONS_LEFT = 1 shl 3 + +/** + * The website content cannot be overscrolled. + */ +@VisibleForTesting +internal const val OVERSCROLL_DIRECTIONS_NONE = 0 + +/** + * The website content can be overscrolled horizontally. + * + * @see [InputResultDetail.canOverscrollRight] + * @see [InputResultDetail.canOverscrollLeft] + */ +@VisibleForTesting +internal const val OVERSCROLL_DIRECTIONS_HORIZONTAL = 1 shl 0 + +/** + * The website content can be overscrolled vertically. + * + * @see [InputResultDetail.canOverscrollTop] + * @see [InputResultDetail.canOverscrollBottom] + */ +@VisibleForTesting +internal const val OVERSCROLL_DIRECTIONS_VERTICAL = 1 shl 1 + +/** + * All data about how a touch will be handled by the browser. + * - whether the event is used for panning/zooming by the browser / by the website or will be ignored. + * - whether the event can scroll the page and in what direction. + * - whether the event can overscroll the page and in what direction. + * + * Data wrapped by this class is safe to use after first calling [update] to update the default values. + * + * This class can be reused by calling [reset] to return all data values to it's default value. + */ +@Suppress("TooManyFunctions") +class InputResultDetail { + /** + * Indicates who will use the current [MotionEvent]. + * + * @see INPUT_UNHANDLED + * @see INPUT_HANDLED + * @see INPUT_HANDLED_CONTENT + */ + @VisibleForTesting + internal var inputResult: Int = INPUT_UNHANDLED + + /** + * Bitwise ORed value of the directions the page can be scrolled to. + * + * This is the same as GeckoView's [org.mozilla.geckoview.PanZoomController.ScrollableDirections]. + */ + @VisibleForTesting + internal var scrollDirections: Int = SCROLL_DIRECTIONS_NONE + + /** + * Bitwise ORed value of the directions the page can be overscrolled to. + * + * This is the same as GeckoView's [org.mozilla.geckoview.PanZoomController.OverscrollDirections]. + * + * The default value is [OVERSCROLL_DIRECTIONS_VERTICAL] and not [OVERSCROLL_DIRECTIONS_NONE] since + * there are cases in which this class can be used before valid values being set and it helps more to have + * overscroll vertically allowed and then stop depending on the values with which this class is updated + * rather than start with a disabled overscroll functionality for the current gesture. + */ + @VisibleForTesting + internal var overscrollDirections: Int = OVERSCROLL_DIRECTIONS_VERTICAL + + /** + * The only way to update the values contained by this class. + * Only after calling this method the various getters are safe to use. + * + * This method will filter out unexpected values to ensure data consistency. + */ + fun update( + inputResult: Int? = this.inputResult, + scrollDirections: Int? = this.scrollDirections, + overscrollDirections: Int? = this.overscrollDirections + ) { + // The range check automatically checks for null but doesn't yet have a contract to say so. + // As such it it safe to use the not-null assertion operator. + + if (inputResult in INPUT_UNHANDLED..INPUT_HANDLED_CONTENT) { + this.inputResult = inputResult!! + } + if (scrollDirections in SCROLL_DIRECTIONS_NONE..(SCROLL_DIRECTIONS_LEFT or (SCROLL_DIRECTIONS_LEFT - 1))) { + this.scrollDirections = scrollDirections!! + } + if (overscrollDirections in + OVERSCROLL_DIRECTIONS_NONE..(OVERSCROLL_DIRECTIONS_VERTICAL or (OVERSCROLL_DIRECTIONS_VERTICAL - 1))) { + + this.overscrollDirections = overscrollDirections!! + } + } + + /** + * Reset all data to default values. + */ + fun reset() { + inputResult = INPUT_UNHANDLED + scrollDirections = SCROLL_DIRECTIONS_NONE + overscrollDirections = OVERSCROLL_DIRECTIONS_VERTICAL + } + + /** + * The [EngineView] handled the last [MotionEvent] to pan or zoom the content. + */ + fun isTouchHandledByBrowser() = inputResult == INPUT_HANDLED + + /** + * The website handled the last [MotionEvent] through it's own touch listeners + * and consumed it without the [EngineView] panning or zooming the website + */ + fun isTouchHandledByWebsite() = inputResult == INPUT_HANDLED_CONTENT + + /** + * Neither the [EngineView], nor the website will handle this [MotionEvent]. + * + * This might happen on a website without touch listeners that is not bigger than the screen + * or when the content has no scrollable element. + */ + fun isTouchUnhandled() = inputResult == INPUT_UNHANDLED + + /** + * Whether the width of the webpage exceeds the display and the webpage can be scrolled to left. + */ + fun canScrollToLeft(): Boolean = + inputResult == INPUT_HANDLED && + scrollDirections and SCROLL_DIRECTIONS_LEFT != 0 + + /** + * Whether the height of the webpage exceeds the display and the webpage can be scrolled to top. + */ + fun canScrollToTop(): Boolean = + inputResult == INPUT_HANDLED && + scrollDirections and SCROLL_DIRECTIONS_TOP != 0 + + /** + * Whether the width of the webpage exceeds the display and the webpage can be scrolled to right. + */ + fun canScrollToRight(): Boolean = + inputResult == INPUT_HANDLED && + scrollDirections and SCROLL_DIRECTIONS_RIGHT != 0 + + /** + * Whether the height of the webpage exceeds the display and the webpage can be scrolled to bottom. + */ + fun canScrollToBottom(): Boolean = + inputResult == INPUT_HANDLED && + scrollDirections and SCROLL_DIRECTIONS_BOTTOM != 0 + + /** + * Whether the webpage can be overscrolled to the left. + * + * @return `true` if the page is already scrolled to the left most part + * and the touch event is not handled by the webpage. + */ + fun canOverscrollLeft(): Boolean = + inputResult != INPUT_HANDLED_CONTENT && + (scrollDirections and SCROLL_DIRECTIONS_LEFT == 0) && + (overscrollDirections and OVERSCROLL_DIRECTIONS_HORIZONTAL != 0) + + /** + * Whether the webpage can be overscrolled to the top. + * + * @return `true` if the page is already scrolled to the top most part + * and the touch event is not handled by the webpage. + */ + fun canOverscrollTop(): Boolean = + inputResult != INPUT_HANDLED_CONTENT && + (scrollDirections and SCROLL_DIRECTIONS_TOP == 0) && + (overscrollDirections and OVERSCROLL_DIRECTIONS_VERTICAL != 0) + + /** + * Whether the webpage can be overscrolled to the right. + * + * @return `true` if the page is already scrolled to the right most part + * and the touch event is not handled by the webpage. + */ + fun canOverscrollRight(): Boolean = + inputResult != INPUT_HANDLED_CONTENT && + (scrollDirections and SCROLL_DIRECTIONS_RIGHT == 0) && + (overscrollDirections and OVERSCROLL_DIRECTIONS_HORIZONTAL != 0) + + /** + * Whether the webpage can be overscrolled to the bottom. + * + * @return `true` if the page is already scrolled to the bottom most part + * and the touch event is not handled by the webpage. + */ + fun canOverscrollBottom(): Boolean = + inputResult != INPUT_HANDLED_CONTENT && + (scrollDirections and SCROLL_DIRECTIONS_BOTTOM == 0) && + (overscrollDirections and OVERSCROLL_DIRECTIONS_VERTICAL != 0) +} diff --git a/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt b/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt new file mode 100644 index 00000000000..f58c2ef6c5c --- /dev/null +++ b/components/concept/engine/src/test/java/mozilla/components/concept/engine/InputResultDetailTest.kt @@ -0,0 +1,266 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.engine + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InputResultDetailTest { + private lateinit var inputResultDetail: InputResultDetail + + @Before + fun setup() { + inputResultDetail = InputResultDetail() + } + + @Test + fun `GIVEN InputResultDetail WHEN creating a new instance THEN it has specific default values`() { + assertEquals(INPUT_UNHANDLED, inputResultDetail.inputResult) + assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections) + assertEquals(OVERSCROLL_DIRECTIONS_VERTICAL, inputResultDetail.overscrollDirections) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN update is called with new values THEN the new values are set for the instance`() { + inputResultDetail.update(INPUT_HANDLED_CONTENT) + assertEquals(INPUT_HANDLED_CONTENT, inputResultDetail.inputResult) + assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections) + assertEquals(OVERSCROLL_DIRECTIONS_VERTICAL, inputResultDetail.overscrollDirections) + + inputResultDetail.update(INPUT_HANDLED, SCROLL_DIRECTIONS_RIGHT) + assertEquals(INPUT_HANDLED, inputResultDetail.inputResult) + assertEquals(SCROLL_DIRECTIONS_RIGHT, inputResultDetail.scrollDirections) + assertEquals(OVERSCROLL_DIRECTIONS_VERTICAL, inputResultDetail.overscrollDirections) + + inputResultDetail.update(INPUT_UNHANDLED, SCROLL_DIRECTIONS_NONE, OVERSCROLL_DIRECTIONS_NONE) + assertEquals(INPUT_UNHANDLED, inputResultDetail.inputResult) + assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections) + assertEquals(OVERSCROLL_DIRECTIONS_NONE, inputResultDetail.overscrollDirections) + + // testing also that filtering our invalid values works + inputResultDetail.update(42, 42, 42) + assertEquals(INPUT_UNHANDLED, inputResultDetail.inputResult) + assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections) + assertEquals(OVERSCROLL_DIRECTIONS_NONE, inputResultDetail.overscrollDirections) + } + + @Test + fun `GIVEN an InputResultDetail instance with already set items WHEN reset is called THEN instance values are set to their defaults`() { + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_TOP, OVERSCROLL_DIRECTIONS_NONE) + + inputResultDetail.reset() + + assertEquals(INPUT_UNHANDLED, inputResultDetail.inputResult) + assertEquals(SCROLL_DIRECTIONS_NONE, inputResultDetail.scrollDirections) + assertEquals(OVERSCROLL_DIRECTIONS_VERTICAL, inputResultDetail.overscrollDirections) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN isTouchHandledByBrowser is called THEN it returns true only if the inputResult is INPUT_HANDLED`() { + assertFalse(inputResultDetail.isTouchHandledByBrowser()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.isTouchHandledByBrowser()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT) + assertFalse(inputResultDetail.isTouchHandledByBrowser()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN isTouchHandledByWebsite is called THEN it returns true only if the inputResult is INPUT_HANDLED_CONTENT`() { + assertFalse(inputResultDetail.isTouchHandledByWebsite()) + + inputResultDetail.update(INPUT_HANDLED) + assertFalse(inputResultDetail.isTouchHandledByWebsite()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT) + assertTrue(inputResultDetail.isTouchHandledByWebsite()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN isTouchUnhandled is called THEN it returns true only if the inputResult is INPUT_UNHANDLED`() { + assertTrue(inputResultDetail.isTouchUnhandled()) + + inputResultDetail.update(INPUT_HANDLED) + assertFalse(inputResultDetail.isTouchUnhandled()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT) + assertFalse(inputResultDetail.isTouchUnhandled()) + + inputResultDetail.update(INPUT_UNHANDLED) + assertTrue(inputResultDetail.isTouchUnhandled()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canScrollToLeft is called THEN it returns true only if the browser can scroll the page to left`() { + assertFalse(inputResultDetail.canScrollToLeft()) + + inputResultDetail.update(INPUT_HANDLED) + assertFalse(inputResultDetail.canScrollToLeft()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_LEFT) + assertFalse(inputResultDetail.canScrollToLeft()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.canScrollToLeft()) + + inputResultDetail.update(overscrollDirections = OVERSCROLL_DIRECTIONS_NONE) + assertTrue(inputResultDetail.canScrollToLeft()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canScrollToTop is called THEN it returns true only if the browser can scroll the page to top`() { + assertFalse(inputResultDetail.canScrollToTop()) + + inputResultDetail.update(INPUT_HANDLED) + assertFalse(inputResultDetail.canScrollToTop()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_TOP) + assertFalse(inputResultDetail.canScrollToTop()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.canScrollToTop()) + + inputResultDetail.update(overscrollDirections = OVERSCROLL_DIRECTIONS_VERTICAL) + assertTrue(inputResultDetail.canScrollToTop()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canScrollToRight is called THEN it returns true only if the browser can scroll the page to right`() { + assertFalse(inputResultDetail.canScrollToRight()) + + inputResultDetail.update(INPUT_HANDLED) + assertFalse(inputResultDetail.canScrollToRight()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_RIGHT) + assertFalse(inputResultDetail.canScrollToRight()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.canScrollToRight()) + + inputResultDetail.update(overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertTrue(inputResultDetail.canScrollToRight()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canScrollToBottom is called THEN it returns true only if the browser can scroll the page to bottom`() { + assertFalse(inputResultDetail.canScrollToBottom()) + + inputResultDetail.update(INPUT_HANDLED) + assertFalse(inputResultDetail.canScrollToBottom()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_BOTTOM) + assertFalse(inputResultDetail.canScrollToBottom()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.canScrollToBottom()) + + inputResultDetail.update(overscrollDirections = OVERSCROLL_DIRECTIONS_NONE) + assertTrue(inputResultDetail.canScrollToBottom()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canOverscrollLeft is called THEN it returns true only in certain scenarios`() { + // The scenarios (for which there is not enough space in the method name) being: + // - event is not handled by the webpage + // - webpage cannot be scrolled to the left in which case scroll would need to happen first + // - the content can be overscrolled to the left. Webpages can request overscroll to be disabled. + + assertFalse(inputResultDetail.canOverscrollLeft()) + + inputResultDetail.update(INPUT_HANDLED, SCROLL_DIRECTIONS_BOTTOM, OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertTrue(inputResultDetail.canOverscrollLeft()) + + inputResultDetail.update(INPUT_UNHANDLED) + assertTrue(inputResultDetail.canOverscrollLeft()) + + inputResultDetail.update(scrollDirections = SCROLL_DIRECTIONS_LEFT) + assertFalse(inputResultDetail.canOverscrollLeft()) + + inputResultDetail.update(scrollDirections = SCROLL_DIRECTIONS_TOP, overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertTrue(inputResultDetail.canOverscrollLeft()) + + inputResultDetail.update(INPUT_HANDLED, SCROLL_DIRECTIONS_RIGHT, OVERSCROLL_DIRECTIONS_VERTICAL) + assertFalse(inputResultDetail.canOverscrollLeft()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canOverscrollTop is called THEN it returns true only in certain scenarios`() { + // The scenarios (for which there is not enough space in the method name) being: + // - event is not handled by the webpage + // - webpage cannot be scrolled to the top in which case scroll would need to happen first + // - the content can be overscrolled to the top. Webpages can request overscroll to be disabled. + + assertTrue(inputResultDetail.canOverscrollTop()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT) + assertFalse(inputResultDetail.canOverscrollTop()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.canOverscrollTop()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_TOP, OVERSCROLL_DIRECTIONS_VERTICAL) + assertFalse(inputResultDetail.canOverscrollTop()) + + inputResultDetail.update(INPUT_UNHANDLED, SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_VERTICAL) + assertTrue(inputResultDetail.canOverscrollTop()) + + inputResultDetail.update(overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertFalse(inputResultDetail.canOverscrollTop()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canOverscrollRight is called THEN it returns true only in certain scenarios`() { + // The scenarios (for which there is not enough space in the method name) being: + // - event is not handled by the webpage + // - webpage cannot be scrolled to the right in which case scroll would need to happen first + // - the content can be overscrolled to the right. Webpages can request overscroll to be disabled. + + assertFalse(inputResultDetail.canOverscrollRight()) + + inputResultDetail.update(INPUT_HANDLED, SCROLL_DIRECTIONS_BOTTOM, OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertTrue(inputResultDetail.canOverscrollRight()) + + inputResultDetail.update(INPUT_UNHANDLED) + assertTrue(inputResultDetail.canOverscrollRight()) + + inputResultDetail.update(scrollDirections = SCROLL_DIRECTIONS_RIGHT) + assertFalse(inputResultDetail.canOverscrollRight()) + + inputResultDetail.update(scrollDirections = SCROLL_DIRECTIONS_TOP, overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertTrue(inputResultDetail.canOverscrollRight()) + + inputResultDetail.update(INPUT_HANDLED, SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_VERTICAL) + assertFalse(inputResultDetail.canOverscrollRight()) + } + + @Test + fun `GIVEN an InputResultDetail instance WHEN canOverscrollBottom is called THEN it returns true only in certain scenarios`() { + // The scenarios (for which there is not enough space in the method name) being: + // - event is not handled by the webpage + // - webpage cannot be scrolled to the bottom in which case scroll would need to happen first + // - the content can be overscrolled to the bottom. Webpages can request overscroll to be disabled. + + assertTrue(inputResultDetail.canOverscrollBottom()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT) + assertFalse(inputResultDetail.canOverscrollBottom()) + + inputResultDetail.update(INPUT_HANDLED) + assertTrue(inputResultDetail.canOverscrollBottom()) + + inputResultDetail.update(INPUT_HANDLED_CONTENT, SCROLL_DIRECTIONS_BOTTOM, OVERSCROLL_DIRECTIONS_VERTICAL) + assertFalse(inputResultDetail.canOverscrollBottom()) + + inputResultDetail.update(INPUT_UNHANDLED, SCROLL_DIRECTIONS_LEFT, OVERSCROLL_DIRECTIONS_VERTICAL) + assertTrue(inputResultDetail.canOverscrollBottom()) + + inputResultDetail.update(overscrollDirections = OVERSCROLL_DIRECTIONS_HORIZONTAL) + assertFalse(inputResultDetail.canOverscrollBottom()) + } +}