From 0fae2eb9d6c4ce772fbf560f19c6eec557d0793d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 28 Mar 2025 10:06:03 +0100 Subject: [PATCH 1/4] Compress Screenshots on a background thread --- .../core/ScreenshotEventProcessor.java | 15 ++++-- .../core/internal/util/ScreenshotUtils.java | 23 ++++++-- .../core/internal/util/ScreenshotUtilTest.kt | 38 ++++++++++--- sentry/api/sentry.api | 3 ++ .../src/main/java/io/sentry/Attachment.java | 54 ++++++++++++++++++- .../java/io/sentry/SentryEnvelopeItem.java | 11 +++- .../java/io/sentry/SentryEnvelopeItemTest.kt | 25 +++++++++ 7 files changed, 152 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 8585cb9614..16e9697945 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -1,10 +1,11 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; +import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; +import android.graphics.Bitmap; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -12,6 +13,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.Debouncer; +import io.sentry.android.core.internal.util.ScreenshotUtils; import io.sentry.protocol.SentryTransaction; import io.sentry.util.HintUtils; import io.sentry.util.Objects; @@ -87,14 +89,19 @@ public ScreenshotEventProcessor( return event; } - final byte[] screenshot = - takeScreenshot( + final Bitmap screenshot = + captureScreenshot( activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } - hint.setScreenshot(Attachment.fromScreenshot(screenshot)); + hint.setScreenshot( + Attachment.fromByteProvider( + () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), + "screenshot.png", + "image/png", + false)); hint.set(ANDROID_ACTIVITY, activity); return event; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index d6cd7bc6af..de89f8717f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -27,15 +27,16 @@ public class ScreenshotUtils { private static final long CAPTURE_TIMEOUT_MS = 1000; - public static @Nullable byte[] takeScreenshot( + public static @Nullable Bitmap captureScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { - return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); + return captureScreenshot( + activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); } @SuppressLint("NewApi") - public static @Nullable byte[] takeScreenshot( + public static @Nullable Bitmap captureScreenshot( final @NotNull Activity activity, final @NotNull IThreadChecker threadChecker, final @NotNull ILogger logger, @@ -71,7 +72,7 @@ public class ScreenshotUtils { return null; } - try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + try { // ARGB_8888 -> This configuration is very flexible and offers the best quality final Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); @@ -132,7 +133,19 @@ public class ScreenshotUtils { return null; } } + return bitmap; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + } + return null; + } + public static @Nullable byte[] compressBitmapToPng( + final @Nullable Bitmap bitmap, final @NotNull ILogger logger) { + if (bitmap == null || bitmap.isRecycled()) { + return null; + } + try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { // 0 meaning compress for small size, 100 meaning compress for max quality. // Some formats, like PNG which is lossless, will ignore the quality setting. bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream); @@ -145,7 +158,7 @@ public class ScreenshotUtils { // screenshot png is around ~100-150 kb return byteArrayOutputStream.toByteArray(); } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e); } return null; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt index 18eecf3128..6be3edf302 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt @@ -1,12 +1,14 @@ package io.sentry.android.core.internal.util import android.app.Activity +import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.view.View import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger +import io.sentry.NoOpLogger import io.sentry.android.core.BuildInfoProvider import junit.framework.TestCase.assertNull import org.junit.runner.RunWith @@ -17,6 +19,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import kotlin.test.Test import kotlin.test.assertNotNull +import kotlin.test.assertTrue @Config( shadows = [ShadowPixelCopy::class], @@ -32,7 +35,7 @@ class ScreenshotUtilTest { whenever(activity.isDestroyed).thenReturn(false) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -44,7 +47,7 @@ class ScreenshotUtilTest { whenever(activity.window).thenReturn(mock()) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -60,7 +63,7 @@ class ScreenshotUtilTest { whenever(window.peekDecorView()).thenReturn(decorView) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -81,7 +84,7 @@ class ScreenshotUtilTest { whenever(rootView.height).thenReturn(0) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -94,7 +97,7 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } @@ -107,9 +110,32 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } + + @Test + fun `a null bitmap compresses into null`() { + val bytes = ScreenshotUtils.compressBitmapToPng(null, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a recycled bitmap compresses into null`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.recycle() + + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a valid bitmap compresses into a valid bytearray`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNotNull(bytes) + assertTrue(bytes.isNotEmpty()) + } } class ExampleActivity : Activity() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 796284690d..d59d623099 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -11,14 +11,17 @@ public final class io/sentry/Attachment { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V + public fun (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;)V public fun ([BLjava/lang/String;Ljava/lang/String;)V public fun ([BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;Ljava/lang/String;Z)V + public static fun fromByteProvider (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/Attachment; public static fun fromScreenshot ([B)Lio/sentry/Attachment; public static fun fromThreadDump ([B)Lio/sentry/Attachment; public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; + public fun getByteProvider ()Ljava/util/concurrent/Callable; public fun getBytes ()[B public fun getContentType ()Ljava/lang/String; public fun getFilename ()Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Attachment.java b/sentry/src/main/java/io/sentry/Attachment.java index 7a4ec3b99d..439ad812b0 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -2,6 +2,7 @@ import io.sentry.protocol.ViewHierarchy; import java.io.File; +import java.util.concurrent.Callable; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -10,6 +11,7 @@ public final class Attachment { private @Nullable byte[] bytes; private final @Nullable JsonSerializable serializable; + private final @Nullable Callable byteProvider; private @Nullable String pathname; private final @NotNull String filename; private final @Nullable String contentType; @@ -84,6 +86,7 @@ public Attachment( final boolean addToTransactions) { this.bytes = bytes; this.serializable = null; + this.byteProvider = null; this.filename = filename; this.contentType = contentType; this.attachmentType = attachmentType; @@ -109,6 +112,33 @@ public Attachment( final boolean addToTransactions) { this.bytes = null; this.serializable = serializable; + this.byteProvider = null; + this.filename = filename; + this.contentType = contentType; + this.attachmentType = attachmentType; + this.addToTransactions = addToTransactions; + } + + /** + * Initializes an Attachment with bytes factory, a filename, a content type, and + * addToTransactions. + * + * @param byteProvider A provider holding the attachment payload + * @param filename The name of the attachment to display in Sentry. + * @param contentType The content type of the attachment. + * @param attachmentType the attachment type. + * @param addToTransactions true if the SDK should add this attachment to every + * {@link ITransaction} or set to false if it shouldn't. + */ + public Attachment( + final @NotNull Callable byteProvider, + final @NotNull String filename, + final @Nullable String contentType, + final @Nullable String attachmentType, + final boolean addToTransactions) { + this.bytes = null; + this.serializable = null; + this.byteProvider = byteProvider; this.filename = filename; this.contentType = contentType; this.attachmentType = attachmentType; @@ -186,6 +216,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.attachmentType = attachmentType; this.addToTransactions = addToTransactions; @@ -212,6 +243,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.addToTransactions = addToTransactions; } @@ -240,6 +272,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.addToTransactions = addToTransactions; this.attachmentType = attachmentType; @@ -310,16 +343,35 @@ boolean isAddToTransactions() { return attachmentType; } + public @Nullable Callable getByteProvider() { + return byteProvider; + } + /** * Creates a new Screenshot Attachment * - * @param screenshotBytes the array bytes + * @param screenshotBytes the array bytes of the PNG screenshot * @return the Attachment */ public static @NotNull Attachment fromScreenshot(final byte[] screenshotBytes) { return new Attachment(screenshotBytes, "screenshot.png", "image/png", false); } + /** + * Creates a new Screenshot Attachment + * + * @param provider the mechanism providing the screenshot payload + * @return the Attachment + */ + public static @NotNull Attachment fromByteProvider( + final @NotNull Callable provider, + final @NotNull String filename, + final @Nullable String contentType, + final boolean addToTransactions) { + return new Attachment( + provider, filename, contentType, DEFAULT_ATTACHMENT_TYPE, addToTransactions); + } + /** * Creates a new View Hierarchy Attachment * diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 9a76d118a9..43ededf6a8 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -213,6 +213,7 @@ public static SentryEnvelopeItem fromAttachment( return data; } else if (attachment.getSerializable() != null) { final JsonSerializable serializable = attachment.getSerializable(); + @SuppressWarnings("NullableProblems") final @Nullable byte[] data = JsonSerializationUtils.bytesFrom(serializer, logger, serializable); @@ -223,11 +224,19 @@ public static SentryEnvelopeItem fromAttachment( } } else if (attachment.getPathname() != null) { return readBytesFromFile(attachment.getPathname(), maxAttachmentSize); + } else if (attachment.getByteProvider() != null) { + @SuppressWarnings("NullableProblems") + final @Nullable byte[] data = attachment.getByteProvider().call(); + if (data != null) { + ensureAttachmentSizeLimit( + data.length, maxAttachmentSize, attachment.getFilename()); + return data; + } } throw new SentryEnvelopeException( String.format( "Couldn't attach the attachment %s.\n" - + "Please check that either bytes, serializable or a path is set.", + + "Please check that either bytes, serializable, path or provider is set.", attachment.getFilename())); }); diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 48df7ff090..cd586ab962 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -23,6 +23,7 @@ import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset import java.nio.file.Files +import java.util.concurrent.Callable import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -103,6 +104,30 @@ class SentryEnvelopeItemTest { assertAttachment(attachment, viewHierarchySerialized, item) } + @Test + fun `fromAttachment with byteProvider`() { + val attachment = Attachment( + object : Callable { + override fun call(): ByteArray? { + return byteArrayOf(0x1) + } + }, + fixture.filename, + "text/plain", + "image/png", + false + ) + + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) + + assertAttachment(attachment, byteArrayOf(0x1), item) + } + @Test fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") From f66343dc05ff235983386cdbd4ab43d9bee1b78d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 28 Mar 2025 10:07:38 +0100 Subject: [PATCH 2/4] Update Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f004b7d8eb..31ada8d7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - The `MANIFEST.MF` of `sentry-opentelemetry-agent` now has `Implementation-Version` set to the raw version ([#4291](https://github.com/getsentry/sentry-java/pull/4291)) - An example value would be `8.6.0` - The value of the `Sentry-Version-Name` attribute looks like `sentry-8.5.0-otel-2.10.0` +- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) ### Internal From 388809fe289b2b22344ccbffc6a65973475bb54d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 28 Mar 2025 10:18:23 +0100 Subject: [PATCH 3/4] Recover APIs used by hybrid SDKs --- .../core/internal/util/ScreenshotUtils.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index de89f8717f..8bad5ee312 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -27,6 +27,30 @@ public class ScreenshotUtils { private static final long CAPTURE_TIMEOUT_MS = 1000; + // Used by Hybrid SDKs + /** + * @noinspection unused + */ + public static @Nullable byte[] takeScreenshot( + final @NotNull Activity activity, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); + } + + // Used by Hybrid SDKs + @SuppressLint("NewApi") + public static @Nullable byte[] takeScreenshot( + final @NotNull Activity activity, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + + final @Nullable Bitmap screenshot = + captureScreenshot(activity, threadChecker, logger, buildInfoProvider); + return compressBitmapToPng(screenshot, logger); + } + public static @Nullable Bitmap captureScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, From 78ccb8ffaa9aee4754a4959ac8229478353a31c3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 7 Apr 2025 10:58:52 +0200 Subject: [PATCH 4/4] Recycle bitmap after compression --- .../android/core/internal/util/ScreenshotUtils.java | 9 +++++++++ .../android/core/internal/util/ScreenshotUtilTest.kt | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 8bad5ee312..db2b12122a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -164,6 +164,14 @@ public class ScreenshotUtils { return null; } + /** + * Compresses the supplied Bitmap to a PNG byte array. After compression, the Bitmap will be + * recycled. + * + * @param bitmap The bitmap to compress + * @param logger the logger + * @return the Bitmap in PNG format, or null if the bitmap was null, recycled or compressing faile + */ public static @Nullable byte[] compressBitmapToPng( final @Nullable Bitmap bitmap, final @NotNull ILogger logger) { if (bitmap == null || bitmap.isRecycled()) { @@ -173,6 +181,7 @@ public class ScreenshotUtils { // 0 meaning compress for small size, 100 meaning compress for max quality. // Some formats, like PNG which is lossless, will ignore the quality setting. bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream); + bitmap.recycle(); if (byteArrayOutputStream.size() <= 0) { logger.log(SentryLevel.DEBUG, "Screenshot is 0 bytes, not attaching the image."); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt index 6be3edf302..10369063f6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt @@ -18,6 +18,7 @@ import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -136,6 +137,14 @@ class ScreenshotUtilTest { assertNotNull(bytes) assertTrue(bytes.isNotEmpty()) } + + @Test + fun `compressBitmapToPng recycles the supplied bitmap`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + assertFalse(bitmap.isRecycled) + ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertTrue(bitmap.isRecycled) + } } class ExampleActivity : Activity() {