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