@everframe/react-native 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,7 +40,7 @@ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
40
40
  # 1.2.0, so the range already asks for 1.2 while package.json is still 1.1.0.
41
41
  # The 1.2.0 release moves package.json to match.
42
42
  native_version_override = ENV['EVERFRAME_NATIVE_POD_VERSION'].to_s.strip
43
- native_minor_range = native_version_override.empty? ? '~> 1.3.0' : native_version_override
43
+ native_minor_range = native_version_override.empty? ? '~> 1.4.0' : native_version_override
44
44
 
45
45
  Pod::Spec.new do |s|
46
46
  s.name = 'EverframeRN'
@@ -86,7 +86,7 @@ Pod::Spec.new do |s|
86
86
  s.dependency 'React-Core'
87
87
 
88
88
  s.test_spec 'Tests' do |t|
89
- t.source_files = 'ios/Tests/**/*.swift'
89
+ t.source_files = 'ios/Tests/**/*.{swift,m,mm}'
90
90
  t.frameworks = ['XCTest', 'UIKit']
91
91
  end
92
92
  end
package/README.md CHANGED
@@ -840,3 +840,186 @@ activity recreation.
840
840
  ## License
841
841
 
842
842
  MIT
843
+
844
+ ### Optional interaction target annotations
845
+
846
+ Phone taps and TV remote interactions automatically use the available native
847
+ control role, accessibility label, test ID, visible text, and screen marker.
848
+ Reporting and automatic breadcrumbs work without app annotations. Logical
849
+ collection positions are included only when known; mounted children are never
850
+ used as the total item count.
851
+
852
+ Use `useEverframeTarget` on an existing host to add product names or logical
853
+ collection metadata:
854
+
855
+ ```tsx
856
+ import { useEverframeTarget } from '@everframe/react-native';
857
+
858
+ const targetRef = useEverframeTarget({
859
+ testId: 'poster-low-orbit', role: 'button', name: 'Low Orbit',
860
+ container: { testId: 'home-originals', name: 'Originals', role: 'list', index: 0, count: 12 },
861
+ });
862
+ return <Pressable ref={targetRef} onPress={openTitle}><Text>Low Orbit</Text></Pressable>;
863
+ ```
864
+
865
+ Indices are zero-based logical positions in the collection. An explicit
866
+ container replaces the automatic container as a unit. Updating the annotation
867
+ updates the existing host without waiting for `onLayout`; passing `undefined`
868
+ removes it. Strings are bounded to 256 UTF-16 units (roles: 64). Do not put
869
+ secrets or user-entered values into annotations. Existing sensitive-view masking
870
+ takes precedence and suppresses names, IDs, containers, and screen metadata.
871
+
872
+ Compose with an existing ref using a stable callback (return no value):
873
+
874
+ ```tsx
875
+ const ref = React.useCallback((node: React.ElementRef<typeof View> | null) => {
876
+ existingRef.current = node;
877
+ targetRef(node);
878
+ }, [targetRef]);
879
+ ```
880
+
881
+ Custom components must forward the ref to their native host. For a layout-only
882
+ `View`, use `collapsable={false}` so a host exists. Unsupported custom refs,
883
+ missing providers, and older native binaries fail softly. This feature requires
884
+ a native rebuild to enable annotation registration; it cannot be enabled by a
885
+ JavaScript-only update. The web export accepts the same type and is a no-op.
886
+ No wrapper, `onPress` replacement, or manual breadcrumb is needed.
887
+
888
+ Native registrations use weak host references and cancel stale ref generations.
889
+ After 4096 distinct registrations in one native session, optional annotations
890
+ fail closed until SDK restart; automatic target extraction continues. This
891
+ bound prevents delayed work from resurrecting evicted registration ownership.
892
+
893
+ Native target qualification uses the installed Fabric fixture under
894
+ `__tests__/fixtures/interaction-targets`. Rebuild native binaries after upgrading.
895
+ The iOS sensitive wrapper retains its native host and resolves it through the
896
+ Fabric view registry, so masked descendants stay excluded from target labels.
897
+
898
+ ### State labels (iOS and Android)
899
+
900
+ `setState(key, value)` records a string, finite number, or boolean diagnostic label;
901
+ `clearState(key)` removes it. Both are also available from `useEverframe()`.
902
+ Call after the provider mounts. State is redacted, bounded, and frozen with report
903
+ evidence. See [State API](../../docs/state.md) for lifecycle, privacy, limits and
904
+ native recovery freshness.
905
+
906
+ ## Semantic interaction evidence
907
+
908
+ On iOS, Android and their TV variants, supported native controls record text edits,
909
+ control changes and completed scroll gestures automatically. Existing taps and
910
+ reporting continue to work without hooks. Programmatic text/control updates do not
911
+ represent user interaction. TV focus movement only counts as scrolling when the
912
+ native content offset changes.
913
+
914
+ Text evidence contains its UTF-16 length by default. Secure or sensitive fields
915
+ produce no text evidence, including length. To permit a value locally, configure
916
+ an exact native `testID`:
917
+
918
+ ```tsx
919
+ <EverframeProvider config={{
920
+ sdkKey: 'txx_…',
921
+ interactions: { valueTestIds: ['search-input'] },
922
+ }}>
923
+ <App />
924
+ </EverframeProvider>
925
+ ```
926
+
927
+ Optional values are redacted and limited to 256 UTF-16 units. Allowlisting a name
928
+ only in hook metadata never grants permission: the live native host must carry
929
+ that exact `testID`. Setting `interactions: { disabled: true }` disables these
930
+ semantic events without changing existing tap/error capture.
931
+
932
+ Use `useEverframeInteraction` to add target metadata to an existing native host.
933
+ Its ref adds no wrapper or app handler. Compose it with any existing app ref:
934
+
935
+ ```tsx
936
+ import { useEverframeInteraction } from '@everframe/react-native';
937
+ import type { ElementRef } from 'react';
938
+ import { TextInput } from 'react-native';
939
+
940
+ function SearchField() {
941
+ const interaction = useEverframeInteraction<ElementRef<typeof TextInput>>({
942
+ target: { testId: 'search-input', name: 'Search' },
943
+ });
944
+ return <TextInput testID="search-input" ref={interaction.ref} />;
945
+ }
946
+ ```
947
+
948
+ Native observation owns text, control and scroll evidence. `helperTypes` is
949
+ retained for source compatibility and always registers an empty list, including
950
+ legacy declarations. Each native bridge also strips helper ownership from older
951
+ JavaScript callers, so these declarations never suppress automatic observation.
952
+ Existing tap evidence is preserved. Missing providers, unsupported refs, older
953
+ native binaries and the web entry safely leave metadata registration inactive.
954
+
955
+ `textChanged(text, options?)`, `controlChanged(change)` and
956
+ `scrollSettled(change)` are compatibility no-ops. They never inspect their
957
+ payloads or send values, lengths or state across the bridge. JavaScript callbacks
958
+ cannot prove whether a host was sensitive when the observation originated;
959
+ checking after it is unmasked would be too late. Custom JavaScript controls
960
+ without a supported native observation point produce missing semantic evidence.
961
+
962
+ Clearing evidence, signing out or changing native capture configuration revokes
963
+ pending work. A queued callback keeps its original ownership and cannot renew it.
964
+ An unchanged mounted hook does not replay old metadata; a fresh committed host
965
+ attachment or metadata change can register again.
966
+
967
+ Text coalesces after 750 ms of inactivity, editing end, submission or safe report
968
+ freeze. An unfinished native composition retains only the last observed user-edit
969
+ length, without its value. If the keyboard ends composition without another text
970
+ change after the idle check, that summary waits for editing end or safe report
971
+ freeze. Its length describes the observed edit, including when the app later
972
+ replaces the text; it is not a newly sampled final value.
973
+ Scrolls coalesce at gesture completion or after 200 ms of inactivity.
974
+ Semantic events share a 20-per-10-second budget; text and scroll also share a
975
+ one-per-second budget per host. Values are bounded again after final redaction.
976
+
977
+ Incoming links are observed through React Native's existing `Linking` channel while the provider is mounted. The SDK does not route links. By default, evidence contains only scheme and host; valid opaque or hostless links retain only their scheme (for example `myapp:`). Parsing works in bare React Native without a global URL patch. Declare known route templates (for example `/watch/:id`) and exact query keys in `interactions.urlRules` to include more. Credentials, fragments and actual dynamic path segments are omitted; ambiguous encoded separators and dot segments are rejected. The initial URL is considered once per JS process. If capture is disabled at that first mount, it is omitted permanently; later mounts and re-enabling capture observe only fresh URL callbacks. The mounted subscription stays active while local policy enables it, and each new callback checks the current native capture lease. Distinct URL callbacks remain distinct observations even when their URLs match, because React Native supplies no delivery ID.
978
+
979
+ Observe an existing permission operation without changing its result:
980
+
981
+ ```ts
982
+ import { observePermissionRequest, recordLaunchContext } from '@everframe/react-native';
983
+ import { PermissionsAndroid } from 'react-native';
984
+
985
+ const result = await observePermissionRequest('android.permission.CAMERA', () =>
986
+ PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA),
987
+ );
988
+
989
+ // For an existing notification integration; call during the matching foreground entry.
990
+ recordLaunchContext({ trigger: 'notification', arguments: { campaign: 'autumn' } });
991
+ ```
992
+
993
+ Without a mapper, the permission observer recognizes Android's `granted`, `denied` and `never_ask_again` results. Supply a mapper returning `granted`, `denied`, `blocked`, `limited`, `unavailable` or `unknown` for iOS or third-party APIs. The operation runs exactly once; its original value or rejection is preserved. A mapper failure or rejected request is recorded as `unknown`, never inferred as denial. A result does not prove a system dialog was shown. TV APIs that do not support a permission should map their actual result to `unavailable`.
994
+
995
+ Launch context enriches only a native foreground entry that is still pending (up to 250 ms, ending earlier on report freeze or background). It cannot create a launch, change cold/warm classification, or alter a frozen report. Late context is omitted. Only exact `interactions.launchArgumentKeys` allowlisted primitive values are accepted, at most 16 entries and 128 UTF-16 units per string. URLs belong to link evidence and are omitted from launch arguments. No arguments are captured by default.
996
+
997
+ Permission operations still run without a provider, with older native binaries and on web. Launch context is a no-op in those cases. A clear, sign-out or native configuration change revokes in-flight observations; it does not cancel the app's operation.
998
+
999
+ Target annotations enrich supported automatic native observations, including IME composition and optional allowlisted values. Native integrations that observe controls directly on the UI thread can supply native helpers with original privacy proof; the JavaScript compatibility methods cannot provide that proof.
1000
+
1001
+ ### Reporter boundaries and developer tools
1002
+
1003
+ Native phone reporter opens and companion report requests add this custom breadcrumb before freezing evidence:
1004
+
1005
+ ```json
1006
+ {"kind":"custom","message":"reporter_opened","data":{"source":"everframe_reporter","trigger":"button"}}
1007
+ ```
1008
+
1009
+ `trigger` is `button`, `shake`, `menu`, or `companion`. Pending text/scroll summaries are flushed before this boundary. Native shake uses `shake`; companion report requests use `companion`; existing `open()` calls use `button`. To identify a host menu entry explicitly, call `open({ trigger: 'menu' })`. Closing or cancelling the native flow adds `reporter_closed` with `data.source: 'everframe_reporter'` to the live trail. Both events retain breadcrumb capture/config gates.
1010
+
1011
+ A reporter call crosses the React Native bridge after its native input can already have been recorded. It cannot prove that the previous tap or remote key opened the reporter. For a button, annotate its native host ref before interaction:
1012
+
1013
+ ```tsx
1014
+ const reporterRef = useEverframeTarget({ reporterTrigger: 'button' });
1015
+ const { open } = useEverframe();
1016
+ return <Pressable ref={reporterRef} onPress={() => void open()}><Text>Report</Text></Pressable>;
1017
+ ```
1018
+
1019
+ That target's tap/activation carries `data.origin: 'reporter'` and `data.trigger: 'button'`. On a remote, only Select/Enter activation is attributed to a `button` target; arrows, Menu and Play/Pause retain their ordinary meaning. An explicitly annotated `reporterTrigger: 'menu'` target attributes only its Menu activation. Attribution survives masking without retaining the target's text. Removing/recycling the bound ref removes attribution.
1020
+
1021
+ Hosts owning a reporting flow outside `open()` can `await markReporterOpened('menu')` before showing their reporter UI and `await markReporterClosed()` when it closes. These promises resolve after the native UI-thread boundary work; await them before adding subsequent host breadcrumbs or changing reporter UI. These explicit markers do not claim or remove earlier native keys: a TV event callback may run after native key capture. Use companion report-request boundaries when a host cannot attribute the original remote trigger. Never infer reporter intent from a button label, a Menu/Play/Pause key, or proximity to `open()`.
1022
+
1023
+ Native target capture adds `data.origin: 'devtools'` to supported taps and structured text/control/scroll observations whose native ancestry belongs to React Native LogBox, the native development menu, or Expo development-client UI. This comes from framework view/window/controller identity, an RN-owned development-menu controller, or a framework root module identity. In a development host, the inline LogBox notification/inspector is also registered by the exact React Native component exports through the existing React DevTools commit hook; application components with the same name are not attributed. This observer reads component identity and native refs, never warning text or component props, and removes registrations when their committed hosts disappear or the provider unmounts. Production JavaScript leaves this observer inert. A development JavaScript host can still attribute LogBox when its native SDK was built in Release mode. Display text such as “Dismiss” never grants an origin. A missing React DevTools hook, unavailable RN private module exports at runtime, initial asynchronous module loading, unknown framework versions and surfaces outside the native capture adapters leave attribution unavailable; origin tagging does not install a new dialog capture mechanism. The observer depends on the LogBox module paths in supported React Native versions; a future version removing those paths requires an SDK compatibility update before Metro can bundle it.
1024
+
1025
+ Consumers should use the latest active reporter interval (matching `reporter_opened`/`reporter_closed` markers by ordered trail), cut before its explicitly attributed triggering interaction when present, and otherwise before its opened marker. Closed reporter intervals can also be excluded while preserving application actions after their closing marker. Developer-tool observations can be excluded by their explicit `data.origin`. A cancelled earlier report must not become the boundary for a later report. Legacy trails lacking these fields remain valid and supply no new attribution evidence.
@@ -153,7 +153,7 @@ val rnSdkPkg = groovy.json.JsonSlurper()
153
153
  val rnSdkVersion = rnSdkPkg["version"] as String
