@akhilpulse/samadhaan-session-replay 0.3.2 → 0.3.3

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.
@@ -2,41 +2,32 @@ package com.akhilpulse.sessionreplay
2
2
 
3
3
  import android.app.Activity
4
4
  import android.graphics.Bitmap
5
- import android.graphics.Canvas
6
- import android.graphics.Paint
7
- import android.graphics.PorterDuff
8
5
  import android.os.Build
9
6
  import android.os.Handler
10
7
  import android.os.HandlerThread
11
8
  import android.util.Base64
12
9
  import android.view.PixelCopy
13
- import android.view.View
14
- import android.view.ViewGroup
15
- import android.view.Window
16
10
  import com.facebook.react.bridge.Promise
17
11
  import com.facebook.react.bridge.ReactApplicationContext
18
12
  import com.facebook.react.bridge.ReactContextBaseJavaModule
19
13
  import com.facebook.react.bridge.ReactMethod
20
14
  import java.io.ByteArrayOutputStream
21
- import java.util.concurrent.atomic.AtomicInteger
15
+ import java.util.concurrent.atomic.AtomicBoolean
22
16
 
23
17
  /**
24
- * Captures the full visible screen by enumerating EVERY active Window the
25
- * app currently has on screen (the Activity window plus any Dialog windows
26
- * react-native `Modal`, react-native-modal, react-native-paper Dialog,
27
- * many bottom-sheet libraries, etc. all render into separate Dialog
28
- * windows on Android), running a PixelCopy on each, and compositing them
29
- * onto a single bitmap in the same z-order they're drawn.
30
- *
31
- * PixelCopy reads the actual on-screen pixels, so hardware-accelerated
32
- * layers (views with `elevation` / `zIndex`, native SurfaceViews like
33
- * react-native-maps, FABs with shadows) are all included.
18
+ * Captures the Activity's window via Android PixelCopy, which reads the
19
+ * actual on-screen pixels (including hardware-accelerated layers like views
20
+ * with `elevation` / `zIndex`, FABs, native SurfaceViews/MapViews).
34
21
  *
35
22
  * Returns a base64-encoded JPEG (no `data:` prefix) — same shape as
36
23
  * react-native-view-shot's `result: "base64"` so the JS side can use it
37
24
  * interchangeably.
38
25
  *
39
26
  * Requires API 24 (Android 7.0). Falls back to view-shot in JS otherwise.
27
+ *
28
+ * Note: Dialog windows (react-native `Modal`, react-native-modal, many
29
+ * bottom-sheet libraries) live on a separate Window and are not included
30
+ * in this capture. A future version will composite those in via reflection.
40
31
  */
41
32
  class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
42
33
  ReactContextBaseJavaModule(reactContext) {
@@ -56,226 +47,74 @@ class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
56
47
  }
57
48
 
