diff --git a/docs/android.md b/docs/android.md index 8b6201ae91..dc020a987c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -179,8 +179,17 @@ This is the Android counterpart of the iOS local-simulator flow in [development. `modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) -lists the corrections and the React Native issues they work around. Its instrumentation test, -`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager: +lists the content-position corrections and the React Native issues they work around. + +The timeline's `nativeID` (`agent-chat-scroll-native-virtualized`) also identifies it to the native +scroll owner: a nested panel's edge overshoot and unconsumed fling must leave the timeline still. +Ordinary scroll parents retain Android's handoff. Keep that identity in sync with +`agent-stream/strategy-native.tsx`; the end pin is absent while reading history, so it cannot +identify the timeline for this rule. + +`VisibleContentPositionClampTest` mounts through React Native's own mounting manager; +`PanelEdgeTest` covers both panel edges, flings, interior/direct scrolling, and ordinary-parent +handoff with the real compiled scroll views: ```bash cd packages/app/android diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/PanelEdgeTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/PanelEdgeTest.kt new file mode 100644 index 0000000000..ac1c927667 --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/PanelEdgeTest.kt @@ -0,0 +1,158 @@ +package sh.paseo.scroll + +import android.app.Activity +import android.content.Intent +import android.os.SystemClock +import android.view.View +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.scroll.ReactScrollViewManager +import com.facebook.react.views.view.ReactViewGroup +import com.facebook.soloader.SoLoader +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Forward-order parent, as used by the current Android timeline, and a nested panel at its edge. */ +@RunWith(AndroidJUnit4::class) +class PanelEdgeTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private var activity: Activity? = null + private lateinit var manager: ReactScrollViewManager + + @After fun finish() { + instrumentation.runOnMainSync { activity?.finish() } + } + + @Test fun panelBottomEdgeLeavesTimelineStill() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + child.scrollTo(0, 600) + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.dispatchNestedScroll(0, 0, 0, 100, null) + child.stopNestedScroll() + assertEquals("Panel stays at its bottom", 600, child.scrollY) + assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY) + } + } + + @Test fun panelTopEdgeLeavesTimelineStill() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + child.scrollTo(0, 0) + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.dispatchNestedScroll(0, 0, 0, -100, null) + child.stopNestedScroll() + assertEquals("Panel stays at its top", 0, child.scrollY) + assertEquals("Panel overshoot must leave timeline still", 400, parent.scrollY) + } + } + + @Test fun panelFlingLeavesTimelineStill() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + child.scrollTo(0, 600) + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.dispatchNestedFling(0f, 1500f, false) + child.stopNestedScroll() + } + SystemClock.sleep(500) + instrumentation.runOnMainSync { + assertEquals("Panel fling must leave timeline still", 400, parent.scrollY) + } + } + + @Test fun disablingChildNestedScrollPreventsHandoff() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + child.isNestedScrollingEnabled = false + assertEquals(false, child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.dispatchNestedScroll(0, 0, 0, 100, null) + assertEquals(400, parent.scrollY) + } + } + + @Test fun ordinaryParentStillConsumesNestedOvershoot() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + manager.setNativeId(parent, "ordinary-scroll-parent") + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.dispatchNestedScroll(0, 0, 0, 100, null) + child.stopNestedScroll() + assertEquals("Ordinary parent retains framework handoff", 500, parent.scrollY) + } + } + + @Test fun ordinaryParentStillConsumesUnconsumedFling() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + manager.setNativeId(parent, "ordinary-scroll-parent") + child.scrollTo(0, 600) + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.dispatchNestedFling(0f, 1500f, false) + child.stopNestedScroll() + } + SystemClock.sleep(500) + instrumentation.runOnMainSync { assertTrue(parent.scrollY > 400) } + } + + @Test fun consumedPanelFlingDoesNotStartParentFling() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + assertEquals(false, child.dispatchNestedFling(0f, 1500f, true)) + child.stopNestedScroll() + assertEquals(400, parent.scrollY) + } + } + + @Test fun panelInteriorScrollingLeavesTimelineStill() { + val (parent, child) = openPanel() + instrumentation.runOnMainSync { + child.scrollTo(0, 100) + assertEquals(100, child.scrollY) + assertEquals(400, parent.scrollY) + parent.scrollTo(0, 500) + assertEquals("Direct timeline scrolling remains available", 500, parent.scrollY) + } + } + + private fun openPanel(): Pair { + val started = instrumentation.startActivitySync( + Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + activity = started + lateinit var parent: ReactScrollView + lateinit var child: ReactScrollView + instrumentation.runOnMainSync { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started) + SoLoader.init(started, OpenSourceMergedSoMapping) + val react = ScrollTestContext(started.applicationContext) + val context = ThemedReactContext(react, started) + manager = PaseoScrollPackage().createViewManager(react, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager + parent = manager.createViewInstance(context) + manager.setNativeId(parent, "agent-chat-scroll-native-virtualized") + child = manager.createViewInstance(context) + child.isNestedScrollingEnabled = true + val content = ReactViewGroup(context) + content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 400 }) + parent.addView(content) + val childContent = ReactViewGroup(context) + child.addView(childContent) + started.setContentView(FrameLayout(started).apply { addView(parent, FrameLayout.LayoutParams(300, 400)) }) + parent.layout(0, 0, 300, 400) + content.layout(0, 0, 300, 2000) + child.layout(0, 400, 300, 600) + childContent.layout(0, 0, 300, 800) + parent.scrollTo(0, 400) + } + return Pair(parent, child) + } +} diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java new file mode 100644 index 0000000000..3c571de7d5 --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java @@ -0,0 +1,19 @@ +package sh.paseo.scroll; + +import android.content.Context; +import com.facebook.react.bridge.BridgeReactContext; +import com.facebook.react.uimanager.events.BlackHoleEventDispatcher; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.EventDispatcherProvider; + +/** Native-only tests use RN's own event sink for a context without JavaScript. */ +final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider { + ScrollTestContext(Context context) { + super(context); + } + + @Override + public EventDispatcher getEventDispatcher() { + return BlackHoleEventDispatcher.INSTANCE; + } +} diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java index 5e2345830c..6538dbbf72 100644 --- a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java +++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollView.java @@ -12,10 +12,13 @@ import com.facebook.react.views.scroll.ReactScrollView; import com.facebook.react.views.scroll.ReactScrollViewHelper; /** - * React Native's vertical scroll view with two corrections. Java, because the content-position + * React Native's vertical scroll view with timeline corrections. Java, because the content-position * config type is internal to React Native's Kotlin module. */ final class PaseoScrollView extends ReactScrollView { + // The chat timeline, packages/app/src/agent-stream/strategy-native.tsx. + private static final String TIMELINE_NATIVE_ID = "agent-chat-scroll-native-virtualized"; + // The chat timeline's end pin, packages/app/src/agent-stream/native-end-pin.tsx. private static final String END_PIN_NATIVE_ID = "paseo-end-pin"; @@ -32,6 +35,26 @@ final class PaseoScrollView extends ReactScrollView { super(context); } + // A panel owns its gesture through its edges; remaining child motion must not move the chat + // behind it. The timeline's native identity stays present when its end pin is removed for + // reading history. Ordinary parents retain Android's nested-scroll handoff. + @Override + public void onNestedScroll( + View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { + if (isTimeline()) return; + super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); + } + + @Override + public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (isTimeline() && !consumed) return true; + return super.onNestedFling(target, velocityX, velocityY, consumed); + } + + private boolean isTimeline() { + return TIMELINE_NATIVE_ID.equals(getTag(R.id.view_tag_native_id)); + } + @Override public void setMaintainVisibleContentPosition( @Nullable MaintainVisibleScrollPositionHelper.Config config) { diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 3288e2be25..1b7bd13b31 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -675,6 +675,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat extraData={renderHistoryMountedRow} keyExtractor={keyExtractor} testID="agent-chat-scroll" + // The Android scroll owner identifies the timeline to contain nested panel gestures. nativeID="agent-chat-scroll-native-virtualized" ListHeaderComponent={historyStartContent} ListFooterComponent={liveAuxiliaryContent ?? undefined}