diff --git a/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec b/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec index 038b75db8..e571aae25 100644 --- a/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec +++ b/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec @@ -3,31 +3,6 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' -# --- RN-floor gate: Fabric SRMaskView (cpp + ios/fabric) needs new-arch + RN >= 0.77 --- -new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' -rn_version = nil -begin - rn_pkg_path = `node -e "console.log(require.resolve('react-native/package.json'))"`.strip - rn_version = JSON.parse(File.read(rn_pkg_path))["version"] unless rn_pkg_path.empty? -rescue StandardError - rn_version = nil -end -srmaskview_version_ge_077 = lambda do |v| - next false if v.nil? - parts = v.split('.') - major = parts[0].to_i - minor = parts[1].to_i - major > 0 || (major == 0 && minor >= 77) -end -# Only a KNOWN RN below 0.77 disqualifies Fabric. When the version is unresolvable, -# the Fabric sources must still build: codegen (type:"all") references SRMaskViewCls -# unconditionally, so omitting them would break linking. (Android gates the same way.) -rn_known_below_077 = !rn_version.nil? && !srmaskview_version_ge_077.call(rn_version) -if new_arch_enabled && rn_known_below_077 - raise "[AmplitudeSessionReplayReactNative] The Fabric SRMaskView component requires React Native >= 0.77 with the New Architecture (found #{rn_version})." -end -fabric_enabled = new_arch_enabled && !rn_known_below_077 - Pod::Spec.new do |s| s.name = "AmplitudeSessionReplayReactNative" s.version = package["version"].split(/[-+]/).first @@ -39,15 +14,7 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://github.com/amplitude/Amplitude-TypeScript.git", :tag => "#{s.version}" } - if fabric_enabled - # Fabric build: include the C++ ShadowNode + the ios/fabric host view. - s.source_files = "ios/**/*.{h,m,mm,swift}", "cpp/**/*.{h,cpp}" - s.public_header_files = "ios/SRMaskingPrimitive.h", "ios/NativeSessionReplay-Bridging-Header.h" - s.private_header_files = "ios/fabric/**/*.h", "cpp/**/*.h" - else - # No Fabric: exclude cpp/** and ios/fabric/** (they pull in renderer headers). - s.source_files = "ios/*.{h,m,mm,swift}" - end + s.source_files = "ios/**/*.{h,m,mm,swift}" s.dependency 'AmplitudeSessionReplay', '>=0.11.1' s.dependency 'AmplitudeCore', '>=1.4.2' @@ -57,14 +24,6 @@ Pod::Spec.new do |s| # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. if respond_to?(:install_modules_dependencies, true) install_modules_dependencies(s) - if fabric_enabled - # Fabric shadow-node C++ headers break Clang module dependency scanning. - existing_xcconfig = s.attributes_hash["pod_target_xcconfig"] || {} - s.pod_target_xcconfig = existing_xcconfig.merge({ - "DEFINES_MODULE" => "NO", - "CLANG_ENABLE_EXPLICIT_MODULES" => "NO", - }) - end else s.dependency "React-Core" @@ -83,4 +42,4 @@ Pod::Spec.new do |s| s.dependency "ReactCommon/turbomodule/core" end end -end +end \ No newline at end of file diff --git a/packages/session-replay-react-native/README.md b/packages/session-replay-react-native/README.md index 1e0cd9ec3..a4681c6e2 100644 --- a/packages/session-replay-react-native/README.md +++ b/packages/session-replay-react-native/README.md @@ -21,19 +21,6 @@ the SDK keeps working on older React Native versions on the legacy architecture. The TurboModule code path is compiled only when the New Architecture is enabled, which itself requires React Native 0.74 or newer. -### Fabric-based masking - -The layout-transparent masking components (`AmpMask` / `AmpUnmask`, see -[Layout-transparent masking](#layout-transparent-masking-with-ampmask--ampunmask-experimental)) -are built on Fabric. - -The Fabric/C++ sources compile only on the New Architecture with React Native -0.77 or newer (they rely on capabilities that exist only in those versions). -On the legacy architecture, or on the New Architecture with React Native older -than 0.77, the Fabric sources are excluded. Enabling the New Architecture on -React Native older than 0.77 fails the build fast with a clear error (an -"RN-floor gate" enforced in both `android/build.gradle` and the iOS podspec). - ## Usage ### Session Replay React Native Standalone SDK @@ -115,105 +102,6 @@ import { AmpMaskView } from '@amplitude/session-replay-react-native'; ; ``` -## Layout-transparent masking with `AmpMask` / `AmpUnmask` (Experimental) - -> **@experimental** — this API is new and may change in a future release. - -`AmpMaskView` wraps its children in an extra native view, which introduces a -layout boundary: children that depend on their parent for sizing (`flex: 1`, -percentage heights, `position: 'absolute'`) can shift or collapse to zero. -`AmpMask` and `AmpUnmask` are layout-transparent replacements: they mark their -children as masked/unmasked in the replay without affecting layout at all — -wrapping content in `` renders pixel-identical to not wrapping it. - -### Requirements - -`AmpMask`/`AmpUnmask` require React Native **0.77 or newer** with the -**New Architecture** enabled (Fabric) **with bridgeless enabled** (the RN -0.77 default). On Fabric without bridgeless (bridge mode) — as well as on -the Old Architecture — they are not supported as a layout-transparent path: - -- On the Old Architecture, in development they throw with a clear error; - in production they fall back to `AmpMaskView` and log a one-time - `console.error`. -- On Fabric without bridgeless (bridge mode), they never throw — they - always fall back to `AmpMaskView` and log a one-time `console.error`, - in both development and production (bridge-mode Fabric cannot detect - `SRMaskView` on iOS). -- Both fallbacks **ignore `enabled`** — wrapped content stays masked - regardless (it fails toward privacy). Neither is layout-transparent — - the `AmpMaskView` layout caveats above apply. Use `AmpMaskView` - directly outside the bridgeless-Fabric path. - -### Caveats - -- `style` is not supported on ``/`` — they never occupy - layout, so there is no box to style. Style your children directly instead. -- `enabled` is only honored on the layout-transparent Fabric path — all - fallback paths (Old Architecture, Fabric bridge-mode, and the - build-misconfiguration cases below) ignore it and keep content masked - regardless (they fail toward privacy). -- If the New Architecture is active but the native `SRMaskView` component is - missing — including on Fabric without bridgeless, which cannot detect - `SRMaskView` on iOS and always falls back — ``/`` log a - one-time `console.error` and fall back to `` — content stays - **masked**, but layout-transparency is lost. Treat that log as a build - error to fix, not a warning to ignore. -- On the **New Architecture**, if the package's native code is absent - entirely (so Session Replay cannot record at all), ``/`` - log a one-time `console.error` and render children directly. If instead the - native module is present but neither masking component is registered (an - unexpected build error), they throw in development and log a distinct - one-time `console.error` in production instead of silently passing content - through. On the **Old Architecture** with the native code absent, rendering - fails at `requireNativeComponent` like any other native component — there - is no silent passthrough. - -### Usage - -```tsx -import { AmpMask, AmpUnmask } from '@amplitude/session-replay-react-native'; - -// Mask: children are masked in the replay, layout is unchanged. - - - {accountNumber} - - - -// Block: fully block the subtree from the replay. - - - - -// Unmask: opt content back in to the replay. - - Public banner - -``` - -`AmpMask` props: - -| Prop | Type | Default | Description | -| --- | --- | --- | --- | -| `enabled` | `boolean` | `true` | When `false`, children render without masking. | -| `maskLevel` | `'mask' \| 'block'` | `'mask'` | Masking level applied to the children. On iOS, `mask` and `block` currently behave identically (both fully block). | - -`AmpUnmask` takes no masking props — it always unmasks its children. - -### Migrating from `AmpMaskView` - -| Before | After | -| --- | --- | -| `` | `` | -| `` | `` | -| `` | `` | - -`AmpMaskView` remains supported on both architectures. Prefer -`AmpMask`/`AmpUnmask` on the New Architecture, especially around children that -are sized by their parent (`flex: 1`, percentage heights, absolute -positioning). - ## Tracking Web Views (Beta) Web views are blocked by default and will not be tracked. If you'd like webviews to be tracked, you can manually unmask diff --git a/packages/session-replay-react-native/android/build.gradle b/packages/session-replay-react-native/android/build.gradle index 22100ee7c..2cfd96f4f 100644 --- a/packages/session-replay-react-native/android/build.gradle +++ b/packages/session-replay-react-native/android/build.gradle @@ -1,5 +1,3 @@ -import groovy.json.JsonSlurper - def reactNativeArchitectures() { def value = rootProject.getProperties().get("reactNativeArchitectures") return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] @@ -28,103 +26,9 @@ def supportsNamespace() { def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.') def major = parsed[0].toInteger() def minor = parsed[1].toInteger() - return (major == 7 && minor >= 3) || major >= 8 -} - -// --- RN-floor gate: the Fabric SRMaskView codegen/C++ needs RN >= 0.77 under new-arch. -// Defensive: if RN can't be resolved, do NOT fail (don't break the happy path). --- -def resolveReactNativeVersion() { - def candidates = [ - file("${rootProject.projectDir}/../node_modules/react-native/package.json"), - file("${rootProject.projectDir}/node_modules/react-native/package.json"), - file("${projectDir}/../../../react-native/package.json"), - ] - for (c in candidates) { - if (c.exists()) { - try { return new JsonSlurper().parse(c).version } catch (ignored) {} - } - } - return null -} - -if (isNewArchitectureEnabled()) { - def rnVersion = resolveReactNativeVersion() - if (rnVersion != null) { - def p = rnVersion.tokenize('.') - def major = p[0].toInteger() - def minor = p[1].toInteger() - def ge077 = major > 0 || (major == 0 && minor >= 77) - if (!ge077) { - throw new GradleException( - "[SessionReplayReactNative] The Fabric SRMaskView component requires React Native >= 0.77 " + - "with the New Architecture (found ${rnVersion})." - ) - } - } -} - -def patchComponentDescriptorsHeader() { - def componentsDir = file("$buildDir/generated/source/codegen/jni/react/renderer/components/AmpSessionReplaySpec") - def header = new File(componentsDir, "ComponentDescriptors.h") - - if (!header.exists()) { - throw new GradleException( - "[SessionReplayReactNative] Expected codegen ComponentDescriptors.h at ${header.absolutePath}. " + - "Ensure newArchEnabled=true and generateCodegenArtifactsFromSchema ran successfully." - ) - } - - def original = header.getText("UTF-8") - if (original.contains("#include ")) { - return - } - - def typedefPattern = /using SRMaskViewComponentDescriptor = ConcreteComponentDescriptor;/ - if (!(original =~ typedefPattern)) { - throw new GradleException( - "[SessionReplayReactNative] patchComponentDescriptorsHeader regex did not match ComponentDescriptors.h. " + - "React Native codegen output may have changed; update the patch task before shipping." - ) - } - - def patched = original.replace( - "#include ", - """#include -#include """ - ).replaceFirst(typedefPattern, "") - header.write(patched, "UTF-8") -} -def patchCodegenCMakeLists() { - def cmakeFile = file("$buildDir/generated/source/codegen/jni/CMakeLists.txt") - - if (!cmakeFile.exists()) { - throw new GradleException( - "[SessionReplayReactNative] Expected codegen CMakeLists.txt at ${cmakeFile.absolutePath}. " + - "Ensure newArchEnabled=true and generateCodegenArtifactsFromSchema ran successfully." - ) - } - - def original = cmakeFile.getText("UTF-8") - if (original.contains("SRMaskViewShadowNode.cpp")) { - return - } - - def cppDir = file("../cpp").absolutePath.replace("\\", "/") - def insertion = """target_sources(react_codegen_AmpSessionReplaySpec PRIVATE - "${cppDir}/SRMaskViewShadowNode.cpp" -) -target_include_directories(react_codegen_AmpSessionReplaySpec PUBLIC "${cppDir}") -""" - - if (!original.contains("target_link_libraries(")) { - throw new GradleException( - "[SessionReplayReactNative] patchCodegenCMakeLists could not find target_link_libraries in CMakeLists.txt." - ) - } - - def patched = original.replace("target_link_libraries(", insertion + "target_link_libraries(") - cmakeFile.write(patched, "UTF-8") + // Namespace support was added in 7.3.0 + return (major == 7 && minor >= 3) || major >= 8 } android { @@ -138,8 +42,8 @@ android { } } - // New Arch compiles src/newarch (codegen spec + BaseReactPackage + Fabric SRMaskView); - // Old Arch compiles src/oldarch (hand-written spec + plain ReactPackage). + // New Arch compiles src/newarch (codegen spec + BaseReactPackage); Old Arch + // compiles src/oldarch (hand-written spec + plain ReactPackage). sourceSets { main { if (isNewArchitectureEnabled()) { @@ -155,12 +59,7 @@ android { defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") - buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - buildFeatures { - buildConfig true } buildTypes { @@ -169,10 +68,6 @@ android { } } - packagingOptions { - pickFirst "lib/**/*.so" - } - lintOptions { disable "GradleCompatible" } @@ -193,33 +88,20 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation("com.amplitude:session-replay-android:[0.24.0,0.25.0)") implementation("com.amplitude:analytics-android:[1.25.0,1.26.0)") + + // For < 0.71, this will be from the local maven repo + // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - - androidTestImplementation "androidx.test.ext:junit:1.2.1" - androidTestImplementation "androidx.test:runner:1.6.2" } +// Codegen for the TurboModule spec (New Architecture only). Generates +// NativeAmpSessionReplaySpec from src/specs/NativeAmpSessionReplay.ts. if (isNewArchitectureEnabled()) { react { jsRootDir = file("../src/specs/") libraryName = "AmpSessionReplaySpec" codegenJavaPackageName = "com.amplitude.sessionreplayreactnative" } - - afterEvaluate { - tasks.named("generateCodegenArtifactsFromSchema").configure { - doLast { - patchComponentDescriptorsHeader() - patchCodegenCMakeLists() - } - } - - tasks.configureEach { task -> - if (task.name.contains("configureCMake")) { - task.dependsOn("generateCodegenArtifactsFromSchema") - } - } - } } diff --git a/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt b/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt deleted file mode 100644 index 1390b67ac..000000000 --- a/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt +++ /dev/null @@ -1,459 +0,0 @@ -package com.amplitude.sessionreplayreactnative - -import android.content.Context -import android.view.View -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.amplitude.android.sessionreplay.SessionReplay -import com.amplitude.sessionreplayreactnative.fabric.SRMaskView -import com.amplitude.sessionreplayreactnative.fabric.SRMaskViewManager -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import com.facebook.react.uimanager.PointerEvents -import org.junit.runner.RunWith - -/** - * Instrumented canaries locking the masking model of the Fabric `SRMaskView` - * host and the `SRMaskingRegistry` seam: - * - per-child mask intent on add (one [SRMaskingPrimitive.mask] per direct child) - * - reset on child removal and on host drop ([SRMaskViewManager.onDropViewInstance]) - * - capture-bounds: a degenerate 0x0 host frame is widened to the union of - * its children so `shouldCapture()` (width>0 && height>0) would pass - * - masking intent recorded before a primitive registers is replayed once - * a primitive registers - * - * The registry is process-global state. [SRMaskingRegistry.setPrimitive] is - * cleared (`null`) in [setUp]/[tearDown], but the registry intentionally retains - * its weak-keyed `intents` map (for replay-on-register) and exposes no public - * way to clear it. Because registering a primitive replays *every* still-live - * recorded intent, tests register their primitive first and then clear the - * recording before acting, so each assertion counts only its own calls (or - * scopes assertions to the specific view under test). - */ -@RunWith(AndroidJUnit4::class) -class SRMaskViewTest { - - /** Records every call the Fabric host / registry routes through the seam. */ - private class RecordingPrimitive : SRMaskingPrimitive { - sealed interface Call { - val view: View - - data class Mask(override val view: View, val level: String) : Call - data class Unmask(override val view: View) : Call - data class Reset(override val view: View) : Call - } - - val calls = mutableListOf() - - val maskCalls get() = calls.filterIsInstance() - val resetCalls get() = calls.filterIsInstance() - - override fun mask(view: View, level: String) { - calls.add(Call.Mask(view, level)) - } - - override fun unmask(view: View) { - calls.add(Call.Unmask(view)) - } - - override fun reset(view: View) { - calls.add(Call.Reset(view)) - } - - fun resetCountFor(view: View): Int = resetCalls.count { it.view === view } - } - - private val context: Context - get() = InstrumentationRegistry.getInstrumentation().targetContext - - /** Run on the main looper; view layout/listeners require the UI thread. */ - private fun onMain(block: () -> Unit) = - InstrumentationRegistry.getInstrumentation().runOnMainSync(block) - - @Before - fun setUp() { - // Start each test from a clean, primitive-less registry. - SRMaskingRegistry.setPrimitive(null) - } - - @After - fun tearDown() { - SRMaskingRegistry.setPrimitive(null) - } - - // 1. Per-child mask: each direct child added to the host produces exactly one - // mask call, in add order. - @Test - fun perChildMask_emitsOneMaskPerDirectChild_inOrder() { - val recording = RecordingPrimitive() - SRMaskingRegistry.setPrimitive(recording) - // Drop any intents replayed from earlier tests; count only this test's adds. - recording.calls.clear() - - val children = mutableListOf() - onMain { - val host = SRMaskView(context) - repeat(3) { - val child = View(context) - children.add(child) - host.addView(child) - } - } - - assertEquals( - "expected one mask call per direct child", - 3, - recording.maskCalls.size, - ) - // Same views, same order, default level "mask". - recording.maskCalls.forEachIndexed { i, call -> - assertSame("mask call $i targets the wrong view", children[i], call.view) - assertEquals("default mask level should be \"mask\"", "mask", call.level) - } - } - - // 2. Reset on removeView: removing a child fires reset for exactly that child. - @Test - fun removeView_resetsRemovedChild() { - val recording = RecordingPrimitive() - SRMaskingRegistry.setPrimitive(recording) - recording.calls.clear() - - lateinit var child: View - onMain { - val host = SRMaskView(context) - child = View(context) - host.addView(child) - host.removeView(child) - } - - assertEquals( - "removeView should reset exactly the removed child once", - 1, - recording.resetCountFor(child), - ) - } - - // 3. Reset on onDropViewInstance: dropping the host resets every child. - @Test - fun onDropViewInstance_resetsAllChildren() { - val recording = RecordingPrimitive() - SRMaskingRegistry.setPrimitive(recording) - recording.calls.clear() - - val manager = SRMaskViewManager() - val children = mutableListOf() - onMain { - val host = SRMaskView(context) - repeat(3) { - val child = View(context) - children.add(child) - host.addView(child) - } - manager.onDropViewInstance(host) - } - - children.forEach { child -> - assertTrue( - "onDropViewInstance should reset child $child", - recording.resetCountFor(child) >= 1, - ) - } - } - - // 4. Capture-bounds: a host given a degenerate 0x0 frame widens itself to - // the union of its laid-out children, so width>0 && height>0 - // (shouldCapture() would pass). - @Test - fun degenerateHostFrame_widensToChildrenUnion() { - lateinit var host: SRMaskView - onMain { - host = SRMaskView(context) - val a = View(context) - val b = View(context) - host.addView(a) - host.addView(b) - // Give children non-zero, disjoint frames. - a.layout(0, 0, 100, 50) - b.layout(120, 60, 200, 140) - // Simulate Fabric's display:contents 0x0 host frame. View.layout is final - // and invokes the overridden onLayout -> expandBoundsToChildrenUnion. - host.layout(0, 0, 0, 0) - } - - assertTrue( - "host width should be widened to the children union (got ${host.width})", - host.width > 0, - ) - assertTrue( - "host height should be widened to the children union (got ${host.height})", - host.height > 0, - ) - // Union of (0,0,100,50) and (120,60,200,140) is (0,0,200,140). - assertEquals("union width", 200, host.width) - assertEquals("union height", 140, host.height) - } - - // 4b. A childless host stays 0x0 after a degenerate layout — nothing to - // capture, nothing to widen. - @Test - fun degenerateHostFrame_withNoChildren_staysZero() { - lateinit var host: SRMaskView - onMain { - host = SRMaskView(context) - host.layout(0, 0, 0, 0) - } - - assertEquals("childless host width should stay 0", 0, host.width) - assertEquals("childless host height should stay 0", 0, host.height) - } - - // 4c. Regression (measured on-device, RN 0.77.2 Fabric): Fabric lays the - // degenerate display:contents host out at a NON-ZERO parent offset - // (flattened intermediate views accumulate into the host origin — e.g. - // an AmpUnmask nested in an AmpMask through a flattened , or a - // mask inside a list row) and the children frames are HOST-relative - // (a child renders at host.left + child.left). The host must widen to - // origin + children extent WITHOUT moving; treating the child extents - // as absolute (a previous revision) produced inverted frames - // (bottom < top) that the capture gate dropped. - @Test - fun degenerateHostFrame_atNonZeroOffset_widensWithoutMoving() { - lateinit var host: SRMaskView - onMain { - host = SRMaskView(context) - val a = View(context) - val b = View(context) - host.addView(a) - host.addView(b) - // Children laid out HOST-relative — as measured from Fabric. - a.layout(0, 0, 100, 50) - b.layout(120, 60, 200, 140) - // Fabric places the 0x0 display:contents host at a non-zero parent offset. - host.layout(300, 400, 300, 400) - } - - // Extent == origin + children union max right/bottom → (300,400,500,540). - assertEquals("union width at offset", 200, host.width) - assertEquals("union height at offset", 140, host.height) - // Position preserved — the host was NOT moved (else children shift absolutely). - assertEquals("host left preserved", 300, host.left) - assertEquals("host top preserved", 400, host.top) - // Never inverted: bottom/right beyond origin. - assertTrue("frame not inverted", host.bottom > host.top && host.right > host.left) - } - - // 4d. A child at fully negative host-relative coordinates (e.g. the - // negative-offset badge shape) cannot be enclosed without moving the - // origin (forbidden). The frame must still never be degenerate or - // inverted — clamp to a 1x1 minimum so shouldCapture() keeps the - // subtree. - @Test - fun degenerateHostFrame_withFullyNegativeChild_clampsToOnePixel() { - lateinit var host: SRMaskView - onMain { - host = SRMaskView(context) - val a = View(context) - host.addView(a) - a.layout(-50, -60, -10, -20) // entirely above/left of the host origin - host.layout(74, 3018, 74, 3018) - } - - assertEquals("clamped width", 1, host.width) - assertEquals("clamped height", 1, host.height) - assertEquals("host left preserved", 74, host.left) - assertEquals("host top preserved", 3018, host.top) - } - - // 4e. Nested-mask shape (AmpUnmask host inside an AmpMask host): the inner - // host sits at a non-zero offset inside the outer host with its own - // host-relative children; the outer host's union must enclose the - // inner host's WIDENED frame. Mirrors the measured Mask-screen nesting - // (outer at (0,0,0,0); inner fabric frame (32,2016,32,2016), inner - // child (0,124,1017,175)). - @Test - fun nestedHosts_outerEnclosesInnerWidenedFrame() { - lateinit var outer: SRMaskView - lateinit var inner: SRMaskView - onMain { - outer = SRMaskView(context) - inner = SRMaskView(context) - val innerChild = View(context) - val outerText = View(context) - outer.addView(outerText) - outer.addView(inner) - inner.addView(innerChild) - outerText.layout(32, 2047, 1049, 2098) // host-relative to outer - innerChild.layout(0, 124, 1017, 175) // host-relative to inner - inner.layout(32, 2016, 32, 2016) // Fabric's degenerate offset frame - outer.layout(0, 0, 0, 0) - } - - // Inner: origin + child extent = (32,2016,1049,2191), never inverted. - assertEquals("inner left", 32, inner.left) - assertEquals("inner top", 2016, inner.top) - assertEquals("inner width", 1017, inner.width) - assertEquals("inner height", 175, inner.height) - // Outer: encloses max(outerText.right, inner.right)=1049 and - // max(outerText.bottom, inner.bottom)=2191 from its (0,0) origin. - assertEquals("outer width", 1049, outer.width) - assertEquals("outer height", 2191, outer.height) - assertTrue("outer capture gate", outer.width > 0 && outer.height > 0) - assertTrue("inner capture gate", inner.width > 0 && inner.height > 0) - } - - // 5. Dropping the host detaches each child's layout listener, so a child that is - // recycled/reparented WITHOUT going through removeView no longer fires layout - // callbacks against (or leaks) the dropped host. - @Test - fun onHostDropped_detachesChildLayoutListeners() { - lateinit var host: SRMaskView - lateinit var child: View - onMain { - host = SRMaskView(context) - child = View(context) - host.addView(child) - child.layout(0, 0, 100, 100) - host.layout(0, 0, 0, 0) // onLayout widens the host to enclose the child - } - assertEquals("precondition: host widened to child", 100, host.width) - - onMain { host.onHostDropped() } // detaches the child's layout listener - - // Grow the child WITHOUT driving host.onLayout: only a still-attached listener - // would re-widen the host. It must not, now that the listener is detached. - onMain { child.layout(0, 0, 500, 500) } - assertEquals("host must not re-widen after drop (listener detached)", 100, host.width) - } - - // 6. The host must disable child clipping (iOS parity: SRMaskView.mm sets - // clipsToBounds=NO). Its Yoga node is display:contents, so the native host is - // 0x0; with default clipChildren=true the masked subtree would paint - // invisibly on-screen even though its layout is correct. - @Test - fun srMaskViewDisablesChildClipping() { - lateinit var host: SRMaskView - onMain { host = SRMaskView(context) } - assertTrue( - "SRMaskView must not clip children (else masked content renders invisibly)", - !host.clipChildren, - ) - } - - // 5. Replay-on-register: masking intent recorded with no primitive is replayed when a - // primitive later registers. - @Test - fun maskIntentBeforeRegistration_isReplayedOnRegister() { - lateinit var someView: View - val recording = RecordingPrimitive() - - onMain { - someView = View(context) - // No primitive registered yet (setUp cleared it): intent is only recorded. - SRMaskingRegistry.setPrimitive(null) - SRMaskingRegistry.mask(someView, "custom") - // Register the primitive AFTER the mask intent. - SRMaskingRegistry.setPrimitive(recording) - } - - // Scope to someView: setPrimitive replays *all* still-live intents (incl. - // any leaked from earlier tests), so assert on this view specifically. - val masksForSomeView = recording.maskCalls.filter { it.view === someView } - assertEquals( - "registering a primitive should replay exactly one mask intent for the view", - 1, - masksForSomeView.size, - ) - assertEquals( - "replayed mask level should match the recorded intent", - "custom", - masksForSomeView.single().level, - ) - } - - // 7. Default primitive mapping (Task 2.6): [SRDefaultMaskingPrimitive] bridges - // the seam to the Session Replay SDK's tag-based hooks. Each level is - // compared against a control view driven through the SDK static directly, - // so the tests don't depend on the SDK's internal tag constants. - @Test - fun defaultPrimitive_maskLevelMask_matchesSdkMask() { - val primitive = SRDefaultMaskingPrimitive() - lateinit var view: View - lateinit var control: View - onMain { - view = View(context) - control = View(context) - primitive.mask(view, "mask") - SessionReplay.mask(control) - } - - assertNotNull("SDK mask should set a tag on the control view", control.tag) - assertEquals("mask(\"mask\") must apply the SDK's mask tag", control.tag, view.tag) - } - - @Test - fun defaultPrimitive_maskLevelBlock_matchesSdkBlock() { - val primitive = SRDefaultMaskingPrimitive() - lateinit var view: View - lateinit var control: View - onMain { - view = View(context) - control = View(context) - primitive.mask(view, "block") - SessionReplay.block(control) - } - - assertNotNull("SDK block should set a tag on the control view", control.tag) - assertEquals("mask(\"block\") must apply the SDK's block tag", control.tag, view.tag) - } - - @Test - fun defaultPrimitive_unmask_matchesSdkUnmask() { - val primitive = SRDefaultMaskingPrimitive() - lateinit var view: View - lateinit var control: View - onMain { - view = View(context) - control = View(context) - primitive.unmask(view) - SessionReplay.unmask(control) - } - - assertNotNull("SDK unmask should set a tag on the control view", control.tag) - assertEquals("unmask must apply the SDK's unmask tag", control.tag, view.tag) - } - - @Test - fun defaultPrimitive_reset_clearsTag() { - val primitive = SRDefaultMaskingPrimitive() - lateinit var view: View - onMain { - view = View(context) - primitive.mask(view, "mask") - } - assertNotNull("precondition: mask should set a tag", view.tag) - - onMain { primitive.reset(view) } - assertNull("reset must clear the SDK tag (return to inherit)", view.tag) - } - - // 7. Touch transparency: the widened host frame necessarily overlaps - // unrelated siblings, so the host itself must never be a touch target — - // RN touch targeting must skip it (BOX_NONE) and only consider children. - @Test - fun host_pointerEvents_isBoxNone() { - lateinit var host: SRMaskView - onMain { host = SRMaskView(context) } - assertEquals( - "SRMaskView host must be BOX_NONE so its widened frame can't swallow input", - PointerEvents.BOX_NONE, - host.pointerEvents, - ) - } -} diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRDefaultMaskingPrimitive.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRDefaultMaskingPrimitive.kt deleted file mode 100644 index 05883dcfb..000000000 --- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRDefaultMaskingPrimitive.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.amplitude.sessionreplayreactnative - -import android.view.View -import com.amplitude.android.sessionreplay.SessionReplay - -/** - * Default [SRMaskingPrimitive] bridging the masking seam to the Amplitude - * Session Replay Android SDK's existing tag-based hooks: - * - * - `mask(view, "mask")` -> [SessionReplay.mask] - * - `mask(view, "block")` -> [SessionReplay.block] - * - `unmask(view)` -> [SessionReplay.unmask] - * - `reset(view)` -> `view.tag = null` (the SDK hooks are tag-based; - * clearing the tag returns the view to "inherit") - * - * Registered on the UI thread at SDK init - * ([SessionReplayReactNativeModule.setup]); registration replays intents - * recorded before init, so mount-before-init masking still applies. - */ -class SRDefaultMaskingPrimitive : SRMaskingPrimitive { - override fun mask(view: View, level: String) { - when (level) { - "block" -> SessionReplay.block(view) - // Default mask level is "mask"; unknown levels fail safe to masking. - else -> SessionReplay.mask(view) - } - } - - override fun unmask(view: View) { - SessionReplay.unmask(view) - } - - override fun reset(view: View) { - view.tag = null - } -} diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRMaskingPrimitive.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRMaskingPrimitive.kt deleted file mode 100644 index 2c688df14..000000000 --- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRMaskingPrimitive.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.amplitude.sessionreplayreactnative - -import android.view.View -import java.util.WeakHashMap - -/** - * Recorder-agnostic masking primitive. Until a concrete implementation is - * registered at SDK init, [SRMaskingRegistry] is inert. - */ -interface SRMaskingPrimitive { - fun mask(view: View, level: String) - fun unmask(view: View) - - /** Return the view to "inherit" — no longer explicitly masked or unmasked. */ - fun reset(view: View) -} - -/** - * Indirection between the Fabric `SRMaskView` and the concrete masking - * primitive. Records each live view's masking intent and replays it when a - * primitive registers, so views masked before registration are still applied - * (without a JS re-render). - * - * Single-threaded (main/UI thread) use, so no locking. - */ -object SRMaskingRegistry { - - private sealed interface Intent { - data class Mask(val level: String) : Intent - object Unmask : Intent - } - - @JvmStatic - var primitive: SRMaskingPrimitive? = null - private set - - private var warnedUnregistered = false - - // Weak keys let views GC naturally without leaking. - private val intents = WeakHashMap() - - @JvmStatic - fun setPrimitive(value: SRMaskingPrimitive?) { - primitive = value - if (value == null) { - return - } - for ((view, intent) in intents) { - when (intent) { - is Intent.Mask -> value.mask(view, intent.level) - Intent.Unmask -> value.unmask(view) - } - } - } - - @JvmStatic - fun mask(view: View, level: String) { - intents[view] = Intent.Mask(level) - primitive?.mask(view, level) ?: warnUnregisteredOnce() - } - - @JvmStatic - fun unmask(view: View) { - intents[view] = Intent.Unmask - primitive?.unmask(view) ?: warnUnregisteredOnce() - } - - @JvmStatic - fun reset(view: View) { - intents.remove(view) - primitive?.reset(view) ?: warnUnregisteredOnce() - } - - private fun warnUnregisteredOnce() { - if (!warnedUnregistered && BuildConfig.DEBUG) { - warnedUnregistered = true - android.util.Log.w( - "SRMaskingRegistry", - "No masking primitive registered; masking calls are recorded and will " + - "replay once a primitive registers.", - ) - } - } -} diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt index 77c691440..932b65c1d 100644 --- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt +++ b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt @@ -9,7 +9,6 @@ import com.amplitude.core.ServerZone import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise -import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.ReadableMap @@ -19,7 +18,6 @@ import com.facebook.react.bridge.ReadableMap class SessionReplayReactNativeModule(private val reactContext: ReactApplicationContext) : SessionReplayReactNativeSpec(reactContext) { private lateinit var sessionReplay: SessionReplay - @Volatile private var invalidated = false override fun getName(): String { return NAME @@ -80,32 +78,11 @@ class SessionReplayReactNativeModule(private val reactContext: ReactApplicationC "EU" -> ServerZone.EU else -> ServerZone.US }, - // Deferred to the UI-thread block below so primitive registration can - // be ordered before start(); behaviorally identical to SDK autoStart. - autoStart = false, + autoStart = autoStart, privacyConfig = PrivacyConfig(maskLevel = maskLevel), ) - - // Register the primitive, start capture, and resolve in ONE UI-thread - // block: the registry is UI-thread-only, and registration must precede - // start() on the capture (UI) thread so an early capture frame can't - // snapshot SRMaskView children before their masking intents apply. - // Re-registering on repeated setup() calls is harmless. - UiThreadUtil.runOnUiThread { - if (invalidated) { - promise.reject("SETUP_ERROR", "Session Replay module was invalidated before setup completed", null) - return@runOnUiThread - } - try { - SRMaskingRegistry.setPrimitive(SRDefaultMaskingPrimitive()) - if (autoStart) { - sessionReplay.start() - } - promise.resolve(null) - } catch (e: Exception) { - promise.reject("SETUP_ERROR", e.message, e) - } - } + + promise.resolve(null) } catch (e: Exception) { promise.reject("SETUP_ERROR", e.message, e) } @@ -200,15 +177,8 @@ class SessionReplayReactNativeModule(private val reactContext: ReactApplicationC } override fun invalidate() { - invalidated = true - // Serialize teardown with the deferred setup() block: both run on the UI - // queue, so a mid-setup invalidate can no longer interleave — shutdown - // always runs either before the block (flag rejects it) or after start() - // (normal stop). - UiThreadUtil.runOnUiThread { - if (::sessionReplay.isInitialized) { - sessionReplay.shutdown() - } + if (::sessionReplay.isInitialized) { + sessionReplay.shutdown() } } diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativePackage.kt b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativePackage.kt index 71ac5ee12..a3f69b55c 100644 --- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativePackage.kt +++ b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativePackage.kt @@ -1,6 +1,5 @@ package com.amplitude.sessionreplayreactnative -import com.amplitude.sessionreplayreactnative.fabric.SRMaskViewManager import com.facebook.react.BaseReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext @@ -36,6 +35,6 @@ class SessionReplayReactNativePackage : BaseReactPackage() { } override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf(SessionReplayReactNativeViewManager(), SRMaskViewManager()) + return listOf(SessionReplayReactNativeViewManager()) } } diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt deleted file mode 100644 index 97b1b3c48..000000000 --- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.amplitude.sessionreplayreactnative.fabric - -import android.content.Context -import android.view.View -import com.amplitude.sessionreplayreactnative.SRMaskingRegistry -import com.facebook.react.views.view.ReactViewGroup - -/** - * Fabric host view for the layout-transparent mask component. Its Yoga node is - * `display:contents`, so Fabric assigns it a 0x0 native frame; the view - * compensates in two ways that must not affect the Yoga layout of its children: - * disabled child clipping (a 0x0 host would otherwise clip children invisible) - * and [expandBoundsToChildrenUnion], which widens only this host's own frame so - * the session-replay capture gate (width>0 && height>0) doesn't drop the - * (mask-tagged) subtree. - */ -class SRMaskView(context: Context) : ReactViewGroup(context) { - var enabled: Boolean = true - private set - var unmask: Boolean = false - private set - var maskLevel: String = "mask" - private set - - // Guards recursion: setLeftTopRightBottom() can trigger another onLayout. - private var expanding = false - - init { - clipChildren = false - clipToPadding = false - // The widened frame (see expandBoundsToChildrenUnion) necessarily spans - // from the host's Fabric-assigned origin to the children's far corner, so - // it overlaps sibling views that have nothing to do with this mask. The - // host must therefore never participate in touch targeting itself: - // BOX_NONE makes RN's TouchTargetHelper skip the host (only its children - // can be targets) and lets misses fall through to views underneath. - // Children are unaffected. This view is never created from a JS - // pointerEvents prop, so nothing else writes this field. - // - // CROSS-VERSION CONSTRAINT (do not "simplify" to a Kotlin call/assignment - // or an accessor override): pointerEvents has three incompatible - // source-level shapes across RN versions and no single Kotlin syntax - // compiles against all of them — see SRMaskViewPointerEvents.java, which - // resolves the ever-present bytecode setter instead. Re-asserted in - // onLayout below so a recycling reset can never be observed by capture - // or touch. - SRMaskViewPointerEvents.forceBoxNone(this) - } - - // Children can be laid out after the host's own layout pass; re-widen then. - private val childLayoutChangeListener = - OnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> - if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { - expandBoundsToChildrenUnion() - } - } - - fun setMaskingProps(enabled: Boolean, unmask: Boolean, maskLevel: String) { - this.enabled = enabled - this.unmask = unmask - this.maskLevel = maskLevel - reapplyMaskingToAllChildren() - } - - override fun addView(child: View, index: Int) { - super.addView(child, index) - applyMaskingToChild(child) - child.addOnLayoutChangeListener(childLayoutChangeListener) - expandBoundsToChildrenUnion() - } - - override fun removeView(view: View) { - view.removeOnLayoutChangeListener(childLayoutChangeListener) - SRMaskingRegistry.reset(view) - super.removeView(view) - expandBoundsToChildrenUnion() - } - - override fun removeViewAt(index: Int) { - val child = getChildAt(index) - child?.removeOnLayoutChangeListener(childLayoutChangeListener) - child?.let { SRMaskingRegistry.reset(it) } - super.removeViewAt(index) - expandBoundsToChildrenUnion() - } - - // Fabric applies the degenerate 0x0 frame via the final View.layout(); - // onLayout is the hook that runs right after, where we can re-widen it. - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - // Re-assert touch transparency in case a recycling path reset the field - // (version-agnostic replacement for an accessor override; see init). - SRMaskViewPointerEvents.forceBoxNone(this) - expandBoundsToChildrenUnion() - } - - // Widen this host's native frame to enclose its children WITHOUT moving it. - // - // Measured coordinate model (SDKRN-33, RN 0.77.2 Fabric, on-device): - // children are ALWAYS host-relative — a child renders at - // (host.left + child.left, host.top + child.top), standard Android. The - // host's Fabric-assigned degenerate frame is (X,Y,X,Y) where (X,Y) is the - // accumulated origin of any flattened views between the host and its - // mounted parent: (0,0) for top-level masks (where host-relative and - // parent-space coincide numerically), non-zero for e.g. an AmpUnmask nested - // inside an AmpMask through a flattened , a mask inside a list row, - // or a mask whose child uses negative offsets. Therefore the enclosing - // extent in parent space is origin + max child extent. The origin must - // never move (children would shift on screen), so children at negative - // host-relative coordinates cannot be enclosed; the extents are clamped so - // the frame is never degenerate or inverted while children exist — the - // session-replay capture gate (width>0 && height>0) is the whole point of - // the widening. The widened frame can overlap unrelated siblings, which is - // why the host is pointer-events BOX_NONE (see init) and must never be a - // touch target itself. - private fun expandBoundsToChildrenUnion() { - if (expanding) return - if (childCount == 0) return - - var maxChildRight = 0 - var maxChildBottom = 0 - for (i in 0 until childCount) { - val c = getChildAt(i) ?: continue - if (c.right > maxChildRight) maxChildRight = c.right - if (c.bottom > maxChildBottom) maxChildBottom = c.bottom - } - - // Children are host-relative, so parent-space extent = origin + extent. - // Clamp to a 1px minimum so a child at fully negative coordinates can - // never produce a zero/inverted frame that the capture gate would drop. - val newRight = left + maxOf(maxChildRight, 1) - val newBottom = top + maxOf(maxChildBottom, 1) - - if (newRight != right || newBottom != bottom) { - expanding = true - try { - setLeftTopRightBottom(left, top, newRight, newBottom) - } finally { - expanding = false - } - } - } - - // Called from SRMaskViewManager.onDropViewInstance. Fabric can drop the host - // without routing children through removeView*; detach their listeners (which - // close over — and would keep alive — this host) and reset their masking. - fun onHostDropped() { - for (i in 0 until childCount) { - val child = getChildAt(i) ?: continue - child.removeOnLayoutChangeListener(childLayoutChangeListener) - SRMaskingRegistry.reset(child) - } - } - - private fun reapplyMaskingToAllChildren() { - for (i in 0 until childCount) { - applyMaskingToChild(getChildAt(i)) - } - } - - private fun applyMaskingToChild(child: View) { - if (!enabled) { - SRMaskingRegistry.reset(child) - return - } - - if (unmask) { - SRMaskingRegistry.unmask(child) - return - } - - SRMaskingRegistry.mask(child, maskLevel) - } -} diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt deleted file mode 100644 index 0f277c6f5..000000000 --- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.amplitude.sessionreplayreactnative.fabric - -import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.ViewGroupManager -import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.SRMaskViewManagerDelegate -import com.facebook.react.viewmanagers.SRMaskViewManagerInterface - -@ReactModule(name = SRMaskViewManager.NAME) -class SRMaskViewManager : - ViewGroupManager(), - SRMaskViewManagerInterface { - - private val delegate = SRMaskViewManagerDelegate(this) - - override fun getDelegate(): ViewManagerDelegate = delegate - - override fun getName(): String = NAME - - override fun createViewInstance(reactContext: ThemedReactContext): SRMaskView = - SRMaskView(reactContext) - - override fun setEnabled(view: SRMaskView, value: Boolean) { - view.setMaskingProps(value, view.unmask, view.maskLevel) - } - - override fun setUnmask(view: SRMaskView, value: Boolean) { - view.setMaskingProps(view.enabled, value, view.maskLevel) - } - - override fun setMaskLevel(view: SRMaskView, value: String?) { - view.setMaskingProps(view.enabled, view.unmask, value ?: "mask") - } - - override fun onDropViewInstance(view: SRMaskView) { - super.onDropViewInstance(view) - view.onHostDropped() - } - - companion object { - const val NAME = "SRMaskView" - } -} diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewPointerEvents.java b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewPointerEvents.java deleted file mode 100644 index 46ab7dc85..000000000 --- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewPointerEvents.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.amplitude.sessionreplayreactnative.fabric; - -import com.facebook.react.uimanager.PointerEvents; -import com.facebook.react.views.view.ReactViewGroup; - -/** - * Java on purpose. {@code pointerEvents} has THREE incompatible source-level - * shapes across React Native versions, so no single Kotlin syntax compiles - * against all of them: - * - *
    - *
  • ≤ ~0.77: Java interface + Java {@code ReactViewGroup} get/set pair - * (Kotlin property syntax OK, setter call OK)
  • - *
  • ~0.78–0.80: Kotlin {@code ReactPointerEventsView} declares - * {@code val pointerEvents} while {@code ReactViewGroup} is still Java — - * Kotlin resolves assignment against the interface's {@code val} and - * rejects it ({@code 'val' cannot be reassigned}); only an explicit - * {@code setPointerEvents(...)} call compiles
  • - *
  • 0.81+: {@code ReactViewGroup} is Kotlin with {@code var pointerEvents} — - * property syntax OK, but {@code setPointerEvents(...)} is an unresolved - * reference from Kotlin source
  • - *
