@akhilpulse/samadhaan-session-replay 0.3.0 → 0.3.2

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,21 +2,35 @@ 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
5
8
  import android.os.Build
6
9
  import android.os.Handler
7
10
  import android.os.HandlerThread
8
11
  import android.util.Base64
9
12
  import android.view.PixelCopy
13
+ import android.view.View
14
+ import android.view.ViewGroup
15
+ import android.view.Window
10
16
  import com.facebook.react.bridge.Promise
11
17
  import com.facebook.react.bridge.ReactApplicationContext
12
18
  import com.facebook.react.bridge.ReactContextBaseJavaModule
13
19
  import com.facebook.react.bridge.ReactMethod
14
20
  import java.io.ByteArrayOutputStream
21
+ import java.util.concurrent.atomic.AtomicInteger
15
22
 
16
23
  /**
17
- * Captures the Activity's window via Android PixelCopy, which reads the
18
- * actual on-screen pixels (including hardware-accelerated layers like views
19
- * with `elevation` / `zIndex`, modals, FABs, native SurfaceViews/MapViews).
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.
20
34
  *
21
35
  * Returns a base64-encoded JPEG (no `data:` prefix) — same shape as
22
36
  * react-native-view-shot's `result: "base64"` so the JS side can use it
@@ -43,55 +57,225 @@ class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
43
57
 
44
58
  activity.runOnUiThread {
45
59
  try {
46
- val window = activity.window
47
- val decor = window.decorView
48
- val srcW = decor.width
49
- val srcH = decor.height
60
+ val activityWindow = activity.window
61
+ val activityDecor = activityWindow.decorView
62
+ val srcW = activityDecor.width
63
+ val srcH = activityDecor.height
50
64
  if (srcW <= 0 || srcH <= 0) {
51
65
  promise.reject("E_NO_SIZE", "Decor view has zero size")
52
66
  return@runOnUiThread
53
67
  }
54
- val bitmap = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
68
+
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())
55
80
 
56
81
  val handlerThread = HandlerThread("SessionReplayPixelCopy")
57
82
  handlerThread.start()
58
83
  val handler = Handler(handlerThread.looper)
59
84
 
60
- PixelCopy.request(
61
- window,
62
- bitmap,
63
- { result ->
64
- try {
65
- if (result == PixelCopy.SUCCESS) {
66
- val tw = targetWidth.toInt().coerceAtLeast(1)
67
- val scaled = if (tw < srcW) {
68
- val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
69
- Bitmap.createScaledBitmap(bitmap, tw, th, true)
70
- } else bitmap
71
-
72
- val q = (quality * 100).toInt().coerceIn(1, 100)
73
- val baos = ByteArrayOutputStream()
74
- scaled.compress(Bitmap.CompressFormat.JPEG, q, baos)
75
- if (scaled !== bitmap) scaled.recycle()
76
- bitmap.recycle()
77
-
78
- val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
79
- promise.resolve(b64)
80
- } else {
81
- bitmap.recycle()
82
- promise.reject("E_PIXELCOPY", "PixelCopy failed: $result")
83
- }
84
- } catch (e: Throwable) {
85
- promise.reject("E_INNER", e)
86
- } finally {
87
- handlerThread.quitSafely()
88
- }
89
- },
90
- handler
91
- )
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
+ }
142
+ }
143
+ }
92
144
  } catch (e: Throwable) {
93
145
  promise.reject("E_OUTER", e)
94
146
  }
95
147
  }
96
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 }
223
+ }
224
+ }
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
+ }
280
+ }
97
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",