@everframe/react-native 1.0.0 → 1.2.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.
@@ -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.0.0' : native_version_override
43
+ native_minor_range = native_version_override.empty? ? '~> 1.2.0' : 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
@@ -56,11 +59,11 @@ import { EverframeProvider, useEverframe } from '@everframe/react-native';
56
59
 
57
60
  export default function App() {
58
61
  return (
59
- // `apiKey` is the only required field. The ingest endpoint is a
62
+ // `sdkKey` is the only required field. The ingest endpoint is a
60
63
  // compile-time constant in the SDK and is not configurable in v1.
61
64
  <EverframeProvider
62
65
  config={{
63
- apiKey: 'txx_live_xxxxxxxxxxxxxxxx',
66
+ sdkKey: 'evf_live_xxxxxxxxxxxxxxxx',
64
67
  // Recommended in RN/Expo development: shake also opens the dev menu.
65
68
  shakeToReport: { enabled: !__DEV__ },
66
69
  }}
@@ -96,10 +99,259 @@ provider context and attach any non-sensitive context needed for diagnosis.
96
99
  The source API requires matching rebuilt native components; published `0.7.0`
97
100
  artifacts are not evidence of support.
98
101
 
102
+ ## Release health
103
+
104
+ Release health counts crash-free foreground sessions for each version of your
105
+ app. It is on by default, and debug builds of the native app aren't counted.
106
+ Turn it off in the `EverframeProvider` config:
107
+
108
+ ```tsx
109
+ releaseHealth: { enabled: false },
110
+ ```
111
+
112
+ Sessions come from the Android and iOS SDKs and count toward the native app
113
+ version, which an over-the-air update doesn't change. Release health needs the
114
+ Hermes bundle identity that `@everframe/metro` adds at build time (or the
115
+ `jsBundle` option); without it, release health stays off and the SDK logs an
116
+ `[everframe]` warning (in development builds only, unless you set
117
+ `releaseHealth`). Add `userId: accountId` to count crash-free users, and
118
+ `includeDebugBuilds: true` to count debug builds too. The configuration is read
119
+ when the Provider mounts, so remount it with a changed `key` to change `enabled`
120
+ or `userId`. A configure with `enabled: false` turns release health off and
121
+ erases its queued records on the device.
122
+ Release health skips a debug build, which is `__DEV__` unless you set
123
+ `debugBuild` on the Provider. See
124
+ [release health](../../docs/release-health.md).
125
+
126
+ ## Promise rejection observation
127
+
128
+ Automatic Hermes rejection observation is opt-in:
129
+
130
+ ```tsx
131
+ import { EverframeProvider, getPromiseRejectionStatus } from '@everframe/react-native';
132
+
133
+ <EverframeProvider config={{
134
+ sdkKey: '…',
135
+ crashReporting: { promiseRejections: { enabled: true } },
136
+ }}>
137
+ <App />
138
+ </EverframeProvider>
139
+
140
+ // Read after mount. This reports observer coverage, not delivery confirmation.
141
+ const status = getPromiseRejectionStatus();
142
+ ```
143
+
144
+ The verified runtime is Android/iOS `react-native-tvos@0.85.3-0` with Hermes
145
+ release `250829098.0.10`, Release/Static Hermes and bytecode 98. Qualification
146
+ uses optimized apps on an Android API 35 arm64 emulator and an iOS 26.5 arm64
147
+ simulator with Debug/local native SDK transport, including expiry after the app
148
+ process is suspended, source mapping and encrypted retry after relaunch.
149
+ Physical devices, device sleep and production transport are not qualified by
150
+ these checks. Matching the SDK's broader RN peer range does not establish
151
+ rejection support. Other versions, JSC, browser execution, tvOS,
152
+ unverified Promise replacements and incompatible hooks return `unsupported`.
153
+ The adapter checks runtime identity and a fulfilled-only hook handshake; it
154
+ does not generate a test rejection or replace the Promise constructor.
155
+
156
+ Rejections still unhandled after 2 seconds enter the existing nonfatal capture
157
+ path. A handler attached within that interval cancels capture; later handling
158
+ does not retract an accepted report. The notification uses a JavaScript timer.
159
+ Android pauses JavaScript timers while the app's activity is paused (in the
160
+ background, behind another activity or in picture-in-picture), although
161
+ JavaScript can keep running. Rejections observed then are notified on later
162
+ Promise activity or when the activity resumes. Explicit capture and automatic
163
+ reporting share accepted error identity, and automatic reports retain their
164
+ existing 10-distinct-key allowance per mount. Automatic reports are
165
+ deduplicated by exception type and top stack frame. Non-Error reasons have no
166
+ stack and are deduplicated by their reported value with digit runs ignored, so
167
+ `Request 1001 failed` repeats `Request 1000 failed` and all object reasons
168
+ share one report per mount. They spend at most 5 of the 10 keys, so values that
169
+ differ in other ways, such as hex identifiers, still leave 5 keys for `Error`
170
+ rejections and other automatic reports.
171
+
172
+ The observer retains at most 16 detached snapshots, each at most 64 KiB of
173
+ serialized UTF-8 data. It drops new arrivals when full and oversize snapshots
174
+ rather than retaining arbitrary error graphs. Work is counted as expired
175
+ instead of reported when it is notified more than 30 seconds after the
176
+ rejection. Age is measured on the platform's elapsed clock and bounded by
177
+ wall-clock time, which keeps advancing while an Android device sleeps. Cleanup
178
+ cannot run while JavaScript is suspended, so stale work expires when
179
+ JavaScript resumes. Error facts and causes are snapshotted at rejection time.
180
+ Rejection reasons that are not `Error` objects are reported as `UnhandledValue`
181
+ without a stack or cause chain, even when they carry a `cause`. Strings,
182
+ numbers, booleans, `null` and `undefined` keep their bounded value; objects,
183
+ arrays, functions, symbols and bigints are reported only as a type label such
184
+ as `[object]` rather than serialized.
185
+ Native context and breadcrumbs are collected at notification time. A preserved
186
+ external tracker may independently retain errors or generate its own reports.
187
+
188
+ `getPromiseRejectionStatus()` returns `disabled`, `unsupported`, `observing`,
189
+ `displaced`, `install-failed`, or `not-mounted`, with a reason and bounded
190
+ counters for pending, accepted, cancelled, dropped, expired, suppressed,
191
+ refused, failed and discarded captures. A rejection handled within the 2-second interval counts as cancelled,
192
+ even if it was dropped or failed to snapshot on arrival. Counters saturate at
193
+ 2,147,483,647 and reset on each mount; they contain no captured messages or
194
+ stacks. Replacing either hook stops this observer until a new mount and
195
+ releases its pending work, counted as `discarded`.
196
+ `crashReporting.disabled: true` overrides the opt-in, and unmount discards
197
+ pending work. Configuration changes take effect on a new mount.
198
+ The browser export reports Hermes observation as unsupported and preserves the
199
+ browser SDK's own error handling.
200
+
201
+ ## Error capture and diagnostics
202
+
203
+ ### Native crashes and ANRs
204
+
205
+ 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.
206
+
207
+ ### Errors no boundary catches
208
+
209
+ Since React Native 0.78, React reports an error that no error boundary catches
210
+ (thrown while rendering, in an effect or in a lifecycle method) through the
211
+ root's `onUncaughtError`. That calls React Native's `ExceptionsManager`, never
212
+ the `ErrorUtils` handler. Crash reporting hooks
213
+ `ExceptionsManager.handleException` too, so such an error is reported as a fatal
214
+ JavaScript crash (`mechanism: "errorutils"`) with its Hermes stack, and React's
215
+ component stack follows the message after `This error is located at:`, as in
216
+ React Native's own report. React unmounts the whole tree, `EverframeProvider`
217
+ included, before it reports the error; the hook stays until the end of that
218
+ task, so the error is still captured. The native fatal React Native raises for
219
+ the same error (`RCTFatalException` on iOS and tvOS, `JavascriptException` on
220
+ Android) is not reported a second time. An error in the render that first
221
+ mounts the provider comes before this capture is installed. Errors a boundary
222
+ catches are not reported automatically; use the callback below.
223
+
224
+ ### React error boundary callback
225
+
226
+ An existing React class boundary can opt into handled capture. Render the
227
+ boundary inside `EverframeProvider`:
228
+
229
+ ```tsx
230
+ import * as React from 'react';
231
+ import { EverframeProvider } from '@everframe/react-native';
232
+ import { captureReactError } from '@everframe/react-native/integrations/react';
233
+
234
+ class Boundary extends React.Component<React.PropsWithChildren, { failed: boolean }> {
235
+ state = { failed: false };
236
+ static getDerivedStateFromError() { return { failed: true }; }
237
+ componentDidCatch(error: unknown, info: React.ErrorInfo) {
238
+ captureReactError(error, info);
239
+ }
240
+ render() { return this.state.failed ? null : this.props.children; }
241
+ }
242
+
243
+ export default function App() {
244
+ return (
245
+ <EverframeProvider config={{ sdkKey: '…' }}>
246
+ <Boundary>
247
+ <RootNavigator />
248
+ </Boundary>
249
+ </EverframeProvider>
250
+ );
251
+ }
252
+ ```
253
+
254
+ A boundary that wraps `EverframeProvider` does not report through it: its
255
+ fallback replaces the provider in the same commit, before `componentDidCatch`
256
+ runs. Such a boundary reports only if its fallback renders its own
257
+ `EverframeProvider`, which mounts before `componentDidCatch` runs. Errors that a
258
+ boundary inside the provider catches during the provider's first commit (the
259
+ initial render that also mounts the provider) are not captured, and they are
260
+ not replayed. Disabled crash reporting and calls after unmount are also inert.
261
+ In development builds, a call that finds no mounted provider logs a warning, at
262
+ most once until a provider mounts or unmounts.
263
+
264
+ The application owns fallback and recovery UI. This callback reports caught
265
+ component errors; it does not install global renderer hooks or capture arbitrary
266
+ async/event-handler errors. Component-stack metadata is bounded and redacted by
267
+ the existing capture pipeline, without replacing exception frames or cause data.
268
+
269
+ The original error shares accepted identity with manual and nonfatal automatic
270
+ capture within a mount. Native refusal permits recapture; equal fingerprint keys
271
+ can also suppress distinct objects. A later fatal escalation may report separately.
272
+ The adapter subpath is unavailable to browser-conditioned consumers.
273
+
274
+ ### Local capture admission diagnostics
275
+
276
+ ```ts
277
+ import { getErrorCaptureStatus } from '@everframe/react-native';
278
+ const status = getErrorCaptureStatus();
279
+ const { attempted, accepted, duplicateSuppressed } = status.counters.handled;
280
+ ```
281
+
282
+ Snapshots are detached, contain no captured content, reset on remount, and perform
283
+ no native query or upload. Paths are `handled` (including the boundary callback),
284
+ `errorUtils` (including errors no boundary catches), and `rejection` (submission only). Each active attempt has one outcome:
285
+ `accepted`, `duplicateSuppressed`, `allowanceSuppressed`, `bridgeUnavailable`,
286
+ `nativeRefused`, `captureFailed`, `reentrantSuppressed`, `inactiveAborted`, or
287
+ `legacyAttempted`. Counters saturate at 2,147,483,647. Existing limits are ten
288
+ handled and ten automatic distinct keys; fatal capture bypasses them.
289
+
290
+ `accepted` requires exactly true from a current native capture method. It does
291
+ not mean upload success, server processing or outbox removal. An automatic call
292
+ to an older native fallback is only `legacyAttempted`, regardless of its return.
293
+ Old-binary unavailable/throwing methods are covered by unit controls, not an
294
+ installed old-binary qualification. This snapshot excludes native queue and
295
+ delivery observations; read those with `getReportDeliveryStatus()`. Status is
296
+ neutral `not-mounted` without a provider, `disabled` under the crash-reporting
297
+ veto, and `unsupported` on the browser root entry.
298
+
299
+ Installed boundary qualification passes on Android API 35 arm64 emulator and
300
+ iOS 26.5 arm64 simulator for this exact phone-host row:
301
+ React 19.2.5, `react-native-tvos` 0.85.3-0 aliased as `react-native`, Expo
302
+ 56.0.0-preview.7, Hermes 250829098.0.10 / bytecode 98. Other RN distributions,
303
+ versions, TV, physical devices and production endpoints are not qualified by
304
+ this matrix. Installed checks cover four genuine boundary capture cases,
305
+ disabled/initial zero controls, mapped causes, and byte-identical encrypted retry
306
+ after relaunch. The application/JS builds are optimized; native SDKs are
307
+ Debug/local. Physical-device and production-endpoint qualification remains open.
308
+
309
+ ### Native report delivery diagnostics
310
+
311
+ `getReportDeliveryStatus()` synchronously copies cached native observations. It
312
+ performs no file reads, uploads, drains, or hook installation. Use
313
+ `getErrorCaptureStatus()` separately for JavaScript admission decisions.
314
+
315
+ ```ts
316
+ import { getReportDeliveryStatus } from '@everframe/react-native';
317
+ const status = getReportDeliveryStatus();
318
+ console.log(status.status, status.queue.observation, status.queue.pendingCount);
319
+ ```
320
+
321
+ The schema separates native capture acceptance, queue operations and settled
322
+ transport attempts (`live-submit` and `outbox-drain`). `server-accepted` means an
323
+ HTTP 2xx response; it does not establish processing, symbolication, dashboard
324
+ visibility or queue removal. Removal has its own operation counter. Counters are
325
+ operations, not unique reports, and saturate at 2,147,483,647.
326
+
327
+ The queue is the SDK's shared report outbox, including reporter submissions and
328
+ entries from earlier configurations. Its count is last-observed, not a fresh
329
+ measurement or a current-project/crash-only total. Missing counts mean unknown;
330
+ `partial` means only readable entries were counted. Android rejects new entries
331
+ at capacity and retains terminal HTTP responses during drain. iOS can evict older
332
+ entries and attempts removal after terminal HTTP responses. Custom standalone
333
+ outbox/submitter instances are not automatically included.
334
+
335
+ Snapshots contain only fixed codes and counters, never report content, IDs,
336
+ URLs, credentials or exception strings. Observations are process-local and reset
337
+ on native start/reconfiguration or kill. They are best-effort: contention and
338
+ termination may lose observations; a busy getter reports `snapshot-busy`.
339
+ `bridge-handled` and `bridge-automatic` cover fact entry paths shared by framework
340
+ bridges. iOS marks `jvm-uncaught` unsupported and implies no automatic iOS native
341
+ crash collector.
342
+
343
+ Without a mounted provider the result is `not-mounted/no-mount`; the browser
344
+ export is `unsupported/platform`. Older native SDKs without the getter return
345
+ `unsupported/native-method-missing`. A throwing native call returns
346
+ `unavailable/native-call-failed`, and malformed native responses return
347
+ `unavailable/invalid-native-snapshot`. These fallbacks have an unobserved queue
348
+ and `unknown` platform policies. This API does not provide a per-report delivery
349
+ guarantee or trigger a retry.
350
+
99
351
  ## Network body capture (client veto)