- * - * The BYTECODE, however, has a {@code setPointerEvents(PointerEvents)} method - * in every era (the Java method through 0.80; the Kotlin-var-generated setter - * from 0.81, verified via javap of react-android AARs). Java source resolves - * against bytecode, so this shim compiles and works on every supported RN - * version. - */ -final class SRMaskViewPointerEvents { - private SRMaskViewPointerEvents() {} - - static void forceBoxNone(ReactViewGroup view) { - view.setPointerEvents(PointerEvents.BOX_NONE); - } -} diff --git a/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h b/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h deleted file mode 100644 index 09d712b46..000000000 --- a/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "SRMaskViewShadowNode.h" - -#include - -namespace facebook::react { - -// Replaces the codegen typedef of the same name (patched on Android; iOS binds -// via +componentDescriptorProvider). Exists only to bind the custom -// SRMaskViewContentsShadowNode class — no adopt() override; the ShadowNode's -// own constructors handle the ForceFlattenView unset (see -// SRMaskViewShadowNode.h). -class SRMaskViewComponentDescriptor final - : public ConcreteComponentDescriptor { - public: - using ConcreteComponentDescriptor::ConcreteComponentDescriptor; -}; - -} // namespace facebook::react diff --git a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp b/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp deleted file mode 100644 index 0f860f502..000000000 --- a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp +++ /dev/null @@ -1,54 +0,0 @@ -#include "SRMaskViewShadowNode.h" - -#include -#include -#include - -namespace facebook::react { - -void SRMaskViewContentsShadowNode::initialize() { - // display:contents comes from the JS style prop and is already in - // yogaNode_'s style (parsed by RN's own updateYogaProps(), which also set - // ForceFlattenView). We never write the Yoga style in C++; the only trait - // customization is keeping the host view mounted (the session-replay SDK - // needs a real view to tag for masking) while Yoga still lays the children - // out as if the host didn't exist. - if (YGNodeStyleGetDisplay(&yogaNode_) != YGDisplayContents) { - return; - } - - traits_.unset(ShadowNodeTraits::Trait::ForceFlattenView); - - // Workaround for an upstream Yoga display:contents bug present in - // RN 0.77-0.82 (fixed by facebook/react-native#56422, shipped in 0.86 and - // backports): because contents nodes are skipped by Yoga's layout - // traversal, their children are cloned through side channels - // (cleanupContentsNodesRecursively / layoutAbsoluteDescendants) that call - // cloneChildrenIfNeeded() on a contents node even when it is clean and - // belongs to an already-committed (sealed) tree. If this node was cloned - // without re-adopting its children, that fires the ShadowNode clone - // callback on the sealed node and SIGABRTs debug builds - // (Sealable::ensureUnsealed). Defuse the precondition: eagerly take - // ownership of every Yoga child at construction time (we are unsealed - // here), so cloneChildrenIfNeeded() on this node is always a no-op later. - // This mirrors what YogaLayoutableShadowNode::cloneChildInPlace() does - // lazily during layout, just moved to a legal (unsealed) point in time. - auto yogaChildren = yogaNode_.getChildren(); // copy: replaceChild mutates - for (yoga::Node* childYogaNode : yogaChildren) { - if (childYogaNode->getOwner() == &yogaNode_) { - continue; - } - auto& childShadowNode = *static_cast( - childYogaNode->getContext()); - auto clonedChildShadowNode = childShadowNode.clone( - {ShadowNodeFragment::propsPlaceholder(), - ShadowNodeFragment::childrenPlaceholder(), - childShadowNode.getState()}); - // Public replaceChild() performs the same Yoga bookkeeping as RN's - // private cloneChildInPlace(): swaps the ShadowNode child and re-owns - // the fresh clone's Yoga node under yogaNode_. - replaceChild(childShadowNode, clonedChildShadowNode); - } -} - -} // namespace facebook::react diff --git a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h b/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h deleted file mode 100644 index 650bdc4fc..000000000 --- a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace facebook::react { - -// Named distinctly from codegen's SRMaskViewShadowNode typealias to avoid ODR -// clashes with generated ShadowNodes.h while reusing the same component name. -extern const char SRMaskViewComponentName[]; - -// The JS component passes style={display:'contents'}; RN's own -// updateYogaProps() (run inside the base constructors) parses it into the -// Yoga style and sets ShadowNodeTraits::ForceFlattenView. We never mutate the -// Yoga style in C++ — the only customization is unsetting ForceFlattenView -// after each construction so the Android/iOS host view stays mounted (the -// session-replay SDK needs a real view to tag for masking) while Yoga still -// lays the children out as if the host didn't exist. -// -// This mirrors the pattern shipped by Expensify react-native-live-markdown -// (MarkdownTextInputDecoratorShadowNode) and Expo's expo-modules-core -// (ExpoViewShadowNode's disableForceFlatten): a post-constructor hook invoked -// from BOTH constructors (create + clone), no ComponentDescriptor::adopt() -// override, no Yoga style writes. -class SRMaskViewContentsShadowNode final - : public ConcreteViewShadowNode< - SRMaskViewComponentName, - SRMaskViewProps, - SRMaskViewEventEmitter> { - public: - SRMaskViewContentsShadowNode( - const ShadowNodeFragment& fragment, - const ShadowNodeFamily::Shared& family, - ShadowNodeTraits traits) - : ConcreteViewShadowNode(fragment, family, traits) { - initialize(); - } - - SRMaskViewContentsShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment) - : ConcreteViewShadowNode(sourceShadowNode, fragment) { - initialize(); - } - - private: - // Runs after the base constructors (and therefore after updateYogaProps(), - // which re-sets ForceFlattenView on every clone that carries new props). - // Must be called from every constructor. Besides the trait unset it also - // eagerly re-owns this node's Yoga children — a workaround for an upstream - // RN 0.77-0.82 display:contents crash; see the .cpp for details. - void initialize(); -}; - -} // namespace facebook::react diff --git a/packages/session-replay-react-native/example/App.tsx b/packages/session-replay-react-native/example/App.tsx index 7c3ae55d7..1b321198e 100644 --- a/packages/session-replay-react-native/example/App.tsx +++ b/packages/session-replay-react-native/example/App.tsx @@ -24,7 +24,6 @@ import { Switch, Text, TextInput, - UIManager, View, } from 'react-native'; import { WebView } from 'react-native-webview'; @@ -43,9 +42,6 @@ import { getSessionReplayProperties, setSessionId, setDeviceId, - AmpMask, - AmpUnmask, - AmpMaskView, } from '@amplitude/session-replay-react-native'; const g = global as unknown as { @@ -55,9 +51,6 @@ const g = global as unknown as { }; const isTurboModule = g.__turboModuleProxy != null || g.RN$Bridgeless === true; const isFabric = g.nativeFabricUIManager != null; -// Same New Architecture check the library uses to select the AmpMask -// implementation (src/index.tsx) — keep the two in sync. -const isNewArch = g.RN$Bridgeless === true || g.nativeFabricUIManager != null; const rnv = Platform.constants?.reactNativeVersion; const rnVersion = rnv ? `${rnv.major}.${rnv.minor}.${rnv.patch}` : 'unknown'; @@ -75,7 +68,6 @@ type RootStackParamList = { Form: undefined; Gallery: undefined; Web: undefined; - Mask: undefined; }; type HomeProps = NativeStackScreenProps; @@ -180,7 +172,6 @@ function HomeScreen({ navigation }: HomeProps): React.JSX.Element {