Skip to content

Commit a04ef28

Browse files
authored
Merge 001f3ad into 52feca7
2 parents 52feca7 + 001f3ad commit a04ef28

13 files changed

Lines changed: 641 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Session Replay: experimental support for capturing `SurfaceView` content (e.g. Unity, video players, maps) ([#5333](https://github.com/getsentry/sentry-java/pull/5333))
8+
- To enable, set `options.sessionReplay.isCaptureSurfaceViews = true`
9+
- Or via manifest: `<meta-data android:name="io.sentry.session-replay.capture-surface-views" android:value="true" />`
10+
- **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.
11+
512
### Dependencies
613

714
- Bump Native SDK from v0.13.7 to v0.13.8 ([#5334](https://github.com/getsentry/sentry-java/pull/5334))

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ final class ManifestMetadataReader {
120120

121121
static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug";
122122
static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy";
123+
static final String REPLAYS_CAPTURE_SURFACE_VIEWS =
124+
"io.sentry.session-replay.capture-surface-views";
123125

124126
static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS =
125127
"io.sentry.session-replay.network-detail-allow-urls";
@@ -547,6 +549,15 @@ static void applyMetadata(
547549
}
548550
}
549551

552+
options
553+
.getSessionReplay()
554+
.setCaptureSurfaceViews(
555+
readBool(
556+
metadata,
557+
logger,
558+
REPLAYS_CAPTURE_SURFACE_VIEWS,
559+
options.getSessionReplay().isCaptureSurfaceViews()));
560+
550561
// Network Details Configuration
551562
if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) {
552563
final @Nullable List<String> allowUrls =

sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ private boolean isMaskingEnabled() {
201201

202202
final ViewHierarchyNode rootNode =
203203
ViewHierarchyNode.Companion.fromView(rootView, null, 0, options.getScreenshot());
204-
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger());
204+
ViewsKt.traverse(rootView, rootNode, options.getScreenshot(), options.getLogger(), null);
205205
return rootNode;
206206
} catch (Throwable e) {
207207
options.getLogger().log(SentryLevel.ERROR, "Failed to build view hierarchy", e);

sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,6 +2022,31 @@ class ManifestMetadataReaderTest {
20222022
)
20232023
}
20242024

2025+
@Test
2026+
fun `applyMetadata reads capture-surface-views to options`() {
2027+
// Arrange
2028+
val bundle = bundleOf(ManifestMetadataReader.REPLAYS_CAPTURE_SURFACE_VIEWS to true)
2029+
val context = fixture.getContext(metaData = bundle)
2030+
2031+
// Act
2032+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2033+
2034+
// Assert
2035+
assertTrue(fixture.options.sessionReplay.isCaptureSurfaceViews)
2036+
}
2037+
2038+
@Test
2039+
fun `applyMetadata reads capture-surface-views and keeps default if not found`() {
2040+
// Arrange
2041+
val context = fixture.getContext()
2042+
2043+
// Act
2044+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2045+
2046+
// Assert
2047+
assertFalse(fixture.options.sessionReplay.isCaptureSurfaceViews)
2048+
}
2049+
20252050
@Test
20262051
fun `applyMetadata reads anrProfilingSampleRate to options`() {
20272052
// Arrange

sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ internal class ScreenshotRecorder(
4747
options,
4848
config,
4949
debugOverlayDrawable,
50+
markContentChanged = { contentChanged.set(true) },
5051
)
5152
}
5253

sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import io.sentry.android.replay.util.hasSize
1717
import io.sentry.android.replay.util.removeOnPreDrawListenerSafe
1818
import io.sentry.util.AutoClosableReentrantLock
1919
import java.lang.ref.WeakReference
20+
import java.util.WeakHashMap
2021
import java.util.concurrent.ScheduledExecutorService
2122
import java.util.concurrent.atomic.AtomicBoolean
2223

@@ -33,6 +34,7 @@ internal class WindowRecorder(
3334
private val isRecording = AtomicBoolean(false)
3435
private val rootViews = ArrayList<WeakReference<View>>()
3536
private var lastKnownWindowSize: Point = Point()
37+
private val rootLayoutListeners = WeakHashMap<View, View.OnLayoutChangeListener>()
3638
private val rootViewsLock = AutoClosableReentrantLock()
3739
private val capturerLock = AutoClosableReentrantLock()
3840
private val backgroundProcessingHandlerLock = AutoClosableReentrantLock()
@@ -124,21 +126,59 @@ internal class WindowRecorder(
124126
rootViews.add(WeakReference(root))
125127
capturer?.recorder?.bind(root)
126128
determineWindowSize(root)
129+
attachLayoutListener(root)
127130
} else {
131+
detachLayoutListener(root)
128132
capturer?.recorder?.unbind(root)
129133
rootViews.removeAll { it.get() == root }
130134

131135
val newRoot = rootViews.lastOrNull()?.get()
132136
if (newRoot != null && root != newRoot) {
133137
capturer?.recorder?.bind(newRoot)
134138
determineWindowSize(newRoot)
139+
attachLayoutListener(newRoot)
135140
} else {
136141
Unit // synchronized block wants us to return something lol
137142
}
138143
}
139144
}
140145
}
141146

147+
/**
148+
* Activities that handle their own configuration changes (e.g. Unity, video players via
149+
* `android:configChanges="orientation|screenSize|..."`) keep the same root view across rotations,
150+
* so [onRootViewsChanged] never fires and [determineWindowSize] would never re-detect the new
151+
* dimensions. Watch the root for layout-time size changes to catch these cases.
152+
*/
153+
private fun attachLayoutListener(root: View) {
154+
if (rootLayoutListeners.containsKey(root)) return
155+
val listener =
156+
View.OnLayoutChangeListener {
157+
v,
158+
left,
159+
top,
160+
right,
161+
bottom,
162+
oldLeft,
163+
oldTop,
164+
oldRight,
165+
oldBottom ->
166+
val width = right - left
167+
val height = bottom - top
168+
val oldWidth = oldRight - oldLeft
169+
val oldHeight = oldBottom - oldTop
170+
if (width != oldWidth || height != oldHeight) {
171+
determineWindowSize(v)
172+
}
173+
}
174+
rootLayoutListeners[root] = listener
175+
root.addOnLayoutChangeListener(listener)
176+
}
177+
178+
private fun detachLayoutListener(root: View) {
179+
rootLayoutListeners.remove(root)?.let { root.removeOnLayoutChangeListener(it) }
180+
}
181+
142182
fun determineWindowSize(root: View) {
143183
if (root.hasSize()) {
144184
if (root.width != lastKnownWindowSize.x && root.height != lastKnownWindowSize.y) {
@@ -222,7 +262,13 @@ internal class WindowRecorder(
222262
override fun reset() {
223263
lastKnownWindowSize.set(0, 0)
224264
rootViewsLock.acquire().use {
225-
rootViews.forEach { capturer?.recorder?.unbind(it.get()) }
265+
rootViews.forEach {
266+
val root = it.get()
267+
if (root != null) {
268+
detachLayoutListener(root)
269+
capturer?.recorder?.unbind(root)
270+
}
271+
}
226272
rootViews.clear()
227273
}
228274
}

0 commit comments

Comments
 (0)