58
49
  activity.runOnUiThread {
59
- try {
60
- val activityWindow = activity.window
61
- val activityDecor = activityWindow.decorView
62
- val srcW = activityDecor.width
63
- val srcH = activityDecor.height
64
- if (srcW <= 0 || srcH <= 0) {
65
- promise.reject("E_NO_SIZE", "Decor view has zero size")
66
- return@runOnUiThread
50
+ val settled = AtomicBoolean(false)
51
+ val handlerThread = HandlerThread("SessionReplayPixelCopy")
52
+ handlerThread.start()
53
+ val handler = Handler(handlerThread.looper)
54
+
55
+ // Safety net: if PixelCopy never invokes its callback (rare driver
56
+ // bug on some vendor ROMs), reject after 1.5s instead of leaving the
57
+ // JS promise pending forever and freezing the capture loop.
58
+ handler.postDelayed({
59
+ if (settled.compareAndSet(false, true)) {
60
+ try { promise.reject("E_TIMEOUT", "PixelCopy timed out") } catch (_: Throwable) {}
61
+ handlerThread.quitSafely()
67
62
  }
63
+ }, 1500)
68
64
 
69
- // Discover every active Window via WindowManagerGlobal reflection.
70
- // The list is returned in the order Android added them, which is
71
- // bottom-to-top z-order (Activity window first, then Dialogs / Toasts
72
- // on top), so we composite in that same order.
73
- val windows = collectWindows(activityWindow)
74
-
75
- val composite = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
76
- val canvas = Canvas(composite)
77
- // Pre-fill black so any sub-window that fails capture doesn't show
78
- // garbage memory.
79
- canvas.drawColor(0xFF000000.toInt())
80
-
81
- val handlerThread = HandlerThread("SessionReplayPixelCopy")
82
- handlerThread.start()
83
- val handler = Handler(handlerThread.looper)
84
-
85
- val remaining = AtomicInteger(windows.size)
86
- val results = arrayOfNulls<Bitmap>(windows.size)
87
- val locations = Array(windows.size) { intArrayOf(0, 0) }
88
-
89
- if (windows.isEmpty()) {
90
- // Should never happen — at least the Activity window exists — but
91
- // bail safely instead of hanging.
92
- finish(composite, srcW, srcH, targetWidth, quality, promise, handlerThread)
93
- return@runOnUiThread
94
- }
95
-
96
- for ((index, win) in windows.withIndex()) {
97
- val decor: View = win.decorView
98
- val w = decor.width
99
- val h = decor.height
100
- if (w <= 0 || h <= 0) {
101
- // Skip zero-size windows (e.g. just-dismissed dialogs).
102
- results[index] = null
103
- if (remaining.decrementAndGet() == 0) {
104
- compositeAll(canvas, windows, results, locations)
105
- finish(composite, srcW, srcH, targetWidth, quality, promise, handlerThread)
106
- }
107
- continue
108
- }
109
- // Capture this window's screen-space origin so we can position it
110
- // correctly on the composite (Dialog windows are usually centered
111
- // or anchored, not at 0,0).
112
- decor.getLocationOnScreen(locations[index])
113
-
114
- val sub = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
115
- try {
116
- PixelCopy.request(
117
- win,
118
- sub,
119
- { result ->
120
- if (result == PixelCopy.SUCCESS) {
121
- results[index] = sub
122
- } else {
123
- sub.recycle()
124
- results[index] = null
125
- }
126
- if (remaining.decrementAndGet() == 0) {
127
- compositeAll(canvas, windows, results, locations)
128
- finish(composite, srcW, srcH, targetWidth, quality, promise, handlerThread)
129
- }
130
- },
131
- handler
132
- )
133
- } catch (e: Throwable) {
134
- // Some windows (e.g. SurfaceView-only windows, secure windows)
135
- // may reject PixelCopy synchronously. Skip them.
136
- sub.recycle()
137
- results[index] = null
138
- if (remaining.decrementAndGet() == 0) {
139
- compositeAll(canvas, windows, results, locations)
140
- finish(composite, srcW, srcH, targetWidth, quality, promise, handlerThread)
141
- }
65
+ try {
66
+ val window = activity.window
67
+ val decor = window.decorView
68
+ val srcW = decor.width
69
+ val srcH = decor.height
70
+ if (srcW <= 0 || srcH <= 0) {
71
+ if (settled.compareAndSet(false, true)) {
72
+ promise.reject("E_NO_SIZE", "Decor view has zero size")
73
+ handlerThread.quitSafely()
142
74
  }
75
+ return@runOnUiThread
143
76
  }
77
+ val bitmap = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
78
+
79
+ PixelCopy.request(
80
+ window,
81
+ bitmap,
82
+ { result ->
83
+ if (!settled.compareAndSet(false, true)) return@request
84
+ try {
85
+ if (result == PixelCopy.SUCCESS) {
86
+ val tw = targetWidth.toInt().coerceAtLeast(1)
87
+ val scaled = if (tw < srcW) {
88
+ val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
89
+ Bitmap.createScaledBitmap(bitmap, tw, th, true)
90
+ } else bitmap
91
+
92
+ val q = (quality * 100).toInt().coerceIn(1, 100)
93
+ val baos = ByteArrayOutputStream()
94
+ scaled.compress(Bitmap.CompressFormat.JPEG, q, baos)
95
+ if (scaled !== bitmap) scaled.recycle()
96
+ bitmap.recycle()
97
+
98
+ val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
99
+ promise.resolve(b64)
100
+ } else {
101
+ bitmap.recycle()
102
+ promise.reject("E_PIXELCOPY", "PixelCopy failed: $result")
103
+ }
104
+ } catch (e: Throwable) {
105
+ promise.reject("E_INNER", e)
106
+ } finally {
107
+ handlerThread.quitSafely()
108
+ }
109
+ },
110
+ handler
111
+ )
144
112
  } catch (e: Throwable) {
145
- promise.reject("E_OUTER", e)
146
- }
147
- }
148
- }
149
-
150
- /**
151
- * Reflects into android.view.WindowManagerGlobal to obtain every Window
152
- * the app currently has attached. Falls back to just the Activity window
153
- * if reflection fails (newer Android versions or vendor-modified ROMs).
154
- */
155
- private fun collectWindows(activityWindow: Window): List<Window> {
156
- val list = mutableListOf<Window>()
157
- try {
158
- val wmgClass = Class.forName("android.view.WindowManagerGlobal")
159
- val getInstance = wmgClass.getMethod("getInstance")
160
- val wmg = getInstance.invoke(null)
161
-
162
- // mViews: ArrayList<View> (the decor view of each window, in z-order)
163
- val viewsField = wmgClass.getDeclaredField("mViews").apply { isAccessible = true }
164
- @Suppress("UNCHECKED_CAST")
165
- val views = viewsField.get(wmg) as List<View>
166
-
167
- // mParams: ArrayList<WindowManager.LayoutParams> — same length as mViews.
168
- // (Reserved for future filtering by window type if needed.)
169
-
170
- for (v in views) {
171
- // Skip views that aren't currently attached / visible.
172
- if (!v.isAttachedToWindow) continue
173
- if (v.visibility != View.VISIBLE) continue
174
- // Skip system overlays from other packages (shouldn't happen since
175
- // mViews is per-process, but guard anyway).
176
- val ctx = v.context ?: continue
177
- if (ctx.packageName != activityWindow.context.packageName) continue
178
-
179
- // The Window object itself isn't directly on the View, but PixelCopy
180
- // accepts a Window — and we can synthesize a wrapper from the
181
- // ViewRootImpl's Surface using the alternative PixelCopy.request
182
- // overload. Easier path: walk up the view tree to find the
183
- // associated Window via the context (Activity / Dialog).
184
- val win = findWindowForView(v, activityWindow)
185
- if (win != null && !list.contains(win)) {
186
- list.add(win)
187
- }
188
- }
189
- } catch (_: Throwable) {
190
- // Reflection failed — at minimum capture the activity window.
191
- }
192
- if (list.isEmpty()) list.add(activityWindow)
193
- return list
194
- }
195
-
196
- /**
197
- * Tries to resolve the Window that owns a given decor view. Activity
198
- * windows are easy (context is the Activity). Dialog windows store their
199
- * Window via the view's parent context chain.
200
- */
201
- private fun findWindowForView(view: View, activityWindow: Window): Window? {
202
- // If this view IS the activity's decor, return that window.
203
- if (view === activityWindow.decorView) return activityWindow
204
-
205
- // Try unwrapping the context chain to find a Dialog or Activity.
206
- var ctx = view.context
207
- var depth = 0
208
- while (ctx != null && depth < 10) {
209
- when (ctx) {
210
- is Activity -> return ctx.window
211
- else -> {
212
- // android.view.ContextThemeWrapper / Dialog's inner context: try
213
- // reflection to get a `getWindow()` if present.
214
- try {
215
- val m = ctx.javaClass.getMethod("getWindow")
216
- val w = m.invoke(ctx)
217
- if (w is Window) return w
218
- } catch (_: Throwable) {
219
- // Walk to the base context.
220
- }
221
- val baseField = try { ctx.javaClass.getMethod("getBaseContext") } catch (_: Throwable) { null }
222
- ctx = try { baseField?.invoke(ctx) as? android.content.Context } catch (_: Throwable) { null }
113
+ if (settled.compareAndSet(false, true)) {
114
+ promise.reject("E_OUTER", e)
115
+ handlerThread.quitSafely()
223
116
  }
224
117
  }
225
- depth++
226
- }
227
- return null
228
- }
229
-
230
- private fun compositeAll(
231
- canvas: Canvas,
232
- windows: List<Window>,
233
- results: Array<Bitmap?>,
234
- locations: Array<IntArray>
235
- ) {
236
- val activityOrigin = intArrayOf(0, 0)
237
- if (windows.isNotEmpty()) {
238
- windows[0].decorView.getLocationOnScreen(activityOrigin)
239
- }
240
- for (i in results.indices) {
241
- val bmp = results[i] ?: continue
242
- // Translate window screen-space origin into composite-space (relative
243
- // to the activity decor's top-left).
244
- val left = (locations[i][0] - activityOrigin[0]).toFloat()
245
- val top = (locations[i][1] - activityOrigin[1]).toFloat()
246
- canvas.drawBitmap(bmp, left, top, null)
247
- bmp.recycle()
248
- }
249
- }
250
-
251
- private fun finish(
252
- composite: Bitmap,
253
- srcW: Int,
254
- srcH: Int,
255
- targetWidth: Double,
256
- quality: Double,
257
- promise: Promise,
258
- handlerThread: HandlerThread
259
- ) {
260
- try {
261
- val tw = targetWidth.toInt().coerceAtLeast(1)
262
- val scaled = if (tw < srcW) {
263
- val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
264
- Bitmap.createScaledBitmap(composite, tw, th, true)
265
- } else composite
266
-
267
- val q = (quality * 100).toInt().coerceIn(1, 100)
268
- val baos = ByteArrayOutputStream()
269
- scaled.compress(Bitmap.CompressFormat.JPEG, q, baos)
270
- if (scaled !== composite) scaled.recycle()
271
- composite.recycle()
272
-
273
- val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
274
- promise.resolve(b64)
275
- } catch (e: Throwable) {
276
- promise.reject("E_FINISH", e)
277
- } finally {
278
- handlerThread.quitSafely()
279
118
  }
280
119
  }
281
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",