Skip to content

Commit 9246ed4

Browse files
authored
Add debouncing mechanism and before-capture callbacks for screenshots/vh (#2773)
1 parent 496bdfd commit 9246ed4

File tree

10 files changed

+405
-3
lines changed

10 files changed

+405
-3
lines changed

.github/workflows/agp-matrix.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
access_token: ${{ github.token }}
1818

1919
agp-matrix-compatibility:
20-
timeout-minutes: 25
20+
timeout-minutes: 30
2121
runs-on: macos-latest
2222
strategy:
2323
fail-fast: false

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Add debouncing mechanism and before-capture callbacks for screenshots and view hierarchies ([#2773](https://github.com/getsentry/sentry-java/pull/2773))
8+
39
## 6.23.0
410

511
### Features

sentry-android-core/api/sentry-android-core.api

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
207207
public fun <init> ()V
208208
public fun enableAllAutoBreadcrumbs (Z)V
209209
public fun getAnrTimeoutIntervalMillis ()J
210+
public fun getBeforeScreenshotCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;
211+
public fun getBeforeViewHierarchyCaptureCallback ()Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;
210212
public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader;
211213
public fun getNativeSdkName ()Ljava/lang/String;
212214
public fun getProfilingTracesHz ()I
@@ -231,6 +233,8 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
231233
public fun setAnrTimeoutIntervalMillis (J)V
232234
public fun setAttachScreenshot (Z)V
233235
public fun setAttachViewHierarchy (Z)V
236+
public fun setBeforeScreenshotCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V
237+
public fun setBeforeViewHierarchyCaptureCallback (Lio/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback;)V
234238
public fun setCollectAdditionalContext (Z)V
235239
public fun setDebugImagesLoader (Lio/sentry/android/core/IDebugImagesLoader;)V
236240
public fun setEnableActivityLifecycleBreadcrumbs (Z)V
@@ -247,6 +251,10 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr
247251
public fun setProfilingTracesIntervalMillis (I)V
248252
}
249253

254+
public abstract interface class io/sentry/android/core/SentryAndroidOptions$BeforeCaptureCallback {
255+
public abstract fun execute (Lio/sentry/SentryEvent;Lio/sentry/Hint;Z)Z
256+
}
257+
250258
public final class io/sentry/android/core/SentryInitProvider {
251259
public fun <init> ()V
252260
public fun attachInfo (Landroid/content/Context;Landroid/content/pm/ProviderInfo;)V

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import io.sentry.IntegrationName;
1111
import io.sentry.SentryEvent;
1212
import io.sentry.SentryLevel;
13+
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
14+
import io.sentry.android.core.internal.util.Debouncer;
1315
import io.sentry.util.HintUtils;
1416
import io.sentry.util.Objects;
1517
import org.jetbrains.annotations.ApiStatus;
@@ -26,12 +28,17 @@ public final class ScreenshotEventProcessor implements EventProcessor, Integrati
2628
private final @NotNull SentryAndroidOptions options;
2729
private final @NotNull BuildInfoProvider buildInfoProvider;
2830

31+
private final @NotNull Debouncer debouncer;
32+
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
33+
2934
public ScreenshotEventProcessor(
3035
final @NotNull SentryAndroidOptions options,
3136
final @NotNull BuildInfoProvider buildInfoProvider) {
3237
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required");
3338
this.buildInfoProvider =
3439
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
40+
this.debouncer = new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS);
41+
3542
if (options.isAttachScreenshot()) {
3643
addIntegrationToSdkVersion();
3744
}
@@ -52,6 +59,19 @@ public ScreenshotEventProcessor(
5259
return event;
5360
}
5461

62+
// skip capturing in case of debouncing (=too many frequent capture requests)
63+
// the BeforeCaptureCallback may overrules the debouncing decision
64+
final boolean shouldDebounce = debouncer.checkForDebounce();
65+
final @Nullable SentryAndroidOptions.BeforeCaptureCallback beforeCaptureCallback =
66+
options.getBeforeScreenshotCaptureCallback();
67+
if (beforeCaptureCallback != null) {
68+
if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) {
69+
return event;
70+
}
71+
} else if (shouldDebounce) {
72+
return event;
73+
}
74+
5575
final byte[] screenshot =
5676
takeScreenshot(
5777
activity, options.getMainThreadChecker(), options.getLogger(), buildInfoProvider);

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

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package io.sentry.android.core;
22

3+
import io.sentry.Hint;
34
import io.sentry.ISpan;
45
import io.sentry.Scope;
56
import io.sentry.Sentry;
7+
import io.sentry.SentryEvent;
68
import io.sentry.SentryOptions;
79
import io.sentry.SpanStatus;
810
import io.sentry.android.core.internal.util.RootChecker;
@@ -98,10 +100,18 @@ public final class SentryAndroidOptions extends SentryOptions {
98100
/** Interface that loads the debug images list */
99101
private @NotNull IDebugImagesLoader debugImagesLoader = NoOpDebugImagesLoader.getInstance();
100102

101-
/** Enables or disables the attach screenshot feature when an error happened. */
103+
/**
104+
* Enables or disables the attach screenshot feature when an error happened. Use {@link
105+
* SentryAndroidOptions#setBeforeScreenshotCaptureCallback(BeforeCaptureCallback)} ()} to control
106+
* when a screenshot should be captured.
107+
*/
102108
private boolean attachScreenshot;
103109

104-
/** Enables or disables the attach view hierarchy feature when an error happened. */
110+
/**
111+
* Enables or disables the attach view hierarchy feature when an error happened. Use {@link
112+
* SentryAndroidOptions#setBeforeViewHierarchyCaptureCallback(BeforeCaptureCallback)} ()} to
113+
* control when a view hierarchy should be captured.
114+
*/
105115
private boolean attachViewHierarchy;
106116

107117
/**
@@ -143,6 +153,35 @@ public final class SentryAndroidOptions extends SentryOptions {
143153
*/
144154
private boolean enableRootCheck = true;
145155

156+
private @Nullable BeforeCaptureCallback beforeScreenshotCaptureCallback;
157+
158+
private @Nullable BeforeCaptureCallback beforeViewHierarchyCaptureCallback;
159+
160+
public interface BeforeCaptureCallback {
161+
162+
/**
163+
* A callback which can be used to suppress capturing of screenshots or view hierarchies. This
164+
* gives more fine grained control when capturing should be performed. E.g. - only capture
165+
* screenshots for fatal events - overrule any debouncing for important events <br>
166+
* As capturing can be resource-intensive, the debounce parameter should be respected if
167+
* possible.
168+
*
169+
* <pre>
170+
* if (debounce) {
171+
* return false;
172+
* } else {
173+
* // check event and hint
174+
* }
175+
* </pre>
176+
*
177+
* @param event the event
178+
* @param hint the hints
179+
* @param debounce true if capturing is marked for being debounced
180+
* @return true if capturing should be performed, false otherwise
181+
*/
182+
boolean execute(@NotNull SentryEvent event, @NotNull Hint hint, boolean debounce);
183+
}
184+
146185
public SentryAndroidOptions() {
147186
setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME);
148187
setSdkVersion(createSdkVersion());
@@ -441,4 +480,34 @@ public boolean isEnableRootCheck() {
441480
public void setEnableRootCheck(final boolean enableRootCheck) {
442481
this.enableRootCheck = enableRootCheck;
443482
}
483+
484+
public @Nullable BeforeCaptureCallback getBeforeScreenshotCaptureCallback() {
485+
return beforeScreenshotCaptureCallback;
486+
}
487+
488+
/**
489+
* Sets a callback which is executed before capturing screenshots. Only relevant if
490+
* attachScreenshot is set to true.
491+
*
492+
* @param beforeScreenshotCaptureCallback the callback to execute
493+
*/
494+
public void setBeforeScreenshotCaptureCallback(
495+
final @NotNull BeforeCaptureCallback beforeScreenshotCaptureCallback) {
496+
this.beforeScreenshotCaptureCallback = beforeScreenshotCaptureCallback;
497+
}
498+
499+
public @Nullable BeforeCaptureCallback getBeforeViewHierarchyCaptureCallback() {
500+
return beforeViewHierarchyCaptureCallback;
501+
}
502+
503+
/**
504+
* Sets a callback which is executed before capturing view hierarchies. Only relevant if
505+
* attachViewHierarchy is set to true.
506+
*
507+
* @param beforeViewHierarchyCaptureCallback the callback to execute
508+
*/
509+
public void setBeforeViewHierarchyCaptureCallback(
510+
final @NotNull BeforeCaptureCallback beforeViewHierarchyCaptureCallback) {
511+
this.beforeViewHierarchyCaptureCallback = beforeViewHierarchyCaptureCallback;
512+
}
444513
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import io.sentry.SentryEvent;
1414
import io.sentry.SentryLevel;
1515
import io.sentry.android.core.internal.gestures.ViewUtils;
16+
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1617
import io.sentry.android.core.internal.util.AndroidMainThreadChecker;
18+
import io.sentry.android.core.internal.util.Debouncer;
1719
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
1820
import io.sentry.protocol.ViewHierarchy;
1921
import io.sentry.protocol.ViewHierarchyNode;
@@ -35,10 +37,15 @@
3537
public final class ViewHierarchyEventProcessor implements EventProcessor, IntegrationName {
3638

3739
private final @NotNull SentryAndroidOptions options;
40+
private final @NotNull Debouncer debouncer;
41+
3842
private static final long CAPTURE_TIMEOUT_MS = 1000;
43+
private static final long DEBOUNCE_WAIT_TIME_MS = 2000;
3944

4045
public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) {
4146
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required");
47+
this.debouncer = new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS);
48+
4249
if (options.isAttachViewHierarchy()) {
4350
addIntegrationToSdkVersion();
4451
}
@@ -59,6 +66,19 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options)
5966
return event;
6067
}
6168

69+
// skip capturing in case of debouncing (=too many frequent capture requests)
70+
// the BeforeCaptureCallback may overrules the debouncing decision
71+
final boolean shouldDebounce = debouncer.checkForDebounce();
72+
final @Nullable SentryAndroidOptions.BeforeCaptureCallback beforeCaptureCallback =
73+
options.getBeforeViewHierarchyCaptureCallback();
74+
if (beforeCaptureCallback != null) {
75+
if (!beforeCaptureCallback.execute(event, hint, shouldDebounce)) {
76+
return event;
77+
}
78+
} else if (shouldDebounce) {
79+
return event;
80+
}
81+
6282
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
6383
final @Nullable ViewHierarchy viewHierarchy =
6484
snapshotViewHierarchy(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.sentry.android.core.internal.util;
2+
3+
import io.sentry.transport.ICurrentDateProvider;
4+
import org.jetbrains.annotations.ApiStatus;
5+
import org.jetbrains.annotations.NotNull;
6+
7+
/** A simple time-based debouncing mechanism */
8+
@ApiStatus.Internal
9+
public class Debouncer {
10+
11+
private final long waitTimeMs;
12+
private final @NotNull ICurrentDateProvider timeProvider;
13+
14+
private Long lastExecutionTime = null;
15+
16+
public Debouncer(final @NotNull ICurrentDateProvider timeProvider, final long waitTimeMs) {
17+
this.timeProvider = timeProvider;
18+
this.waitTimeMs = waitTimeMs;
19+
}
20+
21+
/**
22+
* @return true if the execution should be debounced due to the last execution being within within
23+
* waitTimeMs, otherwise false.
24+
*/
25+
public boolean checkForDebounce() {
26+
final long now = timeProvider.getCurrentTimeMillis();
27+
if (lastExecutionTime == null || (lastExecutionTime + waitTimeMs) <= now) {
28+
lastExecutionTime = now;
29+
return false;
30+
}
31+
return true;
32+
}
33+
}

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import io.sentry.MainEventProcessor
1010
import io.sentry.SentryEvent
1111
import io.sentry.SentryIntegrationPackageStorage
1212
import io.sentry.TypeCheckHint.ANDROID_ACTIVITY
13+
import io.sentry.protocol.SentryException
1314
import io.sentry.util.thread.IMainThreadChecker
1415
import org.junit.runner.RunWith
1516
import org.mockito.kotlin.any
@@ -66,6 +67,7 @@ class ScreenshotEventProcessorTest {
6667
@BeforeTest
6768
fun `set up`() {
6869
fixture = Fixture()
70+
CurrentActivityHolder.getInstance().clearActivity()
6971
}
7072

7173
@Test
@@ -200,5 +202,102 @@ class ScreenshotEventProcessorTest {
200202
assertFalse(fixture.options.sdkVersion!!.integrationSet.contains("Screenshot"))
201203
}
202204

205+
@Test
206+
fun `when screenshots are captured rapidly, capturing should be debounced`() {
207+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
208+
209+
val processor = fixture.getSut(true)
210+
val event = SentryEvent().apply {
211+
exceptions = listOf(SentryException())
212+
}
213+
val hint0 = Hint()
214+
processor.process(event, hint0)
215+
assertNotNull(hint0.screenshot)
216+
217+
val hint1 = Hint()
218+
processor.process(event, hint1)
219+
assertNull(hint1.screenshot)
220+
}
221+
222+
@Test
223+
fun `when screenshots are captured rapidly, debounce flag should be propagated`() {
224+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
225+
226+
var debounceFlag = false
227+
fixture.options.setBeforeScreenshotCaptureCallback { _, _, debounce ->
228+
debounceFlag = debounce
229+
true
230+
}
231+
232+
val processor = fixture.getSut(true)
233+
val event = SentryEvent().apply {
234+
exceptions = listOf(SentryException())
235+
}
236+
val hint0 = Hint()
237+
processor.process(event, hint0)
238+
assertFalse(debounceFlag)
239+
240+
val hint1 = Hint()
241+
processor.process(event, hint1)
242+
assertTrue(debounceFlag)
243+
}
244+
245+
@Test
246+
fun `when screenshots are captured rapidly, capture callback can still overrule debouncing`() {
247+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
248+
249+
val processor = fixture.getSut(true)
250+
251+
fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ ->
252+
true
253+
}
254+
val event = SentryEvent().apply {
255+
exceptions = listOf(SentryException())
256+
}
257+
val hint0 = Hint()
258+
processor.process(event, hint0)
259+
assertNotNull(hint0.screenshot)
260+
261+
val hint1 = Hint()
262+
processor.process(event, hint1)
263+
assertNotNull(hint1.screenshot)
264+
}
265+
266+
@Test
267+
fun `when capture callback returns false, no screenshot should be captured`() {
268+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
269+
270+
fixture.options.setBeforeScreenshotCaptureCallback { _, _, _ ->
271+
false
272+
}
273+
val processor = fixture.getSut(true)
274+
275+
val event = SentryEvent().apply {
276+
exceptions = listOf(SentryException())
277+
}
278+
val hint = Hint()
279+
280+
processor.process(event, hint)
281+
assertNull(hint.screenshot)
282+
}
283+
284+
@Test
285+
fun `when capture callback returns true, a screenshot should be captured`() {
286+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
287+
288+
fixture.options.setBeforeViewHierarchyCaptureCallback { _, _, _ ->
289+
true
290+
}
291+
val processor = fixture.getSut(true)
292+
293+
val event = SentryEvent().apply {
294+
exceptions = listOf(SentryException())
295+
}
296+
val hint = Hint()
297+
298+
processor.process(event, hint)
299+
assertNotNull(hint.screenshot)
300+
}
301+
203302
private fun getEvent(): SentryEvent = SentryEvent(Throwable("Throwable"))
204303
}

0 commit comments

Comments
 (0)