@everframe/react-native 1.1.0 → 1.2.1

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.
@@ -35,8 +35,12 @@ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
35
35
  # evaluated from inside the published npm tarball at a consumer's install, where
36
36
  # no workspace path exists. Unset — every consumer install — the derivation
37
37
  # below is the only source of truth, unchanged.
38
+ #
39
+ # The bridge calls `EverframeConfig(sdkKey:)`, which first ships in native
40
+ # 1.2.0, so the range already asks for 1.2 while package.json is still 1.1.0.
41
+ # The 1.2.0 release moves package.json to match.
38
42
  native_version_override = ENV['EVERFRAME_NATIVE_POD_VERSION'].to_s.strip
39
- native_minor_range = native_version_override.empty? ? '~> 1.1.0' : native_version_override
43
+ native_minor_range = native_version_override.empty? ? '~> 1.2.1' : native_version_override
40
44
 
41
45
  Pod::Spec.new do |s|
42
46
  s.name = 'EverframeRN'
package/README.md CHANGED
@@ -11,6 +11,9 @@ modal to RN host apps via a TurboModule + a thin React provider/hook API.
11
11
 
12
12
  ---
13
13
 
14
+ See the [crash-reporting support matrix](../../docs/crash-reporting-support.md)
15
+ for exact tested paths, unsupported combinations and remaining qualification.
16
+
14
17
  ## Installation
15
18
 
16
19
  ```bash
@@ -26,6 +29,25 @@ Then install pods (iOS) / sync Gradle (Android) as usual:
26
29
  cd ios && pod install
27
30
  ```
28
31
 
