@everframe/react-native 1.5.0 → 1.6.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.
@@ -17,9 +17,6 @@
17
17
  // recordScreen(name, data?) → void (spec 2026-07-14)
18
18
  // reportCrash(crashJson) → boolean (SYNC — spec 2026-07-18)
19
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
20
  // trackPlayer/detachPlayer/recordPlayerEvent/updatePlayerStats/
24
21
  // trackVitals → void (spec 2026-09-06)
25
22
  // configureSync(opts.vitals*) → boolean (spec 2026-09-06)
@@ -57,24 +54,10 @@ import com.facebook.react.bridge.ReactMethod
57
54
  import com.facebook.react.bridge.ReadableMap
58
55
  import com.facebook.react.bridge.ReadableType
59
56
  import com.facebook.react.bridge.UiThreadUtil
60
- import com.facebook.react.bridge.WritableMap
61
57
  import com.facebook.react.modules.core.DeviceEventManagerModule
62
58
  import com.facebook.react.module.annotations.ReactModule
63
59
  import com.facebook.react.uimanager.UIManagerHelper
64
60
  import dev.everframe.Everframe
65
- import dev.everframe.capture.ScreenshotCapture
66
- import dev.everframe.capture.SensitiveRectRegistry
67
- import dev.everframe.companion.AttachChallengeInfo
68
- import dev.everframe.companion.AttachPinUi
69
- import dev.everframe.companion.CompanionBadge
70
- import dev.everframe.companion.CompanionBadgeOptions
71
- import dev.everframe.companion.CompanionCaptureBridge
72
- import dev.everframe.companion.CompanionDeviceFacts
73
- import dev.everframe.companion.CompanionState
74
- import dev.everframe.companion.CompanionSubmissionComposer
75
- import dev.everframe.companion.PreviewCapture
76
- import dev.everframe.companion.RelayWSClient
77
- import dev.everframe.companion.parseCompanionBadgePosition
78
61
  import dev.everframe.config.ReleaseHealthConfig
79
62
  import dev.everframe.config.ReleaseHealthBundleStatus
80
63
  import dev.everframe.config.CaptureConfig
@@ -83,22 +66,10 @@ import dev.everframe.config.ReportResult
83
66
  import dev.everframe.config.ReporterThemeOptions
84
67
  import dev.everframe.config.TXUser
85
68
  import dev.everframe.config.EverframeConfig
86
- import dev.everframe.protocol.generated.ReportAssembled
87
- import dev.everframe.protocol.generated.ReportAssembledCounts
88
- import dev.everframe.protocol.generated.ReportAssembledToggles
89
- import dev.everframe.protocol.generated.ReportSubmit
90
- import kotlinx.coroutines.CoroutineScope
91
- import kotlinx.coroutines.Dispatchers
92
- import kotlinx.coroutines.Job
93
69
  import kotlinx.coroutines.MainScope
94
- import kotlinx.coroutines.SupervisorJob
95
- import kotlinx.coroutines.cancel
96
- import kotlinx.coroutines.flow.collect
97
70
  import kotlinx.coroutines.launch
98
71
  import kotlinx.coroutines.runBlocking
99
72
  import kotlinx.coroutines.withContext
100
- import okhttp3.OkHttpClient
101
- import java.io.ByteArrayOutputStream
102
73
  import java.util.concurrent.ConcurrentHashMap
103
74
 
104
75
  @ReactModule(name = EverframeModule.NAME)
