@akhilpulse/samadhaan-session-replay 0.2.9 → 0.3.0

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,97 @@
1
+ package com.akhilpulse.sessionreplay
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Bitmap
5
+ import android.os.Build
6
+ import android.os.Handler
7
+ import android.os.HandlerThread
8
+ import android.util.Base64
9
+ import android.view.PixelCopy
10
+ import com.facebook.react.bridge.Promise
11
+ import com.facebook.react.bridge.ReactApplicationContext
12
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
13
+ import com.facebook.react.bridge.ReactMethod
14
+ import java.io.ByteArrayOutputStream
15
+
16
+ /**
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).
20
+ *
21
+ * Returns a base64-encoded JPEG (no `data:` prefix) — same shape as
22
+ * react-native-view-shot's `result: "base64"` so the JS side can use it
23
+ * interchangeably.
24
+ *
25
+ * Requires API 24 (Android 7.0). Falls back to view-shot in JS otherwise.
26
+ */
27
+ class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
28
+ ReactContextBaseJavaModule(reactContext) {
29
+
30
+ override fun getName(): String = "SessionReplayPixelCopy"
31
+
32
+ @ReactMethod
33
+ fun capture(targetWidth: Double, quality: Double, promise: Promise) {
34
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
35
+ promise.reject("E_API", "PixelCopy requires Android 7.0+ (API 24)")
36
+ return
37
+ }
38
+ val activity: Activity? = currentActivity
39
+ if (activity == null) {
40
+ promise.reject("E_NO_ACTIVITY", "No current activity")
41
+ return
42
+ }
43
+
44
+ activity.runOnUiThread {
45
+ try {
46
+ val window = activity.window
47
+ val decor = window.decorView
48
+ val srcW = decor.width
49
+ val srcH = decor.height
50
+ if (srcW <= 0 || srcH <= 0) {
51
+ promise.reject("E_NO_SIZE", "Decor view has zero size")
52
+ return@runOnUiThread
53
+ }
54
+ val bitmap = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
55
+
56
+ val handlerThread = HandlerThread("SessionReplayPixelCopy")
57
+ handlerThread.start()
58
+ val handler = Handler(handlerThread.looper)
59
+
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
+ )
92
+ } catch (e: Throwable) {
93
+ promise.reject("E_OUTER", e)
94
+ }
95
+ }
96
+ }
97
+ }
@@ -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.0",
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) {