Skip to content

Commit 6659eb8

Browse files
committed
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.
1 parent 3dd4c87 commit 6659eb8

3 files changed

Lines changed: 134 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### Fixes
1010

11+
- Prevent inflated app start when the process is spawned in the background (API 35+) ([#5843](https://github.com/getsentry/sentry-java/pull/5843))
12+
- 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.
1113
- Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808))
1214
- Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607))
1315

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,30 @@ public void setAppStartType(final @NotNull AppStartType appStartType) {
208208
}
209209
}
210210

211+
/**
212+
* Whether the given {@link ApplicationStartInfo#getReason()} indicates the OS started the process
213+
* for background work (e.g. a push message, job or service) rather than a user-initiated launch.
214+
*
215+
* <p>Unknown/future reasons are treated as user-initiated to avoid discarding valid app starts.
216+
*/
217+
private static boolean isBackgroundStartReason(final int reason) {
218+
switch (reason) {
219+
case ApplicationStartInfo.START_REASON_ALARM:
220+
case ApplicationStartInfo.START_REASON_BACKUP:
221+
case ApplicationStartInfo.START_REASON_BOOT_COMPLETE:
222+
case ApplicationStartInfo.START_REASON_BROADCAST:
223+
case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER:
224+
case ApplicationStartInfo.START_REASON_JOB:
225+
case ApplicationStartInfo.START_REASON_PUSH:
226+
case ApplicationStartInfo.START_REASON_SERVICE:
227+
return true;
228+
// START_REASON_LAUNCHER, START_REASON_LAUNCHER_RECENTS, START_REASON_START_ACTIVITY,
229+
// START_REASON_OTHER and any future reason are considered user-initiated.
230+
default:
231+
return false;
232+
}
233+
}
234+
211235
public boolean isAppLaunchedInForeground() {
212236
return appLaunchedInForeground.getValue();
213237
}
@@ -500,6 +524,16 @@ public void registerLifecycleCallbacks(final @NotNull Application application) {
500524
appStartType = AppStartType.WARM;
501525
}
502526
}
527+
// If the OS spawned the process for background work (push/job/service/broadcast/...)
528+
// rather than a user launch, the app start stays anchored at background process
529+
// creation. Without this, the first activity created once the user finally opens the
530+
// app would report the whole idle gap as an inflated cold start. Marking the launch as
531+
// not-in-foreground makes onActivityCreated re-classify it as a warm start anchored at
532+
// activity creation instead. On API 35+ this replaces the main-looper idle check, which
533+
// is skipped here once the start type is known.
534+
if (isBackgroundStartReason(info.getReason())) {
535+
appLaunchedInForeground.setValue(false);
536+
}
503537
}
504538
} catch (RuntimeException ignored) {
505539
// getHistoricalProcessStartReasons may throw different kinds of exceptions, namely:

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package io.sentry.android.core.performance
22

3+
import android.app.Activity
34
import android.app.ActivityManager.RunningAppProcessInfo
45
import android.app.Application
56
import android.app.ApplicationStartInfo
67
import android.os.Build
78
import android.os.Handler
89
import android.os.Looper
10+
import android.os.SystemClock
911
import androidx.test.core.app.ApplicationProvider
1012
import androidx.test.ext.junit.runners.AndroidJUnit4
1113
import io.sentry.android.core.SentryShadowActivityManager
@@ -16,6 +18,7 @@ import kotlin.test.Test
1618
import kotlin.test.assertEquals
1719
import kotlin.test.assertFalse
1820
import kotlin.test.assertNull
21+
import kotlin.test.assertTrue
1922
import org.junit.Before
2023
import org.junit.runner.RunWith
2124
import org.mockito.kotlin.mock
@@ -263,6 +266,101 @@ class AppStartMetricsTestApi35 {
263266
assertNull(metrics.appStartReason)
264267
}
265268

269+
@Test
270+
fun `background start reason marks app as not launched in foreground`() {
271+
val mockStartInfo = mock<ApplicationStartInfo>()
272+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
273+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
274+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH)
275+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
276+
val metrics = AppStartMetrics.getInstance()
277+
278+
val app = ApplicationProvider.getApplicationContext<Application>()
279+
metrics.registerLifecycleCallbacks(app)
280+
281+
assertFalse(metrics.isAppLaunchedInForeground)
282+
}
283+
284+
@Test
285+
fun `all background start reasons mark app as not launched in foreground`() {
286+
val backgroundReasons =
287+
listOf(
288+
ApplicationStartInfo.START_REASON_ALARM,
289+
ApplicationStartInfo.START_REASON_BACKUP,
290+
ApplicationStartInfo.START_REASON_BOOT_COMPLETE,
291+
ApplicationStartInfo.START_REASON_BROADCAST,
292+
ApplicationStartInfo.START_REASON_CONTENT_PROVIDER,
293+
ApplicationStartInfo.START_REASON_JOB,
294+
ApplicationStartInfo.START_REASON_PUSH,
295+
ApplicationStartInfo.START_REASON_SERVICE,
296+
)
297+
298+
val app = ApplicationProvider.getApplicationContext<Application>()
299+
for (reason in backgroundReasons) {
300+
AppStartMetrics.getInstance().clear()
301+
SentryShadowActivityManager.reset()
302+
303+
val mockStartInfo = mock<ApplicationStartInfo>()
304+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
305+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
306+
whenever(mockStartInfo.reason).thenReturn(reason)
307+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
308+
309+
AppStartMetrics.getInstance().registerLifecycleCallbacks(app)
310+
311+
assertFalse(
312+
AppStartMetrics.getInstance().isAppLaunchedInForeground,
313+
"reason $reason should not be launched in foreground",
314+
)
315+
}
316+
}
317+
318+
@Test
319+
fun `user-initiated start reason keeps app launched in foreground`() {
320+
val mockStartInfo = mock<ApplicationStartInfo>()
321+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
322+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
323+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER)
324+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
325+
SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_FOREGROUND)
326+
val metrics = AppStartMetrics.getInstance()
327+
328+
val app = ApplicationProvider.getApplicationContext<Application>()
329+
metrics.registerLifecycleCallbacks(app)
330+
331+
assertTrue(metrics.isAppLaunchedInForeground)
332+
}
333+
334+
@Test
335+
fun `background-spawned start is re-classified as warm on the first activity`() {
336+
val mockStartInfo = mock<ApplicationStartInfo>()
337+
whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED)
338+
whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD)
339+
whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_PUSH)
340+
SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))
341+
val metrics = AppStartMetrics.getInstance()
342+
// App start span anchored at background process creation.
343+
metrics.appStartTimeSpan.setStartedAt(42)
344+
345+
val app = ApplicationProvider.getApplicationContext<Application>()
346+
metrics.registerLifecycleCallbacks(app)
347+
348+
// Background spawn detected: not launched in foreground, cold type from ApplicationStartInfo.
349+
assertFalse(metrics.isAppLaunchedInForeground)
350+
assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType)
351+
352+
// User opens the app 20s later (well under the 1-minute warm threshold that previously was the
353+
// only way to catch this).
354+
val activityCreatedUptimeMs = 20_000L
355+
SystemClock.setCurrentTimeMillis(activityCreatedUptimeMs)
356+
metrics.onActivityCreated(mock<Activity>(), null)
357+
358+
// The inflated cold start is re-classified as a warm start re-anchored at activity creation.
359+
assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType)
360+
assertTrue(metrics.isAppLaunchedInForeground)
361+
assertEquals(activityCreatedUptimeMs, metrics.appStartTimeSpan.startUptimeMs)
362+
}
363+
266364
private fun waitForMainLooperIdle() {
267365
Handler(Looper.getMainLooper()).post {}
268366
Shadows.shadowOf(Looper.getMainLooper()).idle()

0 commit comments

Comments
 (0)