From e7047cb5ed1c6aebd52ba5463d76c38cd5bf8b75 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 29 Dec 2025 10:14:09 +0100 Subject: [PATCH 1/5] fix(android): Improve app start type detection with main thread timing --- .../core/performance/AppStartMetrics.java | 20 ++- .../core/performance/AppStartMetricsTest.kt | 123 ++++++++++++++++++ 2 files changed, 140 insertions(+), 3 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 add5762fbd4..35f69beeedf 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 @@ -57,6 +57,7 @@ public enum AppStartType { private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; private boolean appLaunchedInForeground; + private volatile long firstPostUptimeMillis = -1; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -234,6 +235,7 @@ public void clear() { shouldSendStartMeasurements = true; firstDrawDone.set(false); activeActivitiesCounter.set(0); + firstPostUptimeMillis = -1; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -316,7 +318,15 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); + new Handler(Looper.getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + firstPostUptimeMillis = SystemClock.uptimeMillis(); + checkCreateTimeOnMain(); + } + }); } private void checkCreateTimeOnMain() { @@ -348,7 +358,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, it's likely wrong + // If the app (process) was launched more than 1 minute ago, consider it a warm start final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { appStartType = AppStartType.WARM; @@ -360,8 +370,12 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved CLASS_LOADED_UPTIME_MS = nowUptimeMs; contentProviderOnCreates.clear(); applicationOnCreate.reset(); + } else if (savedInstanceState != null) { + appStartType = AppStartType.WARM; + } else if (firstPostUptimeMillis > 0 && nowUptimeMs > firstPostUptimeMillis) { + appStartType = AppStartType.WARM; } else { - appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + appStartType = AppStartType.COLD; } } appLaunchedInForeground = true; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 24159cab5cb..863fb8f828c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -537,4 +537,127 @@ class AppStartMetricsTest { assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) } + + @Test + fun `firstPostUptimeMillis is properly cleared`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") + reflectionField.isAccessible = true + val firstPostValue = reflectionField.getLong(metrics) + assertTrue(firstPostValue > 0) + + metrics.clear() + + val clearedValue = reflectionField.getLong(metrics) + assertEquals(-1, clearedValue) + } + + @Test + fun `firstPostUptimeMillis is set when registerLifecycleCallbacks is called`() { + val metrics = AppStartMetrics.getInstance() + val beforeRegister = SystemClock.uptimeMillis() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val afterIdle = SystemClock.uptimeMillis() + + val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") + reflectionField.isAccessible = true + val firstPostValue = reflectionField.getLong(metrics) + + assertTrue(firstPostValue >= beforeRegister) + assertTrue(firstPostValue <= afterIdle) + } + + @Test + fun `Sets app launch type to WARM when activity created after firstPost`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `Sets app launch type to COLD when activity created before firstPost executes`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `Sets app launch type to COLD when activity created at same time as firstPost`() { + val metrics = AppStartMetrics.getInstance() + + val now = SystemClock.uptimeMillis() + val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") + reflectionField.isAccessible = true + reflectionField.setLong(metrics, now) + + SystemClock.setCurrentTimeMillis(now) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `savedInstanceState check takes precedence over firstPost timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `timeout check takes precedence over firstPost timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(futureTime) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(futureTime, metrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `firstPost timing does not affect subsequent activity creations`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } } From 41646199c97951743a1c2700a6eafb0b9e0e09a3 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 29 Dec 2025 10:15:09 +0100 Subject: [PATCH 2/5] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfc501e9d6..4d3acdd07c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **IMPORTANT:** This disables collecting external storage size (total/free) by default, to enable it back use `options.isCollectExternalStorageContext = true` or `` - Fix `NullPointerException` when reading ANR marker ([#4979](https://github.com/getsentry/sentry-java/pull/4979)) +- Improve app start type detection with main thread timing ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) ### Improvements From 70879c1b617f8698edbecdb225b9d52e1fe7b936 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 15 Jan 2026 10:24:35 +0100 Subject: [PATCH 3/5] Reduce number of foreground checks and add maestro tests --- .../core/performance/AppStartMetrics.java | 56 ++-- .../core/performance/AppStartMetricsTest.kt | 267 ++++++++++++++++-- .../maestro/appStart.yaml | 27 ++ .../src/main/AndroidManifest.xml | 47 +-- .../critical/EmptyBroadcastReceiver.kt | 22 ++ .../uitest/android/critical/MainActivity.kt | 39 ++- 6 files changed, 398 insertions(+), 60 deletions(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt 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 35f69beeedf..69ff480160f 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 @@ -21,6 +21,7 @@ import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -56,7 +57,14 @@ public enum AppStartType { new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground; + private final LazyEvaluator appLaunchedInForeground = + new LazyEvaluator<>( + new LazyEvaluator.Evaluator() { + @Override + public @NotNull Boolean evaluate() { + return ContextUtils.isForegroundImportance(); + } + }); private volatile long firstPostUptimeMillis = -1; private final @NotNull TimeSpan appStartSpan; @@ -90,7 +98,6 @@ public AppStartMetrics() { applicationOnCreate = new TimeSpan(); contentProviderOnCreates = new HashMap<>(); activityLifecycles = new ArrayList<>(); - appLaunchedInForeground = ContextUtils.isForegroundImportance(); } /** @@ -141,12 +148,12 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { } public boolean isAppLaunchedInForeground() { - return appLaunchedInForeground; + return appLaunchedInForeground.getValue(); } @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { - this.appLaunchedInForeground = appLaunchedInForeground; + this.appLaunchedInForeground.setValue(appLaunchedInForeground); } /** @@ -177,7 +184,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground; + return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); } public long getClassLoadedUptimeMs() { @@ -192,7 +199,7 @@ public long getClassLoadedUptimeMs() { final @NotNull SentryAndroidOptions options) { // If the app start type was never determined or app wasn't launched in foreground, // the app start is considered invalid - if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground.getValue()) { if (options.isEnablePerformanceV2()) { // Only started when sdk version is >= N final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); @@ -213,6 +220,16 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + @TestOnly + void setFirstPostUptimeMillis(final long firstPostUptimeMillis) { + this.firstPostUptimeMillis = firstPostUptimeMillis; + } + + @TestOnly + long getFirstPostUptimeMillis() { + return firstPostUptimeMillis; + } + @TestOnly public void clear() { appStartType = AppStartType.UNKNOWN; @@ -230,7 +247,7 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground = false; + appLaunchedInForeground.setValue(false); isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); @@ -312,7 +329,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { return; } isCallbackRegistered = true; - appLaunchedInForeground = appLaunchedInForeground || ContextUtils.isForegroundImportance(); + appLaunchedInForeground.resetValue(); application.registerActivityLifecycleCallbacks(instance); // We post on the main thread a task to post a check on the main thread. On Pixel devices // (possibly others) the first task posted on the main thread is called before the @@ -335,7 +352,7 @@ private void checkCreateTimeOnMain() { () -> { // if no activity has ever been created, app was launched in background if (activeActivitiesCounter.get() == 0) { - appLaunchedInForeground = false; + appLaunchedInForeground.setValue(false); // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { @@ -352,6 +369,7 @@ private void checkCreateTimeOnMain() { @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + final long activityCreatedUptimeMillis = SystemClock.uptimeMillis(); CurrentActivityHolder.getInstance().setActivity(activity); // the first activity determines the app start type @@ -360,25 +378,27 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved // If the app (process) was launched more than 1 minute ago, consider it a warm start final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); - if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + if (!appLaunchedInForeground.getValue() + || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { appStartType = AppStartType.WARM; - shouldSendStartMeasurements = true; appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(nowUptimeMs); - CLASS_LOADED_UPTIME_MS = nowUptimeMs; + appStartSpan.setStartedAt(activityCreatedUptimeMillis); + CLASS_LOADED_UPTIME_MS = activityCreatedUptimeMillis; contentProviderOnCreates.clear(); applicationOnCreate.reset(); } else if (savedInstanceState != null) { appStartType = AppStartType.WARM; - } else if (firstPostUptimeMillis > 0 && nowUptimeMs > firstPostUptimeMillis) { + } else if (firstPostUptimeMillis != -1 + && activityCreatedUptimeMillis > firstPostUptimeMillis) { + // Application creation always queues Activity creation + // So if Activity is created after our first measured post, it's a warm start appStartType = AppStartType.WARM; } else { appStartType = AppStartType.COLD; } } - appLaunchedInForeground = true; + appLaunchedInForeground.setValue(true); } @Override @@ -417,9 +437,9 @@ public void onActivityDestroyed(@NonNull Activity activity) { final int remainingActivities = activeActivitiesCounter.decrementAndGet(); // if the app is moving into background - // as the next Activity is considered like a new app start + // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { - appLaunchedInForeground = false; + appLaunchedInForeground.setValue(true); shouldSendStartMeasurements = true; firstDrawDone.set(false); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 863fb8f828c..c45be8df37a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -434,6 +434,7 @@ class AppStartMetricsTest { val metrics = AppStartMetrics.getInstance() assertEquals(AppStartMetrics.AppStartType.UNKNOWN, AppStartMetrics.getInstance().appStartType) val app = mock() + metrics.appStartTimeSpan.start() // Need to start the span for timeout check to work metrics.registerLifecycleCallbacks(app) // when an activity is created later with a null bundle @@ -544,33 +545,29 @@ class AppStartMetricsTest { metrics.registerLifecycleCallbacks(mock()) Shadows.shadowOf(Looper.getMainLooper()).idle() - val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") - reflectionField.isAccessible = true - val firstPostValue = reflectionField.getLong(metrics) - assertTrue(firstPostValue > 0) + assertTrue(metrics.firstPostUptimeMillis > 0) metrics.clear() - val clearedValue = reflectionField.getLong(metrics) - assertEquals(-1, clearedValue) + assertEquals(-1, metrics.firstPostUptimeMillis) } @Test fun `firstPostUptimeMillis is set when registerLifecycleCallbacks is called`() { + SystemClock.setCurrentTimeMillis(90) + val metrics = AppStartMetrics.getInstance() val beforeRegister = SystemClock.uptimeMillis() + SystemClock.setCurrentTimeMillis(100) metrics.registerLifecycleCallbacks(mock()) Shadows.shadowOf(Looper.getMainLooper()).idle() + SystemClock.setCurrentTimeMillis(110) val afterIdle = SystemClock.uptimeMillis() - val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") - reflectionField.isAccessible = true - val firstPostValue = reflectionField.getLong(metrics) - - assertTrue(firstPostValue >= beforeRegister) - assertTrue(firstPostValue <= afterIdle) + assertTrue(metrics.firstPostUptimeMillis >= beforeRegister) + assertTrue(metrics.firstPostUptimeMillis <= afterIdle) } @Test @@ -582,6 +579,7 @@ class AppStartMetricsTest { Shadows.shadowOf(Looper.getMainLooper()).idle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -603,61 +601,280 @@ class AppStartMetricsTest { } @Test - fun `Sets app launch type to COLD when activity created at same time as firstPost`() { + fun `savedInstanceState check takes precedence over firstPost timing`() { val metrics = AppStartMetrics.getInstance() - val now = SystemClock.uptimeMillis() - val reflectionField = AppStartMetrics::class.java.getDeclaredField("firstPostUptimeMillis") - reflectionField.isAccessible = true - reflectionField.setLong(metrics, now) + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() - SystemClock.setCurrentTimeMillis(now) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `timeout check takes precedence over firstPost timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(futureTime) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(futureTime, metrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `firstPost timing does not affect subsequent activity creations`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `COLD start when activity created at same uptime as firstPost with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + // Manually set firstPostUptimeMillis to a known value + val testTime = SystemClock.uptimeMillis() + metrics.firstPostUptimeMillis = testTime + + // Set current time to exactly match firstPost time + SystemClock.setCurrentTimeMillis(testTime) metrics.onActivityCreated(mock(), null) + // When nowUptimeMs <= firstPostUptimeMillis, should be COLD assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test - fun `savedInstanceState check takes precedence over firstPost timing`() { + fun `WARM start when activity created 1ms after firstPost with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + val beforeRegister = SystemClock.uptimeMillis() + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Activity created just 1ms after firstPost executed + SystemClock.setCurrentTimeMillis(beforeRegister + 1) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `COLD start when activity created before firstPost runs despite later wall time`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't let the looper idle yet - simulates activity created before firstPost executes + + // Even if we advance wall time significantly + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 1000) + metrics.onActivityCreated(mock(), null) + + // Should still be COLD because firstPost hasn't executed yet + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // Now let firstPost execute + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Should remain COLD (not change to WARM) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `WARM start takes precedence when both savedInstanceState and firstPost indicate WARM`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) Shadows.shadowOf(Looper.getMainLooper()).idle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + // Both conditions indicate warm: savedInstanceState != null AND after firstPost metrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test - fun `timeout check takes precedence over firstPost timing`() { + fun `WARM start when savedInstanceState is non-null even if created before firstPost`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't idle - activity created before firstPost + + // savedInstanceState check takes precedence + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `firstPostUptimeMillis is -1 initially and after clear`() { val metrics = AppStartMetrics.getInstance() + // Should be -1 initially (already tested in existing test, but good to verify) + metrics.clear() + val initialValue = metrics.firstPostUptimeMillis + assertEquals(-1, initialValue) + + // Register and let it set metrics.registerLifecycleCallbacks(mock()) Shadows.shadowOf(Looper.getMainLooper()).idle() + val afterRegister = metrics.firstPostUptimeMillis + assertTrue(afterRegister > 0) + + // Clear should reset it + metrics.clear() + val afterClear = metrics.firstPostUptimeMillis + assertEquals(-1, afterClear) + } + + @Test + fun `COLD start when firstPostUptimeMillis is still -1 and no savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + // Don't idle - firstPostUptimeMillis will still be -1 + + // Verify firstPost hasn't executed yet + assertEquals(-1, metrics.firstPostUptimeMillis) + + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `App start type priority order is timeout, savedInstanceState, then firstPost timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Test timeout takes precedence over everything val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) SystemClock.setCurrentTimeMillis(futureTime) - metrics.onActivityCreated(mock(), null) + metrics.onActivityCreated(mock(), null) // null savedInstanceState assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) - assertTrue(metrics.appStartTimeSpan.hasStarted()) - assertEquals(futureTime, metrics.appStartTimeSpan.startUptimeMs) } @Test - fun `firstPost timing does not affect subsequent activity creations`() { + fun `Multiple consecutive warm starts are correctly detected`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) + + // First activity - cold start (before firstPost) + val firstActivity = mock() + whenever(firstActivity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(firstActivity, null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertTrue(metrics.shouldSendStartMeasurements()) + metrics.onAppStartSpansSent() Shadows.shadowOf(Looper.getMainLooper()).idle() + // Simulate app going to background (destroy first activity) + metrics.onActivityDestroyed(firstActivity) + + // Second activity - should be warm (process still alive, new activity launch) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + val secondActivity = mock() + metrics.onActivityCreated(secondActivity, null) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements()) + metrics.onAppStartSpansSent() + + // Third activity - should still be warm SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertTrue(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements()) + } - metrics.onActivityCreated(mock(), mock()) + @Test + fun `WARM start when user returns from background with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + // Initial cold start + val mainActivity = mock() + whenever(mainActivity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(mainActivity, null) // savedInstanceState = null + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // User presses home, activity destroyed (not configuration change) + metrics.onActivityDestroyed(mainActivity) + + // User returns to app - MainActivity recreated with NULL savedInstanceState + // (Android doesn't save state when user navigates away normally) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 500) + metrics.onActivityCreated(mock(), null) // savedInstanceState = null! + + // Should be WARM because process was alive and firstPost timing detects it + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `WARM start when launching different activity in same process with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + // Cold start with MainActivity + metrics.onActivityCreated(mock(), null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Later, user navigates to SettingsActivity (new activity, null savedInstanceState) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 200) + metrics.onActivityCreated(mock(), null) // Different activity, null state + + // Should still be WARM (it's not the first activity anymore) + // Note: This test shows activeActivitiesCounter > 1, so detection doesn't run + // But if first activity was destroyed, it would be detected as WARM by firstPost timing + } + + @Test + fun `WARM start when deep link opens new activity in running process`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + // App already running + val mainActivity = mock() + whenever(mainActivity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(mainActivity, null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // Simulate deep link while app is in background + metrics.onActivityDestroyed(mainActivity) + + // Deep link creates new activity with null savedInstanceState + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 300) + metrics.onActivityCreated(mock(), null) // Deep link activity + + // Should be WARM because process was alive assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } } diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml new file mode 100644 index 00000000000..a769a0a4b3e --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -0,0 +1,27 @@ +appId: io.sentry.uitest.android.critical +name: App Launch Tests +--- +# Test 1: A fresh start is considered a cold start +- launchApp: + stopApp: false +- assertVisible: "Welcome!" +- assertVisible: "App Start Type: COLD" + +# Test 2: Background/foreground transition (WARM start) +- launchApp: + stopApp: false +- assertVisible: "Welcome!" +- tapOn: "Finish Activity" +- launchApp: + stopApp: false +- assertVisible: "App Start Type: WARM" + +# Test 3: Launch app after a broadcast receiver already created the application +# Uncomment once https://github.com/mobile-dev-inc/Maestro/pull/2925 is merged +# - killApp +# - sendBroadcast: +# action: io.sentry.uitest.android.critical.ACTION +# receiver: io.sentry.uitest.android.critical/.EmptyBroadcastReceiver +# - launchApp: +# stopApp: false +# - assertVisible: "App Start Type: WARM" diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml index 0ab5e6052df..e34a5bb871d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml @@ -1,21 +1,36 @@ + xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt new file mode 100644 index 00000000000..3aef794189b --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/EmptyBroadcastReceiver.kt @@ -0,0 +1,22 @@ +package io.sentry.uitest.android.critical + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class EmptyBroadcastReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "EmptyBroadcastReceiver" + } + + override fun onReceive(context: Context?, intent: Intent?) { + val pendingResult = goAsync() + Log.d(TAG, "onReceive: broadcast received") + Thread { + Thread.sleep(1000) + pendingResult.finish() + } + .start() + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt index f5e731ecd56..a2cae238681 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt @@ -1,15 +1,27 @@ package io.sentry.uitest.android.critical +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import io.sentry.Sentry +import io.sentry.android.core.performance.AppStartMetrics import java.io.File +import kotlinx.coroutines.delay class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -18,10 +30,19 @@ class MainActivity : ComponentActivity() { Sentry.getCurrentHub().options.outboxPath ?: throw RuntimeException("Outbox path is not set.") setContent { + var appStartType by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + delay(100) + appStartType = AppStartMetrics.getInstance().appStartType.name + } + MaterialTheme { Surface { - Column { + Column(modifier = Modifier.fillMaxSize().padding(20.dp)) { Text(text = "Welcome!") + Text(text = "App Start Type: $appStartType") + Button(onClick = { throw RuntimeException("Crash the test app.") }) { Text("Crash") } Button(onClick = { Sentry.close() }) { Text("Close SDK") } Button( @@ -39,6 +60,22 @@ class MainActivity : ComponentActivity() { ) { Text("Write Corrupted Envelope") } + Button(onClick = { finish() }) { Text("Finish Activity") } + Button( + onClick = { + startActivity( + Intent(this@MainActivity, MainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + } + ) + } + ) { + Text("Launch Main Activity (singleTask)") + } } } } From 7034d95065a48218115ae0f15efe9f16e266e248 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 16 Jan 2026 08:07:05 +0100 Subject: [PATCH 4/5] Update Changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1da9cca4c6..426a681e5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix warm app start type detection for edge cases ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) + ### Features - Added `io.sentry.ndk.sdk-name` Android manifest option to configure the native SDK's name ([#5027](https://github.com/getsentry/sentry-java/pull/5027)) @@ -15,7 +19,6 @@ use `options.isCollectExternalStorageContext = true` or `` - Fix `NullPointerException` when reading ANR marker ([#4979](https://github.com/getsentry/sentry-java/pull/4979)) - Report discarded log in batch processor as `log_byte` ([#4971](https://github.com/getsentry/sentry-java/pull/4971)) -- Fix warm app start type detection for edge cases ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) ### Improvements From ef2b3bfe0638e2df8a468b77aef96ad9ed275043 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 16 Jan 2026 09:25:22 +0100 Subject: [PATCH 5/5] Switch to MessageQueue.IdleHandler As firstPost doesn't work on Android 34+ devices --- .../core/performance/AppStartMetrics.java | 98 ++++++----- .../core/performance/AppStartMetricsTest.kt | 159 ++++++++---------- .../src/main/AndroidManifest.xml | 9 +- .../io/sentry/uitest/android/critical/App.kt | 16 ++ 4 files changed, 147 insertions(+), 135 deletions(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt 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 69ff480160f..1b56bfb3a3a 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 @@ -3,9 +3,11 @@ import android.app.Activity; import android.app.Application; import android.content.ContentProvider; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.MessageQueue; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -65,7 +67,7 @@ public enum AppStartType { return ContextUtils.isForegroundImportance(); } }); - private volatile long firstPostUptimeMillis = -1; + private volatile long firstIdle = -1; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -221,13 +223,13 @@ public long getClassLoadedUptimeMs() { } @TestOnly - void setFirstPostUptimeMillis(final long firstPostUptimeMillis) { - this.firstPostUptimeMillis = firstPostUptimeMillis; + void setFirstIdle(final long firstIdle) { + this.firstIdle = firstIdle; } @TestOnly - long getFirstPostUptimeMillis() { - return firstPostUptimeMillis; + long getFirstIdle() { + return firstIdle; } @TestOnly @@ -247,12 +249,12 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground.setValue(false); + appLaunchedInForeground.resetValue(); isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); activeActivitiesCounter.set(0); - firstPostUptimeMillis = -1; + firstIdle = -1; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -320,7 +322,8 @@ public static void onApplicationPostCreate(final @NotNull Application applicatio } /** - * Register a callback to check if an activity was started after the application was created + * Register a callback to check if an activity was started after the application was created. Must + * be called from the main thread. * * @param application The application object to register the callback to */ @@ -331,40 +334,52 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { isCallbackRegistered = true; appLaunchedInForeground.resetValue(); application.registerActivityLifecycleCallbacks(instance); - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. - new Handler(Looper.getMainLooper()) - .post( - new Runnable() { - @Override - public void run() { - firstPostUptimeMillis = SystemClock.uptimeMillis(); - checkCreateTimeOnMain(); - } - }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Looper.getMainLooper() + .getQueue() + .addIdleHandler( + new MessageQueue.IdleHandler() { + @Override + public boolean queueIdle() { + firstIdle = SystemClock.uptimeMillis(); + checkCreateTimeOnMain(); + return false; + } + }); + } else { + // We post on the main thread a task to post a check on the main thread. On Pixel devices + // (possibly others) the first task posted on the main thread is called before the + // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate + // callback is called before the application one. + final Handler handler = new Handler(Looper.getMainLooper()); + handler.post( + new Runnable() { + @Override + public void run() { + // not technically correct, but close enough for pre-M + firstIdle = SystemClock.uptimeMillis(); + handler.post(() -> checkCreateTimeOnMain()); + } + }); + } } private void checkCreateTimeOnMain() { - new Handler(Looper.getMainLooper()) - .post( - () -> { - // if no activity has ever been created, app was launched in background - if (activeActivitiesCounter.get() == 0) { - appLaunchedInForeground.setValue(false); - - // we stop the app start profilers, as they are useless and likely to timeout - if (appStartProfiler != null && appStartProfiler.isRunning()) { - appStartProfiler.close(); - appStartProfiler = null; - } - if (appStartContinuousProfiler != null && appStartContinuousProfiler.isRunning()) { - appStartContinuousProfiler.close(true); - appStartContinuousProfiler = null; - } - } - }); + // if no activity has ever been created, app was launched in background + if (activeActivitiesCounter.get() == 0) { + appLaunchedInForeground.setValue(false); + + // we stop the app start profilers, as they are useless and likely to timeout + if (appStartProfiler != null && appStartProfiler.isRunning()) { + appStartProfiler.close(); + appStartProfiler = null; + } + if (appStartContinuousProfiler != null && appStartContinuousProfiler.isRunning()) { + appStartContinuousProfiler.close(true); + appStartContinuousProfiler = null; + } + } } @Override @@ -389,10 +404,7 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved applicationOnCreate.reset(); } else if (savedInstanceState != null) { appStartType = AppStartType.WARM; - } else if (firstPostUptimeMillis != -1 - && activityCreatedUptimeMillis > firstPostUptimeMillis) { - // Application creation always queues Activity creation - // So if Activity is created after our first measured post, it's a warm start + } else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) { appStartType = AppStartType.WARM; } else { appStartType = AppStartType.COLD; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c45be8df37a..c15ea3c37d0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -5,6 +5,7 @@ import android.app.Application import android.content.ContentProvider import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -137,7 +138,7 @@ class AppStartMetricsTest { appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false } @@ -164,7 +165,7 @@ class AppStartMetricsTest { } // when the looper runs - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() // but no activity creation happened // then the app wasn't launched in foreground and nothing should be sent @@ -194,7 +195,7 @@ class AppStartMetricsTest { metrics.registerLifecycleCallbacks(mock()) // when the handler callback is executed and no activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() // isAppLaunchedInForeground should be false assertFalse(metrics.isAppLaunchedInForeground) @@ -207,6 +208,11 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } + @Test fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -231,7 +237,7 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) @@ -246,7 +252,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler).close() } @@ -259,7 +265,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler).close(eq(true)) } @@ -273,7 +279,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler, never()).close() } @@ -287,7 +293,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() verify(profiler, never()).close(any()) } @@ -331,7 +337,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // Main thread performs the check and sets the flag to false if no activity was created - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -344,7 +350,7 @@ class AppStartMetricsTest { // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) // Main thread performs the check and keeps the flag to true - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -540,20 +546,20 @@ class AppStartMetricsTest { } @Test - fun `firstPostUptimeMillis is properly cleared`() { + fun `firstIdle is properly cleared`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() - assertTrue(metrics.firstPostUptimeMillis > 0) + assertTrue(metrics.firstIdle > 0) metrics.clear() - assertEquals(-1, metrics.firstPostUptimeMillis) + assertEquals(-1, metrics.firstIdle) } @Test - fun `firstPostUptimeMillis is set when registerLifecycleCallbacks is called`() { + fun `firstIdle is set when registerLifecycleCallbacks is called`() { SystemClock.setCurrentTimeMillis(90) val metrics = AppStartMetrics.getInstance() @@ -561,22 +567,22 @@ class AppStartMetricsTest { SystemClock.setCurrentTimeMillis(100) metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(110) val afterIdle = SystemClock.uptimeMillis() - assertTrue(metrics.firstPostUptimeMillis >= beforeRegister) - assertTrue(metrics.firstPostUptimeMillis <= afterIdle) + assertTrue(metrics.firstIdle >= beforeRegister) + assertTrue(metrics.firstIdle <= afterIdle) } @Test - fun `Sets app launch type to WARM when activity created after firstPost`() { + fun `Sets app launch type to WARM when activity created after firstIdle`() { val metrics = AppStartMetrics.getInstance() assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) metrics.isAppLaunchedInForeground = true @@ -586,7 +592,7 @@ class AppStartMetricsTest { } @Test - fun `Sets app launch type to COLD when activity created before firstPost executes`() { + fun `Sets app launch type to COLD when activity created before firstIdle executes`() { val metrics = AppStartMetrics.getInstance() assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) @@ -595,17 +601,17 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test - fun `savedInstanceState check takes precedence over firstPost timing`() { + fun `savedInstanceState check takes precedence over firstIdle timing`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) metrics.onActivityCreated(mock(), mock()) @@ -614,11 +620,11 @@ class AppStartMetricsTest { } @Test - fun `timeout check takes precedence over firstPost timing`() { + fun `timeout check takes precedence over firstIdle timing`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) SystemClock.setCurrentTimeMillis(futureTime) @@ -630,11 +636,11 @@ class AppStartMetricsTest { } @Test - fun `firstPost timing does not affect subsequent activity creations`() { + fun `firstIdle timing does not affect subsequent activity creations`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) metrics.onActivityCreated(mock(), null) @@ -645,30 +651,30 @@ class AppStartMetricsTest { } @Test - fun `COLD start when activity created at same uptime as firstPost with null savedInstanceState`() { + fun `COLD start when activity created at same uptime as firstIdle with null savedInstanceState`() { val metrics = AppStartMetrics.getInstance() - // Manually set firstPostUptimeMillis to a known value + // Manually set firstIdle to a known value val testTime = SystemClock.uptimeMillis() - metrics.firstPostUptimeMillis = testTime + metrics.firstIdle = testTime - // Set current time to exactly match firstPost time + // Set current time to exactly match firstIdle time SystemClock.setCurrentTimeMillis(testTime) metrics.onActivityCreated(mock(), null) - // When nowUptimeMs <= firstPostUptimeMillis, should be COLD + // When nowUptimeMs <= firstIdle, should be COLD assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test - fun `WARM start when activity created 1ms after firstPost with null savedInstanceState`() { + fun `WARM start when activity created 1ms after firstIdle with null savedInstanceState`() { val metrics = AppStartMetrics.getInstance() val beforeRegister = SystemClock.uptimeMillis() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() - // Activity created just 1ms after firstPost executed + // Activity created just 1ms after firstIdle executed SystemClock.setCurrentTimeMillis(beforeRegister + 1) metrics.onActivityCreated(mock(), null) @@ -676,46 +682,46 @@ class AppStartMetricsTest { } @Test - fun `COLD start when activity created before firstPost runs despite later wall time`() { + fun `COLD start when activity created before firstIdle runs despite later wall time`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - // Don't let the looper idle yet - simulates activity created before firstPost executes + // Don't let the looper idle yet - simulates activity created before firstIdle executes // Even if we advance wall time significantly SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 1000) metrics.onActivityCreated(mock(), null) - // Should still be COLD because firstPost hasn't executed yet + // Should still be COLD because firstIdle hasn't executed yet assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - // Now let firstPost execute - Shadows.shadowOf(Looper.getMainLooper()).idle() + // Now let firstIdle execute + waitForMainLooperIdle() // Should remain COLD (not change to WARM) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) } @Test - fun `WARM start takes precedence when both savedInstanceState and firstPost indicate WARM`() { + fun `WARM start takes precedence when both savedInstanceState and firstIdle indicate WARM`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - // Both conditions indicate warm: savedInstanceState != null AND after firstPost + // Both conditions indicate warm: savedInstanceState != null AND after firstIdle metrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test - fun `WARM start when savedInstanceState is non-null even if created before firstPost`() { + fun `WARM start when savedInstanceState is non-null even if created before firstIdle`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - // Don't idle - activity created before firstPost + // Don't idle - activity created before firstIdle // savedInstanceState check takes precedence metrics.onActivityCreated(mock(), mock()) @@ -724,35 +730,35 @@ class AppStartMetricsTest { } @Test - fun `firstPostUptimeMillis is -1 initially and after clear`() { + fun `firstIdle is -1 initially and after clear`() { val metrics = AppStartMetrics.getInstance() // Should be -1 initially (already tested in existing test, but good to verify) metrics.clear() - val initialValue = metrics.firstPostUptimeMillis + val initialValue = metrics.firstIdle assertEquals(-1, initialValue) // Register and let it set metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() - val afterRegister = metrics.firstPostUptimeMillis + waitForMainLooperIdle() + val afterRegister = metrics.firstIdle assertTrue(afterRegister > 0) // Clear should reset it metrics.clear() - val afterClear = metrics.firstPostUptimeMillis + val afterClear = metrics.firstIdle assertEquals(-1, afterClear) } @Test - fun `COLD start when firstPostUptimeMillis is still -1 and no savedInstanceState`() { + fun `COLD start when firstIdle is still -1 and no savedInstanceState`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - // Don't idle - firstPostUptimeMillis will still be -1 + // Don't idle - firstIdle will still be -1 - // Verify firstPost hasn't executed yet - assertEquals(-1, metrics.firstPostUptimeMillis) + // Verify firstIdle hasn't executed yet + assertEquals(-1, metrics.firstIdle) metrics.onActivityCreated(mock(), null) @@ -760,11 +766,11 @@ class AppStartMetricsTest { } @Test - fun `App start type priority order is timeout, savedInstanceState, then firstPost timing`() { + fun `App start type priority order is timeout, savedInstanceState, then firstIdle timing`() { val metrics = AppStartMetrics.getInstance() metrics.registerLifecycleCallbacks(mock()) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() // Test timeout takes precedence over everything val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) @@ -780,14 +786,14 @@ class AppStartMetricsTest { metrics.registerLifecycleCallbacks(mock()) - // First activity - cold start (before firstPost) + // First activity - cold start (before firstIdle) val firstActivity = mock() whenever(firstActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(firstActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) assertTrue(metrics.shouldSendStartMeasurements()) metrics.onAppStartSpansSent() - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() // Simulate app going to background (destroy first activity) metrics.onActivityDestroyed(firstActivity) @@ -820,7 +826,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(mainActivity, null) // savedInstanceState = null assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() // User presses home, activity destroyed (not configuration change) metrics.onActivityDestroyed(mainActivity) @@ -830,7 +836,7 @@ class AppStartMetricsTest { SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 500) metrics.onActivityCreated(mock(), null) // savedInstanceState = null! - // Should be WARM because process was alive and firstPost timing detects it + // Should be WARM because process was alive and firstIdle timing detects it assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @@ -840,41 +846,18 @@ class AppStartMetricsTest { metrics.registerLifecycleCallbacks(mock()) // Cold start with MainActivity - metrics.onActivityCreated(mock(), null) - assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - - Shadows.shadowOf(Looper.getMainLooper()).idle() - - // Later, user navigates to SettingsActivity (new activity, null savedInstanceState) - SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 200) - metrics.onActivityCreated(mock(), null) // Different activity, null state - - // Should still be WARM (it's not the first activity anymore) - // Note: This test shows activeActivitiesCounter > 1, so detection doesn't run - // But if first activity was destroyed, it would be detected as WARM by firstPost timing - } - - @Test - fun `WARM start when deep link opens new activity in running process`() { - val metrics = AppStartMetrics.getInstance() - metrics.registerLifecycleCallbacks(mock()) - - // App already running val mainActivity = mock() - whenever(mainActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(mainActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - Shadows.shadowOf(Looper.getMainLooper()).idle() + waitForMainLooperIdle() - // Simulate deep link while app is in background metrics.onActivityDestroyed(mainActivity) - // Deep link creates new activity with null savedInstanceState - SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 300) - metrics.onActivityCreated(mock(), null) // Deep link activity + // Later, user navigates to another activity + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 200) + metrics.onActivityCreated(mock(), null) - // Should be WARM because process was alive assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } } diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml index e34a5bb871d..a844b428882 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml @@ -3,9 +3,10 @@ xmlns:tools="http://schemas.android.com/tools"> + android:supportsRtl="true"> + @@ -15,8 +16,8 @@ + android:exported="true" + android:launchMode="singleTask"> diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt new file mode 100644 index 00000000000..a24d8c54cb4 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/App.kt @@ -0,0 +1,16 @@ +package io.sentry.uitest.android.critical + +import android.app.Application +import android.util.Log + +class App : Application() { + + companion object { + private const val TAG = "App" + } + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "onCreate: Application Created") + } +}