100
352
 
101
353
  ```tsx
102
- <EverframeProvider config={{ apiKey: '…', networkBodies: { disabled: true } }}>
354
+ <EverframeProvider config={{ sdkKey: '…', networkBodies: { disabled: true } }}>
103
355
  ```
104
356
 
105
357
  `networkBodies.disabled: true` is a **client veto** — it can only turn body
@@ -182,7 +434,7 @@ import { createNavigationContainerRef } from '@react-navigation/native';
182
434
  const navigationRef = createNavigationContainerRef();
183
435
  const txNav = reactNavigationIntegration({ navigationRef });
184
436
 
185
- <EverframeProvider config={{ apiKey, integrations: [consoleIntegration(), txNav] }}>
437
+ <EverframeProvider config={{ sdkKey, integrations: [consoleIntegration(), txNav] }}>
186
438
  <NavigationContainer ref={navigationRef} onReady={txNav.onReady}>
187
439
  ...
188
440
  ```
@@ -211,7 +463,7 @@ inside a native view and never reaches JS.
211
463
  ### Config
212
464
 
213
465
  ```tsx
214
- <EverframeProvider config={{ apiKey, vitals: { enabled: true, sampleRate: 0.5, captureSourceQuery: false } }}>
466
+ <EverframeProvider config={{ sdkKey, vitals: { enabled: true, sampleRate: 0.5, captureSourceQuery: false } }}>
215
467
  ```
216
468
 
217
469
  - `enabled` — absent follows the dashboard toggle; `false` opts out locally;
@@ -222,7 +474,9 @@ inside a native view and never reaches JS.
222
474
 
223
475
  **Reconfiguring.** A Provider remount with the *same* config does not restart
224
476
  the SDK: both native sides compare the incoming config against the *installed*
225
- one and skip the start entirely. A **changed** config does restart it — and a
477
+ one and skip the start entirely. A **changed** config does restart it (on iOS,
478
+ except a change confined to `releaseHealth` or the loaded bundle; see
479
+ [Release health](#release-health)) — and a
226
480
  start supersedes the running SDK, detaching every player integration it had
227
481
  announced. **Limitation:** players tracked before that restart stay untracked
228
482
  until their screens remount; nothing re-registers them automatically. Two React
@@ -394,7 +648,7 @@ on Android), so React Native's focus engine never sees it — the native VC
394
648
  manages focus on its own UIWindow / Activity.
395
649
 
396
650
  ```tsx
