diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 7ffdda8b15..fcd2c080e9 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -264,6 +264,7 @@ public final class io/sentry/android/core/UserInteractionIntegration : android/a public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchy (Landroid/view/View;)Lio/sentry/protocol/ViewHierarchy; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 95bc5fe6fe..ea3a95c71b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -7,6 +7,7 @@ import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; +import io.sentry.ILogger; import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.android.core.internal.gestures.ViewUtils; @@ -41,30 +42,43 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) } final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + final @Nullable ViewHierarchy viewHierarchy = + snapshotViewHierarchy(activity, options.getLogger()); + + if (viewHierarchy != null) { + hint.setViewHierarchy(Attachment.fromViewHierarchy(viewHierarchy)); + } + + return event; + } + + @Nullable + public static ViewHierarchy snapshotViewHierarchy( + @Nullable Activity activity, @NotNull ILogger logger) { if (activity == null) { - options.getLogger().log(SentryLevel.INFO, "Missing activity for view hierarchy snapshot."); - return event; + logger.log(SentryLevel.INFO, "Missing activity for view hierarchy snapshot."); + return null; } final @Nullable Window window = activity.getWindow(); if (window == null) { - options.getLogger().log(SentryLevel.INFO, "Missing window for view hierarchy snapshot."); - return event; + logger.log(SentryLevel.INFO, "Missing window for view hierarchy snapshot."); + return null; } final @Nullable View decorView = window.peekDecorView(); if (decorView == null) { - options.getLogger().log(SentryLevel.INFO, "Missing decor view for view hierarchy snapshot."); - return event; + logger.log(SentryLevel.INFO, "Missing decor view for view hierarchy snapshot."); + return null; } try { final @NotNull ViewHierarchy viewHierarchy = snapshotViewHierarchy(decorView); - hint.setViewHierarchy(Attachment.fromViewHierarchy(viewHierarchy)); + return viewHierarchy; } catch (Throwable t) { - options.getLogger().log(SentryLevel.ERROR, "Failed to process view hierarchy.", t); + logger.log(SentryLevel.ERROR, "Failed to process view hierarchy.", t); + return null; } - return event; } @NotNull diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d92a8a67b7..795974acb3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3674,6 +3674,7 @@ public final class io/sentry/util/HttpUtils { public final class io/sentry/util/JsonSerializationUtils { public fun ()V public static fun atomicIntegerArrayToList (Ljava/util/concurrent/atomic/AtomicIntegerArray;)Ljava/util/List; + public static fun bytesFrom (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/JsonSerializable;)[B public static fun calendarToMap (Ljava/util/Calendar;)Ljava/util/Map; } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 2d5767718f..595fcb1705 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -6,6 +6,7 @@ import io.sentry.clientreport.ClientReport; import io.sentry.exception.SentryEnvelopeException; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; import io.sentry.vendor.Base64; import java.io.BufferedInputStream; @@ -179,21 +180,13 @@ public static SentryEnvelopeItem fromAttachment( return data; } else if (attachment.getSerializable() != null) { final JsonSerializable serializable = attachment.getSerializable(); - try { - try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = - new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { - - serializer.serialize(serializable, writer); - - final byte[] data = stream.toByteArray(); - ensureAttachmentSizeLimit( - data.length, maxAttachmentSize, attachment.getFilename()); - return data; - } - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "Could not serialize attachment serializable", t); - throw t; + final @Nullable byte[] data = + JsonSerializationUtils.bytesFrom(serializer, logger, serializable); + + if (data != null) { + ensureAttachmentSizeLimit( + data.length, maxAttachmentSize, attachment.getFilename()); + return data; } } else if (attachment.getPathname() != null) { return readBytesFromFile(attachment.getPathname(), maxAttachmentSize); diff --git a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java index afb8ec2e95..83b4a03e8e 100644 --- a/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java +++ b/sentry/src/main/java/io/sentry/util/JsonSerializationUtils.java @@ -1,5 +1,14 @@ package io.sentry.util; +import io.sentry.ILogger; +import io.sentry.ISerializer; +import io.sentry.JsonSerializable; +import io.sentry.SentryLevel; +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Calendar; import java.util.HashMap; @@ -8,10 +17,14 @@ import java.util.concurrent.atomic.AtomicIntegerArray; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class JsonSerializationUtils { + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final Charset UTF_8 = Charset.forName("UTF-8"); + public static @NotNull Map calendarToMap(final @NotNull Calendar calendar) { final @NotNull Map map = new HashMap<>(); @@ -34,4 +47,22 @@ public final class JsonSerializationUtils { } return list; } + + public static @Nullable byte[] bytesFrom( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull JsonSerializable serializable) { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + + serializer.serialize(serializable, writer); + + return stream.toByteArray(); + } + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not serialize serializable", t); + return null; + } + } } diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 7b76678624..12b41ecdb7 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -6,6 +6,7 @@ import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField import io.sentry.vendor.Base64 import org.junit.Assert.assertArrayEquals +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -27,6 +28,11 @@ class SentryEnvelopeItemTest { private class Fixture { val options = SentryOptions() val serializer = JsonSerializer(options) + val errorSerializer: JsonSerializer = mock { + on(it.serialize(any(), any())).then { + throw Exception("Mocked exception.") + } + } val pathname = "hello.txt" val filename = pathname val bytes = "hello".toByteArray() @@ -250,6 +256,20 @@ class SentryEnvelopeItemTest { ) } + @Test + fun `fromAttachment with bytesFrom serializable are null`() { + val attachment = Attachment(mock(), "mock-file-name", null, null, false) + + val item = SentryEnvelopeItem.fromAttachment(fixture.errorSerializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + + assertFailsWith( + "Couldn't attach the attachment ${attachment.filename}.\n" + + "Please check that either bytes or a path is set." + ) { + item.data + } + } + @Test fun `fromProfilingTrace saves file as Base64`() { val file = File(fixture.pathname) diff --git a/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt b/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt index c50ce8d180..f8f1bb48f3 100644 --- a/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/JsonSerializationUtilsTest.kt @@ -1,9 +1,18 @@ package io.sentry.util -import java.util.Calendar +import io.sentry.ILogger +import io.sentry.JsonSerializable +import io.sentry.JsonSerializer +import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import java.io.Writer +import java.util.* import java.util.concurrent.atomic.AtomicIntegerArray import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertNull class JsonSerializationUtilsTest { @@ -30,4 +39,34 @@ class JsonSerializationUtilsTest { val actual = JsonSerializationUtils.atomicIntegerArrayToList(AtomicIntegerArray(arrayOf(1, 2, 3).toIntArray())) assertEquals(listOf(1, 2, 3), actual) } + + @Test + fun `returns byte array of given serializable`() { + val mockSerializer: JsonSerializer = mock { + on(it.serialize(any(), any())).then { invocationOnMock: InvocationOnMock -> + val writer: Writer = invocationOnMock.getArgument(1) + writer.write("mock-data") + writer.flush() + } + } + val logger: ILogger = mock() + val serializable: JsonSerializable = mock() + val actualBytes = JsonSerializationUtils.bytesFrom(mockSerializer, logger, serializable) + + assertContentEquals("mock-data".toByteArray(), actualBytes, "Byte array should represent the mocked input data.") + } + + @Test + fun `return null on serialization error`() { + val mockSerializer: JsonSerializer = mock { + on(it.serialize(any(), any())).then { + throw Exception("Mocked exception.") + } + } + val logger: ILogger = mock() + val serializable: JsonSerializable = mock() + val actualBytes = JsonSerializationUtils.bytesFrom(mockSerializer, logger, serializable) + + assertNull(actualBytes, "Mocker error should be captured and null returned.") + } }