32
+ ### Source maps and symbols
33
+
34
+ Release builds can upload their Hermes source maps, dSYMs, R8 mappings and
35
+ native libraries, so crashes show your code. Wrap Metro with
36
+ `withEverframe` from `@everframe/metro`, add `@everframe/cli`, and set up the
37
+ native projects once. In Expo, apply `withEverframe` from `@everframe/expo`
38
+ with your App ID in `app.config.js`. In a bare app, run:
39
+
40
+ ```bash
41
+ npx @everframe/cli setup react-native --app-id "$EVERFRAME_APP_ID"
42
+ ```
43
+
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;
48
+ without it the build warns and continues. See the
49
+ [CLI guide](../cli/README.md#react-native-and-expo).
50
+
29
51
  ## React Native Web
30
52
 
31
53
  `@everframe/react-native` has a browser entry and an explicit
@@ -46,6 +68,8 @@ the web mask registry. Native iOS and Android use their native capture. A web
46
68
  bundler that honors the `browser` export condition can keep the regular package
47
69
  import; use the `/web` subpath if its resolver does not select that condition.
48
70
 
71
+ Replays also attach to errors; see [session replay](../../docs/session-replay.md).
72
+
49
73
  ## Usage
50
74
 
51
75
  Wrap your app with `<EverframeProvider>` at the highest practical level
@@ -56,11 +80,11 @@ import { EverframeProvider, useEverframe } from '@everframe/react-native';
56
80
 
57
81
  export default function App() {
58
82
  return (
59
- // `apiKey` is the only required field. The ingest endpoint is a
83
+ // `sdkKey` is the only required field. The ingest endpoint is a
60
84
  // compile-time constant in the SDK and is not configurable in v1.
61
85
  <EverframeProvider
62
86
  config={{
63
- apiKey: 'txx_live_xxxxxxxxxxxxxxxx',
87
+ sdkKey: 'evf_live_xxxxxxxxxxxxxxxx',
64
88
  // Recommended in RN/Expo development: shake also opens the dev menu.
65
89
  shakeToReport: { enabled: !__DEV__ },
66
90
  }}
@@ -96,6 +120,30 @@ provider context and attach any non-sensitive context needed for diagnosis.
96
120
  The source API requires matching rebuilt native components; published `0.7.0`
97
121
  artifacts are not evidence of support.
98
122
 
123
+ ## Release health
124
+
125
+ Release health counts crash-free foreground sessions for each version of your
126
+ app. It is on by default, and debug builds of the native app aren't counted.
127
+ Turn it off in the `EverframeProvider` config:
128
+
129
+ ```tsx
130
+ releaseHealth: { enabled: false },
131
+ ```
132
+
133
+ Sessions come from the Android and iOS SDKs and count toward the native app
134
+ version, which an over-the-air update doesn't change. Release health needs the
135
+ Hermes bundle identity that `@everframe/metro` adds at build time (or the
136
+ `jsBundle` option); without it, release health stays off and the SDK logs an
137
+ `[everframe]` warning (in development builds only, unless you set
138
+ `releaseHealth`). Add `userId: accountId` to count crash-free users, and
139
+ `includeDebugBuilds: true` to count debug builds too. The configuration is read
140
+ when the Provider mounts, so remount it with a changed `key` to change `enabled`
141
+ or `userId`. A configure with `enabled: false` turns release health off and
142
+ erases its queued records on the device.
143
+ Release health skips a debug build, which is `__DEV__` unless you set
144
+ `debugBuild` on the Provider. See
145
+ [release health](../../docs/release-health.md).
146
+
99
147
  ## Promise rejection observation
100
148
 
101
149
  Automatic Hermes rejection observation is opt-in:
@@ -104,7 +152,7 @@ Automatic Hermes rejection observation is opt-in:
104
152
  import { EverframeProvider, getPromiseRejectionStatus } from '@everframe/react-native';
105
153
 
106
154
  <EverframeProvider config={{
107
- apiKey: '…',
155
+ sdkKey: '…',
108
156
  crashReporting: { promiseRejections: { enabled: true } },
109
157
  }}>
110
158
  <App />
@@ -173,6 +221,27 @@ browser SDK's own error handling.
173
221
 
174
222
  ## Error capture and diagnostics
175
223
 
224
+ ### Native crashes and ANRs
225
+
226
+ Crash reporting also turns on the native SDKs' automatic crash capture. On Android that covers JVM exceptions, native crashes (OS exit records on API 30+, with tombstone frames on API 31+) and ANRs on API 30+; see the [Android SDK guide](../sdk-android/README.md#crash-capture-on-by-default). On iOS it is the native crash recorder. `crashReporting: { disabled: true }` turns all of it off on both platforms.
227
+
228
+ ### Errors no boundary catches
229
+
230
+ Since React Native 0.78, React reports an error that no error boundary catches
231
+ (thrown while rendering, in an effect or in a lifecycle method) through the
232
+ root's `onUncaughtError`. That calls React Native's `ExceptionsManager`, never
233
+ the `ErrorUtils` handler. Crash reporting hooks
234
+ `ExceptionsManager.handleException` too, so such an error is reported as a fatal
235
+ JavaScript crash (`mechanism: "errorutils"`) with its Hermes stack, and React's
236
+ component stack follows the message after `This error is located at:`, as in
237
+ React Native's own report. React unmounts the whole tree, `EverframeProvider`
238
+ included, before it reports the error; the hook stays until the end of that
239
+ task, so the error is still captured. The native fatal React Native raises for
240
+ the same error (`RCTFatalException` on iOS and tvOS, `JavascriptException` on
241
+ Android) is not reported a second time. An error in the render that first
242
+ mounts the provider comes before this capture is installed. Errors a boundary
243
+ catches are not reported automatically; use the callback below.
244
+
176
245
  ### React error boundary callback
177
246
 
178
247
  An existing React class boundary can opt into handled capture. Render the
@@ -194,7 +263,7 @@ class Boundary extends React.Component<React.PropsWithChildren, { failed: boolea
194
263
 
195
264
  export default function App() {
196
265
  return (
197
- <EverframeProvider config={{ apiKey: '…' }}>
266
+ <EverframeProvider config={{ sdkKey: '…' }}>
198
267
  <Boundary>
199
268
  <RootNavigator />
200
269
  </Boundary>
@@ -233,17 +302,14 @@ const { attempted, accepted, duplicateSuppressed } = status.counters.handled;
233
302
 
234
303
  Snapshots are detached, contain no captured content, reset on remount, and perform
235
304
  no native query or upload. Paths are `handled` (including the boundary callback),
236
- `errorUtils`, and `rejection` (submission only). Each active attempt has one outcome:
305
+ `errorUtils` (including errors no boundary catches), and `rejection` (submission only). Each active attempt has one outcome:
237
306
  `accepted`, `duplicateSuppressed`, `allowanceSuppressed`, `bridgeUnavailable`,
238
- `nativeRefused`, `captureFailed`, `reentrantSuppressed`, `inactiveAborted`, or
239
- `legacyAttempted`. Counters saturate at 2,147,483,647. Existing limits are ten
307
+ `nativeRefused`, `captureFailed`, `reentrantSuppressed`, or `inactiveAborted`.
308
+ Counters saturate at 2,147,483,647. Existing limits are ten
240
309
  handled and ten automatic distinct keys; fatal capture bypasses them.
241
310
 
242
- `accepted` requires exactly true from a current native capture method. It does
243
- not mean upload success, server processing or outbox removal. An automatic call
244
- to an older native fallback is only `legacyAttempted`, regardless of its return.
245
- Old-binary unavailable/throwing methods are covered by unit controls, not an
246
- installed old-binary qualification. This snapshot excludes native queue and
311
+ `accepted` requires exactly true from the native capture method. It does
312
+ not mean upload success, server processing or outbox removal. This snapshot excludes native queue and
247
313
  delivery observations; read those with `getReportDeliveryStatus()`. Status is
248
314
  neutral `not-mounted` without a provider, `disabled` under the crash-reporting
249
315
  veto, and `unsupported` on the browser root entry.
@@ -293,8 +359,7 @@ bridges. iOS marks `jvm-uncaught` unsupported and implies no automatic iOS nativ
293
359
  crash collector.
294
360
 
295
361
  Without a mounted provider the result is `not-mounted/no-mount`; the browser
296
- export is `unsupported/platform`. Older native SDKs without the getter return
297
- `unsupported/native-method-missing`. A throwing native call returns
362
+ export is `unsupported/platform`. A missing or throwing native call returns
298
363
  `unavailable/native-call-failed`, and malformed native responses return
299
364
  `unavailable/invalid-native-snapshot`. These fallbacks have an unobserved queue
300
365
  and `unknown` platform policies. This API does not provide a per-report delivery
@@ -303,7 +368,7 @@ guarantee or trigger a retry.
303
368
  ## Network body capture (client veto)
304
369
 
305
370
  ```tsx
306
- <EverframeProvider config={{ apiKey: '…', networkBodies: { disabled: true } }}>
371
+ <EverframeProvider config={{ sdkKey: '…', networkBodies: { disabled: true } }}>
307
372
  ```
308
373
 
309
374
  `networkBodies.disabled: true` is a **client veto** — it can only turn body
@@ -331,6 +396,34 @@ capturing network traffic in the first place:
331
396
  Do not enable this option expecting RN's own network calls to show up in
332
397
  reports — that capability doesn't exist yet.
333
398
 
399
+ ## Crash-only sessions
400
+
401
+ ```tsx
402
+ <EverframeProvider config={{ sdkKey: '…', reporter: { disabled: true } }}>
403
+ ```
404
+
405
+ `reporter.disabled: true` turns the bug reporter off for the session and
406
+ keeps crash and error reporting, for example in production builds. It is a
407
+ **client veto**: the dashboard can't turn the reporter back on for this
408
+ session.
409
+
410
+ In a crash-only session:
411
+
412
+ - Shake-to-report is off, and `open()` rejects with
413
+ `EverframeReporterDisabledError`.
414
+ - Session replay uses the app's crash-only sampling rate, which is 0 by
415
+ default. An error carries a replay only while the session records.
416
+ - Network bodies aren't captured.
417
+ - Crash and error capture, breadcrumbs, vitals, release health and the
418
+ companion work as usual.
419
+
420
+ A companion bond (a phone scanning the QR code, or a dashboard member
421
+ attaching) upgrades the session. Replay and network bodies then follow the
422
+ app's normal settings for the rest of the session, and recording starts at
423
+ the bond. The reporter stays off: reports from an upgraded session come
424
+ through the companion. See
425
+ [crash-only sessions](../../docs/session-replay.md#crash-only-sessions).
426
+
334
427
  ## Navigation breadcrumbs
335
428
 
336
429
  Mark screens with one line; the native SDK derives `from → to` from a global
@@ -386,7 +479,7 @@ import { createNavigationContainerRef } from '@react-navigation/native';
386
479
  const navigationRef = createNavigationContainerRef();
387
480
  const txNav = reactNavigationIntegration({ navigationRef });
388
481
 
389
- <EverframeProvider config={{ apiKey, integrations: [consoleIntegration(), txNav] }}>
482
+ <EverframeProvider config={{ sdkKey, integrations: [consoleIntegration(), txNav] }}>
390
483
  <NavigationContainer ref={navigationRef} onReady={txNav.onReady}>
391
484
  ...
392
485
  ```
@@ -415,7 +508,7 @@ inside a native view and never reaches JS.
415
508
  ### Config
416
509
 
417
510
  ```tsx
418
- <EverframeProvider config={{ apiKey, vitals: { enabled: true, sampleRate: 0.5, captureSourceQuery: false } }}>
511
+ <EverframeProvider config={{ sdkKey, vitals: { enabled: true, sampleRate: 0.5, captureSourceQuery: false } }}>
419
512
  ```
420
513
 
421
514
  - `enabled` — absent follows the dashboard toggle; `false` opts out locally;
@@ -426,7 +519,9 @@ inside a native view and never reaches JS.
426
519
 
427
520
  **Reconfiguring.** A Provider remount with the *same* config does not restart
428
521
  the SDK: both native sides compare the incoming config against the *installed*
429
- one and skip the start entirely. A **changed** config does restart it — and a
522
+ one and skip the start entirely. A **changed** config does restart it (on iOS,
523
+ except a change confined to `releaseHealth` or the loaded bundle; see
524
+ [Release health](#release-health)) — and a
430
525
  start supersedes the running SDK, detaching every player integration it had
431
526
  announced. **Limitation:** players tracked before that restart stay untracked
432
527
  until their screens remount; nothing re-registers them automatically. Two React
@@ -598,7 +693,7 @@ on Android), so React Native's focus engine never sees it — the native VC
598
693
  manages focus on its own UIWindow / Activity.
599
694
 
600
695
  ```tsx
601
- <EverframeProvider config={{ apiKey }}>
696
+ <EverframeProvider config={{ sdkKey }}>
602
697
  <NavigationContainer>{/* focus engine root */}</NavigationContainer>
603
698
  </EverframeProvider>
604
699
  ```
@@ -727,28 +822,12 @@ recipes above.
727
822
  ## Android reporter initialization
728
823
 
729
824
  The Android reporter can be opened after the host activity has resumed, including
730
- apps that disable AndroidX Startup. Use matching updated React Native bridge and
731
- `dev.everframe:reporter-ui` artifacts: the bridge supplies the current resumed
825
+ apps that disable AndroidX Startup. The bridge supplies the current resumed
732
826
  activity, and installation and presentation run on the main thread. Repeated
733
- configuration with matching updated artifacts does not register another lifecycle observer. Capture configuration
734
- remains synchronous without waiting for reporter UI initialization.
735
-
736
- The existing `ReporterResolverInstaller.create(Context)` entry point is retained
737
- for compiled callers. A new bridge with an older reporter uses its legacy entry
738
- point when the activity-aware method is absent; that combination retains the old
739
- late-initialization limitation. A failure from an available method is reported,
740
- not retried through the legacy path. Background or unavailable activities return
741
- a cancelled result with reason `no_active_activity`.
742
-
743
- Legacy off-main `create(Context)` schedules installation and returns before it is
744
- ready; use the awaited activity-aware bridge to open immediately after configure.
745
- With a new bridge and an older reporter, each configure, companion start, and
746
- open invokes legacy installation again. This can add duplicate lifecycle
747
- callbacks and overwrite that older reporter's resolver and activity supplier;
748
- the previous bridge limited installation to once per module. Conversely, an old
749
- compiled bridge with the new reporter can announce companion PIN capability
750
- before asynchronous reporter installation finishes, temporarily advertising no
751
- PIN support. Use the matching updated pair for the qualified behavior.
827
+ configuration does not register another lifecycle observer. Capture configuration
828
+ remains synchronous without waiting for reporter UI initialization. A failure
829
+ while opening is reported to JavaScript. Background or unavailable activities
830
+ return a cancelled result with reason `no_active_activity`.
752
831
 
753
832
  Activity destruction while a reporter is open resolves the pending call with
754
833
  `{ status: "cancelled", reason: "activity_destroyed" }`, releases that report's
@@ -21,11 +21,10 @@
21
21
  // spec); a host on the new architecture can flip the parent class with a
22
22
  // 1-line patch once codegen output is available in the consuming build.
23
23
  //
24
- // ZERO-PERMISSION:
25
- // No `<uses-permission>` in AndroidManifest.xml. The bridge adds zero
26
- // permissions on top of whatever `:everframe-core` declares (the core also
27
- // has none — Phase 5 zero-permission AAR contract). Verified by the 06-03
28
- // acceptance grep.
24
+ // PERMISSIONS:
25
+ // No `<uses-permission>` in AndroidManifest.xml. The bridge adds no
26
+ // permissions on top of what `:everframe-core` declares (INTERNET only,
27
+ // merged into the host app's manifest).
29
28
  //
30
29
  // SCOPE (NOT in this build):
31
30
  // • Namespace is `dev.everframe.rn`.
@@ -152,7 +151,9 @@ val nativeVersionOverride = (findProperty("everframeNativeVersion") as String?)
152
151
  val rnSdkPkg = groovy.json.JsonSlurper()
153
152
  .parse(file("../package.json")) as Map<*, *>
154
153
  val rnSdkVersion = rnSdkPkg["version"] as String
155
- val nativeMinorRange: String = nativeVersionOverride ?: "[1.1.0,1.2.0)"
154
+ // Native 1.2.0 is the release that renamed the SDK key to `sdkKey`; the
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
157
  if (nativeVersionOverride != null) {
157
158
  logger.lifecycle(
158
159
  "[everframe-rn] native AAR pinned to $nativeVersionOverride via everframeNativeVersion " +
@@ -22,7 +22,7 @@
22
22
  // signalCompanionReportRequestReady(id) → void (Plan 06.2-13)
23
23
  // trackPlayer/detachPlayer/recordPlayerEvent/updatePlayerStats/
24
24
  // trackVitals → void (spec 2026-09-06)
25
- // configure(opts.vitals*) → void (spec 2026-09-06)
25
+ // configureSync(opts.vitals*) → boolean (spec 2026-09-06)
26
26
  // addListener(name)/removeListeners(n) → void (RN event-emitter no-ops)
27
27
  //
28
28
  // The native TXReporterPresenter owns the entire report UX (capture, annotate,
@@ -30,8 +30,7 @@
30
30
  // and the sensitive-rect bridge. Native mobile shake stays below this bridge.
31
31
  //
32
32
  // THREADING:
33
- // • configureSync runs on the JS thread (blocking synchronous); legacy
34
- // void configure retains React Native's queued native dispatch.
33
+ // • configureSync runs on the JS thread (blocking synchronous).
35
34
  // • openReporter dispatches presenter.openReporter onto the activity from
36
35
  // MainScope (presenter manages its own UI thread hops internally).
37
36
  // • registerSensitiveRect runs on the UI thread (UIManager view resolution).
@@ -76,6 +75,8 @@ import dev.everframe.companion.CompanionSubmissionComposer
76
75
  import dev.everframe.companion.PreviewCapture
77
76
  import dev.everframe.companion.RelayWSClient
78
77
  import dev.everframe.companion.parseCompanionBadgePosition
78
+ import dev.everframe.config.ReleaseHealthConfig
79
+ import dev.everframe.config.ReleaseHealthBundleStatus
79
80
  import dev.everframe.config.CaptureConfig
80
81
  import dev.everframe.config.Environment
81
82
  import dev.everframe.config.ReportResult
@@ -146,6 +147,8 @@ class EverframeModule(
146
147
  internal const val REJECT_CAPTURE_DISABLED: String = "capture_disabled"
147
148
  internal const val REJECT_NO_ACTIVITY: String = "no_activity"
148
149
  internal const val REJECT_OPEN_REPORTER_FAILED: String = "open_reporter_failed"
150
+ /** Crash-only sessions (spec 2026-10-12). The JS facade maps this exact code to `EverframeReporterDisabledError`. */
151
+ internal const val REJECT_REPORTER_DISABLED: String = "EVERFRAME_REPORTER_DISABLED"
149
152
 
150
153
  // Plan 06.2-11 — JS-side event names. Keep in lock-step with
151
154
  // `packages/sdk-react-native/src/companion.ts` and the iOS
@@ -173,13 +176,10 @@ class EverframeModule(
173
176
  // below for the full contract.
174
177
  internal const val COMPANION_ATTACH_CHALLENGE_EVENT: String =
175
178
  "everframe.companion.attachChallenge"
176
- // Fired when a `report.request` arrives from the paired phone. This
177
- // used to give JS a window to walk the React fiber tree and call
178
- // `attachReactTree`; both the tree payload and that spec method are
179
- // gone (spec 2026-08-29). The event and its
180
- // `signalCompanionReportRequestReady` acknowledgement survive as the
181
- // companion's JS-ready handshake — the bounded wait is part of the
182
- // pairing flow, not of tap-to-identify.
179
+ // Fired when a `report.request` arrives from the paired phone. The
180
+ // event and its `signalCompanionReportRequestReady` acknowledgement
181
+ // are the companion's JS-ready handshake — the bounded wait is part
182
+ // of the pairing flow.
183
183
  internal const val COMPANION_REPORT_REQUESTED_EVENT: String = "everframe.companion.reportRequested"
184
184
 
185
185
  // Spec 2026-09-17 setExtra-resolver — NOT a companion event (no
@@ -207,20 +207,48 @@ class EverframeModule(
207
207
 
208
208
  // ---------------- configure ----------------
209
209
 
210
- @ReactMethod
211
- override fun configure(opts: ReadableMap) {
212
- configureInternal(opts)
213
- }
214
-
215
210
  @ReactMethod(isBlockingSynchronousMethod = true)
216
211
  override fun configureSync(opts: ReadableMap): Boolean = configureInternal(opts)
217
212
 
213
+ private fun parseReleaseHealth(opts: ReadableMap): ReleaseHealthConfig? {
214
+ // Invalid bridge input revokes earlier monitoring instead of retaining an old subject.
215
+ // The SDK reads the app version itself; the bridge carries the loaded bundle, the user ID and
216
+ // whether debug builds count. Release health is on by default: a missing or null flag is on, and
217
+ // only an explicit false is off. It still needs a valid loaded bundle, so no id means off.
218
+ return runCatching {
219
+ if (opts.takeIfHasBoolean("releaseHealthEnabled") == false) return null
220
+ val loaded = opts.takeIfHasString("releaseHealthLoadedBuildId") ?: return null
221
+ val user = opts.takeIfHasString("releaseHealthUserId")
222
+ if (!validHealthText(loaded, 200) || (user != null && !validHealthText(user, 128))) return null
223
+ val includeDebugBuilds = opts.takeIfHasBoolean("releaseHealthIncludeDebugBuilds") ?: false
224
+ ReleaseHealthConfig(enabled = true, userId = user, loadedBuildId = loaded,
225
+ loadedBundleStatus = ReleaseHealthBundleStatus.KNOWN, includeDebugBuilds = includeDebugBuilds)
226
+ }.getOrNull()
227
+ }
228
+
229
+ private fun validHealthText(value: String, maximum: Int): Boolean {
230
+ // Match the wire's UTF-16 limit and ECMAScript blank definition, preserving ID bytes.
231
+ fun blank(c: Char) = c in '\u0009'..'\u000d' || c == ' ' || c == '\u00a0' ||
232
+ c == '\u1680' || c in '\u2000'..'\u200a' || c == '\u2028' || c == '\u2029' ||
233
+ c == '\u202f' || c == '\u205f' || c == '\u3000' || c == '\ufeff'
234
+ if (value.length > maximum || value.all(::blank)) return false
235
+ var index = 0
236
+ while (index < value.length) {
237
+ val c = value[index++]
238
+ if (c.code < 32 || Character.isLowSurrogate(c)) return false
239
+ if (Character.isHighSurrogate(c)) {
240
+ if (index >= value.length || !Character.isLowSurrogate(value[index++])) return false
241
+ }
242
+ }
243
+ return true
244
+ }
245
+
218
246
  private fun configureInternal(opts: ReadableMap): Boolean {
219
247
  var completed = false
220
248
  txGuardSurface("configure") {
221
- val sdkKey = opts.takeIfHasString("apiKey")
249
+ val sdkKey = opts.takeIfHasString("sdkKey")
222
250
  ?: throw JSApplicationIllegalArgumentException(
223
- "configure: required field 'apiKey' missing",
251
+ "configure: required field 'sdkKey' missing",
224
252
  )
225
253
  val sdkVersion = opts.takeIfHasString("sdkVersion") ?: Everframe.SDK_VERSION
226
254
 
@@ -233,6 +261,13 @@ class EverframeModule(
233
261
  // Polarity flips exactly once, here: JS sends the veto
234
262
  // (`installIdentifierDisabled`), native stores the permission.
235
263
  val installIdentifierDisabled = opts.takeIfHasBoolean("installIdentifierDisabled") ?: false
264
+ // Polarity flips exactly once, here: JS sends the veto
265
+ // (`crashReportingDisabled`), native stores the permission.
266
+ val crashReportingDisabled = opts.takeIfHasBoolean("crashReportingDisabled") ?: false
267
+ // Polarity flips exactly once, here: JS sends the crash-only switch
268
+ // (`reporterDisabled`, flattened from `reporter: { disabled }`),
269
+ // native stores the permission.
270
+ val reporterDisabled = opts.takeIfHasBoolean("reporterDisabled") ?: false
236
271
  // Attach-PIN UI mode (spec 2026-08-19) — a flat optional STRING
237
272
  // field (codegen cannot express a string-literal union), parsed
238
273
  // here and stashed for `startCompanion()` to read (the flag is a
@@ -245,12 +280,20 @@ class EverframeModule(
245
280
  val attachPinUi = parseAttachPinUi(opts.takeIfHasString("attachPinUi"))
246
281
 
247
282
  val config = EverframeConfig(
283
+ // The JS config carries no App ID, and the native SDK requires a
284
+ // non-blank one. It only keys this app's low-memory-kill issue, so
285
+ // the SDK key stands in, as it always has; changing it would split
286
+ // existing React Native low-memory issues.
248
287
  appId = sdkKey,
249
288
  sdkKey = sdkKey,
250
289
  environment = Environment.production,
251
290
  release = sdkVersion,
252
- capture = CaptureConfig(networkBodies = !networkBodiesDisabled),
291
+ capture = CaptureConfig(networkBodies = !networkBodiesDisabled, crash = !crashReportingDisabled),
253
292
  installIdentifierEnabled = !installIdentifierDisabled,
293
+ releaseHealth = parseReleaseHealth(opts),
294
+ // The JS facade always sends React Native's `__DEV__` (or the host's override). A missing
295
+ // or null key stays null so the native SDK decides for itself, as for any other caller.
296
+ debugBuild = opts.takeIfHasBoolean("debugBuild"),
254
297
  // Explicit companion device-identity override (naming spec
255
298
  // 2026-08-24) — read straight off the flat ConfigOpts field,
256
299
  // same shape as attachPinUi above. Absent/blank falls
@@ -268,6 +311,7 @@ class EverframeModule(
268
311
  // `startCompanion()` time, mirroring `attachPinUi`.
269
312
  companionBadgeEnabled = opts.takeIfHasBoolean("companionBadgeEnabled") ?: true,
270
313
  shakeToReportEnabled = opts.takeIfHasBoolean("shakeToReportEnabled") ?: true,
314
+ reporterEnabled = !reporterDisabled,
271
315
  companionBadgePosition = opts.takeIfHasString("companionBadgePosition"),
272
316
  // Inline reporter theme (reporter branding spec 2026-08-25,
273
317
  // RN slice) — 8 flat optional STRING fields on ConfigOpts
@@ -377,6 +421,9 @@ class EverframeModule(
377
421
  * { status: "submitted", reportId: "<uuid>" }
378
422
  * { status: "queued", reportId: "<uuid>" }
379
423
  * { status: "cancelled" }
424
+ *
425
+ * Rejects with [REJECT_REPORTER_DISABLED] in a crash-only session
426
+ * (`reporterDisabled`), including after a companion bond upgraded it.
380
427
  */
381
428
  @ReactMethod
382
429
  override fun openReporter(promise: Promise) {
@@ -384,6 +431,12 @@ class EverframeModule(
384
431
  promise.reject(REJECT_CAPTURE_DISABLED, "Everframe not started (captureGate=false). Call configure() first.")
385
432
  return
386
433
  }
434
+ // Crash-only session: rejected here because ReporterBridge reduces every core failure to one
435
+ // generic message, which would hide `ReporterDisabledException` from JS.
436
+ if (Everframe.currentConfig?.reporterEnabled == false) {
437
+ promise.reject(REJECT_REPORTER_DISABLED, "The reporter is turned off for this session (reporter.disabled).")
438
+ return
439
+ }
387
440
  MainScope().launch {
388
441
  try {
389
442
  val result: ReportResult = reporterBridge.open(reactContext)
@@ -454,7 +507,7 @@ class EverframeModule(
454
507
  * `Everframe.addBreadcrumb` (Task 9) — NO validation/coercion here. That
455
508
  * singleton already owns kind/level/data coercion AND gating (no-op
456
509
  * pre-start / while killed via its own `txGuardVoid` + `captureGate`
457
- * check), exactly like `setExtra`/`attachReactTree` above never re-check
510
+ * check), exactly like `setExtra` above never re-checks
458
511
  * `captureGate` themselves. `data` crosses as a `ReadableMap?` — the
459
512
  * codegen-typed freeform-object boundary — converted with RN's own
460
513
  * `toHashMap()` (no re-shaping) before handing off.
@@ -591,7 +644,7 @@ class EverframeModule(
591
644
  *
592
645
  * The primary fix is the JS projection in `src/user-projection.ts`, applied
593
646
  * before the value ever crosses the bridge. This is the second layer, for a
594
- * hand-wired native caller (or a host on an older JS bundle) that reaches
647
+ * hand-wired native caller that reaches
595
648
  * this method directly: bad fields degrade to `null` — a partial user, or
596
649
  * anonymous — and `Everframe.setUser` is ALWAYS reached, so the call always
597
650
  * replaces what was installed before.
@@ -641,11 +694,11 @@ class EverframeModule(
641
694
  @ReactMethod(isBlockingSynchronousMethod = true)
642
695
  override fun getReportDeliveryStatusJson(): String = Everframe.getReportDeliveryStatus().toJson()
643
696
 
644
- /** Automatic ErrorUtils capture; true acknowledges completed sidecar storage. */
697
+ /** Automatic ErrorUtils and unhandled-rejection capture; true acknowledges completed sidecar storage. A non-fatal error may carry a replay. */
645
698
  @ReactMethod(isBlockingSynchronousMethod = true)
646
699
  override fun reportCrash(crashJson: String): Boolean = captureCrashJson(crashJson, handled = false)
647
700
 
648
- /** Explicit capture always enforces handled=true, fatal=false, captureException. */
701
+ /** Explicit capture always enforces handled=true, fatal=false, captureException, and may carry a replay. */
649
702
  @ReactMethod(isBlockingSynchronousMethod = true)
650
703
  override fun captureHandledException(crashJson: String): Boolean = captureCrashJson(crashJson, handled = true)
651
704
 
@@ -672,12 +725,17 @@ class EverframeModule(
672
725
  val details = if (obj.has("details")) obj.get("details") else null
673
726
  val causeChain = if (obj.has("causeChain")) obj.get("causeChain") else null
674
727
  val fatal = !handled && obj.optBoolean("fatal", false)
675
- val accepted = if (handled) {
676
- dev.everframe.crash.CrashReporter.captureHandledFactsWithCauses(
728
+ // Only errors the app survives ask for a replay. Eligibility and export remain in the core;
729
+ // fatal errors keep the entry point that never carries a replay.
730
+ val accepted = when {
731
+ handled -> dev.everframe.crash.CrashReporter.captureHandledFactsWithReplay(
677
732
  exceptionType, message, frames, occurredAt, jsBundle, details, causeChain, "everframe-android",
678
733
  )
679
- } else {
680
- dev.everframe.crash.CrashReporter.captureFactsAcceptedWithCauses(
734
+ fatal -> dev.everframe.crash.CrashReporter.captureFactsAcceptedWithCauses(
735
+ exceptionType, message, frames, obj.optString("mechanism", "errorutils"),
736
+ fatal, occurredAt, jsBundle, details, causeChain,
737
+ )
738
+ else -> dev.everframe.crash.CrashReporter.captureFactsAcceptedWithReplay(
681
739
  exceptionType, message, frames, obj.optString("mechanism", "errorutils"),
682
740
  fatal, occurredAt, jsBundle, details, causeChain,
683
741
  )
@@ -1051,14 +1109,6 @@ class EverframeModule(
1051
1109
  val capture: ScreenshotCapture.CaptureResult,
1052
1110
  val reportCapture: dev.everframe.capture.video.FrozenReportCapture,
1053
1111
  val hostExtra: String?,
1054
- /**
1055
- * Drained-and-discarded host React tree (spec 2026-08-29). Nothing
1056
- * reads it any more, and RN can no longer attach one — the
1057
- * TurboModule spec method is gone. Kept only because the native core
1058
- * `Everframe.attachReactTree` remains callable by pure-native hosts,
1059
- * so the consume-once drain must still clear the slot.
1060
- */
1061
- val hostReactTreeJson: String?,
1062
1112
  )
1063
1113
 
1064
1114
  private val companionCaptureStash = ConcurrentHashMap<String, CompanionCapture>()
@@ -1115,7 +1165,7 @@ class EverframeModule(
1115
1165
  // same reason the screenshot capture above is —
1116
1166
  // this whole provider runs on the OkHttp WS
1117
1167
  // thread, never the UI thread.
1118
- val (hostExtraRaw, hostReactTreeJson) =
1168
+ val hostExtraRaw =
1119
1169
  runBlocking { Everframe.consumePendingAttachments() }
1120
1170
  val hostExtra = hostExtraRaw?.takeIf { it.isNotEmpty() }
1121
1171
 
@@ -1127,7 +1177,6 @@ class EverframeModule(
1127
1177
  capture = capture,
1128
1178
  reportCapture = reportCapture,
1129
1179
  hostExtra = hostExtra,
1130
- hostReactTreeJson = hostReactTreeJson?.takeIf { it.isNotEmpty() },
1131
1180
  )
1132
1181
  }
1133
1182
  if (!published) {
@@ -1439,9 +1488,7 @@ class EverframeModule(
1439
1488
  /**
1440
1489
  * Called by the JS companion module from the `everframe.companion
1441
1490
  * .reportRequested` event handler once it is ready for capture to
1442
- * proceed. (It used to walk the React fiber tree and call
1443
- * `attachReactTree` first; both are gone — spec 2026-08-29.) Releases
1444
- * the `awaitJsReactTreeAttach` waiter for this correlation id so the
1491
+ * proceed. Releases the `awaitJsReactTreeAttach` waiter for this correlation id so the
1445
1492
  * capture provider can proceed. Idempotent — late signals after the
1446
1493
  * timeout fired are harmless.
1447
1494
  */
@@ -9,7 +9,6 @@ import android.os.Looper
9
9
  import android.util.Log
10
10
  import com.facebook.react.bridge.ReactApplicationContext
11
11
  import com.facebook.react.common.LifecycleState
12
- import dev.everframe.Everframe
13
12
  import dev.everframe.config.ReportResult
14
13
  import java.lang.reflect.InvocationTargetException
15
14
  import java.lang.reflect.Method
@@ -19,7 +18,7 @@ import kotlinx.coroutines.CancellationException
19
18
  import kotlinx.coroutines.Dispatchers
20
19
  import kotlinx.coroutines.withContext
21
20
 
22
- /** Reflection preserves compatibility with separately built native reporter artifacts. */
21
+ /** Resolves the reporter installer reflectively, so tests can substitute it. */
23
22
  internal class ReporterBridge(
24
23
  private val resolveInstaller: () -> Class<*> = { Class.forName("dev.everframe.ui.ReporterResolverInstaller") },
25
24
  ) {
@@ -46,17 +45,9 @@ internal class ReporterBridge(
46
45
  try {
47
46
  val type = installerType()
48
47
  val instance = type.getDeclaredConstructor().newInstance()
49
- val method = try {
50
- type.getMethod("openForActivity", Context::class.java, Activity::class.java, Continuation::class.java)
51
- } catch (_: NoSuchMethodException) { null }
52
- if (method == null) {
53
- // Absence is the only legacy fallback. A present method's failure is final.
54
- invoke(type.getMethod("create", Context::class.java), instance, context.applicationContext)
55
- Everframe.report.open()
56
- } else {
57
- suspendCoroutineUninterceptedOrReturn<ReportResult> { continuation ->
58
- invoke(method, instance, context.applicationContext, activity, continuation)
59
- }
48
+ val method = type.getMethod("openForActivity", Context::class.java, Activity::class.java, Continuation::class.java)
49
+ suspendCoroutineUninterceptedOrReturn<ReportResult> { continuation ->
50
+ invoke(method, instance, context.applicationContext, activity, continuation)
60
51
  }
61
52
  } catch (failure: Throwable) {
62
53
  when (failure) {