Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### 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 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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -263,6 +266,99 @@ class AppStartMetricsTestApi35 {
assertNull(metrics.appStartReason)
}

@Test
fun `background start reason marks app as not launched in foreground`() {
val mockStartInfo = mock<ApplicationStartInfo>()
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<Application>()
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<Application>()
for (reason in backgroundReasons) {
AppStartMetrics.getInstance().clear()
SentryShadowActivityManager.reset()

val mockStartInfo = mock<ApplicationStartInfo>()
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<ApplicationStartInfo>()
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<Application>()
metrics.registerLifecycleCallbacks(app)

assertTrue(metrics.isAppLaunchedInForeground)
}

@Test
fun `background-spawned start is re-classified as warm on the first activity`() {
val mockStartInfo = mock<ApplicationStartInfo>()
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<Application>()
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<Activity>(), 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()
Expand Down
Loading