@@ -167,10 +138,8 @@ class EverframeModule(
167
138
 
168
139
  init {
169
140
  // Extra-resolver ask-and-wait (spec 2026-09-17 setExtra-resolver).
170
- // Installed unconditionally, at construction — NOT gated behind
171
- // startCompanion() the way the companion Combine/Flow subscriptions
172
- // are, because this must also cover the plain in-app openReporter()
173
- // path and native shake (both funnel through Everframe.report.open(),
141
+ // Installed unconditionally, at construction, because this must cover
142
+ // the plain in-app openReporter() path and native shake (both funnel through Everframe.report.open(),
174
143
  // which calls Everframe.consumePendingAttachments() — see that
175
144
  // property's doc comment on Everframe.kt). Reassigning on every
176
145
  // module construction is intentional and harmless: the newest
@@ -188,57 +157,12 @@ class EverframeModule(
188
157
  /** Crash-only sessions (spec 2026-10-12). The JS facade maps this exact code to `EverframeReporterDisabledError`. */
189
158
  internal const val REJECT_REPORTER_DISABLED: String = "EVERFRAME_REPORTER_DISABLED"
190
159
 
191
- // Plan 06.2-11 — JS-side event names. Keep in lock-step with
192
- // `packages/sdk-react-native/src/companion.ts` and the iOS
193
- // `EverframeEventEmitter.swift` constants.
194
- internal const val COMPANION_STATE_EVENT: String = "everframe.companion.state"
195
- internal const val COMPANION_PAIR_URL_EVENT: String = "everframe.companion.pairUrl"
196
-
197
- // Companion discovery (spec 2026-08-07). `code` is the short display
198
- // code from `/api/companion/announce`, rendered beside the QR so a
199
- // team member reading the TV screen can pick this device out of the
200
- // dashboard list. `attachedUserName` is the dashboard member who
201
- // attached, null on an ordinary QR bond. Both cross as String | null.
202
- internal const val COMPANION_CODE_EVENT: String = "everframe.companion.code"
203
- internal const val COMPANION_ATTACHED_USER_NAME_EVENT: String =
204
- "everframe.companion.attachedUserName"
205
- // Server-resolved device display name (naming spec 2026-08-24) — e.g.
206
- // an org-configured friendly name for this device. Null before the
207
- // first successful announce or when discovery never ran. Crosses as
208
- // String | null.
209
- internal const val COMPANION_RESOLVED_NAME_EVENT: String =
210
- "everframe.companion.resolvedName"
211
- // Pending dashboard-initiated attach-PIN challenge (spec
212
- // 2026-08-19). `{ code, requestedByName, ttlMs }` or null — see
213
- // `installCompanionCollectorsIfNeeded()`'s attachChallenge collector
214
- // below for the full contract.
215
- internal const val COMPANION_ATTACH_CHALLENGE_EVENT: String =
216
- "everframe.companion.attachChallenge"
217
- // Fired when a `report.request` arrives from the paired phone. The
218
- // event and its `signalCompanionReportRequestReady` acknowledgement
219
- // are the companion's JS-ready handshake — the bounded wait is part
220
- // of the pairing flow.
221
- internal const val COMPANION_REPORT_REQUESTED_EVENT: String = "everframe.companion.reportRequested"
222
-
223
- // Spec 2026-09-17 setExtra-resolver — NOT a companion event (no
224
- // "companion." prefix): fired for the plain in-app openReporter()
225
- // path and native shake too, not just the phone-companion flow. See
160
+ // Spec 2026-09-17 setExtra-resolver: fired for the plain in-app
161
+ // openReporter() path and native shake. See
226
162
  // `awaitJsExtraResolve()` below and `packages/sdk-react-native/src/
227
163
  // runtime.ts`'s matching listener.
228
164
  internal const val EXTRA_RESOLVE_REQUESTED_EVENT: String = "everframe.extra.resolveRequested"
229
165
 
230
- /**
231
- * Stringify a `CompanionState` for the RN event payload. Snake_case
232
- * mirrors the iOS `CompanionState.rawValue` shape ("unpaired",
233
- * "paired", "report_in_progress", "phone_disconnected") so JS
234
- * consumers can treat both platforms with a single union type.
235
- */
236
- internal fun CompanionState.toRnString(): String = when (this) {
237
- CompanionState.Unpaired -> "unpaired"
238
- CompanionState.Paired -> "paired"
239
- CompanionState.ReportInProgress -> "report_in_progress"
240
- CompanionState.PhoneDisconnected -> "phone_disconnected"
241
- }
242
166
  }
243
167
 
244
168
  // getName() is inherited from NativeEverframeSpec (returns "Everframe").
@@ -306,16 +230,6 @@ class EverframeModule(
306
230
  // (`reporterDisabled`, flattened from `reporter: { disabled }`),
307
231
  // native stores the permission.
308
232
  val reporterDisabled = opts.takeIfHasBoolean("reporterDisabled") ?: false
309
- // Attach-PIN UI mode (spec 2026-08-19) — a flat optional STRING
310
- // field (codegen cannot express a string-literal union), parsed
311
- // here and stashed for `startCompanion()` to read (the flag is a
312
- // startup-time preference riding `configure()`, not a per-call
313
- // argument; see NativeEverframe.ts's D-decision header note).
314
- // Absent/unrecognised both coerce to BUILTIN, the safe default
315
- // matching "host did nothing special". Parsed into a LOCAL: the
316
- // idempotence gate below compares it against the stashed value
317
- // before deciding, and only the start path adopts it.
318
- val attachPinUi = parseAttachPinUi(opts.takeIfHasString("attachPinUi"))
319
233
 
320
234
  val config = EverframeConfig(
321
235
  // The JS config carries no App ID, and the native SDK requires a
@@ -335,26 +249,9 @@ class EverframeModule(
335
249
  // The JS facade always sends React Native's `__DEV__` (or the host's override). A missing
336
250
  // or null key stays null so the native SDK decides for itself, as for any other caller.
337
251
  debugBuild = opts.takeIfHasBoolean("debugBuild"),
338
- // Explicit companion device-identity override (naming spec
339
- // 2026-08-24) — read straight off the flat ConfigOpts field,
340
- // same shape as attachPinUi above. Absent/blank falls
341
- // through to the SSAID-then-stored-UUID chain inside
342
- // CompanionDeviceId.resolve.
343
- companionDeviceId = opts.takeIfHasString("companionDeviceId"),
344
- // Companion on-screen name badge (naming spec 2026-08-24,
345
- // controller ruling / Task 6b) — flat fields on ConfigOpts
346
- // (RN codegen cannot express the nested `{enabled,
347
- // position}` shape the JS facade's
348
- // `RuntimeConfig.companionBadge` uses; `extractBridgeConfig`
349
- // flattens it before it crosses). Absent `enabled` defaults
350
- // to `true` (badge shown); `position` is stashed raw here
351
- // and parsed into `CompanionBadgePosition` only at
352
- // `startCompanion()` time, mirroring `attachPinUi`.
353
- companionBadgeEnabled = opts.takeIfHasBoolean("companionBadgeEnabled") ?: true,
354
252
  shakeToReportEnabled = opts.takeIfHasBoolean("shakeToReportEnabled") ?: true,
355
253
  quickCaptureEnabled = opts.takeIfHasBoolean("quickCaptureEnabled") ?: true,
356
254
  reporterEnabled = !reporterDisabled,
357
- companionBadgePosition = opts.takeIfHasString("companionBadgePosition"),
358
255
  // Inline reporter theme (reporter branding spec 2026-08-25,
359
256
  // RN slice) — 8 flat optional STRING fields on ConfigOpts
360
257
  // (RN codegen cannot express the nested `theme: { … }` shape
@@ -406,16 +303,11 @@ class EverframeModule(
406
303
  // So compare against what is actually INSTALLED. `EverframeConfig` is a data class,
407
304
  // so `==` is structural over every field a start would write, which makes the
408
305
  // comparison exhaustive by construction — a new config field can never be forgotten
409
- // here the way a hand-maintained key list could be. The two extra terms:
306
+ // here the way a hand-maintained key list could be. The extra term:
410
307
  // • `captureGate` — a killed SDK must be restarted even by an identical config,
411
308
  // or the host is left with a dead SDK and no way to revive it.
412
- // • `attachPinUi` — the one bridge option that lands in module state instead of
413
- // in `EverframeConfig`, so config equality alone cannot see it change.
414
309
  interactionEventObserver.refresh(reactContext)
415
- if (Everframe.captureGate &&
416
- Everframe.currentConfig == config &&
417
- companionAttachPinUi == attachPinUi
418
- ) {
310
+ if (Everframe.captureGate && Everframe.currentConfig == config) {
419
311
  android.util.Log.d(
420
312
  "Everframe.rn",
421
313
  "configure: SDK already running this exact config — skipping start()",
@@ -425,11 +317,6 @@ class EverframeModule(
425
317
  return@txGuardSurface
426
318
  }
427
319
 
428
- // Stash the attach-PIN UI mode for startCompanion() to read (spec 2026-08-19). It
429
- // is written only on the start path: the skip path above has already established
430
- // that the stashed value equals the parsed one.
431
- companionAttachPinUi = attachPinUi
432
-
433
320
  // Pass the live Activity: configure() runs from JS long after
434
321
  // MainActivity's onCreate, so without it the tap adapter's
435
322
  // Window.Callback wrapper is never installed on the current screen
@@ -467,7 +354,7 @@ class EverframeModule(
467
354
  * { status: "cancelled" }
468
355
  *
469
356
  * Rejects with [REJECT_REPORTER_DISABLED] in a crash-only session
470
- * (`reporterDisabled`), including after a companion bond upgraded it.
357
+ * (`reporterDisabled`).
471
358
  */
472
359
  @ReactMethod
473
360
  override fun openReporter(promise: Promise) {
@@ -575,8 +462,7 @@ class EverframeModule(
575
462
  * because it is written from the JS thread (this `@ReactMethod`) and
576
463
  * read from `awaitJsExtraResolve`, which runs wherever
577
464
  * `Everframe.consumePendingAttachments()`'s caller happens to run
578
- * (Main for `openReporter()`/shake, the OkHttp WS thread for the
579
- * companion path).
465
+ * (Main for `openReporter()`/shake).
580
466
  */
581
467
  @Volatile
582
468
  private var extraResolverActive: Boolean = false
@@ -857,8 +743,7 @@ class EverframeModule(
857
743
  * IO scope and is a silent no-op pre-start, so there is nothing a JVM
858
744
  * unit test could otherwise assert on). Production default forwards to
859
745
  * the core singleton; `RnReplayBridgeTest` swaps it to a recording
860
- * lambda and restores it. Same `internal`-seam idiom as
861
- * `hasCompanionClientForTesting()` above.
746
+ * lambda and restores it.
862
747
  */
863
748
  internal var __drainRequester: () -> Unit = { Everframe.requestOutboxDrain() }
864
749
 
@@ -889,717 +774,12 @@ class EverframeModule(
889
774
  sensitiveRegistration.register(viewTag)
890
775
  }
891
776
 
892
- // ---------------- Companion (Plan 06.2-11) ----------------
893
- //
894
- // The bridge owns at most ONE live `RelayWSClient` per process.
895
- // `startCompanion()` is idempotent: a second call while a client is
896
- // already live is a no-op — the socket is NOT recreated. Call
897
- // `stopCompanion()` first to tear the connection down before re-starting.
898
- //
899
- // External review, finding NN1 — "at most one per process" is now
900
- // enforced ACROSS this bridge AND `Everframe.startCompanion()` (a plain-
901
- // native host's own companion entry point), not just within this
902
- // module: both route through `Everframe.__registerCompanionClient` /
903
- // `__unregisterCompanionClient`, a single `@Synchronized` slot on the
904
- // core facade. First caller wins; this bridge's `startCompanion()`
905
- // discards its own just-built client (never starting it or installing
906
- // any provider seam for it) when a native host already holds the slot,
907
- // and its `stopCompanion()` only ever stops/unregisters the client THIS
908
- // module itself registered.
909
- //
910
- // StateFlow collection lives on a module-owned `CoroutineScope` (Main +
911
- // SupervisorJob, mirroring the precedent in CompanionCaptureBridge.kt's
912
- // service scope shape). Collectors stay alive across stop/start cycles
913
- // because the underlying `Everframe.companion` StateFlows survive the
914
- // WS lifetime — re-installing on every start would race the initial
915
- // `__setState(Unpaired)` emission on cold start and lose it.
916
- //
917
- // The events forwarded to JS mirror iOS exactly:
918
- // • `everframe.companion.state` — snake_case CompanionState
919
- // • `everframe.companion.pairUrl` — String | null
920
- // • `everframe.companion.code` — String | null
921
- // • `everframe.companion.attachedUserName` — String | null
922
- // • `everframe.companion.attachChallenge` — WritableMap{code,requestedByName,ttlMs} | null (spec 2026-08-19)
923
- //
924
- // The collector Jobs below are never cancelled: `companionScope` and the
925
- // module live for the process, and the collectors deliberately outlive
926
- // stop/start cycles (see above). The fields exist so the jobs are
927
- // inspectable and so a future teardown path has something to cancel.
928
- //
929
- // Emission is via `RCTDeviceEventEmitter` (the default emitter); JS
930
- // facade uses `new NativeEventEmitter()` on Android (no module ref
931
- // needed — the default emitter is platform-supplied).
932
-
933
- private val companionScope: CoroutineScope =
934
- CoroutineScope(Dispatchers.Main + SupervisorJob())
935
-
936
- @Volatile
937
- private var companionClient: RelayWSClient? = null
938
-
939
- @Volatile
940
- private var companionCollectorsInstalled: Boolean = false
941
-
942
- @Volatile
943
- private var companionStateJob: Job? = null
944
-
945
- @Volatile
946
- private var companionPairUrlJob: Job? = null
947
-
948
- @Volatile
949
- private var companionCodeJob: Job? = null
950
-
951
- @Volatile
952
- private var companionAttachedUserNameJob: Job? = null
953
-
954
- @Volatile
955
- private var companionResolvedNameJob: Job? = null
956
-
957
- @Volatile
958
- private var companionAttachChallengeJob: Job? = null
959
-
960
- /**
961
- * The host's configured attach-PIN UI mode (spec 2026-08-19), parsed
962
- * from `ConfigOpts.attachPinUi` at `configure()` time and read by
963
- * `startCompanion()` — both to pass to `RelayWSClient` (the announce
964
- * capability) and to set `Everframe.__attachPinUiSuppressed` (the
965
- * built-in presenter's runtime silence flag). Defaults to `BUILTIN` —
966
- * pre-existing behaviour for a host that never set the flag.
967
- */
968
- @Volatile
969
- private var companionAttachPinUi: AttachPinUi = AttachPinUi.BUILTIN
970
-
971
- /** Coerce the flat `ConfigOpts.attachPinUi` string into [AttachPinUi].
972
- * `null` (field absent) and any unrecognised value both degrade to
973
- * `BUILTIN`. */
974
- private fun parseAttachPinUi(raw: String?): AttachPinUi = when (raw) {
975
- "custom" -> AttachPinUi.CUSTOM
976
- "off" -> AttachPinUi.OFF
977
- else -> AttachPinUi.BUILTIN
978
- }
979
-
980
- // `parseCompanionBadgePosition` moved to `dev.everframe.companion`
981
- // (CompanionBadge.kt) — external review, finding N3 — so the core
982
- // facade's own native-host companion start path (`Everframe.startCompanion`)
983
- // can share it instead of drifting a second copy. Imported above.
984
-
985
- /** WritableMap has no `long` accessor — `ttlMs` crosses as a double, per
986
- * the spec's documented `everframe.companion.attachChallenge` payload
987
- * contract. Extracted so the collector body above stays as short as
988
- * its siblings. */
989
- private fun AttachChallengeInfo.toWritableMap(): WritableMap =
990
- Arguments.createMap().apply {
991
- putString("code", code)
992
- putString("requestedByName", requestedByName)
993
- putDouble("ttlMs", ttlMs.toDouble())
994
- }
995
-
996
- /** Test seam — JVM unit test (`EverframeCompanionModuleTest.kt`) asserts
997
- * the bridge installs a client on startCompanion() and clears it on
998
- * stopCompanion(). Intentionally `internal` so it doesn't leak past
999
- * the module boundary. */
1000
- internal fun hasCompanionClientForTesting(): Boolean = companionClient != null
1001
-
1002
- /** Test seam — the live client instance (or null), so tests can assert by
1003
- * identity that a repeat `startCompanion()` did NOT recreate the socket. */
1004
- internal fun companionClientForTesting(): RelayWSClient? = companionClient
1005
-
1006
- /**
1007
- * The SDK key the host already configured through `configure()`, or null
1008
- * when there isn't a usable one. Read straight off `Everframe`'s own config
1009
- * snapshot — deliberately NOT a second place a key can be configured, and
1010
- * NOT a new public surface (`currentConfig` is already public read-only).
1011
- *
1012
- * Null when `Everframe.start` never ran, and when the host configured a
1013
- * blank key. Both are "no dashboard presence", never "no socket".
1014
- */
1015
- private fun configuredSdkKey(): String? =
1016
- Everframe.currentConfig?.sdkKey?.takeIf { it.isNotBlank() }
1017
-
1018
- /**
1019
- * Non-personal device label for the dashboard's device list — the platform
1020
- * model ("Chromecast", "AFTKA", "Pixel 8"). Deliberately
1021
- * `android.os.Build.MODEL` and not any user-assigned device name, which is
1022
- * commonly a person's name and would leak into a shared dashboard list as
1023
- * a side effect of turning companion on. The display code is the real
1024
- * disambiguator; the label is only a convenience.
1025
- *
1026
- * `Build.MODEL` is a platform type — it reads back null under plain JVM
1027
- * unit tests (unmocked android.jar), so the null-safe call is load-bearing
1028
- * and not defensive noise.
1029
- */
1030
- private fun companionDeviceLabel(): String? =
1031
- android.os.Build.MODEL?.takeIf { it.isNotBlank() }
1032
-
1033
- @ReactMethod
1034
- override fun startCompanion() {
1035
- if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
1036
- android.os.Handler(android.os.Looper.getMainLooper()).post { startCompanion() }
1037
- return
1038
- }
1039
- txGuardVoid("startCompanion") {
1040
- // Idempotent: a live client means a prior startCompanion already
1041
- // opened the socket. Do NOT recreate it — callers must
1042
- // stopCompanion() first. This keeps repeat starts from spinning up
1043
- // a second WS (which would race on `pair.created` / `__setPairUrl`).
1044
- if (companionClient != null) {
1045
- android.util.Log.i(
1046
- "Everframe.rn",
1047
- "startCompanion ignored — already running; call stopCompanion() first",
1048
- )
1049
- return@txGuardVoid
1050
- }
1051
-
1052
- // Await the main-thread install before the relay can announce PIN capability.
1053
- // configureSync itself remains non-blocking with respect to main.
1054
- reporterBridge.scheduleInstall(reactContext)
1055
- installCompanionCollectorsIfNeeded()
1056
-
1057
- // Runtime suppression of the built-in PIN presenter (spec
1058
- // 2026-08-19, controller ruling): a runtime flag, not an
1059
- // install-site gate. `:everframe-reporter-ui`'s
1060
- // `CompanionPinPresenter` is auto-installed via
1061
- // `ReporterResolverInstaller` (Startup or the bridge above) regardless of
1062
- // the configured mode, so `Everframe.__attachPinUiInstalled`
1063
- // stays an honest capability signal — only the PRESENTING is
1064
- // silenced, here, at the one site where the mode is already
1065
- // known (configure() precedes startCompanion() in every host's
1066
- // lifecycle).
1067
- Everframe.__attachPinUiSuppressed = companionAttachPinUi != AttachPinUi.BUILTIN
1068
-
1069
- // RelayWSClient defaults baseUrl to IngestEndpoint.url —
1070
- // release AAR = https://everframe.dev, debug AAR =
1071
- // EVERFRAME_DEV_INGEST_URL env var or 10.0.2.2:8787 fallback.
1072
- //
1073
- // Companion discovery (spec 2026-08-07): announcing is what makes
1074
- // this device appear in the dashboard's Companion tab. It rides
1075
- // the SDK key the host ALREADY configured via `configure()` — we
1076
- // never ask for a second one. A null key takes the ticketless
1077
- // path: RelayWSClient builds no announcer and connects to plain
1078
- // `/relay/tv`, byte-identical to pre-companion behaviour.
1079
- // Announce must never be able to break reporting — no key means
1080
- // "no dashboard presence", never "no socket".
1081
- val client = RelayWSClient(
1082
- client = OkHttpClient(),
1083
- sdkKey = configuredSdkKey(),
1084
- deviceLabel = companionDeviceLabel(),
1085
- // Naming spec 2026-08-24 — resolved fresh on every announce
1086
- // attempt (see `RelayWSClient.deviceProvider`'s own doc for
1087
- // why no caching is needed here). `companionDeviceId` is read
1088
- // off the same `Everframe.currentConfig` snapshot
1089
- // `configuredSdkKey()` above already reads, not a second
1090
- // configuration surface.
1091
- deviceProvider = {
1092
- CompanionDeviceFacts.current(
1093
- reactContext.applicationContext,
1094
- Everframe.currentConfig?.companionDeviceId,
1095
- )
1096
- },
1097
- attachPinUi = companionAttachPinUi,
1098
- // Companion on-screen name badge (naming spec 2026-08-24,
1099
- // controller ruling / Task 6b) — same `Everframe.currentConfig`
1100
- // snapshot `deviceProvider` above already reads, read fresh
1101
- // at start time (a toggle/position can't affect anything
1102
- // before a relay client and its badge exist). `?: true`
1103
- // covers the same "never configured" edge `configuredSdkKey()`'s
1104
- // own null-safe chain already tolerates.
1105
- companionBadge = CompanionBadgeOptions(
1106
- enabled = Everframe.currentConfig?.companionBadgeEnabled ?: true,
1107
- position = parseCompanionBadgePosition(Everframe.currentConfig?.companionBadgePosition),
1108
- ),
1109
- )
1110
-
1111
- // External review, finding NN1 — register through the core
1112
- // facade's SINGLE ownership slot instead of just assigning
1113
- // `companionClient` directly. First caller to register wins:
1114
- // a native host that already called `Everframe.startCompanion()`
1115
- // in this same process (or a concurrent RN start that beat us to
1116
- // it) keeps its client live, and OUR freshly-built one never
1117
- // goes anywhere near a provider seam or `client.start()` — a
1118
- // stale/duplicate RN provider must never be able to clobber
1119
- // whatever the winning caller installed. See
1120
- // `Everframe.__registerCompanionClient`'s own doc comment.
1121
- if (!Everframe.__registerCompanionClient(client)) {
1122
- // Log BEFORE the teardown attempt, not after: this warning is
1123
- // the one observable signal a host gets that its RN start was
1124
- // silently discarded, and it must fire even if `client.stop()`
1125
- // itself throws (DEFE-02 — a never-started client's teardown
1126
- // is best-effort cleanup, not load-bearing for correctness,
1127
- // since it was never registered/started in the first place).
1128
- android.util.Log.w(
1129
- "Everframe.rn",
1130
- "companion already started by the native facade — RN start ignored",
1131
- )
1132
- runCatching { client.stop() }
1133
- return@txGuardVoid
1134
- }
1135
-
1136
- companionClient = client
1137
-
1138
- // Plan 06.2-13 Task 3 — drop any prior bridge providers so a
1139
- // stale closure from a previous startCompanion doesn't double-
1140
- // handle frames against a now-defunct activity. Safe HERE, only
1141
- // after winning the registration race above — until then these
1142
- // providers may belong to someone else entirely.
1143
- CompanionCaptureBridge.__captureProvider = null
1144
- CompanionCaptureBridge.__submitProvider = null
1145
- // Task 11b — same reasoning for the preview-loop and shot-stash
1146
- // seams: null them before installing fresh ones so a stray call
1147
- // between stop and start can never run against a stale Activity.
1148
- CompanionCaptureBridge.__previewProvider = null
1149
- CompanionCaptureBridge.__shotCaptureProvider = null
1150
- companionCaptureStash.values.forEach { it.reportCapture.cancel() }
1151
- companionCaptureStash.clear()
1152
- // Task 6 (naming spec 2026-08-24) — same reasoning for the name
1153
- // badge's activity seam: null before installing fresh, exactly
1154
- // like the removed sharing indicator's own __activityProvider
1155
- // used to be handled here.
1156
- CompanionBadge.__activityProvider = null
1157
-
1158
- installCompanionBridgeProviders()
1159
-
1160
- client.start()
1161
- android.util.Log.i("Everframe.rn", "startCompanion: connected")
1162
- }
1163
- }
1164
-
1165
- @ReactMethod
1166
- override fun stopCompanion() {
1167
- if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
1168
- android.os.Handler(android.os.Looper.getMainLooper()).post { stopCompanion() }
1169
- return
1170
- }
1171
- txGuardVoid("stopCompanion") {
1172
- // External review, finding NN1 — only ever stop/unregister OUR
1173
- // OWN client. `companionClient` is non-null here ONLY when a
1174
- // prior `startCompanion()` on THIS module actually won the
1175
- // registration race above, so reaching this point already means
1176
- // we own the providers cleared below — a native host's own
1177
- // `Everframe.startCompanion()`-registered client (or one still
1178
- // live from a losing/no-op RN start) is untouched:
1179
- // `__unregisterCompanionClient`'s identity check would no-op
1180
- // against it anyway even if `companionClient` somehow pointed
1181
- // at a stale reference, but the early return means we never even
1182
- // ask.
1183
- val client = companionClient ?: return@txGuardVoid
1184
- client.stop()
1185
- Everframe.__unregisterCompanionClient(client)
1186
- companionClient = null
1187
- // Plan 06.2-13 Task 3 — uninstall bridge providers so the
1188
- // singleton can't keep firing against a stale activity.
1189
- CompanionCaptureBridge.__captureProvider = null
1190
- CompanionCaptureBridge.__submitProvider = null
1191
- // Task 11b — a preview/shot provider that outlives its session
1192
- // keeps a stale Activity alive and can capture a screen the
1193
- // session no longer owns; null both alongside the two above.
1194
- CompanionCaptureBridge.__previewProvider = null
1195
- CompanionCaptureBridge.__shotCaptureProvider = null
1196
- companionCaptureStash.values.forEach { it.reportCapture.cancel() }
1197
- companionCaptureStash.clear()
1198
- // Task 6 — a badge activity provider that outlives its session
1199
- // keeps a stale Activity alive for no reason; RelayWSClient.stop()
1200
- // (called just above) already tears down the badge's own
1201
- // subscription/view, this just drops the seam it would have used.
1202
- CompanionBadge.__activityProvider = null
1203
- // unclaimed per-report attribution snapshot goes with it. Any
1204
- // submit already in flight took its token synchronously before
1205
- // its coroutine was dispatched, so this cannot strip attribution
1206
- // from a report that is mid-upload.
1207
- CompanionCaptureBridge.__clearPendingAttribution()
1208
- // Note: in-flight submit coroutines launched by the bridge's
1209
- // own submitScope are NOT cancelled here — they're owned by
1210
- // the bridge object and survive a stop. The next start will
1211
- // install fresh providers; any straggling submit completes
1212
- // (or fails) against the now-disconnected WebSocket and the
1213
- // ws.send(report.completed/failed) becomes a silent drop.
1214
- }
1215
- }
1216
-
1217
- // ---------------- Companion provider install (Plan 06.2-13) ----------------
1218
-
1219
- /**
1220
- * Plan 06.2-13 Task 3+4 — per-correlation_id stash for capture state
1221
- * + drained host attachments. Held on the module (NOT on the bridge
1222
- * singleton) so stopCompanion() can clear it without bridge-internal
1223
- * state coupling. Mirrors the advisor's note: "ownership flipped (RN
1224
- * module owns the bridge install lifecycle)".
1225
- *
1226
- * `ConcurrentHashMap` because the OkHttp WS thread (write at
1227
- * report.request) can race with the bridge's submitScope (read at
1228
- * report.submit).
1229
- */
1230
- private data class CompanionCapture(
1231
- val capture: ScreenshotCapture.CaptureResult,
1232
- val reportCapture: dev.everframe.capture.video.FrozenReportCapture,
1233
- val hostExtra: String?,
1234
- )
1235
-
1236
- private val companionCaptureStash = ConcurrentHashMap<String, CompanionCapture>()
1237
-
1238
- /**
1239
- * Install both `__captureProvider` and `__submitProvider` on the
1240
- * `CompanionCaptureBridge` singleton. Closes over `reactContext` for
1241
- * activity acquisition (RN tracks the foreground activity via
1242
- * `getCurrentActivity()` — same surface `openReporter` uses).
1243
- */
1244
- private fun installCompanionBridgeProviders() {
1245
- // ----- __captureProvider -----
1246
- CompanionCaptureBridge.__captureProvider = { correlationId, reportCapture ->
1247
- val activity = reactContext.currentActivity
1248
- if (activity == null) {
1249
- android.util.Log.w(
1250
- "Everframe.rn",
1251
- "companion captureProvider: no foreground activity; emitting empty assembled",
1252
- )
1253
- emptyAssembledPayload(correlationId)
1254
- } else {
1255
- runCatching {
1256
- // 0. Ask JS to walk the React fiber tree + attach it
1257
- // BEFORE we capture native state, then drain it. JS
1258
- // can no longer attach one (the TurboModule spec
1259
- // method is gone, spec 2026-08-29); the wait survives
1260
- // as the companion's JS-ready handshake. Bounded — we
1261
- // don't block capture forever if JS is non-responsive
1262
- // (DEFE-02).
1263
- awaitJsReactTreeAttach(correlationId)
1264
- // 1. Sensitive-rect walk before capture (mirrors
1265
- // TXReporterPresenter.kt:51-54).
1266
- val rects = SensitiveRectRegistry.collectInWindowCoords(activity)
1267
- // 2. Capture screenshot — suspend, so block on the
1268
- // OkHttp thread. PixelCopy callback is async; the
1269
- // runBlocking is bounded by the PixelCopy timeout
1270
- // inside ScreenshotCapture. Same pattern the
1271
- // in-process modal uses (TXReporterPresenter
1272
- // awaits the suspend directly).
1273
- val capture = runBlocking {
1274
- ScreenshotCapture.captureBeforeReporter(activity, rects)
1275
- }
1276
- if (capture == null) {
1277
- emptyAssembledPayload(correlationId)
1278
- } else {
1279
- // 3. Drain host-attached payload — consume-once
1280
- // semantics, matches the iOS Plan 06.2-13
1281
- // Task 1 timing (request time, NOT submit
1282
- // time). A second back-to-back report sees
1283
- // nil here. `consumePendingAttachments` is
1284
- // `suspend` (spec 2026-09-17 setExtra-resolver —
1285
- // it asks JS for a fresh resolver value before
1286
- // draining); `runBlocking` here is safe for the
1287
- // same reason the screenshot capture above is —
1288
- // this whole provider runs on the OkHttp WS
1289
- // thread, never the UI thread.
1290
- val hostExtraRaw =
1291
- runBlocking { Everframe.consumePendingAttachments() }
1292
- val hostExtra = hostExtraRaw?.takeIf { it.isNotEmpty() }
1293
-
1294
- // Serialize the short stash publication with owner teardown.
1295
- val published = CompanionCaptureBridge.__publishCapture(correlationId, reportCapture) {
1296
- companionCaptureStash.values.forEach { it.reportCapture.cancel() }
1297
- companionCaptureStash.clear()
1298
- companionCaptureStash[correlationId] = CompanionCapture(
1299
- capture = capture,
1300
- reportCapture = reportCapture,
1301
- hostExtra = hostExtra,
1302
- )
1303
- }
1304
- if (!published) {
1305
- reportCapture.cancel()
1306
- return@runCatching null
1307
- }
1308
-
1309
- // 5. Re-encode the screenshot for the relay hop.
1310
- // Bitmap.CompressFormat.WEBP_LOSSY at q=85 cuts
1311
- // typical 1080p screenshot bytes by ~70-80% vs
1312
- // PNG (API 30+); WEBP (legacy, lossy when
1313
- // quality<100) covers API 24-29; JPEG @ q=85 is
1314
- // the universal fallback. The phone consumes
1315
- // via the announced mime — no branching on the
1316
- // receiver side.
1317
- val (relayBytes, relayMime) = reencodeScreenshot(capture.bitmap, capture.pngBytes)
1318
-
1319
- // 6. Build the AssembledPayload (text frame +
1320
- // binary screenshot) for the bridge to ship.
1321
- //
1322
- // NO second binary frame and NO `tree` metadata:
1323
- // tap-to-identify was removed (spec 2026-08-29),
1324
- // so nothing walks, gzips or announces a UI tree.
1325
- // `toggles.uiTree` / `counts.uiTreeNodes` stay on
1326
- // the wire because the generated relay types
1327
- // declare them non-optional, but they are now
1328
- // always false / 0. Announcing a tree the phone
1329
- // would then wait for is the failure mode this
1330
- // guards against — the relay protocol documents
1331
- // the absent case as supported.
1332
- val toggles = ReportAssembledToggles(
1333
- logs = true,
1334
- metadata = true,
1335
- network = true,
1336
- screenshot = true,
1337
- uiTree = false,
1338
- )
1339
- val counts = ReportAssembledCounts(
1340
- breadcrumbs = dev.everframe.capture.sharedBreadcrumbBuffer.size.toLong(),
1341
- logs = dev.everframe.capture.sharedLogBuffer.size().toLong(),
1342
- network = dev.everframe.capture.sharedNetworkBuffer.size().toLong(),
1343
- uiTreeNodes = 0L,
1344
- )
1345
- val assembled = ReportAssembled(
1346
- correlationId = correlationId,
1347
- counts = counts,
1348
- mime = relayMime,
1349
- size = relayBytes.size.toLong(),
1350
- toggles = toggles,
1351
- tree = null,
1352
- )
1353
- CompanionCaptureBridge.AssembledPayload(
1354
- assembled = assembled,
1355
- pngBytes = relayBytes,
1356
- )
1357
- }
1358
- }.getOrElse { t ->
1359
- android.util.Log.w(
1360
- "Everframe.rn",
1361
- "companion captureProvider threw: ${t.javaClass.simpleName}: ${t.message}",
1362
- )
1363
- emptyAssembledPayload(correlationId)
1364
- }
1365
- }
1366
- }
1367
-
1368
- // ----- __submitProvider -----
1369
- // Suspend lambda — the bridge's own submitScope handles dispatch.
1370
- // `companionAttribution` is the token the relay minted for THIS
1371
- // report's `report.request`, snapshotted by RelayWSClient at that
1372
- // moment and handed back here by the bridge (PR-fix 1). It is passed
1373
- // straight into Inputs and never re-derived from the live session:
1374
- // this closure survives stopCompanion() and can still be composing
1375
- // when a different dashboard user attaches.
1376
- // SECURITY: never log it.
1377
- CompanionCaptureBridge.__submitProvider = submit@{
1378
- submit: ReportSubmit,
1379
- bakedPng: ByteArray,
1380
- extraShotPngs: List<ByteArray>,
1381
- companionAttribution: String?,
1382
- reportCapture: dev.everframe.capture.video.FrozenReportCapture,
1383
- capturedSession: dev.everframe.TXCapturedSession
1384
- ->
1385
- val activity = reactContext.currentActivity
1386
- if (activity == null) {
1387
- reportCapture.cancel()
1388
- return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("no_activity") }
1389
- }
1390
- val stash = companionCaptureStash[submit.correlationId]
1391
- ?: run { reportCapture.cancel(); return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("no_capture") } }
1392
-
1393
- if (stash.reportCapture !== reportCapture) {
1394
- reportCapture.cancel()
1395
- return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("capture_mismatch") }
1396
- }
1397
- if (!companionCaptureStash.remove(submit.correlationId, stash)) {
1398
- reportCapture.cancel()
1399
- return@submit suspend { CompanionCaptureBridge.SubmitResult.Err("no_capture") }
1400
- }
1401
- // Everything below may be expensive; captured references already belong to this submit.
1402
- suspend work@{
1403
- // Decode baked PNG → Bitmap. If the phone sent corrupt bytes
1404
- // we fall back to the original capture bitmap so the user's
1405
- // annotations are lost but the report still ships (DEFE-02).
1406
- val bakedBitmap: android.graphics.Bitmap = try {
1407
- android.graphics.BitmapFactory.decodeByteArray(
1408
- bakedPng, 0, bakedPng.size,
1409
- ) ?: stash.capture.bitmap
1410
- } catch (t: Throwable) {
1411
- stash.capture.bitmap
1412
- }
1413
-
1414
- // Tap-to-identify was removed (spec 2026-08-29). A phone running
1415
- // an older reporter SPA may still send `submit.reportTarget`; it
1416
- // is ignored rather than rejected, and nothing is resolved from it.
1417
-
1418
- val inputs = CompanionSubmissionComposer.Inputs(
1419
- activity = activity,
1420
- captureBitmap = bakedBitmap,
1421
- capture = reportCapture,
1422
- // The phone's extra shots, already baked on its side and
1423
- // delivered behind `shot.binary` markers. Passed through
1424
- // verbatim: the composer names them `screenshot-N`.
1425
- extraShotPngs = extraShotPngs,
1426
- title = submit.title,
1427
- description = submit.description.text,
1428
- // Description-redactions integration deferred (matches iOS
1429
- // Plan 12 — reporter SPA sends redactions:[] always).
1430
- includeLogs = submit.includes.logs,
1431
- includeNetwork = submit.includes.network,
1432
- includeMetadata = submit.includes.metadata,
1433
- hostExtra = stash.hostExtra,
1434
- companionAttribution = companionAttribution,
1435
- // Pinned to the snapshot taken at the submit boundary above.
1436
- capturedSession = capturedSession,
1437
- )
1438
-
1439
- val result = try {
1440
- CompanionSubmissionComposer.submit(inputs)
1441
- } catch (t: Throwable) {
1442
- android.util.Log.w(
1443
- "Everframe.rn",
1444
- "companion submitProvider threw: ${t.javaClass.simpleName}: ${t.message}",
1445
- )
1446
- return@work CompanionCaptureBridge.SubmitResult.Err("ingest_error")
1447
- }
1448
-
1449
- when (result) {
1450
- is ReportResult.Submitted ->
1451
- CompanionCaptureBridge.SubmitResult.Ok(result.reportId.toString())
1452
- is ReportResult.Queued ->
1453
- CompanionCaptureBridge.SubmitResult.Ok(result.reportId.toString())
1454
- is ReportResult.Cancelled ->
1455
- CompanionCaptureBridge.SubmitResult.Err(result.reason)
1456
- }
1457
- }
1458
- }
1459
-
1460
- // ----- __previewProvider (Task 11b) -----
1461
- // The 2fps preview loop's seam — low-res JPEG for the live-preview
1462
- // stream (~854px longest edge, quality ~60, see
1463
- // ScreenshotCapture.capturePreviewFrame's own defaults). Resolves
1464
- // the current Activity exactly like __captureProvider above; no
1465
- // foreground Activity (or a zero-size window) degrades to null,
1466
- // which CompanionPreviewSession's loop maps to `capture_unavailable`
1467
- // and stops — never a crash, never a throw (DEFE-02).
1468
- CompanionCaptureBridge.__previewProvider = previewProvider@{
1469
- val activity = reactContext.currentActivity ?: return@previewProvider null
1470
- val rects = SensitiveRectRegistry.collectInWindowCoords(activity)
1471
- ScreenshotCapture.capturePreviewFrame(activity, rects)
1472
- }
1473
-
1474
- // ----- __shotCaptureProvider (Task 11b) -----
1475
- // The shot-stash seam — `shot.request`'s contract is "capture fresh
1476
- // now, at REPORT grade", which a preview-grade frame does not satisfy.
1477
- // `captureBeforeReporter` is reused rather than duplicated, so a shot
1478
- // is shaped exactly like the report's own screenshot: PNG, and
1479
- // downscaled to `ScreenshotCapture.MAX_EDGE_PX` (2048) on the longest
1480
- // edge. `mime` reflects the actual encode instead of asserting a
1481
- // format the bytes don't carry.
1482
- //
1483
- // Task 11b review, finding 4: this comment used to claim "full
1484
- // resolution" three times over while routing through a 2048-capped
1485
- // path. The cap is deliberate and stays — a shot is evidence attached
1486
- // to a report, and matching the primary screenshot's shaping is the
1487
- // point — but the claim was simply false, and the next person to port
1488
- // this (iOS) would have built an uncapped path to satisfy it.
1489
- CompanionCaptureBridge.__shotCaptureProvider = shotProvider@{
1490
- val activity = reactContext.currentActivity ?: return@shotProvider null
1491
- val rects = SensitiveRectRegistry.collectInWindowCoords(activity)
1492
- val capture = ScreenshotCapture.captureBeforeReporter(activity, rects)
1493
- ?: return@shotProvider null
1494
- // Task 11b review, finding 2: `CaptureResult` hands us ownership of
1495
- // this bitmap and only `pngBytes` travels onward, so it is released
1496
- // here rather than left for the GC — ~7.5 MB per shot otherwise.
1497
- capture.bitmap.recycle()
1498
- PreviewCapture(
1499
- bytes = capture.pngBytes,
1500
- width = capture.widthPx,
1501
- height = capture.heightPx,
1502
- mime = "image/png",
1503
- )
1504
- }
1505
-
1506
- // ----- CompanionBadge.__activityProvider (Task 6, naming spec 2026-08-24) -----
1507
- // Same seam, same reasoning as every provider above: `:everframe-core`
1508
- // is a plain AAR and cannot resolve a foreground Activity on its own.
1509
- // Mirrors the removed `CompanionSharingIndicator.__activityProvider`
1510
- // install site this task's brief pointed at — resolved fresh on every
1511
- // call (never cached), exactly like `reactContext.currentActivity`
1512
- // is read fresh above for capture/preview/shot.
1513
- CompanionBadge.__activityProvider = { reactContext.currentActivity }
1514
- }
1515
-
1516
- /**
1517
- * Helper — minimal AssembledPayload for the degraded-path. The bridge
1518
- * accepts a payload with empty pngBytes; the phone reporter SPA shows
1519
- * "0 bytes" in the assembled state and the user can still tap Send.
1520
- */
1521
- private fun emptyAssembledPayload(correlationId: String): CompanionCaptureBridge.AssembledPayload {
1522
- val toggles = ReportAssembledToggles(
1523
- logs = false, metadata = true, network = false, screenshot = false, uiTree = false,
1524
- )
1525
- val counts = ReportAssembledCounts(breadcrumbs = null, logs = 0L, network = 0L, uiTreeNodes = 0L)
1526
- val assembled = ReportAssembled(
1527
- correlationId = correlationId,
1528
- counts = counts,
1529
- mime = "image/png",
1530
- size = 0L,
1531
- toggles = toggles,
1532
- )
1533
- return CompanionCaptureBridge.AssembledPayload(
1534
- assembled = assembled,
1535
- pngBytes = ByteArray(0),
1536
- )
1537
- }
1538
-
1539
- /**
1540
- * Re-encode the captured screenshot for the relay hop. Drops the
1541
- * envelope PNG fidelity (we keep `capture.pngBytes` separately for the
1542
- * eventual submit) in exchange for ~60-80% byte reduction on the
1543
- * phone-bound binary frame.
1544
- *
1545
- * Format preference, in order:
1546
- * 1. WEBP_LOSSY @ q=85 — API 30+; ~70-80% byte cut, no visible loss
1547
- * for screenshot-class images.
1548
- * 2. WEBP (legacy enum, behaves as lossy when quality < 100) —
1549
- * API 24-29. Same wire mime; encoder is older but bytes still
1550
- * decode on every phone.
1551
- * 3. JPEG @ q=85 — universal fallback, ~50-60% byte cut.
1552
- *
1553
- * Returns the original PNG bytes unchanged when every encoder fails
1554
- * (DEFE-02: don't block the report flow on a re-encode failure).
1555
- */
1556
- private fun reencodeScreenshot(
1557
- bitmap: android.graphics.Bitmap,
1558
- fallbackPng: ByteArray,
1559
- ): Pair<ByteArray, String> {
1560
- // Try WEBP_LOSSY (API 30+), then legacy WEBP, then JPEG.
1561
- if (android.os.Build.VERSION.SDK_INT >= 30) {
1562
- val webp = runCatching {
1563
- val out = java.io.ByteArrayOutputStream()
1564
- @Suppress("DEPRECATION_ERROR")
1565
- // CompressFormat.WEBP_LOSSY introduced in API 30.
1566
- val ok = bitmap.compress(
1567
- android.graphics.Bitmap.CompressFormat.WEBP_LOSSY, 85, out,
1568
- )
1569
- if (ok) out.toByteArray() else null
1570
- }.getOrNull()
1571
- if (webp != null && webp.size < fallbackPng.size) return Pair(webp, "image/webp")
1572
- }
1573
- val legacyWebp = runCatching {
1574
- val out = java.io.ByteArrayOutputStream()
1575
- @Suppress("DEPRECATION")
1576
- val ok = bitmap.compress(android.graphics.Bitmap.CompressFormat.WEBP, 85, out)
1577
- if (ok) out.toByteArray() else null
1578
- }.getOrNull()
1579
- if (legacyWebp != null && legacyWebp.size < fallbackPng.size) {
1580
- return Pair(legacyWebp, "image/webp")
1581
- }
1582
- val jpeg = runCatching {
1583
- val out = java.io.ByteArrayOutputStream()
1584
- val ok = bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 85, out)
1585
- if (ok) out.toByteArray() else null
1586
- }.getOrNull()
1587
- if (jpeg != null && jpeg.size < fallbackPng.size) return Pair(jpeg, "image/jpeg")
1588
- return Pair(fallbackPng, "image/png")
1589
- }
1590
-
1591
- /** Test seam — assert provider install/uninstall via the bridge. */
1592
- internal fun hasCompanionProvidersInstalledForTesting(): Boolean =
1593
- CompanionCaptureBridge.__captureProvider != null &&
1594
- CompanionCaptureBridge.__submitProvider != null
1595
-
1596
777
  @ReactMethod
1597
778
  override fun addListener(eventName: String) {
1598
779
  // No-op — RN NativeEventEmitter contract. RCTDeviceEventEmitter on
1599
780
  // the JS side handles subscriber bookkeeping internally; we only
1600
781
  // need to ensure the method is declared so RN's warning suppression
1601
- // is satisfied. State/pairUrl collectors are installed on first
1602
- // startCompanion(), not on first addListener().
782
+ // is satisfied.
1603
783
  }
1604
784
 
1605
785
  @ReactMethod
@@ -1607,46 +787,6 @@ class EverframeModule(
1607
787
  // No-op — see addListener() comment.
1608
788
  }
1609
789
 
1610
- /**
1611
- * Called by the JS companion module from the `everframe.companion
1612
- * .reportRequested` event handler once it is ready for capture to
1613
- * proceed. Releases the `awaitJsReactTreeAttach` waiter for this correlation id so the
1614
- * capture provider can proceed. Idempotent — late signals after the
1615
- * timeout fired are harmless.
1616
- */
1617
- @ReactMethod
1618
- override fun signalCompanionReportRequestReady(correlationId: String) {
1619
- pendingReactTreeAttachSignals.remove(correlationId)?.complete(Unit)
1620
- }
1621
-
1622
- /** Per-correlation-id signals released by `signalCompanionReportRequestReady`. */
1623
- private val pendingReactTreeAttachSignals =
1624
- ConcurrentHashMap<String, kotlinx.coroutines.CompletableDeferred<Unit>>()
1625
-
1626
- /**
1627
- * Emit `everframe.companion.reportRequested` then block up to 250ms
1628
- * waiting for JS to call back via `signalCompanionReportRequestReady`.
1629
- * The wait is bounded so a non-responsive JS thread can't stall the
1630
- * native capture path indefinitely (DEFE-02). Runs on the OkHttp WS
1631
- * thread — the surrounding `runCatching` block in the capture provider
1632
- * absorbs any throw.
1633
- *
1634
- * Returns silently when:
1635
- * - JS signals ready (fast path, ~5-50ms on a healthy app)
1636
- * - The 250ms timeout elapses (slow path)
1637
- */
1638
- private fun awaitJsReactTreeAttach(correlationId: String) {
1639
- val signal = kotlinx.coroutines.CompletableDeferred<Unit>()
1640
- pendingReactTreeAttachSignals[correlationId] = signal
1641
- val emitter = reactApplicationContext
1642
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1643
- emitter.emit(COMPANION_REPORT_REQUESTED_EVENT, correlationId)
1644
- runBlocking {
1645
- kotlinx.coroutines.withTimeoutOrNull(250) { signal.await() }
1646
- }
1647
- pendingReactTreeAttachSignals.remove(correlationId)
1648
- }
1649
-
1650
790
  // ---------------- Extra-resolver ask-and-wait (spec 2026-09-17 setExtra-resolver) ----------------
1651
791
 
1652
792
  /**
@@ -1655,8 +795,7 @@ class EverframeModule(
1655
795
  * resolver is registered — pushed a freshly resolved value through the
1656
796
  * ordinary `setExtra` method above. Releases `awaitJsExtraResolve`'s
1657
797
  * bounded wait for this correlation id. Idempotent — a late signal
1658
- * after the 250ms timeout already fired is a harmless no-op, exactly
1659
- * like `signalCompanionReportRequestReady` above.
798
+ * after the 250ms timeout already fired is a harmless no-op.
1660
799
  */
1661
800
  @ReactMethod
1662
801
  override fun signalExtraResolverReady(correlationId: String) {
@@ -1677,10 +816,9 @@ class EverframeModule(
1677
816
  * before this feature landed (requirement: "no behaviour change
1678
817
  * whatsoever").
1679
818
  *
1680
- * Otherwise mirrors `awaitJsReactTreeAttach` exactly — emit
1681
- * `everframe.extra.resolveRequested`, then suspend up to 250ms for JS's
1682
- * `signalExtraResolverReady` ack. `suspend`, unlike
1683
- * `awaitJsReactTreeAttach`: this hook's caller
819
+ * Otherwise emit `everframe.extra.resolveRequested`, then suspend up to
820
+ * 250ms for JS's `signalExtraResolverReady` ack. `suspend`: this hook's
821
+ * caller
1684
822
  * (`Everframe.consumePendingAttachments()`) may run on Android's Main
1685
823
  * dispatcher (RN's `openReporter()`/native shake both funnel through
1686
824
  * `Everframe.report.open()` there), so a real suspend point is required
@@ -1702,90 +840,6 @@ class EverframeModule(
1702
840
  }
1703
841
  }
1704
842
 
1705
- /**
1706
- * Install StateFlow collectors on `Everframe.companion.state`, `.pairUrl`,
1707
- * `.code` and `.attachedUserName` that emit the matching
1708
- * `everframe.companion.*` device events. Idempotent — the
1709
- * `companionCollectorsInstalled` latch survives stop/start cycles because
1710
- * the underlying StateFlows on `Everframe.companion` outlive the
1711
- * RelayWSClient.
1712
- */
1713
- private fun installCompanionCollectorsIfNeeded() {
1714
- if (companionCollectorsInstalled) return
1715
- companionCollectorsInstalled = true
1716
-
1717
- val emitter = reactApplicationContext
1718
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1719
-
1720
- companionStateJob = companionScope.launch {
1721
- Everframe.companion.state.collect { state ->
1722
- android.util.Log.i("Everframe.companion", "emit state=$state")
1723
- emitter.emit(COMPANION_STATE_EVENT, state.toRnString())
1724
- }
1725
- }
1726
- companionPairUrlJob = companionScope.launch {
1727
- Everframe.companion.pairUrl.collect { url ->
1728
- android.util.Log.i("Everframe.companion", "emit pairUrl=${if (url == null) "null" else "set"}")
1729
- // Both null and String cross to JS cleanly via the
1730
- // DeviceEventManager — null becomes `null` on the JS side.
1731
- emitter.emit(COMPANION_PAIR_URL_EVENT, url)
1732
- }
1733
- }
1734
- // Companion discovery (spec 2026-08-07). Same install-once lifecycle as
1735
- // the two above, and for the same reason: `Everframe.companion.code`
1736
- // is a StateFlow that outlives the RelayWSClient, so a collector
1737
- // installed on the FIRST start replays the current value and keeps
1738
- // delivering across stop/start cycles. Re-installing per start would
1739
- // race the initial emission and lose it.
1740
- companionCodeJob = companionScope.launch {
1741
- Everframe.companion.code.collect { code ->
1742
- android.util.Log.i("Everframe.companion", "emit code=${if (code == null) "null" else "set"}")
1743
- emitter.emit(COMPANION_CODE_EVENT, code)
1744
- }
1745
- }
1746
- companionAttachedUserNameJob = companionScope.launch {
1747
- Everframe.companion.attachedUserName.collect { name ->
1748
- // The NAME itself is not logged — it is a dashboard member's
1749
- // display name, and this log line goes to logcat.
1750
- android.util.Log.i(
1751
- "Everframe.companion",
1752
- "emit attachedUserName=${if (name == null) "null" else "set"}",
1753
- )
1754
- emitter.emit(COMPANION_ATTACHED_USER_NAME_EVENT, name)
1755
- }
1756
- }
1757
- // Resolved device display name (naming spec 2026-08-24). Same
1758
- // install-once lifecycle as the collectors above, and for the same
1759
- // reason: the StateFlow outlives the RelayWSClient, so a collector
1760
- // installed on the FIRST start replays the current value across
1761
- // stop/start cycles.
1762
- companionResolvedNameJob = companionScope.launch {
1763
- Everframe.companion.resolvedName.collect { name ->
1764
- android.util.Log.i(
1765
- "Everframe.companion",
1766
- "emit resolvedName=${if (name == null) "null" else "set"}",
1767
- )
1768
- emitter.emit(COMPANION_RESOLVED_NAME_EVENT, name)
1769
- }
1770
- }
1771
- // Attach-PIN challenge (spec 2026-08-19). Same install-once lifecycle
1772
- // as the collectors above, and for the same reason: the StateFlow
1773
- // outlives the RelayWSClient, so a collector installed on the FIRST
1774
- // start replays the current value across stop/start cycles. Payload
1775
- // conversion lives in `AttachChallengeInfo.toWritableMap()` below so
1776
- // this collector body stays as short as its siblings.
1777
- companionAttachChallengeJob = companionScope.launch {
1778
- Everframe.companion.attachChallenge.collect { challenge ->
1779
- // SECURITY: the PIN code itself is not logged.
1780
- android.util.Log.i(
1781
- "Everframe.companion",
1782
- "emit attachChallenge=${if (challenge == null) "null" else "set"}",
1783
- )
1784
- emitter.emit(COMPANION_ATTACH_CHALLENGE_EVENT, challenge?.toWritableMap())
1785
- }
1786
- }
1787
- }
1788
-
1789
843
  // ---------------- Helpers ----------------
1790
844
 
1791
845
 
@@ -1818,7 +872,7 @@ class EverframeModule(
1818
872
  }
1819
873
  }
1820
874
 
1821
- // rewriteEmulatorLoopback removed — JS-side `apiBase` / `startCompanion`
1822
- // endpoint args are no longer plumbed through. The 10.0.2.2 emulator
875
+ // rewriteEmulatorLoopback removed — JS-side `apiBase` endpoint args are
876
+ // no longer plumbed through. The 10.0.2.2 emulator
1823
877
  // alias is baked into the debug-variant BuildConfig.INGEST_URL fallback.
1824
878
  }