Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixes

- Fix TTFD measurement when API called too early ([#4297](https://github.com/getsentry/sentry-java/pull/4297))
- Hook User Interaction integration into running Activity in case of deferred SDK init ([#4337](https://github.com/getsentry/sentry-java/pull/4337))

## 8.8.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import android.app.Application;
import android.os.Bundle;
import android.view.Window;
import androidx.lifecycle.Lifecycle;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actual access to these optional deps is guarded by an if-check, I guess that should be good enough. Will do a manual test as well, just to be sure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just did a test, works as expected 🚀

import androidx.lifecycle.LifecycleOwner;
import io.sentry.IScopes;
import io.sentry.Integration;
import io.sentry.SentryLevel;
Expand All @@ -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 io.sentry.util.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) {
Expand Down Expand Up @@ -127,6 +132,17 @@ public void register(@NotNull IScopes scopes, @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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 io.sentry.util.AutoClosableReentrantLock;
Expand Down Expand Up @@ -346,6 +347,13 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved

// the first activity determines the app start type
if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, I just realized - we only set the currentActivity for the first activity here right? So it won't be the "last known activity" but rather the first one, which is incorrect, right? E.g. if you launched 3 activities, and then initialized the SDK it would use the wrong one to hook user interactions. Unless I got the above if-check wrong 😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's correct, it's a better rephrase of my comment 😅

final @Nullable Activity currentKnownActivity =
CurrentActivityHolder.getInstance().getActivity();
if (currentKnownActivity == null) {
CurrentActivityHolder.getInstance().setActivity(activity);
}

// 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package io.sentry.android.core
import android.app.Activity
import android.app.Application
import android.view.Window
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.sentry.Scopes
import io.sentry.android.core.internal.gestures.NoOpWindowCallback
Expand All @@ -11,13 +13,17 @@ import junit.framework.TestCase.assertNull
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
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.assertIs
import kotlin.test.assertIsNot
import kotlin.test.assertNotEquals
import kotlin.test.assertSame

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

fun getSut(
callback: Window.Callback? = null,
isAndroidXAvailable: Boolean = true
isAndroidXAvailable: Boolean = true,
isLifecycleAvailable: Boolean = true
): UserInteractionIntegration {
whenever(loadClass.isClassAvailable(any(), anyOrNull<SentryAndroidOptions>())).thenReturn(isAndroidXAvailable)
whenever(loadClass.isClassAvailable(eq("androidx.core.view.GestureDetectorCompat"), anyOrNull<SentryAndroidOptions>())).thenReturn(isAndroidXAvailable)
whenever(loadClass.isClassAvailable(eq("androidx.lifecycle.Lifecycle"), anyOrNull<SentryAndroidOptions>())).thenReturn(isLifecycleAvailable)
whenever(scopes.options).thenReturn(options)
if (callback != null) {
window.callback = callback
Expand All @@ -49,6 +57,11 @@ class UserInteractionIntegrationTest {

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()
Expand Down Expand Up @@ -156,6 +169,50 @@ class UserInteractionIntegrationTest {

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.scopes, fixture.options)
assertIsNot<SentryWindowCallback>(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.scopes, fixture.options)
assertIs<SentryWindowCallback>(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<Activity>()
val window = mock<Window>()
whenever(activity.window).thenReturn(window)

CurrentActivityHolder.getInstance().setActivity(activity)
sut.register(fixture.scopes, 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.scopes, fixture.options)
assertIsNot<SentryWindowCallback>(fixture.activity.window.callback)
}
}

private class EmptyActivity : Activity()
private class EmptyActivity() : Activity(), LifecycleOwner {

override val lifecycle: Lifecycle = mock<Lifecycle>()
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.sentry.DateUtils
import io.sentry.IContinuousProfiler
import io.sentry.ITransactionProfiler
import io.sentry.SentryNanotimeDate
import io.sentry.android.core.CurrentActivityHolder
import io.sentry.android.core.SentryAndroidOptions
import io.sentry.android.core.SentryShadowProcess
import org.junit.Before
Expand Down Expand Up @@ -507,4 +508,29 @@ class AppStartMetricsTest {
// Class loaded uptimeMs is 10 ms, and process init span should finish at the same ms
assertEquals(10, span.projectedStopTimestampMs)
}

@Test
fun `when activity is created and CurrentActivityHolder is empty, sets the activity`() {
val metrics = AppStartMetrics.getInstance()
val activity = mock<Activity>()
val currentActivityHolder = CurrentActivityHolder.getInstance()
currentActivityHolder.clearActivity()

metrics.onActivityCreated(activity, null)

assertEquals(activity, currentActivityHolder.getActivity())
}

@Test
fun `when activity is created and CurrentActivityHolder has activity, does not change it`() {
val metrics = AppStartMetrics.getInstance()
val existingActivity = mock<Activity>()
val newActivity = mock<Activity>()
val currentActivityHolder = CurrentActivityHolder.getInstance()
currentActivityHolder.setActivity(existingActivity)

metrics.onActivityCreated(newActivity, null)

assertEquals(existingActivity, currentActivityHolder.getActivity())
}
}
Loading