Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

### Features

- Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333))
- To enable, set `options.sessionReplay.isCaptureSurfaceViews = true`
- Or via manifest: `<meta-data android:name="io.sentry.session-replay.capture-surface-views" android:value="true" />`
- **Warning:** masking granularity is at the SurfaceView level only β€” the SDK cannot mask individual elements rendered inside the SurfaceView (e.g. native Unity UI, map labels, video frames). Only enable for SurfaceViews whose content is safe to record.

### Dependencies

- Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ final class ManifestMetadataReader {

static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug";
static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy";
static final String REPLAYS_CAPTURE_SURFACE_VIEWS =
"io.sentry.session-replay.capture-surface-views";

static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS =
"io.sentry.session-replay.network-detail-allow-urls";
Expand Down Expand Up @@ -547,6 +549,15 @@ static void applyMetadata(
}
}

options
.getSessionReplay()
.setCaptureSurfaceViews(
readBool(
metadata,
logger,
REPLAYS_CAPTURE_SURFACE_VIEWS,
options.getSessionReplay().isCaptureSurfaceViews()));

// Network Details Configuration
if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) {
final @Nullable List<String> allowUrls =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ private boolean isMaskingEnabled() {

final ViewHierarchyNode rootNode =
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot());
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger());
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null);
return rootNode;
} catch (Throwable e) {
options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2022,6 +2022,31 @@ class ManifestMetadataReaderTest {
)
}

@Test
fun `applyMetadata reads capture-surface-views to options`() {
// Arrange
val bundle = bundleOf(ManifestMetadataReader.REPLAYS_CAPTURE_SURFACE_VIEWS to true)
val context = fixture.getContext(metaData = bundle)

// Act
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertTrue(fixture.options.sessionReplay.isCaptureSurfaceViews)
}

@Test
fun `applyMetadata reads capture-surface-views and keeps default if not found`() {
// Arrange
val context = fixture.getContext()

// Act
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertFalse(fixture.options.sessionReplay.isCaptureSurfaceViews)
}

@Test
fun `applyMetadata reads anrProfilingSampleRate to options`() {
// Arrange
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ internal class ScreenshotRecorder(
options,
config,
debugOverlayDrawable,
markContentChanged = { contentChanged.set(true) },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import io.sentry.android.replay.util.hasSize
import io.sentry.android.replay.util.removeOnPreDrawListenerSafe
import io.sentry.util.AutoClosableReentrantLock
import java.lang.ref.WeakReference
import java.util.WeakHashMap
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.atomic.AtomicBoolean

Expand All @@ -33,6 +34,7 @@ internal class WindowRecorder(
private val isRecording = AtomicBoolean(false)
private val rootViews = ArrayList<WeakReference<View>>()
private var lastKnownWindowSize: Point = Point()
private val rootLayoutListeners = WeakHashMap<View, View.OnLayoutChangeListener>()
private val rootViewsLock = AutoClosableReentrantLock()
private val capturerLock = AutoClosableReentrantLock()
private val backgroundProcessingHandlerLock = AutoClosableReentrantLock()
Expand Down Expand Up @@ -124,21 +126,59 @@ internal class WindowRecorder(
rootViews.add(WeakReference(root))
capturer?.recorder?.bind(root)
determineWindowSize(root)
attachLayoutListener(root)
} else {
detachLayoutListener(root)
capturer?.recorder?.unbind(root)
rootViews.removeAll { it.get() == root }

val newRoot = rootViews.lastOrNull()?.get()
if (newRoot != null && root != newRoot) {
capturer?.recorder?.bind(newRoot)
determineWindowSize(newRoot)
attachLayoutListener(newRoot)
} else {
Unit // synchronized block wants us to return something lol
}
}
}
}

/**
* Activities that handle their own configuration changes (e.g. Unity, video players via
* `android:configChanges="orientation|screenSize|..."`) keep the same root view across rotations,
* so [onRootViewsChanged] never fires and [determineWindowSize] would never re-detect the new
* dimensions. Watch the root for layout-time size changes to catch these cases.
*/
private fun attachLayoutListener(root: View) {
if (rootLayoutListeners.containsKey(root)) return
val listener =
View.OnLayoutChangeListener {
v,
left,
top,
right,
bottom,
oldLeft,
oldTop,
oldRight,
oldBottom ->
val width = right - left
val height = bottom - top
val oldWidth = oldRight - oldLeft
val oldHeight = oldBottom - oldTop
if (width != oldWidth || height != oldHeight) {
determineWindowSize(v)
}
}
rootLayoutListeners[root] = listener
root.addOnLayoutChangeListener(listener)
}
Comment thread
cursor[bot] marked this conversation as resolved.

private fun detachLayoutListener(root: View) {
rootLayoutListeners.remove(root)?.let { root.removeOnLayoutChangeListener(it) }
}

fun determineWindowSize(root: View) {
if (root.hasSize()) {
if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) {
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
Expand Down Expand Up @@ -222,7 +262,13 @@ internal class WindowRecorder(
override fun reset() {
lastKnownWindowSize.set(0, 0)
rootViewsLock.acquire().use {
rootViews.forEach { capturer?.recorder?.unbind(it.get()) }
rootViews.forEach {
val root = it.get()
if (root != null) {
detachLayoutListener(root)
capturer?.recorder?.unbind(root)
}
}
rootViews.clear()
}
}
Expand Down
Loading
Loading