Skip to content

Commit 49f0f1c

Browse files
markushiromtsn
authored andcommitted
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 4727424 commit 49f0f1c

File tree

8 files changed

+197
-14
lines changed

8 files changed

+197
-14
lines changed

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+
### Fixes
6+
7+
- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295))
8+
39
## 7.22.5
410

511
### Fixes

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

Lines changed: 11 additions & 4 deletions
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.getMainThreadChecker(), 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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ 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,
@@ -35,12 +39,33 @@ public class ScreenshotUtils {
3539
activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider);
3640
}
3741

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

@@ -72,7 +97,7 @@ public class ScreenshotUtils {
7297
return null;
7398
}
7499

75-
try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
100+
try {
76101
// ARGB_8888 -> This configuration is very flexible and offers the best quality
77102
final Bitmap bitmap =
78103
Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
@@ -133,10 +158,31 @@ public class ScreenshotUtils {
133158
return null;
134159
}
135160
}
161+
return bitmap;
162+
} catch (Throwable e) {
163+
logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e);
164+
}
165+
return null;
166+
}
136167

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

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

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

Lines changed: 41 additions & 6 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 53 additions & 1 deletion
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)