Skip to content

Commit ac80b92

Browse files
committed
Hook User Interaction integration into running Activity in case of deferred SDK init
1 parent 484644c commit ac80b92

File tree

4 files changed

+111
-4
lines changed

4 files changed

+111
-4
lines changed

sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import android.app.Application;
77
import android.os.Bundle;
88
import android.view.Window;
9+
import androidx.lifecycle.Lifecycle;
10+
import androidx.lifecycle.LifecycleOwner;
911
import io.sentry.IScopes;
1012
import io.sentry.Integration;
1113
import io.sentry.SentryLevel;
@@ -27,12 +29,15 @@ public final class UserInteractionIntegration
2729
private @Nullable SentryAndroidOptions options;
2830

2931
private final boolean isAndroidXAvailable;
32+
private final boolean isAndroidxLifecycleAvailable;
3033

3134
public UserInteractionIntegration(
3235
final @NotNull Application application, final @NotNull io.sentry.util.LoadClass classLoader) {
3336
this.application = Objects.requireNonNull(application, "Application is required");
3437
isAndroidXAvailable =
3538
classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options);
39+
isAndroidxLifecycleAvailable =
40+
classLoader.isClassAvailable("androidx.lifecycle.Lifecycle", options);
3641
}
3742

3843
private void startTracking(final @NotNull Activity activity) {
@@ -127,6 +132,17 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) {
127132
application.registerActivityLifecycleCallbacks(this);
128133
this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed.");
129134
addIntegrationToSdkVersion("UserInteraction");
135+
136+
// In case of a deferred init, we hook into any resumed activity
137+
if (isAndroidxLifecycleAvailable) {
138+
final @Nullable Activity activity = CurrentActivityHolder.getInstance().getActivity();
139+
if (activity instanceof LifecycleOwner) {
140+
if (((LifecycleOwner) activity).getLifecycle().getCurrentState()
141+
== Lifecycle.State.RESUMED) {
142+
startTracking(activity);
143+
}
144+
}
145+
}
130146
} else {
131147
options
132148
.getLogger()

sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import io.sentry.TracesSamplingDecision;
1818
import io.sentry.android.core.BuildInfoProvider;
1919
import io.sentry.android.core.ContextUtils;
20+
import io.sentry.android.core.CurrentActivityHolder;
2021
import io.sentry.android.core.SentryAndroidOptions;
2122
import io.sentry.android.core.internal.util.FirstDrawDoneListener;
2223
import io.sentry.util.AutoClosableReentrantLock;
@@ -346,6 +347,13 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved
346347

347348
// the first activity determines the app start type
348349
if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) {
350+
351+
final @Nullable Activity currentKnownActivity =
352+
CurrentActivityHolder.getInstance().getActivity();
353+
if (currentKnownActivity == null) {
354+
CurrentActivityHolder.getInstance().setActivity(activity);
355+
}
356+
349357
// If the app (process) was launched more than 1 minute ago, it's likely wrong
350358
final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs();
351359
if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) {

sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package io.sentry.android.core
33
import android.app.Activity
44
import android.app.Application
55
import android.view.Window
6+
import androidx.lifecycle.Lifecycle
7+
import androidx.lifecycle.LifecycleOwner
68
import androidx.test.ext.junit.runners.AndroidJUnit4
79
import io.sentry.Scopes
810
import io.sentry.android.core.internal.gestures.NoOpWindowCallback
@@ -11,13 +13,17 @@ import junit.framework.TestCase.assertNull
1113
import org.junit.runner.RunWith
1214
import org.mockito.kotlin.any
1315
import org.mockito.kotlin.anyOrNull
16+
import org.mockito.kotlin.eq
1417
import org.mockito.kotlin.mock
1518
import org.mockito.kotlin.never
19+
import org.mockito.kotlin.times
1620
import org.mockito.kotlin.verify
1721
import org.mockito.kotlin.whenever
1822
import org.robolectric.Robolectric.buildActivity
23+
import kotlin.test.BeforeTest
1924
import kotlin.test.Test
2025
import kotlin.test.assertIs
26+
import kotlin.test.assertIsNot
2127
import kotlin.test.assertNotEquals
2228
import kotlin.test.assertSame
2329

@@ -30,15 +36,17 @@ class UserInteractionIntegrationTest {
3036
val options = SentryAndroidOptions().apply {
3137
dsn = "https://key@sentry.io/proj"
3238
}
33-
val activity: Activity = buildActivity(EmptyActivity::class.java).setup().get()
39+
val activity: EmptyActivity = buildActivity(EmptyActivity::class.java).setup().get()
3440
val window: Window = activity.window
3541
val loadClass = mock<LoadClass>()
3642

3743
fun getSut(
3844
callback: Window.Callback? = null,
39-
isAndroidXAvailable: Boolean = true
45+
isAndroidXAvailable: Boolean = true,
46+
isLifecycleAvailable: Boolean = true
4047
): UserInteractionIntegration {
41-
whenever(loadClass.isClassAvailable(any(), anyOrNull<SentryAndroidOptions>())).thenReturn(isAndroidXAvailable)
48+
whenever(loadClass.isClassAvailable(eq("androidx.core.view.GestureDetectorCompat"), anyOrNull<SentryAndroidOptions>())).thenReturn(isAndroidXAvailable)
49+
whenever(loadClass.isClassAvailable(eq("androidx.lifecycle.Lifecycle"), anyOrNull<SentryAndroidOptions>())).thenReturn(isLifecycleAvailable)
4250
whenever(scopes.options).thenReturn(options)
4351
if (callback != null) {
4452
window.callback = callback
@@ -49,6 +57,11 @@ class UserInteractionIntegrationTest {
4957

5058
private val fixture = Fixture()
5159

60+
@BeforeTest
61+
fun setup() {
62+
CurrentActivityHolder.getInstance().clearActivity()
63+
}
64+
5265
@Test
5366
fun `when user interaction breadcrumb is enabled registers a callback`() {
5467
val sut = fixture.getSut()
@@ -156,6 +169,50 @@ class UserInteractionIntegrationTest {
156169

157170
assertNotEquals(existingCallback, (fixture.window.callback as SentryWindowCallback).delegate)
158171
}
172+
173+
@Test
174+
fun `when androidx lifecycle is unavailable doesn't hook into activity`() {
175+
val sut = fixture.getSut(isLifecycleAvailable = false)
176+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
177+
sut.register(fixture.scopes, fixture.options)
178+
assertIsNot<SentryWindowCallback>(fixture.window)
179+
}
180+
181+
@Test
182+
fun `when activity is resumed and is a LifecycleOwner, starts tracking immediately`() {
183+
val sut = fixture.getSut()
184+
whenever(fixture.activity.lifecycle.currentState).thenReturn(Lifecycle.State.RESUMED)
185+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
186+
187+
sut.register(fixture.scopes, fixture.options)
188+
assertIs<SentryWindowCallback>(fixture.window.callback)
189+
}
190+
191+
@Test
192+
fun `when activity is resumed but not a LifecycleOwner, does not start tracking immediately`() {
193+
val sut = fixture.getSut()
194+
val activity = mock<Activity>()
195+
val window = mock<Window>()
196+
whenever(activity.window).thenReturn(window)
197+
198+
CurrentActivityHolder.getInstance().setActivity(activity)
199+
sut.register(fixture.scopes, fixture.options)
200+
201+
verify(window, never()).callback = any()
202+
}
203+
204+
@Test
205+
fun `when activity is not in RESUMED state, does not start tracking immediately`() {
206+
val sut = fixture.getSut()
207+
whenever(fixture.activity.lifecycle.currentState).thenReturn(Lifecycle.State.CREATED)
208+
CurrentActivityHolder.getInstance().setActivity(fixture.activity)
209+
210+
sut.register(fixture.scopes, fixture.options)
211+
assertIsNot<SentryWindowCallback>(fixture.activity.window.callback)
212+
}
159213
}
160214

161-
private class EmptyActivity : Activity()
215+
private class EmptyActivity() : Activity(), LifecycleOwner {
216+
217+
override val lifecycle: Lifecycle = mock<Lifecycle>()
218+
}

sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.sentry.DateUtils
1212
import io.sentry.IContinuousProfiler
1313
import io.sentry.ITransactionProfiler
1414
import io.sentry.SentryNanotimeDate
15+
import io.sentry.android.core.CurrentActivityHolder
1516
import io.sentry.android.core.SentryAndroidOptions
1617
import io.sentry.android.core.SentryShadowProcess
1718
import org.junit.Before
@@ -507,4 +508,29 @@ class AppStartMetricsTest {
507508
// Class loaded uptimeMs is 10 ms, and process init span should finish at the same ms
508509
assertEquals(10, span.projectedStopTimestampMs)
509510
}
511+
512+
@Test
513+
fun `when activity is created and CurrentActivityHolder is empty, sets the activity`() {
514+
val metrics = AppStartMetrics.getInstance()
515+
val activity = mock<Activity>()
516+
val currentActivityHolder = CurrentActivityHolder.getInstance()
517+
currentActivityHolder.clearActivity()
518+
519+
metrics.onActivityCreated(activity, null)
520+
521+
assertEquals(activity, currentActivityHolder.getActivity())
522+
}
523+
524+
@Test
525+
fun `when activity is created and CurrentActivityHolder has activity, does not change it`() {
526+
val metrics = AppStartMetrics.getInstance()
527+
val existingActivity = mock<Activity>()
528+
val newActivity = mock<Activity>()
529+
val currentActivityHolder = CurrentActivityHolder.getInstance()
530+
currentActivityHolder.setActivity(existingActivity)
531+
532+
metrics.onActivityCreated(newActivity, null)
533+
534+
assertEquals(existingActivity, currentActivityHolder.getActivity())
535+
}
510536
}

0 commit comments

Comments
 (0)