diff --git a/apps/touch-test-demo/App.tsx b/apps/touch-test-demo/App.tsx
new file mode 100644
index 0000000..9647fc2
--- /dev/null
+++ b/apps/touch-test-demo/App.tsx
@@ -0,0 +1,269 @@
+/**
+ * Touch Test Demo
+ *
+ * Single scrollable page combining Touch Bleed and Overlay tests.
+ * Minimal host views to keep tag values low and predictable.
+ */
+import SandboxReactNativeView from '@callstack/react-native-sandbox'
+import React, {useState} from 'react'
+import {
+ SafeAreaView,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native'
+
+// ─── Padding helper ─────────────────────────────────────────────────────────
+
+function TagPadding({count}: {count: number}) {
+ return (
+ <>
+ {Array.from({length: count}, (_, i) => (
+
+ ))}
+ >
+ )
+}
+
+// ─── App ────────────────────────────────────────────────────────────────────
+
+export default function App() {
+ const [pressCount, setPressCount] = useState(0)
+ const [hostTag, setHostTag] = useState(null)
+ const [overlayVisible, setOverlayVisible] = useState(false)
+ const [overlayPressCount, setOverlayPressCount] = useState(0)
+ const [overlayBtnTag, setOverlayBtnTag] = useState(null)
+
+ return (
+
+
+ {/* ── Test 1: Touch Bleed ── */}
+
+ Test 1: Touch Bleed
+
+ Press a sandbox button whose tag matches the host button tag. Watch
+ if the host button highlights or press count increases.
+
+
+
+
+ {
+ const tag = (e as any).nativeEvent?.target
+ if (tag != null) setHostTag(tag)
+ }}
+ onPress={() => setPressCount(c => c + 1)}>
+
+ Host Button{hostTag != null ? ` (tag: ${hostTag})` : ''}
+
+
+
+ Press count: {pressCount}{' '}
+ {pressCount > 0 ? '❌ BLEED DETECTED' : '✅ No bleed'}
+
+
+
+
+ Sandbox (Touch Bleed)
+ console.warn('Sandbox error:', error)}
+ />
+
+
+
+
+ {/* ── Test 2: Overlay over Sandbox ── */}
+
+ Test 2: Overlay over Sandbox
+
+ Tests that host views rendered on top of a sandbox correctly receive
+ touches without bleeding into sandbox buttons.{'\n\n'}
+ Expected behavior:{'\n'}• Overlay buttons — should fire (count
+ increments){'\n'}• Card area over sandbox — should NOT fire sandbox
+ buttons{'\n'}• Grey backdrop — should block sandbox touches{'\n'}•
+ Top-half sandbox buttons (no overlay) — should work normally
+
+
+ {
+ const tag = (e as any).nativeEvent?.target
+ if (tag != null) setOverlayBtnTag(tag)
+ }}
+ onPress={() => setOverlayVisible(v => !v)}>
+
+ {overlayVisible ? 'Hide Overlay' : 'Show Overlay'}
+ {overlayBtnTag != null ? ` (tag: ${overlayBtnTag})` : ''}
+
+
+
+
+ Overlay presses: {overlayPressCount}
+ {overlayPressCount > 0 ? ' ✅' : ''}
+
+
+
+
+ Sandbox (Overlay)
+
+ console.warn('Sandbox error:', error)}
+ />
+ {overlayVisible && (
+
+
+ Host Overlay
+
+ This view is rendered by the host ON TOP of the sandbox.
+ Tapping the button below should work normally.
+
+ setOverlayPressCount(c => c + 1)}>
+
+ Tap Me (overlay) — count: {overlayPressCount}
+
+
+ setOverlayVisible(false)}>
+ Dismiss
+
+
+
+ )}
+
+
+
+
+ )
+}
+
+// ─── Styles ─────────────────────────────────────────────────────────────────
+
+const overlayStyles = StyleSheet.create({
+ backdrop: {
+ position: 'absolute',
+ top: '50%',
+ left: '15%',
+ right: '15%',
+ bottom: 0,
+ backgroundColor: 'rgba(0,0,0,0.3)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 10,
+ elevation: 0,
+ },
+ card: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#000000ff',
+ padding: 24,
+ width: '65%',
+ marginTop: -150,
+ shadowColor: '#000',
+ shadowOffset: {width: 0, height: 4},
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 12,
+ },
+ cardTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 8,
+ },
+ cardDesc: {
+ fontSize: 14,
+ color: '#666',
+ marginBottom: 16,
+ lineHeight: 20,
+ },
+ overlayButton: {
+ backgroundColor: '#34c759',
+ paddingVertical: 14,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+})
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ section: {
+ padding: 16,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 8,
+ },
+ sectionHeader: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ description: {
+ fontSize: 14,
+ color: '#666',
+ marginBottom: 16,
+ lineHeight: 20,
+ },
+ pad: {
+ height: 1,
+ },
+ hostButton: {
+ backgroundColor: '#007aff',
+ paddingVertical: 14,
+ borderRadius: 8,
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ actionButton: {
+ backgroundColor: '#5856d6',
+ paddingVertical: 12,
+ borderRadius: 8,
+ alignItems: 'center',
+ marginTop: 12,
+ marginBottom: 8,
+ },
+ buttonText: {
+ color: '#fff',
+ fontWeight: '600',
+ fontSize: 16,
+ },
+ result: {
+ fontSize: 15,
+ marginVertical: 4,
+ },
+ sandboxSection: {
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#ccc',
+ },
+ sandbox: {
+ height: 400,
+ borderWidth: 1,
+ borderColor: '#8232ff',
+ borderRadius: 4,
+ },
+ divider: {
+ height: 8,
+ backgroundColor: '#f0f0f0',
+ marginVertical: 8,
+ },
+})
diff --git a/apps/touch-test-demo/README.md b/apps/touch-test-demo/README.md
new file mode 100644
index 0000000..4f94295
--- /dev/null
+++ b/apps/touch-test-demo/README.md
@@ -0,0 +1,83 @@
+# Touch Test Demo
+
+Reproduces and tests touch isolation bugs between host and sandbox React Native surfaces. The app deliberately aligns sandbox button view tags with host button view tags to expose tag collision issues.
+
+## Background: Android vs iOS tag resolution
+
+React Native assigns each native view an integer tag used internally to route touch events. On iOS, each React surface (host and sandbox) gets its own tag namespace — tags are scoped per surface, so collisions between host and sandbox are impossible by design. On Android, view tags are allocated from a single global counter shared across all surfaces in the process, meaning that tags in the host app and in a sandbox could occur.
+
+
+## What it tests
+
+### Test 1: Touch Bleed
+
+A host button and a sandbox button share the same React view tag (34). When you tap the sandbox button, the test checks whether the host button also receives the touch event. Touch events should be handled only in the proper surface.
+
+### Test 2: Overlay over Sandbox
+
+A host overlay card is rendered on top of the sandbox surface. A sandbox button is padded to share the same tag (92) as the host overlay button. The test checks:
+
+- Overlay buttons receive touches normally
+- The overlay card blocks touches from reaching sandbox buttons underneath
+- Sandbox buttons not covered by the overlay still work
+- The grey backdrop area blocks sandbox touches
+
+## How the tag alignment works
+
+The sandbox component (`Sandbox.tsx`) inserts invisible padding `View` elements before specific buttons to consume tag IDs and push button tags to target values. Each padding view consumes ~2 tags on Android.
+
+Current alignment:
+- Sandbox Button 2 → tag 34 (matches host button in Test 1)
+- Sandbox Button 3 → tag 92 (matches host overlay button in Test 2)
+
+Tag values may be device/platform-dependent. If they drift after changes, adjust the `PADDING_BEFORE_BUTTON` values in `Sandbox.tsx`.
+
+> **Fragility note:** The padding counts are sensitive to the number of host views rendered before the sandbox surface starts, React Native's internal tag allocation strategy, and the number of views inside the sandbox before each button. These may change across RN versions. The on-screen `(tag: N)` labels in the demo UI make it easy to spot drift — if the displayed tags no longer match the expected collision values, update `PADDING_BEFORE_BUTTON` in `Sandbox.tsx` accordingly.
+
+## Build steps (Android release)
+
+All commands run from the monorepo root (`react-native-sandbox/`).
+
+### 1. Install dependencies
+
+```bash
+yarn install
+```
+
+### 2. Bundle the sandbox JS
+
+From `apps/touch-test-demo/`:
+
+```bash
+npx react-native bundle \
+ --platform android \
+ --dev false \
+ --entry-file sandbox.js \
+ --bundle-output android/app/src/main/assets/sandbox.android.bundle \
+ --assets-dest android/app/src/main/res/
+```
+
+### 3. Generate codegen artifacts
+
+From `apps/touch-test-demo/android/`:
+
+```bash
+./gradlew :callstack_react-native-sandbox:generateCodegenArtifactsFromSchema
+```
+
+### 4. Build the release APK
+
+From `apps/touch-test-demo/android/`:
+
+```bash
+./gradlew assembleRelease
+```
+
+The APK is at `android/app/build/outputs/apk/release/app-release.apk`.
+
+### 5. Install and launch
+
+```bash
+adb install android/app/build/outputs/apk/release/app-release.apk
+adb shell am start -n com.touchtestdemo/.MainActivity
+```
diff --git a/apps/touch-test-demo/Sandbox.tsx b/apps/touch-test-demo/Sandbox.tsx
new file mode 100644
index 0000000..e93bbd5
--- /dev/null
+++ b/apps/touch-test-demo/Sandbox.tsx
@@ -0,0 +1,108 @@
+import React, {useState} from 'react'
+import {
+ LogBox,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native'
+
+// Padding views inserted before specific buttons to push their tags
+// to desired values for collision testing.
+// Each View consumes 1 tag in the React surface.
+//
+// Why these specific counts?
+// React Native Fabric allocates view tags sequentially from a shared counter.
+// The sandbox surface starts at a known offset relative to the host surface.
+// By inserting invisible zero-height Views before a button, we consume tag IDs
+// and push the button's tag to a value that collides with a host view tag.
+//
+// Current targets (Android, RN 0.76.x):
+// Button 2 → tag 34 (matches host "Host Button" in Test 1)
+// Button 3 → tag 92 (matches host overlay button in Test 2)
+//
+// FRAGILITY NOTE: These counts are sensitive to:
+// - The number of host views rendered before the sandbox surface starts
+// - React Native's internal tag allocation strategy (may change across RN versions)
+// - The number of views rendered inside the sandbox before these buttons
+// If tags drift after an RN upgrade or component tree change, adjust the counts
+// here and verify with the on-screen "(tag: N)" labels in the demo UI.
+const PADDING_BEFORE_BUTTON: Record = {
+ 2: 5, // push button 2 → tag 34
+ 3: 24, // push button 3 → tag 92
+}
+
+function TagPadding({count}: {count: number}) {
+ return (
+ <>
+ {Array.from({length: count}, (_, i) => (
+
+ ))}
+ >
+ )
+}
+
+export default function Sandbox() {
+ const [status, setStatus] = useState('Ready')
+ const [tags, setTags] = useState>({})
+
+ return (
+
+ Sandbox status: {status}
+ {[1, 2, 3, 4, 5].map(n => (
+
+ {PADDING_BEFORE_BUTTON[n] && (
+
+ )}
+ {
+ const tag = (e as any).nativeEvent?.target
+ if (tag != null) setTags(prev => ({...prev, [n]: tag}))
+ }}
+ onPress={() => setStatus(`Button ${n} pressed`)}>
+
+ Button {n}
+ {tags[n] != null ? ` (tag: ${tags[n]})` : ''}
+
+
+ {n < 5 && }
+
+ ))}
+
+ )
+}
+
+LogBox.ignoreAllLogs()
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ justifyContent: 'center',
+ },
+ spacer: {
+ height: 16,
+ },
+ pad: {
+ height: 0,
+ },
+ status: {
+ fontSize: 14,
+ fontStyle: 'italic',
+ marginBottom: 16,
+ color: '#666',
+ },
+ button: {
+ backgroundColor: '#2196F3',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+ buttonText: {
+ color: '#ffffff',
+ fontWeight: '600',
+ fontSize: 15,
+ },
+})
diff --git a/apps/touch-test-demo/android/app/build.gradle b/apps/touch-test-demo/android/app/build.gradle
new file mode 100644
index 0000000..e130c05
--- /dev/null
+++ b/apps/touch-test-demo/android/app/build.gradle
@@ -0,0 +1,61 @@
+apply plugin: "com.android.application"
+apply plugin: "org.jetbrains.kotlin.android"
+apply plugin: "com.facebook.react"
+
+react {
+ root = file("../..")
+ reactNativeDir = file("../../../../node_modules/react-native")
+ codegenDir = file("../../../../node_modules/@react-native/codegen")
+ cliFile = file("../../../../node_modules/react-native/cli.js")
+
+ hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parentFile.absolutePath + "/sdks/hermesc/%OS-BIN%/hermesc"
+
+ autolinkLibrariesWithApp()
+}
+
+def enableProguardInReleaseBuilds = false
+
+def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
+
+android {
+ ndkVersion rootProject.ext.ndkVersion
+ buildToolsVersion rootProject.ext.buildToolsVersion
+ compileSdk rootProject.ext.compileSdkVersion
+
+ namespace "com.touchtestdemo"
+ defaultConfig {
+ applicationId "com.touchtestdemo"
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 1
+ versionName "1.0"
+ }
+ signingConfigs {
+ debug {
+ storeFile file('debug.keystore')
+ storePassword 'android'
+ keyAlias 'androiddebugkey'
+ keyPassword 'android'
+ }
+ }
+ buildTypes {
+ debug {
+ signingConfig signingConfigs.debug
+ }
+ release {
+ signingConfig signingConfigs.debug
+ minifyEnabled enableProguardInReleaseBuilds
+ proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ }
+ }
+}
+
+dependencies {
+ implementation("com.facebook.react:react-android")
+
+ if (hermesEnabled.toBoolean()) {
+ implementation("com.facebook.react:hermes-android")
+ } else {
+ implementation jscFlavor
+ }
+}
diff --git a/apps/touch-test-demo/android/app/debug.keystore b/apps/touch-test-demo/android/app/debug.keystore
new file mode 100644
index 0000000..364e105
Binary files /dev/null and b/apps/touch-test-demo/android/app/debug.keystore differ
diff --git a/apps/touch-test-demo/android/app/proguard-rules.pro b/apps/touch-test-demo/android/app/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/apps/touch-test-demo/android/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/apps/touch-test-demo/android/app/src/debug/AndroidManifest.xml b/apps/touch-test-demo/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..eb98c01
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/apps/touch-test-demo/android/app/src/main/AndroidManifest.xml b/apps/touch-test-demo/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e189252
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/touch-test-demo/android/app/src/main/java/com/touchtestdemo/MainActivity.kt b/apps/touch-test-demo/android/app/src/main/java/com/touchtestdemo/MainActivity.kt
new file mode 100644
index 0000000..e3cf9ee
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/main/java/com/touchtestdemo/MainActivity.kt
@@ -0,0 +1,14 @@
+package com.touchtestdemo
+
+import com.facebook.react.ReactActivity
+import com.facebook.react.ReactActivityDelegate
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+import com.facebook.react.defaults.DefaultReactActivityDelegate
+
+class MainActivity : ReactActivity() {
+
+ override fun getMainComponentName(): String = "TouchTestDemo"
+
+ override fun createReactActivityDelegate(): ReactActivityDelegate =
+ DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
+}
diff --git a/apps/touch-test-demo/android/app/src/main/java/com/touchtestdemo/MainApplication.kt b/apps/touch-test-demo/android/app/src/main/java/com/touchtestdemo/MainApplication.kt
new file mode 100644
index 0000000..56277ea
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/main/java/com/touchtestdemo/MainApplication.kt
@@ -0,0 +1,40 @@
+package com.touchtestdemo
+
+import android.app.Application
+import com.facebook.react.PackageList
+import com.facebook.react.ReactApplication
+import com.facebook.react.ReactHost
+import com.facebook.react.ReactNativeHost
+import com.facebook.react.ReactPackage
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
+import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
+import com.facebook.react.defaults.DefaultReactNativeHost
+import com.facebook.react.soloader.OpenSourceMergedSoMapping
+import com.facebook.soloader.SoLoader
+
+class MainApplication : Application(), ReactApplication {
+
+ override val reactNativeHost: ReactNativeHost =
+ object : DefaultReactNativeHost(this) {
+ override fun getPackages(): List =
+ PackageList(this).packages
+
+ override fun getJSMainModuleName(): String = "index"
+
+ override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
+
+ override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
+ override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
+ }
+
+ override val reactHost: ReactHost
+ get() = getDefaultReactHost(applicationContext, reactNativeHost)
+
+ override fun onCreate() {
+ super.onCreate()
+ SoLoader.init(this, OpenSourceMergedSoMapping)
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ load()
+ }
+ }
+}
diff --git a/apps/touch-test-demo/android/app/src/main/res/drawable/rn_edit_text_material.xml b/apps/touch-test-demo/android/app/src/main/res/drawable/rn_edit_text_material.xml
new file mode 100644
index 0000000..5c25e72
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/main/res/drawable/rn_edit_text_material.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a2f5908
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1b52399
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..ff10afd
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..115a4c7
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..dcd3cd8
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..459ca60
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..8ca12fe
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8e19b41
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..b824ebd
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4c19a13
Binary files /dev/null and b/apps/touch-test-demo/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/apps/touch-test-demo/android/app/src/main/res/values/strings.xml b/apps/touch-test-demo/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8ac4dbd
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Touch Test Demo
+
diff --git a/apps/touch-test-demo/android/app/src/main/res/values/styles.xml b/apps/touch-test-demo/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..5a69370
--- /dev/null
+++ b/apps/touch-test-demo/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/touch-test-demo/android/build.gradle b/apps/touch-test-demo/android/build.gradle
new file mode 100644
index 0000000..9766946
--- /dev/null
+++ b/apps/touch-test-demo/android/build.gradle
@@ -0,0 +1,21 @@
+buildscript {
+ ext {
+ buildToolsVersion = "35.0.0"
+ minSdkVersion = 24
+ compileSdkVersion = 35
+ targetSdkVersion = 35
+ ndkVersion = "27.1.12297006"
+ kotlinVersion = "2.0.21"
+ }
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle")
+ classpath("com.facebook.react:react-native-gradle-plugin")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
+ }
+}
+
+apply plugin: "com.facebook.react.rootproject"
diff --git a/apps/touch-test-demo/android/gradle.properties b/apps/touch-test-demo/android/gradle.properties
new file mode 100644
index 0000000..63c2178
--- /dev/null
+++ b/apps/touch-test-demo/android/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
+android.useAndroidX=true
+reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+newArchEnabled=true
+hermesEnabled=true
diff --git a/apps/touch-test-demo/android/gradle/wrapper/gradle-wrapper.jar b/apps/touch-test-demo/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/apps/touch-test-demo/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/apps/touch-test-demo/android/gradle/wrapper/gradle-wrapper.properties b/apps/touch-test-demo/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e0fd020
--- /dev/null
+++ b/apps/touch-test-demo/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/apps/touch-test-demo/android/gradlew b/apps/touch-test-demo/android/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/apps/touch-test-demo/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/apps/touch-test-demo/android/gradlew.bat b/apps/touch-test-demo/android/gradlew.bat
new file mode 100644
index 0000000..11bf182
--- /dev/null
+++ b/apps/touch-test-demo/android/gradlew.bat
@@ -0,0 +1,99 @@
+@REM Copyright (c) Meta Platforms, Inc. and affiliates.
+@REM
+@REM This source code is licensed under the MIT license found in the
+@REM LICENSE file in the root directory of this source tree.
+
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/apps/touch-test-demo/android/settings.gradle b/apps/touch-test-demo/android/settings.gradle
new file mode 100644
index 0000000..2d8815c
--- /dev/null
+++ b/apps/touch-test-demo/android/settings.gradle
@@ -0,0 +1,6 @@
+pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") }
+plugins { id("com.facebook.react.settings") }
+extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
+rootProject.name = 'TouchTestDemo'
+include ':app'
+includeBuild('../../../node_modules/@react-native/gradle-plugin')
diff --git a/apps/touch-test-demo/app.json b/apps/touch-test-demo/app.json
new file mode 100644
index 0000000..e189b9d
--- /dev/null
+++ b/apps/touch-test-demo/app.json
@@ -0,0 +1,4 @@
+{
+ "name": "TouchTestDemo",
+ "displayName": "Touch Test Demo"
+}
diff --git a/apps/touch-test-demo/babel.config.js b/apps/touch-test-demo/babel.config.js
new file mode 100644
index 0000000..3e0218e
--- /dev/null
+++ b/apps/touch-test-demo/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: ['module:@react-native/babel-preset'],
+}
diff --git a/apps/touch-test-demo/index.js b/apps/touch-test-demo/index.js
new file mode 100644
index 0000000..f5279ed
--- /dev/null
+++ b/apps/touch-test-demo/index.js
@@ -0,0 +1,6 @@
+import {AppRegistry} from 'react-native'
+
+import App from './App'
+import {name as appName} from './app.json'
+
+AppRegistry.registerComponent(appName, () => App)
diff --git a/apps/touch-test-demo/jest.config.js b/apps/touch-test-demo/jest.config.js
new file mode 100644
index 0000000..c742f11
--- /dev/null
+++ b/apps/touch-test-demo/jest.config.js
@@ -0,0 +1,7 @@
+module.exports = {
+ preset: 'react-native',
+ transformIgnorePatterns: [
+ 'node_modules/(?!(react-native|@react-native|@react-native-community|@callstack/react-native-sandbox)/)',
+ ],
+ setupFilesAfterEnv: ['/../../jest.setup.js'],
+}
diff --git a/apps/touch-test-demo/metro.config.js b/apps/touch-test-demo/metro.config.js
new file mode 100644
index 0000000..a4d147a
--- /dev/null
+++ b/apps/touch-test-demo/metro.config.js
@@ -0,0 +1,24 @@
+const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config')
+const path = require('path')
+
+const projectRoot = __dirname
+const workspaceRoot = path.resolve(projectRoot, '../..')
+
+/**
+ * Metro configuration
+ * https://reactnative.dev/docs/metro
+ *
+ * @type {import('@react-native/metro-config').MetroConfig}
+ */
+const config = {
+ watchFolders: [workspaceRoot],
+ resolver: {
+ nodeModulesPaths: [
+ path.resolve(projectRoot, 'node_modules'),
+ path.resolve(workspaceRoot, 'node_modules'),
+ ],
+ disableHierarchicalLookup: true,
+ },
+}
+
+module.exports = mergeConfig(getDefaultConfig(projectRoot), config)
diff --git a/apps/touch-test-demo/package.json b/apps/touch-test-demo/package.json
new file mode 100644
index 0000000..a949f78
--- /dev/null
+++ b/apps/touch-test-demo/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@apps/touch-test-demo",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "android": "react-native run-android",
+ "ios": "react-native run-ios",
+ "start": "react-native start",
+ "typecheck": "tsc --noEmit",
+ "test": "jest"
+ },
+ "dependencies": {
+ "react": "19.1.0",
+ "react-native": "0.80.1",
+ "@callstack/react-native-sandbox": "workspace:*"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/preset-env": "^7.25.3",
+ "@babel/runtime": "^7.25.0",
+ "@react-native-community/cli": "18.0.0",
+ "@react-native-community/cli-platform-android": "18.0.0",
+ "@react-native-community/cli-platform-ios": "18.0.0",
+ "@react-native/babel-preset": "0.80.1",
+ "@react-native/eslint-config": "0.80.1",
+ "@react-native/metro-config": "0.80.1",
+ "@react-native/typescript-config": "0.80.1",
+ "react-test-renderer": "19.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/apps/touch-test-demo/sandbox.js b/apps/touch-test-demo/sandbox.js
new file mode 100644
index 0000000..307cd0a
--- /dev/null
+++ b/apps/touch-test-demo/sandbox.js
@@ -0,0 +1,8 @@
+import {AppRegistry, LogBox} from 'react-native'
+
+// eslint-disable-next-line import/no-unresolved
+import Sandbox from './Sandbox'
+
+LogBox.uninstall()
+
+AppRegistry.registerComponent('SandboxedDemo', () => Sandbox)
diff --git a/apps/touch-test-demo/tsconfig.json b/apps/touch-test-demo/tsconfig.json
new file mode 100644
index 0000000..88fa317
--- /dev/null
+++ b/apps/touch-test-demo/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "@react-native/typescript-config"
+}
diff --git a/bun.lock b/bun.lock
index e62047c..3240e91 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "multi-instance-monorepo",
@@ -186,9 +187,31 @@
"react-test-renderer": "19.1.0",
},
},
+ "apps/touch-test-demo": {
+ "name": "@apps/touch-test-demo",
+ "version": "1.0.0",
+ "dependencies": {
+ "@callstack/react-native-sandbox": "workspace:*",
+ "react": "19.1.0",
+ "react-native": "0.80.1",
+ },
+ "devDependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/preset-env": "^7.25.3",
+ "@babel/runtime": "^7.25.0",
+ "@react-native-community/cli": "18.0.0",
+ "@react-native-community/cli-platform-android": "18.0.0",
+ "@react-native-community/cli-platform-ios": "18.0.0",
+ "@react-native/babel-preset": "0.80.1",
+ "@react-native/eslint-config": "0.80.1",
+ "@react-native/metro-config": "0.80.1",
+ "@react-native/typescript-config": "0.80.1",
+ "react-test-renderer": "19.1.0",
+ },
+ },
"packages/react-native-sandbox": {
"name": "@callstack/react-native-sandbox",
- "version": "0.6.0",
+ "version": "0.6.1",
"devDependencies": {
"react": "19.1.0",
"react-native": "0.80.1",
@@ -214,6 +237,8 @@
"@apps/side-by-side": ["@apps/side-by-side@workspace:apps/side-by-side"],
+ "@apps/touch-test-demo": ["@apps/touch-test-demo@workspace:apps/touch-test-demo"],
+
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt
index da17a8e..3729790 100644
--- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt
+++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt
@@ -2,6 +2,7 @@ package io.callstack.rnsandbox
import android.content.Context
import android.os.Bundle
+import android.view.MotionEvent
import android.widget.FrameLayout
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
@@ -22,11 +23,46 @@ class SandboxReactNativeView(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
+ SandboxTouchInterceptor.register(this)
if (needsLoad && childCount == 0) {
onAttachLoadCallback?.invoke()
}
}
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ SandboxTouchInterceptor.unregister(this)
+ }
+
+ /**
+ * Forward a touch event directly to the sandbox's child surface view,
+ * bypassing the host's ReactSurfaceView dispatch entirely.
+ * Called by [SandboxTouchInterceptor] when a touch lands inside this sandbox.
+ */
+ fun dispatchTouchEventToChild(ev: MotionEvent) {
+ if (childCount > 0) {
+ val child = getChildAt(0)
+
+ // Convert screen-absolute coordinates to sandbox-local coordinates.
+ // The MotionEvent from the Window callback carries raw screen coords
+ // (ev.rawX/rawY), but dispatchTouchEvent on a child expects coords
+ // relative to the child's top-left corner. We get our screen position
+ // and subtract it from the raw coords to produce the local offset.
+ val loc = IntArray(2)
+ getLocationOnScreen(loc)
+ val offsetX = ev.rawX - loc[0]
+ val offsetY = ev.rawY - loc[1]
+
+ // Create a copy of the original event rather than mutating it —
+ // the original may still be referenced by other parts of the
+ // Android dispatch pipeline.
+ val localEvent = MotionEvent.obtain(ev)
+ localEvent.setLocation(offsetX, offsetY)
+ child?.dispatchTouchEvent(localEvent)
+ localEvent.recycle()
+ }
+ }
+
/**
* Fabric manages our dimensions but not our children's (they come from a
* separate ReactHost). Force children to fill the space Fabric gave us.
diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxTouchInterceptor.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxTouchInterceptor.kt
new file mode 100644
index 0000000..df55509
--- /dev/null
+++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxTouchInterceptor.kt
@@ -0,0 +1,504 @@
+package io.callstack.rnsandbox
+
+import android.app.Activity
+import android.graphics.Rect
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.Window
+import java.lang.ref.WeakReference
+
+/**
+ * Intercepts touch events at the Activity window level to prevent the host's
+ * ReactSurfaceView from processing touches that land inside sandbox views.
+ *
+ * ## Background
+ *
+ * The host and sandbox Fabric surfaces share the same global React view tag
+ * namespace on Android. When a sandbox internal view has the same tag as a
+ * host view, touch events from the sandbox "bleed" into the host — the host's
+ * Fabric C++ renderer resolves the touch to its own view at the colliding tag.
+ *
+ * ## Approach
+ *
+ * The interceptor wraps the Activity's [Window.Callback], which is the
+ * first thing that sees touch events before they enter the view tree. For
+ * every touch that lands within a sandbox's screen bounds, the interceptor
+ * either:
+ *
+ * - **Not obscured**: Routes the event directly to the sandbox's child
+ * [ReactSurfaceView] via [SandboxReactNativeView.dispatchTouchEventToChild],
+ * bypassing the host's Fabric touch processing entirely.
+ *
+ * - **Obscured by an overlay**: Temporarily hides the sandbox's children
+ * (setting them [View.INVISIBLE]) and re-dispatches through the normal
+ * [Window.Callback] delegate. This lets Android's standard view dispatch
+ * deliver the event to the overlay, while the host's Fabric renderer skips
+ * the invisible sandbox surface during hit-testing. Children visibility is
+ * restored immediately after dispatch (synchronous, no visual flicker).
+ *
+ * ## Z-order awareness
+ *
+ * The interceptor walks the view hierarchy from the sandbox up to the root to
+ * detect overlapping siblings and their descendants (including overflow, e.g.
+ * a card with negative margin extending outside its parent). Custom drawing
+ * order (React Native's `zIndex` via `ReactZIndexedViewGroup`) is handled
+ * through reflection on the protected [ViewGroup.getChildDrawingOrder] and
+ * `isChildrenDrawingOrderEnabled` methods.
+ */
+object SandboxTouchInterceptor {
+ /** All registered sandbox views, held as weak references to avoid leaks. */
+ private val sandboxViews = mutableSetOf>()
+
+ /** The Activity whose Window.Callback we've wrapped. Tracked to re-install on recreation. */
+ private var installedActivity: WeakReference? = null
+
+ /**
+ * Register a sandbox view for touch interception. Called from
+ * [SandboxReactNativeView.onAttachedToWindow]. Also installs the
+ * Window.Callback wrapper on the hosting Activity if not already done.
+ */
+ fun register(view: SandboxReactNativeView) {
+ sandboxViews.removeAll { it.get() == null }
+ sandboxViews.add(WeakReference(view))
+
+ val activity = getActivity(view) ?: return
+ installIfNeeded(activity)
+ }
+
+ /**
+ * Unregister a sandbox view. Called from
+ * [SandboxReactNativeView.onDetachedFromWindow].
+ *
+ * Before unwrapping the Window.Callback, we verify that the current
+ * callback is still the [SandboxWindowCallback] we installed. Another
+ * library (analytics SDK, testing framework, etc.) may have wrapped it
+ * again after us, in which case we leave the chain intact to avoid
+ * breaking that wrapper's state.
+ */
+ fun unregister(view: SandboxReactNativeView) {
+ sandboxViews.removeAll { it.get() == null || it.get() === view }
+
+ // If no sandbox views remain, consider unwrapping. Only do so if the
+ // current callback is exactly the SandboxWindowCallback we installed —
+ // a third-party wrapper on top of ours must not be silently discarded.
+ if (sandboxViews.none { it.get() != null }) {
+ val activity = installedActivity?.get() ?: return
+ val current = activity.window.callback
+ if (current is SandboxWindowCallback) {
+ activity.window.callback = current.delegate
+ installedActivity = null
+ }
+ // If current is NOT our SandboxWindowCallback, another library has
+ // wrapped on top of us. Leave the chain alone; our wrapper will
+ // simply become a no-op once sandboxViews is empty.
+ }
+ }
+
+ /**
+ * Wrap the Activity's [Window.Callback] with our [SandboxWindowCallback]
+ * if not already wrapped. Re-installs if the Activity has changed (e.g.
+ * after a configuration change / Activity recreation).
+ */
+ private fun installIfNeeded(activity: Activity) {
+ val currentActivity = installedActivity?.get()
+ if (currentActivity === activity) return
+
+ installedActivity = WeakReference(activity)
+
+ val window = activity.window
+ val originalCallback = window.callback
+ // Guard against double-wrapping if called multiple times
+ if (originalCallback is SandboxWindowCallback) return
+
+ window.callback = SandboxWindowCallback(originalCallback)
+ }
+
+ /**
+ * Custom [Window.Callback] that intercepts all touches landing within
+ * sandbox bounds.
+ *
+ * ## Gesture tracking
+ *
+ * On [MotionEvent.ACTION_DOWN] (start of a new gesture), we determine
+ * whether the touch lands in a sandbox and whether that sandbox is
+ * obscured. The decision is stored in [activeGestureSandbox] and
+ * [activeGestureObscured] so that all subsequent events in the same
+ * gesture (MOVE, UP, CANCEL, POINTER_DOWN, POINTER_UP) follow the
+ * same routing. This ensures a gesture can't switch targets mid-drag.
+ *
+ * ## Routing
+ *
+ * - **No sandbox hit**: Falls through to [delegate] (normal Android dispatch).
+ * - **Sandbox hit, not obscured**: Routes directly to the sandbox via
+ * [SandboxReactNativeView.dispatchTouchEventToChild].
+ * - **Sandbox hit, obscured**: Calls [redispatchWithHiddenSandbox] to
+ * deliver to the overlay while preventing Fabric tag collision.
+ */
+ private class SandboxWindowCallback(
+ /** The original callback we wrapped. Exposed so [unregister] can restore it. */
+ val delegate: Window.Callback,
+ ) : Window.Callback by delegate {
+ /** The sandbox handling the current gesture, or null if no sandbox is involved. */
+ private var activeGestureSandbox: WeakReference? = null
+
+ /** Whether the current gesture started on an obscured sandbox area. */
+ private var activeGestureObscured = false
+
+ override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
+ if (event == null) return delegate.dispatchTouchEvent(event)
+
+ when (event.actionMasked) {
+ // New gesture — determine routing for the entire gesture
+ MotionEvent.ACTION_DOWN -> {
+ val result = findSandboxAndObscured(event.rawX, event.rawY)
+ if (result != null) {
+ val (sandbox, obscured) = result
+ activeGestureSandbox = WeakReference(sandbox)
+ activeGestureObscured = obscured
+ return if (obscured) {
+ redispatchWithHiddenSandbox(sandbox, event)
+ } else {
+ sandbox.dispatchTouchEventToChild(event)
+ true
+ }
+ }
+ activeGestureSandbox = null
+ activeGestureObscured = false
+ }
+
+ // Continuation / end of gesture — route same as ACTION_DOWN decided
+ MotionEvent.ACTION_MOVE,
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_CANCEL,
+ -> {
+ val sandbox = activeGestureSandbox?.get()
+ if (sandbox != null) {
+ val handled =
+ if (activeGestureObscured) {
+ redispatchWithHiddenSandbox(sandbox, event)
+ } else {
+ sandbox.dispatchTouchEventToChild(event)
+ true
+ }
+ // Clean up gesture state when the gesture ends
+ if (event.actionMasked == MotionEvent.ACTION_UP ||
+ event.actionMasked == MotionEvent.ACTION_CANCEL
+ ) {
+ activeGestureSandbox = null
+ activeGestureObscured = false
+ }
+ return handled
+ }
+ }
+
+ // Multi-touch — additional fingers follow the active gesture's routing.
+ // ACTION_DOWN already decided which sandbox (or none) owns this gesture;
+ // a second finger arriving mid-gesture inherits that decision so the
+ // gesture can't switch targets. This is the correct semantic: the gesture
+ // target is locked on the first finger down.
+ MotionEvent.ACTION_POINTER_DOWN,
+ MotionEvent.ACTION_POINTER_UP,
+ -> {
+ val sandbox = activeGestureSandbox?.get()
+ if (sandbox != null) {
+ return if (activeGestureObscured) {
+ redispatchWithHiddenSandbox(sandbox, event)
+ } else {
+ sandbox.dispatchTouchEventToChild(event)
+ true
+ }
+ }
+ }
+ }
+
+ // No sandbox involved — normal Android dispatch
+ return delegate.dispatchTouchEvent(event)
+ }
+
+ /**
+ * Handle touches that land in a sandbox's bounds but are obscured by
+ * an overlay view.
+ *
+ * We can't just fall through to [delegate.dispatchTouchEvent] because
+ * the host's [ReactSurfaceView] would still process the event through
+ * Fabric's C++ touch handler, hitting the colliding tag. Instead, we
+ * temporarily set the sandbox's children to [View.INVISIBLE] so the
+ * host's Fabric renderer skips them during hit-testing, then dispatch
+ * normally so the overlay receives the event. Visibility is restored
+ * immediately — this is synchronous on the UI thread, so there's no
+ * visual flicker.
+ *
+ * Why INVISIBLE and not GONE?
+ * GONE triggers a layout pass (requestLayout) which is expensive and
+ * can cause a brief visual glitch. INVISIBLE only affects drawing and
+ * hit-testing — the view retains its measured size and position, so
+ * no layout is invalidated. It also avoids sending spurious
+ * accessibility events that GONE would trigger.
+ *
+ * Why not dispatch a CANCEL to the sandbox instead?
+ * A synthetic CANCEL would require the sandbox's gesture recogniser to
+ * already be tracking a gesture. On ACTION_DOWN there is no active
+ * gesture to cancel, so a CANCEL event would be silently dropped and
+ * the sandbox's Fabric renderer would still process the original DOWN.
+ */
+ private fun redispatchWithHiddenSandbox(
+ sandbox: SandboxReactNativeView,
+ event: MotionEvent,
+ ): Boolean {
+ // Save and hide each child's visibility
+ val children = mutableListOf>()
+ for (i in 0 until sandbox.childCount) {
+ val child = sandbox.getChildAt(i)
+ children.add(Pair(child, child.visibility))
+ child.visibility = View.INVISIBLE
+ }
+
+ // Dispatch through normal path — overlay will receive the event,
+ // Fabric won't see the sandbox's surface view
+ val handled = delegate.dispatchTouchEvent(event)
+
+ // Restore original visibility
+ for ((child, originalVisibility) in children) {
+ child.visibility = originalVisibility
+ }
+
+ return handled
+ }
+ }
+
+ /**
+ * Find a sandbox whose screen bounds contain the touch point.
+ *
+ * Iterates all registered sandbox views. For the first one whose bounds
+ * contain the point, calls [isObscuredAt] to determine if an overlay is
+ * drawn on top at that location.
+ *
+ * @return A pair of (sandbox, isObscured), or null if no sandbox contains
+ * the touch point.
+ */
+ private fun findSandboxAndObscured(
+ rawX: Float,
+ rawY: Float,
+ ): Pair? {
+ val location = IntArray(2)
+ val x = rawX.toInt()
+ val y = rawY.toInt()
+
+ for (ref in sandboxViews) {
+ val sandbox = ref.get() ?: continue
+ if (!sandbox.isAttachedToWindow || !sandbox.isShown) continue
+
+ sandbox.getLocationOnScreen(location)
+ val rect =
+ Rect(
+ location[0],
+ location[1],
+ location[0] + sandbox.width,
+ location[1] + sandbox.height,
+ )
+ if (!rect.contains(x, y)) continue
+
+ return Pair(sandbox, isObscuredAt(sandbox, x, y))
+ }
+ return null
+ }
+
+ /**
+ * Determine if a view drawn above [target] in the z-order contains the
+ * screen-coordinate point ([screenX], [screenY]).
+ *
+ * Walks from [target] up to the root of the view tree. At each
+ * [ViewGroup] ancestor, examines all sibling views that are drawn after
+ * (on top of) the branch containing [target]. A sibling "obscures" if:
+ * 1. It is drawn after [target]'s branch ([isDrawnAfter])
+ * 2. It is [View.VISIBLE]
+ * 3. Its screen bounds contain the touch point, OR any of its descendants'
+ * screen bounds contain the point ([hasDescendantAt] — handles overflow)
+ *
+ * The walk continues up the tree because an obscuring view might be a
+ * sibling of a grandparent, not just a direct sibling of the sandbox.
+ */
+ private fun isObscuredAt(
+ target: View,
+ screenX: Int,
+ screenY: Int,
+ ): Boolean {
+ var child: View = target
+ var parent = child.parent
+ val siblingLoc = IntArray(2)
+
+ while (parent is ViewGroup) {
+ val group = parent
+ val childIndex = group.indexOfChild(child)
+
+ for (i in 0 until group.childCount) {
+ if (i == childIndex) continue
+ val sibling = group.getChildAt(i)
+ if (!isDrawnAfter(group, i, childIndex)) continue
+ if (sibling.visibility != View.VISIBLE) continue
+
+ // Check the sibling's own bounds
+ sibling.getLocationOnScreen(siblingLoc)
+ val sibRect =
+ Rect(
+ siblingLoc[0],
+ siblingLoc[1],
+ siblingLoc[0] + sibling.width,
+ siblingLoc[1] + sibling.height,
+ )
+ if (sibRect.contains(screenX, screenY)) return true
+
+ // Check descendants — catches overflow (e.g. a card with
+ // negative margin extending outside its parent's bounds)
+ if (sibling is ViewGroup && hasDescendantAt(sibling, screenX, screenY)) {
+ return true
+ }
+ }
+
+ // Move up one level in the tree
+ child = group
+ parent = group.parent
+ }
+
+ return false
+ }
+
+ /**
+ * Recursively check if [viewGroup] has any visible descendant whose
+ * screen bounds contain ([screenX], [screenY]).
+ *
+ * This is needed because Android allows children to render outside their
+ * parent's bounds (unless `clipChildren` is set). A common case is a
+ * React Native view with negative margin or absolute positioning that
+ * overflows its container.
+ */
+ private fun hasDescendantAt(
+ viewGroup: ViewGroup,
+ screenX: Int,
+ screenY: Int,
+ ): Boolean {
+ val loc = IntArray(2)
+ for (i in 0 until viewGroup.childCount) {
+ val child = viewGroup.getChildAt(i)
+ if (child.visibility != View.VISIBLE) continue
+
+ child.getLocationOnScreen(loc)
+ val childRect =
+ Rect(
+ loc[0],
+ loc[1],
+ loc[0] + child.width,
+ loc[1] + child.height,
+ )
+ if (childRect.contains(screenX, screenY)) return true
+ if (child is ViewGroup && hasDescendantAt(child, screenX, screenY)) return true
+ }
+ return false
+ }
+
+ /**
+ * Determine if the child at [candidateIndex] is drawn after (on top of)
+ * the child at [referenceIndex] within [parent].
+ *
+ * By default, Android draws children in index order (higher index = on top).
+ * When custom drawing order is enabled (e.g. React Native's zIndex),
+ * [ViewGroup.getChildDrawingOrder] maps draw positions to child indices.
+ * We iterate all draw positions to find where each child is actually drawn
+ * and compare their positions.
+ *
+ * Falls back to natural index ordering if reflection fails.
+ */
+ private fun isDrawnAfter(
+ parent: ViewGroup,
+ candidateIndex: Int,
+ referenceIndex: Int,
+ ): Boolean {
+ val customOrder =
+ try {
+ isChildrenDrawingOrderEnabledMethod?.invoke(parent) as? Boolean ?: false
+ } catch (_: Exception) {
+ false
+ }
+
+ if (!customOrder) return candidateIndex > referenceIndex
+
+ val method = getChildDrawingOrderMethod ?: return candidateIndex > referenceIndex
+
+ try {
+ val count = parent.childCount
+ var candidateDrawPos = candidateIndex
+ var referenceDrawPos = referenceIndex
+
+ for (drawPos in 0 until count) {
+ val childIdx = method.invoke(parent, count, drawPos) as Int
+ if (childIdx == candidateIndex) candidateDrawPos = drawPos
+ if (childIdx == referenceIndex) referenceDrawPos = drawPos
+ }
+
+ return candidateDrawPos > referenceDrawPos
+ } catch (_: Exception) {
+ return candidateIndex > referenceIndex
+ }
+ }
+
+ /**
+ * Extract the hosting [Activity] from a view's Context chain.
+ * Android wraps contexts (e.g. ThemedReactContext → ContextWrapper →
+ * Activity), so we unwrap until we find the Activity.
+ */
+ private fun getActivity(view: SandboxReactNativeView): Activity? {
+ var ctx = view.context
+ while (ctx is android.content.ContextWrapper) {
+ if (ctx is Activity) return ctx
+ ctx = ctx.baseContext
+ }
+ return null
+ }
+
+ // ── Drawing order helpers ───────────────────────────────────────────────
+ //
+ // ViewGroup.isChildrenDrawingOrderEnabled() and getChildDrawingOrder()
+ // are protected methods. React Native's ReactZIndexedViewGroup overrides
+ // them to implement zIndex support. We use reflection (cached in lazy
+ // vals so the Method lookup happens once) to access them.
+ //
+ // If reflection fails (future Android release, vendor fork, or ProGuard
+ // stripping), we fall back to natural index order and emit a one-time
+ // warning so silent regressions surface in production logs.
+
+ private val isChildrenDrawingOrderEnabledMethod by lazy {
+ try {
+ ViewGroup::class.java.getDeclaredMethod("isChildrenDrawingOrderEnabled").also {
+ it.isAccessible = true
+ }
+ } catch (e: Exception) {
+ android.util.Log.w(
+ "SandboxTouchInterceptor",
+ "Reflection on ViewGroup.isChildrenDrawingOrderEnabled() failed — " +
+ "falling back to natural draw order. zIndex-based overlay detection may be inaccurate. " +
+ "Cause: ${e.message}",
+ )
+ null
+ }
+ }
+
+ private val getChildDrawingOrderMethod by lazy {
+ try {
+ ViewGroup::class.java
+ .getDeclaredMethod(
+ "getChildDrawingOrder",
+ Int::class.javaPrimitiveType,
+ Int::class.javaPrimitiveType,
+ ).also { it.isAccessible = true }
+ } catch (e: Exception) {
+ android.util.Log.w(
+ "SandboxTouchInterceptor",
+ "Reflection on ViewGroup.getChildDrawingOrder() failed — " +
+ "falling back to natural draw order. zIndex-based overlay detection may be inaccurate. " +
+ "Cause: ${e.message}",
+ )
+ null
+ }
+ }
+}