@everframe/react-native 0.9.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.
Files changed (38) hide show
  1. package/EverframeRN.podspec +88 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +8 -0
  4. package/README.md +505 -0
  5. package/android/build.gradle.kts +209 -0
  6. package/android/settings.gradle.kts +8 -0
  7. package/android/src/main/AndroidManifest.xml +9 -0
  8. package/android/src/main/java/dev/everframe/rn/EverframeModule.kt +1670 -0
  9. package/android/src/main/java/dev/everframe/rn/EverframePackage.kt +40 -0
  10. package/android/src/main/java/dev/everframe/rn/EverframeSensitiveViewManager.kt +20 -0
  11. package/android/src/main/java/dev/everframe/rn/RnVideoPrivacyAdapter.kt +65 -0
  12. package/dist/NativeEverframe.d.ts +308 -0
  13. package/dist/NativeEverframe.js +1 -0
  14. package/dist/browser-ZQAY6B4M.js +1 -0
  15. package/dist/chunk-LPIKPZNX.js +1 -0
  16. package/dist/chunk-QRPINMCR.js +1 -0
  17. package/dist/chunk-QZRTTQRS.js +1 -0
  18. package/dist/chunk-XFLFA52R.js +1 -0
  19. package/dist/chunk-XUSV3KOD.js +1 -0
  20. package/dist/index.d.ts +1006 -0
  21. package/dist/index.js +51 -0
  22. package/dist/integrations/console.d.ts +8 -0
  23. package/dist/integrations/console.js +1 -0
  24. package/dist/integrations/react-native-video.d.ts +44 -0
  25. package/dist/integrations/react-native-video.js +1 -0
  26. package/dist/integrations/react-navigation.d.ts +21 -0
  27. package/dist/integrations/react-navigation.js +1 -0
  28. package/dist/integrations/theoplayer.d.ts +37 -0
  29. package/dist/integrations/theoplayer.js +1 -0
  30. package/dist/types-D5e4kWaw.d.ts +12 -0
  31. package/ios/Sources/EverframeBridge.swift +1043 -0
  32. package/ios/Sources/EverframeEventEmitter.swift +204 -0
  33. package/ios/Sources/EverframeModule.h +36 -0
  34. package/ios/Sources/EverframeModule.mm +489 -0
  35. package/ios/Sources/RemoteVitalsBridge.swift +99 -0
  36. package/package.json +121 -0
  37. package/src/NativeEverframe.ts +439 -0
  38. package/src/codegen-types.ts +11 -0
