@obsrviq/react-native 0.3.1

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.
@@ -0,0 +1,530 @@
1
+ package app.lumera.replay
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Rect
8
+ import android.os.Build
9
+ import android.app.Activity
10
+ import android.os.Handler
11
+ import android.os.HandlerThread
12
+ import android.util.Base64
13
+ import android.view.MotionEvent
14
+ import android.view.PixelCopy
15
+ import android.view.View
16
+ import android.view.ViewGroup
17
+ import android.view.ViewTreeObserver
18
+ import android.view.Window
19
+ import android.widget.EditText
20
+ import android.widget.ImageView
21
+ import android.widget.TextView
22
+ import com.facebook.react.bridge.Promise
23
+ import com.facebook.react.bridge.ReactApplicationContext
24
+ import org.json.JSONArray
25
+ import org.json.JSONObject
26
+ import java.io.OutputStream
27
+ import java.net.HttpURLConnection
28
+ import java.net.URL
29
+ import java.util.UUID
30
+ import java.util.concurrent.atomic.AtomicBoolean
31
+
32
+ /**
33
+ * Lumera native screenshot-replay capture engine (Android).
34
+ *
35
+ * THREADING CONTRACT:
36
+ * • UI thread → only schedules a PixelCopy (which is async) + a cheap mask-rect walk.
37
+ * • bg thread → PixelCopy callback lands here (its Handler), then mask + JPEG + UPLOAD.
38
+ * • change-driven via ViewTreeObserver.OnDrawListener, throttled to `fps`, overlapping DROPPED.
39
+ *
40
+ * Uses PixelCopy (API 26+, async, captures GPU/SurfaceView) — NOT View.draw(Canvas),
41
+ * which misses hardware-accelerated content. One reused RGB_565 bitmap. No pixels
42
+ * cross the RN bridge: this engine uploads frames itself to `${endpoint}/v1/batch`.
43
+ *
44
+ * NOTE: extends the codegen'd `NativeLumeraReplaySpec` (New Arch). The
45
+ * `LumeraReplayPackage` registers it as the TurboModule named "LumeraReplay".
46
+ */
47
+ class LumeraReplayModule(private val reactCtx: ReactApplicationContext) :
48
+ NativeLumeraReplaySpec(reactCtx) {
49
+
50
+ // Dedicated background thread for ALL post-capture work (PixelCopy callback + encode + upload).
51
+ private val bgThread = HandlerThread("lumera-replay").apply { start() }
52
+ private val bg = Handler(bgThread.looper)
53
+
54
+ private var opts: ReadableStartOptions? = null
55
+ private val inFlight = AtomicBoolean(false) // drop overlapping captures
56
+ private var reuseBitmap: Bitmap? = null
57
+ // Buffered, fully-built `screen` event JSON objects awaiting the next flush.
58
+ private val pending = ArrayList<JSONObject>()
59
+ private var drawListener: ViewTreeObserver.OnDrawListener? = null
60
+ private var lastCaptureAt = 0L
61
+
62
+ // Touch capture: buffered `touch` events + the original Window.Callback we wrap (non-consuming).
63
+ private val pendingTouch = ArrayList<JSONObject>()
64
+ private var origCallback: Window.Callback? = null
65
+ private var lastTouchMoveAt = 0L
66
+
67
+ // Native's OWN monotonic batch counter (independent of the JS structured-event stream).
68
+ // Starts at 0, ++ only on a successful flush, so the worker can order screen chunks.
69
+ private var seq = 0
70
+
71
+ // Reused gray fill for redaction blocks (composited on `bg`).
72
+ private val maskPaint = Paint().apply {
73
+ color = Color.rgb(0x9E, 0x9E, 0x9E) // neutral material gray
74
+ style = Paint.Style.FILL
75
+ isAntiAlias = true
76
+ }
77
+
78
+ // Diagnostics
79
+ private var framesCaptured = 0
80
+ private var framesSent = 0
81
+ private var framesDropped = 0
82
+ private var bytesSent = 0L
83
+
84
+ override fun getName() = "LumeraReplay"
85
+
86
+ // MARK: - Lifecycle
87
+
88
+ override fun start(options: com.facebook.react.bridge.ReadableMap) {
89
+ opts = ReadableStartOptions(options)
90
+ // The Activity may not be attached yet at init() time — the async JS bootstrap can
91
+ // call start() before onResume. Retry briefly instead of bailing forever.
92
+ attachWhenReady(0)
93
+ scheduleFlush()
94
+ }
95
+
96
+ private fun attachWhenReady(attempt: Int) {
97
+ val activity = reactCtx.currentActivity
98
+ if (activity == null) {
99
+ if (attempt < 20) bg.postDelayed({ attachWhenReady(attempt + 1) }, 250)
100
+ return
101
+ }
102
+ activity.runOnUiThread {
103
+ if (drawListener != null) return@runOnUiThread
104
+ val decor = activity.window?.decorView ?: return@runOnUiThread
105
+ // Change-driven: OnDrawListener fires only when the UI actually draws; throttled to fps.
106
+ val l = ViewTreeObserver.OnDrawListener { maybeCapture() }
107
+ decor.viewTreeObserver.addOnDrawListener(l)
108
+ drawListener = l
109
+ if (opts?.captureTouches == true) installTouchCallback(activity)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Observe taps/swipes WITHOUT consuming them: wrap the window's Callback and intercept
115
+ * `dispatchTouchEvent` (the single chokepoint every touch flows through before the view
116
+ * tree sees it), record the gesture, then delegate to the original so the app behaves
117
+ * normally. Kotlin interface-delegation (`by base`) forwards every other Callback method.
118
+ * Runs on the UI thread; recordTouch only builds a small JSON object, no raster.
119
+ */
120
+ private fun installTouchCallback(activity: Activity) {
121
+ if (origCallback != null) return
122
+ val window = activity.window ?: return
123
+ val base = window.callback ?: return
124
+ origCallback = base
125
+ val density = activity.resources.displayMetrics.density
126
+ window.callback = TouchCallback(base) { ev -> recordTouch(ev, density) }
127
+ }
128
+
129
+ private fun removeTouchCallback(activity: Activity?) {
130
+ val window = activity?.window ?: return
131
+ val orig = origCallback ?: return
132
+ // Only restore if our wrapper is still the active callback (don't clobber a later wrapper).
133
+ if (window.callback is TouchCallback) window.callback = orig
134
+ origCallback = null
135
+ }
136
+
137
+ /** Map a MotionEvent into a buffered `touch` event in logical dp coords (move throttled to 50ms). */
138
+ private fun recordTouch(e: MotionEvent, density: Float) {
139
+ val o = opts ?: return
140
+ if (!o.captureTouches) return
141
+ val phase = when (e.actionMasked) {
142
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> "start"
143
+ MotionEvent.ACTION_MOVE -> "move"
144
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> "end"
145
+ MotionEvent.ACTION_CANCEL -> "cancel"
146
+ else -> return
147
+ }
148
+ val now = System.currentTimeMillis()
149
+ if (phase == "move") {
150
+ if (now - lastTouchMoveAt < 50) return
151
+ lastTouchMoveAt = now
152
+ }
153
+ val d = if (density > 0f) density else 1f
154
+ val points = JSONArray()
155
+ for (i in 0 until e.pointerCount) {
156
+ points.put(JSONObject().apply {
157
+ put("x", Math.round(e.getX(i) / d))
158
+ put("y", Math.round(e.getY(i) / d))
159
+ })
160
+ }
161
+ val ev = JSONObject().apply {
162
+ put("id", UUID.randomUUID().toString())
163
+ put("sessionId", o.sessionId)
164
+ put("type", "touch")
165
+ put("t", (now - o.startedAtMs).toInt())
166
+ put("ts", now)
167
+ put("phase", phase)
168
+ put("points", points)
169
+ }
170
+ synchronized(pendingTouch) {
171
+ pendingTouch.add(ev)
172
+ if (pendingTouch.size > 600) pendingTouch.removeAt(0) // bound memory if upload stalls
173
+ }
174
+ }
175
+
176
+ override fun stop() {
177
+ val activity = reactCtx.currentActivity
178
+ activity?.runOnUiThread {
179
+ drawListener?.let { activity.window?.decorView?.viewTreeObserver?.removeOnDrawListener(it) }
180
+ drawListener = null
181
+ removeTouchCallback(activity)
182
+ }
183
+ bg.post { flush() }
184
+ }
185
+
186
+ override fun configure(options: com.facebook.react.bridge.ReadableMap) {
187
+ opts?.merge(options)
188
+ }
189
+
190
+ override fun setViewMasked(reactTag: Double, masked: Boolean) {
191
+ if (masked) maskTag(reactTag.toInt()) else unmaskTag(reactTag.toInt())
192
+ }
193
+
194
+ override fun getDiagnostics(promise: Promise) {
195
+ val map = com.facebook.react.bridge.Arguments.createMap()
196
+ map.putInt("framesCaptured", framesCaptured)
197
+ map.putInt("framesSent", framesSent)
198
+ map.putInt("framesDropped", framesDropped)
199
+ map.putDouble("bytesSent", bytesSent.toDouble())
200
+ promise.resolve(map)
201
+ }
202
+
203
+ // MARK: - Capture (UI thread schedules; PixelCopy is async → bg)
204
+
205
+ private fun maybeCapture() {
206
+ val o = opts ?: return
207
+ val now = System.currentTimeMillis()
208
+ val minIntervalMs = (1000.0 / o.fps).toLong()
209
+ if (now - lastCaptureAt < minIntervalMs) return // throttle to fps
210
+ if (!inFlight.compareAndSet(false, true)) { framesDropped++; return } // drop overlapping
211
+
212
+ val activity = reactCtx.currentActivity
213
+ val window = activity?.window
214
+ val decor = window?.decorView
215
+ if (window == null || decor == null || Build.VERSION.SDK_INT < 26) { inFlight.set(false); return }
216
+
217
+ // Change-driven already: OnDrawListener only fires when the UI actually draws, and
218
+ // we throttle to `fps` above. (No content token — decorView identity + size are
219
+ // constant for the session, so any such token would freeze capture after frame 1.)
220
+ lastCaptureAt = now
221
+
222
+ // Collect redaction rects on the UI thread (geometry needs the UI thread; no raster here).
223
+ val masks = collectMaskRects(decor, o)
224
+
225
+ val w = decor.width; val h = decor.height
226
+ if (w <= 0 || h <= 0) { inFlight.set(false); return }
227
+ val bmp = reuseBitmap?.takeIf { it.width == w && it.height == h }
228
+ ?: Bitmap.createBitmap(w, h, Bitmap.Config.RGB_565).also { reuseBitmap = it }
229
+
230
+ // Logical (dp) screen size — independent of the captured pixel raster.
231
+ val density = decor.resources.displayMetrics.density
232
+ val scale = if (density > 0f) Math.round(density) else 1
233
+ val screenW = if (scale > 0) Math.round(w / density) else w
234
+ val screenH = if (scale > 0) Math.round(h / density) else h
235
+
236
+ val ts = now
237
+ val tRel = (now - o.startedAtMs).toInt()
238
+ // PixelCopy is async; its callback fires on `bg` — UI thread is now free.
239
+ PixelCopy.request(window, bmp, { result ->
240
+ if (result == PixelCopy.SUCCESS) {
241
+ framesCaptured++
242
+ process(bmp, masks, w, h, screenW, screenH, scale, tRel, ts, o)
243
+ }
244
+ inFlight.set(false)
245
+ }, bg)
246
+ }
247
+
248
+ // MARK: - Post-capture pipeline (bg thread)
249
+
250
+ private fun process(
251
+ bmp: Bitmap,
252
+ masks: List<Rect>,
253
+ w: Int,
254
+ h: Int,
255
+ screenW: Int,
256
+ screenH: Int,
257
+ scale: Int,
258
+ tRel: Int,
259
+ ts: Long,
260
+ o: ReadableStartOptions,
261
+ ) {
262
+ // ① Mask compositing onto the captured bitmap (never a second capture).
263
+ // Rounded gray blocks over each redaction rect — drawn on `bg`, not the UI thread.
264
+ if (masks.isNotEmpty()) {
265
+ val canvas = Canvas(bmp)
266
+ val radius = 8f * (if (scale > 0) scale else 1)
267
+ for (r in masks) {
268
+ if (r.width() <= 0 || r.height() <= 0) continue
269
+ canvas.drawRoundRect(
270
+ r.left.toFloat(), r.top.toFloat(), r.right.toFloat(), r.bottom.toFloat(),
271
+ radius, radius, maskPaint,
272
+ )
273
+ }
274
+ }
275
+
276
+ // ② Downscale (THE storage/bandwidth lever) — cap the longer edge at maxCaptureDim.
277
+ // Full native res (~1080×2400) is ~4× the bytes for no replay benefit. Masks were
278
+ // composited above at full res, so they scale down cleanly with the frame. Bilinear
279
+ // (filter=true). At ~1fps the per-frame alloc is negligible; recycle the temp after.
280
+ val longEdge = maxOf(w, h)
281
+ val downscale = o.maxCaptureDim > 0 && longEdge > o.maxCaptureDim
282
+ val outBmp: Bitmap
283
+ val outW: Int
284
+ val outH: Int
285
+ if (downscale) {
286
+ val s = o.maxCaptureDim.toDouble() / longEdge
287
+ outW = Math.max(1, Math.round(w * s).toInt())
288
+ outH = Math.max(1, Math.round(h * s).toInt())
289
+ outBmp = Bitmap.createScaledBitmap(bmp, outW, outH, true)
290
+ } else {
291
+ outW = w; outH = h; outBmp = bmp
292
+ }
293
+ // Effective pixel/logical ratio after downscale (player maps by screenW/H; this is metadata).
294
+ val effScale = if (screenW > 0) outW.toDouble() / screenW else scale.toDouble()
295
+
296
+ // ③ JPEG compress off the UI thread.
297
+ val out = java.io.ByteArrayOutputStream()
298
+ outBmp.compress(Bitmap.CompressFormat.JPEG, (o.jpegQuality * 100).toInt(), out)
299
+ if (downscale) outBmp.recycle() // temp scaled bitmap; the reused full-res bmp lives on
300
+ val jpeg = out.toByteArray()
301
+ val img = Base64.encodeToString(jpeg, Base64.NO_WRAP)
302
+
303
+ // ④ Build the `screen` event (BaseEvent + MobileScreenEvent) and buffer for flush.
304
+ val event = JSONObject().apply {
305
+ put("id", UUID.randomUUID().toString())
306
+ put("sessionId", o.sessionId)
307
+ put("type", "screen")
308
+ put("t", tRel)
309
+ put("ts", ts)
310
+ put("img", img)
311
+ put("format", "jpeg")
312
+ put("w", outW) // encoded (possibly downscaled) pixel size
313
+ put("h", outH)
314
+ put("screenW", screenW) // logical dp — unchanged by downscale
315
+ put("screenH", screenH)
316
+ put("scale", effScale)
317
+ put("full", true)
318
+ }
319
+ synchronized(pending) { pending.add(event) }
320
+ }
321
+
322
+ /**
323
+ * Walk the decorView on the UI thread → redaction rects (in captured-pixel space) for
324
+ * masked nodes: TextView/EditText when [ReadableStartOptions.maskAllText], ImageView when
325
+ * [ReadableStartOptions.maskAllImages]. A view whose id (RN reactTag) is in [maskedTags]
326
+ * — set via `setViewMasked(tag, true)` or `<LumeraMask>` — is force-masked along with its
327
+ * whole subtree (one block), regardless of mask-all. Rect = getLocationInWindow + width/height.
328
+ *
329
+ * Honoring per-view UN-masking under mask-all (force-unmask) would need a second, explicit
330
+ * "unmasked" set since Android can't distinguish that from "no override"; not implemented.
331
+ */
332
+ private fun collectMaskRects(root: View, o: ReadableStartOptions): List<Rect> {
333
+ val rects = ArrayList<Rect>()
334
+ val loc = IntArray(2)
335
+ // decorView's own window offset — subtract so rects are relative to the captured bitmap.
336
+ root.getLocationInWindow(loc)
337
+ val originX = loc[0]
338
+ val originY = loc[1]
339
+
340
+ fun rectFor(v: View): Rect {
341
+ v.getLocationInWindow(loc)
342
+ val left = loc[0] - originX
343
+ val top = loc[1] - originY
344
+ return Rect(left, top, left + v.width, top + v.height)
345
+ }
346
+
347
+ fun explicitState(v: View): Boolean? {
348
+ // null = no explicit override; true = force-masked.
349
+ // RN reactTags are surfaced as the host View.id on Android. We can only test for
350
+ // presence in the masked set; an absent tag means "no explicit MASK override".
351
+ return if (maskedTags.contains(v.id)) true else null
352
+ }
353
+
354
+ fun walk(v: View, ancestorMasked: Boolean) {
355
+ if (v.visibility != View.VISIBLE) return
356
+ if (v.width <= 0 || v.height <= 0) return
357
+
358
+ val explicit = explicitState(v)
359
+ val masked = explicit ?: ancestorMasked
360
+
361
+ // Leaf redaction by class heuristic (only when not already covered by an ancestor mask).
362
+ if (!ancestorMasked && explicit == null) {
363
+ val byClass = when {
364
+ o.maskAllText && (v is TextView || v is EditText) -> true
365
+ o.maskAllImages && v is ImageView -> true
366
+ else -> false
367
+ }
368
+ if (byClass) {
369
+ rects.add(rectFor(v))
370
+ return // children of a masked text/image node need no further rects
371
+ }
372
+ }
373
+
374
+ if (masked && explicit == true) {
375
+ // An explicit container mask: one block covering the whole subtree.
376
+ rects.add(rectFor(v))
377
+ return
378
+ }
379
+
380
+ if (v is ViewGroup) {
381
+ for (i in 0 until v.childCount) walk(v.getChildAt(i), masked)
382
+ }
383
+ }
384
+
385
+ walk(root, ancestorMasked = false)
386
+ return rects
387
+ }
388
+
389
+ // MARK: - Flush / upload (bg thread)
390
+
391
+ private fun scheduleFlush() {
392
+ val o = opts ?: return
393
+ bg.postDelayed({
394
+ if (opts != null) { flush(); scheduleFlush() }
395
+ }, o.flushIntervalMs.toLong())
396
+ }
397
+
398
+ /**
399
+ * Assemble ONE `IngestBatch` from buffered `screen` events and POST it to
400
+ * `${endpoint}/v1/batch` (HttpURLConnection on `bg`, x-lumera-key header, plain JSON).
401
+ * On HTTP 2xx: bump counters + ++seq. On failure: re-queue (prepend so order holds).
402
+ */
403
+ private fun flush() {
404
+ val o = opts ?: return
405
+ val frames = synchronized(pending) { ArrayList(pending).also { pending.clear() } }
406
+ val touches = synchronized(pendingTouch) { ArrayList(pendingTouch).also { pendingTouch.clear() } }
407
+ if (frames.isEmpty() && touches.isEmpty()) return
408
+
409
+ val events = JSONArray()
410
+ for (e in frames) events.put(e)
411
+ for (e in touches) events.put(e)
412
+
413
+ // Use the first frame's logical size for the meta viewport (all frames share one window).
414
+ // A touch-only batch (no screen this interval) carries 0 — the server keeps the session's
415
+ // established viewport from earlier frame batches.
416
+ val first = frames.firstOrNull()
417
+ val viewport = JSONObject().apply {
418
+ put("w", first?.optInt("screenW") ?: 0)
419
+ put("h", first?.optInt("screenH") ?: 0)
420
+ }
421
+ val device = JSONObject().apply {
422
+ put("type", "mobile")
423
+ put("os", "Android")
424
+ put("viewport", viewport)
425
+ }
426
+ val meta = JSONObject().apply {
427
+ put("startedAt", o.startedAtMs.toLong())
428
+ put("entryUrl", "")
429
+ put("device", device)
430
+ }
431
+ val body = JSONObject().apply {
432
+ put("siteKey", o.siteKey)
433
+ put("sessionId", o.sessionId)
434
+ put("seq", seq)
435
+ put("meta", meta)
436
+ put("events", events)
437
+ }
438
+
439
+ val payload = body.toString().toByteArray(Charsets.UTF_8)
440
+ val ok = post(o, payload)
441
+ if (ok) {
442
+ framesSent += frames.size
443
+ bytesSent += payload.size.toLong()
444
+ seq++
445
+ } else {
446
+ // Re-queue at the front so ordering is preserved for the next attempt.
447
+ synchronized(pending) { pending.addAll(0, frames) }
448
+ synchronized(pendingTouch) { pendingTouch.addAll(0, touches) }
449
+ }
450
+ }
451
+
452
+ /** Blocking POST of one batch on the `bg` thread. Returns true on HTTP 2xx. */
453
+ private fun post(o: ReadableStartOptions, payload: ByteArray): Boolean {
454
+ val url = "${o.endpoint.trimEnd('/')}/v1/batch"
455
+ var conn: HttpURLConnection? = null
456
+ return try {
457
+ conn = (URL(url).openConnection() as HttpURLConnection).apply {
458
+ requestMethod = "POST"
459
+ doOutput = true
460
+ connectTimeout = 10_000
461
+ readTimeout = 15_000
462
+ // octet-stream, NOT application/json — the ingest reads the raw body buffer and
463
+ // sniffs gzip itself; application/json hits Fastify's JSON parser → 400 empty_body.
464
+ setRequestProperty("content-type", "application/octet-stream")
465
+ setRequestProperty("x-lumera-key", o.siteKey)
466
+ setFixedLengthStreamingMode(payload.size)
467
+ }
468
+ conn.outputStream.use { os: OutputStream -> os.write(payload) }
469
+ val code = conn.responseCode
470
+ // Drain so the socket can be reused by the connection pool.
471
+ (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes() }
472
+ code in 200..299
473
+ } catch (_: Exception) {
474
+ false
475
+ } finally {
476
+ conn?.disconnect()
477
+ }
478
+ }
479
+
480
+ /** Typed view over the JS start() options map. */
481
+ private class ReadableStartOptions(map: com.facebook.react.bridge.ReadableMap) {
482
+ var endpoint = map.getString("endpoint") ?: ""
483
+ var siteKey = map.getString("siteKey") ?: ""
484
+ var sessionId = map.getString("sessionId") ?: ""
485
+ var startedAtMs = map.getDouble("startedAtMs")
486
+ var fps = if (map.hasKey("fps")) map.getDouble("fps") else 1.0
487
+ var jpegQuality = if (map.hasKey("jpegQuality")) map.getDouble("jpegQuality") else 0.4
488
+ var maxCaptureDim = if (map.hasKey("maxCaptureDim")) map.getDouble("maxCaptureDim").toInt() else 1200
489
+ var maskAllText = !map.hasKey("maskAllText") || map.getBoolean("maskAllText")
490
+ var maskAllImages = !map.hasKey("maskAllImages") || map.getBoolean("maskAllImages")
491
+ var captureTouches = !map.hasKey("captureTouches") || map.getBoolean("captureTouches")
492
+ var flushIntervalMs = if (map.hasKey("flushIntervalMs")) map.getDouble("flushIntervalMs") else 5000.0
493
+ fun merge(m: com.facebook.react.bridge.ReadableMap) {
494
+ if (m.hasKey("fps")) fps = m.getDouble("fps")
495
+ if (m.hasKey("jpegQuality")) jpegQuality = m.getDouble("jpegQuality")
496
+ if (m.hasKey("maskAllText")) maskAllText = m.getBoolean("maskAllText")
497
+ if (m.hasKey("maskAllImages")) maskAllImages = m.getBoolean("maskAllImages")
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Non-consuming Window.Callback wrapper. Delegates EVERY method to [base] via Kotlin
503
+ * interface delegation; only [dispatchTouchEvent] is overridden to peek at the gesture
504
+ * (synchronously — no MotionEvent is retained past the call) before delegating, so the
505
+ * app's own touch handling is completely unaffected.
506
+ */
507
+ private class TouchCallback(
508
+ private val base: Window.Callback,
509
+ private val onTouch: (MotionEvent) -> Unit,
510
+ ) : Window.Callback by base {
511
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
512
+ try { onTouch(event) } catch (_: Throwable) { /* never let capture break input */ }
513
+ return base.dispatchTouchEvent(event)
514
+ }
515
+ }
516
+
517
+ companion object {
518
+ /**
519
+ * Process-wide set of explicitly-masked View ids (RN reactTags). Shared so both the
520
+ * TurboModule (`setViewMasked`) and the `<LumeraMask>` view manager
521
+ * ([LumeraMaskViewManager]) feed the same registry the capture walk reads. A
522
+ * ConcurrentHashMap-backed set: written from the UI thread, read on `bg`.
523
+ */
524
+ private val maskedTags: MutableSet<Int> =
525
+ java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<Int, Boolean>())
526
+
527
+ fun maskTag(tag: Int) { maskedTags.add(tag) }
528
+ fun unmaskTag(tag: Int) { maskedTags.remove(tag) }
529
+ }
530
+ }
@@ -0,0 +1,50 @@
1
+ package app.lumera.replay
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+ import com.facebook.react.uimanager.ViewManager
9
+
10
+ /**
11
+ * TurboModule package for the Lumera native capture engine.
12
+ *
13
+ * Registered by the host app (autolinking via react-native.config.js, or manually in
14
+ * MainApplication's `getPackages()`). Exposes the New-Architecture TurboModule named
15
+ * "LumeraReplay" plus the optional <LumeraMask> view manager.
16
+ *
17
+ * Uses [BaseReactPackage] (RN 0.76+) — `getModule(name, ctx)` is resolved lazily by the
18
+ * TurboModule infrastructure, and [getReactModuleInfoProvider] marks the module
19
+ * `isTurboModule = true` so it is loaded over JSI (never the legacy bridge).
20
+ */
21
+ class LumeraReplayPackage : BaseReactPackage() {
22
+
23
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
24
+ when (name) {
25
+ LumeraReplayModuleName -> LumeraReplayModule(reactContext)
26
+ else -> null
27
+ }
28
+
29
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
30
+ ReactModuleInfoProvider {
31
+ mapOf(
32
+ LumeraReplayModuleName to ReactModuleInfo(
33
+ /* name = */ LumeraReplayModuleName,
34
+ /* className = */ LumeraReplayModule::class.java.name,
35
+ /* canOverrideExistingModule = */ false,
36
+ /* needsEagerInit = */ false,
37
+ /* isCxxModule = */ false,
38
+ /* isTurboModule = */ true,
39
+ ),
40
+ )
41
+ }
42
+
43
+ // View managers are not TurboModules; supply the optional <LumeraMask> manager directly.
44
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
45
+ listOf(LumeraMaskViewManager())
46
+
47
+ private companion object {
48
+ const val LumeraReplayModuleName = "LumeraReplay"
49
+ }
50
+ }
@@ -0,0 +1,19 @@
1
+ #import <UIKit/UIKit.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ /**
6
+ * Backing native view for the <LumeraMask> RN component. It is a plain pass-through
7
+ * container (children render normally); its only job is to register/unregister its RN
8
+ * `reactTag` with the capture engine so the region it occupies is masked (or, with
9
+ * `masked={false}`, explicitly UN-masked — overriding the global maskAll* defaults).
10
+ *
11
+ * The mask itself is composited natively off the UI thread during capture — this view
12
+ * adds no per-frame cost and never rasterizes anything.
13
+ */
14
+ @interface LumeraMaskView : UIView
15
+ /** YES → mask this subtree; NO → force-unmask it. Default YES. */
16
+ @property (nonatomic, assign) BOOL masked;
17
+ @end
18
+
19
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,79 @@
1
+ #import "LumeraMaskView.h"
2
+ #import <React/RCTComponent.h>
3
+
4
+ // The capture engine's @objc surface (see LumeraReplay.swift). Mirrors the umbrella-
5
+ // header resolution in LumeraReplayModule.mm; we only need `shared` + setViewMasked here.
6
+ #if __has_include(<lumera_react_native/lumera_react_native-Swift.h>)
7
+ #import <lumera_react_native/lumera_react_native-Swift.h>
8
+ #elif __has_include("lumera_react_native-Swift.h")
9
+ #import "lumera_react_native-Swift.h"
10
+ #elif __has_include("LumeraReactNative-Swift.h")
11
+ #import "LumeraReactNative-Swift.h"
12
+ #else
13
+ @interface LumeraReplay : NSObject
14
+ @property (class, nonatomic, readonly, strong) LumeraReplay *shared;
15
+ - (void)setViewMasked:(NSNumber *)reactTag masked:(NSNumber *)masked;
16
+ @end
17
+ #endif
18
+
19
+ @implementation LumeraMaskView
20
+
21
+ - (instancetype)init
22
+ {
23
+ if ((self = [super init])) {
24
+ _masked = YES;
25
+ // Transparent container — the wrapped children own all visuals.
26
+ self.backgroundColor = [UIColor clearColor];
27
+ self.opaque = NO;
28
+ }
29
+ return self;
30
+ }
31
+
32
+ // `reactTag` is assigned by RN before the view is mounted into the tree, so by the time
33
+ // we have a superview the tag is valid — register the redaction here.
34
+ - (void)didMoveToSuperview
35
+ {
36
+ [super didMoveToSuperview];
37
+ if (self.superview) {
38
+ [self applyMask:YES];
39
+ }
40
+ }
41
+
42
+ // Unregister as the view leaves the tree (covers unmount + reparenting to nil).
43
+ - (void)willMoveToSuperview:(UIView *)newSuperview
44
+ {
45
+ [super willMoveToSuperview:newSuperview];
46
+ if (newSuperview == nil) {
47
+ [self applyMask:NO];
48
+ }
49
+ }
50
+
51
+ // Re-apply if the `masked` prop flips while mounted.
52
+ - (void)setMasked:(BOOL)masked
53
+ {
54
+ if (_masked == masked) { return; }
55
+ _masked = masked;
56
+ if (self.superview) { [self applyMask:YES]; }
57
+ }
58
+
59
+ - (void)applyMask:(BOOL)registering
60
+ {
61
+ NSNumber *tag = self.reactTag;
62
+ if (tag == nil) { return; }
63
+ // registering → honor the `masked` prop (YES = mask, NO = force-unmask).
64
+ // !registering (leaving the tree) → clear the entry by unmasking.
65
+ BOOL value = registering ? self.masked : NO;
66
+ [LumeraReplay.shared setViewMasked:tag masked:@(value)];
67
+ }
68
+
69
+ - (void)dealloc
70
+ {
71
+ // Belt-and-suspenders: drop any lingering registration if the view is torn down
72
+ // without a willMoveToSuperview:nil (shouldn't happen, but tags must not leak).
73
+ NSNumber *tag = self.reactTag;
74
+ if (tag != nil) {
75
+ [LumeraReplay.shared setViewMasked:tag masked:@(NO)];
76
+ }
77
+ }
78
+
79
+ @end