154
154
  // Native 1.2.0 is the release that renamed the SDK key to `sdkKey`; the
155
155
  // bridge moves with it, ahead of package.json, which the 1.2.0 release bumps.
156
- val nativeMinorRange: String = nativeVersionOverride ?: "[1.3.0,1.4.0)"
156
+ val nativeMinorRange: String = nativeVersionOverride ?: "[1.4.0,1.5.0)"
157
157
  if (nativeVersionOverride != null) {
158
158
  logger.lifecycle(
159
159
  "[everframe-rn] native AAR pinned to $nativeVersionOverride via everframeNativeVersion " +
@@ -106,6 +106,44 @@ class EverframeModule(
106
106
  private val reactContext: ReactApplicationContext,
107
107
  ) : NativeEverframeSpec(reactContext) {
108
108
 
109
+ private val interactionTargetAdapter = dev.everframe.capture.InteractionTargetViewAdapters.register(RnInteractionTargetAdapter)
110
+ private val interactionEvents = RnInteractionEventRegistration(
111
+ { tag -> com.facebook.react.uimanager.UIManagerHelper.getUIManagerForReactTag(reactContext, tag)?.resolveView(tag) },
112
+ { action -> com.facebook.react.bridge.UiThreadUtil.runOnUiThread { action() } },
113
+ )
114
+ private val interactionEventObserver = RnInteractionEventObserver.install(reactContext) { interactionEvents.active() }
115
+ private val interactionTargets = RnInteractionTargetRegistration(
116
+ { tag -> UIManagerHelper.getUIManagerForReactTag(reactContext, tag)?.resolveView(tag)?.takeIf { it.isAttachedToWindow } },
117
+ { block -> UiThreadUtil.runOnUiThread { block() } },
118
+ )
119
+
120
+ private var interactionMountsClosed = false
121
+ private var interactionTargetUIManager: com.facebook.react.bridge.UIManager? = null
122
+ @OptIn(com.facebook.react.common.annotations.UnstableReactNativeAPI::class)
123
+ private val interactionMountListener = object : com.facebook.react.bridge.UIManagerListener {
124
+ override fun willDispatchViewUpdates(uiManager: com.facebook.react.bridge.UIManager) = Unit
125
+ override fun willMountItems(uiManager: com.facebook.react.bridge.UIManager) = Unit
126
+ override fun didMountItems(uiManager: com.facebook.react.bridge.UIManager) { interactionTargets.onMount(); interactionEvents.onMount() }
127
+ override fun didDispatchMountItems(uiManager: com.facebook.react.bridge.UIManager) { interactionTargets.onMount(); interactionEvents.onMount() }
128
+ override fun didScheduleMountItems(uiManager: com.facebook.react.bridge.UIManager) = Unit
129
+ }
130
+ @OptIn(com.facebook.react.common.annotations.UnstableReactNativeAPI::class)
131
+ @Synchronized private fun observeInteractionMounts(tag: Int) {
132
+ if (interactionMountsClosed) return
133
+ val manager = UIManagerHelper.getUIManagerForReactTag(reactContext, tag) ?: return
134
+ if (interactionTargetUIManager === manager) return
135
+ interactionTargetUIManager?.removeUIManagerEventListener(interactionMountListener)
136
+ interactionTargetUIManager = manager
137
+ manager.addUIManagerEventListener(interactionMountListener)
138
+ }
139
+
140
+ @OptIn(com.facebook.react.common.annotations.UnstableReactNativeAPI::class)
141
+ @Synchronized private fun stopObservingInteractionMounts() {
142
+ interactionMountsClosed = true
143
+ interactionTargetUIManager?.removeUIManagerEventListener(interactionMountListener)
144
+ interactionTargetUIManager = null
145
+ }
146
+
109
147
  private val reporterBridge = ReporterBridge()
110
148
 
111
149
  private val videoPrivacyAdapter = Everframe.__registerVideoPrivacyAdapter(RnVideoPrivacyAdapter)
@@ -290,6 +328,9 @@ class EverframeModule(
290
328
  release = sdkVersion,
291
329
  capture = CaptureConfig(networkBodies = !networkBodiesDisabled, crash = !crashReportingDisabled),
292
330
  installIdentifierEnabled = !installIdentifierDisabled,
331
+ interactions = dev.everframe.capture.InteractionEventPolicy.parse(opts.takeIfHasString("interactionsJson")),
332
+ loadedJsBundle = dev.everframe.config.LoadedJsBundle.create(
333
+ opts.takeIfHasString("jsBundleBuildId"), opts.takeIfHasString("jsBundleName")),
293
334
  releaseHealth = parseReleaseHealth(opts),
294
335
  // The JS facade always sends React Native's `__DEV__` (or the host's override). A missing
295
336
  // or null key stays null so the native SDK decides for itself, as for any other caller.
@@ -369,6 +410,7 @@ class EverframeModule(
369
410
  // or the host is left with a dead SDK and no way to revive it.
370
411
  // • `attachPinUi` — the one bridge option that lands in module state instead of
371
412
  // in `EverframeConfig`, so config equality alone cannot see it change.
413
+ interactionEventObserver.refresh(reactContext)
372
414
  if (Everframe.captureGate &&
373
415
  Everframe.currentConfig == config &&
374
416
  companionAttachPinUi == attachPinUi
@@ -392,6 +434,7 @@ class EverframeModule(
392
434
  // Window.Callback wrapper is never installed on the current screen
393
435
  // and the session ships zero tap crumbs (field bug 2026-07-10).
394
436
  Everframe.start(reactContext.applicationContext, config, reactContext.currentActivity)
437
+ interactionTargets.onStarted()
395
438
 
396
439
  // Codex round-5, G1 — the registry is NOT renewed here any more. The live players
397
440
  // this start just orphaned are re-registered from JS instead: `mount()` bumps the
@@ -427,6 +470,45 @@ class EverframeModule(
427
470
  */
428
471
  @ReactMethod
429
472
  override fun openReporter(promise: Promise) {
473
+ openReporterInternal(null,promise)
474
+ }
475
+
476
+ @ReactMethod
477
+ override fun openReporterWithTrigger(trigger: String, promise: Promise) {
478
+ val value = dev.everframe.config.ReporterTrigger.fromValue(trigger)
479
+ if (value == null) {
480
+ promise.reject(REJECT_OPEN_REPORTER_FAILED,"Invalid reporter trigger")
481
+ return
482
+ }
483
+ openReporterInternal(value,promise)
484
+ }
485
+
486
+ @ReactMethod
487
+ override fun markReporterOpened(trigger: String, promise: Promise) {
488
+ runReporterMarker(Everframe.__prepareReporterOpened(trigger),promise)
489
+ }
490
+
491
+ @ReactMethod
492
+ override fun markReporterClosed(promise: Promise) {
493
+ runReporterMarker(Everframe.__prepareReporterClosed(),promise)
494
+ }
495
+
496
+ private fun runReporterMarker(action: Runnable, promise: Promise) {
497
+ try {
498
+ UiThreadUtil.runOnUiThread {
499
+ try {
500
+ action.run()
501
+ promise.resolve(null)
502
+ } catch (_: Throwable) {
503
+ promise.reject("reporter_marker_failed","Could not record reporter marker")
504
+ }
505
+ }
506
+ } catch (_: Throwable) {
507
+ promise.reject("reporter_marker_failed","Could not schedule reporter marker")
508
+ }
509
+ }
510
+
511
+ private fun openReporterInternal(trigger: dev.everframe.config.ReporterTrigger?, promise: Promise) {
430
512
  if (!Everframe.captureGate) {
431
513
  promise.reject(REJECT_CAPTURE_DISABLED, "Everframe not started (captureGate=false). Call configure() first.")
432
514
  return
@@ -439,7 +521,10 @@ class EverframeModule(
439
521
  }
440
522
  MainScope().launch {
441
523
  try {
442
- val result: ReportResult = reporterBridge.open(reactContext)
524
+ val result: ReportResult = if (trigger == null) reporterBridge.open(reactContext)
525
+ else kotlinx.coroutines.withContext(dev.everframe.config.ReporterTriggerContext(trigger)) {
526
+ reporterBridge.open(reactContext)
527
+ }
443
528
  val dict = Arguments.createMap().apply {
444
529
  when (result) {
445
530
  is ReportResult.Submitted -> {
@@ -602,7 +687,13 @@ class EverframeModule(
602
687
  * BEFORE the Provider's configure-on-mount, so clearing there would drop
603
688
  * legitimate registrations that had already landed.
604
689
  */
690
+ @OptIn(com.facebook.react.common.annotations.UnstableReactNativeAPI::class)
605
691
  override fun invalidate() {
692
+ interactionEventObserver.close()
693
+ interactionEvents.close()
694
+ stopObservingInteractionMounts()
695
+ interactionTargets.close()
696
+ interactionTargetAdapter.close()
606
697
  sensitiveRegistration.close()
607
698
  videoPrivacyAdapter.close()
608
699
  super.invalidate()
@@ -623,6 +714,16 @@ class EverframeModule(
623
714
  extraResolverActive = false
624
715
  }
625
716
 
717
+ @ReactMethod(isBlockingSynchronousMethod = true)
718
+ override fun setStateSync(key: String, valueJson: String): Boolean {
719
+ if (valueJson.length > 2048) return false
720
+ val value = try { kotlinx.serialization.json.Json.decodeFromString(dev.everframe.protocol.generated.StateValue.serializer(), valueJson) }
721
+ catch (_: Exception) { return false }
722
+ return Everframe.setState(key, value)
723
+ }
724
+ @ReactMethod(isBlockingSynchronousMethod = true)
725
+ override fun clearStateSync(key: String): Boolean = Everframe.clearState(key)
726
+
626
727
  // ---------------- setUser (self-declared identity — spec 2026-08-12) ----------------
627
728
 
628
729
  /**
@@ -760,6 +861,26 @@ class EverframeModule(
760
861
  */
761
862
  internal var __drainRequester: () -> Unit = { Everframe.requestOutboxDrain() }
762
863
 
864
+ @ReactMethod(isBlockingSynchronousMethod = true)
865
+ override fun setInteractionTarget(viewTag: Double, registrationId: String, generation: Double, annotation: ReadableMap): Boolean =
866
+ runCatching {
867
+ if (viewTag.isFinite() && viewTag > 0 && viewTag <= Int.MAX_VALUE && viewTag == viewTag.toInt().toDouble()) observeInteractionMounts(viewTag.toInt())
868
+ interactionTargets.set(viewTag, registrationId, generation, annotation.toHashMap())
869
+ }.getOrDefault(false)
870
+
871
+ @ReactMethod(isBlockingSynchronousMethod = true)
872
+ override fun getInteractionLease(): String = interactionEvents.lease()
873
+ override fun setInteractionObserver(viewTag: Double, id: String, generation: Double, lease: String, optionsJson: String): Boolean =
874
+ runCatching {
875
+ if (viewTag.isFinite() && viewTag > 0 && viewTag <= Int.MAX_VALUE && viewTag == viewTag.toInt().toDouble()) observeInteractionMounts(viewTag.toInt())
876
+ interactionEvents.set(viewTag,id,generation,lease,optionsJson)
877
+ }.getOrDefault(false)
878
+ override fun clearInteractionObserver(id: String, generation: Double): Boolean = runCatching { interactionEvents.clear(id,generation) }.getOrDefault(false)
879
+ override fun recordInteraction(lease: String, id: String, generation: Double, eventJson: String): Boolean = runCatching { interactionEvents.record(lease,id,generation,eventJson) }.getOrDefault(false)
880
+
881
+ override fun clearInteractionTarget(registrationId: String, generation: Double): Boolean =
882
+ runCatching { interactionTargets.clear(registrationId, generation) }.getOrDefault(false)
883
+
763
884
  // ---------------- registerSensitiveRect ----------------
764
885
 
765
886
  @ReactMethod
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ package dev.everframe.rn
4
+
5
+ import android.os.Looper
6
+ import android.view.View
7
+ import android.widget.CompoundButton
8
+ import com.facebook.react.bridge.ReactApplicationContext
9
+ import com.facebook.react.bridge.UiThreadUtil
10
+ import com.facebook.react.uimanager.UIManagerHelper
11
+ import com.facebook.react.uimanager.events.Event
12
+ import com.facebook.react.uimanager.events.EventDispatcher
13
+ import com.facebook.react.uimanager.events.EventDispatcherListener
14
+ import com.facebook.react.views.textinput.ReactEditText
15
+ import dev.everframe.capture.InteractionTextAdapter
16
+ import dev.everframe.capture.InteractionControlAdapter
17
+ import dev.everframe.capture.InteractionScrollAdapter
18
+
19
+ /** Observes before JS dispatch. Never reads protected event payloads or replaces app callbacks. */
20
+ internal class RnInteractionEventObserver(private val resolve: (Int, Int) -> View?, private val active: () -> Boolean = { true }) : EventDispatcherListener, AutoCloseable {
21
+ private var dispatcher: EventDispatcher? = null
22
+ @Volatile private var closed = false
23
+ fun attach(value: EventDispatcher) {
24
+ if (closed || dispatcher === value) return
25
+ dispatcher?.removeListener(this); dispatcher = value; value.addListener(this)
26
+ }
27
+ fun refresh(context: ReactApplicationContext) {
28
+ UiThreadUtil.runOnUiThread { runCatching { UIManagerHelper.getEventDispatcher(context)?.let(::attach) } }
29
+ }
30
+ override fun onEventDispatch(event: Event<*>) {
31
+ // Queuing an event would acquire ownership after a possible clear and retain raw payload.
32
+ if (closed || !active() || Looper.myLooper() != Looper.getMainLooper()) return
33
+ runCatching {
34
+ val name = event.getEventName()
35
+ if (name !in names) return
36
+ val view = resolve(event.viewTag, event.surfaceId) ?: return
37
+ if (!view.isAttachedToWindow || UIManagerHelper.getSurfaceId(view) != event.surfaceId) return
38
+ when {
39
+ view is ReactEditText -> when (name) {
40
+ "topChange" -> InteractionTextAdapter.shared.changed(view)
41
+ "topEndEditing" -> InteractionTextAdapter.shared.ended(view)
42
+ "topSubmitEditing" -> InteractionTextAdapter.shared.ended(view, submit = true)
43
+ else -> Unit
44
+ }
45
+ view is CompoundButton && view.javaClass.name == "com.facebook.react.views.switchview.ReactSwitch" && name == "topChange" -> InteractionControlAdapter.shared.changed(view)
46
+ view.javaClass.name in scrollHosts -> when (name) {
47
+ "topScrollBeginDrag" -> InteractionScrollAdapter.shared.began(view, if (view is android.widget.HorizontalScrollView) "x" else "y")
48
+ "topScroll" -> InteractionScrollAdapter.shared.moved(view)
49
+ "topScrollEndDrag" -> InteractionScrollAdapter.shared.endDrag(view)
50
+ "topMomentumScrollEnd" -> InteractionScrollAdapter.shared.finished(view)
51
+ else -> Unit
52
+ }
53
+ }
54
+ }
55
+ }
56
+ override fun close() {
57
+ closed = true
58
+ dispatcher?.removeListener(this); dispatcher = null
59
+ }
60
+ companion object {
61
+ private val names = setOf("topChange", "topEndEditing", "topSubmitEditing", "topScrollBeginDrag", "topScroll", "topScrollEndDrag", "topMomentumScrollEnd")
62
+ private val scrollHosts = setOf("com.facebook.react.views.scroll.ReactScrollView", "com.facebook.react.views.scroll.ReactHorizontalScrollView")
63
+ fun install(context: ReactApplicationContext, active: () -> Boolean): RnInteractionEventObserver {
64
+ val observer = RnInteractionEventObserver({ tag, _ -> UIManagerHelper.getUIManagerForReactTag(context, tag)?.resolveView(tag) }, active)
65
+ observer.refresh(context)
66
+ return observer
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ package dev.everframe.rn
4
+
5
+ import android.view.View
6
+ import dev.everframe.capture.InteractionEvents
7
+ import dev.everframe.capture.PreparedInteraction
8
+ import java.lang.ref.WeakReference
9
+ import kotlinx.serialization.json.*
10
+
11
+ /** Queued work owns the original lease. A new runtime cannot renew old refs or callbacks. */
12
+ internal class RnInteractionEventRegistration(
13
+ private val resolve: (Int) -> View?,
14
+ private val dispatch: (() -> Unit) -> Unit,
15
+ ) : AutoCloseable {
16
+ private class Request(val tag: Int, val generation: Long, val lease: String, val types: Set<String>) {
17
+ var view: WeakReference<View>? = null
18
+ var resolved = false
19
+ var listener: View.OnAttachStateChangeListener? = null
20
+ }
21
+ val runtime = InteractionEvents.openRuntime()
22
+ private val namespace = "$runtime:"
23
+ private val requests = mutableMapOf<String, Request>()
24
+ private class PendingRecord(val item: PreparedInteraction, val id: String, val generation: Long)
25
+ private val records = ArrayDeque<PendingRecord>()
26
+ private var recordDrainScheduled = false
27
+ @Volatile private var closed = false
28
+ fun active(): Boolean = !closed && InteractionEvents.ownsRuntime(runtime)
29
+ fun lease(): String = if (active()) InteractionEvents.lease() ?: "" else ""
30
+ private fun integer(value: Double, max: Double) = value.isFinite() && value >= 0 && value <= max && value == value.toLong().toDouble()
31
+ private fun validId(id: String) = id.isNotEmpty() && id.length <= 128 && id.none { it.code < 32 || it.code == 127 }
32
+ @Synchronized fun set(tag: Double, id: String, generation: Double, lease: String, json: String): Boolean {
33
+ if (!active() || !validId(id) || tag <= 0 || !integer(tag,Int.MAX_VALUE.toDouble()) || !integer(generation,9007199254740990.0) || lease.isEmpty() || lease != lease()) return false
34
+ if (json.toByteArray(Charsets.UTF_8).size > 8192) return false
35
+ val options = runCatching { Json.parseToJsonElement(json) as? JsonObject }.getOrNull() ?: return false
36
+ val list = options["helperTypes"] as? JsonArray ?: return false
37
+ if (list.size > 3) return false
38
+ val types = list.map { (it as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return false }.toSet()
39
+ if (!setOf("text_edit","control_change","scroll").containsAll(types)) return false
40
+ // JS callbacks have no immutable native privacy proof at observation.
41
+ // Keep annotations while leaving all native automatic observation active.
42
+ requests.filterValues { it.lease != lease }.values.forEach(::detachListener)
43
+ requests.entries.removeAll { it.value.lease != lease }
44
+ if (requests.size >= 4096 && !requests.containsKey(id)) return false
45
+ if (!InteractionEvents.beginRegistration(namespace+id,generation.toLong(),lease,json)) return false
46
+ val request = Request(tag.toInt(),generation.toLong(),lease,emptySet())
47
+ requests.put(id,request)?.let(::detachListener); enqueue(id,request)
48
+ return true
49
+ }
50
+ private fun enqueue(id: String, request: Request) {
51
+ dispatch {
52
+ synchronized(this) {
53
+ if (!active() || request.lease != lease() || requests[id] !== request || request.resolved) return@dispatch
54
+ val view = runCatching { resolve(request.tag) }.getOrNull() ?: return@dispatch
55
+ if (!view.isAttachedToWindow || view.id != request.tag) return@dispatch
56
+ request.view = WeakReference(view); request.resolved = true
57
+ if (InteractionEvents.completeRegistration(view,namespace+id,request.generation,request.lease,request.types)) {
58
+ val owner = WeakReference(this)
59
+ val nativeId = namespace+id
60
+ val listener = object : View.OnAttachStateChangeListener {
61
+ override fun onViewAttachedToWindow(view: View) = Unit
62
+ override fun onViewDetachedFromWindow(view: View) {
63
+ view.removeOnAttachStateChangeListener(this)
64
+ InteractionEvents.cancelRegistration(nativeId,request.generation)
65
+ owner.get()?.let { bridge -> synchronized(bridge) { if (bridge.requests[id] === request) bridge.requests.remove(id) } }
66
+ }
67
+ }
68
+ request.listener = listener; view.addOnAttachStateChangeListener(listener)
69
+ }
70
+ }
71
+ }
72
+ }
73
+ private fun detachListener(request: Request) {
74
+ dispatch { request.listener?.let { request.view?.get()?.removeOnAttachStateChangeListener(it) }; request.listener = null }
75
+ }
76
+ @Synchronized fun onMount() {
77
+ if (!active()) return
78
+ requests.filterValues { it.lease != lease() }.values.forEach(::detachListener)
79
+ requests.entries.removeAll { it.value.lease != lease() }
80
+ requests.filterValues { !it.resolved }.forEach { (id,request) -> enqueue(id,request) }
81
+ }
82
+ @Synchronized fun clear(id: String, generation: Double): Boolean {
83
+ if (!active() || !validId(id) || !integer(generation,9007199254740991.0)) return false
84
+ if (!InteractionEvents.clearRegistration(namespace+id,generation.toLong())) return false
85
+ requests.remove(id)?.let(::detachListener); return true
86
+ }
87
+ @Synchronized fun record(lease: String, id: String, generation: Double, json: String): Boolean {
88
+ if (!active() || !integer(generation,9007199254740991.0) || (id.isNotEmpty() && !validId(id))) return false
89
+ val prepared = InteractionEvents.prepare(lease,if (id.isEmpty()) "" else namespace+id,generation.toLong(),json) ?: return false
90
+ // Native coalescing/revocation can empty a prepared cell before main runs.
91
+ // Bound those cells too, and enqueue only one callback for the mailbox.
92
+ if (records.size == 64) InteractionEvents.discardPrepared(records.removeFirst().item)
93
+ records.addLast(PendingRecord(prepared,id,generation.toLong()))
94
+ if (!recordDrainScheduled) { recordDrainScheduled = true; scheduleRecordDrain() }
95
+ return true
96
+ }
97
+ private fun scheduleRecordDrain() {
98
+ val owner = WeakReference(this)
99
+ dispatch { owner.get()?.drainRecords() }
100
+ }
101
+ private fun drainRecords() {
102
+ // Yield after a bounded batch even if producers keep refilling the mailbox.
103
+ repeat(64) {
104
+ synchronized(this) {
105
+ if (records.isEmpty()) return@repeat
106
+ val pending = records.removeFirst()
107
+ try {
108
+ if (!active()) return@repeat
109
+ if (pending.id.isNotEmpty()) {
110
+ val request = requests[pending.id] ?: return@repeat
111
+ val view = request.view?.get() ?: return@repeat
112
+ if (request.generation != pending.generation || resolve(request.tag) !== view) return@repeat
113
+ }
114
+ InteractionEvents.recordPrepared(pending.item)
115
+ } finally { InteractionEvents.discardPrepared(pending.item) }
116
+ }
117
+ }
118
+ synchronized(this) {
119
+ if (records.isEmpty()) recordDrainScheduled = false else scheduleRecordDrain()
120
+ }
121
+ }
122
+ @Synchronized override fun close() {
123
+ closed = true
124
+ records.forEach { InteractionEvents.discardPrepared(it.item) }; records.clear()
125
+ requests.values.forEach(::detachListener); requests.clear(); InteractionEvents.closeRuntime(runtime)
126
+ }
127
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ package dev.everframe.rn
4
+
5
+ import android.view.View
6
+ import com.facebook.react.ReactRootView
7
+ import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole
8
+ import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role
9
+ import dev.everframe.capture.InteractionTargetViewAdapter
10
+ import dev.everframe.capture.InteractionTargetViewHints
11
+ import java.util.Locale
12
+
13
+ internal object RnInteractionTargetAdapter : InteractionTargetViewAdapter {
14
+ override fun describe(view: View): InteractionTargetViewHints? {
15
+ val testId = view.getTag(com.facebook.react.R.id.react_test_id) as? String
16
+ val rawRole = ((view.getTag(com.facebook.react.R.id.role) as? Role)?.name
17
+ ?: (view.getTag(com.facebook.react.R.id.accessibility_role) as? AccessibilityRole)?.name)?.lowercase(Locale.ROOT)
18
+ val role = when (rawRole) {
19
+ "imagebutton" -> "button"
20
+ "adjustable" -> "slider"
21
+ "edittext", "search", "searchbox", "textbox" -> "textfield"
22
+ "heading" -> "header"
23
+ "img" -> "image"
24
+ "none", null -> null
25
+ else -> rawRole
26
+ }
27
+ return InteractionTargetViewHints(testId = testId, role = role,
28
+ // ReactSurfaceView (Fabric) also extends ReactRootView. The module
29
+ // identity is assigned by the framework, independent of rendered labels.
30
+ devtools = view is ReactRootView && runCatching { view.jsModuleName in setOf("LogBox", "DevMenu") }.getOrDefault(false),
31
+ actionable = role in setOf("button", "tab", "link", "switch", "checkbox", "radio", "slider", "menuitem", "textfield"))
32
+ }
33
+ }