Skip to content

Commit 1e82a71

Browse files
authored
Compress Screenshots on a background thread (#4295)
* Compress Screenshots on a background thread * Update Changelog * Recover APIs used by hybrid SDKs * Recycle bitmap after compression
1 parent 5c3cd7a commit 1e82a71

File tree

8 files changed

+195
-14
lines changed

8 files changed

+195
-14
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
- Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293))
88
- Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310))
99

10+
### Fixes
11+
12+
- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295))
13+
1014
## 8.6.0
1115

1216
### Behavioral Changes

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

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
package io.sentry.android.core;
22

33
import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY;
4-
import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot;
4+
import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot;
55
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;
66

77
import android.app.Activity;
8+
import android.graphics.Bitmap;
89
import io.sentry.Attachment;
910
import io.sentry.EventProcessor;
1011
import io.sentry.Hint;
1112
import io.sentry.SentryEvent;
1213
import io.sentry.SentryLevel;
1314
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
1415
import io.sentry.android.core.internal.util.Debouncer;
16+
import io.sentry.android.core.internal.util.ScreenshotUtils;
1517
import io.sentry.protocol.SentryTransaction;
1618
import io.sentry.util.HintUtils;
1719
import io.sentry.util.Objects;
@@ -87,14 +89,19 @@ public ScreenshotEventProcessor(
8789
return event;
8890
}
8991

90-
final byte[] screenshot =
91-
takeScreenshot(
92+
final Bitmap screenshot =
93+
captureScreenshot(
9294
activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider);
9395
if (screenshot == null) {
9496
return event;
9597
}
9698

97-
hint.setScreenshot(Attachment.fromScreenshot(screenshot));
99+
hint.setScreenshot(
100+
Attachment.fromByteProvider(
101+
() -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()),
102+
"screenshot.png",
103+
"image/png",
104+
false));
98105
hint.set(ANDROID_ACTIVITY, activity);
99106
return event;
100107
}

sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java

+48-2
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,44 @@ public class ScreenshotUtils {
2727

2828
private static final long CAPTURE_TIMEOUT_MS = 1000;
2929

30+
// Used by Hybrid SDKs
31+
/**
32+
* @noinspection unused
33+
*/
3034
public static @Nullable byte[] takeScreenshot(
3135
final @NotNull Activity activity,
3236
final @NotNull ILogger logger,
3337
final @NotNull BuildInfoProvider buildInfoProvider) {
3438
return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider);
3539
}
3640

41+
// Used by Hybrid SDKs
3742
@SuppressLint("NewApi")
3843
public static @Nullable byte[] takeScreenshot(
3944
final @NotNull Activity activity,
4045
final @NotNull IThreadChecker threadChecker,
4146
final @NotNull ILogger logger,
4247
final @NotNull BuildInfoProvider buildInfoProvider) {
48+
49+
final @Nullable Bitmap screenshot =
50+
captureScreenshot(activity, threadChecker, logger, buildInfoProvider);
51+
return compressBitmapToPng(screenshot, logger);
52+
}
53+
54+
public static @Nullable Bitmap captureScreenshot(
55+
final @NotNull Activity activity,
56+
final @NotNull ILogger logger,
57+
final @NotNull BuildInfoProvider buildInfoProvider) {
58+
return captureScreenshot(
59+
activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider);
60+
}
61+
62+
@SuppressLint("NewApi")
63+
public static @Nullable Bitmap captureScreenshot(
64+
final @NotNull Activity activity,
65+
final @NotNull IThreadChecker threadChecker,
66+
final @NotNull ILogger logger,
67+
final @NotNull BuildInfoProvider buildInfoProvider) {
4368
// We are keeping BuildInfoProvider param for compatibility, as it's being used by
4469
// cross-platform SDKs
4570

@@ -71,7 +96,7 @@ public class ScreenshotUtils {
7196
return null;
7297
}
7398

74-
try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
99+
try {
75100
// ARGB_8888 -> This configuration is very flexible and offers the best quality
76101
final Bitmap bitmap =
77102
Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
@@ -132,10 +157,31 @@ public class ScreenshotUtils {
132157
return null;
133158
}
134159
}
160+
return bitmap;
161+
} catch (Throwable e) {
162+
logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e);
163+
}
164+
return null;
165+
}
135166

167+
/**
168+
* Compresses the supplied Bitmap to a PNG byte array. After compression, the Bitmap will be
169+
* recycled.
170+
*
171+
* @param bitmap The bitmap to compress
172+
* @param logger the logger
173+
* @return the Bitmap in PNG format, or null if the bitmap was null, recycled or compressing faile
174+
*/
175+
public static @Nullable byte[] compressBitmapToPng(
176+
final @Nullable Bitmap bitmap, final @NotNull ILogger logger) {
177+
if (bitmap == null || bitmap.isRecycled()) {
178+
return null;
179+
}
180+
try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
136181
// 0 meaning compress for small size, 100 meaning compress for max quality.
137182
// Some formats, like PNG which is lossless, will ignore the quality setting.
138183
bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream);
184+
bitmap.recycle();
139185

