@akhilpulse/samadhaan-session-replay 0.4.2 → 0.4.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.
package/README.md CHANGED
@@ -167,6 +167,7 @@ After installing native deps on Android, **rebuild the app**
167
167
  4. Pause screenshot capture while the iOS keyboard is visible.
168
168
  5. Auto-mount the guided-tour overlay inside the provider, and singleton-guard it.
169
169
  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).
170
+ 7. **GPU-busy crash hardening (0.4.3).** On some Adreno devices, queueing PixelCopy readbacks while the GPU is saturated (maps / navigation animations) can crash the host app inside the vendor driver (`vulkan.adreno.so`) — a native fault that cannot be caught from Java or JS. Starting in 0.4.3 the native module: captures directly into a small (target-width) reused bitmap instead of a full-screen one (~20x smaller GPU readback per frame); reuses a single handler thread instead of spawning one per frame; allows only ONE capture in flight at a time (a busy driver makes the JS loop skip frames via `E_BUSY` rather than pile up readbacks); and keeps a crash-loop breaker — if the process previously died mid-capture twice, PixelCopy is permanently disabled on that device (`E_DISABLED`) and the SDK falls back to view-shot instead of crashing the app again. **Rebuild the Android app after upgrading** for these fixes to take effect.
170
171
 
171
172
  ## Publishing
172
173
 
@@ -1,6 +1,7 @@
1
1
  package com.akhilpulse.sessionreplay
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.Context
4
5
  import android.graphics.Bitmap
5
6
  import android.os.Build
6
7
  import android.os.Handler
@@ -25,6 +26,27 @@ import java.util.concurrent.atomic.AtomicBoolean
25
26
  *
26
27
  * Requires API 24 (Android 7.0). Falls back to view-shot in JS otherwise.
27
28
  *
29
+ * Efficiency / crash-hardening notes (v0.4.3):
30
+ * - PixelCopy scales into the destination bitmap, so we capture DIRECTLY
31
+ * into a small (targetWidth-sized) bitmap instead of a full-screen
32
+ * ARGB_8888 one (~10 MB/frame at 1080p, 4 fps). This cuts the GPU
33
+ * readback size ~20x, which both saves battery and dramatically narrows
34
+ * the window in which a busy GPU driver (maps, nav animations) can fault.
35
+ * - The destination bitmap and the HandlerThread are reused across frames
36
+ * instead of being allocated/started/torn down 4x per second.
37
+ * - Only ONE capture may be in flight at a time. If JS asks for a frame
38
+ * while the previous PixelCopy is still queued in the driver (typical
39
+ * when the GPU is saturated), we reject with E_BUSY and JS simply skips
40
+ * that frame — queueing readbacks against a saturated Adreno driver is
41
+ * the known crash scenario (native fault in vulkan.adreno.so, which
42
+ * cannot be caught from Java/Kotlin).
43
+ * - Crash-loop breaker: a SharedPreferences sentinel is set while a copy
44
+ * is in flight and cleared when it settles. If the process died mid-copy
45
+ * (i.e. the driver crashed the app), the next launch sees the sentinel,
46
+ * increments a crash counter, and after MAX_DRIVER_CRASHES the module
47
+ * permanently rejects with E_DISABLED on this device so JS uses the
48
+ * safe view-shot fallback instead of crashing the host app again.
49
+ *
28
50
  * Note: Dialog windows (react-native `Modal`, react-native-modal, many
29
51
  * bottom-sheet libraries) live on a separate Window and are not included
30
52
  * in this capture. A future version will composite those in via reflection.
@@ -32,89 +54,211 @@ import java.util.concurrent.atomic.AtomicBoolean
32
54
  class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