397
- <EverframeProvider config={{ apiKey }}>
651
+ <EverframeProvider config={{ sdkKey }}>
398
652
  <NavigationContainer>{/* focus engine root */}</NavigationContainer>
399
653
  </EverframeProvider>
400
654
  ```
@@ -520,6 +774,40 @@ recipes above.
520
774
 
521
775
  ---
522
776
 
777
+ ## Android reporter initialization
778
+
779
+ The Android reporter can be opened after the host activity has resumed, including
780
+ apps that disable AndroidX Startup. Use matching updated React Native bridge and
781
+ `dev.everframe:reporter-ui` artifacts: the bridge supplies the current resumed
782
+ activity, and installation and presentation run on the main thread. Repeated
783
+ configuration with matching updated artifacts does not register another lifecycle observer. Capture configuration
784
+ remains synchronous without waiting for reporter UI initialization.
785
+
786
+ The existing `ReporterResolverInstaller.create(Context)` entry point is retained
787
+ for compiled callers. A new bridge with an older reporter uses its legacy entry
788
+ point when the activity-aware method is absent; that combination retains the old
789
+ late-initialization limitation. A failure from an available method is reported,
790
+ not retried through the legacy path. Background or unavailable activities return
791
+ a cancelled result with reason `no_active_activity`.
792
+
793
+ Legacy off-main `create(Context)` schedules installation and returns before it is
794
+ ready; use the awaited activity-aware bridge to open immediately after configure.
795
+ With a new bridge and an older reporter, each configure, companion start, and
796
+ open invokes legacy installation again. This can add duplicate lifecycle
797
+ callbacks and overwrite that older reporter's resolver and activity supplier;
798
+ the previous bridge limited installation to once per module. Conversely, an old
799
+ compiled bridge with the new reporter can announce companion PIN capability
800
+ before asynchronous reporter installation finishes, temporarily advertising no
801
+ PIN support. Use the matching updated pair for the qualified behavior.
802
+
803
+ Activity destruction while a reporter is open resolves the pending call with
804
+ `{ status: "cancelled", reason: "activity_destroyed" }`, releases that report's
805
+ frozen replay capture, and clears presenting state so the reporter can open
806
+ again. Backgrounding alone does not cancel the dialog. Once Send is tapped,
807
+ submission owns the result and continues independently of activity destruction;
808
+ its normal delivery result is preserved. Unsaved drafts are not restored after
809
+ activity recreation.
810
+
523
811
  ## License
524
812
 
525
813
  MIT
@@ -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.0.0,1.1.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.0,1.3.0)"
156
157
  if (nativeVersionOverride != null) {
157
158
  logger.lifecycle(
158
159
  "[everframe-rn] native AAR pinned to $nativeVersionOverride via everframeNativeVersion " +
@@ -76,6 +76,8 @@ import dev.everframe.companion.CompanionSubmissionComposer
76
76
  import dev.everframe.companion.PreviewCapture
77
77
  import dev.everframe.companion.RelayWSClient
78
78
  import dev.everframe.companion.parseCompanionBadgePosition
79
+ import dev.everframe.config.ReleaseHealthConfig
80
+ import dev.everframe.config.ReleaseHealthBundleStatus
79
81
  import dev.everframe.config.CaptureConfig
80
82
  import dev.everframe.config.Environment
81
83
  import dev.everframe.config.ReportResult
@@ -105,6 +107,8 @@ class EverframeModule(
105
107
  private val reactContext: ReactApplicationContext,
106
108
  ) : NativeEverframeSpec(reactContext) {
107
109
 
110
+ private val reporterBridge = ReporterBridge()
111
+
108
112
  private val videoPrivacyAdapter = Everframe.__registerVideoPrivacyAdapter(RnVideoPrivacyAdapter)
109
113
  private val sensitiveRegistration = RnSensitiveRegistration(
110
114
  { tag -> UIManagerHelper.getUIManagerForReactTag(reactContext, tag)?.resolveView(tag) },
@@ -213,12 +217,45 @@ class EverframeModule(
213
217
  @ReactMethod(isBlockingSynchronousMethod = true)
214
218
  override fun configureSync(opts: ReadableMap): Boolean = configureInternal(opts)
215
219
 
220
+ private fun parseReleaseHealth(opts: ReadableMap): ReleaseHealthConfig? {
221
+ // Invalid bridge input revokes earlier monitoring instead of retaining an old subject.
222
+ // The SDK reads the app version itself; the bridge carries the loaded bundle, the user ID and
223
+ // whether debug builds count. Release health is on by default: a missing or null flag is on, and
224
+ // only an explicit false is off. It still needs a valid loaded bundle, so no id means off.
225
+ return runCatching {
226
+ if (opts.takeIfHasBoolean("releaseHealthEnabled") == false) return null
227
+ val loaded = opts.takeIfHasString("releaseHealthLoadedBuildId") ?: return null
228
+ val user = opts.takeIfHasString("releaseHealthUserId")
229
+ if (!validHealthText(loaded, 200) || (user != null && !validHealthText(user, 128))) return null
230
+ val includeDebugBuilds = opts.takeIfHasBoolean("releaseHealthIncludeDebugBuilds") ?: false
231
+ ReleaseHealthConfig(enabled = true, userId = user, loadedBuildId = loaded,
232
+ loadedBundleStatus = ReleaseHealthBundleStatus.KNOWN, includeDebugBuilds = includeDebugBuilds)
233
+ }.getOrNull()
234
+ }
235
+
236
+ private fun validHealthText(value: String, maximum: Int): Boolean {
237
+ // Match the wire's UTF-16 limit and ECMAScript blank definition, preserving ID bytes.
238
+ fun blank(c: Char) = c in '\u0009'..'\u000d' || c == ' ' || c == '\u00a0' ||
239
+ c == '\u1680' || c in '\u2000'..'\u200a' || c == '\u2028' || c == '\u2029' ||
240
+ c == '\u202f' || c == '\u205f' || c == '\u3000' || c == '\ufeff'
241
+ if (value.length > maximum || value.all(::blank)) return false
242
+ var index = 0
243
+ while (index < value.length) {
244
+ val c = value[index++]
245
+ if (c.code < 32 || Character.isLowSurrogate(c)) return false
246
+ if (Character.isHighSurrogate(c)) {
247
+ if (index >= value.length || !Character.isLowSurrogate(value[index++])) return false
248
+ }
249
+ }
250
+ return true
251
+ }
252
+
216
253
  private fun configureInternal(opts: ReadableMap): Boolean {
217
254
  var completed = false
218
255
  txGuardSurface("configure") {
219
- val sdkKey = opts.takeIfHasString("apiKey")
256
+ val sdkKey = opts.takeIfHasString("sdkKey")
220
257
  ?: throw JSApplicationIllegalArgumentException(
221
- "configure: required field 'apiKey' missing",
258
+ "configure: required field 'sdkKey' missing",
222
259
  )
223
260
  val sdkVersion = opts.takeIfHasString("sdkVersion") ?: Everframe.SDK_VERSION
224
261
 
@@ -231,6 +268,9 @@ class EverframeModule(
231
268
  // Polarity flips exactly once, here: JS sends the veto
232
269
  // (`installIdentifierDisabled`), native stores the permission.
233
270
  val installIdentifierDisabled = opts.takeIfHasBoolean("installIdentifierDisabled") ?: false
271
+ // Polarity flips exactly once, here: JS sends the veto
272
+ // (`crashReportingDisabled`), native stores the permission.
273
+ val crashReportingDisabled = opts.takeIfHasBoolean("crashReportingDisabled") ?: false
234
274
  // Attach-PIN UI mode (spec 2026-08-19) — a flat optional STRING
235
275
  // field (codegen cannot express a string-literal union), parsed
236
276
  // here and stashed for `startCompanion()` to read (the flag is a
@@ -243,12 +283,20 @@ class EverframeModule(
243
283
  val attachPinUi = parseAttachPinUi(opts.takeIfHasString("attachPinUi"))
244
284
 
245
285
  val config = EverframeConfig(
286
+ // The JS config carries no App ID, and the native SDK requires a
287
+ // non-blank one. It only keys this app's low-memory-kill issue, so
288
+ // the SDK key stands in, as it always has; changing it would split
289
+ // existing React Native low-memory issues.
246
290
  appId = sdkKey,
247
291
  sdkKey = sdkKey,
248
292
  environment = Environment.production,
249
293
  release = sdkVersion,
250
- capture = CaptureConfig(networkBodies = !networkBodiesDisabled),
294
+ capture = CaptureConfig(networkBodies = !networkBodiesDisabled, crash = !crashReportingDisabled),
251
295
  installIdentifierEnabled = !installIdentifierDisabled,
296
+ releaseHealth = parseReleaseHealth(opts),
297
+ // The JS facade always sends React Native's `__DEV__` (or the host's override). A missing
298
+ // or null key stays null so the native SDK decides for itself, as for any other caller.
299
+ debugBuild = opts.takeIfHasBoolean("debugBuild"),
252
300
  // Explicit companion device-identity override (naming spec
253
301
  // 2026-08-24) — read straight off the flat ConfigOpts field,
254
302
  // same shape as attachPinUi above. Absent/blank falls
@@ -331,7 +379,7 @@ class EverframeModule(
331
379
  "Everframe.rn",
332
380
  "configure: SDK already running this exact config — skipping start()",
333
381
  )
334
- installPresenterResolverIfNeeded()
382
+ reporterBridge.scheduleInstall(reactContext)
335
383
  completed = true
336
384
  return@txGuardSurface
337
385
  }
@@ -357,10 +405,10 @@ class EverframeModule(
357
405
 
358
406
  // Wire TXReporterPresenter as the resolver for
359
407
  // Everframe.shared.report.open(). Mirrors iOS TXReporterPresenter
360
- // .installResolver(). Reflective lookup keeps :sdk-react-native
361
- // from a hard dependency on :everframe-reporter-ui (which the host
362
- // app pulls in via its app build.gradle).
363
- installPresenterResolverIfNeeded()
408
+ // .installResolver(). Reflection preserves compatibility with
409
+ // separately built reporter artifacts; the awaited open retries
410
+ // installation on main before selecting the resumed host.
411
+ reporterBridge.scheduleInstall(reactContext)
364
412
  completed = true
365
413
  }
366
414
  return completed
@@ -382,16 +430,9 @@ class EverframeModule(
382
430
  promise.reject(REJECT_CAPTURE_DISABLED, "Everframe not started (captureGate=false). Call configure() first.")
383
431
  return
384
432
  }
385
- // ReporterResolverInstaller tracks the active Activity via its own
386
- // ActivityRegistry — we don't pass currentActivity through. But we
387
- // ensure the resolver is installed so report.open() doesn't throw.
388
- installPresenterResolverIfNeeded()
389
-
390
433
  MainScope().launch {
391
434
  try {
392
- val result: ReportResult = withContext(Dispatchers.Main) {
393
- Everframe.report.open()
394
- }
435
+ val result: ReportResult = reporterBridge.open(reactContext)
395
436
  val dict = Arguments.createMap().apply {
396
437
  when (result) {
397
438
  is ReportResult.Submitted -> {
@@ -410,7 +451,8 @@ class EverframeModule(
410
451
  }
411
452
  promise.resolve(dict)
412
453
  } catch (t: Throwable) {
413
- promise.reject(REJECT_OPEN_REPORTER_FAILED, t.message, t)
454
+ promise.reject(REJECT_OPEN_REPORTER_FAILED, reporterBridge.rejectionMessage(t))
455
+ if (t is kotlinx.coroutines.CancellationException) throw t
414
456
  }
415
457
  }
416
458
  }
@@ -641,6 +683,10 @@ class EverframeModule(
641
683
 
642
684
  // ---------------- synchronous crash persistence ----------------
643
685
 
686
+ /** Copies cached native facts; never initializes storage or starts a drain. */
687
+ @ReactMethod(isBlockingSynchronousMethod = true)
688
+ override fun getReportDeliveryStatusJson(): String = Everframe.getReportDeliveryStatus().toJson()
689
+
644
690
  /** Automatic ErrorUtils capture; true acknowledges completed sidecar storage. */
645
691
  @ReactMethod(isBlockingSynchronousMethod = true)
646
692
  override fun reportCrash(crashJson: String): Boolean = captureCrashJson(crashJson, handled = false)
@@ -670,15 +716,16 @@ class EverframeModule(
670
716
  }
671
717
  }.getOrNull()
672
718
  val details = if (obj.has("details")) obj.get("details") else null
719
+ val causeChain = if (obj.has("causeChain")) obj.get("causeChain") else null
673
720
  val fatal = !handled && obj.optBoolean("fatal", false)
674
721
  val accepted = if (handled) {
675
- dev.everframe.crash.CrashReporter.captureHandledFactsWithDetails(
676
- exceptionType, message, frames, occurredAt, jsBundle, details,
722
+ dev.everframe.crash.CrashReporter.captureHandledFactsWithCauses(
723
+ exceptionType, message, frames, occurredAt, jsBundle, details, causeChain, "everframe-android",
677
724
  )
678
725
  } else {
679
- dev.everframe.crash.CrashReporter.captureFactsAcceptedWithDetails(
726
+ dev.everframe.crash.CrashReporter.captureFactsAcceptedWithCauses(
680
727
  exceptionType, message, frames, obj.optString("mechanism", "errorutils"),
681
- fatal, occurredAt, jsBundle, details,
728
+ fatal, occurredAt, jsBundle, details, causeChain,
682
729
  )
683
730
  }
684
731
  // Scheduling is best-effort after acceptance; a scheduler failure
@@ -851,6 +898,10 @@ class EverframeModule(
851
898
 
852
899
  @ReactMethod
853
900
  override fun startCompanion() {
901
+ if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
902
+ android.os.Handler(android.os.Looper.getMainLooper()).post { startCompanion() }
903
+ return
904
+ }
854
905
  txGuardVoid("startCompanion") {
855
906
  // Idempotent: a live client means a prior startCompanion already
856
907
  // opened the socket. Do NOT recreate it — callers must
@@ -864,13 +915,16 @@ class EverframeModule(
864
915
  return@txGuardVoid
865
916
  }
866
917
 
918
+ // Await the main-thread install before the relay can announce PIN capability.
919
+ // configureSync itself remains non-blocking with respect to main.
920
+ reporterBridge.scheduleInstall(reactContext)
867
921
  installCompanionCollectorsIfNeeded()
868
922
 
869
923
  // Runtime suppression of the built-in PIN presenter (spec
870
924
  // 2026-08-19, controller ruling): a runtime flag, not an
871
925
  // install-site gate. `:everframe-reporter-ui`'s
872
926
  // `CompanionPinPresenter` is auto-installed via
873
- // `ReporterResolverInstaller` (androidx.startup) regardless of
927
+ // `ReporterResolverInstaller` (Startup or the bridge above) regardless of
874
928
  // the configured mode, so `Everframe.__attachPinUiInstalled`
875
929
  // stays an honest capability signal — only the PRESENTING is
876
930
  // silenced, here, at the one site where the mode is already
@@ -976,6 +1030,10 @@ class EverframeModule(
976
1030
 
977
1031
  @ReactMethod
978
1032
  override fun stopCompanion() {
1033
+ if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
1034
+ android.os.Handler(android.os.Looper.getMainLooper()).post { stopCompanion() }
1035
+ return
1036
+ }
979
1037
  txGuardVoid("stopCompanion") {
980
1038
  // External review, finding NN1 — only ever stop/unregister OUR
981
1039
  // OWN client. `companionClient` is non-null here ONLY when a
@@ -1607,34 +1665,7 @@ class EverframeModule(
1607
1665
 
1608
1666
  // ---------------- Helpers ----------------
1609
1667
 
1610
- @Volatile
1611
- private var resolverInstalled = false
1612
1668
 
1613
- /**
1614
- * Idempotently runs `ReporterResolverInstaller.create(applicationContext)`
1615
- * — the androidx.startup `Initializer` from :everframe-reporter-ui. Hosts
1616
- * using AndroidX App Startup will have this run automatically at process
1617
- * launch; this method covers hosts that disable App Startup (common in
1618
- * React Native apps) by invoking the same code path reflectively. No-op
1619
- * after the first successful call.
1620
- */
1621
- private fun installPresenterResolverIfNeeded() {
1622
- if (resolverInstalled) return
1623
- try {
1624
- val cls = Class.forName("dev.everframe.ui.ReporterResolverInstaller")
1625
- val ctor = cls.getDeclaredConstructor()
1626
- ctor.isAccessible = true
1627
- val instance = ctor.newInstance()
1628
- val create = cls.getMethod("create", android.content.Context::class.java)
1629
- create.invoke(instance, reactContext.applicationContext)
1630
- resolverInstalled = true
1631
- } catch (t: Throwable) {
1632
- android.util.Log.w(
1633
- "Everframe.rn",
1634
- "ReporterResolverInstaller not on classpath; openReporter will fail until the host app depends on :everframe-reporter-ui (${t.javaClass.simpleName}: ${t.message})",
1635
- )
1636
- }
1637
- }
1638
1669
 
1639
1670
  private fun ReadableMap.takeIfHasString(key: String): String? =
1640
1671
  if (hasKey(key) && !isNull(key)) getString(key) else null