@akhilpulse/samadhaan-session-replay 0.2.9 → 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 CHANGED
@@ -86,7 +86,7 @@ const { pause, resume, sessionId, status } = useSessionReplay();
86
86
  3. The guided-tour overlay must NOT cover the screen center. Use 4 thin edge strips with `pointerEvents: "none"`.
87
87
  4. Pause screenshot capture while the iOS keyboard is visible.
88
88
  5. Auto-mount the guided-tour overlay inside the provider, and singleton-guard it.
89
- 6. **Android elevation/zIndex break screenshot capture.** `react-native-view-shot` (both `captureRef` and `captureScreen`) uses a software canvas draw on Android (`view.draw(canvas)`) that **silently skips any view promoted to a hardware compositor layer** — i.e. anything with `elevation: > 0` or `zIndex: > 0` (RN implements `zIndex` via `setElevation` on Android). The base view tree (including native `SurfaceView`s like `react-native-maps`) is captured fine, but FABs, modals, cards-with-shadow, and any RN overlay with elevation/zIndex will be invisible in screenshots and the live-streamed frames. **Fix in your app:** rely on tree order to control layering on Android (later siblings paint on top), and remove `elevation`/`zIndex` from anything that must appear in captures. Keep `shadowColor`/`shadowOpacity`/`shadowRadius` those are iOS-only and don't trigger Android hardware layers. The SDK's own overlays (red pointer dot, end-tour pill, edge halos, consent card) follow this rule.
89
+ 6. **Android elevated views & screenshots.** `react-native-view-shot` uses a software canvas draw on Android (`view.draw(canvas)`) which silently skips any view promoted to a hardware compositor layer (anything with `elevation > 0` or `zIndex > 0` RN implements `zIndex` via `setElevation` on Android). To work around this, **starting in 0.3.0 the SDK ships its own native module that captures via Android's `PixelCopy` API** (real on-screen pixels, including hardware layers, modals, FABs, and native `SurfaceView`s like `react-native-maps`). This module is autolinked **after upgrading the SDK on Android you must rebuild the app** (`cd android && ./gradlew clean && cd .. && npx react-native run-android`) for the new screenshot path to activate. If the native module isn't present at runtime (older build, Expo Go), the SDK transparently falls back to `captureScreen` and the elevation limitation re-applies. Requires Android 7.0+ (API 24).
90
90
 
91
91
  ## Publishing
92
92
 
@@ -0,0 +1,42 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+
4
+ def safeExtGet(prop, fallback) {
5
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
6
+ }
7
+
8
+ android {
9
+ namespace "com.akhilpulse.sessionreplay"
10
+ compileSdkVersion safeExtGet('compileSdkVersion', 34)
11
+
12
+ defaultConfig {
13
+ minSdkVersion safeExtGet('minSdkVersion', 24)
14
+ targetSdkVersion safeExtGet('targetSdkVersion', 34)
15
+ }
16
+
17
+ compileOptions {
18
+ sourceCompatibility JavaVersion.VERSION_17
19
+ targetCompatibility JavaVersion.VERSION_17
20
+ }
21
+
22
+ kotlinOptions {
23
+ jvmTarget = "17"
24
+ }
25
+
26
+ sourceSets {
27
+ main {
28
+ java.srcDirs = ['src/main/java']
29
+ manifest.srcFile 'src/main/AndroidManifest.xml'
30
+ }
31
+ }
32
+ }
33
+
34
+ repositories {
35
+ google()
36
+ mavenCentral()
37
+ }
38
+
39
+ dependencies {
40
+ implementation "com.facebook.react:react-android"
41
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlinVersion', '1.9.24')}"
42
+ }
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,281 @@
1
+ package com.akhilpulse.sessionreplay
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Canvas
6
+ import android.graphics.Paint
7
+ import android.graphics.PorterDuff
8
+ import android.os.Build
9
+ import android.os.Handler
10
+ import android.os.HandlerThread
11
+ import android.util.Base64
12
+ import android.view.PixelCopy
13
+ import android.view.View
14
+ import android.view.ViewGroup
15
+ import android.view.Window
16
+ import com.facebook.react.bridge.Promise
17
+ import com.facebook.react.bridge.ReactApplicationContext
18
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
19
+ import com.facebook.react.bridge.ReactMethod
20
+ import java.io.ByteArrayOutputStream
21
+ import java.util.concurrent.atomic.AtomicInteger
22
+
23
+ /**
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.
34
+ *
35
+ * Returns a base64-encoded JPEG (no `data:` prefix) — same shape as
36
+ * react-native-view-shot's `result: "base64"` so the JS side can use it
37
+ * interchangeably.
38
+ *
39
+ * Requires API 24 (Android 7.0). Falls back to view-shot in JS otherwise.
40
+ */
41
+ class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
42
+ ReactContextBaseJavaModule(reactContext) {
43
+
44
+ override fun getName(): String = "SessionReplayPixelCopy"
45
+
46
+ @ReactMethod
47
+ fun capture(targetWidth: Double, quality: Double, promise: Promise) {
48
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
49
+ promise.reject("E_API", "PixelCopy requires Android 7.0+ (API 24)")
50
+ return
51
+ }
52
+ val activity: Activity? = currentActivity
53
+ if (activity == null) {
54
+ promise.reject("E_NO_ACTIVITY", "No current activity")
55
+ return
56
+ }
57
+
58
+ 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
67
+ }
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())
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
+ }
142
+ }
143
+ }
144
+ } 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 }
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
+ }
281
+ }
@@ -0,0 +1,14 @@
1
+ package com.akhilpulse.sessionreplay
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class SessionReplayPixelCopyPackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10
+ listOf(SessionReplayPixelCopyModule(reactContext))
11
+
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13
+ emptyList()
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
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",
@@ -9,6 +9,8 @@
9
9
  "source": "src/index.ts",
