From e6c672d420be6d596e7c166e73e56f2ff1a0e327 Mon Sep 17 00:00:00 2001 From: markushi Date: Mon, 12 May 2025 13:34:08 +0200 Subject: [PATCH 1/2] Hook User Interaction integration into running Activity in case of deferred SDK init --- .../api/sentry-android-core.api | 17 +- .../core/AndroidOptionsInitializer.java | 1 - .../android/core/CurrentActivityHolder.java | 14 +- .../core/CurrentActivityIntegration.java | 80 --------- .../core/UserInteractionIntegration.java | 16 ++ .../core/performance/AppStartMetrics.java | 25 ++- .../core/AndroidOptionsInitializerTest.kt | 9 - .../core/CurrentActivityIntegrationTest.kt | 107 ------------ .../sentry/android/core/SentryAndroidTest.kt | 3 +- .../core/UserInteractionIntegrationTest.kt | 163 ++++++++++-------- .../core/performance/AppStartMetricsTest.kt | 57 ++++++ 11 files changed, 201 insertions(+), 291 deletions(-) delete mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java delete mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8096a5058e3..de7b2ef2982 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -168,24 +168,12 @@ public final class io/sentry/android/core/ContextUtils { public class io/sentry/android/core/CurrentActivityHolder { public fun clearActivity ()V + public fun clearActivity (Landroid/app/Activity;)V public fun getActivity ()Landroid/app/Activity; public static fun getInstance ()Lio/sentry/android/core/CurrentActivityHolder; public fun setActivity (Landroid/app/Activity;)V } -public final class io/sentry/android/core/CurrentActivityIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { - public fun (Landroid/app/Application;)V - public fun close ()V - public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityDestroyed (Landroid/app/Activity;)V - public fun onActivityPaused (Landroid/app/Activity;)V - public fun onActivityResumed (Landroid/app/Activity;)V - public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V - public fun onActivityStarted (Landroid/app/Activity;)V - public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V -} - public final class io/sentry/android/core/DeviceInfoUtil { public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V public fun collectDeviceInformation (ZZ)Lio/sentry/protocol/Device; @@ -449,7 +437,10 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun isAppLaunchedInForeground ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityPaused (Landroid/app/Activity;)V + public fun onActivityResumed (Landroid/app/Activity;)V public fun onActivityStarted (Landroid/app/Activity;)V + public fun onActivityStopped (Landroid/app/Activity;)V public fun onAppStartSpansSent ()V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V 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 5378f93110b..4c5bd2d3bc4 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 @@ -281,7 +281,6 @@ static void installDefaultIntegrations( new ActivityLifecycleIntegration( (Application) context, buildInfoProvider, activityFramesTracker)); options.addIntegration(new ActivityBreadcrumbsIntegration((Application) context)); - options.addIntegration(new CurrentActivityIntegration((Application) context)); options.addIntegration(new UserInteractionIntegration((Application) context, loadClass)); if (isFragmentAvailable) { options.addIntegration(new FragmentLifecycleIntegration((Application) context, true, true)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java index 8da322b20bf..a7733821c05 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityHolder.java @@ -1,11 +1,10 @@ package io.sentry.android.core; import android.app.Activity; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import java.lang.ref.WeakReference; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public class CurrentActivityHolder { @@ -16,7 +15,7 @@ private CurrentActivityHolder() {} private @Nullable WeakReference currentActivity; - public static @NonNull CurrentActivityHolder getInstance() { + public static @NotNull CurrentActivityHolder getInstance() { return instance; } @@ -27,7 +26,7 @@ private CurrentActivityHolder() {} return null; } - public void setActivity(final @NonNull Activity activity) { + public void setActivity(final @NotNull Activity activity) { if (currentActivity != null && currentActivity.get() == activity) { return; } @@ -38,4 +37,11 @@ public void setActivity(final @NonNull Activity activity) { public void clearActivity() { currentActivity = null; } + + public void clearActivity(final @NotNull Activity activity) { + if (currentActivity != null && currentActivity.get() != activity) { + return; + } + currentActivity = null; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java deleted file mode 100644 index b4c5f1ed027..00000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.sentry.android.core; - -import android.app.Activity; -import android.app.Application; -import android.os.Bundle; -import androidx.annotation.NonNull; -import io.sentry.IHub; -import io.sentry.Integration; -import io.sentry.SentryOptions; -import io.sentry.util.Objects; -import java.io.Closeable; -import java.io.IOException; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@ApiStatus.Internal -public final class CurrentActivityIntegration - implements Integration, Closeable, Application.ActivityLifecycleCallbacks { - - private final @NotNull Application application; - - public CurrentActivityIntegration(final @NotNull Application application) { - this.application = Objects.requireNonNull(application, "Application is required"); - } - - @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { - application.registerActivityLifecycleCallbacks(this); - } - - @Override - public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - setCurrentActivity(activity); - } - - @Override - public void onActivityStarted(@NonNull Activity activity) { - setCurrentActivity(activity); - } - - @Override - public void onActivityResumed(@NonNull Activity activity) { - setCurrentActivity(activity); - } - - @Override - public void onActivityPaused(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void onActivityStopped(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} - - @Override - public void onActivityDestroyed(@NonNull Activity activity) { - cleanCurrentActivity(activity); - } - - @Override - public void close() throws IOException { - application.unregisterActivityLifecycleCallbacks(this); - CurrentActivityHolder.getInstance().clearActivity(); - } - - private void cleanCurrentActivity(final @NotNull Activity activity) { - if (CurrentActivityHolder.getInstance().getActivity() == activity) { - CurrentActivityHolder.getInstance().clearActivity(); - } - } - - private void setCurrentActivity(final @NotNull Activity activity) { - CurrentActivityHolder.getInstance().setActivity(activity); - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index 87e60fa6bfd..0c0362f4c74 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 @@ -6,6 +6,8 @@ import android.app.Application; import android.os.Bundle; import android.view.Window; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; import io.sentry.IHub; import io.sentry.Integration; import io.sentry.SentryLevel; @@ -27,12 +29,15 @@ public final class UserInteractionIntegration private @Nullable SentryAndroidOptions options; private final boolean isAndroidXAvailable; + private final boolean isAndroidxLifecycleAvailable; 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); + isAndroidxLifecycleAvailable = + classLoader.isClassAvailable("androidx.lifecycle.Lifecycle", options); } private void startTracking(final @NotNull Activity activity) { @@ -127,6 +132,17 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { application.registerActivityLifecycleCallbacks(this); this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); addIntegrationToSdkVersion("UserInteraction"); + + // In case of a deferred init, we hook into any resumed activity + if (isAndroidxLifecycleAvailable) { + final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity(); + if (activity instanceof LifecycleOwner) { + if (((LifecycleOwner) activity).getLifecycle().getCurrentState() + == Lifecycle.State.RESUMED) { + startTracking(activity); + } + } + } } else { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 6107c2e1178..d2dd3d92778 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -15,6 +15,7 @@ import io.sentry.TracesSamplingDecision; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; +import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; import java.util.ArrayList; @@ -39,7 +40,6 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { - public enum AppStartType { UNKNOWN, COLD, @@ -304,10 +304,12 @@ private void checkCreateTimeOnMain() { @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - final long nowUptimeMs = SystemClock.uptimeMillis(); + CurrentActivityHolder.getInstance().setActivity(activity); // the first activity determines the app start type if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { + final long nowUptimeMs = SystemClock.uptimeMillis(); + // If the app (process) was launched more than 1 minute ago, it's likely wrong final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { @@ -329,6 +331,8 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved @Override public void onActivityStarted(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().setActivity(activity); + if (firstDrawDone.get()) { return; } @@ -340,8 +344,25 @@ public void onActivityStarted(@NonNull Activity activity) { } } + @Override + public void onActivityResumed(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().setActivity(activity); + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().clearActivity(activity); + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().clearActivity(activity); + } + @Override public void onActivityDestroyed(@NonNull Activity activity) { + CurrentActivityHolder.getInstance().clearActivity(activity); + final int remainingActivities = activeActivitiesCounter.decrementAndGet(); // if the app is moving into background // as the next Activity is considered like a new app start 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 31d1547ca49..3bc9c5569b6 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 @@ -517,15 +517,6 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.envelopeDiskCache is AndroidEnvelopeCache } } - @Test - fun `CurrentActivityIntegration is added by default`() { - fixture.initSut(useRealContext = true) - - val actual = - fixture.sentryOptions.integrations.firstOrNull { it is CurrentActivityIntegration } - assertNotNull(actual) - } - @Test fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be available`() { fixture.initSut( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt deleted file mode 100644 index 63306231214..00000000000 --- a/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.sentry.android.core - -import android.app.Activity -import android.app.Application -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.IHub -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@RunWith(AndroidJUnit4::class) -class CurrentActivityIntegrationTest { - - private class Fixture { - val application = mock() - val activity = mock() - val hub = mock() - - val options = SentryAndroidOptions().apply { - dsn = "https://key@sentry.io/proj" - } - - fun getSut(): CurrentActivityIntegration { - val integration = CurrentActivityIntegration(application) - integration.register(hub, options) - return integration - } - } - - private lateinit var fixture: Fixture - - @BeforeTest - fun `set up`() { - fixture = Fixture() - } - - @Test - fun `when the integration is added registerActivityLifecycleCallbacks is called`() { - fixture.getSut() - verify(fixture.application).registerActivityLifecycleCallbacks(any()) - } - - @Test - fun `when the integration is closed unregisterActivityLifecycleCallbacks is called`() { - val sut = fixture.getSut() - sut.close() - - verify(fixture.application).unregisterActivityLifecycleCallbacks(any()) - } - - @Test - fun `when an activity is created the activity holder provides it`() { - val sut = fixture.getSut() - - sut.onActivityCreated(fixture.activity, null) - assertEquals(fixture.activity, CurrentActivityHolder.getInstance().activity) - } - - @Test - fun `when there is no active activity the holder does not provide an outdated one`() { - val sut = fixture.getSut() - - sut.onActivityCreated(fixture.activity, null) - sut.onActivityDestroyed(fixture.activity) - - assertNull(CurrentActivityHolder.getInstance().activity) - } - - @Test - fun `when a second activity is started it gets the current one`() { - val sut = fixture.getSut() - - sut.onActivityCreated(fixture.activity, null) - sut.onActivityStarted(fixture.activity) - sut.onActivityResumed(fixture.activity) - - val secondActivity = mock() - sut.onActivityCreated(secondActivity, null) - sut.onActivityStarted(secondActivity) - - assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) - } - - @Test - fun `destroying an old activity keeps the current one`() { - val sut = fixture.getSut() - - sut.onActivityCreated(fixture.activity, null) - sut.onActivityStarted(fixture.activity) - sut.onActivityResumed(fixture.activity) - - val secondActivity = mock() - sut.onActivityCreated(secondActivity, null) - sut.onActivityStarted(secondActivity) - - sut.onActivityPaused(fixture.activity) - sut.onActivityStopped(fixture.activity) - sut.onActivityDestroyed(fixture.activity) - - assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) - } -} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 9645bb0b2d5..22f5aea27d0 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 @@ -470,7 +470,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(19, options.integrations.size) + assertEquals(18, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -481,7 +481,6 @@ class SentryAndroidTest { it is AnrIntegration || it is ActivityLifecycleIntegration || it is ActivityBreadcrumbsIntegration || - it is CurrentActivityIntegration || it is UserInteractionIntegration || it is FragmentLifecycleIntegration || it is SentryTimberIntegration || diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index f126e6c9229..eb77397060e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -2,24 +2,30 @@ package io.sentry.android.core import android.app.Activity import android.app.Application -import android.content.Context -import android.content.res.Resources -import android.util.DisplayMetrics import android.view.Window +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hub import io.sentry.android.core.internal.gestures.NoOpWindowCallback import io.sentry.android.core.internal.gestures.SentryWindowCallback +import junit.framework.TestCase.assertNull import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq 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.Robolectric.buildActivity +import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertTrue +import kotlin.test.assertIs +import kotlin.test.assertIsNot +import kotlin.test.assertNotEquals +import kotlin.test.assertSame @RunWith(AndroidJUnit4::class) class UserInteractionIntegrationTest { @@ -30,38 +36,32 @@ class UserInteractionIntegrationTest { val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } - val activity = mock() - val window = mock() + val activity: EmptyActivity = buildActivity(EmptyActivity::class.java).setup().get() + val window: Window = activity.window val loadClass = mock() fun getSut( callback: Window.Callback? = null, - isAndroidXAvailable: Boolean = true + isAndroidXAvailable: Boolean = true, + isLifecycleAvailable: Boolean = true ): UserInteractionIntegration { - whenever(loadClass.isClassAvailable(any(), anyOrNull())).thenReturn(isAndroidXAvailable) + whenever(loadClass.isClassAvailable(eq("androidx.core.view.GestureDetectorCompat"), anyOrNull())).thenReturn(isAndroidXAvailable) + whenever(loadClass.isClassAvailable(eq("androidx.lifecycle.Lifecycle"), anyOrNull())).thenReturn(isLifecycleAvailable) whenever(hub.options).thenReturn(options) - whenever(window.callback).thenReturn(callback) - whenever(activity.window).thenReturn(window) - - val resources = mockResources() - whenever(activity.resources).thenReturn(resources) - return UserInteractionIntegration(application, loadClass) - } - - companion object { - fun mockResources(): Resources { - val displayMetrics = mock() - displayMetrics.density = 1.0f - - val resources = mock() - whenever(resources.displayMetrics).thenReturn(displayMetrics) - return resources + if (callback != null) { + window.callback = callback } + return UserInteractionIntegration(application, loadClass) } } private val fixture = Fixture() + @BeforeTest + fun setup() { + CurrentActivityHolder.getInstance().clearActivity() + } + @Test fun `when user interaction breadcrumb is enabled registers a callback`() { val sut = fixture.getSut() @@ -105,74 +105,50 @@ class UserInteractionIntegrationTest { sut.register(fixture.hub, fixture.options) sut.onActivityResumed(fixture.activity) - - val argumentCaptor = argumentCaptor() - verify(fixture.window).callback = argumentCaptor.capture() - assertTrue { argumentCaptor.firstValue is SentryWindowCallback } + assertIs(fixture.activity.window.callback) } @Test fun `when no original callback delegates to NoOpWindowCallback`() { val sut = fixture.getSut() sut.register(fixture.hub, fixture.options) + fixture.window.callback = null sut.onActivityResumed(fixture.activity) - - val argumentCaptor = argumentCaptor() - verify(fixture.window).callback = argumentCaptor.capture() - assertTrue { - argumentCaptor.firstValue is SentryWindowCallback && - (argumentCaptor.firstValue as SentryWindowCallback).delegate is NoOpWindowCallback - } + assertIs(fixture.activity.window.callback) + assertIs((fixture.activity.window.callback as SentryWindowCallback).delegate) } @Test fun `unregisters window callback on activity paused`() { - val context = mock() - val resources = Fixture.mockResources() - whenever(context.resources).thenReturn(resources) - val sut = fixture.getSut( - SentryWindowCallback( - NoOpWindowCallback(), - context, - mock(), - mock() - ) - ) + val sut = fixture.getSut() + fixture.activity.window.callback = null - sut.register(fixture.hub, fixture.options) + sut.onActivityResumed(fixture.activity) sut.onActivityPaused(fixture.activity) - verify(fixture.window).callback = null + assertNull(fixture.activity.window.callback) } @Test fun `preserves original callback on activity paused`() { - val delegate = mock() - val context = mock() - val resources = Fixture.mockResources() - whenever(context.resources).thenReturn(resources) - val sut = fixture.getSut( - SentryWindowCallback( - delegate, - context, - mock(), - mock() - ) - ) + val sut = fixture.getSut() + val mockCallback = mock() - sut.register(fixture.hub, fixture.options) + fixture.window.callback = mockCallback + + sut.onActivityResumed(fixture.activity) sut.onActivityPaused(fixture.activity) - verify(fixture.window).callback = delegate + assertSame(mockCallback, fixture.activity.window.callback) } @Test fun `stops tracing on activity paused`() { val callback = mock() - val sut = fixture.getSut(callback) + val sut = fixture.getSut() + fixture.activity.window.callback = callback - sut.register(fixture.hub, fixture.options) sut.onActivityPaused(fixture.activity) verify(callback).stopTracking() @@ -180,13 +156,9 @@ class UserInteractionIntegrationTest { @Test fun `does not instrument if the callback is already ours`() { - val delegate = mock() - val context = mock() - val resources = Fixture.mockResources() - whenever(context.resources).thenReturn(resources) val existingCallback = SentryWindowCallback( - delegate, - context, + NoOpWindowCallback(), + fixture.activity, mock(), mock() ) @@ -195,7 +167,52 @@ class UserInteractionIntegrationTest { sut.register(fixture.hub, fixture.options) sut.onActivityResumed(fixture.activity) - val argumentCaptor = argumentCaptor() - verify(fixture.window, never()).callback = argumentCaptor.capture() + assertNotEquals(existingCallback, (fixture.window.callback as SentryWindowCallback).delegate) + } + + @Test + fun `when androidx lifecycle is unavailable doesn't hook into activity`() { + val sut = fixture.getSut(isLifecycleAvailable = false) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + sut.register(fixture.hub, fixture.options) + assertIsNot(fixture.window) + } + + @Test + fun `when activity is resumed and is a LifecycleOwner, starts tracking immediately`() { + val sut = fixture.getSut() + whenever(fixture.activity.lifecycle.currentState).thenReturn(Lifecycle.State.RESUMED) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + + sut.register(fixture.hub, fixture.options) + assertIs(fixture.window.callback) + } + + @Test + fun `when activity is resumed but not a LifecycleOwner, does not start tracking immediately`() { + val sut = fixture.getSut() + val activity = mock() + val window = mock() + whenever(activity.window).thenReturn(window) + + CurrentActivityHolder.getInstance().setActivity(activity) + sut.register(fixture.hub, fixture.options) + + verify(window, never()).callback = any() } + + @Test + fun `when activity is not in RESUMED state, does not start tracking immediately`() { + val sut = fixture.getSut() + whenever(fixture.activity.lifecycle.currentState).thenReturn(Lifecycle.State.CREATED) + CurrentActivityHolder.getInstance().setActivity(fixture.activity) + + sut.register(fixture.hub, fixture.options) + assertIsNot(fixture.activity.window.callback) + } +} + +private class EmptyActivity() : Activity(), LifecycleOwner { + + override val lifecycle: Lifecycle = mock() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 855973d9418..e7901c83574 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -9,6 +9,7 @@ import android.os.Looper import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ITransactionProfiler +import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess import org.junit.Before @@ -454,4 +455,60 @@ class AppStartMetricsTest { // then the app start is still considered warm assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } + + @Test + fun `when an activity is created the activity holder provides it`() { + val metrics = AppStartMetrics.getInstance() + val activity = mock() + + metrics.onActivityCreated(activity, null) + assertEquals(activity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `when there is no active activity the holder does not provide an outdated one`() { + val metrics = AppStartMetrics.getInstance() + val activity = mock() + + metrics.onActivityCreated(activity, null) + metrics.onActivityDestroyed(activity) + + assertNull(CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `when a second activity is started it gets the current one`() { + val metrics = AppStartMetrics.getInstance() + val firstActivity = mock() + + metrics.onActivityCreated(firstActivity, null) + metrics.onActivityStarted(firstActivity) + metrics.onActivityResumed(firstActivity) + + val secondActivity = mock() + metrics.onActivityCreated(secondActivity, null) + metrics.onActivityStarted(secondActivity) + + assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `destroying an old activity keeps the current one`() { + val metrics = AppStartMetrics.getInstance() + val firstActivity = mock() + + metrics.onActivityCreated(firstActivity, null) + metrics.onActivityStarted(firstActivity) + metrics.onActivityResumed(firstActivity) + + val secondActivity = mock() + metrics.onActivityCreated(secondActivity, null) + metrics.onActivityStarted(secondActivity) + + metrics.onActivityPaused(firstActivity) + metrics.onActivityStopped(firstActivity) + metrics.onActivityDestroyed(firstActivity) + + assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) + } } From 8f083860322ea4d92a0d1cbf4edea0035279da85 Mon Sep 17 00:00:00 2001 From: markushi Date: Mon, 12 May 2025 13:37:23 +0200 Subject: [PATCH 2/2] Update Changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e7a2114f3..4553da98be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Hook User Interaction integration into running Activity in case of deferred SDK init ([#4387](https://github.com/getsentry/sentry-java/pull/4387)) + ## 7.22.5 ### Fixes