@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.
- package/README.md +108 -0
- package/android/build.gradle +31 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/lumera/replay/LumeraMaskViewManager.kt +39 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayModule.kt +530 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayPackage.kt +50 -0
- package/ios/LumeraMaskView.h +19 -0
- package/ios/LumeraMaskView.m +79 -0
- package/ios/LumeraMaskViewManager.m +29 -0
- package/ios/LumeraReplay.swift +703 -0
- package/ios/LumeraReplayModule.h +20 -0
- package/ios/LumeraReplayModule.mm +93 -0
- package/lumera-react-native.podspec +19 -0
- package/package.json +46 -0
- package/src/LumeraMask.tsx +52 -0
- package/src/config.ts +96 -0
- package/src/emit.ts +5 -0
- package/src/index.ts +292 -0
- package/src/instrument/console.ts +46 -0
- package/src/instrument/errors.ts +28 -0
- package/src/instrument/network.ts +219 -0
- package/src/session.ts +108 -0
- package/src/spec/NativeLumeraReplay.ts +70 -0
- package/src/transport.ts +40 -0
- package/src/util.ts +38 -0
|
@@ -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
|