10
10
  "files": [
11
11
  "src",
12
+ "android",
13
+ "react-native.config.js",
12
14
  "README.md"
13
15
  ],
14
16
  "scripts": {
@@ -0,0 +1,12 @@
1
+ module.exports = {
2
+ dependency: {
3
+ platforms: {
4
+ android: {
5
+ sourceDir: './android',
6
+ packageImportPath: 'import com.akhilpulse.sessionreplay.SessionReplayPixelCopyPackage;',
7
+ packageInstance: 'new SessionReplayPixelCopyPackage()',
8
+ },
9
+ ios: null,
10
+ },
11
+ },
12
+ };
@@ -31,12 +31,23 @@ import {
31
31
  findNodeHandle,
32
32
  StyleSheet,
33
33
  Pressable,
34
+ NativeModules,
34
35
  } from "react-native";
35
36
  import AsyncStorage from "@react-native-async-storage/async-storage";
36
37
  import { captureRef, captureScreen } from "react-native-view-shot";
37
38
 
39
+ // Bundled native module (Android only) that uses PixelCopy to capture the
40
+ // real on-screen pixels — including hardware-accelerated layers (views with
41
+ // `elevation`/`zIndex`, modals, FABs, native MapViews). Autolinked via
42
+ // react-native.config.js. Will be `undefined` if the host app hasn't been
43
+ // rebuilt after upgrading the SDK, in which case we fall back to view-shot.
44
+ type PixelCopyNative = { capture: (targetWidth: number, quality: number) => Promise<string> };
45
+ const SessionReplayPixelCopy: PixelCopyNative | undefined =
46
+ Platform.OS === "android" ? (NativeModules as any).SessionReplayPixelCopy : undefined;
47
+ let pixelCopyDisabled = false; // turned true after a runtime failure to avoid retrying every frame
48
+
38
49
  const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
39
- const SDK_VERSION = "0.2.9";
50
+ const SDK_VERSION = "0.3.0";
40
51
 
41
52
  /** Default Samadhaan production endpoint. Override via `config.serverUrl` for staging. */
42
53
  export const DEFAULT_SERVER_URL = "https://samadhaan.pulseenergy.io";
@@ -232,9 +243,10 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
232
243
  const ref = rootRef.current;
233
244
  if (ref) {
234
245
  // Aggressively downscaled + low quality so capture+encode stays under ~50ms on mid devices.
235
- // Android: captureRef misses views with `elevation` (they render in a separate
236
- // hardware layer). Use captureScreen() which does an OS-level screenshot and
237
- // includes elevated views (modals, FABs, cards with shadow, etc.).
246
+ // Android: native PixelCopy reads the actual on-screen pixels including
247
+ // hardware-accelerated layers (elevation/zIndex views, modals, FABs,
248
+ // native MapViews). Falls back to captureScreen if the native module
249
+ // isn't installed (host app hasn't rebuilt) or if PixelCopy fails.
238
250
  // iOS: captureRef on the app root works correctly and stays scoped to the app.
239
251
  const captureOpts = {
240
252
  format: "jpg" as const,
@@ -242,9 +254,22 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
242
254
  result: "base64" as const,
243
255
  width: 360,
244
256
  };
245
- const uri = Platform.OS === "android"
246
- ? await captureScreen(captureOpts)
247
- : await captureRef(ref as any, captureOpts);
257
+ let uri: string;
258
+ if (Platform.OS === "android") {
259
+ if (SessionReplayPixelCopy && !pixelCopyDisabled) {
260
+ try {
261
+ uri = await SessionReplayPixelCopy.capture(360, 0.25);
262
+ } catch (err) {
263
+ if (__DEV__) console.warn("[session-replay] PixelCopy failed, falling back to view-shot:", err);
264
+ pixelCopyDisabled = true;
265
+ uri = await captureScreen(captureOpts);
266
+ }
267
+ } else {
268
+ uri = await captureScreen(captureOpts);
269
+ }
270
+ } else {
271
+ uri = await captureRef(ref as any, captureOpts);
272
+ }
248
273
  const blob = base64ToUint8Array(uri);
249
274
  // Stream binary over WS — no REST upload in the hot path.
250
275
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {