From d00c46496975956ae09d09c664644ebe1fca2bb8 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 13 Dec 2022 14:07:14 +0100 Subject: [PATCH 01/11] Start a session after init if AutoSessionTracking is enabled (#2356) Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 4 + .../sentry/android/core/LifecycleWatcher.java | 42 +++++---- .../io/sentry/android/core/SentryAndroid.java | 8 ++ .../gestures/SentryGestureListener.java | 5 ++ .../core/internal/util/BreadcrumbFactory.java | 19 ++++ .../android/core/LifecycleWatcherTest.kt | 87 +++++++++++++++++-- .../sentry/android/core/SentryAndroidTest.kt | 21 +++++ .../SentryGestureListenerScrollTest.kt | 1 + sentry/api/sentry.api | 1 + sentry/src/main/java/io/sentry/Scope.java | 5 ++ 10 files changed, 164 insertions(+), 29 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/BreadcrumbFactory.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f24e050621..a7a55c2ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Start a session after init if AutoSessionTracking is enabled ([#2356](https://github.com/getsentry/sentry-java/pull/2356)) + ### Dependencies - Bump Native SDK from v0.5.2 to v0.5.3 ([#2423](https://github.com/getsentry/sentry-java/pull/2423)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index c2b100b7ff..995d7f9f19 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -5,11 +5,12 @@ import io.sentry.Breadcrumb; import io.sentry.IHub; import io.sentry.SentryLevel; +import io.sentry.Session; +import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -27,7 +28,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final @NotNull IHub hub; private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; - private final @NotNull AtomicBoolean runningSession = new AtomicBoolean(); private final @NotNull ICurrentDateProvider currentDateProvider; @@ -74,15 +74,24 @@ private void startSession() { cancelTask(); final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { - addSessionBreadcrumb("start"); - hub.startSession(); - runningSession.set(true); - } - this.lastUpdatedSession.set(currentTimeMillis); + hub.withScope( + scope -> { + long lastUpdatedSession = this.lastUpdatedSession.get(); + if (lastUpdatedSession == 0L) { + @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + lastUpdatedSession = currentSession.getStarted().getTime(); + } + } + + if (lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + addSessionBreadcrumb("start"); + hub.startSession(); + } + this.lastUpdatedSession.set(currentTimeMillis); + }); } } @@ -110,7 +119,6 @@ private void scheduleEndSession() { public void run() { addSessionBreadcrumb("end"); hub.endSession(); - runningSession.set(false); } }; @@ -140,20 +148,10 @@ private void addAppBreadcrumb(final @NotNull String state) { } private void addSessionBreadcrumb(final @NotNull String state) { - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("session"); - breadcrumb.setData("state", state); - breadcrumb.setCategory("app.lifecycle"); - breadcrumb.setLevel(SentryLevel.INFO); + final Breadcrumb breadcrumb = BreadcrumbFactory.forSession(state); hub.addBreadcrumb(breadcrumb); } - @TestOnly - @NotNull - AtomicBoolean isRunningSession() { - return runningSession; - } - @TestOnly @Nullable TimerTask getTimerTask() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index e9e8a75c1f..9d046cbdb7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -3,12 +3,14 @@ import android.content.Context; import android.os.SystemClock; import io.sentry.DateUtils; +import io.sentry.IHub; import io.sentry.ILogger; import io.sentry.Integration; import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; @@ -119,6 +121,12 @@ public static synchronized void init( deduplicateIntegrations(options, isFragmentAvailable, isTimberAvailable); }, true); + + final @NotNull IHub hub = Sentry.getCurrentHub(); + if (hub.getOptions().isEnableAutoSessionTracking()) { + hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); + hub.startSession(); + } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index a076bd6bf4..72a2d3983c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -181,6 +181,11 @@ private void addBreadcrumb( final @NotNull String eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { + + if ((!options.isEnableUserInteractionBreadcrumbs())) { + return; + } + @NotNull String className; @Nullable String canonicalName = target.getClass().getCanonicalName(); if (canonicalName != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/BreadcrumbFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/BreadcrumbFactory.java new file mode 100644 index 0000000000..04cabc9430 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/BreadcrumbFactory.java @@ -0,0 +1,19 @@ +package io.sentry.android.core.internal.util; + +import io.sentry.Breadcrumb; +import io.sentry.SentryLevel; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public class BreadcrumbFactory { + + public static @NotNull Breadcrumb forSession(@NotNull String state) { + final Breadcrumb breadcrumb = new Breadcrumb(); + breadcrumb.setType("session"); + breadcrumb.setData("state", state); + breadcrumb.setCategory("app.lifecycle"); + breadcrumb.setLevel(SentryLevel.INFO); + return breadcrumb; + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 4802b44da4..740d340a7e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -2,17 +2,24 @@ package io.sentry.android.core import androidx.lifecycle.LifecycleOwner import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback import io.sentry.SentryLevel +import io.sentry.Session +import io.sentry.Session.State import io.sentry.transport.ICurrentDateProvider -import org.awaitility.kotlin.await +import org.mockito.ArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.timeout import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.util.* import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -25,8 +32,26 @@ class LifecycleWatcherTest { val hub = mock() val dateProvider = mock() - fun getSUT(sessionIntervalMillis: Long = 0L, enableAutoSessionTracking: Boolean = true, enableAppLifecycleBreadcrumbs: Boolean = true): LifecycleWatcher { - return LifecycleWatcher(hub, sessionIntervalMillis, enableAutoSessionTracking, enableAppLifecycleBreadcrumbs, dateProvider) + fun getSUT( + sessionIntervalMillis: Long = 0L, + enableAutoSessionTracking: Boolean = true, + enableAppLifecycleBreadcrumbs: Boolean = true, + session: Session? = null + ): LifecycleWatcher { + val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) + val scope = mock() + whenever(scope.session).thenReturn(session) + whenever(hub.withScope(argumentCaptor.capture())).thenAnswer { + argumentCaptor.value.run(scope) + } + + return LifecycleWatcher( + hub, + sessionIntervalMillis, + enableAutoSessionTracking, + enableAppLifecycleBreadcrumbs, + dateProvider + ) } } @@ -62,8 +87,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) watcher.onStop(fixture.ownerMock) - await.untilFalse(watcher.isRunningSession) - verify(fixture.hub).endSession() + verify(fixture.hub, timeout(10000)).endSession() } @Test @@ -112,9 +136,8 @@ class LifecycleWatcherTest { @Test fun `When session tracking is enabled, add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.isRunningSession.set(true) watcher.onStop(fixture.ownerMock) - await.untilFalse(watcher.isRunningSession) + verify(fixture.hub, timeout(10000)).endSession() verify(fixture.hub).addBreadcrumb( check { assertEquals("app.lifecycle", it.category) @@ -193,4 +216,54 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) assertNull(watcher.timer) } + + @Test + fun `if the hub has already a fresh session running, don't start new one`() { + val watcher = fixture.getSUT( + enableAppLifecycleBreadcrumbs = false, + session = Session( + State.Ok, + DateUtils.getCurrentDateTime(), + DateUtils.getCurrentDateTime(), + 0, + "abc", + UUID.fromString("3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17"), + true, + 0, + 10.0, + null, + null, + null, + "release" + ) + ) + + watcher.onStart(fixture.ownerMock) + verify(fixture.hub, never()).startSession() + } + + @Test + fun `if the hub has a long running session, start new one`() { + val watcher = fixture.getSUT( + enableAppLifecycleBreadcrumbs = false, + session = Session( + State.Ok, + DateUtils.getDateTime(-1), + DateUtils.getDateTime(-1), + 0, + "abc", + UUID.fromString("3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17"), + true, + 0, + 10.0, + null, + null, + null, + "release" + ) + ) + + watcher.onStart(fixture.ownerMock) + verify(fixture.hub).startSession() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index ebbfd13b1f..a00b57072c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -30,6 +30,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -186,6 +187,26 @@ class SentryAndroidTest { assertEquals(expectedCacheDir, (options!!.envelopeDiskCache as AndroidEnvelopeCache).directory.absolutePath) } + @Test + fun `init starts a session if auto session tracking is enabled`() { + fixture.initSut { options -> + options.isEnableAutoSessionTracking = true + } + Sentry.getCurrentHub().withScope { scope -> + assertNotNull(scope.session) + } + } + + @Test + fun `init does not start a session by if auto session tracking is disabled`() { + fixture.initSut { options -> + options.isEnableAutoSessionTracking = false + } + Sentry.getCurrentHub().withScope { scope -> + assertNull(scope.session) + } + } + private class CustomEnvelopCache : IEnvelopeCache { override fun iterator(): MutableIterator = TODO() override fun store(envelope: SentryEnvelope, hint: Hint) = Unit diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt index 7e935893ae..414538c657 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -34,6 +34,7 @@ class SentryGestureListenerScrollTest { val resources = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" + isEnableUserInteractionBreadcrumbs = true } val hub = mock() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 85f6c24022..be126c57cb 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -989,6 +989,7 @@ public final class io/sentry/Scope { public fun getContexts ()Lio/sentry/protocol/Contexts; public fun getLevel ()Lio/sentry/SentryLevel; public fun getRequest ()Lio/sentry/protocol/Request; + public fun getSession ()Lio/sentry/Session; public fun getSpan ()Lio/sentry/ISpan; public fun getTags ()Ljava/util/Map; public fun getTransaction ()Lio/sentry/ITransaction; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 0d5904fc6a..ac27668611 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -762,6 +762,11 @@ public void withTransaction(final @NotNull IWithTransaction callback) { } } + @ApiStatus.Internal + public @Nullable Session getSession() { + return session; + } + /** the IWithTransaction callback */ @ApiStatus.Internal public interface IWithTransaction { From b5b855d0b2599d47882ddee5d7df88d0e1027b36 Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 14 Dec 2022 09:35:08 +0100 Subject: [PATCH 02/11] Add ttid span to ActivityLifecycleIntegration (#2369) * added time-to-initial-display span to ActivityLifecycleIntegration * added FirstDrawDoneListener * added reference to Firebase sdk --- CHANGELOG.md | 9 +- .../api/sentry-android-core.api | 1 + .../core/ActivityLifecycleIntegration.java | 93 +++++++++++- .../internal/util/FirstDrawDoneListener.java | 99 +++++++++++++ .../core/ActivityLifecycleIntegrationTest.kt | 59 +++++++- .../util/FirstDrawDoneListenerTest.kt | 138 ++++++++++++++++++ .../sentry/samples/android/MainActivity.java | 3 +- .../samples/android/ProfilingActivity.kt | 5 - .../main/kotlin/io/sentry/test/Reflection.kt | 8 +- 9 files changed, 392 insertions(+), 23 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a55c2ee5..097c379ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add time-to-initial-display span to Activity transactions ([#2369](https://github.com/getsentry/sentry-java/pull/2369)) - Start a session after init if AutoSessionTracking is enabled ([#2356](https://github.com/getsentry/sentry-java/pull/2356)) ### Dependencies @@ -59,15 +60,15 @@ ## 6.8.0 +### Features + +- Add FrameMetrics to Android profiling data ([#2342](https://github.com/getsentry/sentry-java/pull/2342)) + ### Fixes - Remove profiler main thread io ([#2348](https://github.com/getsentry/sentry-java/pull/2348)) - Fix ensure all options are processed before integrations are loaded ([#2377](https://github.com/getsentry/sentry-java/pull/2377)) -### Features - -- Add FrameMetrics to Android profiling data ([#2342](https://github.com/getsentry/sentry-java/pull/2342)) - ## 6.7.1 ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 17dca925d3..fff1af2668 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -15,6 +15,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android public fun onActivityDestroyed (Landroid/app/Activity;)V public fun onActivityPaused (Landroid/app/Activity;)V public fun onActivityPostResumed (Landroid/app/Activity;)V + public fun onActivityPrePaused (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 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 62be5a39e0..00a5ed24b2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -3,14 +3,20 @@ import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityManager; import android.app.Application; import android.content.Context; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.os.Process; +import android.view.View; +import androidx.annotation.NonNull; import io.sentry.Breadcrumb; +import io.sentry.DateUtils; import io.sentry.Hint; import io.sentry.IHub; import io.sentry.ISpan; @@ -23,6 +29,7 @@ import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.Objects; import java.io.Closeable; @@ -43,8 +50,10 @@ public final class ActivityLifecycleIntegration static final String UI_LOAD_OP = "ui.load"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; + static final String TTID_OP = "ui.load.initial_display"; private final @NotNull Application application; + private final @NotNull BuildInfoProvider buildInfoProvider; private @Nullable IHub hub; private @Nullable SentryAndroidOptions options; @@ -57,6 +66,9 @@ public final class ActivityLifecycleIntegration private boolean foregroundImportance = false; private @Nullable ISpan appStartSpan; + private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); + private @NotNull Date lastPausedTime = DateUtils.getCurrentDateTime(); + private final @NotNull Handler mainHandler = new Handler(Looper.getMainLooper()); // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the // main-thread @@ -70,7 +82,8 @@ public ActivityLifecycleIntegration( final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull ActivityFramesTracker activityFramesTracker) { this.application = Objects.requireNonNull(application, "Application is required"); - Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + this.buildInfoProvider = + Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.activityFramesTracker = Objects.requireNonNull(activityFramesTracker, "ActivityFramesTracker is required"); @@ -146,7 +159,8 @@ private void stopPreviousTransactions() { for (final Map.Entry entry : activitiesWithOngoingTransactions.entrySet()) { final ITransaction transaction = entry.getValue(); - finishTransaction(transaction); + final ISpan ttidSpan = ttidSpanMap.get(entry.getKey()); + finishTransaction(transaction, ttidSpan); } } @@ -202,6 +216,18 @@ private void startTracing(final @NotNull Activity activity) { getAppStartDesc(coldStart), appStartTime, Instrumenter.SENTRY); + // The first activity ttidSpan should start at the same time as the app start time + ttidSpanMap.put( + activity, + transaction.startChild( + TTID_OP, getTtidDesc(activityName), appStartTime, Instrumenter.SENTRY)); + } else { + // Other activities (or in case appStartTime is not available) the ttid span should + // start when the previous activity called its onPause method. + ttidSpanMap.put( + activity, + transaction.startChild( + TTID_OP, getTtidDesc(activityName), lastPausedTime, Instrumenter.SENTRY)); } // lets bind to the scope so other integrations can pick it up @@ -250,11 +276,12 @@ private boolean isRunningTransaction(final @NotNull Activity activity) { private void stopTracing(final @NotNull Activity activity, final boolean shouldFinishTracing) { if (performanceEnabled && shouldFinishTracing) { final ITransaction transaction = activitiesWithOngoingTransactions.get(activity); - finishTransaction(transaction); + finishTransaction(transaction, null); } } - private void finishTransaction(final @Nullable ITransaction transaction) { + private void finishTransaction( + final @Nullable ITransaction transaction, final @Nullable ISpan ttidSpan) { if (transaction != null) { // if io.sentry.traces.activity.auto-finish.enable is disabled, transaction may be already // finished manually when this method is called. @@ -262,6 +289,9 @@ private void finishTransaction(final @Nullable ITransaction transaction) { return; } + // in case the ttidSpan isn't completed yet, we finish it as cancelled to avoid memory leak + finishSpan(ttidSpan, SpanStatus.CANCELLED); + SpanStatus status = transaction.getStatus(); // status might be set by other integrations, let's not overwrite it if (status == null) { @@ -301,6 +331,7 @@ public synchronized void onActivityStarted(final @NotNull Activity activity) { addBreadcrumb(activity, "started"); } + @SuppressLint("NewApi") @Override public synchronized void onActivityResumed(final @NotNull Activity activity) { if (!firstActivityResumed) { @@ -326,6 +357,17 @@ public synchronized void onActivityResumed(final @NotNull Activity activity) { firstActivityResumed = true; } + final ISpan ttidSpan = ttidSpanMap.get(activity); + final View rootView = activity.findViewById(android.R.id.content); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN + && rootView != null) { + FirstDrawDoneListener.registerForNextDraw( + rootView, () -> finishSpan(ttidSpan), buildInfoProvider); + } else { + // Posting a task to the main thread's handler will make it executed after it finished + // its current job. That is, right after the activity draws the layout. + mainHandler.post(() -> finishSpan(ttidSpan)); + } addBreadcrumb(activity, "resumed"); // fallback call for API < 29 compatibility, otherwise it happens on onActivityPostResumed @@ -344,8 +386,20 @@ public synchronized void onActivityPostResumed(final @NotNull Activity activity) } } + @Override + public void onActivityPrePaused(@NonNull Activity activity) { + // only executed if API >= 29 otherwise it happens on onActivityPaused + if (isAllActivityCallbacksAvailable) { + lastPausedTime = DateUtils.getCurrentDateTime(); + } + } + @Override public synchronized void onActivityPaused(final @NotNull Activity activity) { + // only executed if API < 29 otherwise it happens on onActivityPrePaused + if (!isAllActivityCallbacksAvailable) { + lastPausedTime = DateUtils.getCurrentDateTime(); + } addBreadcrumb(activity, "paused"); } @@ -366,9 +420,11 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak - if (appStartSpan != null && !appStartSpan.isFinished()) { - appStartSpan.finish(SpanStatus.CANCELLED); - } + finishSpan(appStartSpan, SpanStatus.CANCELLED); + + // we finish the ttidSpan as cancelled in case it isn't completed yet + final ISpan ttidSpan = ttidSpanMap.get(activity); + finishSpan(ttidSpan, SpanStatus.CANCELLED); // in case people opt-out enableActivityLifecycleTracingAutoFinish and forgot to finish it, // we make sure to finish it when the activity gets destroyed. @@ -376,6 +432,7 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + ttidSpanMap.remove(activity); // clear it up, so we don't start again for the same activity if the activity is in the activity // stack still. @@ -385,6 +442,18 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) { } } + private void finishSpan(@Nullable ISpan span) { + if (span != null && !span.isFinished()) { + span.finish(); + } + } + + private void finishSpan(@Nullable ISpan span, @NotNull SpanStatus status) { + if (span != null && !span.isFinished()) { + span.finish(status); + } + } + @TestOnly @NotNull WeakHashMap getActivitiesWithOngoingTransactions() { @@ -403,6 +472,12 @@ ISpan getAppStartSpan() { return appStartSpan; } + @TestOnly + @NotNull + WeakHashMap getTtidSpanMap() { + return ttidSpanMap; + } + private void setColdStart(final @Nullable Bundle savedInstanceState) { if (!firstActivityCreated) { // if Activity has savedInstanceState then its a warm start @@ -411,6 +486,10 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) { } } + private @NotNull String getTtidDesc(final @NotNull String activityName) { + return activityName + " initial display"; + } + private @NotNull String getAppStartDesc(final boolean coldStart) { if (coldStart) { return "Cold Start"; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java new file mode 100644 index 0000000000..10c160377b --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -0,0 +1,99 @@ +package io.sentry.android.core.internal.util; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewTreeObserver; +import androidx.annotation.RequiresApi; +import io.sentry.android.core.BuildInfoProvider; +import java.util.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.NotNull; + +/** + * OnDrawListener that unregisters itself and invokes callback when the next draw is done. This API + * 16+ implementation is an approximation of the initial-display-time defined by Android Vitals. + * + *

Adapted from Firebase + * under the Apache License, Version 2.0. + */ +@SuppressLint("ObsoleteSdkInt") +@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) +public class FirstDrawDoneListener implements ViewTreeObserver.OnDrawListener { + private final @NotNull Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + private final @NotNull AtomicReference viewReference; + private final @NotNull Runnable callback; + + /** Registers a post-draw callback for the next draw of a view. */ + public static void registerForNextDraw( + final @NotNull View view, + final @NotNull Runnable drawDoneCallback, + final @NotNull BuildInfoProvider buildInfoProvider) { + final FirstDrawDoneListener listener = new FirstDrawDoneListener(view, drawDoneCallback); + // Handle bug prior to API 26 where OnDrawListener from the floating ViewTreeObserver is not + // merged into the real ViewTreeObserver. + // https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3 + if (buildInfoProvider.getSdkInfoVersion() < 26 + && !isAliveAndAttached(view, buildInfoProvider)) { + view.addOnAttachStateChangeListener( + new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View view) { + view.getViewTreeObserver().addOnDrawListener(listener); + view.removeOnAttachStateChangeListener(this); + } + + @Override + public void onViewDetachedFromWindow(View view) { + view.removeOnAttachStateChangeListener(this); + } + }); + } else { + view.getViewTreeObserver().addOnDrawListener(listener); + } + } + + private FirstDrawDoneListener(final @NotNull View view, final @NotNull Runnable callback) { + this.viewReference = new AtomicReference<>(view); + this.callback = callback; + } + + @Override + public void onDraw() { + // Set viewReference to null so any onDraw past the first is a no-op + final View view = viewReference.getAndSet(null); + if (view == null) { + return; + } + // OnDrawListeners cannot be removed within onDraw, so we remove it with a + // GlobalLayoutListener + view.getViewTreeObserver() + .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + mainThreadHandler.postAtFrontOfQueue(callback); + } + + /** + * Helper to avoid bug + * prior to API 26, where the floating ViewTreeObserver's OnDrawListeners are not merged into + * the real ViewTreeObserver during attach. + * + * @return true if the View is already attached and the ViewTreeObserver is not a floating + * placeholder. + */ + private static boolean isAliveAndAttached( + final @NotNull View view, final @NotNull BuildInfoProvider buildInfoProvider) { + return view.getViewTreeObserver().isAlive() && isAttachedToWindow(view, buildInfoProvider); + } + + @SuppressLint("NewApi") + private static boolean isAttachedToWindow( + final @NotNull View view, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= 19) { + return view.isAttachedToWindow(); + } + return view.getWindowToken() != null; + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 7c530b0758..89e7399f80 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -363,13 +363,14 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `When tracing auto finish is enabled, it stops the transaction on onActivityPostResumed`() { + fun `When tracing auto finish is enabled and ttid span is finished, it stops the transaction on onActivityPostResumed`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) + sut.ttidSpanMap.values.first().finish() sut.onActivityPostResumed(activity) verify(fixture.hub).captureTransaction( @@ -381,6 +382,25 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `When tracing auto finish is enabled, it doesn't stop the transaction on onActivityPostResumed`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityPostResumed(activity) + + verify(fixture.hub, never()).captureTransaction( + check { + assertEquals(SpanStatus.OK, it.status) + }, + anyOrNull(), + anyOrNull() + ) + } + @Test fun `When tracing has status, do not overwrite it`() { val sut = fixture.getSut() @@ -393,6 +413,7 @@ class ActivityLifecycleIntegrationTest { fixture.transaction.status = SpanStatus.UNKNOWN_ERROR sut.onActivityPostResumed(activity) + sut.onActivityDestroyed(activity) verify(fixture.hub).captureTransaction( check { @@ -498,6 +519,39 @@ class ActivityLifecycleIntegrationTest { assertNull(sut.appStartSpan) } + @Test + fun `When Activity is destroyed, sets ttidSpan status to cancelled and finish it`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val span = fixture.transaction.children.first { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertEquals(SpanStatus.CANCELLED, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `When Activity is destroyed, sets ttidSpan to null`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + assertNotNull(sut.ttidSpanMap[activity]) + + sut.onActivityDestroyed(activity) + assertNull(sut.ttidSpanMap[activity]) + } + @Test fun `When new Activity and transaction is created, finish previous ones`() { val sut = fixture.getSut() @@ -538,13 +592,14 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `stop transaction on resumed if API 29 less than 29`() { + fun `stop transaction on resumed if API 29 less than 29 and ttid is finished`() { val sut = fixture.getSut(14) fixture.options.tracesSampleRate = 1.0 sut.register(fixture.hub, fixture.options) val activity = mock() sut.onActivityCreated(activity, mock()) + sut.ttidSpanMap.values.first().finish() sut.onActivityResumed(activity) verify(fixture.hub).captureTransaction(any(), anyOrNull(), anyOrNull()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt new file mode 100644 index 0000000000..07fc383e1d --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -0,0 +1,138 @@ +package io.sentry.android.core.internal.util + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewTreeObserver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.BuildInfoProvider +import io.sentry.test.getProperty +import org.junit.runner.RunWith +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Shadows +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class FirstDrawDoneListenerTest { + + private class Fixture { + val application: Context = ApplicationProvider.getApplicationContext() + val buildInfo = mock() + lateinit var onDrawListeners: ArrayList + + fun getSut(apiVersion: Int = 26): View { + whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) + val view = View(application) + + // Adding a listener forces ViewTreeObserver.mOnDrawListeners to be initialized and non-null. + val dummyListener = ViewTreeObserver.OnDrawListener {} + view.viewTreeObserver.addOnDrawListener(dummyListener) + view.viewTreeObserver.removeOnDrawListener(dummyListener) + + // Obtain mOnDrawListeners field through reflection + onDrawListeners = view.viewTreeObserver.getProperty("mOnDrawListeners") + assertTrue(onDrawListeners.isEmpty()) + + return view + } + } + + private val fixture = Fixture() + + @Test + fun `registerForNextDraw adds listener on attach state changed on sdk 25-`() { + val view = fixture.getSut(25) + + // OnDrawListener is not registered, it is delayed for later + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + assertTrue(fixture.onDrawListeners.isEmpty()) + + // Register listener after the view is attached to a window + val listenerInfo = Class.forName("android.view.View\$ListenerInfo") + val mListenerInfo: Any = view.getProperty("mListenerInfo") + val mOnAttachStateChangeListeners: CopyOnWriteArrayList = + mListenerInfo.getProperty(listenerInfo, "mOnAttachStateChangeListeners") + assertFalse(mOnAttachStateChangeListeners.isEmpty()) + + // Dispatch onViewAttachedToWindow() + for (listener in mOnAttachStateChangeListeners) { + listener.onViewAttachedToWindow(view) + } + + assertFalse(fixture.onDrawListeners.isEmpty()) + assertIs(fixture.onDrawListeners[0]) + + // mOnAttachStateChangeListeners is automatically removed + assertTrue(mOnAttachStateChangeListeners.isEmpty()) + } + + @Test + fun `registerForNextDraw adds listener on sdk 26+`() { + val view = fixture.getSut() + + // Immediately register an OnDrawListener to ViewTreeObserver + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + assertFalse(fixture.onDrawListeners.isEmpty()) + assertIs(fixture.onDrawListeners[0]) + } + + @Test + fun `registerForNextDraw posts callback to front of queue`() { + val view = fixture.getSut() + val handler = Handler(Looper.getMainLooper()) + val drawDoneCallback = mock() + val otherCallback = mock() + val inOrder = inOrder(drawDoneCallback, otherCallback) + FirstDrawDoneListener.registerForNextDraw(view, drawDoneCallback, fixture.buildInfo) + handler.post(otherCallback) // 3rd in queue + handler.postAtFrontOfQueue(otherCallback) // 2nd in queue + view.viewTreeObserver.dispatchOnDraw() // 1st in queue + verify(drawDoneCallback, never()).run() + verify(otherCallback, never()).run() + + // Execute all posted tasks + Shadows.shadowOf(Looper.getMainLooper()).idle() + inOrder.verify(drawDoneCallback).run() + inOrder.verify(otherCallback, times(2)).run() + inOrder.verifyNoMoreInteractions() + } + + @Test + fun `registerForNextDraw unregister itself after onDraw`() { + val view = fixture.getSut() + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + assertFalse(fixture.onDrawListeners.isEmpty()) + + // Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered + view.viewTreeObserver.dispatchOnGlobalLayout() + assertFalse(fixture.onDrawListeners.isEmpty()) + + // Removes OnDrawListener in the next OnGlobalLayout after onDraw + view.viewTreeObserver.dispatchOnDraw() + view.viewTreeObserver.dispatchOnGlobalLayout() + assertTrue(fixture.onDrawListeners.isEmpty()) + } + + @Test + fun `registerForNextDraw calls the given callback on the main thread after onDraw`() { + val view = fixture.getSut() + val r: Runnable = mock() + FirstDrawDoneListener.registerForNextDraw(view, r, fixture.buildInfo) + view.viewTreeObserver.dispatchOnDraw() + + // Execute all tasks posted to main looper + Shadows.shadowOf(Looper.getMainLooper()).idle() + verify(r).run() + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 6f3c401633..3b3cc037b8 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -7,7 +7,6 @@ import io.sentry.ISpan; import io.sentry.MeasurementUnit; import io.sentry.Sentry; -import io.sentry.SpanStatus; import io.sentry.UserFeedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; @@ -206,7 +205,7 @@ protected void onResume() { final ISpan span = Sentry.getSpan(); if (span != null) { span.setMeasurement("screen_load_count", screenLoadCount, new MeasurementUnit.Custom("test")); - span.finish(SpanStatus.OK); + // span.finish(SpanStatus.OK); } } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index 60def396b9..1ec37bee4b 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt @@ -140,11 +140,6 @@ class ProfilingActivity : AppCompatActivity() { } } - override fun onResume() { - super.onResume() - Sentry.getSpan()?.finish() - } - override fun onBackPressed() { if (profileFinished) { super.onBackPressed() diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/Reflection.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/Reflection.kt index f8b4df892e..f76d86e817 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/Reflection.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/Reflection.kt @@ -37,11 +37,13 @@ fun Class<*>.containsMethod(name: String, parameterTypes: Array>): Bool fun Class<*>.containsMethod(name: String, parameterTypes: Class<*>): Boolean = containsMethod(name, arrayOf(parameterTypes)) -inline fun Any.getProperty(name: String): T = +inline fun Any.getProperty(name: String): T = this.getProperty(this::class.java, name) + +inline fun Any.getProperty(clz: Class<*>, name: String): T = try { - this::class.java.getDeclaredField(name) + clz.getDeclaredField(name) } catch (_: NoSuchFieldException) { - this::class.java.superclass.getDeclaredField(name) + clz.superclass.getDeclaredField(name) }.apply { this.isAccessible = true }.get(this) as T From 87598a5b2f9dbd390780b954bd76f39ae25f29c6 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 14 Dec 2022 12:21:15 +0100 Subject: [PATCH 03/11] Provide automatic breadcrumbs and transactions for click/scroll events for Compose (#2390) Co-authored-by: Roman Zavarnitsyn Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 4 - sentry-android-core/build.gradle.kts | 1 + .../core/AndroidOptionsInitializer.java | 24 +++++ .../android/core/SentryAndroidOptions.java | 24 +---- .../core/UserInteractionIntegration.java | 16 ++- .../AndroidViewGestureTargetLocator.java | 85 ++++++++++++++++ .../gestures/SentryGestureListener.java | 96 +++++++----------- .../core/internal/gestures/ViewUtils.java | 79 +++++---------- .../SentryGestureListenerClickTest.kt | 21 ++-- .../SentryGestureListenerScrollTest.kt | 13 +-- .../SentryGestureListenerTracingTest.kt | 14 +-- .../core/internal/gestures/ViewHelpers.kt | 6 +- .../sentry-uitest-android/build.gradle.kts | 11 ++- .../uitest/android/UserInteractionTests.kt | 93 +++++++++++++++++ .../src/main/AndroidManifest.xml | 3 + .../sentry/uitest/android/ComposeActivity.kt | 65 ++++++++++++ sentry-compose-helper/README.md | 10 ++ sentry-compose-helper/build.gradle.kts | 39 ++++++++ .../gestures/ComposeGestureTargetLocator.java | 99 +++++++++++++++++++ sentry-compose/build.gradle.kts | 35 ++++++- .../android/compose/ComposeActivity.kt | 21 +++- sentry/api/sentry.api | 31 ++++++ .../src/main/java/io/sentry/Breadcrumb.java | 25 +++++ .../main/java/io/sentry/SentryOptions.java | 47 +++++++++ .../gestures/GestureTargetLocator.java | 11 +++ .../sentry/internal/gestures/UiElement.java | 70 +++++++++++++ .../src/main/java/io/sentry/util/Objects.java | 9 ++ settings.gradle.kts | 1 + 29 files changed, 769 insertions(+), 185 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/ComposeActivity.kt create mode 100644 sentry-compose-helper/README.md create mode 100644 sentry-compose-helper/build.gradle.kts create mode 100644 sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java create mode 100644 sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java create mode 100644 sentry/src/main/java/io/sentry/internal/gestures/UiElement.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 097c379ea0..5e77af3173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add time-to-initial-display span to Activity transactions ([#2369](https://github.com/getsentry/sentry-java/pull/2369)) - Start a session after init if AutoSessionTracking is enabled ([#2356](https://github.com/getsentry/sentry-java/pull/2356)) +- Provide automatic breadcrumbs and transactions for click/scroll events for Compose ([#2390](https://github.com/getsentry/sentry-java/pull/2390)) ### Dependencies diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index fff1af2668..05316fa0d4 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -160,8 +160,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableFramesTracking ()Z public fun isEnableSystemEventBreadcrumbs ()Z - public fun isEnableUserInteractionBreadcrumbs ()Z - public fun isEnableUserInteractionTracing ()Z public fun setAnrEnabled (Z)V public fun setAnrReportInDebug (Z)V public fun setAnrTimeoutIntervalMillis (J)V @@ -175,8 +173,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V - public fun setEnableUserInteractionBreadcrumbs (Z)V - public fun setEnableUserInteractionTracing (Z)V public fun setProfilingTracesHz (I)V public fun setProfilingTracesIntervalMillis (I)V } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index b60f1f8274..9b8a2623ac 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { api(projects.sentry) compileOnly(projects.sentryAndroidFragment) compileOnly(projects.sentryAndroidTimber) + compileOnly(projects.sentryCompose) // lifecycle processor, session tracking implementation(Config.Libs.lifecycleProcess) 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 821f147119..82b746eb62 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 @@ -12,10 +12,13 @@ import io.sentry.SendFireAndForgetOutboxSender; import io.sentry.SentryLevel; import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; +import io.sentry.compose.gestures.ComposeGestureTargetLocator; +import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.Objects; import java.io.BufferedInputStream; @@ -23,6 +26,8 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -130,6 +135,25 @@ static void initializeIntegrationsAndProcessors( options.setTransactionProfiler( new AndroidTransactionProfiler(context, options, buildInfoProvider, frameMetricsCollector)); options.setModulesLoader(new AssetsModulesLoader(context, options.getLogger())); + + final boolean isAndroidXScrollViewAvailable = + loadClass.isClassAvailable("androidx.core.view.ScrollingView", options); + + if (options.getGestureTargetLocators().isEmpty()) { + final List gestureTargetLocators = new ArrayList<>(2); + gestureTargetLocators.add(new AndroidViewGestureTargetLocator(isAndroidXScrollViewAvailable)); + try { + gestureTargetLocators.add(new ComposeGestureTargetLocator()); + } catch (NoClassDefFoundError error) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "ComposeGestureTargetLocator not available, consider adding the `sentry-compose` library.", + error); + } + options.setGestureTargetLocators(gestureTargetLocators); + } } private static void installDefaultIntegrations( 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 d58ea76314..ca1f9a03c1 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 @@ -39,9 +39,6 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable automatic breadcrumbs for App Components Using ComponentCallbacks */ private boolean enableAppComponentBreadcrumbs = true; - /** Enable or disable automatic breadcrumbs for User interactions Using Window.Callback */ - private boolean enableUserInteractionBreadcrumbs = true; - /** * Enables the Auto instrumentation for Activity lifecycle tracing. * @@ -93,9 +90,6 @@ public final class SentryAndroidOptions extends SentryOptions { */ private int profilingTracesHz = 101; - /** Enables the Auto instrumentation for user interaction tracing. */ - private boolean enableUserInteractionTracing = false; - /** Interface that loads the debug images list */ private @NotNull IDebugImagesLoader debugImagesLoader = NoOpDebugImagesLoader.getInstance(); @@ -241,14 +235,6 @@ public void setEnableAppComponentBreadcrumbs(boolean enableAppComponentBreadcrum this.enableAppComponentBreadcrumbs = enableAppComponentBreadcrumbs; } - public boolean isEnableUserInteractionBreadcrumbs() { - return enableUserInteractionBreadcrumbs; - } - - public void setEnableUserInteractionBreadcrumbs(boolean enableUserInteractionBreadcrumbs) { - this.enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs; - } - /** * Enable or disable all the automatic breadcrumbs * @@ -259,7 +245,7 @@ public void enableAllAutoBreadcrumbs(boolean enable) { enableAppComponentBreadcrumbs = enable; enableSystemEventBreadcrumbs = enable; enableAppLifecycleBreadcrumbs = enable; - enableUserInteractionBreadcrumbs = enable; + setEnableUserInteractionBreadcrumbs(enable); } /** @@ -343,14 +329,6 @@ public void setAttachScreenshot(boolean attachScreenshot) { this.attachScreenshot = attachScreenshot; } - public boolean isEnableUserInteractionTracing() { - return enableUserInteractionTracing; - } - - public void setEnableUserInteractionTracing(boolean enableUserInteractionTracing) { - this.enableUserInteractionTracing = enableUserInteractionTracing; - } - public boolean isCollectAdditionalContext() { return collectAdditionalContext; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index 5c6a736e53..b23d6fcbb2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -25,16 +25,12 @@ public final class UserInteractionIntegration private @Nullable SentryAndroidOptions options; private final boolean isAndroidXAvailable; - private final boolean isAndroidXScrollViewAvailable; public UserInteractionIntegration( final @NotNull Application application, final @NotNull LoadClass classLoader) { this.application = Objects.requireNonNull(application, "Application is required"); - isAndroidXAvailable = classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options); - isAndroidXScrollViewAvailable = - classLoader.isClassAvailable("androidx.core.view.ScrollingView", options); } private void startTracking(final @NotNull Activity activity) { @@ -53,7 +49,7 @@ private void startTracking(final @NotNull Activity activity) { } final SentryGestureListener gestureListener = - new SentryGestureListener(activity, hub, options, isAndroidXScrollViewAvailable); + new SentryGestureListener(activity, hub, options); window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); } } @@ -112,14 +108,14 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { this.hub = Objects.requireNonNull(hub, "Hub is required"); + final boolean integrationEnabled = + this.options.isEnableUserInteractionBreadcrumbs() + || this.options.isEnableUserInteractionTracing(); this.options .getLogger() - .log( - SentryLevel.DEBUG, - "UserInteractionIntegration enabled: %s", - this.options.isEnableUserInteractionBreadcrumbs()); + .log(SentryLevel.DEBUG, "UserInteractionIntegration enabled: %s", integrationEnabled); - if (this.options.isEnableUserInteractionBreadcrumbs()) { + if (integrationEnabled) { if (isAndroidXAvailable) { application.registerActivityLifecycleCallbacks(this); this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java new file mode 100644 index 0000000000..224d60491c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -0,0 +1,85 @@ +package io.sentry.android.core.internal.gestures; + +import android.content.res.Resources; +import android.view.View; +import android.widget.AbsListView; +import android.widget.ScrollView; +import androidx.core.view.ScrollingView; +import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.gestures.UiElement; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class AndroidViewGestureTargetLocator implements GestureTargetLocator { + + private final boolean isAndroidXAvailable; + private final int[] coordinates = new int[2]; + + public AndroidViewGestureTargetLocator(final boolean isAndroidXAvailable) { + this.isAndroidXAvailable = isAndroidXAvailable; + } + + @Override + public @Nullable UiElement locate( + @NotNull Object root, float x, float y, UiElement.Type targetType) { + if (!(root instanceof View)) { + return null; + } + final View view = (View) root; + if (touchWithinBounds(view, x, y)) { + if (targetType == UiElement.Type.CLICKABLE && isViewTappable(view)) { + return createUiElement(view); + } else if (targetType == UiElement.Type.SCROLLABLE + && isViewScrollable(view, isAndroidXAvailable)) { + return createUiElement(view); + } + } + return null; + } + + private UiElement createUiElement(final @NotNull View targetView) { + try { + final String resourceName = ViewUtils.getResourceId(targetView); + @Nullable String className = targetView.getClass().getCanonicalName(); + if (className == null) { + className = targetView.getClass().getSimpleName(); + } + return new UiElement(targetView, className, resourceName, null); + } catch (Resources.NotFoundException ignored) { + return null; + } + } + + private boolean touchWithinBounds(final @NotNull View view, final float x, final float y) { + view.getLocationOnScreen(coordinates); + int vx = coordinates[0]; + int vy = coordinates[1]; + + int w = view.getWidth(); + int h = view.getHeight(); + + return !(x < vx || x > vx + w || y < vy || y > vy + h); + } + + private static boolean isViewTappable(final @NotNull View view) { + return view.isClickable() && view.getVisibility() == View.VISIBLE; + } + + private static boolean isViewScrollable( + final @NotNull View view, final boolean isAndroidXAvailable) { + return (isJetpackScrollingView(view, isAndroidXAvailable) + || AbsListView.class.isAssignableFrom(view.getClass()) + || ScrollView.class.isAssignableFrom(view.getClass())) + && view.getVisibility() == View.VISIBLE; + } + + private static boolean isJetpackScrollingView( + final @NotNull View view, final boolean isAndroidXAvailable) { + if (!isAndroidXAvailable) { + return false; + } + return ScrollingView.class.isAssignableFrom(view.getClass()); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 72a2d3983c..2bc8a52694 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -4,7 +4,6 @@ import static io.sentry.TypeCheckHint.ANDROID_VIEW; import android.app.Activity; -import android.content.res.Resources; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; @@ -19,6 +18,7 @@ import io.sentry.TransactionContext; import io.sentry.TransactionOptions; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.internal.gestures.UiElement; import io.sentry.protocol.TransactionNameSource; import java.lang.ref.WeakReference; import java.util.Collections; @@ -36,9 +36,8 @@ public final class SentryGestureListener implements GestureDetector.OnGestureLis private final @NotNull WeakReference activityRef; private final @NotNull IHub hub; private final @NotNull SentryAndroidOptions options; - private final boolean isAndroidXAvailable; - private @Nullable WeakReference activeView = null; + private @Nullable UiElement activeUiElement = null; private @Nullable ITransaction activeTransaction = null; private @Nullable String activeEventType = null; @@ -47,17 +46,15 @@ public final class SentryGestureListener implements GestureDetector.OnGestureLis public SentryGestureListener( final @NotNull Activity currentActivity, final @NotNull IHub hub, - final @NotNull SentryAndroidOptions options, - final boolean isAndroidXAvailable) { + final @NotNull SentryAndroidOptions options) { this.activityRef = new WeakReference<>(currentActivity); this.hub = hub; this.options = options; - this.isAndroidXAvailable = isAndroidXAvailable; } public void onUp(final @NotNull MotionEvent motionEvent) { final View decorView = ensureWindowDecorView("onUp"); - final View scrollTarget = scrollState.targetRef.get(); + final UiElement scrollTarget = scrollState.target; if (decorView == null || scrollTarget == null) { return; } @@ -97,13 +94,9 @@ public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) { return false; } - @SuppressWarnings("Convert2MethodRef") - final @Nullable View target = + final @Nullable UiElement target = ViewUtils.findTarget( - decorView, - motionEvent.getX(), - motionEvent.getY(), - view -> ViewUtils.isViewTappable(view)); + options, decorView, motionEvent.getX(), motionEvent.getY(), UiElement.Type.CLICKABLE); if (target == null) { options @@ -129,28 +122,19 @@ public boolean onScroll( } if (scrollState.type == null) { - final @Nullable View target = + final @Nullable UiElement target = ViewUtils.findTarget( - decorView, - firstEvent.getX(), - firstEvent.getY(), - new ViewTargetSelector() { - @Override - public boolean select(@NotNull View view) { - return ViewUtils.isViewScrollable(view, isAndroidXAvailable); - } - - @Override - public boolean skipChildren() { - return true; - } - }); + options, decorView, firstEvent.getX(), firstEvent.getY(), UiElement.Type.SCROLLABLE); if (target == null) { options .getLogger() .log(SentryLevel.DEBUG, "Unable to find scroll target. No breadcrumb captured."); return false; + } else { + options + .getLogger() + .log(SentryLevel.DEBUG, "Scroll target found: " + target.getIdentifier()); } scrollState.setTarget(target); @@ -177,34 +161,30 @@ public void onLongPress(MotionEvent motionEvent) {} // region utils private void addBreadcrumb( - final @NotNull View target, + final @NotNull UiElement target, final @NotNull String eventType, final @NotNull Map additionalData, final @NotNull MotionEvent motionEvent) { - if ((!options.isEnableUserInteractionBreadcrumbs())) { + if (!options.isEnableUserInteractionBreadcrumbs()) { return; } - @NotNull String className; - @Nullable String canonicalName = target.getClass().getCanonicalName(); - if (canonicalName != null) { - className = canonicalName; - } else { - className = target.getClass().getSimpleName(); - } - final Hint hint = new Hint(); hint.set(ANDROID_MOTION_EVENT, motionEvent); - hint.set(ANDROID_VIEW, target); + hint.set(ANDROID_VIEW, target.getView()); hub.addBreadcrumb( Breadcrumb.userInteraction( - eventType, ViewUtils.getResourceIdWithFallback(target), className, additionalData), + eventType, + target.getResourceName(), + target.getClassName(), + target.getTag(), + additionalData), hint); } - private void startTracing(final @NotNull View target, final @NotNull String eventType) { + private void startTracing(final @NotNull UiElement target, final @NotNull String eventType) { if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { return; } @@ -215,21 +195,11 @@ private void startTracing(final @NotNull View target, final @NotNull String even return; } - final String viewId; - try { - viewId = ViewUtils.getResourceId(target); - } catch (Resources.NotFoundException e) { - options - .getLogger() - .log( - SentryLevel.DEBUG, - "View id cannot be retrieved from Resources, no transaction captured."); - return; - } + final @Nullable String viewIdentifier = target.getIdentifier(); + final UiElement uiElement = activeUiElement; - final View view = (activeView != null) ? activeView.get() : null; if (activeTransaction != null) { - if (target.equals(view) + if (target.equals(uiElement) && eventType.equals(activeEventType) && !activeTransaction.isFinished()) { options @@ -237,7 +207,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even .log( SentryLevel.DEBUG, "The view with id: " - + viewId + + viewIdentifier + " already has an ongoing transaction assigned. Rescheduling finish"); final Long idleTimeout = options.getIdleTimeout(); @@ -255,7 +225,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even } // we can only bind to the scope if there's no running transaction - final String name = getActivityName(activity) + "." + viewId; + final String name = getActivityName(activity) + "." + viewIdentifier; final String op = UI_ACTION + "." + eventType; final TransactionOptions transactionOptions = new TransactionOptions(); @@ -273,7 +243,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even }); activeTransaction = transaction; - activeView = new WeakReference<>(target); + activeUiElement = target; activeEventType = eventType; } @@ -286,8 +256,8 @@ void stopTracing(final @NotNull SpanStatus status) { clearScope(scope); }); activeTransaction = null; - if (activeView != null) { - activeView.clear(); + if (activeUiElement != null) { + activeUiElement = null; } activeEventType = null; } @@ -355,12 +325,12 @@ void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transact // region scroll logic private static final class ScrollState { private @Nullable String type = null; - private WeakReference targetRef = new WeakReference<>(null); + private @Nullable UiElement target; private float startX = 0f; private float startY = 0f; - private void setTarget(final @NotNull View target) { - targetRef = new WeakReference<>(target); + private void setTarget(final @NotNull UiElement target) { + this.target = target; } /** @@ -390,7 +360,7 @@ private void setTarget(final @NotNull View target) { } private void reset() { - targetRef.clear(); + target = null; type = null; startX = 0f; startY = 0f; 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 a8e84f1be7..68360451c9 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 @@ -4,16 +4,17 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.widget.AbsListView; -import android.widget.ScrollView; -import androidx.core.view.ScrollingView; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.gestures.UiElement; import io.sentry.util.Objects; -import java.util.ArrayDeque; +import java.util.LinkedList; import java.util.Queue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; final class ViewUtils { + /** * Finds a target view, that has been selected/clicked by the given coordinates x and y and the * given {@code viewTargetSelector}. @@ -21,79 +22,45 @@ final class ViewUtils { * @param decorView - the root view of this window * @param x - the x coordinate of a {@link MotionEvent} * @param y - the y coordinate of {@link MotionEvent} - * @param viewTargetSelector - the selector, which defines whether the given view is suitable as a - * target or not. + * @param targetType - the type of target to find * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} */ - static @Nullable View findTarget( + static @Nullable UiElement findTarget( + final @NotNull SentryAndroidOptions options, final @NotNull View decorView, final float x, final float y, - final @NotNull ViewTargetSelector viewTargetSelector) { - Queue queue = new ArrayDeque<>(); - queue.add(decorView); + final UiElement.Type targetType) { - @Nullable View target = null; - // the coordinates variable can be method-local, but we allocate it here, to avoid allocation - // in the while- and for-loops - int[] coordinates = new int[2]; + final Queue queue = new LinkedList<>(); + queue.add(decorView); + @Nullable UiElement target = null; while (queue.size() > 0) { final View view = Objects.requireNonNull(queue.poll(), "view is required"); - if (viewTargetSelector.select(view)) { - target = view; - if (viewTargetSelector.skipChildren()) { - return target; - } - } - if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; for (int i = 0; i < viewGroup.getChildCount(); i++) { - final View child = viewGroup.getChildAt(i); - if (touchWithinBounds(child, x, y, coordinates)) { - queue.add(child); + queue.add(viewGroup.getChildAt(i)); + } + } + + for (GestureTargetLocator locator : options.getGestureTargetLocators()) { + final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType); + if (newTarget != null) { + if (targetType == UiElement.Type.CLICKABLE) { + target = newTarget; + } else { + return newTarget; } } } } - return target; } - private static boolean touchWithinBounds( - final @NotNull View view, final float x, final float y, final int[] coords) { - view.getLocationOnScreen(coords); - int vx = coords[0]; - int vy = coords[1]; - - int w = view.getWidth(); - int h = view.getHeight(); - - return !(x < vx || x > vx + w || y < vy || y > vy + h); - } - - static boolean isViewTappable(final @NotNull View view) { - return view.isClickable() && view.getVisibility() == View.VISIBLE; - } - - static boolean isViewScrollable(final @NotNull View view, final boolean isAndroidXAvailable) { - return (isJetpackScrollingView(view, isAndroidXAvailable) - || AbsListView.class.isAssignableFrom(view.getClass()) - || ScrollView.class.isAssignableFrom(view.getClass())) - && view.getVisibility() == View.VISIBLE; - } - - private static boolean isJetpackScrollingView( - final @NotNull View view, final boolean isAndroidXAvailable) { - if (!isAndroidXAvailable) { - return false; - } - return ScrollingView.class.isAssignableFrom(view.getClass()); - } - /** * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls * back to a hexadecimal id representation in case the view id is not available in the resources. diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index ed4abc1f12..b61f3805f0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -30,6 +30,9 @@ class SentryGestureListenerClickTest { val context = mock() val resources = mock() val options = SentryAndroidOptions().apply { + isEnableUserInteractionBreadcrumbs = true + isEnableUserInteractionTracing = true + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) dsn = "https://key@sentry.io/proj" } val hub = mock() @@ -47,13 +50,15 @@ class SentryGestureListenerClickTest { invalidTarget = mockView( event = event, visible = isInvalidTargetVisible, - clickable = isInvalidTargetClickable + clickable = isInvalidTargetClickable, + context = context ) if (targetOverride == null) { this.target = mockView( event = event, - clickable = true + clickable = true, + context = context ) } else { this.target = targetOverride @@ -61,7 +66,8 @@ class SentryGestureListenerClickTest { if (attachViewsToRoot) { window.mockDecorView( - event = event + event = event, + context = context ) { whenever(it.childCount).thenReturn(2) whenever(it.getChildAt(0)).thenReturn(invalidTarget) @@ -76,8 +82,7 @@ class SentryGestureListenerClickTest { return SentryGestureListener( activity, hub, - options, - true + options ) } } @@ -93,15 +98,15 @@ class SentryGestureListenerClickTest { attachViewsToRoot = false ) - val container1 = mockView(event = event, touchWithinBounds = false) + val container1 = mockView(event = event, touchWithinBounds = false, context = fixture.context) val notClickableInvalidTarget = mockView(event = event) - val container2 = mockView(event = event, clickable = true) { + val container2 = mockView(event = event, clickable = true, context = fixture.context) { whenever(it.childCount).thenReturn(3) whenever(it.getChildAt(0)).thenReturn(notClickableInvalidTarget) whenever(it.getChildAt(1)).thenReturn(fixture.invalidTarget) whenever(it.getChildAt(2)).thenReturn(fixture.target) } - fixture.window.mockDecorView(event = event) { + fixture.window.mockDecorView(event = event, context = fixture.context) { whenever(it.childCount).thenReturn(2) whenever(it.getChildAt(0)).thenReturn(container1) whenever(it.getChildAt(1)).thenReturn(container2) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt index 414538c657..e00d22c73e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -35,6 +35,8 @@ class SentryGestureListenerScrollTest { val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" isEnableUserInteractionBreadcrumbs = true + isEnableUserInteractionTracing = true + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) } val hub = mock() @@ -47,12 +49,12 @@ class SentryGestureListenerScrollTest { internal inline fun getSut( resourceName: String = "test_scroll_view", touchWithinBounds: Boolean = true, - direction: String = "", - isAndroidXAvailable: Boolean = true + direction: String = "" ): SentryGestureListener { target = mockView( event = firstEvent, - touchWithinBounds = touchWithinBounds + touchWithinBounds = touchWithinBounds, + context = context ) window.mockDecorView(event = firstEvent) { whenever(it.childCount).thenReturn(1) @@ -70,8 +72,7 @@ class SentryGestureListenerScrollTest { return SentryGestureListener( activity, hub, - options, - isAndroidXAvailable + options ) } } @@ -170,7 +171,7 @@ class SentryGestureListenerScrollTest { @Test fun `if androidX is not available, does not capture a breadcrumb for ScrollingView`() { - val sut = fixture.getSut(isAndroidXAvailable = false) + val sut = fixture.getSut() sut.onDown(fixture.firstEvent) fixture.eventsInBetween.forEach { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index 4ba36a9ae8..bd31d98fda 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -52,12 +52,15 @@ class SentryGestureListenerTracingTest { ): SentryGestureListener { options.tracesSampleRate = tracesSampleRate options.isEnableUserInteractionTracing = isEnableUserInteractionTracing + options.isEnableUserInteractionBreadcrumbs = true + options.gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) + whenever(hub.options).thenReturn(options) this.transaction = transaction ?: SentryTracer(TransactionContext("name", "op"), hub) - target = mockView(event = event, clickable = true) - window.mockDecorView(event = event) { + target = mockView(event = event, clickable = true, context = context) + window.mockDecorView(event = event, context = context) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(target) } @@ -80,8 +83,7 @@ class SentryGestureListenerTracingTest { return SentryGestureListener( activity, hub, - options, - true + options ) } } @@ -228,13 +230,13 @@ class SentryGestureListenerTracingTest { clearInvocations(fixture.hub) // second view interaction with another view - val newTarget = mockView(event = fixture.event, clickable = true) + val newTarget = mockView(event = fixture.event, clickable = true, context = fixture.context) val newContext = mock() val newRes = mock() newRes.mockForTarget(newTarget, "test_checkbox") whenever(newContext.resources).thenReturn(newRes) whenever(newTarget.context).thenReturn(newContext) - fixture.window.mockDecorView(event = fixture.event) { + fixture.window.mockDecorView(event = fixture.event, context = fixture.context) { whenever(it.childCount).thenReturn(1) whenever(it.getChildAt(0)).thenReturn(newTarget) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt index 4664cfeb55..16eeeb676f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -1,5 +1,6 @@ package io.sentry.android.core.internal.gestures +import android.content.Context import android.content.res.Resources import android.view.MotionEvent import android.view.View @@ -17,9 +18,10 @@ internal inline fun Window.mockDecorView( touchWithinBounds: Boolean = true, clickable: Boolean = false, visible: Boolean = true, + context: Context? = null, finalize: (T) -> Unit = {} ): T { - val view = mockView(id, event, touchWithinBounds, clickable, visible, finalize) + val view = mockView(id, event, touchWithinBounds, clickable, visible, context, finalize) whenever(decorView).doReturn(view) return view } @@ -30,6 +32,7 @@ internal inline fun mockView( touchWithinBounds: Boolean = true, clickable: Boolean = false, visible: Boolean = true, + context: Context? = null, finalize: (T) -> Unit = {} ): T { val coordinates = IntArray(2) @@ -42,6 +45,7 @@ internal inline fun mockView( } val mockView: T = mock { whenever(it.id).thenReturn(id) + whenever(it.context).thenReturn(context) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 350cc35a5c..f189234316 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -14,7 +14,7 @@ android { namespace = "io.sentry.uitest.android" defaultConfig { - minSdk = Config.Android.minSdkVersionNdk + minSdk = Config.Android.minSdkVersionCompose targetSdk = Config.Android.targetSdkVersion versionCode = 1 versionName = "1.0.0" @@ -36,6 +36,11 @@ android { // Determines whether to support View Binding. // Note that the viewBinding.enabled property is now deprecated. viewBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.composeVersion } signingConfigs { @@ -87,8 +92,12 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)) implementation(projects.sentryAndroid) + implementation(projects.sentryCompose) implementation(Config.Libs.appCompat) implementation(Config.Libs.androidxCore) + implementation(Config.Libs.composeActivity) + implementation(Config.Libs.composeFoundation) + implementation(Config.Libs.composeMaterial) implementation(Config.Libs.androidxRecylerView) implementation(Config.Libs.constraintLayout) implementation(Config.TestLibs.espressoIdlingResource) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt new file mode 100644 index 0000000000..635eb7a51f --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserInteractionTests.kt @@ -0,0 +1,93 @@ +package io.sentry.uitest.android + +import android.view.InputDevice +import android.view.MotionEvent +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.android.core.SentryAndroidOptions +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class UserInteractionTests : BaseUiTest() { + + @Test + fun composableClickGeneratesMatchingBreadcrumb() { + val breadcrumbs = mutableListOf() + initSentryAndCollectBreadcrumbs(breadcrumbs) + + val activity = launchActivity() + activity.moveToState(Lifecycle.State.RESUMED) + + // some sane defaults + var height = 500 + var width = 500 + activity.onActivity { + height = it.resources.displayMetrics.heightPixels + width = it.resources.displayMetrics.widthPixels + } + + Espresso.onView(ViewMatchers.withId(android.R.id.content)).perform( + GeneralClickAction( + Tap.SINGLE, + { floatArrayOf(width / 2f, height / 2f) }, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY + ) + ) + activity.moveToState(Lifecycle.State.DESTROYED) + assertTrue( + breadcrumbs.filter { + it.category == "ui.click" && it.data["view.tag"] == "button_login" + }.size == 1 + ) + } + + @Test + fun composableSwipeGeneratesMatchingBreadcrumb() { + val breadcrumbs = mutableListOf() + initSentryAndCollectBreadcrumbs(breadcrumbs) + + val activity = launchActivity() + activity.moveToState(Lifecycle.State.RESUMED) + Espresso.onView(ViewMatchers.withId(android.R.id.content)).perform( + ViewActions.swipeUp() + ) + activity.moveToState(Lifecycle.State.DESTROYED) + assertTrue( + breadcrumbs.filter { + it.category == "ui.swipe" && + it.data["view.tag"] == "list" && + it.data["direction"] == "up" + }.size == 1 + ) + } + + private fun initSentryAndCollectBreadcrumbs(breadcrumbs: MutableList) { + initSentry(false) { options: SentryAndroidOptions -> + options.isDebug = true + options.setDiagnosticLevel(SentryLevel.DEBUG) + options.tracesSampleRate = 1.0 + options.profilesSampleRate = 1.0 + options.isEnableUserInteractionTracing = true + options.isEnableUserInteractionBreadcrumbs = true + options.beforeBreadcrumb = + SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + breadcrumbs.add(breadcrumb) + breadcrumb + } + } + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml index 6a02e5c7c5..dea9d863ff 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ + { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +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")) +} diff --git a/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java new file mode 100644 index 0000000000..d6a6e79ed6 --- /dev/null +++ b/sentry-compose-helper/src/main/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -0,0 +1,99 @@ +package io.sentry.compose.gestures; + +import androidx.compose.ui.layout.LayoutCoordinatesKt; +import androidx.compose.ui.layout.ModifierInfo; +import androidx.compose.ui.node.LayoutNode; +import androidx.compose.ui.node.Owner; +import androidx.compose.ui.semantics.SemanticsConfiguration; +import androidx.compose.ui.semantics.SemanticsModifier; +import androidx.compose.ui.semantics.SemanticsPropertyKey; +import io.sentry.internal.gestures.GestureTargetLocator; +import io.sentry.internal.gestures.UiElement; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("KotlinInternalInJava") +public final class ComposeGestureTargetLocator implements GestureTargetLocator { + + @Override + public @Nullable UiElement locate( + @NotNull Object root, float x, float y, UiElement.Type targetType) { + @Nullable String targetTag = null; + + if (!(root instanceof Owner)) { + return null; + } + + final @NotNull Queue queue = new LinkedList<>(); + queue.add(((Owner) root).getRoot()); + + while (!queue.isEmpty()) { + final @Nullable LayoutNode node = queue.poll(); + if (node == null) { + continue; + } + + if (node.isPlaced() && layoutNodeBoundsContain(node, x, y)) { + boolean isClickable = false; + boolean isScrollable = false; + @Nullable String testTag = null; + + final List modifiers = node.getModifierInfo(); + for (ModifierInfo modifierInfo : modifiers) { + if (modifierInfo.getModifier() instanceof SemanticsModifier) { + final SemanticsModifier semanticsModifierCore = + (SemanticsModifier) modifierInfo.getModifier(); + final SemanticsConfiguration semanticsConfiguration = + semanticsModifierCore.getSemanticsConfiguration(); + for (Map.Entry, ?> entry : semanticsConfiguration) { + final @Nullable String key = entry.getKey().getName(); + if ("ScrollBy".equals(key)) { + isScrollable = true; + } else if ("OnClick".equals(key)) { + isClickable = true; + } else if ("TestTag".equals(key)) { + if (entry.getValue() instanceof String) { + testTag = (String) entry.getValue(); + } + } + } + } + } + + if (isClickable && targetType == UiElement.Type.CLICKABLE) { + targetTag = testTag; + } + if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { + targetTag = testTag; + // skip any children for scrollable targets + break; + } + } + queue.addAll(node.getZSortedChildren().asMutableList()); + } + + if (targetTag == null) { + return null; + } else { + return new UiElement(null, null, null, targetTag); + } + } + + private static boolean layoutNodeBoundsContain( + @NotNull LayoutNode node, final float x, final float y) { + final int nodeHeight = node.getHeight(); + final int nodeWidth = node.getWidth(); + + // Offset is a Kotlin value class, packing x/y into a long + // TODO find a way to use the existing APIs + final long nodePosition = LayoutCoordinatesKt.positionInWindow(node.getCoordinates()); + final int nodeX = (int) Float.intBitsToFloat((int) (nodePosition >> 32)); + final int nodeY = (int) Float.intBitsToFloat((int) (nodePosition)); + + return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight); + } +} diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index e8839e6701..8b9abab586 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.gradle.internal.tasks.LibraryAarJarsTask import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.dokka.gradle.DokkaTask @@ -18,7 +19,7 @@ kotlin { android { publishLibraryVariants("release") } - jvm("desktop") { + jvm() { compilations.all { kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() } @@ -41,9 +42,19 @@ kotlin { implementation(Config.Libs.kotlinStdLib) } } - val androidMain by getting { + + val jvmMain by getting { dependencies { api(projects.sentry) + implementation(Config.Libs.kotlinStdLib) + api(projects.sentryComposeHelper) + } + } + + val androidMain by getting { + dependsOn(jvmMain) + + dependencies { api(projects.sentryAndroidNavigation) api(Config.Libs.composeNavigation) @@ -128,3 +139,23 @@ tasks.withType().configureEach { } } } + +/** + * Due to https://youtrack.jetbrains.com/issue/KT-30878 + * you can not have java sources in a KMP-enabled project which has the android-lib plugin applied. + * Thus we compile relevant java code in sentry-compose-helper first and embed it in here. + */ +val embedComposeHelperConfig by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + embedComposeHelperConfig( + project(":" + projects.sentryComposeHelper.name, "embeddedJar") + ) +} + +tasks.withType { + mainScopeClassFiles.setFrom(embedComposeHelperConfig) +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 74f7cdccfc..259947399a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -60,20 +61,28 @@ fun Landing( modifier = Modifier.fillMaxSize() ) { Button( - onClick = { navigateGithub() }, - modifier = Modifier.padding(top = 32.dp) + onClick = { + navigateGithub() + }, + modifier = Modifier + .testTag("button_nav_github") + .padding(top = 32.dp) ) { Text("Navigate to Github Page") } Button( onClick = { navigateGithubWithArgs() }, - modifier = Modifier.padding(top = 32.dp) + modifier = Modifier + .testTag("button_nav_github_args") + .padding(top = 32.dp) ) { Text("Navigate to Github Page With Args") } Button( onClick = { throw RuntimeException("Crash from Compose") }, - modifier = Modifier.padding(top = 32.dp) + modifier = Modifier + .testTag("button_crash") + .padding(top = 32.dp) ) { Text("Crash from Compose") } @@ -111,7 +120,9 @@ fun Github( result = GithubAPI.service.listReposAsync(user.text, perPage).random().full_name } }, - modifier = Modifier.padding(top = 32.dp) + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) ) { Text("Make Request") } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index be126c57cb..fbfa9c1bc7 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -107,6 +107,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public static fun ui (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun user (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun userInteraction (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; + public static fun userInteraction (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/sentry/Breadcrumb; public static fun userInteraction (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/sentry/Breadcrumb; } @@ -1392,6 +1393,7 @@ public class io/sentry/SentryOptions { public fun getEventProcessors ()Ljava/util/List; public fun getExecutorService ()Lio/sentry/ISentryExecutorService; public fun getFlushTimeoutMillis ()J + public fun getGestureTargetLocators ()Ljava/util/List; public fun getHostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public fun getIdleTimeout ()Ljava/lang/Long; public fun getIgnoredExceptionsForType ()Ljava/util/Set; @@ -1445,6 +1447,8 @@ public class io/sentry/SentryOptions { public fun isEnableScopeSync ()Z public fun isEnableShutdownHook ()Z public fun isEnableUncaughtExceptionHandler ()Z + public fun isEnableUserInteractionBreadcrumbs ()Z + public fun isEnableUserInteractionTracing ()Z public fun isPrintUncaughtStackTrace ()Z public fun isProfilingEnabled ()Z public fun isSendClientReports ()Z @@ -1472,11 +1476,14 @@ public class io/sentry/SentryOptions { public fun setEnableScopeSync (Z)V public fun setEnableShutdownHook (Z)V public fun setEnableUncaughtExceptionHandler (Z)V + public fun setEnableUserInteractionBreadcrumbs (Z)V + public fun setEnableUserInteractionTracing (Z)V public fun setEnvelopeDiskCache (Lio/sentry/cache/IEnvelopeCache;)V public fun setEnvelopeReader (Lio/sentry/IEnvelopeReader;)V public fun setEnvironment (Ljava/lang/String;)V public fun setExecutorService (Lio/sentry/ISentryExecutorService;)V public fun setFlushTimeoutMillis (J)V + public fun setGestureTargetLocators (Ljava/util/List;)V public fun setHostnameVerifier (Ljavax/net/ssl/HostnameVerifier;)V public fun setIdleTimeout (Ljava/lang/Long;)V public fun setInstrumenter (Lio/sentry/Instrumenter;)V @@ -2247,6 +2254,28 @@ public final class io/sentry/instrumentation/file/SentryFileWriter : java/io/Out public fun (Ljava/lang/String;Z)V } +public abstract interface class io/sentry/internal/gestures/GestureTargetLocator { + public abstract fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; +} + +public final class io/sentry/internal/gestures/UiElement { + public fun (Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z + public fun getClassName ()Ljava/lang/String; + public fun getIdentifier ()Ljava/lang/String; + public fun getResourceName ()Ljava/lang/String; + public fun getTag ()Ljava/lang/String; + public fun getView ()Ljava/lang/Object; + public fun hashCode ()I +} + +public final class io/sentry/internal/gestures/UiElement$Type : java/lang/Enum { + public static final field CLICKABLE Lio/sentry/internal/gestures/UiElement$Type; + public static final field SCROLLABLE Lio/sentry/internal/gestures/UiElement$Type; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/internal/gestures/UiElement$Type; + public static fun values ()[Lio/sentry/internal/gestures/UiElement$Type; +} + public abstract interface class io/sentry/internal/modules/IModulesLoader { public abstract fun getOrLoadModules ()Ljava/util/Map; } @@ -3452,6 +3481,8 @@ public final class io/sentry/util/LogUtils { } public final class io/sentry/util/Objects { + public static fun equals (Ljava/lang/Object;Ljava/lang/Object;)Z + public static fun hash ([Ljava/lang/Object;)I public static fun requireNonNull (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 9d2231eeea..d3924cb6e1 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -237,6 +237,7 @@ public Breadcrumb(final @NotNull Date timestamp) { * @param subCategory - the category, for example "click" * @param viewId - the human-readable view id, for example "button_load" * @param viewClass - the fully qualified class name, for example "android.widget.Button" + * @param viewTag - the custom tag of the view, for example "button_launch_rocket" * @param additionalData - additional properties to be put into the data bag * @return the breadcrumb */ @@ -244,6 +245,7 @@ public Breadcrumb(final @NotNull Date timestamp) { final @NotNull String subCategory, final @Nullable String viewId, final @Nullable String viewClass, + final @Nullable String viewTag, final @NotNull Map additionalData) { final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("user"); @@ -254,6 +256,9 @@ public Breadcrumb(final @NotNull Date timestamp) { if (viewClass != null) { breadcrumb.setData("view.class", viewClass); } + if (viewTag != null) { + breadcrumb.setData("view.tag", viewTag); + } for (final Map.Entry entry : additionalData.entrySet()) { breadcrumb.getData().put(entry.getKey(), entry.getValue()); } @@ -261,6 +266,26 @@ public Breadcrumb(final @NotNull Date timestamp) { return breadcrumb; } + /** + * Creates user breadcrumb - a user interaction with your app's UI. The breadcrumb can contain + * additional data like {@code viewId} or {@code viewClass}. By default, the breadcrumb is + * captured with {@link SentryLevel} INFO level. + * + * @param subCategory - the category, for example "click" + * @param viewId - the human-readable view id, for example "button_load" + * @param viewClass - the fully qualified class name, for example "android.widget.Button" + * @param additionalData - additional properties to be put into the data bag + * @return the breadcrumb + */ + public static @NotNull Breadcrumb userInteraction( + final @NotNull String subCategory, + final @Nullable String viewId, + final @Nullable String viewClass, + final @NotNull Map additionalData) { + + return userInteraction(subCategory, viewId, viewClass, null, additionalData); + } + /** Breadcrumb ctor */ public Breadcrumb() { this(DateUtils.getCurrentDateTime()); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 4b5543d601..f0d200113d 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -5,6 +5,7 @@ import io.sentry.clientreport.ClientReportRecorder; import io.sentry.clientreport.IClientReportRecorder; import io.sentry.clientreport.NoOpClientReportRecorder; +import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.modules.IModulesLoader; import io.sentry.internal.modules.NoOpModulesLoader; import io.sentry.protocol.SdkVersion; @@ -368,9 +369,18 @@ public class SentryOptions { /** Modules (dependencies, packages) that will be send along with each event. */ private @NotNull IModulesLoader modulesLoader = NoOpModulesLoader.getInstance(); + /** Enables the Auto instrumentation for user interaction tracing. */ + private boolean enableUserInteractionTracing = false; + + /** Enable or disable automatic breadcrumbs for User interactions */ + private boolean enableUserInteractionBreadcrumbs = true; + /** Which framework is responsible for instrumenting. */ private @NotNull Instrumenter instrumenter = Instrumenter.SENTRY; + /** Contains a list of GestureTargetLocator instances used for user interaction tracking * */ + private final @NotNull List gestureTargetLocators = new ArrayList<>(); + /** * Adds an event processor * @@ -1770,6 +1780,22 @@ public void setSendClientReports(boolean sendClientReports) { } } + public boolean isEnableUserInteractionTracing() { + return enableUserInteractionTracing; + } + + public void setEnableUserInteractionTracing(boolean enableUserInteractionTracing) { + this.enableUserInteractionTracing = enableUserInteractionTracing; + } + + public boolean isEnableUserInteractionBreadcrumbs() { + return enableUserInteractionBreadcrumbs; + } + + public void setEnableUserInteractionBreadcrumbs(boolean enableUserInteractionBreadcrumbs) { + this.enableUserInteractionBreadcrumbs = enableUserInteractionBreadcrumbs; + } + /** * Sets the instrumenter used for performance instrumentation. * @@ -1817,6 +1843,27 @@ public void setModulesLoader(final @Nullable IModulesLoader modulesLoader) { this.modulesLoader = modulesLoader != null ? modulesLoader : NoOpModulesLoader.getInstance(); } + /** + * Returns a list of all {@link GestureTargetLocator} instances used to determine which {@link + * io.sentry.internal.gestures.UiElement} was part of an user interaction. + * + * @return a list of {@link GestureTargetLocator} + */ + public List getGestureTargetLocators() { + return gestureTargetLocators; + } + + /** + * Sets the list of {@link GestureTargetLocator} being used to determine relevant {@link + * io.sentry.internal.gestures.UiElement} for user interactions. + * + * @param locators a list of {@link GestureTargetLocator} + */ + public void setGestureTargetLocators(@NotNull final List locators) { + gestureTargetLocators.clear(); + gestureTargetLocators.addAll(locators); + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java new file mode 100644 index 0000000000..79109f70ff --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/gestures/GestureTargetLocator.java @@ -0,0 +1,11 @@ +package io.sentry.internal.gestures; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface GestureTargetLocator { + + @Nullable + UiElement locate( + final @NotNull Object root, final float x, final float y, final UiElement.Type targetType); +} diff --git a/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java new file mode 100644 index 0000000000..36f6126675 --- /dev/null +++ b/sentry/src/main/java/io/sentry/internal/gestures/UiElement.java @@ -0,0 +1,70 @@ +package io.sentry.internal.gestures; + +import io.sentry.util.Objects; +import java.lang.ref.WeakReference; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class UiElement { + final @NotNull WeakReference viewRef; + final @Nullable String className; + final @Nullable String resourceName; + final @Nullable String tag; + + public UiElement( + @Nullable Object view, + @Nullable String className, + @Nullable String resourceName, + @Nullable String tag) { + this.viewRef = new WeakReference<>(view); + this.className = className; + this.resourceName = resourceName; + this.tag = tag; + } + + public @Nullable String getClassName() { + return className; + } + + public @Nullable String getResourceName() { + return resourceName; + } + + public @Nullable String getTag() { + return tag; + } + + public @NotNull String getIdentifier() { + // either resourcename or tag is not null + if (resourceName != null) { + return resourceName; + } else { + return Objects.requireNonNull(tag, "UiElement.tag can't be null"); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UiElement uiElement = (UiElement) o; + + return Objects.equals(className, uiElement.className) + && Objects.equals(resourceName, uiElement.resourceName) + && Objects.equals(tag, uiElement.tag); + } + + public @Nullable Object getView() { + return viewRef.get(); + } + + @Override + public int hashCode() { + return Objects.hash(viewRef, resourceName, tag); + } + + public enum Type { + CLICKABLE, + SCROLLABLE + } +} diff --git a/sentry/src/main/java/io/sentry/util/Objects.java b/sentry/src/main/java/io/sentry/util/Objects.java index 1ab070157b..df7aeab38b 100644 --- a/sentry/src/main/java/io/sentry/util/Objects.java +++ b/sentry/src/main/java/io/sentry/util/Objects.java @@ -1,5 +1,6 @@ package io.sentry.util; +import java.util.Arrays; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,4 +13,12 @@ public static T requireNonNull(final @Nullable T obj, final @NotNull String if (obj == null) throw new IllegalArgumentException(message); return obj; } + + public static boolean equals(@Nullable Object a, @Nullable Object b) { + return (a == b) || (a != null && a.equals(b)); + } + + public static int hash(@Nullable Object... values) { + return Arrays.hashCode(values); + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index eb534489d4..3d8af33808 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include( "sentry-android-fragment", "sentry-android-navigation", "sentry-compose", + "sentry-compose-helper", "sentry-apollo", "sentry-apollo-3", "sentry-test-support", From 81a3c3273f211de79706ad83e24929ef1ec58f0a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 15 Dec 2022 17:29:57 +0100 Subject: [PATCH 04/11] File I/O on main thread (#2382) --- CHANGELOG.md | 1 + .../android/core/ActivityFramesTracker.java | 4 +- .../core/AndroidOptionsInitializer.java | 2 + .../android/core/AppLifecycleIntegration.java | 6 +- .../core/DefaultAndroidEventProcessor.java | 4 +- .../util/AndroidMainThreadChecker.java | 23 +++++++ .../core/internal/util/MainThreadChecker.java | 47 ------------- .../core/AndroidOptionsInitializerTest.kt | 8 +++ ...est.kt => AndroidMainThreadCheckerTest.kt} | 12 ++-- .../sentry-samples-android/proguard-rules.pro | 1 - .../sentry/samples/android/MainActivity.java | 3 +- .../sentry/samples/android/ThirdFragment.kt | 3 + .../src/main/res/layout/third_fragment.xml | 1 + sentry/api/sentry.api | 33 +++++++++- .../java/io/sentry/MainEventProcessor.java | 3 +- sentry/src/main/java/io/sentry/Sentry.java | 9 +++ .../main/java/io/sentry/SentryBaseEvent.java | 21 ++++++ .../src/main/java/io/sentry/SentryEvent.java | 17 ----- .../main/java/io/sentry/SentryOptions.java | 12 ++++ .../io/sentry/SentryStackTraceFactory.java | 54 ++++++++++++++- .../file/FileIOSpanManager.java | 19 ++++-- .../file/FileInputStreamInitData.java | 7 +- .../file/FileOutputStreamInitData.java | 7 +- .../file/SentryFileInputStream.java | 8 +-- .../file/SentryFileOutputStream.java | 10 ++- .../java/io/sentry/util/CollectionUtils.java | 19 +++++- .../util/thread/IMainThreadChecker.java | 41 ++++++++++++ .../sentry/util/thread/MainThreadChecker.java | 28 ++++++++ .../util/thread/NoOpMainThreadChecker.java | 18 +++++ .../java/io/sentry/MainEventProcessorTest.kt | 16 +++++ .../io/sentry/SentryStackTraceFactoryTest.kt | 66 +++++++++++++++++++ sentry/src/test/java/io/sentry/SentryTest.kt | 31 +++++++++ .../file/SentryFileInputStreamTest.kt | 44 ++++++++++++- .../file/SentryFileOutputStreamTest.kt | 45 ++++++++++++- .../file/SentryFileReaderTest.kt | 8 ++- .../file/SentryFileWriterTest.kt | 8 ++- .../SentryBaseEventSerializationTest.kt | 1 + .../protocol/SentryEventSerializationTest.kt | 1 - .../io/sentry/util/CollectionUtilsTest.kt | 13 +++- .../util/thread/MainThreadCheckerTest.kt | 46 +++++++++++++ .../resources/json/sentry_base_event.json | 24 +++++++ .../src/test/resources/json/sentry_event.json | 48 +++++++------- .../resources/json/sentry_transaction.json | 24 +++++++ ...sentry_transaction_legacy_date_format.json | 24 +++++++ 44 files changed, 684 insertions(+), 136 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java rename sentry-android-core/src/test/java/io/sentry/android/core/internal/util/{MainThreadCheckerTest.kt => AndroidMainThreadCheckerTest.kt} (72%) create mode 100644 sentry/src/main/java/io/sentry/util/thread/IMainThreadChecker.java create mode 100644 sentry/src/main/java/io/sentry/util/thread/MainThreadChecker.java create mode 100644 sentry/src/main/java/io/sentry/util/thread/NoOpMainThreadChecker.java create mode 100644 sentry/src/test/java/io/sentry/util/thread/MainThreadCheckerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e77af3173..a7ce05d739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Add time-to-initial-display span to Activity transactions ([#2369](https://github.com/getsentry/sentry-java/pull/2369)) - Start a session after init if AutoSessionTracking is enabled ([#2356](https://github.com/getsentry/sentry-java/pull/2356)) - Provide automatic breadcrumbs and transactions for click/scroll events for Compose ([#2390](https://github.com/getsentry/sentry-java/pull/2390)) +- Add `blocked_main_thread` and `call_stack` to File I/O spans to detect performance issues ([#2382](https://github.com/getsentry/sentry-java/pull/2382)) ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java index 4dd16e03e0..80374b839a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java @@ -5,7 +5,7 @@ import androidx.core.app.FrameMetricsAggregator; import io.sentry.MeasurementUnit; import io.sentry.SentryLevel; -import io.sentry.android.core.internal.util.MainThreadChecker; +import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; import java.util.HashMap; @@ -208,7 +208,7 @@ public synchronized void stop() { private void runSafelyOnUiThread(final Runnable runnable, final String tag) { try { - if (MainThreadChecker.isMainThread()) { + if (AndroidMainThreadChecker.getInstance().isMainThread()) { runnable.run(); } else { handler.post( 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 82b746eb62..620fbed05c 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 @@ -14,6 +14,7 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; +import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; @@ -154,6 +155,7 @@ static void initializeIntegrationsAndProcessors( } options.setGestureTargetLocators(gestureTargetLocators); } + options.setMainThreadChecker(AndroidMainThreadChecker.getInstance()); } private static void installDefaultIntegrations( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index fc9b48377f..28aad388f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -5,7 +5,7 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.MainThreadChecker; +import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -56,7 +56,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio try { Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (MainThreadChecker.isMainThread()) { + if (AndroidMainThreadChecker.getInstance().isMainThread()) { addObserver(hub); } else { // some versions of the androidx lifecycle-process require this to be executed on the main @@ -115,7 +115,7 @@ private void removeObserver() { @Override public void close() throws IOException { if (watcher != null) { - if (MainThreadChecker.isMainThread()) { + if (AndroidMainThreadChecker.getInstance().isMainThread()) { removeObserver(); } else { // some versions of the androidx lifecycle-process require this to be executed on the main diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index bf17d90aab..5fcccb3642 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -26,9 +26,9 @@ import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.internal.util.ConnectivityChecker; import io.sentry.android.core.internal.util.DeviceOrientations; -import io.sentry.android.core.internal.util.MainThreadChecker; import io.sentry.android.core.internal.util.RootChecker; import io.sentry.protocol.App; import io.sentry.protocol.Device; @@ -217,7 +217,7 @@ private void setThreads(final @NotNull SentryEvent event) { if (event.getThreads() != null) { for (SentryThread thread : event.getThreads()) { if (thread.isCurrent() == null) { - thread.setCurrent(MainThreadChecker.isMainThread(thread)); + thread.setCurrent(AndroidMainThreadChecker.getInstance().isMainThread(thread)); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java new file mode 100644 index 0000000000..a893fa87d1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java @@ -0,0 +1,23 @@ +package io.sentry.android.core.internal.util; + +import android.os.Looper; +import io.sentry.util.thread.IMainThreadChecker; +import org.jetbrains.annotations.ApiStatus; + +/** Class that checks if a given thread is the Android Main/UI thread */ +@ApiStatus.Internal +public final class AndroidMainThreadChecker implements IMainThreadChecker { + + private static final AndroidMainThreadChecker instance = new AndroidMainThreadChecker(); + + public static AndroidMainThreadChecker getInstance() { + return instance; + } + + private AndroidMainThreadChecker() {} + + @Override + public boolean isMainThread(final long threadId) { + return Looper.getMainLooper().getThread().getId() == threadId; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java deleted file mode 100644 index c4c389134c..0000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/MainThreadChecker.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.sentry.android.core.internal.util; - -import android.os.Looper; -import io.sentry.protocol.SentryThread; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; - -/** Class that checks if a given thread is the Android Main/UI thread */ -@ApiStatus.Internal -public final class MainThreadChecker { - - private MainThreadChecker() {} - - /** - * Checks if a given thread is the Android Main/UI thread - * - * @param thread the Thread - * @return true if it is the main thread or false otherwise - */ - public static boolean isMainThread(final @NotNull Thread thread) { - return isMainThread(thread.getId()); - } - - /** - * Checks if the calling/current thread is the Android Main/UI thread - * - * @return true if it is the main thread or false otherwise - */ - public static boolean isMainThread() { - return isMainThread(Thread.currentThread()); - } - - /** - * Checks if a given thread is the Android Main/UI thread - * - * @param sentryThread the SentryThread - * @return true if it is the main thread or false otherwise - */ - public static boolean isMainThread(final @NotNull SentryThread sentryThread) { - final Long threadId = sentryThread.getId(); - return threadId != null && isMainThread(threadId); - } - - private static boolean isMainThread(final long threadId) { - return Looper.getMainLooper().getThread().getId() == threadId; - } -} 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 f3bc66b7f1..cf61131fe3 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 @@ -9,6 +9,7 @@ import io.sentry.MainEventProcessor import io.sentry.SentryOptions import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.internal.modules.AssetsModulesLoader +import io.sentry.android.core.internal.util.AndroidMainThreadChecker import io.sentry.android.fragment.FragmentLifecycleIntegration import io.sentry.android.timber.SentryTimberIntegration import org.junit.runner.RunWith @@ -447,4 +448,11 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.modulesLoader is AssetsModulesLoader } } + + @Test + fun `AndroidMainThreadChecker is set to options`() { + fixture.initSut() + + assertTrue { fixture.sentryOptions.mainThreadChecker is AndroidMainThreadChecker } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/MainThreadCheckerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidMainThreadCheckerTest.kt similarity index 72% rename from sentry-android-core/src/test/java/io/sentry/android/core/internal/util/MainThreadCheckerTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidMainThreadCheckerTest.kt index 391074325a..c759bdf79e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/MainThreadCheckerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidMainThreadCheckerTest.kt @@ -8,23 +8,23 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -class MainThreadCheckerTest { +class AndroidMainThreadCheckerTest { @Test fun `When calling isMainThread from the same thread, it should return true`() { - assertTrue(MainThreadChecker.isMainThread()) + assertTrue(AndroidMainThreadChecker.getInstance().isMainThread) } @Test fun `When calling isMainThread with the current thread, it should return true`() { val thread = Thread.currentThread() - assertTrue(MainThreadChecker.isMainThread(thread)) + assertTrue(AndroidMainThreadChecker.getInstance().isMainThread(thread)) } @Test fun `When calling isMainThread from a different thread, it should return false`() { val thread = Thread() - assertFalse(MainThreadChecker.isMainThread(thread)) + assertFalse(AndroidMainThreadChecker.getInstance().isMainThread(thread)) } @Test @@ -33,7 +33,7 @@ class MainThreadCheckerTest { val sentryThread = SentryThread().apply { id = thread.id } - assertTrue(MainThreadChecker.isMainThread(sentryThread)) + assertTrue(AndroidMainThreadChecker.getInstance().isMainThread(sentryThread)) } @Test @@ -42,6 +42,6 @@ class MainThreadCheckerTest { val sentryThread = SentryThread().apply { id = thread.id } - assertFalse(MainThreadChecker.isMainThread(sentryThread)) + assertFalse(AndroidMainThreadChecker.getInstance().isMainThread(sentryThread)) } } diff --git a/sentry-samples/sentry-samples-android/proguard-rules.pro b/sentry-samples/sentry-samples-android/proguard-rules.pro index 95c4bb7bbc..1165340c89 100644 --- a/sentry-samples/sentry-samples-android/proguard-rules.pro +++ b/sentry-samples/sentry-samples-android/proguard-rules.pro @@ -16,7 +16,6 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile - # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native -keepclasseswithmembernames,includedescriptorclasses class * { native ; diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 3b3cc037b8..761fb92d4e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -8,6 +8,7 @@ import io.sentry.MeasurementUnit; import io.sentry.Sentry; import io.sentry.UserFeedback; +import io.sentry.instrumentation.file.SentryFileOutputStream; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import io.sentry.samples.android.compose.ComposeActivity; @@ -78,7 +79,7 @@ protected void onCreate(Bundle savedInstanceState) { view -> { String fileName = Calendar.getInstance().getTimeInMillis() + "_file.txt"; File file = getApplication().getFileStreamPath(fileName); - try (final FileOutputStream fileOutputStream = new FileOutputStream(file); + try (final FileOutputStream fileOutputStream = new SentryFileOutputStream(file); final OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream); final Writer writer = new BufferedWriter(outputStreamWriter)) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt index f9047f100b..449b667fda 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ThirdFragment.kt @@ -12,6 +12,9 @@ import retrofit2.Response class ThirdFragment : Fragment(R.layout.third_fragment) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.third_button).setOnClickListener { + throw RuntimeException("Test") + } val span = Sentry.getSpan() val child = span?.startChild("calc") diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/third_fragment.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/third_fragment.xml index c721c41442..98cd9b3921 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/third_fragment.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/third_fragment.xml @@ -4,6 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent">