Skip to content

Commit 0fae2eb

Browse files
committed
Compress Screenshots on a background thread
1 parent 087248f commit 0fae2eb

File tree

7 files changed

+152
-17
lines changed

7 files changed

+152
-17
lines changed

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.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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ public class ScreenshotUtils {
2727

2828
private static final long CAPTURE_TIMEOUT_MS = 1000;
2929

30-
public static @Nullable byte[] takeScreenshot(
30+
public static @Nullable Bitmap captureScreenshot(
3131
final @NotNull Activity activity,
3232
final @NotNull ILogger logger,
3333
final @NotNull BuildInfoProvider buildInfoProvider) {
34-
return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider);
34+
return captureScreenshot(
35+
activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider);
3536
}
3637

3738
@SuppressLint("NewApi")
38-
public static @Nullable byte[] takeScreenshot(
39+
public static @Nullable Bitmap captureScreenshot(
3940
final @NotNull Activity activity,
4041
final @NotNull IThreadChecker threadChecker,
4142
final @NotNull ILogger logger,
@@ -71,7 +72,7 @@ public class ScreenshotUtils {
7172
return null;
7273
}
7374

74-
try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
75+
try {
7576
// ARGB_8888 -> This configuration is very flexible and offers the best quality
7677
final Bitmap bitmap =
7778
Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
@@ -132,7 +133,19 @@ public class ScreenshotUtils {
132133
return null;
133134
}
134135
}
136+
return bitmap;
137+
} catch (Throwable e) {
138+
logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e);
139+
}
140+
return null;
141+
}
135142

143+
public static @Nullable byte[] compressBitmapToPng(
144+
final @Nullable Bitmap bitmap, final @NotNull ILogger logger) {
145+
if (bitmap == null || bitmap.isRecycled()) {
146+
return null;
147+
}
148+
try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
136149
// 0 meaning compress for small size, 100 meaning compress for max quality.
137150
// Some formats, like PNG which is lossless, will ignore the quality setting.
138151
bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream);
@@ -145,7 +158,7 @@ public class ScreenshotUtils {
145158
// screenshot png is around ~100-150 kb
146159
return byteArrayOutputStream.toByteArray();
147160
} catch (Throwable e) {
148-
logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e);
161+
logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e);
149162
}
150163
return null;
151164
}

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

Lines changed: 32 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
@@ -17,6 +19,7 @@ import org.robolectric.annotation.Config
1719
import org.robolectric.shadows.ShadowPixelCopy
1820
import kotlin.test.Test
1921
import kotlin.test.assertNotNull
22+
import kotlin.test.assertTrue
2023

2124
@Config(
2225
shadows = [ShadowPixelCopy::class],
@@ -32,7 +35,7 @@ class ScreenshotUtilTest {
3235
whenever(activity.isDestroyed).thenReturn(false)
3336

3437
val data =
35-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
38+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
3639
assertNull(data)
3740
}
3841

@@ -44,7 +47,7 @@ class ScreenshotUtilTest {
4447
whenever(activity.window).thenReturn(mock<Window>())
4548

4649
val data =
47-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
50+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
4851
assertNull(data)
4952
}
5053

@@ -60,7 +63,7 @@ class ScreenshotUtilTest {
6063
whenever(window.peekDecorView()).thenReturn(decorView)
6164

6265
val data =
63-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
66+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
6467
assertNull(data)
6568
}
6669

@@ -81,7 +84,7 @@ class ScreenshotUtilTest {
8184
whenever(rootView.height).thenReturn(0)
8285

8386
val data =
84-
ScreenshotUtils.takeScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
87+
ScreenshotUtils.captureScreenshot(activity, mock<ILogger>(), mock<BuildInfoProvider>())
8588
assertNull(data)
8689
}
8790

@@ -94,7 +97,7 @@ class ScreenshotUtilTest {
9497
val buildInfoProvider = mock<BuildInfoProvider>()
9598
whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O)
9699

97-
val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider)
100+
val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider)
98101
assertNotNull(data)
99102
}
100103

@@ -107,9 +110,32 @@ class ScreenshotUtilTest {
107110
val buildInfoProvider = mock<BuildInfoProvider>()
108111
whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N)
109112

110-
val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider)
113+
val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider)
111114
assertNotNull(data)
112115
}
116+
117+
@Test
118+
fun `a null bitmap compresses into null`() {
119+
val bytes = ScreenshotUtils.compressBitmapToPng(null, NoOpLogger.getInstance())
120+
assertNull(bytes)
121+
}
122+
123+
@Test
124+
fun `a recycled bitmap compresses into null`() {
125+
val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
126+
bitmap.recycle()
127+
128+
val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance())
129+
assertNull(bytes)
130+
}
131+
132+
@Test
133+
fun `a valid bitmap compresses into a valid bytearray`() {
134+
val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
135+
val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance())
136+
assertNotNull(bytes)
137+
assertTrue(bytes.isNotEmpty())
138+
}
113139
}
114140

115141
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
*

sentry/src/main/java/io/sentry/SentryEnvelopeItem.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ public static SentryEnvelopeItem fromAttachment(
213213
return data;
214214
} else if (attachment.getSerializable() != null) {
215215
final JsonSerializable serializable = attachment.getSerializable();
216+
@SuppressWarnings("NullableProblems")
216217
final @Nullable byte[] data =
217218
JsonSerializationUtils.bytesFrom(serializer, logger, serializable);
218219

@@ -223,11 +224,19 @@ public static SentryEnvelopeItem fromAttachment(
223224
}
224225
} else if (attachment.getPathname() != null) {
225226
return readBytesFromFile(attachment.getPathname(), maxAttachmentSize);
227+
} else if (attachment.getByteProvider() != null) {
228+
@SuppressWarnings("NullableProblems")
229+
final @Nullable byte[] data = attachment.getByteProvider().call();
230+
if (data != null) {
231+
ensureAttachmentSizeLimit(
232+
data.length, maxAttachmentSize, attachment.getFilename());
233+
return data;
234+
}
226235
}
227236
throw new SentryEnvelopeException(
228237
String.format(
229238
"Couldn't attach the attachment %s.\n"
230-
+ "Please check that either bytes, serializable or a path is set.",
239+
+ "Please check that either bytes, serializable, path or provider is set.",
231240
attachment.getFilename()));
232241
});
233242

0 commit comments

Comments
 (0)