diff --git a/CHANGELOG.md b/CHANGELOG.md index 4658a65397b..898cfe5d2b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add Android View Hierarchy support ([#2440](https://github.com/getsentry/sentry-java/pull/2440)) + ## 6.11.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 05316fa0d4e..e29b172bbb3 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -89,6 +89,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 @@ -121,16 +134,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; } @@ -152,6 +157,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 @@ -164,6 +170,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 @@ -235,6 +242,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 de6ce205965..d7478786fe3 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 5b3e473743e..8e6599592dd 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 @@ -209,12 +209,12 @@ 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)); + options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider)); } else { options .getLogger() @@ -222,6 +222,8 @@ private static void installDefaultIntegrations( SentryLevel.WARNING, "ActivityLifecycle, FragmentLifecycle and UserInteraction Integrations need an Application class to be installed."); } + options.addEventProcessor(new ViewHierarchyEventProcessor(options)); + 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 00000000000..b4c5f1ed027 --- /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 d05560d792d..60bd3e338eb 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 041d4415793..550634d309c 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 ca1f9a03c17..03a697d13d9 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 00000000000..39186ede155 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -0,0 +1,169 @@ +package io.sentry.android.core; + +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import androidx.annotation.NonNull; +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.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +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 { + + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final @NotNull Charset UTF_8 = Charset.forName("UTF-8"); + + private static final long TIMEOUT_PROCESSING_MILLIS = 1000; + + 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) { + return event; + } + + final @Nullable Window window = activity.getWindow(); + if (window == null) { + return event; + } + + final @Nullable View decorView = window.peekDecorView(); + if (decorView == null) { + return event; + } + + try { + final @NotNull ViewHierarchy viewHierarchy = snapshotViewHierarchy(decorView); + final @NotNull Future future = + options + .getExecutorService() + .submit( + () -> { + try { + serializeViewHierarchy(viewHierarchy, hint); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed trying to serialize view hierarchy.", e); + } + }); + future.get(TIMEOUT_PROCESSING_MILLIS, TimeUnit.MILLISECONDS); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Failed to process view hierarchy.", t); + } + return event; + } + + private void serializeViewHierarchy(@NonNull ViewHierarchy viewHierarchy, @NonNull Hint hint) { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + + options.getSerializer().serialize(viewHierarchy, writer); + + final Attachment attachment = Attachment.fromViewHierarchy(stream.toByteArray()); + hint.setViewHierarchy(attachment); + } + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Could not snapshot ViewHierarchy", t); + } + } + + @NotNull + public static ViewHierarchy snapshotViewHierarchy(@NotNull final View view) { + final List windows = new ArrayList<>(); + final ViewHierarchy viewHierarchy = new ViewHierarchy("android_view_system", windows); + + final @NotNull ViewHierarchyNode decorNode = viewToNode(view); + windows.add(decorNode); + addChildren(view, decorNode); + + 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()); + node.setVisible(view.getVisibility() == View.VISIBLE); + + 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 d2da63ada95..00000000000 --- 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 68360451c9b..cb55d922149 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,7 +87,7 @@ 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(); final Resources resources = view.getContext().getResources(); String resourceId = ""; 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 5b8dadf0979..ef77c71e513 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 @@ -398,6 +398,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 00000000000..63306231214 --- /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 9835a380f42..a99bee37d68 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 3d81b176bff..2d208db7201 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 00000000000..8fc704a568d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt @@ -0,0 +1,273 @@ +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.ISentryExecutorService +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 java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +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.VISIBLE), + 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(true, contentNode.visible) + 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(true, visible) + assertEquals(null, children) + } + + contentNode.children!![1].apply { + assertEquals(200.0, width) + assertEquals(201.0, height) + assertEquals(20.0, x) + assertEquals(21.0, y) + assertEquals(false, visible) + assertEquals(null, children) + } + } + + @Test + fun `if serialization of view hierarchy takes too long, it does not get attached`() { + fixture.options.executorService = object : ISentryExecutorService { + val service = Executors.newSingleThreadScheduledExecutor() + override fun submit(runnable: Runnable): Future<*> { + service.submit { + Thread.sleep(2000L) + } + return service.submit(runnable) + } + + override fun submit(callable: Callable): Future { + service.submit { + Thread.sleep(2000L) + } + return service.submit(callable) + } + + override fun schedule(runnable: Runnable, delayMillis: Long): Future<*> { + return service.schedule(runnable, delayMillis, TimeUnit.MILLISECONDS) + } + + override fun close(timeoutMillis: Long) { + service.shutdown() + } + } + val (event, hint) = fixture.process( + true, + SentryEvent().apply { + exceptions = listOf(SentryException()) + } + ) + + assertNotNull(event) + assertNull(hint.viewHierarchy) + } + + 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-compose-helper/api/sentry-compose-helper.api b/sentry-compose-helper/api/sentry-compose-helper.api new file mode 100644 index 00000000000..b9fe8287fcd --- /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 0243cacb442..734fd1143f6 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 9e82bdc8b3c..ebe16ed7b51 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 18930d1e873..44899fead01 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7,12 +7,15 @@ public final class io/sentry/Attachment { 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 ([B)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; public fun getBytes ()[B public fun getContentType ()Ljava/lang/String; @@ -262,10 +265,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; } @@ -3341,6 +3346,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 getVisible ()Ljava/lang/Boolean; + 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 setVisible (Ljava/lang/Boolean;)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 VISIBLE 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 5e4113c96e9..01506095af7 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -18,6 +18,7 @@ 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 +60,29 @@ 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.filename = filename; this.contentType = contentType; + this.attachmentType = attachmentType; this.addToTransactions = addToTransactions; } @@ -110,7 +131,34 @@ 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); + } + + /** + * 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 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 String pathname, + final @NotNull String filename, + final @Nullable String contentType, + final @Nullable String attachmentType, + final boolean addToTransactions) { + this.pathname = pathname; + this.filename = filename; + this.contentType = contentType; + this.attachmentType = attachmentType; + this.addToTransactions = addToTransactions; } /** @@ -230,4 +278,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 viewHierarchyBytes the serialized View Hierarchy + * @return the Attachment + */ + public static @NotNull Attachment fromViewHierarchy(final byte[] viewHierarchyBytes) { + return new Attachment( + viewHierarchyBytes, + "view-hierarchy.json", + "application/json", + DEFAULT_ATTACHMENT_TYPE, // TODO replace with 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 fa7ade6461a..689860b3e5f 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(@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 b4091105730..e3de57e9715 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 a6feae65e70..1827a57fdc7 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; } 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 553b9279ba9..8b6bc9ba37e 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 00000000000..12e5bf79315 --- /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( + @Nullable String renderingSystem, @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 00000000000..fcc4353edde --- /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 VISIBLE = "visible"; + 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 Boolean visible; + 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 setVisible(final @Nullable Boolean visible) { + this.visible = visible; + } + + 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 Boolean getVisible() { + return visible; + } + + @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 (visible != null) { + writer.name(JsonKeys.VISIBLE).value(visible); + } + 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.VISIBLE: + node.visible = reader.nextBooleanOrNull(); + 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 c2887fe94fc..341eba4b194 100644 --- a/sentry/src/test/java/io/sentry/AttachmentTest.kt +++ b/sentry/src/test/java/io/sentry/AttachmentTest.kt @@ -112,4 +112,17 @@ class AttachmentTest { assertEquals(false, attachment.isAddToTransactions) assertEquals(bytes, attachment.bytes) } + + @Test + fun `creates attachment from view hierarchy`() { + val bytes = byteArrayOf() + val attachment = Attachment.fromViewHierarchy(bytes) + + assertEquals("view-hierarchy.json", attachment.filename) + assertEquals("application/json", attachment.contentType) + assertEquals(false, attachment.isAddToTransactions) + // TODO replace with event.view_hierarchy + assertEquals("event.attachment", attachment.attachmentType) + assertEquals(bytes, attachment.bytes) + } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index e1f5a88f1ba..64aa7316b53 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1420,6 +1420,42 @@ class SentryClientTest { ) } + @Test + fun `view hierarchy is added to the envelope from the hint`() { + val sut = fixture.getSut() + val attachment = Attachment.fromViewHierarchy(byteArrayOf()) + 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(byteArrayOf()) + 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 +2014,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/hints/HintTest.kt b/sentry/src/test/java/io/sentry/hints/HintTest.kt index fba9dd848ae..054baedcaef 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 00000000000..77f9f20383c --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ViewHierarchyNodeSerializationTest.kt @@ -0,0 +1,78 @@ +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 { + setType("com.example.ui.FancyButton") + setIdentifier("button_logout") + setChildren( + listOf( + ViewHierarchyNode().apply { + setRenderingSystem("compose") + setType("Clickable") + } + ) + ) + setWidth(100.0) + setHeight(200.0) + setX(0.0) + setY(2.0) + setVisible(true) + setAlpha(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 00000000000..b2fc9c4e028 --- /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/resources/json/view_hierarchy.json b/sentry/src/test/resources/json/view_hierarchy.json new file mode 100644 index 00000000000..5f6da6e6d4f --- /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 00000000000..adb6daafc61 --- /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, + "visible": true, + "alpha": 1.0, + "children": [ + { + "rendering_system": "compose", + "type": "Clickable" + } + ], + "extra_property": 42 +}