From 6659eb8b9ee1237394e60a29d9958ebeacb92f55 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 27 Jul 2026 15:40:08 +0200 Subject: [PATCH 1/4] fix(android): Drop inflated app start for background-spawned processes on API 35+ When the OS spawns the process for background work (FCM push, job, service, broadcast, etc.) and the user opens the app later, the app start stayed anchored at background process creation, inflating the reported cold start by the whole idle gap. On API 35+ we now use ApplicationStartInfo.getReason() to detect background process starts and mark them as not launched in foreground, so the first created activity re-classifies them as a warm start anchored at activity creation. --- CHANGELOG.md | 2 + .../core/performance/AppStartMetrics.java | 34 +++++++ .../performance/AppStartMetricsTestApi35.kt | 98 +++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e8119a422..fe0bf56071a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixes +- Prevent inflated app start when the process is spawned in the background (API 35+) ([#5843](https://github.com/getsentry/sentry-java/pull/5843)) + - When the OS starts the process for background work (e.g. an FCM push, job or service) and the user opens the app later, the app start stayed anchored at background process creation, so the first activity reported the whole idle gap as an inflated cold start. Background process starts (`ApplicationStartInfo.getReason()` of `push`, `job`, `service`, `broadcast`, `alarm`, `backup`, `boot_complete`, `content_provider`) are now marked as not launched in foreground, so the first activity re-classifies them as a warm start anchored at activity creation instead. - 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..444b3c6686e 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,30 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { } } + /** + * Whether the given {@link ApplicationStartInfo#getReason()} indicates the OS started the process + * for background work (e.g. a push message, job or service) rather than a user-initiated 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; + // START_REASON_LAUNCHER, START_REASON_LAUNCHER_RECENTS, START_REASON_START_ACTIVITY, + // START_REASON_OTHER and any future reason are considered user-initiated. + default: + return false; + } + } + public boolean isAppLaunchedInForeground() { return appLaunchedInForeground.getValue(); } @@ -500,6 +524,16 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { appStartType = AppStartType.WARM; } } + // If the OS spawned the process for background work (push/job/service/broadcast/...) + // rather than a user launch, the app start stays anchored at background process + // creation. Without this, the first activity created once the user finally opens the + // app would report the whole idle gap as an inflated cold start. Marking the launch as + // not-in-foreground makes onActivityCreated re-classify it as a warm start anchored at + // activity creation instead. On API 35+ this replaces the main-looper idle check, which + // is skipped here once the start type is known. + 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..3c1c719b70c 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,101 @@ 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) + + // Background spawn detected: not launched in foreground, cold type from ApplicationStartInfo. + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // User opens the app 20s later (well under the 1-minute warm threshold that previously was the + // only way to catch this). + val activityCreatedUptimeMs = 20_000L + SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs) + metrics.onActivityCreated(mock(), null) + + // The inflated cold start is 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() From 1101a9c9683f1c4cfbf5b0efee966bb4112bba0d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 27 Jul 2026 15:41:08 +0200 Subject: [PATCH 2/4] docs: fix changelog PR link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0bf56071a..fdba4624676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixes -- Prevent inflated app start when the process is spawned in the background (API 35+) ([#5843](https://github.com/getsentry/sentry-java/pull/5843)) +- Prevent inflated app start when the process is spawned in the background (API 35+) ([#5841](https://github.com/getsentry/sentry-java/pull/5841)) - When the OS starts the process for background work (e.g. an FCM push, job or service) and the user opens the app later, the app start stayed anchored at background process creation, so the first activity reported the whole idle gap as an inflated cold start. Background process starts (`ApplicationStartInfo.getReason()` of `push`, `job`, `service`, `broadcast`, `alarm`, `backup`, `boot_complete`, `content_provider`) are now marked as not launched in foreground, so the first activity re-classifies them as a warm start anchored at activity creation instead. - 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)) From c546da6040166e2e9b7e15448df3dcb71d010428 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 27 Jul 2026 16:37:21 +0200 Subject: [PATCH 3/4] docs: trim code comments --- .../core/performance/AppStartMetrics.java | 21 +++++++------------ .../performance/AppStartMetricsTestApi35.kt | 6 ++---- 2 files changed, 9 insertions(+), 18 deletions(-) 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 444b3c6686e..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 @@ -209,10 +209,9 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { } /** - * Whether the given {@link ApplicationStartInfo#getReason()} indicates the OS started the process - * for background work (e.g. a push message, job or service) rather than a user-initiated launch. - * - *

Unknown/future reasons are treated as user-initiated to avoid discarding valid app starts. + * 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) { @@ -225,9 +224,7 @@ private static boolean isBackgroundStartReason(final int reason) { case ApplicationStartInfo.START_REASON_PUSH: case ApplicationStartInfo.START_REASON_SERVICE: return true; - // START_REASON_LAUNCHER, START_REASON_LAUNCHER_RECENTS, START_REASON_START_ACTIVITY, - // START_REASON_OTHER and any future reason are considered user-initiated. - default: + default: // launcher/recents/start_activity/other and future reasons: user-initiated return false; } } @@ -524,13 +521,9 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { appStartType = AppStartType.WARM; } } - // If the OS spawned the process for background work (push/job/service/broadcast/...) - // rather than a user launch, the app start stays anchored at background process - // creation. Without this, the first activity created once the user finally opens the - // app would report the whole idle gap as an inflated cold start. Marking the launch as - // not-in-foreground makes onActivityCreated re-classify it as a warm start anchored at - // activity creation instead. On API 35+ this replaces the main-looper idle check, which - // is skipped here once the start type is known. + // 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); } 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 3c1c719b70c..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 @@ -345,17 +345,15 @@ class AppStartMetricsTestApi35 { val app = ApplicationProvider.getApplicationContext() metrics.registerLifecycleCallbacks(app) - // Background spawn detected: not launched in foreground, cold type from ApplicationStartInfo. assertFalse(metrics.isAppLaunchedInForeground) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - // User opens the app 20s later (well under the 1-minute warm threshold that previously was the - // only way to catch this). + // User opens the app 20s later (under the 1-minute warm threshold). val activityCreatedUptimeMs = 20_000L SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs) metrics.onActivityCreated(mock(), null) - // The inflated cold start is re-classified as a warm start re-anchored at activity creation. + // 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) From 4ead0d6cbbfa6082bb7841c9032b0bb2e2548024 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 28 Jul 2026 09:46:46 +0200 Subject: [PATCH 4/4] docs: trim changelog entry --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdba4624676..ce86ff1e58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,7 @@ ### Fixes -- Prevent inflated app start when the process is spawned in the background (API 35+) ([#5841](https://github.com/getsentry/sentry-java/pull/5841)) - - When the OS starts the process for background work (e.g. an FCM push, job or service) and the user opens the app later, the app start stayed anchored at background process creation, so the first activity reported the whole idle gap as an inflated cold start. Background process starts (`ApplicationStartInfo.getReason()` of `push`, `job`, `service`, `broadcast`, `alarm`, `backup`, `boot_complete`, `content_provider`) are now marked as not launched in foreground, so the first activity re-classifies them as a warm start anchored at activity creation instead. +- 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))