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