diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e8119a422..ce86ff1e58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841)) - Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) 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 0f4b646856a..729982385a1 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 @@ -208,6 +208,27 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { } } + /** + * Whether {@link ApplicationStartInfo#getReason()} indicates the OS spawned the process for + * background work (push, job, service, ...) rather than a user launch. Unknown/future reasons are + * treated as user-initiated to avoid discarding valid app starts. + */ + private static boolean isBackgroundStartReason(final int reason) { + switch (reason) { + case ApplicationStartInfo.START_REASON_ALARM: + case ApplicationStartInfo.START_REASON_BACKUP: + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + case ApplicationStartInfo.START_REASON_BROADCAST: + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + case ApplicationStartInfo.START_REASON_JOB: + case ApplicationStartInfo.START_REASON_PUSH: + case ApplicationStartInfo.START_REASON_SERVICE: + return true; + default: // launcher/recents/start_activity/other and future reasons: user-initiated + return false; + } + } + public boolean isAppLaunchedInForeground() { return appLaunchedInForeground.getValue(); } @@ -500,6 +521,12 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { appStartType = AppStartType.WARM; } } + // Background-spawned processes (push/job/service/...) keep the app start anchored at + // process creation. Marking them not-in-foreground makes onActivityCreated re-classify + // them as a warm start anchored at activity creation, avoiding an inflated cold start. + if (isBackgroundStartReason(info.getReason())) { + appLaunchedInForeground.setValue(false); + } } } catch (RuntimeException ignored) { // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index 0624e70b898..f53c557246c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -1,11 +1,13 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application import android.app.ApplicationStartInfo import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager @@ -16,6 +18,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -263,6 +266,99 @@ class AppStartMetricsTestApi35 { assertNull(metrics.appStartReason) } + @Test + fun `background start reason marks app as not launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `all background start reasons mark app as not launched in foreground`() { + val backgroundReasons = + listOf( + ApplicationStartInfo.START_REASON_ALARM, + ApplicationStartInfo.START_REASON_BACKUP, + ApplicationStartInfo.START_REASON_BOOT_COMPLETE, + ApplicationStartInfo.START_REASON_BROADCAST, + ApplicationStartInfo.START_REASON_CONTENT_PROVIDER, + ApplicationStartInfo.START_REASON_JOB, + ApplicationStartInfo.START_REASON_PUSH, + ApplicationStartInfo.START_REASON_SERVICE, + ) + + val app = ApplicationProvider.getApplicationContext() + for (reason in backgroundReasons) { + AppStartMetrics.getInstance().clear() + SentryShadowActivityManager.reset() + + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(reason) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertFalse( + AppStartMetrics.getInstance().isAppLaunchedInForeground, + "reason $reason should not be launched in foreground", + ) + } + } + + @Test + fun `user-initiated start reason keeps app launched in foreground`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertTrue(metrics.isAppLaunchedInForeground) + } + + @Test + fun `background-spawned start is re-classified as warm on the first activity`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + // App start span anchored at background process creation. + metrics.appStartTimeSpan.setStartedAt(42) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // User opens the app 20s later (under the 1-minute warm threshold). + val activityCreatedUptimeMs = 20_000L + SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs) + metrics.onActivityCreated(mock(), null) + + // Re-classified as a warm start re-anchored at activity creation. + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(activityCreatedUptimeMs, metrics.appStartTimeSpan.startUptimeMs) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle()