@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 +1 -1
- package/android/build.gradle +42 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyModule.kt +281 -0
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyPackage.kt +14 -0
- package/package.json +3 -1
- package/react-native.config.js +12 -0
- package/src/SessionReplayProvider.tsx +32 -7
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
|
|
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,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.
|
|
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.
|
|
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:
|
|
236
|
-
// hardware
|
|
237
|
-
//
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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) {
|