diff --git a/.craft.yml b/.craft.yml index ee94d4d7dd..6be8f9ee9a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -14,7 +14,7 @@ targets: mavenRepoUrl: https://oss.sonatype.org/service/local/staging/deploy/maven2/ android: distDirRegex: /^(sentry-android-|.*-android).*$/ - fileReplaceeRegex: /\d\.\d\.\d(-\w+(\.\d)?)?(-SNAPSHOT)?/ + fileReplaceeRegex: /\d+\.\d+\.\d+(-\w+(\.\d+)?)?(-SNAPSHOT)?/ fileReplacerStr: release.aar - name: github excludeNames: /^libsentry.*\.so$/ diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 80ba5e7bd0..725cc1b534 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -40,7 +40,7 @@ jobs: ${{ runner.os }}-gradle- - name: Initialize CodeQL - uses: github/codeql-action/init@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # pin@v2 + uses: github/codeql-action/init@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # pin@v2 with: languages: ${{ matrix.language }} @@ -48,4 +48,4 @@ jobs: ./gradlew assemble - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c3b6fce4ee2ca25bc1066aa3bf73962fda0e8898 # pin@v2 + uses: github/codeql-action/analyze@959cbb7472c4d4ad70cdfe6f4976053fe48ab394 # pin@v2 diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 6bd8c44706..684c0192c0 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -30,7 +30,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@132898c54c57c7cc6b80eb3a89968de8fc283505 # pin@3.7.1 + uses: JamesIves/github-pages-deploy-action@ba1486788b0490a235422264426c45848eac35c6 # pin@4.4.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages diff --git a/CHANGELOG.md b/CHANGELOG.md index 1639c3d3b2..71cd5c9c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,23 @@ ## Unreleased +### Fixes + +- Use minSdk compatible `Objects` class ([#2436](https://github.com/getsentry/sentry-java/pull/2436)) + ### Features - Add logging for OpenTelemetry integration ([#2425](https://github.com/getsentry/sentry-java/pull/2425)) +## 6.10.0 + +### 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)) +- 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 - Bump Native SDK from v0.5.2 to v0.5.3 ([#2423](https://github.com/getsentry/sentry-java/pull/2423)) @@ -59,15 +72,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/build.gradle.kts b/build.gradle.kts index a5d08899c4..1a49ffe941 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -104,7 +104,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support" && this.name != "sentry-compose-helper") { apply() val sep = File.separator diff --git a/gradle.properties b/gradle.properties index 16036c233d..c2c4e22316 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ org.gradle.parallel=true android.useAndroidX=true # Release information -versionName=6.9.2 +versionName=6.10.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 17dca925d3..05316fa0d4 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 @@ -159,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 @@ -174,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/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/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/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 821f147119..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 @@ -12,10 +12,14 @@ 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.AndroidMainThreadChecker; 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 +27,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 +136,26 @@ 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); + } + 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/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/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 a076bd6bf4..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,29 +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) { - @NotNull String className; - @Nullable String canonicalName = target.getClass().getCanonicalName(); - if (canonicalName != null) { - className = canonicalName; - } else { - className = target.getClass().getSimpleName(); + + if (!options.isEnableUserInteractionBreadcrumbs()) { + return; } 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; } @@ -210,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 @@ -232,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(); @@ -250,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(); @@ -268,7 +243,7 @@ private void startTracing(final @NotNull View target, final @NotNull String even }); activeTransaction = transaction; - activeView = new WeakReference<>(target); + activeUiElement = target; activeEventType = eventType; } @@ -281,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; } @@ -350,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; } /** @@ -385,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/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/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/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/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/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/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/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/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 7e935893ae..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 @@ -34,6 +34,9 @@ class SentryGestureListenerScrollTest { val resources = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" + isEnableUserInteractionBreadcrumbs = true + isEnableUserInteractionTracing = true + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) } val hub = mock() @@ -46,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) @@ -69,8 +72,7 @@ class SentryGestureListenerScrollTest { return SentryGestureListener( activity, hub, - options, - isAndroidXAvailable + options ) } } @@ -169,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-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-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-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..aae846513e 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -1,3 +1,6 @@ +import com.android.build.gradle.internal.tasks.LibraryAarJarsTask +import groovy.util.Node +import groovy.util.NodeList import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.dokka.gradle.DokkaTask @@ -39,6 +42,7 @@ kotlin { api(compose.ui) implementation(Config.Libs.kotlinStdLib) + api(projects.sentryComposeHelper) } } val androidMain by getting { @@ -128,3 +132,52 @@ 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) +} + +// we embed the sentry-compose-helper classes to the same .jar above +// so we need to exclude the dependency from the .pom publication and .module metadata +configure { + publications.withType(MavenPublication::class.java).all { + this.pom { + this.withXml { + (asNode().get("dependencies") as NodeList) + .flatMap { + if (it is Node) it.children() else NodeList() + } + .filterIsInstance() + .filter { dependency -> + val artifactIdNodes = dependency.get("artifactId") as NodeList + artifactIdNodes.any { + (it is Node && it.value().toString().contains("sentry-compose-helper")) + } + } + .forEach { dependency -> + dependency.parent().remove(dependency) + } + } + } + } +} + +tasks.withType { + enabled = false +} 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 6f3c401633..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 @@ -7,8 +7,8 @@ import io.sentry.ISpan; import io.sentry.MeasurementUnit; import io.sentry.Sentry; -import io.sentry.SpanStatus; 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; @@ -79,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)) { @@ -206,7 +206,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-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/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-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">