140186
if (byteArrayOutputStream.size() <= 0) {
141187
logger.log(SentryLevel.DEBUG, "Screenshot is 0 bytes, not attaching the image.");
@@ -145,7 +191,7 @@ public class ScreenshotUtils {
145191
// screenshot png is around ~100-150 kb
146192
return byteArrayOutputStream.toByteArray();
147193
} catch (Throwable e) {
148-
logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e);
194+
logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e);
149195
}
150196
return null;
151197
}

sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt

+41-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package io.sentry.android.core.internal.util
22

33
import android.app.Activity
4+
import android.graphics.Bitmap
45
import android.os.Build
56
import android.os.Bundle
67
import android.view.View
78
import android.view.Window
89
import androidx.test.ext.junit.runners.AndroidJUnit4
910
import io.sentry.ILogger
11+
import io.sentry.NoOpLogger
1012
import io.sentry.android.core.BuildInfoProvider
1113
import junit.framework.TestCase.assertNull
1214
import org.junit.runner.RunWith
@@ -16,7 +18,9 @@ import org.robolectric.Robolectric.buildActivity
1618
import org.robolectric.annotation.Config
1719
import org.robolectric.shadows.ShadowPixelCopy
1820
import kotlin.test.Test
21+
import kotlin.test.assertFalse
1922
import kotlin.test.assertNotNull
23+
import kotlin.test.assertTrue
2024

2125
@Config(
2226
shadows = [ShadowPixelCopy::class],
@@ -32,7 +36,7 @@ class ScreenshotUtilTest {
3236
whenever(activity.isDestroyed).thenReturn(false)
3337

3438
val data =
35-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
39+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
3640
assertNull(data)
3741
}
3842

@@ -44,7 +48,7 @@ class ScreenshotUtilTest {
4448
whenever(activity.window).thenReturn(mock<Window>())
4549

4650
val data =
47-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
51+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
4852
assertNull(data)
4953
}
5054

@@ -60,7 +64,7 @@ class ScreenshotUtilTest {
6064
whenever(window.peekDecorView()).thenReturn(decorView)
6165

6266
val data =
63-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
67+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
6468
assertNull(data)
6569
}
6670

@@ -81,7 +85,7 @@ class ScreenshotUtilTest {
8185
whenever(rootView.height).thenReturn(0)
8286

8387
val data =
84-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
88+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
8589
assertNull(data)
8690
}
8791

@@ -94,7 +98,7 @@ class ScreenshotUtilTest {
9498
val buildInfoProvider = mock<BuildInfoProvider>()
9599
whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O)
96100

97-
val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider)
101+
val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider)
98102
assertNotNull(data)
99103
}
100104

@@ -107,9 +111,40 @@ class ScreenshotUtilTest {
107111
val buildInfoProvider = mock<BuildInfoProvider>()
108112
whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N)
109113

110-
val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider)
114+
val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider)
111115
assertNotNull(data)
112116
}
117+
118+
@Test
119+
fun `a null bitmap compresses into null`() {
120+
val bytes = ScreenshotUtils.compressBitmapToPng(null, NoOpLogger.getInstance())
121+
assertNull(bytes)
122+
}
123+
124+
@Test
125+
fun `a recycled bitmap compresses into null`() {
126+
val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
127+
bitmap.recycle()
128+
129+
val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance())
130+
assertNull(bytes)
131+
}
132+
133+
@Test
134+
fun `a valid bitmap compresses into a valid bytearray`() {
135+
val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
136+
val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance())
137+
assertNotNull(bytes)
138+
assertTrue(bytes.isNotEmpty())
139+
}
140+
141+
@Test
142+
fun `compressBitmapToPng recycles the supplied bitmap`() {
143+
val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
144+
assertFalse(bitmap.isRecycled)
145+
ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance())
146+
assertTrue(bitmap.isRecycled)
147+
}
113148
}
114149

