@everframe/react-native 1.2.1 → 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.2.1' : 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
@@ -42,9 +42,9 @@ npx @everframe/cli setup react-native --app-id "$EVERFRAME_APP_ID"
42
42
  ```
43
43
 
44
44
  Either way, Android gets `apply plugin: "dev.everframe"` and
45
- `everframe { appId = "…" }` plus one classpath line, and Xcode gets a Run
46
- Script phase that runs `everframe xcode build-phase`. The upload logic lives in
47
- the Gradle plugin and the CLI. Set `EVERFRAME_API_TOKEN` in release builds;
45
+ `everframe { appId = "…" }` plus one classpath line, and Xcode gets a
46
+ one-line Run Script phase that runs `scripts/xcode-upload.sh` from the installed
47
+ `@everframe/cli`. The upload logic lives in the Gradle plugin and the CLI. Set `EVERFRAME_API_TOKEN` in release builds;
48
48
  without it the build warns and continues. See the
49
49
  [CLI guide](../cli/README.md#react-native-and-expo).
50
50
 
@@ -517,15 +517,15 @@ inside a native view and never reaches JS.
517
517
  - `captureSourceQuery` — keep the query string on `source_change.src`
518
518
  (default `false`: signed CDN URLs carry tokens in the query).
519
519
 
520
- **Reconfiguring.** A Provider remount with the *same* config does not restart
521
- the SDK: both native sides compare the incoming config against the *installed*
520
+ **Reconfiguring.** A Provider remount with the _same_ config does not restart
521
+ the SDK: both native sides compare the incoming config against the _installed_
522
522
  one and skip the start entirely. A **changed** config does restart it (on iOS,
523
523
  except a change confined to `releaseHealth` or the loaded bundle; see
524
524
  [Release health](#release-health)) — and a
525
525
  start supersedes the running SDK, detaching every player integration it had
526
526
  announced. **Limitation:** players tracked before that restart stay untracked
527
527
  until their screens remount; nothing re-registers them automatically. Two React
528
- instances in one process are *not* coordinated either: if a second one starts
528
+ instances in one process are _not_ coordinated either: if a second one starts
529
529
  the SDK under the first, the first instance's players stay untracked until its
530
530
  own screens remount.
531
531
 
@@ -570,7 +570,7 @@ adapter has subscribed — and `onLoadStart` is what arms the adapter's startup
570
570
  clock, so the first source's startup latency is simply never seen. Deferring
571
571
  the load until after the adapter subscribed puts the first `onLoadStart` back
572
572
  where it can be observed. Without this, everything else still works; only the
573
- *first* `startup` measurement is missing (later source changes are fine).
573
+ _first_ `startup` measurement is missing (later source changes are fine).
574
574
 
575
575
  Options: `name`, `libraryVersion`, and `captureErrors` (default `false` — see
576
576
  Limitations). `onBandwidthUpdate` is read per platform: on iOS its `bitrate`
@@ -580,7 +580,7 @@ the `bandwidthEstimate` stat (with `quality_change` for the accompanying
580
580
  rendition size) and never as a bitrate.
581
581
 
582
582
  `onProgress` is read per platform too. The `bufferAheadMs` stat is always the
583
- media time buffered *ahead of the playhead*: on Android `bufferDuration` is
583
+ media time buffered _ahead of the playhead_: on Android `bufferDuration` is
584
584
  already that, on iOS it is the buffered range's absolute end position, so the
585
585
  adapter subtracts `currentTime` from it.
586
586
 
@@ -663,7 +663,7 @@ dropped, not forwarded.
663
663
  `captureErrors` is off by default.
664
664
  - **`rate_change` was not observed from react-native-video v7 on Android** in
665
665
  our Android emulator smoke — `onPlaybackRateChange` never arrived there.
666
- Note that the library *does* forward ExoPlayer's
666
+ Note that the library _does_ forward ExoPlayer's
667
667
  `onPlaybackParametersChanged`, so this is a "not seen in our smoke run"
668
668
  result, not a proven library gap; a host that changes playback speed
669
669
  explicitly may well see it. Nothing on the adapter side gates it either way.
@@ -738,7 +738,7 @@ subscription?.remove();
738
738
  ```
739
739
 
740
740
  > If you have seen the older `new TVEventHandler(); handler.enable(cmp, cb);
741
- > handler.disable()` shape elsewhere — that class-based API was removed in
741
+ handler.disable()` shape elsewhere — that class-based API was removed in
742
742
  > RN-tvos 0.85.x. Always read the version installed in your `node_modules`.
743
743
 
744
744
  #### Apple TV: long-press Play/Pause
@@ -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.2.1,1.3.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
+ }