@@ -0,0 +1,1670 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ //
4
+ // EverframeModule — Android half of the @everframe/react-native TurboModule
5
+ // bridge. After the D-05/D-07 flip (2026-05-11) the surface collapsed to a thin
6
+ // forwarding layer; later D-decisions grew it to (keep in lock-step with
7
+ // `packages/sdk-react-native/src/NativeEverframe.ts`):
8
+ //
9
+ // configure(opts) → void
10
+ // configureSync(opts) → boolean (SYNC — startup boundary)
11
+ // openReporter() → Promise<{status, reportId?}>
12
+ // registerSensitiveRect(viewTag, rect) → void
13
+ // setExtra(value) → void
14
+ // setExtraResolverActive(active) → void (spec 2026-09-17)
15
+ // signalExtraResolverReady(id) → void (spec 2026-09-17)
16
+ // addBreadcrumb(message, kind?, level?, data?) → void (Plan 4 / Task 14)
17
+ // recordScreen(name, data?) → void (spec 2026-07-14)
18
+ // reportCrash(crashJson) → boolean (SYNC — spec 2026-07-18)
19
+ // setUser(user?) → void (spec 2026-08-12)
20
+ // configure(opts.attachPinUi?) → void (spec 2026-08-19)
21
+ // startCompanion()/stopCompanion() → void (Plan 06.2-11)
22
+ // signalCompanionReportRequestReady(id) → void (Plan 06.2-13)
23
+ // trackPlayer/detachPlayer/recordPlayerEvent/updatePlayerStats/
24
+ // trackVitals → void (spec 2026-09-06)
25
+ // configure(opts.vitals*) → void (spec 2026-09-06)
26
+ // addListener(name)/removeListeners(n) → void (RN event-emitter no-ops)
27
+ //
28
+ // The native TXReporterPresenter owns the entire report UX (capture, annotate,
29
+ // redact, submit). JS provides the host React context, host-owned triggers,
30
+ // and the sensitive-rect bridge. Native mobile shake stays below this bridge.
31
+ //
32
+ // THREADING:
33
+ // • configureSync runs on the JS thread (blocking synchronous); legacy
34
+ // void configure retains React Native's queued native dispatch.
35
+ // • openReporter dispatches presenter.openReporter onto the activity from
36
+ // MainScope (presenter manages its own UI thread hops internally).
37
+ // • registerSensitiveRect runs on the UI thread (UIManager view resolution).
38
+ //
39
+ // CODEGEN:
40
+ // `dev.everframe.rn.NativeEverframeSpec` is generated by the
41
+ // `com.facebook.react` Gradle plugin from `packages/sdk-react-native/src/
42
+ // NativeEverframe.ts`. The bridge build.gradle.kts now applies that plugin
43
+ // directly (see 33734a8), so the abstract spec class is on this library's
44
+ // compile classpath. We extend it so the module is recognised as a
45
+ // TurboModule at runtime (the spec implements
46
+ // `com.facebook.react.turbomodule.core.interfaces.TurboModule`). Without
47
+ // that interface, `TurboModuleRegistry.getEnforcing('Everframe')` rejects
48
+ // us at runtime even though the C++ JSI shim and Java module are wired.
49
+
50
+ package dev.everframe.rn
51
+
52
+ import dev.everframe.rn.NativeEverframeSpec
53
+ import com.facebook.react.bridge.Arguments
54
+ import com.facebook.react.bridge.JSApplicationIllegalArgumentException
55
+ import com.facebook.react.bridge.Promise
56
+ import com.facebook.react.bridge.ReactApplicationContext
57
+ import com.facebook.react.bridge.ReactMethod
58
+ import com.facebook.react.bridge.ReadableMap
59
+ import com.facebook.react.bridge.ReadableType
60
+ import com.facebook.react.bridge.UiThreadUtil
61
+ import com.facebook.react.bridge.WritableMap
62
+ import com.facebook.react.modules.core.DeviceEventManagerModule
63
+ import com.facebook.react.module.annotations.ReactModule
64
+ import com.facebook.react.uimanager.UIManagerHelper
65
+ import dev.everframe.Everframe
66
+ import dev.everframe.capture.ScreenshotCapture
67
+ import dev.everframe.capture.SensitiveRectRegistry
68
+ import dev.everframe.companion.AttachChallengeInfo
69
+ import dev.everframe.companion.AttachPinUi
70
+ import dev.everframe.companion.CompanionBadge
71
+ import dev.everframe.companion.CompanionBadgeOptions
72
+ import dev.everframe.companion.CompanionCaptureBridge
73
+ import dev.everframe.companion.CompanionDeviceFacts
74
+ import dev.everframe.companion.CompanionState
75
+ import dev.everframe.companion.CompanionSubmissionComposer
76
+ import dev.everframe.companion.PreviewCapture
77
+ import dev.everframe.companion.RelayWSClient
78
+ import dev.everframe.companion.parseCompanionBadgePosition
79
+ import dev.everframe.config.CaptureConfig
80
+ import dev.everframe.config.Environment
81
+ import dev.everframe.config.ReportResult
82
+ import dev.everframe.config.ReporterThemeOptions
83
+ import dev.everframe.config.TXUser
84
+ import dev.everframe.config.EverframeConfig
85
+ import dev.everframe.protocol.generated.ReportAssembled
86
+ import dev.everframe.protocol.generated.ReportAssembledCounts
87
+ import dev.everframe.protocol.generated.ReportAssembledToggles
88
+ import dev.everframe.protocol.generated.ReportSubmit
89
+ import kotlinx.coroutines.CoroutineScope
90
+ import kotlinx.coroutines.Dispatchers
91
+ import kotlinx.coroutines.Job
92
+ import kotlinx.coroutines.MainScope
93
+ import kotlinx.coroutines.SupervisorJob
94
+ import kotlinx.coroutines.cancel
95
+ import kotlinx.coroutines.flow.collect
96
+ import kotlinx.coroutines.launch
97
+ import kotlinx.coroutines.runBlocking
98
+ import kotlinx.coroutines.withContext
99
+ import okhttp3.OkHttpClient
100
+ import java.io.ByteArrayOutputStream
101
+ import java.util.concurrent.ConcurrentHashMap
102
+
103
+ @ReactModule(name = EverframeModule.NAME)
104
+ class EverframeModule(
105
+ private val reactContext: ReactApplicationContext,
106
+ ) : NativeEverframeSpec(reactContext) {
107
+
108
+ private val videoPrivacyAdapter = Everframe.__registerVideoPrivacyAdapter(RnVideoPrivacyAdapter)
109
+ private val sensitiveRegistration = RnSensitiveRegistration(
110
+ { tag -> UIManagerHelper.getUIManagerForReactTag(reactContext, tag)?.resolveView(tag) },
111
+ { block -> UiThreadUtil.runOnUiThread { block() } },
112
+ )
113
+
114
+ /**
115
+ * The exact closure installed onto [Everframe.__pendingExtraResolveHook] —
116
+ * kept as a field (not inlined at the install site) so `invalidate()`
117
+ * can identity-compare (`===`) against whatever is CURRENTLY installed
118
+ * there before clearing it (review finding F4). Without the identity
119
+ * check, a stale instance's `invalidate()` racing behind a newer
120
+ * instance's constructor would clobber the newer instance's live hook —
121
+ * exactly the hazard `videoPrivacyAdapter`'s own doc comment above
122
+ * describes for a process-wide registry, applied here to a single-slot
123
+ * one instead.
124
+ */
125
+ private val extraResolveHook: suspend () -> Unit = { awaitJsExtraResolve() }
126
+
127
+ init {
128
+ // Extra-resolver ask-and-wait (spec 2026-09-17 setExtra-resolver).
129
+ // Installed unconditionally, at construction — NOT gated behind
130
+ // startCompanion() the way the companion Combine/Flow subscriptions
131
+ // are, because this must also cover the plain in-app openReporter()
132
+ // path and native shake (both funnel through Everframe.report.open(),
133
+ // which calls Everframe.consumePendingAttachments() — see that
134
+ // property's doc comment on Everframe.kt). Reassigning on every
135
+ // module construction is intentional and harmless: the newest
136
+ // module instance's closure is always the one that should win,
137
+ // mirroring `videoPrivacyAdapter` above.
138
+ Everframe.__pendingExtraResolveHook = extraResolveHook
139
+ }
140
+
141
+ companion object {
142
+ const val NAME: String = "Everframe"
143
+
144
+ internal const val REJECT_CAPTURE_DISABLED: String = "capture_disabled"
145
+ internal const val REJECT_NO_ACTIVITY: String = "no_activity"
146
+ internal const val REJECT_OPEN_REPORTER_FAILED: String = "open_reporter_failed"
147
+
148
+ // Plan 06.2-11 — JS-side event names. Keep in lock-step with
149
+ // `packages/sdk-react-native/src/companion.ts` and the iOS
150
+ // `EverframeEventEmitter.swift` constants.
151
+ internal const val COMPANION_STATE_EVENT: String = "everframe.companion.state"
152
+ internal const val COMPANION_PAIR_URL_EVENT: String = "everframe.companion.pairUrl"
153
+
154
+ // Companion discovery (spec 2026-08-07). `code` is the short display
155
+ // code from `/api/companion/announce`, rendered beside the QR so a
156
+ // team member reading the TV screen can pick this device out of the
157
+ // dashboard list. `attachedUserName` is the dashboard member who
158
+ // attached, null on an ordinary QR bond. Both cross as String | null.
159
+ internal const val COMPANION_CODE_EVENT: String = "everframe.companion.code"
160
+ internal const val COMPANION_ATTACHED_USER_NAME_EVENT: String =
161
+ "everframe.companion.attachedUserName"
162
+ // Server-resolved device display name (naming spec 2026-08-24) — e.g.
163
+ // an org-configured friendly name for this device. Null before the
164
+ // first successful announce or when discovery never ran. Crosses as
165
+ // String | null.
166
+ internal const val COMPANION_RESOLVED_NAME_EVENT: String =
167
+ "everframe.companion.resolvedName"
168
+ // Pending dashboard-initiated attach-PIN challenge (spec
169
+ // 2026-08-19). `{ code, requestedByName, ttlMs }` or null — see
170
+ // `installCompanionCollectorsIfNeeded()`'s attachChallenge collector
171
+ // below for the full contract.
172
+ internal const val COMPANION_ATTACH_CHALLENGE_EVENT: String =
173
+ "everframe.companion.attachChallenge"
174
+ // Fired when a `report.request` arrives from the paired phone. This
175
+ // used to give JS a window to walk the React fiber tree and call
176
+ // `attachReactTree`; both the tree payload and that spec method are
177
+ // gone (spec 2026-08-29). The event and its
178
+ // `signalCompanionReportRequestReady` acknowledgement survive as the
179
+ // companion's JS-ready handshake — the bounded wait is part of the
180
+ // pairing flow, not of tap-to-identify.
181
+ internal const val COMPANION_REPORT_REQUESTED_EVENT: String = "everframe.companion.reportRequested"
182
+
183
+ // Spec 2026-09-17 setExtra-resolver — NOT a companion event (no
184
+ // "companion." prefix): fired for the plain in-app openReporter()
185
+ // path and native shake too, not just the phone-companion flow. See
186
+ // `awaitJsExtraResolve()` below and `packages/sdk-react-native/src/
187
+ // runtime.ts`'s matching listener.
188
+ internal const val EXTRA_RESOLVE_REQUESTED_EVENT: String = "everframe.extra.resolveRequested"
189
+
190
+ /**
191
+ * Stringify a `CompanionState` for the RN event payload. Snake_case
192
+ * mirrors the iOS `CompanionState.rawValue` shape ("unpaired",
193
+ * "paired", "report_in_progress", "phone_disconnected") so JS
194
+ * consumers can treat both platforms with a single union type.
195
+ */
196
+ internal fun CompanionState.toRnString(): String = when (this) {
197
+ CompanionState.Unpaired -> "unpaired"
198
+ CompanionState.Paired -> "paired"
199
+ CompanionState.ReportInProgress -> "report_in_progress"
200
+ CompanionState.PhoneDisconnected -> "phone_disconnected"
201
+ }
202
+ }
203
+
204
+ // getName() is inherited from NativeEverframeSpec (returns "Everframe").
205
+
206
+ // ---------------- configure ----------------
207
+
208
+ @ReactMethod
209
+ override fun configure(opts: ReadableMap) {
210
+ configureInternal(opts)
211
+ }
212
+
213
+ @ReactMethod(isBlockingSynchronousMethod = true)
214
+ override fun configureSync(opts: ReadableMap): Boolean = configureInternal(opts)
215
+
216
+ private fun configureInternal(opts: ReadableMap): Boolean {
217
+ var completed = false
218
+ txGuardSurface("configure") {
219
+ val sdkKey = opts.takeIfHasString("apiKey")
220
+ ?: throw JSApplicationIllegalArgumentException(
221
+ "configure: required field 'apiKey' missing",
222
+ )
223
+ val sdkVersion = opts.takeIfHasString("sdkVersion") ?: Everframe.SDK_VERSION
224
+
225
+ // Ingest URL is baked into the AAR at build time per Gradle
226
+ // variant (release = prod, debug = EVERFRAME_DEV_INGEST_URL).
227
+ // The JS-side `apiBase` field is no longer plumbed through.
228
+ // Polarity flips exactly once, here: JS sends the veto
229
+ // (`networkBodiesDisabled`), native stores the permission.
230
+ val networkBodiesDisabled = opts.takeIfHasBoolean("networkBodiesDisabled") ?: false
231
+ // Polarity flips exactly once, here: JS sends the veto
232
+ // (`installIdentifierDisabled`), native stores the permission.
233
+ val installIdentifierDisabled = opts.takeIfHasBoolean("installIdentifierDisabled") ?: false
234
+ // Attach-PIN UI mode (spec 2026-08-19) — a flat optional STRING
235
+ // field (codegen cannot express a string-literal union), parsed
236
+ // here and stashed for `startCompanion()` to read (the flag is a
237
+ // startup-time preference riding `configure()`, not a per-call
238
+ // argument; see NativeEverframe.ts's D-decision header note).
239
+ // Absent/unrecognised both coerce to BUILTIN, the safe default
240
+ // matching "host did nothing special". Parsed into a LOCAL: the
241
+ // idempotence gate below compares it against the stashed value
242
+ // before deciding, and only the start path adopts it.
243
+ val attachPinUi = parseAttachPinUi(opts.takeIfHasString("attachPinUi"))
244
+
245
+ val config = EverframeConfig(
246
+ appId = sdkKey,
247
+ sdkKey = sdkKey,
248
+ environment = Environment.production,
249
+ release = sdkVersion,
250
+ capture = CaptureConfig(networkBodies = !networkBodiesDisabled),
251
+ installIdentifierEnabled = !installIdentifierDisabled,
252
+ // Explicit companion device-identity override (naming spec
253
+ // 2026-08-24) — read straight off the flat ConfigOpts field,
254
+ // same shape as attachPinUi above. Absent/blank falls
255
+ // through to the SSAID-then-stored-UUID chain inside
256
+ // CompanionDeviceId.resolve.
257
+ companionDeviceId = opts.takeIfHasString("companionDeviceId"),
258
+ // Companion on-screen name badge (naming spec 2026-08-24,
259
+ // controller ruling / Task 6b) — flat fields on ConfigOpts
260
+ // (RN codegen cannot express the nested `{enabled,
261
+ // position}` shape the JS facade's
262
+ // `RuntimeConfig.companionBadge` uses; `extractBridgeConfig`
263
+ // flattens it before it crosses). Absent `enabled` defaults
264
+ // to `true` (badge shown); `position` is stashed raw here
265
+ // and parsed into `CompanionBadgePosition` only at
266
+ // `startCompanion()` time, mirroring `attachPinUi`.
267
+ companionBadgeEnabled = opts.takeIfHasBoolean("companionBadgeEnabled") ?: true,
268
+ shakeToReportEnabled = opts.takeIfHasBoolean("shakeToReportEnabled") ?: true,
269
+ companionBadgePosition = opts.takeIfHasString("companionBadgePosition"),
270
+ // Inline reporter theme (reporter branding spec 2026-08-25,
271
+ // RN slice) — 8 flat optional STRING fields on ConfigOpts
272
+ // (RN codegen cannot express the nested `theme: { … }` shape
273
+ // the JS facade's `RuntimeConfig.theme` uses;
274
+ // `extractBridgeConfig` flattens it before it crosses).
275
+ // Collapses to null when the host set no theme, preserving
276
+ // "host did nothing". Values cross unvalidated; the core
277
+ // ThemeResolver revalidates hex per-field and the server
278
+ // watermark entitlement gates rendering.
279
+ theme = ReporterThemeOptions(
280
+ background = opts.takeIfHasString("themeBackground"),
281
+ surface = opts.takeIfHasString("themeSurface"),
282
+ border = opts.takeIfHasString("themeBorder"),
283
+ text = opts.takeIfHasString("themeText"),
284
+ textMuted = opts.takeIfHasString("themeTextMuted"),
285
+ accent = opts.takeIfHasString("themeAccent"),
286
+ accentForeground = opts.takeIfHasString("themeAccentForeground"),
287
+ destructive = opts.takeIfHasString("themeDestructive"),
288
+ ).takeIf { it != ReporterThemeOptions() },
289
+ // Session Vitals (spec 2026-09-06): three flat ConfigOpts
290
+ // fields (RN codegen cannot express the nested `vitals`
291
+ // object; `extractBridgeConfig` flattens). Present fields
292
+ // only — absent keeps VitalsConfig's own defaults, which is
293
+ // what "no host opinion" has to mean: null `enabled` follows
294
+ // the dashboard toggle, null `sampleRate` follows the server
295
+ // rate.
296
+ vitals = dev.everframe.config.VitalsConfig(
297
+ enabled = opts.takeIfHasBoolean("vitalsEnabled"),
298
+ sampleRate = opts.takeIfHasDouble("vitalsSampleRate"),
299
+ captureSourceQuery = opts.takeIfHasBoolean("vitalsCaptureSourceQuery") ?: false,
300
+ ),
301
+ )
302
+ // Codex round-6, H1 — IDEMPOTENT configure, decided against the INSTALLED config.
303
+ //
304
+ // A `Everframe.start(...)` supersedes the running SDK: the outgoing controller's
305
+ // shutdown detaches every integration it had announced, so a Provider that merely
306
+ // REMOUNTS with the same config used to kill every live player registration for
307
+ // nothing. Nothing about the SDK's state differs after a start with an identical
308
+ // config, so the cheapest correct answer is not to start at all.
309
+ //
310
+ // Round-5 answered that question from a CACHED snapshot of the last options this
311
+ // bridge had started with. A cache can disagree with reality. Two overlapping
312
+ // configures (A then B) can interleave so the cache ends up saying A while the
313
+ // singleton runs B — and a native host that calls `Everframe.start(configB)` between
314
+ // two bridge configures of A reproduces it with no concurrency at all: the second
315
+ // A matches the stale cached A, skips, and leaves B installed under a JS host that
316
+ // believes it configured A.
317
+ //
318
+ // So compare against what is actually INSTALLED. `EverframeConfig` is a data class,
319
+ // so `==` is structural over every field a start would write, which makes the
320
+ // comparison exhaustive by construction — a new config field can never be forgotten
321
+ // here the way a hand-maintained key list could be. The two extra terms:
322
+ // • `captureGate` — a killed SDK must be restarted even by an identical config,
323
+ // or the host is left with a dead SDK and no way to revive it.
324
+ // • `attachPinUi` — the one bridge option that lands in module state instead of
325
+ // in `EverframeConfig`, so config equality alone cannot see it change.
326
+ if (Everframe.captureGate &&
327
+ Everframe.currentConfig == config &&
328
+ companionAttachPinUi == attachPinUi
329
+ ) {
330
+ android.util.Log.d(
331
+ "Everframe.rn",
332
+ "configure: SDK already running this exact config — skipping start()",
333
+ )
334
+ installPresenterResolverIfNeeded()
335
+ completed = true
336
+ return@txGuardSurface
337
+ }
338
+
339
+ // Stash the attach-PIN UI mode for startCompanion() to read (spec 2026-08-19). It
340
+ // is written only on the start path: the skip path above has already established
341
+ // that the stashed value equals the parsed one.
342
+ companionAttachPinUi = attachPinUi
343
+
344
+ // Pass the live Activity: configure() runs from JS long after
345
+ // MainActivity's onCreate, so without it the tap adapter's
346
+ // Window.Callback wrapper is never installed on the current screen
347
+ // and the session ships zero tap crumbs (field bug 2026-07-10).
348
+ Everframe.start(reactContext.applicationContext, config, reactContext.currentActivity)
349
+
350
+ // Codex round-5, G1 — the registry is NOT renewed here any more. The live players
351
+ // this start just orphaned are re-registered from JS instead: `mount()` bumps the
352
+ // configure epoch right after this call returns, every vitals hook has that epoch in
353
+ // its effect deps, and each effect's cleanup+re-run detaches the dead token and
354
+ // tracks a fresh one against the SDK that is now current. `renewAll()` could race
355
+ // the runtime's deferred-registration drain and detach the very replacement it had
356
+ // installed, and it only ever covered THIS module instance's registry.
357
+
358
+ // Wire TXReporterPresenter as the resolver for
359
+ // 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()
364
+ completed = true
365
+ }
366
+ return completed
367
+ }
368
+
369
+ // ---------------- openReporter ----------------
370
+
371
+ /**
372
+ * Opens the native TXReporterPresenter modal. Resolves with the user's
373
+ * outcome. Result shape matches iOS — JS facade casts to ReporterResult.
374
+ *
375
+ * { status: "submitted", reportId: "<uuid>" }
376
+ * { status: "queued", reportId: "<uuid>" }
377
+ * { status: "cancelled" }
378
+ */
379
+ @ReactMethod
380
+ override fun openReporter(promise: Promise) {
381
+ if (!Everframe.captureGate) {
382
+ promise.reject(REJECT_CAPTURE_DISABLED, "Everframe not started (captureGate=false). Call configure() first.")
383
+ return
384
+ }
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
+ MainScope().launch {
391
+ try {
392
+ val result: ReportResult = withContext(Dispatchers.Main) {
393
+ Everframe.report.open()
394
+ }
395
+ val dict = Arguments.createMap().apply {
396
+ when (result) {
397
+ is ReportResult.Submitted -> {
398
+ putString("status", "submitted")
399
+ putString("reportId", result.reportId.toString())
400
+ }
401
+ is ReportResult.Queued -> {
402
+ putString("status", "queued")
403
+ putString("reportId", result.reportId.toString())
404
+ }
405
+ is ReportResult.Cancelled -> {
406
+ putString("status", "cancelled")
407
+ putString("reason", result.reason)
408
+ }
409
+ }
410
+ }
411
+ promise.resolve(dict)
412
+ } catch (t: Throwable) {
413
+ promise.reject(REJECT_OPEN_REPORTER_FAILED, t.message, t)
414
+ }
415
+ }
416
+ }
417
+
418
+ // ---------------- setExtra ----------------
419
+
420
+ /**
421
+ * Forward an opaque string from JS into Everframe.setExtra. Empty string
422
+ * clears the pending extra (matches the JS spec contract).
423
+ */
424
+ @ReactMethod
425
+ override fun setExtra(value: String) {
426
+ txGuardVoid("setExtra") {
427
+ if (value.isEmpty()) {
428
+ Everframe.clearExtra()
429
+ } else {
430
+ Everframe.setExtra(value)
431
+ }
432
+ }
433
+ }
434
+
435
+ // ---------------- setExtraResolverActive (spec 2026-09-17 setExtra-resolver) ----------------
436
+
437
+ /**
438
+ * JS presence flag — see this method's own doc comment on
439
+ * `NativeEverframe.ts`'s `Spec.setExtraResolverActive`. `@Volatile`
440
+ * because it is written from the JS thread (this `@ReactMethod`) and
441
+ * read from `awaitJsExtraResolve`, which runs wherever
442
+ * `Everframe.consumePendingAttachments()`'s caller happens to run
443
+ * (Main for `openReporter()`/shake, the OkHttp WS thread for the
444
+ * companion path).
445
+ */
446
+ @Volatile
447
+ private var extraResolverActive: Boolean = false
448
+
449
+ @ReactMethod
450
+ override fun setExtraResolverActive(active: Boolean) {
451
+ extraResolverActive = active
452
+ }
453
+
454
+ // ---------------- addBreadcrumb (Plan 4 / Task 14) ----------------
455
+
456
+ /**
457
+ * Manual breadcrumb escape hatch. Forwards straight to
458
+ * `Everframe.addBreadcrumb` (Task 9) — NO validation/coercion here. That
459
+ * singleton already owns kind/level/data coercion AND gating (no-op
460
+ * pre-start / while killed via its own `txGuardVoid` + `captureGate`
461
+ * check), exactly like `setExtra`/`attachReactTree` above never re-check
462
+ * `captureGate` themselves. `data` crosses as a `ReadableMap?` — the
463
+ * codegen-typed freeform-object boundary — converted with RN's own
464
+ * `toHashMap()` (no re-shaping) before handing off.
465
+ */
466
+ @ReactMethod
467
+ override fun addBreadcrumb(message: String, kind: String?, level: String?, data: ReadableMap?) {
468
+ txGuardVoid("addBreadcrumb") {
469
+ Everframe.addBreadcrumb(
470
+ message = message,
471
+ kind = kind,
472
+ level = level,
473
+ data = data?.toHashMap(),
474
+ )
475
+ }
476
+ }
477
+
478
+ // ---------------- recordScreen (screen markers — spec 2026-07-14) ----------------
479
+
480
+ /**
481
+ * Navigation screen marker. Forwards straight to `Everframe.recordScreen`
482
+ * — NO validation/coercion here; the core singleton owns from→to
483
+ * derivation (global chain shared with the Activity-level auto-capture),
484
+ * gating, and data coercion. Same fail-soft contract as `addBreadcrumb`.
485
+ */
486
+ @ReactMethod
487
+ override fun recordScreen(name: String, data: ReadableMap?) {
488
+ txGuardVoid("recordScreen") {
489
+ Everframe.recordScreen(name = name, data = data?.toHashMap())
490
+ }
491
+ }
492
+
493
+ // ---------------- Session Vitals (spec 2026-09-06) ----------------
494
+
495
+ /**
496
+ * Token → registration map. Lives in everframe-core (tested there); this
497
+ * module is glue — NO validation here (unknown tokens, out-of-range
498
+ * timestamps and the 32-token cap are all the registry's business, and
499
+ * every one of its methods is a silent no-op rather than a throw).
500
+ *
501
+ * `captureSourceQuery` is a PROVIDER lambda resolved at attach time
502
+ * (I13), never a value read once at configure() — a host that reconfigures
503
+ * mid-session must get the new answer for players attached afterwards.
504
+ *
505
+ * LIFETIME: cleared in `invalidate()`, i.e. when this module instance is
506
+ * torn down — which is exactly what a Metro/OTA reload does. That matters
507
+ * because JS mints tokens from a per-bundle counter that restarts at
508
+ * `rp1`: without the reset, the previous bundle's registrations would keep
509
+ * accumulating play/buffer time natively AND shadow the re-minted tokens
510
+ * (`track` refuses a token that is still live). A bare `Everframe.kill()`
511
+ * does NOT clear it — the module instance survives that, and the JS hooks
512
+ * still own detach for every player they registered.
513
+ */
514
+ private val remotePlayers = dev.everframe.vitals.RemotePlayerRegistry(
515
+ register = { integration, name -> Everframe.trackPlayer(integration, name) },
516
+ sessionTrack = { name, data -> Everframe.trackVitals(name, data, null) },
517
+ captureSourceQuery = { Everframe.currentConfig?.vitals?.captureSourceQuery ?: false },
518
+ )
519
+
520
+ @ReactMethod
521
+ override fun trackPlayer(token: String, library: String, name: String?, libraryVersion: String?) {
522
+ txGuardVoid("trackPlayer") { remotePlayers.track(token, library, name, libraryVersion) }
523
+ }
524
+
525
+ @ReactMethod
526
+ override fun detachPlayer(token: String) {
527
+ txGuardVoid("detachPlayer") { remotePlayers.detach(token) }
528
+ }
529
+
530
+ @ReactMethod
531
+ override fun recordPlayerEvent(token: String, type: String, t: Double, data: ReadableMap?) {
532
+ txGuardVoid("recordPlayerEvent") { remotePlayers.record(token, type, t, data?.toHashMap()) }
533
+ }
534
+
535
+ @ReactMethod
536
+ override fun updatePlayerStats(token: String, stats: ReadableMap) {
537
+ txGuardVoid("updatePlayerStats") { remotePlayers.updateStats(token, stats.toHashMap()) }
538
+ }
539
+
540
+ @ReactMethod
541
+ override fun trackVitals(name: String, dataJson: String?, token: String?) {
542
+ txGuardVoid("trackVitals") { remotePlayers.trackVitals(name, dataJson, token) }
543
+ }
544
+
545
+ /**
546
+ * Module-instance teardown (RN tears the instance down on a Metro/OTA
547
+ * reload, and on host shutdown). Detach every player this instance
548
+ * registered so their play/buffer spans close natively instead of being
549
+ * accumulated forever against a bundle that no longer exists — and so the
550
+ * reloaded bundle's re-minted `rp1` is not shadowed by the dead one.
551
+ *
552
+ * NOT done in `configure()`: a screen's `useTrackPlayer` effect runs
553
+ * BEFORE the Provider's configure-on-mount, so clearing there would drop
554
+ * legitimate registrations that had already landed.
555
+ */
556
+ override fun invalidate() {
557
+ sensitiveRegistration.close()
558
+ videoPrivacyAdapter.close()
559
+ super.invalidate()
560
+ txGuardVoid("invalidate") { remotePlayers.detachAll() }
561
+ // Review finding F4 (spec 2026-09-17 setExtra-resolver). Unlike
562
+ // iOS's process-static flag, `extraResolverActive` here is already
563
+ // per-instance and dies with this module — but the HOOK it backs is
564
+ // stored on the `Everframe` singleton, so without this it would go on
565
+ // referencing an invalidated module instance forever after a
566
+ // JS-context teardown that skips `runtime.ts`'s `unmount()` (a hard
567
+ // reload, or a native host tearing the `ReactInstanceManager` down
568
+ // directly). The identity check (`extraResolveHook` field doc
569
+ // comment above) guards against clobbering a NEWER instance's hook
570
+ // if this invalidate() races behind its constructor.
571
+ if (Everframe.__pendingExtraResolveHook === extraResolveHook) {
572
+ Everframe.__pendingExtraResolveHook = null
573
+ }
574
+ extraResolverActive = false
575
+ }
576
+
577
+ // ---------------- setUser (self-declared identity — spec 2026-08-12) ----------------
578
+
579
+ /**
580
+ * Forward a host-declared user from JS into `Everframe.setUser`. The
581
+ * singleton owns storage AND gating (no-op pre-start / while killed via its
582
+ * own `txGuardVoid` + `captureGate` check), exactly like
583
+ * `setExtra`/`addBreadcrumb` above never re-check `captureGate` themselves.
584
+ * A missing key becomes `null`, never an empty string — that's what keeps
585
+ * the Task 9 email-only `TXUser` shape (nullable `id`) usable.
586
+ *
587
+ * External review, finding 2 (Serious) — reads are TYPE-checked
588
+ * ([stringOrNull]), not merely key-presence-checked. `getString(key)` THROWS
589
+ * on a non-string value, and `txGuardVoid` swallows that exception BEFORE
590
+ * `Everframe.setUser()` is reached: `setUser({ id: 12345 })` was therefore a
591
+ * silent no-op that left the PREVIOUS account installed, so every subsequent
592
+ * report — crashes included — stayed attributed to someone the host had
593
+ * explicitly replaced. That is the one outcome worse than losing the user
594
+ * entirely.
595
+ *
596
+ * The primary fix is the JS projection in `src/user-projection.ts`, applied
597
+ * before the value ever crosses the bridge. This is the second layer, for a
598
+ * hand-wired native caller (or a host on an older JS bundle) that reaches
599
+ * this method directly: bad fields degrade to `null` — a partial user, or
600
+ * anonymous — and `Everframe.setUser` is ALWAYS reached, so the call always
601
+ * replaces what was installed before.
602
+ */
603
+ @ReactMethod
604
+ override fun setUser(user: ReadableMap?) {
605
+ txGuardVoid("setUser") {
606
+ if (user == null) {
607
+ Everframe.setUser(null)
608
+ return@txGuardVoid
609
+ }
610
+ Everframe.setUser(
611
+ TXUser(
612
+ id = user.stringOrNull("id"),
613
+ email = user.stringOrNull("email"),
614
+ displayName = user.stringOrNull("displayName"),
615
+ )
616
+ )
617
+ }
618
+ }
619
+
620
+ /**
621
+ * `getString(key)` only when the value really IS a string; `null` for a
622
+ * missing key, an explicit JS `null`, or any other type (number, boolean,
623
+ * object, array). See [setUser] — `ReadableMap.getString` throws on a type
624
+ * mismatch, and a throw here costs the whole call, not just the field.
625
+ *
626
+ * [hasKey] is still checked FIRST and is not redundant:
627
+ * `ReadableNativeMap.getType` throws `NoSuchKeyException` for a key that
628
+ * isn't present, so type-checking alone would turn the ordinary partial
629
+ * user (`{ email }` with no `id` — the Task 9 shape this API exists to
630
+ * support) into the very swallowed-throw this fix removes.
631
+ *
632
+ * Wrapped in `runCatching` because the contract this helper exists to
633
+ * enforce is "never throws": these are the two throwing cases known today,
634
+ * and a third one from a future RN `ReadableMap` implementation must cost
635
+ * its own field, not the whole `setUser` call.
636
+ */
637
+ private fun ReadableMap.stringOrNull(key: String): String? =
638
+ runCatching {
639
+ if (hasKey(key) && getType(key) == ReadableType.String) getString(key) else null
640
+ }.getOrNull()
641
+
642
+ // ---------------- synchronous crash persistence ----------------
643
+
644
+ /** Automatic ErrorUtils capture; true acknowledges completed sidecar storage. */
645
+ @ReactMethod(isBlockingSynchronousMethod = true)
646
+ override fun reportCrash(crashJson: String): Boolean = captureCrashJson(crashJson, handled = false)
647
+
648
+ /** Explicit capture always enforces handled=true, fatal=false, captureException. */
649
+ @ReactMethod(isBlockingSynchronousMethod = true)
650
+ override fun captureHandledException(crashJson: String): Boolean = captureCrashJson(crashJson, handled = true)
651
+
652
+ private fun captureCrashJson(crashJson: String, handled: Boolean): Boolean {
653
+ return try {
654
+ val obj = org.json.JSONObject(crashJson)
655
+ val frames = obj.optJSONArray("framesRaw")?.let { arr ->
656
+ List(minOf(arr.length(), 256)) { i ->
657
+ val raw = arr.get(i)
658
+ require(raw is String) { "framesRaw entries must be strings" }
659
+ raw.take(1024)
660
+ }
661
+ } ?: emptyList()
662
+ val exceptionType = obj.optString("exceptionType", "Error").take(256)
663
+ val message = obj.optString("message", "").take(4096)
664
+ val occurredAt = obj.optString("occurredAt", java.time.Instant.now().toString())
665
+ // Optional enrichment must never prevent capture of the raw facts.
666
+ val jsBundle = runCatching {
667
+ obj.optJSONObject("jsBundle")?.let { metadata ->
668
+ kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
669
+ .decodeFromString<dev.everframe.protocol.generated.JSBundle>(metadata.toString())
670
+ }
671
+ }.getOrNull()
672
+ val details = if (obj.has("details")) obj.get("details") else null
673
+ val fatal = !handled && obj.optBoolean("fatal", false)
674
+ val accepted = if (handled) {
675
+ dev.everframe.crash.CrashReporter.captureHandledFactsWithDetails(
676
+ exceptionType, message, frames, occurredAt, jsBundle, details,
677
+ )
678
+ } else {
679
+ dev.everframe.crash.CrashReporter.captureFactsAcceptedWithDetails(
680
+ exceptionType, message, frames, obj.optString("mechanism", "errorutils"),
681
+ fatal, occurredAt, jsBundle, details,
682
+ )
683
+ }
684
+ // Scheduling is best-effort after acceptance; a scheduler failure
685
+ // cannot undo completed storage or make JS account it as refused.
686
+ if (accepted && !fatal) runCatching { __drainRequester() }
687
+ accepted
688
+ } catch (_: Throwable) {
689
+ false
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Test seam — the fatal-branch drain decision, observable without a live
695
+ * coroutine/outbox (`Everframe.requestOutboxDrain` launches on the SDK's
696
+ * IO scope and is a silent no-op pre-start, so there is nothing a JVM
697
+ * unit test could otherwise assert on). Production default forwards to
698
+ * the core singleton; `RnReplayBridgeTest` swaps it to a recording
699
+ * lambda and restores it. Same `internal`-seam idiom as
700
+ * `hasCompanionClientForTesting()` above.
701
+ */
702
+ internal var __drainRequester: () -> Unit = { Everframe.requestOutboxDrain() }
703
+
704
+ // ---------------- registerSensitiveRect ----------------
705
+
706
+ @ReactMethod
707
+ override fun registerSensitiveRect(viewTag: Double, rect: ReadableMap) {
708
+ sensitiveRegistration.register(viewTag)
709
+ }
710
+
711
+ // ---------------- Companion (Plan 06.2-11) ----------------
712
+ //
713
+ // The bridge owns at most ONE live `RelayWSClient` per process.
714
+ // `startCompanion()` is idempotent: a second call while a client is
715
+ // already live is a no-op — the socket is NOT recreated. Call
716
+ // `stopCompanion()` first to tear the connection down before re-starting.
717
+ //
718
+ // External review, finding NN1 — "at most one per process" is now
719
+ // enforced ACROSS this bridge AND `Everframe.startCompanion()` (a plain-
720
+ // native host's own companion entry point), not just within this
721
+ // module: both route through `Everframe.__registerCompanionClient` /
722
+ // `__unregisterCompanionClient`, a single `@Synchronized` slot on the
723
+ // core facade. First caller wins; this bridge's `startCompanion()`
724
+ // discards its own just-built client (never starting it or installing
725
+ // any provider seam for it) when a native host already holds the slot,
726
+ // and its `stopCompanion()` only ever stops/unregisters the client THIS
727
+ // module itself registered.
728
+ //
729
+ // StateFlow collection lives on a module-owned `CoroutineScope` (Main +
730
+ // SupervisorJob, mirroring the precedent in CompanionCaptureBridge.kt's
731
+ // service scope shape). Collectors stay alive across stop/start cycles
732
+ // because the underlying `Everframe.companion` StateFlows survive the
733
+ // WS lifetime — re-installing on every start would race the initial
734
+ // `__setState(Unpaired)` emission on cold start and lose it.
735
+ //
736
+ // The events forwarded to JS mirror iOS exactly:
737
+ // • `everframe.companion.state` — snake_case CompanionState
738
+ // • `everframe.companion.pairUrl` — String | null
739
+ // • `everframe.companion.code` — String | null
740
+ // • `everframe.companion.attachedUserName` — String | null
741
+ // • `everframe.companion.attachChallenge` — WritableMap{code,requestedByName,ttlMs} | null (spec 2026-08-19)
742
+ //
743
+ // The collector Jobs below are never cancelled: `companionScope` and the
744
+ // module live for the process, and the collectors deliberately outlive
745
+ // stop/start cycles (see above). The fields exist so the jobs are
746
+ // inspectable and so a future teardown path has something to cancel.
747
+ //
748
+ // Emission is via `RCTDeviceEventEmitter` (the default emitter); JS
749
+ // facade uses `new NativeEventEmitter()` on Android (no module ref
750
+ // needed — the default emitter is platform-supplied).
751
+
752
+ private val companionScope: CoroutineScope =
753
+ CoroutineScope(Dispatchers.Main + SupervisorJob())
754
+
755
+ @Volatile
756
+ private var companionClient: RelayWSClient? = null
757
+
758
+ @Volatile
759
+ private var companionCollectorsInstalled: Boolean = false
760
+
761
+ @Volatile
762
+ private var companionStateJob: Job? = null
763
+
764
+ @Volatile
765
+ private var companionPairUrlJob: Job? = null
766
+
767
+ @Volatile
768
+ private var companionCodeJob: Job? = null
769
+
770
+ @Volatile
771
+ private var companionAttachedUserNameJob: Job? = null
772
+
773
+ @Volatile
774
+ private var companionResolvedNameJob: Job? = null
775
+
776
+ @Volatile
777
+ private var companionAttachChallengeJob: Job? = null
778
+
779
+ /**
780
+ * The host's configured attach-PIN UI mode (spec 2026-08-19), parsed
781
+ * from `ConfigOpts.attachPinUi` at `configure()` time and read by
782
+ * `startCompanion()` — both to pass to `RelayWSClient` (the announce
783
+ * capability) and to set `Everframe.__attachPinUiSuppressed` (the
784
+ * built-in presenter's runtime silence flag). Defaults to `BUILTIN` —
785
+ * pre-existing behaviour for a host that never set the flag.
786
+ */
787
+ @Volatile
788
+ private var companionAttachPinUi: AttachPinUi = AttachPinUi.BUILTIN
789
+
790
+ /** Coerce the flat `ConfigOpts.attachPinUi` string into [AttachPinUi].
791
+ * `null` (field absent) and any unrecognised value both degrade to
792
+ * `BUILTIN`. */
793
+ private fun parseAttachPinUi(raw: String?): AttachPinUi = when (raw) {
794
+ "custom" -> AttachPinUi.CUSTOM
795
+ "off" -> AttachPinUi.OFF
796
+ else -> AttachPinUi.BUILTIN
797
+ }
798
+
799
+ // `parseCompanionBadgePosition` moved to `dev.everframe.companion`
800
+ // (CompanionBadge.kt) — external review, finding N3 — so the core
801
+ // facade's own native-host companion start path (`Everframe.startCompanion`)
802
+ // can share it instead of drifting a second copy. Imported above.
803
+
804
+ /** WritableMap has no `long` accessor — `ttlMs` crosses as a double, per
805
+ * the spec's documented `everframe.companion.attachChallenge` payload
806
+ * contract. Extracted so the collector body above stays as short as
807
+ * its siblings. */
808
+ private fun AttachChallengeInfo.toWritableMap(): WritableMap =
809
+ Arguments.createMap().apply {
810
+ putString("code", code)
811
+ putString("requestedByName", requestedByName)
812
+ putDouble("ttlMs", ttlMs.toDouble())
813
+ }
814
+
815
+ /** Test seam — JVM unit test (`EverframeCompanionModuleTest.kt`) asserts
816
+ * the bridge installs a client on startCompanion() and clears it on
817
+ * stopCompanion(). Intentionally `internal` so it doesn't leak past
818
+ * the module boundary. */
819
+ internal fun hasCompanionClientForTesting(): Boolean = companionClient != null
820
+
821
+ /** Test seam — the live client instance (or null), so tests can assert by
822
+ * identity that a repeat `startCompanion()` did NOT recreate the socket. */
823
+ internal fun companionClientForTesting(): RelayWSClient? = companionClient
824
+
825
+ /**
826
+ * The SDK key the host already configured through `configure()`, or null
827
+ * when there isn't a usable one. Read straight off `Everframe`'s own config
828
+ * snapshot — deliberately NOT a second place a key can be configured, and
829
+ * NOT a new public surface (`currentConfig` is already public read-only).
830
+ *
831
+ * Null when `Everframe.start` never ran, and when the host configured a
832
+ * blank key. Both are "no dashboard presence", never "no socket".
833
+ */
834
+ private fun configuredSdkKey(): String? =
835
+ Everframe.currentConfig?.sdkKey?.takeIf { it.isNotBlank() }
836
+
837
+ /**
838
+ * Non-personal device label for the dashboard's device list — the platform
839
+ * model ("Chromecast", "AFTKA", "Pixel 8"). Deliberately
840
+ * `android.os.Build.MODEL` and not any user-assigned device name, which is
841
+ * commonly a person's name and would leak into a shared dashboard list as
842
+ * a side effect of turning companion on. The display code is the real
843
+ * disambiguator; the label is only a convenience.
844
+ *
845
+ * `Build.MODEL` is a platform type — it reads back null under plain JVM
846
+ * unit tests (unmocked android.jar), so the null-safe call is load-bearing
847
+ * and not defensive noise.
848
+ */
849
+ private fun companionDeviceLabel(): String? =
850
+ android.os.Build.MODEL?.takeIf { it.isNotBlank() }
851
+
852
+ @ReactMethod
853
+ override fun startCompanion() {
854
+ txGuardVoid("startCompanion") {
855
+ // Idempotent: a live client means a prior startCompanion already
856
+ // opened the socket. Do NOT recreate it — callers must
857
+ // stopCompanion() first. This keeps repeat starts from spinning up
858
+ // a second WS (which would race on `pair.created` / `__setPairUrl`).
859
+ if (companionClient != null) {
860
+ android.util.Log.i(
861
+ "Everframe.rn",
862
+ "startCompanion ignored — already running; call stopCompanion() first",
863
+ )
864
+ return@txGuardVoid
865
+ }
866
+
867
+ installCompanionCollectorsIfNeeded()
868
+
869
+ // Runtime suppression of the built-in PIN presenter (spec
870
+ // 2026-08-19, controller ruling): a runtime flag, not an
871
+ // install-site gate. `:everframe-reporter-ui`'s
872
+ // `CompanionPinPresenter` is auto-installed via
873
+ // `ReporterResolverInstaller` (androidx.startup) regardless of
874
+ // the configured mode, so `Everframe.__attachPinUiInstalled`
875
+ // stays an honest capability signal — only the PRESENTING is
876
+ // silenced, here, at the one site where the mode is already
877
+ // known (configure() precedes startCompanion() in every host's
878
+ // lifecycle).
879
+ Everframe.__attachPinUiSuppressed = companionAttachPinUi != AttachPinUi.BUILTIN
880
+
881
+ // RelayWSClient defaults baseUrl to IngestEndpoint.url —
882
+ // release AAR = https://everframe.dev, debug AAR =
883
+ // EVERFRAME_DEV_INGEST_URL env var or 10.0.2.2:8787 fallback.
884
+ //
885
+ // Companion discovery (spec 2026-08-07): announcing is what makes
886
+ // this device appear in the dashboard's Companion tab. It rides
887
+ // the SDK key the host ALREADY configured via `configure()` — we
888
+ // never ask for a second one. A null key takes the ticketless
889
+ // path: RelayWSClient builds no announcer and connects to plain
890
+ // `/relay/tv`, byte-identical to pre-companion behaviour.
891
+ // Announce must never be able to break reporting — no key means
892
+ // "no dashboard presence", never "no socket".
893
+ val client = RelayWSClient(
894
+ client = OkHttpClient(),
895
+ sdkKey = configuredSdkKey(),
896
+ deviceLabel = companionDeviceLabel(),
897
+ // Naming spec 2026-08-24 — resolved fresh on every announce
898
+ // attempt (see `RelayWSClient.deviceProvider`'s own doc for
899
+ // why no caching is needed here). `companionDeviceId` is read
900
+ // off the same `Everframe.currentConfig` snapshot
901
+ // `configuredSdkKey()` above already reads, not a second
902
+ // configuration surface.
903
+ deviceProvider = {
904
+ CompanionDeviceFacts.current(
905
+ reactContext.applicationContext,
906
+ Everframe.currentConfig?.companionDeviceId,
907
+ )
908
+ },
909
+ attachPinUi = companionAttachPinUi,
910
+ // Companion on-screen name badge (naming spec 2026-08-24,
911
+ // controller ruling / Task 6b) — same `Everframe.currentConfig`
912
+ // snapshot `deviceProvider` above already reads, read fresh
913
+ // at start time (a toggle/position can't affect anything
914
+ // before a relay client and its badge exist). `?: true`
915
+ // covers the same "never configured" edge `configuredSdkKey()`'s
916
+ // own null-safe chain already tolerates.
917
+ companionBadge = CompanionBadgeOptions(
918
+ enabled = Everframe.currentConfig?.companionBadgeEnabled ?: true,
919
+ position = parseCompanionBadgePosition(Everframe.currentConfig?.companionBadgePosition),
920
+ ),
921
+ )
922
+
923
+ // External review, finding NN1 — register through the core
924
+ // facade's SINGLE ownership slot instead of just assigning
925
+ // `companionClient` directly. First caller to register wins:
926
+ // a native host that already called `Everframe.startCompanion()`
927
+ // in this same process (or a concurrent RN start that beat us to
928
+ // it) keeps its client live, and OUR freshly-built one never
929
+ // goes anywhere near a provider seam or `client.start()` — a
930
+ // stale/duplicate RN provider must never be able to clobber
931
+ // whatever the winning caller installed. See
932
+ // `Everframe.__registerCompanionClient`'s own doc comment.
933
+ if (!Everframe.__registerCompanionClient(client)) {
934
+ // Log BEFORE the teardown attempt, not after: this warning is
935
+ // the one observable signal a host gets that its RN start was
936
+ // silently discarded, and it must fire even if `client.stop()`
937
+ // itself throws (DEFE-02 — a never-started client's teardown
938
+ // is best-effort cleanup, not load-bearing for correctness,
939
+ // since it was never registered/started in the first place).
940
+ android.util.Log.w(
941
+ "Everframe.rn",
942
+ "companion already started by the native facade — RN start ignored",
943
+ )
944
+ runCatching { client.stop() }
945
+ return@txGuardVoid
946
+ }
947
+
948
+ companionClient = client
949
+
950
+ // Plan 06.2-13 Task 3 — drop any prior bridge providers so a
951
+ // stale closure from a previous startCompanion doesn't double-
952
+ // handle frames against a now-defunct activity. Safe HERE, only
953
+ // after winning the registration race above — until then these
954
+ // providers may belong to someone else entirely.
955
+ CompanionCaptureBridge.__captureProvider = null
956
+ CompanionCaptureBridge.__submitProvider = null
957
+ // Task 11b — same reasoning for the preview-loop and shot-stash
958
+ // seams: null them before installing fresh ones so a stray call
959
+ // between stop and start can never run against a stale Activity.
960
+ CompanionCaptureBridge.__previewProvider = null
961
+ CompanionCaptureBridge.__shotCaptureProvider = null
962
+ companionCaptureStash.values.forEach { it.reportCapture.cancel() }
963
+ companionCaptureStash.clear()
964
+ // Task 6 (naming spec 2026-08-24) — same reasoning for the name
965
+ // badge's activity seam: null before installing fresh, exactly
966
+ // like the removed sharing indicator's own __activityProvider
967
+ // used to be handled here.
968
+ CompanionBadge.__activityProvider = null
969
+
970
+ installCompanionBridgeProviders()
971
+
972
+ client.start()
973
+ android.util.Log.i("Everframe.rn", "startCompanion: connected")
974
+ }
975
+ }
976
+
977
+ @ReactMethod
978
+ override fun stopCompanion() {
979
+ txGuardVoid("stopCompanion") {
980
+ // External review, finding NN1 — only ever stop/unregister OUR
981
+ // OWN client. `companionClient` is non-null here ONLY when a
982
+ // prior `startCompanion()` on THIS module actually won the
983
+ // registration race above, so reaching this point already means
984
+ // we own the providers cleared below — a native host's own
985
+ // `Everframe.startCompanion()`-registered client (or one still
986
+ // live from a losing/no-op RN start) is untouched:
987
+ // `__unregisterCompanionClient`'s identity check would no-op
988
+ // against it anyway even if `companionClient` somehow pointed
989
+ // at a stale reference, but the early return means we never even
990
+ // ask.
991
+ val client = companionClient ?: return@txGuardVoid
992
+ client.stop()
993
+ Everframe.__unregisterCompanionClient(client)
994
+ companionClient = null
995
+ // Plan 06.2-13 Task 3 — uninstall bridge providers so the
996
+ // singleton can't keep firing against a stale activity.
997
+ CompanionCaptureBridge.__captureProvider = null
998
+ CompanionCaptureBridge.__submitProvider = null
999
+ // Task 11b — a preview/shot provider that outlives its session
1000
+ // keeps a stale Activity alive and can capture a screen the
1001
+ // session no longer owns; null both alongside the two above.
1002
+ CompanionCaptureBridge.__previewProvider = null
1003
+ CompanionCaptureBridge.__shotCaptureProvider = null
1004
+ companionCaptureStash.values.forEach { it.reportCapture.cancel() }
1005
+ companionCaptureStash.clear()
1006
+ // Task 6 — a badge activity provider that outlives its session
1007
+ // keeps a stale Activity alive for no reason; RelayWSClient.stop()
1008
+ // (called just above) already tears down the badge's own
1009
+ // subscription/view, this just drops the seam it would have used.
1010
+ CompanionBadge.__activityProvider = null
1011
+ // unclaimed per-report attribution snapshot goes with it. Any
1012
+ // submit already in flight took its token synchronously before
1013
+ // its coroutine was dispatched, so this cannot strip attribution
1014
+ // from a report that is mid-upload.
1015
+ CompanionCaptureBridge.__clearPendingAttribution()
1016
+ // Note: in-flight submit coroutines launched by the bridge's
1017
+ // own submitScope are NOT cancelled here — they're owned by
1018
+ // the bridge object and survive a stop. The next start will
1019
+ // install fresh providers; any straggling submit completes
1020
+ // (or fails) against the now-disconnected WebSocket and the
1021
+ // ws.send(report.completed/failed) becomes a silent drop.
1022
+ }
1023
+ }
1024
+
1025
+ // ---------------- Companion provider install (Plan 06.2-13) ----------------
1026
+
1027
+ /**
1028
+ * Plan 06.2-13 Task 3+4 — per-correlation_id stash for capture state
1029
+ * + drained host attachments. Held on the module (NOT on the bridge
1030
+ * singleton) so stopCompanion() can clear it without bridge-internal
1031
+ * state coupling. Mirrors the advisor's note: "ownership flipped (RN
1032
+ * module owns the bridge install lifecycle)".
1033
+ *
1034
+ * `ConcurrentHashMap` because the OkHttp WS thread (write at
1035
+ * report.request) can race with the bridge's submitScope (read at
1036
+ * report.submit).
1037
+ */
1038
+ private data class CompanionCapture(
1039
+ val capture: ScreenshotCapture.CaptureResult,
1040
+ val reportCapture: dev.everframe.capture.video.FrozenReportCapture,
1041
+ val hostExtra: String?,
1042
+ /**
1043
+ * Drained-and-discarded host React tree (spec 2026-08-29). Nothing
1044
+ * reads it any more, and RN can no longer attach one — the
1045
+ * TurboModule spec method is gone. Kept only because the native core
1046
+ * `Everframe.attachReactTree` remains callable by pure-native hosts,
1047
+ * so the consume-once drain must still clear the slot.
1048
+ */
1049
+ val hostReactTreeJson: String?,
1050
+ )
1051
+
1052
+ private val companionCaptureStash = ConcurrentHashMap<String, CompanionCapture>()
1053
+
1054
+ /**
1055
+ * Install both `__captureProvider` and `__submitProvider` on the
1056
+ * `CompanionCaptureBridge` singleton. Closes over `reactContext` for
1057
+ * activity acquisition (RN tracks the foreground activity via
1058
+ * `getCurrentActivity()` — same surface `openReporter` uses).
1059
+ */
1060
+ private fun installCompanionBridgeProviders() {
1061
+ // ----- __captureProvider -----
1062
+ CompanionCaptureBridge.__captureProvider = { correlationId, reportCapture ->
1063
+ val activity = reactContext.currentActivity
1064
+ if (activity == null) {
1065
+ android.util.Log.w(
1066
+ "Everframe.rn",
1067
+ "companion captureProvider: no foreground activity; emitting empty assembled",
1068
+ )
1069
+ emptyAssembledPayload(correlationId)
1070
+ } else {
1071
+ runCatching {
1072
+ // 0. Ask JS to walk the React fiber tree + attach it
1073
+ // BEFORE we capture native state, then drain it. JS
1074
+ // can no longer attach one (the TurboModule spec
1075
+ // method is gone, spec 2026-08-29); the wait survives
1076
+ // as the companion's JS-ready handshake. Bounded — we
1077
+ // don't block capture forever if JS is non-responsive
1078
+ // (DEFE-02).
1079
+ awaitJsReactTreeAttach(correlationId)
1080
+ // 1. Sensitive-rect walk before capture (mirrors
1081
+ // TXReporterPresenter.kt:51-54).
1082
+ val rects = SensitiveRectRegistry.collectInWindowCoords(activity)
1083
+ // 2. Capture screenshot — suspend, so block on the
1084
+ // OkHttp thread. PixelCopy callback is async; the
1085
+ // runBlocking is bounded by the PixelCopy timeout
1086
+ // inside ScreenshotCapture. Same pattern the
1087
+ // in-process modal uses (TXReporterPresenter
1088
+ // awaits the suspend directly).
1089
+ val capture = runBlocking {
1090
+ ScreenshotCapture.captureBeforeReporter(activity, rects)
1091
+ }
1092
+ if (capture == null) {
1093
+ emptyAssembledPayload(correlationId)
1094
+ } else {
1095
+ // 3. Drain host-attached payload — consume-once
1096
+ // semantics, matches the iOS Plan 06.2-13
1097
+ // Task 1 timing (request time, NOT submit
1098
+ // time). A second back-to-back report sees
1099
+ // nil here. `consumePendingAttachments` is
1100
+ // `suspend` (spec 2026-09-17 setExtra-resolver —
1101
+ // it asks JS for a fresh resolver value before
1102
+ // draining); `runBlocking` here is safe for the
1103
+ // same reason the screenshot capture above is —
1104
+ // this whole provider runs on the OkHttp WS
1105
+ // thread, never the UI thread.
1106
+ val (hostExtraRaw, hostReactTreeJson) =
1107
+ runBlocking { Everframe.consumePendingAttachments() }
1108
+ val hostExtra = hostExtraRaw?.takeIf { it.isNotEmpty() }
1109
+
1110
+ // Serialize the short stash publication with owner teardown.
1111
+ val published = CompanionCaptureBridge.__publishCapture(correlationId, reportCapture) {
1112
+ companionCaptureStash.values.forEach { it.reportCapture.cancel() }
1113
+ companionCaptureStash.clear()
1114
+ companionCaptureStash[correlationId] = CompanionCapture(
1115
+ capture = capture,
1116
+ reportCapture = reportCapture,
1117
+ hostExtra = hostExtra,
1118
+ hostReactTreeJson = hostReactTreeJson?.takeIf { it.isNotEmpty() },
1119
+ )
1120
+ }
1121
+ if (!published) {
1122
+ reportCapture.cancel()
1123
+ return@runCatching null
1124
+ }
1125
+
1126
+ // 5. Re-encode the screenshot for the relay hop.
1127
+ // Bitmap.CompressFormat.WEBP_LOSSY at q=85 cuts
1128
+ // typical 1080p screenshot bytes by ~70-80% vs
1129
+ // PNG (API 30+); WEBP (legacy, lossy when
1130
+ // quality<100) covers API 24-29; JPEG @ q=85 is
1131
+ // the universal fallback. The phone consumes
1132
+ // via the announced mime — no branching on the
1133
+ // receiver side.
1134
+ val (relayBytes, relayMime) = reencodeScreenshot(capture.bitmap, capture.pngBytes)
1135
+
1136
+ // 6. Build the AssembledPayload (text frame +
1137
+ // binary screenshot) for the bridge to ship.
1138
+ //
1139
+ // NO second binary frame and NO `tree` metadata:
1140
+ // tap-to-identify was removed (spec 2026-08-29),
1141
+ // so nothing walks, gzips or announces a UI tree.
1142
+ // `toggles.uiTree` / `counts.uiTreeNodes` stay on
1143
+ // the wire because the generated relay types
1144
+ // declare them non-optional, but they are now
1145
+ // always false / 0. Announcing a tree the phone
1146
+ // would then wait for is the failure mode this
1147
+ // guards against — the relay protocol documents
1148
+ // the absent case as supported.
1149
+ val toggles = ReportAssembledToggles(
1150
+ logs = true,
1151
+ metadata = true,
1152
+ network = true,
1153
+ screenshot = true,
1154
+ uiTree = false,
1155
+ )
1156
+ val counts = ReportAssembledCounts(
1157
+ breadcrumbs = dev.everframe.capture.sharedBreadcrumbBuffer.size.toLong(),
1158
+ logs = dev.everframe.capture.sharedLogBuffer.size().toLong(),
1159
+ network = dev.everframe.capture.sharedNetworkBuffer.size().toLong(),
1160
+ uiTreeNodes = 0L,
1161
+ )
1162
+ val assembled = ReportAssembled(
1163
+ correlationId = correlationId,
1164
+ counts = counts,
1165
+ mime = relayMime,
1166
+ size = relayBytes.size.toLong(),
1167
+ toggles = toggles,
1168
+ tree = null,
1169
+ )
1170
+ CompanionCaptureBridge.AssembledPayload(
1171
+ assembled = assembled,
1172
+ pngBytes = relayBytes,
1173
+ )
1174
+ }
1175
+ }.getOrElse { t ->
1176
+ android.util.Log.w(
1177
+ "Everframe.rn",
1178
+ "companion captureProvider threw: ${t.javaClass.simpleName}: ${t.message}",
1179
+ )
1180
+ emptyAssembledPayload(correlationId)
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ // ----- __submitProvider -----
1186
+ // Suspend lambda — the bridge's own submitScope handles dispatch.
1187
+ // `companionAttribution` is the token the relay minted for THIS
1188
+ // report's `report.request`, snapshotted by RelayWSClient at that
1189
+ // moment and handed back here by the bridge (PR-fix 1). It is passed
1190
+ // straight into Inputs and never re-derived from the live session:
1191
+ // this closure survives stopCompanion() and can still be composing
1192
+ // when a different dashboard user attaches.
1193
+ // SECURITY: never log it.
1194
+ CompanionCaptureBridge.__submitProvider = submit@{
1195
+ submit: ReportSubmit,
1196
+ bakedPng: ByteArray,
1197
+ extraShotPngs: List<ByteArray>,
1198
+ companionAttribution: String?,
1199
+ reportCapture: dev.everframe.capture.video.FrozenReportCapture,
1200
+ capturedSession: dev.everframe.TXCapturedSession
1201
+ ->
1202
+ val activity = reactContext.currentActivity
1203
+ if (activity == null) {
1204
+ reportCapture.cancel()
1205
+ return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("no_activity") }
1206
+ }
1207
+ val stash = companionCaptureStash[submit.correlationId]
1208
+ ?: run { reportCapture.cancel(); return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("no_capture") } }
1209
+
1210
+ if (stash.reportCapture !== reportCapture) {
1211
+ reportCapture.cancel()
1212
+ return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("capture_mismatch") }
1213
+ }
1214
+ if (!companionCaptureStash.remove(submit.correlationId, stash)) {
1215
+ reportCapture.cancel()
1216
+ return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("no_capture") }
1217
+ }
1218
+ // Everything below may be expensive; captured references already belong to this submit.
1219
+ suspend work@{
1220
+ // Decode baked PNG → Bitmap. If the phone sent corrupt bytes
1221
+ // we fall back to the original capture bitmap so the user's
1222
+ // annotations are lost but the report still ships (DEFE-02).
1223
+ val bakedBitmap: android.graphics.Bitmap = try {
1224
+ android.graphics.BitmapFactory.decodeByteArray(
1225
+ bakedPng, 0, bakedPng.size,
1226
+ ) ?: stash.capture.bitmap
1227
+ } catch (t: Throwable) {
1228
+ stash.capture.bitmap
1229
+ }
1230
+
1231
+ // Tap-to-identify was removed (spec 2026-08-29). A phone running
1232
+ // an older reporter SPA may still send `submit.reportTarget`; it
1233
+ // is ignored rather than rejected, and nothing is resolved from it.
1234
+
1235
+ val inputs = CompanionSubmissionComposer.Inputs(
1236
+ activity = activity,
1237
+ captureBitmap = bakedBitmap,
1238
+ capture = reportCapture,
1239
+ // The phone's extra shots, already baked on its side and
1240
+ // delivered behind `shot.binary` markers. Passed through
1241
+ // verbatim: the composer names them `screenshot-N`.
1242
+ extraShotPngs = extraShotPngs,
1243
+ title = submit.title,
1244
+ description = submit.description.text,
1245
+ // Description-redactions integration deferred (matches iOS
1246
+ // Plan 12 — reporter SPA sends redactions:[] always).
1247
+ includeLogs = submit.includes.logs,
1248
+ includeNetwork = submit.includes.network,
1249
+ includeMetadata = submit.includes.metadata,
1250
+ hostExtra = stash.hostExtra,
1251
+ companionAttribution = companionAttribution,
1252
+ // Pinned to the snapshot taken at the submit boundary above.
1253
+ capturedSession = capturedSession,
1254
+ )
1255
+
1256
+ val result = try {
1257
+ CompanionSubmissionComposer.submit(inputs)
1258
+ } catch (t: Throwable) {
1259
+ android.util.Log.w(
1260
+ "Everframe.rn",
1261
+ "companion submitProvider threw: ${t.javaClass.simpleName}: ${t.message}",
1262
+ )
1263
+ return@work CompanionCaptureBridge.SubmitResult.Err("ingest_error")
1264
+ }
1265
+
1266
+ when (result) {
1267
+ is ReportResult.Submitted ->
1268
+ CompanionCaptureBridge.SubmitResult.Ok(result.reportId.toString())
1269
+ is ReportResult.Queued ->
1270
+ CompanionCaptureBridge.SubmitResult.Ok(result.reportId.toString())
1271
+ is ReportResult.Cancelled ->
1272
+ CompanionCaptureBridge.SubmitResult.Err(result.reason)
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ // ----- __previewProvider (Task 11b) -----
1278
+ // The 2fps preview loop's seam — low-res JPEG for the live-preview
1279
+ // stream (~854px longest edge, quality ~60, see
1280
+ // ScreenshotCapture.capturePreviewFrame's own defaults). Resolves
1281
+ // the current Activity exactly like __captureProvider above; no
1282
+ // foreground Activity (or a zero-size window) degrades to null,
1283
+ // which CompanionPreviewSession's loop maps to `capture_unavailable`
1284
+ // and stops — never a crash, never a throw (DEFE-02).
1285
+ CompanionCaptureBridge.__previewProvider = previewProvider@{
1286
+ val activity = reactContext.currentActivity ?: return@previewProvider null
1287
+ val rects = SensitiveRectRegistry.collectInWindowCoords(activity)
1288
+ ScreenshotCapture.capturePreviewFrame(activity, rects)
1289
+ }
1290
+
1291
+ // ----- __shotCaptureProvider (Task 11b) -----
1292
+ // The shot-stash seam — `shot.request`'s contract is "capture fresh
1293
+ // now, at REPORT grade", which a preview-grade frame does not satisfy.
1294
+ // `captureBeforeReporter` is reused rather than duplicated, so a shot
1295
+ // is shaped exactly like the report's own screenshot: PNG, and
1296
+ // downscaled to `ScreenshotCapture.MAX_EDGE_PX` (2048) on the longest
1297
+ // edge. `mime` reflects the actual encode instead of asserting a
1298
+ // format the bytes don't carry.
1299
+ //
1300
+ // Task 11b review, finding 4: this comment used to claim "full
1301
+ // resolution" three times over while routing through a 2048-capped
1302
+ // path. The cap is deliberate and stays — a shot is evidence attached
1303
+ // to a report, and matching the primary screenshot's shaping is the
1304
+ // point — but the claim was simply false, and the next person to port
1305
+ // this (iOS) would have built an uncapped path to satisfy it.
1306
+ CompanionCaptureBridge.__shotCaptureProvider = shotProvider@{
1307
+ val activity = reactContext.currentActivity ?: return@shotProvider null
1308
+ val rects = SensitiveRectRegistry.collectInWindowCoords(activity)
1309
+ val capture = ScreenshotCapture.captureBeforeReporter(activity, rects)
1310
+ ?: return@shotProvider null
1311
+ // Task 11b review, finding 2: `CaptureResult` hands us ownership of
1312
+ // this bitmap and only `pngBytes` travels onward, so it is released
1313
+ // here rather than left for the GC — ~7.5 MB per shot otherwise.
1314
+ capture.bitmap.recycle()
1315
+ PreviewCapture(
1316
+ bytes = capture.pngBytes,
1317
+ width = capture.widthPx,
1318
+ height = capture.heightPx,
1319
+ mime = "image/png",
1320
+ )
1321
+ }
1322
+
1323
+ // ----- CompanionBadge.__activityProvider (Task 6, naming spec 2026-08-24) -----
1324
+ // Same seam, same reasoning as every provider above: `:everframe-core`
1325
+ // is a plain AAR and cannot resolve a foreground Activity on its own.
1326
+ // Mirrors the removed `CompanionSharingIndicator.__activityProvider`
1327
+ // install site this task's brief pointed at — resolved fresh on every
1328
+ // call (never cached), exactly like `reactContext.currentActivity`
1329
+ // is read fresh above for capture/preview/shot.
1330
+ CompanionBadge.__activityProvider = { reactContext.currentActivity }
1331
+ }
1332
+
1333
+ /**
1334
+ * Helper — minimal AssembledPayload for the degraded-path. The bridge
1335
+ * accepts a payload with empty pngBytes; the phone reporter SPA shows
1336
+ * "0 bytes" in the assembled state and the user can still tap Send.
1337
+ */
1338
+ private fun emptyAssembledPayload(correlationId: String): CompanionCaptureBridge.AssembledPayload {
1339
+ val toggles = ReportAssembledToggles(
1340
+ logs = false, metadata = true, network = false, screenshot = false, uiTree = false,
1341
+ )
1342
+ val counts = ReportAssembledCounts(breadcrumbs = null, logs = 0L, network = 0L, uiTreeNodes = 0L)
1343
+ val assembled = ReportAssembled(
1344
+ correlationId = correlationId,
1345
+ counts = counts,
1346
+ mime = "image/png",
1347
+ size = 0L,
1348
+ toggles = toggles,
1349
+ )
1350
+ return CompanionCaptureBridge.AssembledPayload(
1351
+ assembled = assembled,
1352
+ pngBytes = ByteArray(0),
1353
+ )
1354
+ }
1355
+
1356
+ /**
1357
+ * Re-encode the captured screenshot for the relay hop. Drops the
1358
+ * envelope PNG fidelity (we keep `capture.pngBytes` separately for the
1359
+ * eventual submit) in exchange for ~60-80% byte reduction on the
1360
+ * phone-bound binary frame.
1361
+ *
1362
+ * Format preference, in order:
1363
+ * 1. WEBP_LOSSY @ q=85 — API 30+; ~70-80% byte cut, no visible loss
1364
+ * for screenshot-class images.
1365
+ * 2. WEBP (legacy enum, behaves as lossy when quality < 100) —
1366
+ * API 24-29. Same wire mime; encoder is older but bytes still
1367
+ * decode on every phone.
1368
+ * 3. JPEG @ q=85 — universal fallback, ~50-60% byte cut.
1369
+ *
1370
+ * Returns the original PNG bytes unchanged when every encoder fails
1371
+ * (DEFE-02: don't block the report flow on a re-encode failure).
1372
+ */
1373
+ private fun reencodeScreenshot(
1374
+ bitmap: android.graphics.Bitmap,
1375
+ fallbackPng: ByteArray,
1376
+ ): Pair<ByteArray, String> {
1377
+ // Try WEBP_LOSSY (API 30+), then legacy WEBP, then JPEG.
1378
+ if (android.os.Build.VERSION.SDK_INT >= 30) {
1379
+ val webp = runCatching {
1380
+ val out = java.io.ByteArrayOutputStream()
1381
+ @Suppress("DEPRECATION_ERROR")
1382
+ // CompressFormat.WEBP_LOSSY introduced in API 30.
1383
+ val ok = bitmap.compress(
1384
+ android.graphics.Bitmap.CompressFormat.WEBP_LOSSY, 85, out,
1385
+ )
1386
+ if (ok) out.toByteArray() else null
1387
+ }.getOrNull()
1388
+ if (webp != null && webp.size < fallbackPng.size) return Pair(webp, "image/webp")
1389
+ }
1390
+ val legacyWebp = runCatching {
1391
+ val out = java.io.ByteArrayOutputStream()
1392
+ @Suppress("DEPRECATION")
1393
+ val ok = bitmap.compress(android.graphics.Bitmap.CompressFormat.WEBP, 85, out)
1394
+ if (ok) out.toByteArray() else null
1395
+ }.getOrNull()
1396
+ if (legacyWebp != null && legacyWebp.size < fallbackPng.size) {
1397
+ return Pair(legacyWebp, "image/webp")
1398
+ }
1399
+ val jpeg = runCatching {
1400
+ val out = java.io.ByteArrayOutputStream()
1401
+ val ok = bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out)
1402
+ if (ok) out.toByteArray() else null
1403
+ }.getOrNull()
1404
+ if (jpeg != null && jpeg.size < fallbackPng.size) return Pair(jpeg, "image/jpeg")
1405
+ return Pair(fallbackPng, "image/png")
1406
+ }
1407
+
1408
+ /** Test seam — assert provider install/uninstall via the bridge. */
1409
+ internal fun hasCompanionProvidersInstalledForTesting(): Boolean =
1410
+ CompanionCaptureBridge.__captureProvider != null &&
1411
+ CompanionCaptureBridge.__submitProvider != null
1412
+
1413
+ @ReactMethod
1414
+ override fun addListener(eventName: String) {
1415
+ // No-op — RN NativeEventEmitter contract. RCTDeviceEventEmitter on
1416
+ // the JS side handles subscriber bookkeeping internally; we only
1417
+ // need to ensure the method is declared so RN's warning suppression
1418
+ // is satisfied. State/pairUrl collectors are installed on first
1419
+ // startCompanion(), not on first addListener().
1420
+ }
1421
+
1422
+ @ReactMethod
1423
+ override fun removeListeners(count: Double) {
1424
+ // No-op — see addListener() comment.
1425
+ }
1426
+
1427
+ /**
1428
+ * Called by the JS companion module from the `everframe.companion
1429
+ * .reportRequested` event handler once it is ready for capture to
1430
+ * proceed. (It used to walk the React fiber tree and call
1431
+ * `attachReactTree` first; both are gone — spec 2026-08-29.) Releases
1432
+ * the `awaitJsReactTreeAttach` waiter for this correlation id so the
1433
+ * capture provider can proceed. Idempotent — late signals after the
1434
+ * timeout fired are harmless.
1435
+ */
1436
+ @ReactMethod
1437
+ override fun signalCompanionReportRequestReady(correlationId: String) {
1438
+ pendingReactTreeAttachSignals.remove(correlationId)?.complete(Unit)
1439
+ }
1440
+
1441
+ /** Per-correlation-id signals released by `signalCompanionReportRequestReady`. */
1442
+ private val pendingReactTreeAttachSignals =
1443
+ ConcurrentHashMap<String, kotlinx.coroutines.CompletableDeferred<Unit>>()
1444
+
1445
+ /**
1446
+ * Emit `everframe.companion.reportRequested` then block up to 250ms
1447
+ * waiting for JS to call back via `signalCompanionReportRequestReady`.
1448
+ * The wait is bounded so a non-responsive JS thread can't stall the
1449
+ * native capture path indefinitely (DEFE-02). Runs on the OkHttp WS
1450
+ * thread — the surrounding `runCatching` block in the capture provider
1451
+ * absorbs any throw.
1452
+ *
1453
+ * Returns silently when:
1454
+ * - JS signals ready (fast path, ~5-50ms on a healthy app)
1455
+ * - The 250ms timeout elapses (slow path)
1456
+ */
1457
+ private fun awaitJsReactTreeAttach(correlationId: String) {
1458
+ val signal = kotlinx.coroutines.CompletableDeferred<Unit>()
1459
+ pendingReactTreeAttachSignals[correlationId] = signal
1460
+ val emitter = reactApplicationContext
1461
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1462
+ emitter.emit(COMPANION_REPORT_REQUESTED_EVENT, correlationId)
1463
+ runBlocking {
1464
+ kotlinx.coroutines.withTimeoutOrNull(250) { signal.await() }
1465
+ }
1466
+ pendingReactTreeAttachSignals.remove(correlationId)
1467
+ }
1468
+
1469
+ // ---------------- Extra-resolver ask-and-wait (spec 2026-09-17 setExtra-resolver) ----------------
1470
+
1471
+ /**
1472
+ * Called by the JS `everframe.extra.resolveRequested` listener
1473
+ * (`runtime.ts`'s `installExtraResolveHandler`) once it has — if a
1474
+ * resolver is registered — pushed a freshly resolved value through the
1475
+ * ordinary `setExtra` method above. Releases `awaitJsExtraResolve`'s
1476
+ * bounded wait for this correlation id. Idempotent — a late signal
1477
+ * after the 250ms timeout already fired is a harmless no-op, exactly
1478
+ * like `signalCompanionReportRequestReady` above.
1479
+ */
1480
+ @ReactMethod
1481
+ override fun signalExtraResolverReady(correlationId: String) {
1482
+ pendingExtraResolveSignals.remove(correlationId)?.complete(Unit)
1483
+ }
1484
+
1485
+ /** Per-correlation-id signals released by `signalExtraResolverReady`. */
1486
+ private val pendingExtraResolveSignals =
1487
+ ConcurrentHashMap<String, kotlinx.coroutines.CompletableDeferred<Unit>>()
1488
+
1489
+ /**
1490
+ * Installed onto `Everframe.__pendingExtraResolveHook` (see that
1491
+ * property's doc comment on Everframe.kt) — called right before every
1492
+ * `consumePendingAttachments()` drain, on whatever thread that happens
1493
+ * to run on. No-ops IMMEDIATELY — no event, no wait — unless JS has a
1494
+ * resolver CURRENTLY registered (`extraResolverActive`), which is what
1495
+ * keeps a string/object-only host's report path exactly as fast as
1496
+ * before this feature landed (requirement: "no behaviour change
1497
+ * whatsoever").
1498
+ *
1499
+ * Otherwise mirrors `awaitJsReactTreeAttach` exactly — emit
1500
+ * `everframe.extra.resolveRequested`, then suspend up to 250ms for JS's
1501
+ * `signalExtraResolverReady` ack. `suspend`, unlike
1502
+ * `awaitJsReactTreeAttach`: this hook's caller
1503
+ * (`Everframe.consumePendingAttachments()`) may run on Android's Main
1504
+ * dispatcher (RN's `openReporter()`/native shake both funnel through
1505
+ * `Everframe.report.open()` there), so a real suspend point is required
1506
+ * — `runBlocking` here would freeze the UI thread for up to 250ms on
1507
+ * every report a resolver-using host opens.
1508
+ */
1509
+ private suspend fun awaitJsExtraResolve() {
1510
+ if (!extraResolverActive) return
1511
+ val correlationId = java.util.UUID.randomUUID().toString()
1512
+ val signal = kotlinx.coroutines.CompletableDeferred<Unit>()
1513
+ pendingExtraResolveSignals[correlationId] = signal
1514
+ try {
1515
+ val emitter = reactApplicationContext
1516
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1517
+ emitter.emit(EXTRA_RESOLVE_REQUESTED_EVENT, correlationId)
1518
+ kotlinx.coroutines.withTimeoutOrNull(250) { signal.await() }
1519
+ } finally {
1520
+ pendingExtraResolveSignals.remove(correlationId)
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * Install StateFlow collectors on `Everframe.companion.state`, `.pairUrl`,
1526
+ * `.code` and `.attachedUserName` that emit the matching
1527
+ * `everframe.companion.*` device events. Idempotent — the
1528
+ * `companionCollectorsInstalled` latch survives stop/start cycles because
1529
+ * the underlying StateFlows on `Everframe.companion` outlive the
1530
+ * RelayWSClient.
1531
+ */
1532
+ private fun installCompanionCollectorsIfNeeded() {
1533
+ if (companionCollectorsInstalled) return
1534
+ companionCollectorsInstalled = true
1535
+
1536
+ val emitter = reactApplicationContext
1537
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1538
+
1539
+ companionStateJob = companionScope.launch {
1540
+ Everframe.companion.state.collect { state ->
1541
+ android.util.Log.i("Everframe.companion", "emit state=$state")
1542
+ emitter.emit(COMPANION_STATE_EVENT, state.toRnString())
1543
+ }
1544
+ }
1545
+ companionPairUrlJob = companionScope.launch {
1546
+ Everframe.companion.pairUrl.collect { url ->
1547
+ android.util.Log.i("Everframe.companion", "emit pairUrl=${if (url == null) "null" else "set"}")
1548
+ // Both null and String cross to JS cleanly via the
1549
+ // DeviceEventManager — null becomes `null` on the JS side.
1550
+ emitter.emit(COMPANION_PAIR_URL_EVENT, url)
1551
+ }
1552
+ }
1553
+ // Companion discovery (spec 2026-08-07). Same install-once lifecycle as
1554
+ // the two above, and for the same reason: `Everframe.companion.code`
1555
+ // is a StateFlow that outlives the RelayWSClient, so a collector
1556
+ // installed on the FIRST start replays the current value and keeps
1557
+ // delivering across stop/start cycles. Re-installing per start would
1558
+ // race the initial emission and lose it.
1559
+ companionCodeJob = companionScope.launch {
1560
+ Everframe.companion.code.collect { code ->
1561
+ android.util.Log.i("Everframe.companion", "emit code=${if (code == null) "null" else "set"}")
1562
+ emitter.emit(COMPANION_CODE_EVENT, code)
1563
+ }
1564
+ }
1565
+ companionAttachedUserNameJob = companionScope.launch {
1566
+ Everframe.companion.attachedUserName.collect { name ->
1567
+ // The NAME itself is not logged — it is a dashboard member's
1568
+ // display name, and this log line goes to logcat.
1569
+ android.util.Log.i(
1570
+ "Everframe.companion",
1571
+ "emit attachedUserName=${if (name == null) "null" else "set"}",
1572
+ )
1573
+ emitter.emit(COMPANION_ATTACHED_USER_NAME_EVENT, name)
1574
+ }
1575
+ }
1576
+ // Resolved device display name (naming spec 2026-08-24). Same
1577
+ // install-once lifecycle as the collectors above, and for the same
1578
+ // reason: the StateFlow outlives the RelayWSClient, so a collector
1579
+ // installed on the FIRST start replays the current value across
1580
+ // stop/start cycles.
1581
+ companionResolvedNameJob = companionScope.launch {
1582
+ Everframe.companion.resolvedName.collect { name ->
1583
+ android.util.Log.i(
1584
+ "Everframe.companion",
1585
+ "emit resolvedName=${if (name == null) "null" else "set"}",
1586
+ )
1587
+ emitter.emit(COMPANION_RESOLVED_NAME_EVENT, name)
1588
+ }
1589
+ }
1590
+ // Attach-PIN challenge (spec 2026-08-19). Same install-once lifecycle
1591
+ // as the collectors above, and for the same reason: the StateFlow
1592
+ // outlives the RelayWSClient, so a collector installed on the FIRST
1593
+ // start replays the current value across stop/start cycles. Payload
1594
+ // conversion lives in `AttachChallengeInfo.toWritableMap()` below so
1595
+ // this collector body stays as short as its siblings.
1596
+ companionAttachChallengeJob = companionScope.launch {
1597
+ Everframe.companion.attachChallenge.collect { challenge ->
1598
+ // SECURITY: the PIN code itself is not logged.
1599
+ android.util.Log.i(
1600
+ "Everframe.companion",
1601
+ "emit attachChallenge=${if (challenge == null) "null" else "set"}",
1602
+ )
1603
+ emitter.emit(COMPANION_ATTACH_CHALLENGE_EVENT, challenge?.toWritableMap())
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ // ---------------- Helpers ----------------
1609
+
1610
+ @Volatile
1611
+ private var resolverInstalled = false
1612
+
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
+
1639
+ private fun ReadableMap.takeIfHasString(key: String): String? =
1640
+ if (hasKey(key) && !isNull(key)) getString(key) else null
1641
+
1642
+ private fun ReadableMap.takeIfHasBoolean(key: String): Boolean? =
1643
+ if (hasKey(key) && !isNull(key)) getBoolean(key) else null
1644
+
1645
+ private fun ReadableMap.takeIfHasDouble(key: String): Double? =
1646
+ if (hasKey(key) && !isNull(key)) getDouble(key) else null
1647
+
1648
+ /** A bridge bug must never crash the host. Mirrors txGuardVoid from Everframe.kt. */
1649
+ private inline fun txGuardVoid(api: String, body: () -> Unit) {
1650
+ try {
1651
+ body()
1652
+ } catch (t: Throwable) {
1653
+ android.util.Log.w("Everframe.rn", "guarded exception in $api: ${t.message}")
1654
+ }
1655
+ }
1656
+
1657
+ private inline fun txGuardSurface(api: String, body: () -> Unit) {
1658
+ try {
1659
+ body()
1660
+ } catch (t: JSApplicationIllegalArgumentException) {
1661
+ throw t
1662
+ } catch (t: Throwable) {
1663
+ android.util.Log.w("Everframe.rn", "guarded exception in $api: ${t.message}")
1664
+ }
1665
+ }
1666
+
1667
+ // rewriteEmulatorLoopback removed — JS-side `apiBase` / `startCompanion`
1668
+ // endpoint args are no longer plumbed through. The 10.0.2.2 emulator
1669
+ // alias is baked into the debug-variant BuildConfig.INGEST_URL fallback.
1670
+ }