tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit ec94943015fd6306d298bc2e7d7570c649f1ce9a
parent a6e4f92f079e36ad86f84790e35e7d1d497c47bf
Author: Mugurell <Mugurell@users.noreply.github.com>
Date:   Wed, 12 Nov 2025 08:07:05 +0000

Bug 1990215 - Surface scroll data and scroll deltas from APZ to AC's EngineView r=android-reviewers,ohall,jonalmeida,skhan,m_kato

Differential Revision: https://phabricator.services.mozilla.com/D266776

Diffstat:
Mmobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt | 7+++++++
Amobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoVerticalScrollListener.kt | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt | 26++++++++++++++++++++++++++
Amobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoVerticalScrollListenerTest.kt | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/android-components/components/browser/engine-system/src/main/java/mozilla/components/browser/engine/system/SystemEngineView.kt | 4++++
Mmobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt | 11+++++++++++
Mmobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.kt | 5+++++
Mmobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt | 3+++
Mmobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlinx/coroutines/flow/Flow.kt | 43+++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlinx/coroutines/flow/FlowKtTest.kt | 33+++++++++++++++++++++++++++++++++
Mmobile/android/android-components/components/support/test-fakes/src/main/java/mozilla/components/support/test/fakes/engine/FakeEngineView.kt | 5+++++
Mmobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt | 3+++
Mmobile/android/android-components/docs/changelog.md | 2++
Mmobile/android/focus-android/app/src/test/java/org/mozilla/focus/BrowserFragmentTest.kt | 4++++
14 files changed, 400 insertions(+), 0 deletions(-)

diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineView.kt @@ -94,6 +94,11 @@ class GeckoEngineView @JvmOverloads constructor( override var selectionActionDelegate: SelectionActionDelegate? = null + @VisibleForTesting + internal var verticalScrollListener = GeckoVerticalScrollListener() + override val verticalScrollPosition = verticalScrollListener.scrollYPosition + override val verticalScrollDelta = verticalScrollListener.scrollYDeltas + init { addView(geckoView) @@ -138,6 +143,7 @@ class GeckoEngineView @JvmOverloads constructor( try { geckoView.setSession(internalSession.geckoSession) attachSelectionActionDelegate(internalSession.geckoSession) + verticalScrollListener.observe(internalSession.geckoSession) } catch (e: IllegalStateException) { // This is to debug "display already acquired" crashes val otherActivityClassName = @@ -172,6 +178,7 @@ class GeckoEngineView @JvmOverloads constructor( @Synchronized override fun release() { detachSelectionActionDelegate(currentSession?.geckoSession) + verticalScrollListener.release() currentSession = null diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoVerticalScrollListener.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoVerticalScrollListener.kt @@ -0,0 +1,122 @@ +/* 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.browser.engine.gecko + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.isActive +import mozilla.components.support.ktx.kotlinx.coroutines.flow.windowed +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate +import org.mozilla.geckoview.GeckoSession.ScrollPositionUpdate + +/** + * Delegate for observing scroll related updates from a given [GeckoSession] + * and exposing this data through [scrollYPosition] and [scrollYDeltas] flows. + */ +@OptIn(ExperimentalCoroutinesApi::class) // for flatMapLatest +internal class GeckoVerticalScrollListener( + private val dispatcher: CoroutineDispatcher = Dispatchers.Main, +) { + private var updatesScope: CoroutineScope? = null + private val currentGeckoSession = MutableStateFlow<GeckoSession?>(null) + + private val _scrollYPosition = MutableStateFlow(0f) + private val _scrollYDeltas = MutableStateFlow(0f) + + /** + * Running flow of the current scroll position in pixels. + */ + val scrollYPosition: StateFlow<Float> = _scrollYPosition.asStateFlow() + + /** + * Running flow of scroll deltas in pixels. + */ + val scrollYDeltas: StateFlow<Float> = _scrollYDeltas.asStateFlow() + + /** + * Start observing [geckoSession] for scroll related updates. + */ + fun observe(geckoSession: GeckoSession) { + if (updatesScope?.isActive != true) { + updatesScope = CoroutineScope(dispatcher) + startCollectors() + } + currentGeckoSession.value = geckoSession + } + + /** + * Stops observing the current session and resets the data flows to 0f. + */ + fun release() { + currentGeckoSession.value = null + updatesScope?.cancel() + + _scrollYPosition.value = 0f + _scrollYDeltas.value = 0f + } + + private fun startCollectors() { + val scope = updatesScope ?: return + + // Single source of truth for the scroll position in the current session. + val sharedPositionFlow: SharedFlow<ScrollPositionUpdate> = currentGeckoSession + .filterNotNull() + .flatMapLatest { session -> + callbackFlow { + val delegate = CompositorScrollDelegate { trySend(it) } + session.compositorScrollDelegate = delegate + awaitClose { session.compositorScrollDelegate = null } + } + } + .shareIn(scope, SharingStarted.Eagerly) + + sharedPositionFlow + .map { it.scrollY * it.zoom } + .onEach { _scrollYPosition.value = it } + .launchIn(scope) + + sharedPositionFlow + .windowed(2, 1) + .map { (old, new) -> + // Report a new scroll delta if not in the progress of zooming in/out. + // Or if coming back to no in/out zoom report immediately avoiding the need of two + // data points for scrolling with no in/out zoom. + if (new.zoom == old.zoom || new.zoom == 1f) { + (new.scrollY - old.scrollY) * new.zoom + } else { + 0f + } + } + .filter { it != 0f } + .onEach { _scrollYDeltas.value = it } + .launchIn(scope) + } +} + +private class CompositorScrollDelegate( + private val scrollUpdatesCallback: (ScrollPositionUpdate) -> Unit, +) : CompositorScrollDelegate { + override fun onScrollChanged(session: GeckoSession, update: ScrollPositionUpdate) { + scrollUpdatesCallback(update) + } +} diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoEngineViewTest.kt @@ -27,6 +27,7 @@ import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn import org.mockito.Mockito.never import org.mockito.Mockito.times import org.mockito.Mockito.verify @@ -244,6 +245,31 @@ class GeckoEngineViewTest { verify(geckoSession).selectionActionDelegate = null } + fun `WHEN rendering a new session THEN start observing scroll events`() { + val listener: GeckoVerticalScrollListener = mock() + val engineView = GeckoEngineView(context).apply { + verticalScrollListener = listener + } + val geckoSession: GeckoSession = mock() + val engineSession = mock<GeckoEngineSession>() + doReturn(geckoSession).`when`(engineSession).geckoSession + + engineView.render(engineSession) + + verify(listener).observe(geckoSession) + } + + fun `WHEN releasing a session THEN stop observing scroll events`() { + val listener: GeckoVerticalScrollListener = mock() + val engineView = GeckoEngineView(context).apply { + verticalScrollListener = listener + } + + engineView.release() + + verify(listener).release() + } + @Test fun `setVisibility is propagated to gecko view`() { val engineView = GeckoEngineView(context) diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoVerticalScrollListenerTest.kt b/mobile/android/android-components/components/browser/engine-gecko/src/test/java/mozilla/components/browser/engine/gecko/GeckoVerticalScrollListenerTest.kt @@ -0,0 +1,132 @@ +/* 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/. */ + +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import mozilla.components.browser.engine.gecko.GeckoVerticalScrollListener +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito.isNull +import org.mockito.Mockito.verify +import org.mozilla.geckoview.GeckoSession + +@ExperimentalCoroutinesApi +class GeckoVerticalScrollListenerTest { + private val geckoSession: GeckoSession = mock() + private val delegateCaptor = argumentCaptor<GeckoSession.CompositorScrollDelegate>() + + private val testScheduler = TestCoroutineScheduler() + val testDispatcher = StandardTestDispatcher(testScheduler) + + @Test + fun `WHEN there is no scroll data THEN return 0 for scroll and delta`() = runTest(testScheduler) { + val scrollListener = GeckoVerticalScrollListener(testDispatcher) + + assertEquals(0f, scrollListener.scrollYPosition.value) + assertEquals(0f, scrollListener.scrollYDeltas.value) + } + + @Test + fun `GIVEN multiple scroll and zoom updates in a GeckoSession WHEN observing them THEN get the scroll values and scroll deltas only if not zooming`() = runTest(testScheduler) { + val scrollListener = GeckoVerticalScrollListener(testDispatcher) + + val positions = mutableListOf<Float>() + val deltas = mutableListOf<Float>() + scrollListener.scrollYPosition.onEach { positions.add(it) }.launchIn(backgroundScope) + scrollListener.scrollYDeltas.onEach { deltas.add(it) }.launchIn(backgroundScope) + + scrollListener.observe(geckoSession) + advanceUntilIdle() + verify(geckoSession).compositorScrollDelegate = delegateCaptor.capture() + val delegate = delegateCaptor.value + delegate.onScrollChanged(geckoSession, buildScrollUpdate(0f)) + runCurrent() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(50f)) + runCurrent() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(120f)) + runCurrent() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(70f, 2f)) + runCurrent() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(160f, 1.2f)) + runCurrent() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(180f)) + runCurrent() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(150f)) + runCurrent() + + assertEquals(listOf(0f, 50f, 120f, 140f, 192f, 180f, 150f), positions) + assertEquals(listOf(0f, 50f, 70f, 20f, -30f), deltas) + assertEquals(150f, scrollListener.scrollYPosition.value) + assertEquals(-30f, scrollListener.scrollYDeltas.value) + } + + @Test + fun `GIVEN a new GeckoSession WHEN observing it for scroll details THEN resets reset current data and start observing the new session`() = runTest(testScheduler) { + val scrollListener = GeckoVerticalScrollListener(testDispatcher) + val positions = mutableListOf<Float>() + scrollListener.scrollYPosition.onEach { positions.add(it) }.launchIn(backgroundScope) + + scrollListener.observe(geckoSession) + advanceUntilIdle() + verify(geckoSession).compositorScrollDelegate = delegateCaptor.capture() + val initialDelegate = delegateCaptor.value + initialDelegate.onScrollChanged(geckoSession, buildScrollUpdate(100f)) + runCurrent() + assertEquals(100f, scrollListener.scrollYPosition.value) + + val otherGeckoSession: GeckoSession = mock() + scrollListener.observe(otherGeckoSession) + runCurrent() + verify(geckoSession).compositorScrollDelegate = isNull() + verify(otherGeckoSession).compositorScrollDelegate = delegateCaptor.capture() + + val newDelegate = delegateCaptor.value + newDelegate.onScrollChanged(otherGeckoSession, buildScrollUpdate(200f)) + runCurrent() + assertEquals(listOf(0f, 100f, 200f), positions) + assertEquals(200f, scrollListener.scrollYPosition.value) + } + + @Test + fun `GIVEN already reporting scroll updates for a GeckoSession WHEN called to release the session THEN stop reporting scroll updates`() = runTest(testScheduler) { + val scrollListener = GeckoVerticalScrollListener(testDispatcher) + val positions = mutableListOf<Float>() + scrollListener.scrollYPosition.onEach { positions.add(it) }.launchIn(backgroundScope) + + scrollListener.observe(geckoSession) + advanceUntilIdle() + verify(geckoSession).compositorScrollDelegate = delegateCaptor.capture() + val delegate = delegateCaptor.value + delegate.onScrollChanged(geckoSession, buildScrollUpdate(100f)) + advanceUntilIdle() + delegate.onScrollChanged(geckoSession, buildScrollUpdate(120f)) + advanceUntilIdle() + assertEquals(120f, scrollListener.scrollYPosition.value) + assertEquals(20f, scrollListener.scrollYDeltas.value) + + scrollListener.release() + assertEquals(0f, scrollListener.scrollYPosition.value) + assertEquals(0f, scrollListener.scrollYPosition.value) + delegate.onScrollChanged(geckoSession, buildScrollUpdate(200f)) + advanceUntilIdle() + assertEquals(0f, scrollListener.scrollYPosition.value) + assertEquals(0f, scrollListener.scrollYPosition.value) + } + + private fun buildScrollUpdate( + scrollY: Float, + zoom: Float = 1f, + ) = mock<GeckoSession>().ScrollPositionUpdate().apply { + this.scrollY = scrollY + this.zoom = zoom + } +} diff --git a/mobile/android/android-components/components/browser/engine-system/src/main/java/mozilla/components/browser/engine/system/SystemEngineView.kt b/mobile/android/android-components/components/browser/engine-system/src/main/java/mozilla/components/browser/engine/system/SystemEngineView.kt @@ -42,6 +42,7 @@ import androidx.annotation.VisibleForTesting.Companion.PRIVATE import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.core.view.ViewCompat +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import mozilla.components.browser.engine.system.matcher.UrlMatcher import mozilla.components.browser.engine.system.permission.SystemPermissionRequest @@ -77,6 +78,9 @@ class SystemEngineView @JvmOverloads constructor( override var selectionActionDelegate: SelectionActionDelegate? = null + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) + /** * Render the content of the given session. */ diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/EngineView.kt @@ -11,6 +11,7 @@ import androidx.core.view.OnApplyWindowInsetsListener import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.flow.Flow import mozilla.components.concept.engine.selection.SelectionActionDelegate /** @@ -80,6 +81,16 @@ interface EngineView { fun canClearSelection(): Boolean = false /** + * Running flow of the current scroll position in pixels. + */ + val verticalScrollPosition: Flow<Float> + + /** + * Running flow of scroll deltas in pixels. + */ + val verticalScrollDelta: Flow<Float> + + /** * Check if [EngineView] can be scrolled vertically up. * true if can and false otherwise. */ diff --git a/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.kt b/mobile/android/android-components/components/concept/engine/src/test/java/mozilla/components/concept/engine/EngineViewTest.kt @@ -8,6 +8,7 @@ import android.content.Context import android.graphics.Bitmap import android.widget.FrameLayout import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.flowOf import mozilla.components.concept.engine.selection.SelectionActionDelegate import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext @@ -62,6 +63,8 @@ class EngineViewTest { private fun createDummyEngineView(context: Context): EngineView = DummyEngineView(context) open class DummyEngineView(context: Context) : FrameLayout(context), EngineView { + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) override fun setVerticalClipping(clippingHeight: Int) {} override fun setDynamicToolbarMaxHeight(height: Int) {} override fun setActivityContext(context: Context?) {} @@ -78,6 +81,8 @@ class EngineViewTest { // Class it not actually a View! open class BrokenEngineView : EngineView { + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) override fun setVerticalClipping(clippingHeight: Int) {} override fun setDynamicToolbarMaxHeight(height: Int) {} override fun setActivityContext(context: Context?) {} diff --git a/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt b/mobile/android/android-components/components/feature/session/src/test/java/mozilla/components/feature/session/SwipeRefreshFeatureTest.kt @@ -8,6 +8,7 @@ import android.content.Context import android.graphics.Bitmap import android.widget.FrameLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import kotlinx.coroutines.flow.flowOf import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab import mozilla.components.browser.state.state.BrowserState @@ -118,6 +119,8 @@ class SwipeRefreshFeatureTest { } private open class DummyEngineView(context: Context) : FrameLayout(context), EngineView { + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) override fun setVerticalClipping(clippingHeight: Int) {} override fun setDynamicToolbarMaxHeight(height: Int) {} override fun setActivityContext(context: Context?) {} diff --git a/mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlinx/coroutines/flow/Flow.kt b/mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlinx/coroutines/flow/Flow.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow /** * Returns a [Flow] containing only changed elements of the lists of the original [Flow]. @@ -98,3 +99,45 @@ fun <T> Flow<T>.ifAnyChanged(transform: (T) -> Array<Any?>): Flow<T> { } } } + +/** + * Partition the elements emitted by the original flow in groups of [size] elements, with each new emission + * being advanced by [step] elements. + * + * @param size the number of elements to take in each window. + * @param step the number of elements to move forward between windows. + * @param partialWindows if `true`, windows at the end of the flow that are smaller than [size] + * will also be emitted. + */ +fun <T> Flow<T>.windowed(size: Int, step: Int, partialWindows: Boolean = false): Flow<List<T>> = flow { + require(size > 0 && step > 0) { "size and step must be positive, was size=$size, step=$step" } + + val window = ArrayDeque<T>(size) + + /** + * Helper function to emit the current window and slide it forward by the step size. + */ + suspend fun emitAndSlide() { + emit(window.toList()) + repeat(step) { + if (window.isEmpty()) return@repeat + window.removeFirst() + } + } + + collect { element -> + window.addLast(element) + + // Emit whenever a full window is ready. + if (window.size == size) { + emitAndSlide() + } + } + + // Handle any remaining partial windows after the main collection is done. + if (partialWindows) { + while (window.isNotEmpty()) { + emitAndSlide() + } + } +} diff --git a/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlinx/coroutines/flow/FlowKtTest.kt b/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlinx/coroutines/flow/FlowKtTest.kt @@ -86,4 +86,37 @@ class FlowKtTest { identityItems, ) } + + @Test + fun `GIVEN a flow of values WHEN asked for just full windows of these THEN avoid returning partial windows`() = runTest { + val values = flowOf(1, 2, 3, 4, 5) + var valuesReceived = 0 + val step = 2 + values.windowed(2, step).collect { output -> + assertEquals(2, output.size) + assertEquals(++valuesReceived, output.first()) + assertEquals(++valuesReceived, output.last()) + } + assertEquals(4, valuesReceived) // the last value in the last full window is 4 + } + + @Test + fun `GIVEN a flow of values WHEN asked for full and a last partial window of these THEN return the correct windows`() = runTest { + val values = flowOf(1, 2, 3, 4, 5) + var valuesReceived = 0 + var currentStep = 0 + val fullWindowsNo = 2 + val step = 2 + values.windowed(2, step, true).collect { output -> + if (++currentStep <= fullWindowsNo) { + assertEquals(2, output.size) + assertEquals(++valuesReceived, output.first()) + assertEquals(++valuesReceived, output.last()) + } else { + assertEquals(1, output.size) + assertEquals(++valuesReceived, output.first()) + } + } + assertEquals(5, valuesReceived) // the last value in the end partial window is 5 + } } diff --git a/mobile/android/android-components/components/support/test-fakes/src/main/java/mozilla/components/support/test/fakes/engine/FakeEngineView.kt b/mobile/android/android-components/components/support/test-fakes/src/main/java/mozilla/components/support/test/fakes/engine/FakeEngineView.kt @@ -7,6 +7,7 @@ package mozilla.components.support.test.fakes.engine import android.content.Context import android.graphics.Bitmap import android.view.View +import kotlinx.coroutines.flow.flowOf import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView import mozilla.components.concept.engine.selection.SelectionActionDelegate @@ -19,6 +20,10 @@ class FakeEngineView( context: Context, ) : View(context), EngineView { + + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) + override fun render(session: EngineSession) = Unit override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) = Unit diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt @@ -13,6 +13,7 @@ import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.flowOf import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView import mozilla.components.concept.engine.InputResultDetail @@ -531,6 +532,8 @@ class EngineViewScrollingBehaviorTest { private fun createDummyEngineView(context: Context): EngineView = DummyEngineView(context) open class DummyEngineView(context: Context) : FrameLayout(context), EngineView { + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) override fun setVerticalClipping(clippingHeight: Int) {} override fun setDynamicToolbarMaxHeight(height: Int) {} override fun setActivityContext(context: Context?) {} diff --git a/mobile/android/android-components/docs/changelog.md b/mobile/android/android-components/docs/changelog.md @@ -5,6 +5,8 @@ permalink: /changelog/ --- # 147.0 (In Development) +* **concept-engine** and **browser-engine-gecko** + * 🆕 New `verticalScrollPosition` and `verticalScrollDelta` APIs exposing the current scroll position and delta of the webpage [Bug 1990215](https://bugzilla.mozilla.org/show_bug.cgi?id=1990215) # 146.0 diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/BrowserFragmentTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/BrowserFragmentTest.kt @@ -9,6 +9,7 @@ import android.graphics.Bitmap import android.util.AttributeSet import android.view.LayoutInflater import android.view.View +import kotlinx.coroutines.flow.flowOf import mozilla.components.browser.state.state.ContentState import mozilla.components.browser.state.state.CustomTabConfig import mozilla.components.browser.state.state.CustomTabSessionState @@ -110,6 +111,9 @@ class DummyEngineView(context: Context) : View(context), EngineView { id = R.id.engineView } + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) + override fun render(session: EngineSession) { // no-op }