115150
class ExampleActivity : Activity() {

sentry/api/sentry.api

+3
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ public final class io/sentry/Attachment {
1111
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
1212
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
1313
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V
14+
public fun <init> (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
1415
public fun <init> ([BLjava/lang/String;)V
1516
public fun <init> ([BLjava/lang/String;Ljava/lang/String;)V
1617
public fun <init> ([BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
1718
public fun <init> ([BLjava/lang/String;Ljava/lang/String;Z)V
19+
public static fun fromByteProvider (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/Attachment;
1820
public static fun fromScreenshot ([B)Lio/sentry/Attachment;
1921
public static fun fromThreadDump ([B)Lio/sentry/Attachment;
2022
public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment;
2123
public fun getAttachmentType ()Ljava/lang/String;
24+
public fun getByteProvider ()Ljava/util/concurrent/Callable;
2225
public fun getBytes ()[B
2326
public fun getContentType ()Ljava/lang/String;
2427
public fun getFilename ()Ljava/lang/String;

sentry/src/main/java/io/sentry/Attachment.java

+53-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io.sentry.protocol.ViewHierarchy;
44
import java.io.File;
5+
import java.util.concurrent.Callable;
56
import org.jetbrains.annotations.NotNull;
67
import org.jetbrains.annotations.Nullable;
78

@@ -10,6 +11,7 @@ public final class Attachment {
1011

1112
private @Nullable byte[] bytes;
1213
private final @Nullable JsonSerializable serializable;
14+
private final @Nullable Callable<byte[]> byteProvider;
1315
private @Nullable String pathname;
1416
private final @NotNull String filename;
1517
private final @Nullable String contentType;
@@ -84,6 +86,7 @@ public Attachment(
8486
final boolean addToTransactions) {
8587
this.bytes = bytes;
8688
this.serializable = null;
89+
this.byteProvider = null;
8790
this.filename = filename;
8891
this.contentType = contentType;
8992
this.attachmentType = attachmentType;
@@ -109,6 +112,33 @@ public Attachment(
109112
final boolean addToTransactions) {
110113
this.bytes = null;
111114
this.serializable = serializable;
115+
this.byteProvider = null;
116+
this.filename = filename;
117+
this.contentType = contentType;
118+
this.attachmentType = attachmentType;
119+
this.addToTransactions = addToTransactions;
120+
}
121+
122+
/**
123+
* Initializes an Attachment with bytes factory, a filename, a content type, and
124+
* addToTransactions.
125+
*
126+
* @param byteProvider A provider holding the attachment payload
127+
* @param filename The name of the attachment to display in Sentry.
128+
* @param contentType The content type of the attachment.
129+
* @param attachmentType the attachment type.
130+
* @param addToTransactions <code>true</code> if the SDK should add this attachment to every
131+
* {@link ITransaction} or set to <code>false</code> if it shouldn't.
132+
*/
133+
public Attachment(
134+
final @NotNull Callable<byte[]> byteProvider,
135+
final @NotNull String filename,
136+
final @Nullable String contentType,
137+
final @Nullable String attachmentType,
138+
final boolean addToTransactions) {
139+
this.bytes = null;
140+
this.serializable = null;
141+
this.byteProvider = byteProvider;
112142
this.filename = filename;
113143
this.contentType = contentType;
114144
this.attachmentType = attachmentType;
@@ -186,6 +216,7 @@ public Attachment(
186216
this.pathname = pathname;
187217
this.filename = filename;
188218
this.serializable = null;
219+
this.byteProvider = null;
189220
this.contentType = contentType;
190221
this.attachmentType = attachmentType;
191222
this.addToTransactions = addToTransactions;
@@ -212,6 +243,7 @@ public Attachment(
212243
this.pathname = pathname;
213244
this.filename = filename;
214245
this.serializable = null;
246+
this.byteProvider = null;
215247
this.contentType = contentType;
216248
this.addToTransactions = addToTransactions;
217249
}
@@ -240,6 +272,7 @@ public Attachment(
240272
this.pathname = pathname;
241273
this.filename = filename;
242274
this.serializable = null;
275+
this.byteProvider = null;
243276
this.contentType = contentType;
244277
this.addToTransactions = addToTransactions;
245278
this.attachmentType = attachmentType;
@@ -310,16 +343,35 @@ boolean isAddToTransactions() {
310343
return attachmentType;
311344
}
312345

346+
public @Nullable Callable<byte[]> getByteProvider() {
347+
return byteProvider;
348+
}
349+
313350
/**
314351
* Creates a new Screenshot Attachment
315352
*
316-
* @param screenshotBytes the array bytes
353+
* @param screenshotBytes the array bytes of the PNG screenshot
317354
* @return the Attachment
318355
*/
319356
public static @NotNull Attachment fromScreenshot(final byte[] screenshotBytes) {
320357
return new Attachment(screenshotBytes, "screenshot.png", "image/png", false);
321358
}
322359

360+
/**
361+
* Creates a new Screenshot Attachment
362+
*
363+
* @param provider the mechanism providing the screenshot payload
364+
* @return the Attachment
365+
*/
366+
public static @NotNull Attachment fromByteProvider(
367+
final @NotNull Callable<byte[]> provider,
368+
final @NotNull String filename,
369+
final @Nullable String contentType,
370+
final boolean addToTransactions) {
371+
return new Attachment(
372+
provider, filename, contentType, DEFAULT_ATTACHMENT_TYPE, addToTransactions);
373+
}
374+
323375
/**
324376
* Creates a new View Hierarchy Attachment
325377
*

0 commit comments

Comments
 (0)