33
55
  ReactContextBaseJavaModule(reactContext) {
34
56
 
57
+ companion object {
58
+ private const val PREFS = "session_replay_pixelcopy"
59
+ private const val KEY_IN_FLIGHT = "capture_in_flight"
60
+ private const val KEY_DRIVER_CRASHES = "driver_crash_count"
61
+ private const val MAX_DRIVER_CRASHES = 2
62
+ private const val TIMEOUT_MS = 1200L
63
+ }
64
+
35
65
  override fun getName(): String = "SessionReplayPixelCopy"
36
66
 
67
+ private val inFlight = AtomicBoolean(false)
68
+
69
+ // Lazily-created, reused across frames. Never torn down per capture.
70
+ @Volatile private var handlerThread: HandlerThread? = null
71
+ @Volatile private var handler: Handler? = null
72
+
73
+ // Reused destination bitmap (small: targetWidth x scaled height).
74
+ // Guarded by bitmapLock — touched from the UI thread (obtain), the capture
75
+ // handler thread (timeout orphaning) and arbitrary threads (invalidate).
76
+ private val bitmapLock = Any()
77
+ private var reusableBitmap: Bitmap? = null
78
+
79
+ // Cached crash-history verdict (null = not checked yet this process).
80
+ @Volatile private var disabledOnThisDevice: Boolean? = null
81
+
82
+ private fun prefs() =
83
+ reactApplicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
84
+
85
+ /**
86
+ * Returns true when PixelCopy must not be used on this device because a
87
+ * previous process died while a copy was in flight (native driver crash).
88
+ */
89
+ private fun isDisabledByCrashHistory(): Boolean {
90
+ disabledOnThisDevice?.let { return it }
91
+ synchronized(this) {
92
+ disabledOnThisDevice?.let { return it }
93
+ val p = prefs()
94
+ var crashes = p.getInt(KEY_DRIVER_CRASHES, 0)
95
+ if (p.getBoolean(KEY_IN_FLIGHT, false)) {
96
+ // Previous process died mid-capture — treat as a driver crash.
97
+ crashes += 1
98
+ p.edit()
99
+ .putInt(KEY_DRIVER_CRASHES, crashes)
100
+ .putBoolean(KEY_IN_FLIGHT, false)
101
+ .apply()
102
+ }
103
+ val disabled = crashes >= MAX_DRIVER_CRASHES
104
+ disabledOnThisDevice = disabled
105
+ return disabled
106
+ }
107
+ }
108
+
109
+ private fun captureHandler(): Handler {
110
+ handler?.let { return it }
111
+ synchronized(this) {
112
+ handler?.let { return it }
113
+ val t = HandlerThread("SessionReplayPixelCopy")
114
+ t.start()
115
+ handlerThread = t
116
+ val h = Handler(t.looper)
117
+ handler = h
118
+ return h
119
+ }
120
+ }
121
+
122
+ private fun obtainBitmap(w: Int, h: Int): Bitmap {
123
+ synchronized(bitmapLock) {
124
+ val existing = reusableBitmap
125
+ if (existing != null && !existing.isRecycled && existing.width == w && existing.height == h) {
126
+ return existing
127
+ }
128
+ existing?.recycle()
129
+ val fresh = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
130
+ reusableBitmap = fresh
131
+ return fresh
132
+ }
133
+ }
134
+
135
+ /** Drop the cached bitmap WITHOUT recycling (a late driver write may still land in it). */
136
+ private fun orphanBitmap() {
137
+ synchronized(bitmapLock) { reusableBitmap = null }
138
+ }
139
+
140
+ private fun markInFlight(active: Boolean) {
141
+ try {
142
+ prefs().edit().putBoolean(KEY_IN_FLIGHT, active).apply()
143
+ } catch (_: Throwable) {}
144
+ }
145
+
37
146
  @ReactMethod
38
147
  fun capture(targetWidth: Double, quality: Double, promise: Promise) {
39
148
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
40
149
  promise.reject("E_API", "PixelCopy requires Android 7.0+ (API 24)")
41
150
  return
42
151
  }
152
+ if (isDisabledByCrashHistory()) {
153
+ promise.reject(
154
+ "E_DISABLED",
155
+ "PixelCopy disabled on this device after previous GPU driver crashes")
156
+ return
157
+ }
158
+ // Single in-flight capture: if the driver is still working on the last
159
+ // readback (GPU busy — maps, animations), skip this frame instead of
160
+ // queueing more work against a saturated driver.
161
+ if (!inFlight.compareAndSet(false, true)) {
162
+ promise.reject("E_BUSY", "Previous capture still in flight")
163
+ return
164
+ }
165
+
43
166
  val activity: Activity? = currentActivity
44
167
  if (activity == null) {
168
+ inFlight.set(false)
45
169
  promise.reject("E_NO_ACTIVITY", "No current activity")
46
170
  return
47
171
  }
48
172
 
49
173
  activity.runOnUiThread {
50
174
  val settled = AtomicBoolean(false)
51
- val handlerThread = HandlerThread("SessionReplayPixelCopy")
52
- handlerThread.start()
53
- val handler = Handler(handlerThread.looper)
175
+ val h = captureHandler()
54
176
 
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({
177
+ fun settle(block: () -> Unit) {
59
178
  if (settled.compareAndSet(false, true)) {
60
- try { promise.reject("E_TIMEOUT", "PixelCopy timed out") } catch (_: Throwable) {}
61
- handlerThread.quitSafely()
179
+ try {
180
+ block()
181
+ } catch (_: Throwable) {}
182
+ markInFlight(false)
183
+ inFlight.set(false)
184
+ }
185
+ }
186
+
187
+ // Safety net: if PixelCopy never invokes its callback (rare driver
188
+ // bug on some vendor ROMs), reject instead of leaving the JS promise
189
+ // pending forever and freezing the capture loop.
190
+ h.postDelayed({
191
+ settle {
192
+ // The driver may still write into the destination bitmap after a
193
+ // timeout — orphan it (without recycling) so the next capture
194
+ // allocates a fresh one instead of racing the late writer.
195
+ orphanBitmap()
196
+ promise.reject("E_TIMEOUT", "PixelCopy timed out")
62
197
  }
63
- }, 1500)
198
+ }, TIMEOUT_MS)
64
199
 
65
200
  try {
66
201
  val window = activity.window
67
202
  val decor = window.decorView
68
203
  val srcW = decor.width
69
204
  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()
74
- }
205
+ if (srcW <= 0 || srcH <= 0 || !decor.isAttachedToWindow) {
206
+ settle { promise.reject("E_NO_SIZE", "Decor view not ready") }
75
207
  return@runOnUiThread
76
208
  }
77
- val bitmap = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
209
+
210
+ // Capture directly at the downscaled size — PixelCopy scales the
211
+ // window contents to fit the destination bitmap, so there is no
212
+ // need for a full-resolution intermediate bitmap + scale pass.
213
+ val tw = targetWidth.toInt().coerceAtLeast(1).coerceAtMost(srcW)
214
+ val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
215
+ val bitmap = obtainBitmap(tw, th)
216
+
217
+ // Sentinel: if the process dies inside the driver during this copy,
218
+ // the next launch will see it and count a crash (see
219
+ // isDisabledByCrashHistory).
220
+ markInFlight(true)
78
221
 
79
222
  PixelCopy.request(
80
223
  window,
81
224
  bitmap,
82
225
  { result ->
83
- if (!settled.compareAndSet(false, true)) return@request
84
- try {
226
+ settle {
85
227
  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
228
  val q = (quality * 100).toInt().coerceIn(1, 100)
93
229
  val baos = ByteArrayOutputStream()
94
- scaled.compress(Bitmap.CompressFormat.JPEG, q, baos)
95
- if (scaled !== bitmap) scaled.recycle()
96
- bitmap.recycle()
97
-
230
+ bitmap.compress(Bitmap.CompressFormat.JPEG, q, baos)
98
231
  val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
99
232
  promise.resolve(b64)
100
233
  } else {
101
- bitmap.recycle()
102
234
  promise.reject("E_PIXELCOPY", "PixelCopy failed: $result")
103
235
  }
104
- } catch (e: Throwable) {
105
- promise.reject("E_INNER", e)
106
- } finally {
107
- handlerThread.quitSafely()
108
236
  }
109
237
  },
110
- handler
238
+ h
111
239
  )
112
240
  } catch (e: Throwable) {
113
- if (settled.compareAndSet(false, true)) {
114
- promise.reject("E_OUTER", e)
115
- handlerThread.quitSafely()
116
- }
241
+ settle { promise.reject("E_OUTER", e) }
117
242
  }
118
243
  }
119
244
  }
245
+
246
+ override fun invalidate() {
247
+ // Called when the React instance is torn down — release resources.
248
+ // Clear the crash sentinel: a teardown mid-capture is a CLEAN shutdown,
249
+ // not a driver crash, and must not count toward the crash-loop breaker.
250
+ if (inFlight.getAndSet(false)) {
251
+ markInFlight(false)
252
+ }
253
+ try {
254
+ handlerThread?.quitSafely()
255
+ } catch (_: Throwable) {}
256
+ handlerThread = null
257
+ handler = null
258
+ synchronized(bitmapLock) {
259
+ // Don't recycle — a late driver write could still land in it.
260
+ reusableBitmap = null
261
+ }
262
+ super.invalidate()
263
+ }
120
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, guided-tour overlays, and Shake-to-Assist AI voice guide.",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
@@ -50,10 +50,11 @@ import {
50
50
  type PixelCopyNative = { capture: (targetWidth: number, quality: number) => Promise<string> };
51
51
  const SessionReplayPixelCopy: PixelCopyNative | undefined =
52
52
  Platform.OS === "android" ? (NativeModules as any).SessionReplayPixelCopy : undefined;
53
- let pixelCopyDisabled = false; // turned true after a runtime failure to avoid retrying every frame
53
+ let pixelCopyDisabled = false; // turned true after repeated runtime failures to avoid retrying every frame
54
+ let pixelCopyFailStreak = 0; // consecutive non-busy failures; resets on success
54
55
 
55
56
  const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
56
- const SDK_VERSION = "0.4.2";
57
+ const SDK_VERSION = "0.4.3";
57
58
 
58
59
  // ---- Module-level session singleton --------------------------------------
59
60
  // Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
@@ -392,15 +393,32 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
392
393
  result: "base64" as const,
393
394
  width: 360,
394
395
  };
