@akhilpulse/samadhaan-session-replay 0.3.2 → 0.3.4
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/android/build.gradle
CHANGED
|
@@ -15,12 +15,12 @@ android {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
compileOptions {
|
|
18
|
-
sourceCompatibility JavaVersion.
|
|
19
|
-
targetCompatibility JavaVersion.
|
|
18
|
+
sourceCompatibility JavaVersion.VERSION_11
|
|
19
|
+
targetCompatibility JavaVersion.VERSION_11
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
kotlinOptions {
|
|
23
|
-
jvmTarget = "
|
|
23
|
+
jvmTarget = "11"
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
sourceSets {
|
|
@@ -37,6 +37,6 @@ repositories {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
dependencies {
|
|
40
|
-
implementation "com.facebook.react:react-
|
|
40
|
+
implementation "com.facebook.react:react-native:+"
|
|
41
41
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.24')}"
|
|
42
42
|
}
|
|
@@ -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.
|
|
15
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
22
16
|
|
|
23
17
|
/**
|
|
24
|
-
* Captures the
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
val
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.4",
|
|
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",
|