From aef472d4772df8425c550adfad8ad3e0a8ff307b Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 8 Jun 2026 01:14:34 +0530 Subject: [PATCH 1/3] Fix Android accessibility collection metadata handling --- .../uimanager/ReactAccessibilityDelegate.kt | 14 +++ .../ReactScrollViewAccessibilityDelegate.kt | 18 ++- .../ReactAccessibilityDelegateTest.kt | 36 ++++++ ...eactScrollViewAccessibilityDelegateTest.kt | 117 ++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 3c1cbe917d11..69538124e541 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -19,6 +19,7 @@ import android.widget.EditText import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat import androidx.core.view.accessibility.AccessibilityNodeProviderCompat @@ -103,6 +104,18 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac } val accessibilityActions = host.getTag(R.id.accessibility_actions) as ReadableArray? + val accessibilityCollection = host.getTag(R.id.accessibility_collection) as ReadableMap? + if (accessibilityCollection != null) { + val rowCount = accessibilityCollection.getInt("rowCount") + val columnCount = accessibilityCollection.getInt("columnCount") + val hierarchical = + accessibilityCollection.hasKey("hierarchical") && + accessibilityCollection.getBoolean("hierarchical") + + val collectionInfoCompat = CollectionInfoCompat.obtain(rowCount, columnCount, hierarchical) + info.setCollectionInfo(collectionInfoCompat) + } + val accessibilityCollectionItem = host.getTag(R.id.accessibility_collection_item) as ReadableMap? if (accessibilityCollectionItem != null) { @@ -597,6 +610,7 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac view.getTag(R.id.accessibility_state) != null || view.getTag(R.id.accessibility_actions) != null || view.getTag(R.id.react_test_id) != null || + view.getTag(R.id.accessibility_collection) != null || view.getTag(R.id.accessibility_collection_item) != null || view.getTag(R.id.accessibility_links) != null || view.getTag(R.id.role) != null) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt index 56c6224230ab..1031155aefe3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt @@ -55,7 +55,9 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa val accessibilityCollection = view.getTag(R.id.accessibility_collection) as? ReadableMap ?: return - event.itemCount = accessibilityCollection.getInt("itemCount") + if (accessibilityCollection.hasKey("itemCount")) { + event.itemCount = accessibilityCollection.getInt("itemCount") + } val contentView = (view as? ViewGroup)?.getChildAt(0) as? ViewGroup ?: return @@ -71,10 +73,10 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa return } var accessibilityCollectionItem: ReadableMap? = - nextChild.getTag(R.id.accessibility_collection_item) as ReadableMap + nextChild.getTag(R.id.accessibility_collection_item) as? ReadableMap if (nextChild !is ViewGroup) { - return + continue } // If this child's accessibilityCollectionItem is null, we'll check one more @@ -93,10 +95,12 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa } if (isVisible && accessibilityCollectionItem != null) { - if (firstVisibleIndex == null) { + if (accessibilityCollectionItem.hasKey("itemIndex") && firstVisibleIndex == null) { firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex") } - lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex") + if (accessibilityCollectionItem.hasKey("itemIndex")) { + lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex") + } } if (firstVisibleIndex != null && lastVisibleIndex != null) { @@ -121,7 +125,9 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa if (accessibilityCollection != null) { val rowCount = accessibilityCollection.getInt("rowCount") val columnCount = accessibilityCollection.getInt("columnCount") - val hierarchical = accessibilityCollection.getBoolean("hierarchical") + val hierarchical = + accessibilityCollection.hasKey("hierarchical") && + accessibilityCollection.getBoolean("hierarchical") val collectionInfoCompat = AccessibilityNodeInfoCompat.CollectionInfoCompat.obtain( diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt index 8b7c303bdf78..bc881275d0eb 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt @@ -10,6 +10,7 @@ package com.facebook.react.uimanager import android.os.Bundle +import androidx.core.view.ViewCompat import androidx.core.view.accessibility.AccessibilityNodeInfoCompat import com.facebook.react.R import com.facebook.react.bridge.BridgeReactContext @@ -123,6 +124,41 @@ class ReactAccessibilityDelegateTest { assertThat(result).isTrue() } + @Test + fun testAccessibilityCollection_setsCollectionInfo() { + view.setTag( + R.id.accessibility_collection, + JavaOnlyMap().apply { + putInt("rowCount", 4) + putInt("columnCount", 2) + putBoolean("hierarchical", true) + }, + ) + + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + accessibilityDelegate.onInitializeAccessibilityNodeInfo(view, nodeInfo) + + assertThat(nodeInfo.collectionInfo).isNotNull() + assertThat(nodeInfo.collectionInfo.rowCount).isEqualTo(4) + assertThat(nodeInfo.collectionInfo.columnCount).isEqualTo(2) + assertThat(nodeInfo.collectionInfo.isHierarchical).isTrue() + } + + @Test + fun testSetDelegate_accessibilityCollection_installsAccessibilityDelegate() { + view.setTag( + R.id.accessibility_collection, + JavaOnlyMap().apply { + putInt("rowCount", 4) + putInt("columnCount", 2) + }, + ) + + ReactAccessibilityDelegate.setDelegate(view, false, 0) + + assertThat(ViewCompat.hasAccessibilityDelegate(view)).isTrue() + } + @Test fun testPerformAccessibilityAction_activateAction_dispatchesEvent() { val accessibilityActions = JavaOnlyArray() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt new file mode 100644 index 000000000000..79ab3eb5c45f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.scroll + +import android.content.Context +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.widget.FrameLayout +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.facebook.react.R +import com.facebook.react.bridge.JavaOnlyMap +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactScrollViewAccessibilityDelegateTest { + private lateinit var context: Context + private lateinit var scrollView: TestScrollView + private lateinit var contentView: FrameLayout + private lateinit var delegate: ReactScrollViewAccessibilityDelegate + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + scrollView = TestScrollView(context) + contentView = FrameLayout(context) + scrollView.addView(contentView) + delegate = ReactScrollViewAccessibilityDelegate() + } + + @Test + fun testOnInitializeAccessibilityEvent_allowsMissingOptionalCollectionFields() { + scrollView.setTag( + R.id.accessibility_collection, + JavaOnlyMap().apply { + putInt("rowCount", 3) + putInt("columnCount", 1) + }, + ) + contentView.addView(View(context)) + + val event = AccessibilityEvent.obtain() + delegate.onInitializeAccessibilityEvent(scrollView, event) + + assertThat(event.itemCount).isEqualTo(-1) + assertThat(event.fromIndex).isEqualTo(-1) + assertThat(event.toIndex).isEqualTo(-1) + } + + @Test + fun testOnInitializeAccessibilityEvent_readsNestedCollectionItemIndex() { + scrollView.setTag( + R.id.accessibility_collection, + JavaOnlyMap().apply { + putInt("itemCount", 5) + putInt("rowCount", 5) + putInt("columnCount", 1) + }, + ) + val wrapper = FrameLayout(context) + val item = View(context) + item.setTag( + R.id.accessibility_collection_item, + JavaOnlyMap().apply { + putInt("itemIndex", 2) + putInt("rowIndex", 2) + putInt("rowSpan", 1) + putInt("columnIndex", 0) + putInt("columnSpan", 1) + putBoolean("heading", false) + }, + ) + wrapper.addView(item) + contentView.addView(wrapper) + + val event = AccessibilityEvent.obtain() + delegate.onInitializeAccessibilityEvent(scrollView, event) + + assertThat(event.itemCount).isEqualTo(5) + assertThat(event.fromIndex).isEqualTo(2) + assertThat(event.toIndex).isEqualTo(2) + } + + @Test + fun testOnInitializeAccessibilityNodeInfo_defaultsMissingHierarchicalToFalse() { + scrollView.setTag( + R.id.accessibility_collection, + JavaOnlyMap().apply { + putInt("rowCount", 3) + putInt("columnCount", 1) + }, + ) + + val nodeInfo = AccessibilityNodeInfoCompat.obtain() + delegate.onInitializeAccessibilityNodeInfo(scrollView, nodeInfo) + + assertThat(nodeInfo.collectionInfo).isNotNull() + assertThat(nodeInfo.collectionInfo.rowCount).isEqualTo(3) + assertThat(nodeInfo.collectionInfo.columnCount).isEqualTo(1) + assertThat(nodeInfo.collectionInfo.isHierarchical).isFalse() + } + + private class TestScrollView(context: Context) : FrameLayout(context), ReactAccessibleScrollView { + override val scrollEnabled: Boolean = true + + override fun isPartiallyScrolledInView(view: View): Boolean = true + } +} From d1427eb9121e3f519a40ebabf945fc0f9194a01b Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 8 Jun 2026 02:06:30 +0530 Subject: [PATCH 2/3] Handle collection metadata in text accessibility delegate --- .../ReactTextViewAccessibilityDelegate.kt | 1 + .../ReactTextViewAccessibilityDelegateTest.kt | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt index 6834a329ac6c..46f74ba2458f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegate.kt @@ -44,6 +44,7 @@ internal class ReactTextViewAccessibilityDelegate( view.getTag(R.id.accessibility_state) != null || view.getTag(R.id.accessibility_actions) != null || view.getTag(R.id.react_test_id) != null || + view.getTag(R.id.accessibility_collection) != null || view.getTag(R.id.accessibility_collection_item) != null || view.getTag(R.id.accessibility_links) != null || view.getTag(R.id.role) != null) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt new file mode 100644 index 000000000000..1856b45d3682 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewAccessibilityDelegateTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.view.View +import androidx.core.view.ViewCompat +import com.facebook.react.R +import com.facebook.react.bridge.JavaOnlyMap +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactTextViewAccessibilityDelegateTest { + + @Test + fun testSetDelegate_accessibilityCollection_installsAccessibilityDelegate() { + val view = + View(RuntimeEnvironment.getApplication()).apply { + setTag( + R.id.accessibility_collection, + JavaOnlyMap().apply { + putInt("rowCount", 4) + putInt("columnCount", 2) + }, + ) + } + + ReactTextViewAccessibilityDelegate.setDelegate(view, false, 0) + + assertThat(ViewCompat.hasAccessibilityDelegate(view)).isTrue() + } +} From c7d36dd5db57d8aba09fa0cd4b2a137704cf5b99 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 8 Jun 2026 22:16:54 +0530 Subject: [PATCH 3/3] Avoid deprecated accessibility event test API --- .../views/scroll/ReactScrollViewAccessibilityDelegateTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt index 79ab3eb5c45f..ef3d0215a72a 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegateTest.kt @@ -48,7 +48,7 @@ class ReactScrollViewAccessibilityDelegateTest { ) contentView.addView(View(context)) - val event = AccessibilityEvent.obtain() + val event = AccessibilityEvent() delegate.onInitializeAccessibilityEvent(scrollView, event) assertThat(event.itemCount).isEqualTo(-1) @@ -82,7 +82,7 @@ class ReactScrollViewAccessibilityDelegateTest { wrapper.addView(item) contentView.addView(wrapper) - val event = AccessibilityEvent.obtain() + val event = AccessibilityEvent() delegate.onInitializeAccessibilityEvent(scrollView, event) assertThat(event.itemCount).isEqualTo(5)