395
- let uri: string;
396
+ let uri: string | null = null;
396
397
  if (Platform.OS === "android") {
397
398
  if (SessionReplayPixelCopy && !pixelCopyDisabled) {
398
399
  try {
399
400
  uri = await SessionReplayPixelCopy.capture(360, 0.25);
400
- } catch (err) {
401
- if (__DEV__) console.warn("[session-replay] PixelCopy failed, falling back to view-shot:", err);
402
- pixelCopyDisabled = true;
403
- uri = await captureScreen(captureOpts);
401
+ pixelCopyFailStreak = 0;
402
+ } catch (err: any) {
403
+ const code = err?.code;
404
+ if (code === "E_BUSY") {
405
+ // Previous capture still in flight — the GPU is saturated
406
+ // (maps, nav animations). Skip this frame entirely rather
407
+ // than piling more readback work onto a busy driver.
408
+ uri = null;
409
+ } else {
410
+ pixelCopyFailStreak++;
411
+ if (code === "E_DISABLED" || code === "E_API" || pixelCopyFailStreak >= 3) {
412
+ // Native module says never use PixelCopy on this device
413
+ // (previous GPU driver crashes / API too old), or it has
414
+ // failed repeatedly — stop trying.
415
+ if (__DEV__) console.warn("[session-replay] PixelCopy disabled, using view-shot:", err);
416
+ pixelCopyDisabled = true;
417
+ } else if (__DEV__) {
418
+ console.warn("[session-replay] PixelCopy failed, falling back to view-shot for this frame:", err);
419
+ }
420
+ uri = await captureScreen(captureOpts);
421
+ }
404
422
  }
405
423
  } else {
406
424
  uri = await captureScreen(captureOpts);
@@ -408,6 +426,12 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
408
426
  } else {
409
427
  uri = await captureRef(ref as any, captureOpts);
410
428
  }
429
+ if (!uri) {
430
+ // Frame intentionally skipped (GPU busy) — try again next tick.
431
+ const elapsedSkip = Date.now() - startedAt;
432
+ setTimeout(tick, Math.max(0, liveTargetMs - elapsedSkip));
433
+ return;
434
+ }
411
435
  const blob = base64ToUint8Array(uri);
412
436
  // Stream binary over WS — no REST upload in the hot path.
413
437
  if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {