diff --git a/CHANGELOG.md b/CHANGELOG.md index 41efa643f4..3f86efcd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Attach View Hierarchy to the errored/crashed events ([#2440](https://github.com/getsentry/sentry-java/pull/2440)) - Collect memory usage in transactions ([#2445](https://github.com/getsentry/sentry-java/pull/2445)) - Add `traceOptionsRequests` option to disable tracing of OPTIONS requests ([#2453](https://github.com/getsentry/sentry-java/pull/2453)) - Extend list of HTTP headers considered sensitive ([#2455](https://github.com/getsentry/sentry-java/pull/2455)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ed6bbe2359..908e8da030 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -94,6 +94,19 @@ public class io/sentry/android/core/CurrentActivityHolder { public fun setActivity (Landroid/app/Activity;)V } +public final class io/sentry/android/core/CurrentActivityIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { + public fun (Landroid/app/Application;)V + public fun close ()V + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V + public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V +} + public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun close ()V @@ -126,16 +139,8 @@ public final class io/sentry/android/core/PhoneStateBreadcrumbsIntegration : io/ public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } -public final class io/sentry/android/core/ScreenshotEventProcessor : android/app/Application$ActivityLifecycleCallbacks, io/sentry/EventProcessor, java/io/Closeable { - public fun (Landroid/app/Application;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V - public fun close ()V - public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityDestroyed (Landroid/app/Activity;)V - public fun onActivityPaused (Landroid/app/Activity;)V - public fun onActivityResumed (Landroid/app/Activity;)V - public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityStarted (Landroid/app/Activity;)V - public fun onActivityStopped (Landroid/app/Activity;)V +public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; } @@ -157,6 +162,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isAnrEnabled ()Z public fun isAnrReportInDebug ()Z public fun isAttachScreenshot ()Z + public fun isAttachViewHierarchy ()Z public fun isCollectAdditionalContext ()Z public fun isEnableActivityLifecycleBreadcrumbs ()Z public fun isEnableActivityLifecycleTracingAutoFinish ()Z @@ -169,6 +175,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V public fun setAttachScreenshot (Z)V + public fun setAttachViewHierarchy (Z)V public fun setCollectAdditionalContext (Z)V public fun setDebugImagesLoader (Lio/sentry/android/core/IDebugImagesLoader;)V public fun setEnableActivityLifecycleBreadcrumbs (Z)V @@ -240,6 +247,12 @@ public final class io/sentry/android/core/UserInteractionIntegration : android/a public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } +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/view/View;)Lio/sentry/protocol/ViewHierarchy; +} + public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index de6ce20596..d7478786fe 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { testImplementation(projects.sentryAndroidTimber) testImplementation(projects.sentryComposeHelper) testImplementation(projects.sentryAndroidNdk) + testRuntimeOnly(Config.Libs.composeUi) testRuntimeOnly(Config.Libs.timber) testRuntimeOnly(Config.Libs.fragment) } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 65a530d8af..7046539efe 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -134,7 +134,8 @@ static void initializeIntegrationsAndProcessors( options.addEventProcessor( new DefaultAndroidEventProcessor(context, buildInfoProvider, options)); options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker)); - + options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider)); + options.addEventProcessor(new ViewHierarchyEventProcessor(options)); options.setTransportGate(new AndroidTransportGate(context, options.getLogger())); final SentryFrameMetricsCollector frameMetricsCollector = new SentryFrameMetricsCollector(context, options, buildInfoProvider); @@ -210,12 +211,11 @@ private static void installDefaultIntegrations( options.addIntegration( new ActivityLifecycleIntegration( (Application) context, buildInfoProvider, activityFramesTracker)); + options.addIntegration(new CurrentActivityIntegration((Application) context)); options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); if (isFragmentAvailable) { options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true)); } - options.addEventProcessor( - new ScreenshotEventProcessor((Application) context, options, buildInfoProvider)); } else { options .getLogger() @@ -223,6 +223,7 @@ private static void installDefaultIntegrations( SentryLevel.WARNING, "ActivityLifecycle, FragmentLifecycle and UserInteraction Integrations need an Application class to be installed."); } + if (isTimberAvailable) { options.addIntegration(new SentryTimberIntegration()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java new file mode 100644 index 0000000000..b4c5f1ed02 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java @@ -0,0 +1,80 @@ +package io.sentry.android.core; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import androidx.annotation.NonNull; +import io.sentry.IHub; +import io.sentry.Integration; +import io.sentry.SentryOptions; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class CurrentActivityIntegration + implements Integration, Closeable, Application.ActivityLifecycleCallbacks { + + private final @NotNull Application application; + + public CurrentActivityIntegration(final @NotNull Application application) { + this.application = Objects.requireNonNull(application, "Application is required"); + } + + @Override + public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + application.registerActivityLifecycleCallbacks(this); + } + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + setCurrentActivity(activity); + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + setCurrentActivity(activity); + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + setCurrentActivity(activity); + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + cleanCurrentActivity(activity); + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + cleanCurrentActivity(activity); + } + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + cleanCurrentActivity(activity); + } + + @Override + public void close() throws IOException { + application.unregisterActivityLifecycleCallbacks(this); + CurrentActivityHolder.getInstance().clearActivity(); + } + + private void cleanCurrentActivity(final @NotNull Activity activity) { + if (CurrentActivityHolder.getInstance().getActivity() == activity) { + CurrentActivityHolder.getInstance().clearActivity(); + } + } + + private void setCurrentActivity(final @NotNull Activity activity) { + CurrentActivityHolder.getInstance().setActivity(activity); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index d05560d792..60bd3e338e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -75,6 +75,7 @@ final class ManifestMetadataReader { static final String IDLE_TIMEOUT = "io.sentry.traces.idle-timeout"; static final String ATTACH_SCREENSHOT = "io.sentry.attach-screenshot"; + static final String ATTACH_VIEW_HIERARCHY = "io.sentry.attach-view-hierarchy"; static final String CLIENT_REPORTS_ENABLE = "io.sentry.send-client-reports"; static final String COLLECT_ADDITIONAL_CONTEXT = "io.sentry.additional-context"; @@ -220,6 +221,9 @@ static void applyMetadata( options.setAttachScreenshot( readBool(metadata, logger, ATTACH_SCREENSHOT, options.isAttachScreenshot())); + options.setAttachViewHierarchy( + readBool(metadata, logger, ATTACH_VIEW_HIERARCHY, options.isAttachViewHierarchy())); + options.setSendClientReports( readBool(metadata, logger, CLIENT_REPORTS_ENABLE, options.isSendClientReports())); 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 041d441579..550634d309 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 @@ -4,10 +4,6 @@ import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; import android.app.Activity; -import android.app.Application; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -15,55 +11,39 @@ import io.sentry.SentryLevel; import io.sentry.util.HintUtils; import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * ScreenshotEventProcessor responsible for taking a screenshot of the screen when an error is * captured. */ @ApiStatus.Internal -public final class ScreenshotEventProcessor - implements EventProcessor, Application.ActivityLifecycleCallbacks, Closeable { +public final class ScreenshotEventProcessor implements EventProcessor { - private final @NotNull Application application; private final @NotNull SentryAndroidOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; - private boolean lifecycleCallbackInstalled = true; public ScreenshotEventProcessor( - final @NotNull Application application, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { - this.application = Objects.requireNonNull(application, "Application is required"); this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); - - application.registerActivityLifecycleCallbacks(this); } - @SuppressWarnings("NullAway") @Override - public @NotNull SentryEvent process(final @NotNull SentryEvent event, @NotNull Hint hint) { - if (!lifecycleCallbackInstalled || !event.isErrored()) { + public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { + if (!event.isErrored()) { return event; } if (!options.isAttachScreenshot()) { - application.unregisterActivityLifecycleCallbacks(this); - lifecycleCallbackInstalled = false; - - this.options - .getLogger() - .log( - SentryLevel.DEBUG, - "attachScreenshot is disabled, ScreenshotEventProcessor isn't installed."); + this.options.getLogger().log(SentryLevel.DEBUG, "attachScreenshot is disabled."); return event; } - final Activity activity = CurrentActivityHolder.getInstance().getActivity(); + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); if (activity == null || HintUtils.isFromHybridSdk(hint)) { return event; } @@ -77,55 +57,4 @@ public ScreenshotEventProcessor( hint.set(ANDROID_ACTIVITY, activity); return event; } - - @Override - public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - CurrentActivityHolder.getInstance().setActivity(activity); - } - - @Override - public void onActivityStarted(@NonNull Activity activity) { - setCurrentActivity(activity); - } - - @Override - public void onActivityResumed(@NonNull Activity activity) { - setCurrentActivity(activity); - } - - @Override - public void onActivityPaused(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void onActivityStopped(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} - - @Override - public void onActivityDestroyed(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void close() throws IOException { - if (options.isAttachScreenshot()) { - application.unregisterActivityLifecycleCallbacks(this); - CurrentActivityHolder.getInstance().clearActivity(); - } - } - - private void cleanCurrentActivity(@NonNull Activity activity) { - if (CurrentActivityHolder.getInstance().getActivity() == activity) { - CurrentActivityHolder.getInstance().clearActivity(); - } - } - - private void setCurrentActivity(@NonNull Activity activity) { - CurrentActivityHolder.getInstance().setActivity(activity); - } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index ca1f9a03c1..03a697d13d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -96,6 +96,9 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enables or disables the attach screenshot feature when an error happened. */ private boolean attachScreenshot; + /** Enables or disables the attach view hierarchy feature when an error happened. */ + private boolean attachViewHierarchy; + /** * Enables or disables collecting of device information which requires Inter-Process Communication * (IPC) @@ -329,6 +332,14 @@ public void setAttachScreenshot(boolean attachScreenshot) { this.attachScreenshot = attachScreenshot; } + public boolean isAttachViewHierarchy() { + return attachViewHierarchy; + } + + public void setAttachViewHierarchy(boolean attachViewHierarchy) { + this.attachViewHierarchy = attachViewHierarchy; + } + public boolean isCollectAdditionalContext() { return collectAdditionalContext; } 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 new file mode 100644 index 0000000000..95bc5fe6fe --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -0,0 +1,145 @@ +package io.sentry.android.core; + +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import io.sentry.Attachment; +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.android.core.internal.gestures.ViewUtils; +import io.sentry.protocol.ViewHierarchy; +import io.sentry.protocol.ViewHierarchyNode; +import io.sentry.util.Objects; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** ViewHierarchyEventProcessor responsible for taking a snapshot of the current view hierarchy. */ +@ApiStatus.Internal +public final class ViewHierarchyEventProcessor implements EventProcessor { + + private final @NotNull SentryAndroidOptions options; + + public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) { + this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required"); + } + + @Override + public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { + if (!event.isErrored()) { + return event; + } + + if (!options.isAttachViewHierarchy()) { + options.getLogger().log(SentryLevel.DEBUG, "attachViewHierarchy is disabled."); + return event; + } + + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity == null) { + options.getLogger().log(SentryLevel.INFO, "Missing activity for view hierarchy snapshot."); + return event; + } + + final @Nullable Window window = activity.getWindow(); + if (window == null) { + options.getLogger().log(SentryLevel.INFO, "Missing window for view hierarchy snapshot."); + return event; + } + + final @Nullable View decorView = window.peekDecorView(); + if (decorView == null) { + options.getLogger().log(SentryLevel.INFO, "Missing decor view for view hierarchy snapshot."); + return event; + } + + try { + final @NotNull ViewHierarchy viewHierarchy = snapshotViewHierarchy(decorView); + hint.setViewHierarchy(Attachment.fromViewHierarchy(viewHierarchy)); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Failed to process view hierarchy.", t); + } + return event; + } + + @NotNull + public static ViewHierarchy snapshotViewHierarchy(@NotNull final View view) { + final List windows = new ArrayList<>(1); + final ViewHierarchy viewHierarchy = new ViewHierarchy("android_view_system", windows); + + final @NotNull ViewHierarchyNode node = viewToNode(view); + windows.add(node); + addChildren(view, node); + + return viewHierarchy; + } + + private static void addChildren( + @NotNull final View view, @NotNull final ViewHierarchyNode parentNode) { + if (!(view instanceof ViewGroup)) { + return; + } + + final @NotNull ViewGroup viewGroup = ((ViewGroup) view); + final int childCount = viewGroup.getChildCount(); + if (childCount == 0) { + return; + } + + final @NotNull List childNodes = new ArrayList<>(childCount); + for (int i = 0; i < childCount; i++) { + final @Nullable View child = viewGroup.getChildAt(i); + if (child != null) { + final @NotNull ViewHierarchyNode childNode = viewToNode(child); + childNodes.add(childNode); + addChildren(child, childNode); + } + } + parentNode.setChildren(childNodes); + } + + @NotNull + private static ViewHierarchyNode viewToNode(@NotNull final View view) { + @NotNull final ViewHierarchyNode node = new ViewHierarchyNode(); + + @Nullable String className = view.getClass().getCanonicalName(); + if (className == null) { + className = view.getClass().getSimpleName(); + } + node.setType(className); + + try { + final String identifier = ViewUtils.getResourceId(view); + node.setIdentifier(identifier); + } catch (Throwable e) { + // ignored + } + node.setX((double) view.getX()); + node.setY((double) view.getY()); + node.setWidth((double) view.getWidth()); + node.setHeight((double) view.getHeight()); + node.setAlpha((double) view.getAlpha()); + + switch (view.getVisibility()) { + case View.VISIBLE: + node.setVisibility("visible"); + break; + case View.INVISIBLE: + node.setVisibility("invisible"); + break; + case View.GONE: + node.setVisibility("gone"); + break; + default: + // ignored + break; + } + + return node; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewTargetSelector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewTargetSelector.java deleted file mode 100644 index d2da63ada9..0000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewTargetSelector.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.sentry.android.core.internal.gestures; - -import android.view.View; -import org.jetbrains.annotations.NotNull; - -interface ViewTargetSelector { - /** - * Defines whether the given {@code view} should be selected from the view hierarchy. - * - * @param view - the view to be selected. - * @return true, when the view should be selected, false otherwise. - */ - boolean select(@NotNull View view); - - /** - * Defines whether the view from the select method is eligible for children traversal, in case - * it's a ViewGroup. - * - * @return true, when the ViewGroup is sufficient to be selected and children traversal is not - * necessary. - */ - default boolean skipChildren() { - return false; - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 68360451c9..6e7dab2ef5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -10,10 +10,12 @@ import io.sentry.util.Objects; import java.util.LinkedList; import java.util.Queue; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -final class ViewUtils { +@ApiStatus.Internal +public final class ViewUtils { /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the @@ -85,13 +87,19 @@ static String getResourceIdWithFallback(final @NotNull View view) { * @return human-readable view id * @throws Resources.NotFoundException in case the view id was not found */ - static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { + public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { final int viewId = view.getId(); + if (viewId == View.NO_ID || isViewIdGenerated(viewId)) { + throw new Resources.NotFoundException(); + } final Resources resources = view.getContext().getResources(); - String resourceId = ""; if (resources != null) { - resourceId = resources.getResourceEntryName(viewId); + return resources.getResourceEntryName(viewId); } - return resourceId; + return ""; + } + + private static boolean isViewIdGenerated(int id) { + return (id & 0xFF000000) == 0 && (id & 0x00FFFFFF) != 0; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 6c4d4a83e9..f9dce2ca21 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -166,6 +166,14 @@ class AndroidOptionsInitializerTest { assertNotNull(actual) } + @Test + fun `ViewHierarchyEventProcessor added to processors list`() { + fixture.initSut() + val actual = + fixture.sentryOptions.eventProcessors.any { it is ViewHierarchyEventProcessor } + assertNotNull(actual) + } + @Test fun `envelopesDir should be set at initialization`() { fixture.initSut() @@ -398,6 +406,15 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.envelopeDiskCache is AndroidEnvelopeCache } } + @Test + fun `CurrentActivityIntegration is added by default`() { + fixture.initSut(useRealContext = true) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is CurrentActivityIntegration } + assertNotNull(actual) + } + @Test fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be available`() { fixture.initSut( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt new file mode 100644 index 0000000000..6330623121 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt @@ -0,0 +1,107 @@ +package io.sentry.android.core + +import android.app.Activity +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IHub +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +class CurrentActivityIntegrationTest { + + private class Fixture { + val application = mock() + val activity = mock() + val hub = mock() + + val options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + } + + fun getSut(): CurrentActivityIntegration { + val integration = CurrentActivityIntegration(application) + integration.register(hub, options) + return integration + } + } + + private lateinit var fixture: Fixture + + @BeforeTest + fun `set up`() { + fixture = Fixture() + } + + @Test + fun `when the integration is added registerActivityLifecycleCallbacks is called`() { + fixture.getSut() + verify(fixture.application).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `when the integration is closed unregisterActivityLifecycleCallbacks is called`() { + val sut = fixture.getSut() + sut.close() + + verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `when an activity is created the activity holder provides it`() { + val sut = fixture.getSut() + + sut.onActivityCreated(fixture.activity, null) + assertEquals(fixture.activity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `when there is no active activity the holder does not provide an outdated one`() { + val sut = fixture.getSut() + + sut.onActivityCreated(fixture.activity, null) + sut.onActivityDestroyed(fixture.activity) + + assertNull(CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `when a second activity is started it gets the current one`() { + val sut = fixture.getSut() + + sut.onActivityCreated(fixture.activity, null) + sut.onActivityStarted(fixture.activity) + sut.onActivityResumed(fixture.activity) + + val secondActivity = mock() + sut.onActivityCreated(secondActivity, null) + sut.onActivityStarted(secondActivity) + + assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `destroying an old activity keeps the current one`() { + val sut = fixture.getSut() + + sut.onActivityCreated(fixture.activity, null) + sut.onActivityStarted(fixture.activity) + sut.onActivityResumed(fixture.activity) + + val secondActivity = mock() + sut.onActivityCreated(secondActivity, null) + sut.onActivityStarted(secondActivity) + + sut.onActivityPaused(fixture.activity) + sut.onActivityStopped(fixture.activity) + sut.onActivityDestroyed(fixture.activity) + + assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 9835a380f4..a99bee37d6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -949,6 +949,19 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isAttachScreenshot) } + @Test + fun `applyMetadata reads attach viewhierarchy to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ATTACH_VIEW_HIERARCHY to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isAttachViewHierarchy) + } + @Test fun `applyMetadata reads attach screenshots and keep default value if not found`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt index 3d81b176bf..2d208db720 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt @@ -1,7 +1,6 @@ package io.sentry.android.core import android.app.Activity -import android.app.Application import android.view.View import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -11,10 +10,7 @@ import io.sentry.MainEventProcessor import io.sentry.SentryEvent import io.sentry.TypeCheckHint.ANDROID_ACTIVITY import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.BeforeTest import kotlin.test.Test @@ -27,7 +23,6 @@ import kotlin.test.assertTrue class ScreenshotEventProcessorTest { private class Fixture { - val application = mock() val buildInfo = mock() val activity = mock() val window = mock() @@ -49,7 +44,7 @@ class ScreenshotEventProcessorTest { fun getSut(attachScreenshot: Boolean = false): ScreenshotEventProcessor { options.isAttachScreenshot = attachScreenshot - return ScreenshotEventProcessor(application, options, buildInfo) + return ScreenshotEventProcessor(options, buildInfo) } } @@ -60,48 +55,12 @@ class ScreenshotEventProcessorTest { fixture = Fixture() } - @Test - fun `when adding screenshot event processor, registerActivityLifecycleCallbacks`() { - fixture.getSut() - - verify(fixture.application).registerActivityLifecycleCallbacks(any()) - } - - @Test - fun `when close is called and attach screenshot is enabled, unregisterActivityLifecycleCallbacks`() { - val sut = fixture.getSut(true) - - sut.close() - - verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) - } - - @Test - fun `when close is called and attach screenshot is disabled, does not unregisterActivityLifecycleCallbacks`() { - val sut = fixture.getSut() - - sut.close() - - verify(fixture.application, never()).unregisterActivityLifecycleCallbacks(any()) - } - - @Test - fun `when process is called and attachScreenshot is disabled, unregisterActivityLifecycleCallbacks`() { - val sut = fixture.getSut() - val hint = Hint() - - val event = fixture.mainProcessor.process(getEvent(), hint) - sut.process(event, hint) - - verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) - } - @Test fun `when process is called and attachScreenshot is disabled, does nothing`() { val sut = fixture.getSut() val hint = Hint() - sut.onActivityCreated(fixture.activity, null) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(getEvent(), hint) sut.process(event, hint) @@ -114,7 +73,7 @@ class ScreenshotEventProcessorTest { val sut = fixture.getSut(true) val hint = Hint() - sut.onActivityCreated(fixture.activity, null) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(SentryEvent(), hint) sut.process(event, hint) @@ -139,7 +98,7 @@ class ScreenshotEventProcessorTest { val hint = Hint() whenever(fixture.activity.isFinishing).thenReturn(true) - sut.onActivityCreated(fixture.activity, null) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(getEvent(), hint) sut.process(event, hint) @@ -154,7 +113,7 @@ class ScreenshotEventProcessorTest { whenever(fixture.rootView.width).thenReturn(0) whenever(fixture.rootView.height).thenReturn(0) - sut.onActivityCreated(fixture.activity, null) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(getEvent(), hint) sut.process(event, hint) @@ -167,7 +126,7 @@ class ScreenshotEventProcessorTest { val sut = fixture.getSut(true) val hint = Hint() - sut.onActivityCreated(fixture.activity, null) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) val event = fixture.mainProcessor.process(getEvent(), hint) sut.process(event, hint) @@ -185,8 +144,8 @@ class ScreenshotEventProcessorTest { val sut = fixture.getSut(true) val hint = Hint() - sut.onActivityCreated(fixture.activity, null) - sut.onActivityDestroyed(fixture.activity) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + CurrentActivityHolder.getInstance().clearActivity() val event = fixture.mainProcessor.process(getEvent(), hint) sut.process(event, hint) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt new file mode 100644 index 0000000000..4fd03b761b --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt @@ -0,0 +1,231 @@ +package io.sentry.android.core + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import android.view.Window +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.protocol.SentryException +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +class ViewHierarchyEventProcessorTest { + private class Fixture { + val activity = mock() + val window = mock() + val view = mock() + val options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + } + + init { + whenever(view.width).thenReturn(1) + whenever(view.height).thenReturn(1) + whenever(window.decorView).thenReturn(view) + whenever(window.peekDecorView()).thenReturn(view) + whenever(activity.window).thenReturn(window) + + CurrentActivityHolder.getInstance().setActivity(activity) + } + + fun getSut(attachViewHierarchy: Boolean = false): ViewHierarchyEventProcessor { + options.isAttachViewHierarchy = attachViewHierarchy + return ViewHierarchyEventProcessor(options) + } + + fun process( + attachViewHierarchy: Boolean, + event: SentryEvent + ): Pair { + val processor = getSut(attachViewHierarchy) + val hint = Hint() + processor.process(event, hint) + + return Pair(event, hint) + } + } + + private lateinit var fixture: Fixture + + @BeforeTest + fun `set up`() { + fixture = Fixture() + } + + @Test + fun `when an event errored, the view hierarchy should not attached if the feature is disabled`() { + val (event, hint) = fixture.process( + false, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + @Test + fun `when an event errored, the view hierarchy should be attached if the feature is enabled`() { + val (event, hint) = fixture.process( + true, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNotNull(hint.viewHierarchy) + } + + @Test + fun `when an event did not error, the view hierarchy should be attached if the feature is enabled`() { + val (event, hint) = fixture.process( + false, + SentryEvent(null) + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + @Test + fun `when there's no current activity the view hierarchy is null`() { + CurrentActivityHolder.getInstance().clearActivity() + + val (event, hint) = fixture.process( + true, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + @Test + fun `when there's no current window the view hierarchy is null`() { + whenever(fixture.activity.window).thenReturn(null) + + val (event, hint) = fixture.process( + true, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + @Test + fun `when there's no current decor view the view hierarchy is null`() { + whenever(fixture.window.peekDecorView()).thenReturn(null) + + val (event, hint) = fixture.process( + true, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + @Test + fun `when retrieving the view hierarchy crashes no view hierarchy is collected`() { + whenever(fixture.view.width).thenThrow(IllegalStateException("invalid ui state")) + val (event, hint) = fixture.process( + true, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + @Test + fun `snapshot of android view is properly created`() { + val content = mockedView( + 0.0f, + 1.0f, + 200, + 400, + 1f, + View.VISIBLE, + listOf( + mockedView(10.0f, 11.0f, 100, 101, 0.5f, View.GONE), + mockedView(20.0f, 21.0f, 200, 201, 1f, View.INVISIBLE) + ) + ) + + val viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchy(content) + assertEquals("android_view_system", viewHierarchy.renderingSystem) + assertEquals(1, viewHierarchy.windows!!.size) + + val contentNode = viewHierarchy.windows!![0] + assertEquals(200.0, contentNode.width) + assertEquals(400.0, contentNode.height) + assertEquals(0.0, contentNode.x) + assertEquals(1.0, contentNode.y) + assertEquals("visible", contentNode.visibility) + assertEquals(2, contentNode.children!!.size) + + contentNode.children!![0].apply { + assertEquals(100.0, width) + assertEquals(101.0, height) + assertEquals(10.0, x) + assertEquals(11.0, y) + assertEquals("gone", visibility) + assertEquals(null, children) + } + + contentNode.children!![1].apply { + assertEquals(200.0, width) + assertEquals(201.0, height) + assertEquals(20.0, x) + assertEquals(21.0, y) + assertEquals("invisible", visibility) + assertEquals(null, children) + } + } + + private fun mockedView( + x: Float, + y: Float, + width: Int, + height: Int, + alpha: Float, + visibility: Int, + children: List = emptyList() + ): View { + val view = mock() + + whenever(view.x).thenReturn(x) + whenever(view.y).thenReturn(y) + whenever(view.width).thenReturn(width) + whenever(view.height).thenReturn(height) + whenever(view.alpha).thenReturn(alpha) + whenever(view.visibility).thenReturn(visibility) + whenever(view.childCount).thenReturn(children.size) + + for (i in children.indices) { + whenever(view.getChildAt(i)).thenReturn(children[i]) + } + + return view + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 57ff124510..05f7abc478 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -7,6 +7,8 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals @@ -44,6 +46,37 @@ class ViewUtilsTest { assertFailsWith { ViewUtils.getResourceId(view) } } + @Test + fun `when view has no id set, resource name is not looked up `() { + val context = mock() + val resources = mock() + whenever(context.resources).thenReturn(resources) + + val view = mock { + whenever(it.id).doReturn(View.NO_ID) + whenever(it.context).thenReturn(context) + } + + assertFailsWith { ViewUtils.getResourceId(view) } + verify(context, never()).resources + } + + @Test + fun `when view id is generated, resource name is not looked up `() { + val context = mock() + val resources = mock() + whenever(context.resources).thenReturn(resources) + + val view = mock { + // View.generateViewId() starts with 1 + whenever(it.id).doReturn(1) + whenever(it.context).thenReturn(context) + } + + assertFailsWith { ViewUtils.getResourceId(view) } + verify(context, never()).resources + } + @Test fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() { val view = mock { diff --git a/sentry-compose-helper/api/sentry-compose-helper.api b/sentry-compose-helper/api/sentry-compose-helper.api new file mode 100644 index 0000000000..b9fe8287fc --- /dev/null +++ b/sentry-compose-helper/api/sentry-compose-helper.api @@ -0,0 +1,5 @@ +public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator { + public fun ()V + public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; +} + diff --git a/sentry-compose-helper/build.gradle.kts b/sentry-compose-helper/build.gradle.kts index 0243cacb44..734fd1143f 100644 --- a/sentry-compose-helper/build.gradle.kts +++ b/sentry-compose-helper/build.gradle.kts @@ -1,13 +1,30 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - `java-library` - jacoco + kotlin("multiplatform") id("org.jetbrains.compose") + `java-library` id(Config.QualityPlugins.gradleVersions) id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion } +kotlin { + jvm { + withJava() + } + + sourceSets { + val jvmMain by getting { + dependencies { + implementation(projects.sentry) + + compileOnly(compose.runtime) + compileOnly(compose.ui) + } + } + } +} + configure { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -17,23 +34,11 @@ tasks.withType().configureEach { kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() } -dependencies { - implementation(projects.sentry) - implementation(compose.runtime) - implementation(compose.ui) -} - -configure { - test { - java.srcDir("src/test/java") - } -} - val embeddedJar by configurations.creating { isCanBeConsumed = true isCanBeResolved = false } artifacts { - add("embeddedJar", File("$buildDir/libs/sentry-compose-helper-$version.jar")) + add("embeddedJar", File("$buildDir/libs/sentry-compose-helper-jvm-$version.jar")) } diff --git a/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java similarity index 100% rename from sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java rename to sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 9e82bdc8b3..ebe16ed7b5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -135,6 +135,9 @@ + + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d78e9a0746..ccd1b3fa85 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4,20 +4,25 @@ public final class io/sentry/AsyncHttpTransportFactory : io/sentry/ITransportFac } public final class io/sentry/Attachment { + public fun (Lio/sentry/JsonSerializable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + 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 ([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 fromScreenshot ([B)Lio/sentry/Attachment; + public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; public fun getBytes ()[B public fun getContentType ()Ljava/lang/String; public fun getFilename ()Ljava/lang/String; public fun getPathname ()Ljava/lang/String; + public fun getSerializable ()Lio/sentry/JsonSerializable; } public final class io/sentry/Baggage { @@ -262,10 +267,12 @@ public final class io/sentry/Hint { public fun getAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public fun getAttachments ()Ljava/util/List; public fun getScreenshot ()Lio/sentry/Attachment; + public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V public fun set (Ljava/lang/String;Ljava/lang/Object;)V public fun setScreenshot (Lio/sentry/Attachment;)V + public fun setViewHierarchy (Lio/sentry/Attachment;)V public static fun withAttachment (Lio/sentry/Attachment;)Lio/sentry/Hint; public static fun withAttachments (Ljava/util/List;)Lio/sentry/Hint; } @@ -1271,7 +1278,7 @@ public final class io/sentry/SentryEnvelopeHeader$JsonKeys { } public final class io/sentry/SentryEnvelopeItem { - public static fun fromAttachment (Lio/sentry/Attachment;J)Lio/sentry/SentryEnvelopeItem; + public static fun fromAttachment (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/Attachment;J)Lio/sentry/SentryEnvelopeItem; public static fun fromClientReport (Lio/sentry/ISerializer;Lio/sentry/clientreport/ClientReport;)Lio/sentry/SentryEnvelopeItem; public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; @@ -3373,6 +3380,77 @@ public final class io/sentry/protocol/User$JsonKeys { public fun ()V } +public final class io/sentry/protocol/ViewHierarchy : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Ljava/lang/String;Ljava/util/List;)V + public fun getRenderingSystem ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getWindows ()Ljava/util/List; + public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/protocol/ViewHierarchy$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/protocol/ViewHierarchy$JsonKeys { + public static final field RENDERING_SYSTEM Ljava/lang/String; + public static final field WINDOWS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/protocol/ViewHierarchyNode : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getAlpha ()Ljava/lang/Double; + public fun getChildren ()Ljava/util/List; + public fun getHeight ()Ljava/lang/Double; + public fun getIdentifier ()Ljava/lang/String; + public fun getRenderingSystem ()Ljava/lang/String; + public fun getTag ()Ljava/lang/String; + public fun getType ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getVisibility ()Ljava/lang/String; + public fun getWidth ()Ljava/lang/Double; + public fun getX ()Ljava/lang/Double; + public fun getY ()Ljava/lang/Double; + public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V + public fun setAlpha (Ljava/lang/Double;)V + public fun setChildren (Ljava/util/List;)V + public fun setHeight (Ljava/lang/Double;)V + public fun setIdentifier (Ljava/lang/String;)V + public fun setRenderingSystem (Ljava/lang/String;)V + public fun setTag (Ljava/lang/String;)V + public fun setType (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setVisibility (Ljava/lang/String;)V + public fun setWidth (Ljava/lang/Double;)V + public fun setX (Ljava/lang/Double;)V + public fun setY (Ljava/lang/Double;)V +} + +public final class io/sentry/protocol/ViewHierarchyNode$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; + public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { + public static final field ALPHA Ljava/lang/String; + public static final field CHILDREN Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field IDENTIFIER Ljava/lang/String; + public static final field RENDERING_SYSTEM Ljava/lang/String; + public static final field TAG Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field VISIBILITY Ljava/lang/String; + public static final field WIDTH Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { public fun (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V public fun (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V diff --git a/sentry/src/main/java/io/sentry/Attachment.java b/sentry/src/main/java/io/sentry/Attachment.java index 5e4113c96e..4f2e12fe40 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.protocol.ViewHierarchy; import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -8,6 +9,7 @@ public final class Attachment { private @Nullable byte[] bytes; + private final @Nullable JsonSerializable serializable; private @Nullable String pathname; private final @NotNull String filename; private final @Nullable String contentType; @@ -19,6 +21,8 @@ public final class Attachment { /** A standard attachment without special meaning */ private static final String DEFAULT_ATTACHMENT_TYPE = "event.attachment"; + private static final String VIEW_HIERARCHY_ATTACHMENT_TYPE = "event.view_hierarchy"; + /** * Initializes an Attachment with bytes and a filename. Sets addToTransaction to false * . @@ -59,9 +63,55 @@ public Attachment( final @NotNull String filename, final @Nullable String contentType, final boolean addToTransactions) { + this(bytes, filename, contentType, DEFAULT_ATTACHMENT_TYPE, addToTransactions); + } + + /** + * Initializes an Attachment with bytes, a filename, a content type, and addToTransactions. + * + * @param bytes The bytes of file. + * @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 byte[] bytes, + final @NotNull String filename, + final @Nullable String contentType, + final @Nullable String attachmentType, + final boolean addToTransactions) { this.bytes = bytes; + this.serializable = 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 serializable A json serializable 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 JsonSerializable serializable, + final @NotNull String filename, + final @Nullable String contentType, + final @Nullable String attachmentType, + final boolean addToTransactions) { + this.bytes = null; + this.serializable = serializable; this.filename = filename; this.contentType = contentType; + this.attachmentType = attachmentType; this.addToTransactions = addToTransactions; } @@ -110,7 +160,7 @@ public Attachment( final @NotNull String pathname, final @NotNull String filename, final @Nullable String contentType) { - this(pathname, filename, contentType, false); + this(pathname, filename, contentType, DEFAULT_ATTACHMENT_TYPE, false); } /** @@ -123,6 +173,7 @@ public Attachment( * @param pathname The pathname string of the file to upload as an attachment. * @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. */ @@ -130,9 +181,37 @@ public Attachment( final @NotNull String pathname, final @NotNull String filename, final @Nullable String contentType, + final @Nullable String attachmentType, final boolean addToTransactions) { this.pathname = pathname; this.filename = filename; + this.serializable = null; + this.contentType = contentType; + this.attachmentType = attachmentType; + this.addToTransactions = addToTransactions; + } + + /** + * Initializes an Attachment with a path, a filename, a content type, and addToTransactions. + * + *

The file located at the pathname is read lazily when the SDK captures an event or + * transaction not when the attachment is initialized. The pathname string is converted into an + * abstract pathname before reading the file. + * + * @param pathname The pathname string of the file to upload as an attachment. + * @param filename The name of the attachment to display in Sentry. + * @param contentType The content type of the attachment. + * @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 String pathname, + final @NotNull String filename, + final @Nullable String contentType, + final boolean addToTransactions) { + this.pathname = pathname; + this.filename = filename; + this.serializable = null; this.contentType = contentType; this.addToTransactions = addToTransactions; } @@ -160,6 +239,7 @@ public Attachment( final @Nullable String attachmentType) { this.pathname = pathname; this.filename = filename; + this.serializable = null; this.contentType = contentType; this.addToTransactions = addToTransactions; this.attachmentType = attachmentType; @@ -174,6 +254,15 @@ public Attachment( return bytes; } + /** + * Provides the bytes of the attachment. + * + * @return the bytes factory responsible for providing the bytes. + */ + public @Nullable JsonSerializable getSerializable() { + return serializable; + } + /** * Gets the pathname string of the attachment. * @@ -230,4 +319,19 @@ boolean isAddToTransactions() { public static @NotNull Attachment fromScreenshot(final byte[] screenshotBytes) { return new Attachment(screenshotBytes, "screenshot.png", "image/png", false); } + + /** + * Creates a new View Hierarchy Attachment + * + * @param viewHierarchy the View Hierarchy + * @return the Attachment + */ + public static @NotNull Attachment fromViewHierarchy(final ViewHierarchy viewHierarchy) { + return new Attachment( + viewHierarchy, + "view-hierarchy.json", + "application/json", + VIEW_HIERARCHY_ATTACHMENT_TYPE, + false); + } } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index fa7ade6461..ab945c5605 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -28,6 +28,7 @@ public final class Hint { private final @NotNull Map internalStorage = new HashMap(); private final @NotNull List attachments = new ArrayList<>(); private @Nullable Attachment screenshot = null; + private @Nullable Attachment viewHierarchy = null; public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @NotNull final Hint hint = new Hint(); @@ -117,6 +118,14 @@ public void setScreenshot(@Nullable Attachment screenshot) { return screenshot; } + public void setViewHierarchy(final @Nullable Attachment viewHierarchy) { + this.viewHierarchy = viewHierarchy; + } + + public @Nullable Attachment getViewHierarchy() { + return viewHierarchy; + } + private boolean isCastablePrimitive(@Nullable Object hintValue, @NotNull Class clazz) { Class nonPrimitiveClass = PRIMITIVE_MAPPINGS.get(clazz.getCanonicalName()); return hintValue != null diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index b409110573..e3de57e971 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -26,6 +26,8 @@ import io.sentry.protocol.SentryThread; import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; +import io.sentry.protocol.ViewHierarchy; +import io.sentry.protocol.ViewHierarchyNode; import io.sentry.util.Objects; import java.io.BufferedOutputStream; import java.io.BufferedWriter; @@ -108,6 +110,8 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(User.class, new User.Deserializer()); deserializersByClass.put(UserFeedback.class, new UserFeedback.Deserializer()); deserializersByClass.put(ClientReport.class, new ClientReport.Deserializer()); + deserializersByClass.put(ViewHierarchyNode.class, new ViewHierarchyNode.Deserializer()); + deserializersByClass.put(ViewHierarchy.class, new ViewHierarchy.Deserializer()); } // Deserialize diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a6feae65e7..48567f186f 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -232,6 +232,11 @@ private boolean shouldSendSessionUpdateForDroppedEvent( attachments.add(screenshot); } + @Nullable final Attachment viewHierarchy = hint.getViewHierarchy(); + if (viewHierarchy != null) { + attachments.add(viewHierarchy); + } + return attachments; } @@ -273,7 +278,11 @@ private boolean shouldSendSessionUpdateForDroppedEvent( if (attachments != null) { for (final Attachment attachment : attachments) { final SentryEnvelopeItem attachmentItem = - SentryEnvelopeItem.fromAttachment(attachment, options.getMaxAttachmentSize()); + SentryEnvelopeItem.fromAttachment( + options.getSerializer(), + options.getLogger(), + attachment, + options.getMaxAttachmentSize()); envelopeItems.add(attachmentItem); } } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 56fe30ebdf..2d5767718f 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -165,31 +165,43 @@ public static SentryEnvelopeItem fromUserFeedback( } public static SentryEnvelopeItem fromAttachment( - final @NotNull Attachment attachment, final long maxAttachmentSize) { + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull Attachment attachment, + final long maxAttachmentSize) { final CachedItem cachedItem = new CachedItem( () -> { if (attachment.getBytes() != null) { - if (attachment.getBytes().length > maxAttachmentSize) { - throw new SentryEnvelopeException( - String.format( - "Dropping attachment with filename '%s', because the " - + "size of the passed bytes with %d bytes is bigger " - + "than the maximum allowed attachment size of " - + "%d bytes.", - attachment.getFilename(), - attachment.getBytes().length, - maxAttachmentSize)); + final byte[] data = attachment.getBytes(); + ensureAttachmentSizeLimit(data.length, maxAttachmentSize, attachment.getFilename()); + 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; } - return attachment.getBytes(); } else if (attachment.getPathname() != null) { return readBytesFromFile(attachment.getPathname(), maxAttachmentSize); } throw new SentryEnvelopeException( String.format( "Couldn't attach the attachment %s.\n" - + "Please check that either bytes or a path is set.", + + "Please check that either bytes, serializable or a path is set.", attachment.getFilename())); }); @@ -205,6 +217,20 @@ public static SentryEnvelopeItem fromAttachment( return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); } + private static void ensureAttachmentSizeLimit( + final long size, final long maxAttachmentSize, final @NotNull String filename) + throws SentryEnvelopeException { + if (size > maxAttachmentSize) { + throw new SentryEnvelopeException( + String.format( + "Dropping attachment with filename '%s', because the " + + "size of the passed bytes with %d bytes is bigger " + + "than the maximum allowed attachment size of " + + "%d bytes.", + filename, size, maxAttachmentSize)); + } + } + public static @NotNull SentryEnvelopeItem fromProfilingTrace( final @NotNull ProfilingTraceData profilingTraceData, final long maxTraceFileSize, diff --git a/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java b/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java index 553b9279ba..8b6bc9ba37 100644 --- a/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java +++ b/sentry/src/main/java/io/sentry/internal/modules/ModulesLoader.java @@ -16,6 +16,9 @@ @ApiStatus.Internal public abstract class ModulesLoader implements IModulesLoader { + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final Charset UTF_8 = Charset.forName("UTF-8"); + public static final String EXTERNAL_MODULES_FILENAME = "sentry-external-modules.txt"; protected final @NotNull ILogger logger; private @Nullable Map cachedModules = null; @@ -35,11 +38,9 @@ public ModulesLoader(final @NotNull ILogger logger) { protected abstract Map loadModules(); - @SuppressWarnings("CharsetObjectCanBeUsed") protected Map parseStream(final @NotNull InputStream stream) { final Map modules = new TreeMap<>(); - try (final BufferedReader reader = - new BufferedReader(new InputStreamReader(stream, Charset.forName("UTF-8")))) { + try (final BufferedReader reader = new BufferedReader(new InputStreamReader(stream, UTF_8))) { String module = reader.readLine(); while (module != null) { int sep = module.lastIndexOf(':'); diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java new file mode 100644 index 0000000000..9a24ccc835 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java @@ -0,0 +1,108 @@ +package io.sentry.protocol; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonObjectWriter; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ViewHierarchy implements JsonUnknown, JsonSerializable { + + public static final class JsonKeys { + public static final String RENDERING_SYSTEM = "rendering_system"; + public static final String WINDOWS = "windows"; + } + + private final @Nullable String renderingSystem; + private final @Nullable List windows; + private @Nullable Map unknown; + + public ViewHierarchy( + final @Nullable String renderingSystem, final @Nullable List windows) { + this.renderingSystem = renderingSystem; + this.windows = windows; + } + + @Nullable + public String getRenderingSystem() { + return renderingSystem; + } + + @Nullable + public List getWindows() { + return windows; + } + + @Override + public void serialize(@NotNull JsonObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (renderingSystem != null) { + writer.name(JsonKeys.RENDERING_SYSTEM).value(renderingSystem); + } + if (windows != null) { + writer.name(JsonKeys.WINDOWS).value(logger, windows); + } + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ViewHierarchy deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + + @Nullable String renderingSystem = null; + @Nullable List windows = null; + @Nullable Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.RENDERING_SYSTEM: + renderingSystem = reader.nextStringOrNull(); + break; + case JsonKeys.WINDOWS: + windows = reader.nextList(logger, new ViewHierarchyNode.Deserializer()); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + final ViewHierarchy viewHierarchy = new ViewHierarchy(renderingSystem, windows); + viewHierarchy.setUnknown(unknown); + return viewHierarchy; + } + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java new file mode 100644 index 0000000000..d751b27a80 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java @@ -0,0 +1,263 @@ +package io.sentry.protocol; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonObjectWriter; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ViewHierarchyNode implements JsonUnknown, JsonSerializable { + + public static final class JsonKeys { + public static final String RENDERING_SYSTEM = "rendering_system"; + public static final String TYPE = "type"; + public static final String IDENTIFIER = "identifier"; + public static final String TAG = "tag"; + public static final String WIDTH = "width"; + public static final String HEIGHT = "height"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String VISIBILITY = "visibility"; + public static final String ALPHA = "alpha"; + public static final String CHILDREN = "children"; + } + + private @Nullable String renderingSystem; + private @Nullable String type; + private @Nullable String identifier; + private @Nullable String tag; + private @Nullable Double width; + private @Nullable Double height; + private @Nullable Double x; + private @Nullable Double y; + private @Nullable String visibility; + private @Nullable Double alpha; + private @Nullable List children; + private @Nullable Map unknown; + + public ViewHierarchyNode() {} + + public void setRenderingSystem(String renderingSystem) { + this.renderingSystem = renderingSystem; + } + + public void setType(String type) { + this.type = type; + } + + public void setIdentifier(final @Nullable String identifier) { + this.identifier = identifier; + } + + public void setTag(final @Nullable String tag) { + this.tag = tag; + } + + public void setWidth(final @Nullable Double width) { + this.width = width; + } + + public void setHeight(final @Nullable Double height) { + this.height = height; + } + + public void setX(final @Nullable Double x) { + this.x = x; + } + + public void setY(final @Nullable Double y) { + this.y = y; + } + + public void setVisibility(final @Nullable String visibility) { + this.visibility = visibility; + } + + public void setAlpha(final @Nullable Double alpha) { + this.alpha = alpha; + } + + public void setChildren(final @Nullable List children) { + this.children = children; + } + + @Nullable + public String getRenderingSystem() { + return renderingSystem; + } + + @Nullable + public String getType() { + return type; + } + + @Nullable + public String getIdentifier() { + return identifier; + } + + @Nullable + public String getTag() { + return tag; + } + + @Nullable + public Double getWidth() { + return width; + } + + @Nullable + public Double getHeight() { + return height; + } + + @Nullable + public Double getX() { + return x; + } + + @Nullable + public Double getY() { + return y; + } + + @Nullable + public String getVisibility() { + return visibility; + } + + @Nullable + public Double getAlpha() { + return alpha; + } + + @Nullable + public List getChildren() { + return children; + } + + @Override + public void serialize(@NotNull JsonObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (renderingSystem != null) { + writer.name(JsonKeys.RENDERING_SYSTEM).value(renderingSystem); + } + if (type != null) { + writer.name(JsonKeys.TYPE).value(type); + } + if (identifier != null) { + writer.name(JsonKeys.IDENTIFIER).value(identifier); + } + if (tag != null) { + writer.name(JsonKeys.TAG).value(tag); + } + if (width != null) { + writer.name(JsonKeys.WIDTH).value(width); + } + if (height != null) { + writer.name(JsonKeys.HEIGHT).value(height); + } + if (x != null) { + writer.name(JsonKeys.X).value(x); + } + if (y != null) { + writer.name(JsonKeys.Y).value(y); + } + if (visibility != null) { + writer.name(JsonKeys.VISIBILITY).value(visibility); + } + if (alpha != null) { + writer.name(JsonKeys.ALPHA).value(alpha); + } + if (children != null && !children.isEmpty()) { + writer.name(JsonKeys.CHILDREN).value(logger, children); + } + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ViewHierarchyNode deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @Nullable Map unknown = null; + @NotNull final ViewHierarchyNode node = new ViewHierarchyNode(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.RENDERING_SYSTEM: + node.renderingSystem = reader.nextStringOrNull(); + break; + case JsonKeys.TYPE: + node.type = reader.nextStringOrNull(); + break; + case JsonKeys.IDENTIFIER: + node.identifier = reader.nextStringOrNull(); + break; + case JsonKeys.TAG: + node.tag = reader.nextStringOrNull(); + break; + case JsonKeys.WIDTH: + node.width = reader.nextDoubleOrNull(); + break; + case JsonKeys.HEIGHT: + node.height = reader.nextDoubleOrNull(); + break; + case JsonKeys.X: + node.x = reader.nextDoubleOrNull(); + break; + case JsonKeys.Y: + node.y = reader.nextDoubleOrNull(); + break; + case JsonKeys.VISIBILITY: + node.visibility = reader.nextStringOrNull(); + break; + case JsonKeys.ALPHA: + node.alpha = reader.nextDoubleOrNull(); + break; + case JsonKeys.CHILDREN: + node.children = reader.nextList(logger, this); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + node.setUnknown(unknown); + return node; + } + } +} diff --git a/sentry/src/test/java/io/sentry/AttachmentTest.kt b/sentry/src/test/java/io/sentry/AttachmentTest.kt index c2887fe94f..7ce47b4e1c 100644 --- a/sentry/src/test/java/io/sentry/AttachmentTest.kt +++ b/sentry/src/test/java/io/sentry/AttachmentTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.protocol.ViewHierarchy import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -112,4 +113,17 @@ class AttachmentTest { assertEquals(false, attachment.isAddToTransactions) assertEquals(bytes, attachment.bytes) } + + @Test + fun `creates attachment from view hierarchy`() { + val hierarchy = ViewHierarchy("android", emptyList()) + val attachment = Attachment.fromViewHierarchy(hierarchy) + + assertEquals("view-hierarchy.json", attachment.filename) + assertEquals("application/json", attachment.contentType) + assertEquals(false, attachment.isAddToTransactions) + assertEquals("event.view_hierarchy", attachment.attachmentType) + assertNull(attachment.bytes) + assertEquals(hierarchy, attachment.serializable) + } } diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index 944fb24f73..af24bcf042 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -45,14 +45,15 @@ class JsonSerializerTest { val serializer: ISerializer val hub = mock() val traceFile = Files.createTempFile("test", "here").toFile() + val options = SentryOptions() init { - val options = SentryOptions() options.dsn = "https://key@sentry.io/proj" options.setLogger(logger) - options.setDebug(true) + options.isDebug = true whenever(hub.options).thenReturn(options) serializer = JsonSerializer(options) + options.setSerializer(serializer) options.setEnvelopeReader(EnvelopeReader(serializer)) } } @@ -946,9 +947,9 @@ class JsonSerializerTest { val message = "hello" val attachment = Attachment(message.toByteArray(), "bytes.txt") - val validAttachmentItem = SentryEnvelopeItem.fromAttachment(attachment, 5) + val validAttachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, 5) - val invalidAttachmentItem = SentryEnvelopeItem.fromAttachment(Attachment("no"), 5) + val invalidAttachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, Attachment("no"), 5) val envelope = SentryEnvelope(header, listOf(invalidAttachmentItem, validAttachmentItem)) val actualJson = serializeToString(envelope) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index e1f5a88f1b..e9543f5493 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -15,6 +15,7 @@ import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User +import io.sentry.protocol.ViewHierarchy import io.sentry.test.callMethod import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate @@ -1420,6 +1421,42 @@ class SentryClientTest { ) } + @Test + fun `view hierarchy is added to the envelope from the hint`() { + val sut = fixture.getSut() + val attachment = Attachment.fromViewHierarchy(ViewHierarchy("android_view_system", emptyList())) + val hint = Hint().also { it.viewHierarchy = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport).send( + check { envelope -> + val viewHierarchy = envelope.items.last() + assertNotNull(viewHierarchy) { + assertEquals(attachment.filename, viewHierarchy.header.fileName) + } + }, + anyOrNull() + ) + } + + @Test + fun `view hierarchy is dropped from hint via before send`() { + fixture.sentryOptions.beforeSend = CustomBeforeSendCallback() + val sut = fixture.getSut() + val attachment = Attachment.fromViewHierarchy(ViewHierarchy("android_view_system", emptyList())) + val hint = Hint().also { it.viewHierarchy = attachment } + + sut.captureEvent(SentryEvent(), hint) + + verify(fixture.transport).send( + check { envelope -> + assertEquals(1, envelope.items.count()) + }, + anyOrNull() + ) + } + @Test fun `capturing an error updates session and sends event + session`() { val sut = fixture.getSut() @@ -1978,7 +2015,7 @@ class SentryClientTest { class CustomBeforeSendCallback : SentryOptions.BeforeSendCallback { override fun execute(event: SentryEvent, hint: Hint): SentryEvent? { hint.screenshot = null - + hint.viewHierarchy = null return event } } diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 896445554f..7b76678624 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -2,13 +2,18 @@ package io.sentry import io.sentry.exception.SentryEnvelopeException import io.sentry.protocol.User +import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField import io.sentry.vendor.Base64 import org.junit.Assert.assertArrayEquals import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.io.BufferedWriter +import java.io.ByteArrayOutputStream import java.io.File +import java.io.OutputStreamWriter +import java.nio.charset.Charset import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -20,7 +25,8 @@ import kotlin.test.assertNull class SentryEnvelopeItemTest { private class Fixture { - val serializer = JsonSerializer(SentryOptions()) + val options = SentryOptions() + val serializer = JsonSerializer(options) val pathname = "hello.txt" val filename = pathname val bytes = "hello".toByteArray() @@ -53,16 +59,28 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes`() { val attachment = Attachment(fixture.bytesAllowed, fixture.filename) - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertAttachment(attachment, fixture.bytesAllowed, item) } + @Test + fun `fromAttachment with Serializable`() { + val viewHierarchy = ViewHierarchy("android", emptyList()) + val viewHierarchySerialized = serialize(viewHierarchy) + + val attachment = Attachment(viewHierarchy, fixture.filename, "text/plain", null, false) + + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + + assertAttachment(attachment, viewHierarchySerialized, item) + } + @Test fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertEquals("event.minidump", item.header.attachmentType) } @@ -73,7 +91,7 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytesAllowed) val attachment = Attachment(file.path) - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -85,7 +103,7 @@ class SentryEnvelopeItemTest { file.writeBytes(twoMB) val attachment = Attachment(file.absolutePath) - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertAttachment(attachment, twoMB, item) } @@ -94,7 +112,7 @@ class SentryEnvelopeItemTest { fun `fromAttachment with non existent file`() { val attachment = Attachment("I don't exist", "file.txt") - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, because the file located at " + @@ -114,7 +132,7 @@ class SentryEnvelopeItemTest { if (changedFileReadPermission) { val attachment = Attachment(file.path, "file.txt") - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, " + @@ -137,7 +155,7 @@ class SentryEnvelopeItemTest { val securityManager = DenyReadFileSecurityManager(fixture.pathname) System.setSecurityManager(securityManager) - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertFailsWith("Reading the attachment ${attachment.pathname} failed.") { item.data @@ -156,7 +174,7 @@ class SentryEnvelopeItemTest { // reflection instead. attachment.injectForField("pathname", null) - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -171,7 +189,7 @@ class SentryEnvelopeItemTest { val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! val attachment = Attachment(image.path) - val item = SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) assertAttachment(attachment, image.readBytes(), item) } @@ -179,7 +197,7 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes too big`() { val attachment = Attachment(fixture.bytesTooBig, fixture.filename) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data } assertEquals( @@ -191,6 +209,29 @@ class SentryEnvelopeItemTest { ) } + @Test + fun `fromAttachment with serializable too big`() { + val serializable = JsonSerializable { writer, _ -> + writer.beginObject() + writer.name("payload").value(String(fixture.bytesTooBig)) + writer.endObject() + } + val serializedBytes = serialize(serializable) + + val attachment = Attachment(serializable, fixture.filename, "text/plain", null, false) + val exception = assertFailsWith { + SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + } + + assertEquals( + "Dropping attachment with filename '${fixture.filename}', because the " + + "size of the passed bytes with ${serializedBytes.size} bytes is bigger " + + "than the maximum allowed attachment size of " + + "${fixture.maxAttachmentSize} bytes.", + exception.message + ) + } + @Test fun `fromAttachment with file too big`() { val file = File(fixture.pathname) @@ -198,7 +239,7 @@ class SentryEnvelopeItemTest { val attachment = Attachment(file.path) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data } assertEquals( @@ -287,4 +328,13 @@ class SentryEnvelopeItemTest { assertEquals(attachment.filename, actualItem.header.fileName) assertArrayEquals(expectedBytes, actualItem.data) } + + private fun serialize(serializable: JsonSerializable): ByteArray { + ByteArrayOutputStream().use { stream -> + BufferedWriter(OutputStreamWriter(stream, Charset.forName("UTF-8"))).use { writer -> + fixture.serializer.serialize(serializable, writer) + return stream.toByteArray() + } + } + } } diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 5593404fb8..4bca606ad6 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -5,6 +5,7 @@ import io.sentry.DataCategory import io.sentry.DateUtils import io.sentry.EventProcessor import io.sentry.Hint +import io.sentry.NoOpLogger import io.sentry.Sentry import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader @@ -52,7 +53,7 @@ class ClientReportTest { SentryEnvelopeItem.fromEvent(opts.serializer, SentryEvent()), SentryEnvelopeItem.fromSession(opts.serializer, Session("dis", User(), "env", "0.0.1")), SentryEnvelopeItem.fromUserFeedback(opts.serializer, UserFeedback(SentryId(UUID.randomUUID()))), - SentryEnvelopeItem.fromAttachment(Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) + SentryEnvelopeItem.fromAttachment(opts.serializer, NoOpLogger.getInstance(), Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) ) clientReportRecorder.recordLostEnvelope(DiscardReason.NETWORK_ERROR, envelope) diff --git a/sentry/src/test/java/io/sentry/hints/HintTest.kt b/sentry/src/test/java/io/sentry/hints/HintTest.kt index fba9dd848a..054baedcae 100644 --- a/sentry/src/test/java/io/sentry/hints/HintTest.kt +++ b/sentry/src/test/java/io/sentry/hints/HintTest.kt @@ -208,6 +208,7 @@ class HintTest { hint.set(userAttribute, "test label") hint.addAttachment(newAttachment("test attachment")) hint.screenshot = newAttachment("2") + hint.viewHierarchy = newAttachment("3") hint.clear() @@ -215,6 +216,25 @@ class HintTest { assertNull(hint.get(userAttribute)) assertEquals(1, hint.attachments.size) assertNotNull(hint.screenshot) + assertNotNull(hint.viewHierarchy) + } + + @Test + fun `can create hint with a screenshot`() { + val hint = Hint() + val attachment = newAttachment("test1") + hint.screenshot = attachment + + assertNotNull(hint.screenshot) + } + + @Test + fun `can create hint with a view hierarchy`() { + val hint = Hint() + val attachment = newAttachment("test1") + hint.viewHierarchy = attachment + + assertNotNull(hint.viewHierarchy) } companion object { diff --git a/sentry/src/test/java/io/sentry/protocol/ViewHierarchyNodeSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ViewHierarchyNodeSerializationTest.kt new file mode 100644 index 0000000000..fa2fe7103a --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ViewHierarchyNodeSerializationTest.kt @@ -0,0 +1,76 @@ +package io.sentry.protocol + +import io.sentry.FileFromResources +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonObjectWriter +import io.sentry.JsonSerializable +import org.junit.Test +import org.mockito.kotlin.mock +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.assertEquals + +class ViewHierarchyNodeSerializationTest { + + private class Fixture { + val logger = mock() + + fun getSut() = ViewHierarchyNode().apply { + type = "com.example.ui.FancyButton" + identifier = "button_logout" + children = listOf( + ViewHierarchyNode().apply { + renderingSystem = "compose" + type = "Clickable" + } + ) + width = 100.0 + height = 200.0 + x = 0.0 + y = 2.0 + visibility = "visible" + alpha = 1.0 + unknown = mapOf( + "extra_property" to 42 + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/view_hierarchy_node.json") + val actual = serialize(fixture.getSut()) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/view_hierarchy_node.json") + val actual = deserialize(expectedJson) + val actualJson = serialize(actual) + assertEquals(expectedJson, actualJson) + } + + // Helper + + private fun sanitizedFile(path: String): String { + return FileFromResources.invoke(path) + .replace(Regex("[\n\r]"), "") + .replace(" ", "") + } + + private fun serialize(jsonSerializable: JsonSerializable): String { + val wrt = StringWriter() + val jsonWrt = JsonObjectWriter(wrt, 100) + jsonSerializable.serialize(jsonWrt, fixture.logger) + return wrt.toString() + } + + private fun deserialize(json: String): ViewHierarchyNode { + val reader = JsonObjectReader(StringReader(json)) + return ViewHierarchyNode.Deserializer().deserialize(reader, fixture.logger) + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/ViewHierarchySerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ViewHierarchySerializationTest.kt new file mode 100644 index 0000000000..b2fc9c4e02 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ViewHierarchySerializationTest.kt @@ -0,0 +1,64 @@ +package io.sentry.protocol + +import io.sentry.FileFromResources +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonObjectWriter +import io.sentry.JsonSerializable +import org.junit.Test +import org.mockito.kotlin.mock +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.assertEquals + +class ViewHierarchySerializationTest { + + private class Fixture { + val logger = mock() + fun getSut() = ViewHierarchy( + "android_view_system", + listOf( + ViewHierarchyNode().apply { + setType("com.example.ui.FancyButton") + } + ) + ) + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/view_hierarchy.json") + val actual = serialize(fixture.getSut()) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/view_hierarchy.json") + val actual = deserialize(expectedJson) + val actualJson = serialize(actual) + assertEquals(expectedJson, actualJson) + } + + // Helper + + private fun sanitizedFile(path: String): String { + return FileFromResources.invoke(path) + .replace(Regex("[\n\r]"), "") + .replace(" ", "") + } + + private fun serialize(jsonSerializable: JsonSerializable): String { + val wrt = StringWriter() + val jsonWrt = JsonObjectWriter(wrt, 100) + jsonSerializable.serialize(jsonWrt, fixture.logger) + return wrt.toString() + } + + private fun deserialize(json: String): ViewHierarchy { + val reader = JsonObjectReader(StringReader(json)) + return ViewHierarchy.Deserializer().deserialize(reader, fixture.logger) + } +} diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index e1cff28c96..fc1a133f6f 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -186,7 +186,7 @@ class RateLimiterTest { } ) val sessionItem = SentryEnvelopeItem.fromSession(fixture.serializer, Session("123", User(), "env", "release")) - val attachmentItem = SentryEnvelopeItem.fromAttachment(Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) + val attachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, NoOpLogger.getInstance(), Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem, userFeedbackItem, sessionItem, attachmentItem)) @@ -218,7 +218,7 @@ class RateLimiterTest { } ) val sessionItem = SentryEnvelopeItem.fromSession(fixture.serializer, Session("123", User(), "env", "release")) - val attachmentItem = SentryEnvelopeItem.fromAttachment(Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) + val attachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, NoOpLogger.getInstance(), Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem, userFeedbackItem, sessionItem, attachmentItem)) diff --git a/sentry/src/test/resources/json/view_hierarchy.json b/sentry/src/test/resources/json/view_hierarchy.json new file mode 100644 index 0000000000..5f6da6e6d4 --- /dev/null +++ b/sentry/src/test/resources/json/view_hierarchy.json @@ -0,0 +1,6 @@ +{ + "rendering_system": "android_view_system", + "windows": [{ + "type": "com.example.ui.FancyButton" + }] +} diff --git a/sentry/src/test/resources/json/view_hierarchy_node.json b/sentry/src/test/resources/json/view_hierarchy_node.json new file mode 100644 index 0000000000..d7978618a1 --- /dev/null +++ b/sentry/src/test/resources/json/view_hierarchy_node.json @@ -0,0 +1,17 @@ +{ + "type": "com.example.ui.FancyButton", + "identifier": "button_logout", + "width": 100.0, + "height": 200.0, + "x": 0.0, + "y": 2.0, + "visibility": "visible", + "alpha": 1.0, + "children": [ + { + "rendering_system": "compose", + "type": "Clickable" + } + ], + "extra_property": 42 +}