From e7047cb5ed1c6aebd52ba5463d76c38cd5bf8b75 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 29 Dec 2025 10:14:09 +0100 Subject: [PATCH 01/31] 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 02/31] 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 03/31] 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 04/31] 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 05/31] 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") + } +} From ac3defe307a31a61a210d9f6080cd9ffa8dfd765 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 11:21:52 +0100 Subject: [PATCH 06/31] fix(android): Improve warm start detection with API 35+ support - Add support for ApplicationStartInfo API on Android 15+ (API 35) for more accurate cold/warm start detection - Add null safety check for ActivityManager service - Fix detection logic to prevent legacy methods from running when API 35+ already determined start type - Set app start type to WARM when app moves to background (process stays alive) - Add comprehensive test coverage with notification-triggered start scenarios Co-Authored-By: Claude Sonnet 4.5 --- .../core/performance/AppStartMetrics.java | 41 ++++++++++++--- .../maestro/appStart.yaml | 33 +++++++++++- .../src/main/AndroidManifest.xml | 2 + .../uitest/android/critical/MainActivity.kt | 40 +++++++++++++- .../android/critical/NotificationHelper.kt | 52 +++++++++++++++++++ 5 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.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 1b56bfb3a3a..1bb95b9061a 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 @@ -1,8 +1,11 @@ package io.sentry.android.core.performance; import android.app.Activity; +import android.app.ActivityManager; import android.app.Application; +import android.app.ApplicationStartInfo; import android.content.ContentProvider; +import android.content.Context; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -335,7 +338,25 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { appLaunchedInForeground.resetValue(); application.registerActivityLifecycleCallbacks(instance); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + final @Nullable ActivityManager activityManager = + (ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + final List historicalProcessStartReasons = + activityManager.getHistoricalProcessStartReasons(1); + if (!historicalProcessStartReasons.isEmpty()) { + final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { + if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + appStartType = AppStartType.COLD; + } else { + appStartType = AppStartType.WARM; + } + } + } + } + + if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( @@ -347,7 +368,7 @@ public boolean queueIdle() { return false; } }); - } else { + } else if (appStartType == AppStartType.UNKNOWN) { // 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 @@ -402,12 +423,15 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved CLASS_LOADED_UPTIME_MS = activityCreatedUptimeMillis; contentProviderOnCreates.clear(); applicationOnCreate.reset(); - } else if (savedInstanceState != null) { - appStartType = AppStartType.WARM; - } else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) { - appStartType = AppStartType.WARM; - } else { - appStartType = AppStartType.COLD; + } else if (appStartType == AppStartType.UNKNOWN) { + // pre API 35 handling + if (savedInstanceState != null) { + appStartType = AppStartType.WARM; + } else if (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) { + appStartType = AppStartType.WARM; + } else { + appStartType = AppStartType.COLD; + } } } appLaunchedInForeground.setValue(true); @@ -451,6 +475,7 @@ public void onActivityDestroyed(@NonNull Activity activity) { // if the app is moving into background // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appStartType = AppStartType.WARM; appLaunchedInForeground.setValue(true); shouldSendStartMeasurements = true; firstDrawDone.set(false); 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 index a769a0a4b3e..09bfd4c9090 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -16,7 +16,38 @@ name: App Launch Tests stopApp: false - assertVisible: "App Start Type: WARM" -# Test 3: Launch app after a broadcast receiver already created the application +# Test 3: Notification (WARM start) +- launchApp: + stopApp: true + permissions: + all: allow +- assertVisible: "Welcome!" +- tapOn: "Trigger Notification" +- tapOn: "Finish Activity" +- assertNotVisible: "Welcome!" +- swipe: + start: 50%, 0% + end: 50%, 50% +- tapOn: "Sentry Test Notification" +- assertVisible: "App Start Type: WARM" + + +# Test 4: Notification (COLD start) +- launchApp: + stopApp: true + permissions: + all: allow +- assertVisible: "Welcome!" +- tapOn: "Trigger Notification" +- killApp +- swipe: + start: 50%, 0% + end: 50%, 50% +- tapOn: "Sentry Test Notification" +- assertVisible: "App Start Type: COLD" + + +# Test 5: 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: 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 a844b428882..d9d9b7f7d1b 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 @@ -2,6 +2,8 @@ + + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(android.R.style.Theme_DeviceDefault_NoActionBar) + super.onCreate(savedInstanceState) val outboxPath = Sentry.getCurrentHub().options.outboxPath ?: throw RuntimeException("Outbox path is not set.") + requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> + if (isGranted) { + // Permission granted, show notification + postNotification() + } else { + // Permission denied, handle accordingly + Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show() + } + } setContent { var appStartType by remember { mutableStateOf("") } @@ -39,7 +58,7 @@ class MainActivity : ComponentActivity() { MaterialTheme { Surface { - Column(modifier = Modifier.fillMaxSize().padding(20.dp)) { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { Text(text = "Welcome!") Text(text = "App Start Type: $appStartType") @@ -61,6 +80,17 @@ class MainActivity : ComponentActivity() { Text("Write Corrupted Envelope") } Button(onClick = { finish() }) { Text("Finish Activity") } + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + postNotification() + } + } + ) { + Text("Trigger Notification") + } Button( onClick = { startActivity( @@ -81,4 +111,12 @@ class MainActivity : ComponentActivity() { } } } + + fun postNotification() { + NotificationHelper.showNotification( + this@MainActivity, + "Sentry Test Notification", + "This is a test notification.", + ) + } } diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.kt new file mode 100644 index 00000000000..4cbaede1b7f --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/NotificationHelper.kt @@ -0,0 +1,52 @@ +package io.sentry.uitest.android.critical + +import android.R +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat + +object NotificationHelper { + + private const val CHANNEL_ID = "channel_id" + private const val NOTIFICATION_ID = 1 + + fun showNotification(context: Context, title: String?, message: String?) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create notification channel for Android 8.0+ (API 26+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel(CHANNEL_ID, "Notifications", NotificationManager.IMPORTANCE_DEFAULT) + channel.description = "description" + notificationManager.createNotificationChannel(channel) + } + + // Intent to open when notification is tapped + val intent = Intent(context, MainActivity::class.java) + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Build the notification + val builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) // Dismiss when tapped + + // Show the notification + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } +} From 46daaf3e750cc64f866fb2c6456360b2d5ac212d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 11:37:23 +0100 Subject: [PATCH 07/31] Trying to fix notification drawer open --- .../sentry-uitest-android-critical/maestro/appStart.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 09bfd4c9090..23ad747b391 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -26,8 +26,8 @@ name: App Launch Tests - tapOn: "Finish Activity" - assertNotVisible: "Welcome!" - swipe: - start: 50%, 0% - end: 50%, 50% + start: 50%, 1% + end: 50%, 90% - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: WARM" @@ -41,8 +41,8 @@ name: App Launch Tests - tapOn: "Trigger Notification" - killApp - swipe: - start: 50%, 0% - end: 50%, 50% + start: 50%, 1% + end: 50%, 90% - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: COLD" From 2e01fc9b4a4130a711d9600b099465b8a9e1a615 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 13:53:46 +0100 Subject: [PATCH 08/31] include maestro debug logs --- .github/workflows/integration-tests-ui-critical.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 28ed9d75a91..2ed9b019fe4 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -144,5 +144,6 @@ jobs: uses: actions/upload-artifact@v6 with: name: maestro-logs + include-hidden-files: true # maestro debug logs are stored within maestro-logs/.maestro/ path: "${{env.BASE_PATH}}/maestro-logs" retention-days: 1 From e4c4fbdc80cab3839b81e12461145fd1087c89e5 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 14:34:34 +0100 Subject: [PATCH 09/31] Enable adb screenrecord --- .github/workflows/integration-tests-ui-critical.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 2ed9b019fe4..19f4536c15a 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,13 +137,24 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" + + adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & + SCREENRECORD_PID=$! + maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" + MAESTRO_EXIT_CODE=$? + + kill $SCREENRECORD_PID 2>/dev/null || true + wait $SCREENRECORD_PID 2>/dev/null || true + adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" + + exit $MAESTRO_EXIT_CODE - name: Upload Maestro test results if: failure() uses: actions/upload-artifact@v6 with: - name: maestro-logs + name: maestro-logs-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} include-hidden-files: true # maestro debug logs are stored within maestro-logs/.maestro/ path: "${{env.BASE_PATH}}/maestro-logs" retention-days: 1 From 81e7e5138bb3b6adb4376a531ea3c8a090888e65 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 14:50:53 +0100 Subject: [PATCH 10/31] capture maestro errors --- .github/workflows/integration-tests-ui-critical.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 19f4536c15a..5c7d88c1d0d 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -141,8 +141,10 @@ jobs: adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & SCREENRECORD_PID=$! + set +e maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" MAESTRO_EXIT_CODE=$? + set -e kill $SCREENRECORD_PID 2>/dev/null || true wait $SCREENRECORD_PID 2>/dev/null || true From 0ac6ce5460e86bae49e678c73bfa6b2e9f6b29fc Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 15:07:45 +0100 Subject: [PATCH 11/31] single-line maestro as test runner executes scripts as individual lines --- .../integration-tests-ui-critical.yml | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 5c7d88c1d0d..603023cf31e 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -138,19 +138,14 @@ jobs: adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & - SCREENRECORD_PID=$! - - set +e - maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" - MAESTRO_EXIT_CODE=$? - set -e - - kill $SCREENRECORD_PID 2>/dev/null || true - wait $SCREENRECORD_PID 2>/dev/null || true - adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" - - exit $MAESTRO_EXIT_CODE + adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & \ + SCREENRECORD_PID=$!; \ + maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; \ + kill $SCREENRECORD_PID 2>/dev/null || true; \ + wait $SCREENRECORD_PID 2>/dev/null || true; \ + adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; \ + adb shell rm /sdcard/recording.mp4 || true; \ + exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: failure() From 71e4a2c1c4eaa3efe734fa3b4443e43b16e1a166 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 15:16:25 +0100 Subject: [PATCH 12/31] put maestro run into single line --- .github/workflows/integration-tests-ui-critical.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 603023cf31e..9147e91e6ce 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,15 +137,7 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - - adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & \ - SCREENRECORD_PID=$!; \ - maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; \ - kill $SCREENRECORD_PID 2>/dev/null || true; \ - wait $SCREENRECORD_PID 2>/dev/null || true; \ - adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; \ - adb shell rm /sdcard/recording.mp4 || true; \ - exit ${MAESTRO_EXIT_CODE:-0} + adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & SCREENRECORD_PID=$!; maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; kill $SCREENRECORD_PID 2>/dev/null || true; wait $SCREENRECORD_PID 2>/dev/null || true; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: failure() From 7cc431fd02afdeb593501361a53af03cf2a7840e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 15:36:44 +0100 Subject: [PATCH 13/31] fix empty screenrecordings --- .github/workflows/integration-tests-ui-critical.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 9147e91e6ce..f420f15f58f 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,8 +137,8 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & SCREENRECORD_PID=$!; maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; kill $SCREENRECORD_PID 2>/dev/null || true; wait $SCREENRECORD_PID 2>/dev/null || true; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} - + adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; adb shell "pkill -2 screenrecord" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} + - name: Upload Maestro test results if: failure() uses: actions/upload-artifact@v6 From d8d7ba78a820d55734106f70712a5876f4983d69 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 16:00:10 +0100 Subject: [PATCH 14/31] send sigint to screenrecord to gracefully finish recording --- .github/workflows/integration-tests-ui-critical.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index f420f15f58f..6892019cbc5 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,8 +137,7 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb shell screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 & maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; adb shell "pkill -2 screenrecord" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} - + adb shell "screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4" &; maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; adb shell "kill -2 \$(pgrep screenrecord)" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: failure() uses: actions/upload-artifact@v6 From 87e6e30319188def5b91047ff00b106e6b96c355 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 3 Feb 2026 16:16:58 +0100 Subject: [PATCH 15/31] fix typo --- .github/workflows/integration-tests-ui-critical.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 6892019cbc5..169a8d393ba 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,7 +137,7 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb shell "screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4" &; maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; adb shell "kill -2 \$(pgrep screenrecord)" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} + adb shell "screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 &" && sleep 2 && maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; adb shell "kill -2 \$(pgrep screenrecord)" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: failure() uses: actions/upload-artifact@v6 From cf8083eb33d956d165215cf02f8b9cbc4a8b8789 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 4 Feb 2026 13:04:53 +0100 Subject: [PATCH 16/31] use google_apis in order to have a launcher for notification access --- .../workflows/integration-tests-ui-critical.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 169a8d393ba..3d991285c3d 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -60,23 +60,23 @@ jobs: matrix: include: - api-level: 31 # Android 12 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 - api-level: 33 # Android 13 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 - api-level: 34 # Android 14 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 - api-level: 35 # Android 15 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 - api-level: 36 # Android 16 - target: aosp_atd + target: google_apis channel: canary # Necessary for ATDs arch: x86_64 steps: @@ -109,7 +109,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." @@ -133,7 +133,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save + emulator-options: -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" From f1c745187324485cb782878182a239badd5a00ee Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 4 Feb 2026 13:19:52 +0100 Subject: [PATCH 17/31] enable more debugging --- .github/workflows/integration-tests-ui-critical.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 3d991285c3d..042d5773f86 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,9 +137,8 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb shell "screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 &" && sleep 2 && maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" || MAESTRO_EXIT_CODE=$?; adb shell "kill -2 \$(pgrep screenrecord)" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} + adb shell "screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 &" && sleep 2 && maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb shell "kill -2 \$(pgrep screenrecord)" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results - if: failure() uses: actions/upload-artifact@v6 with: name: maestro-logs-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} From adad43c5168a85474cf2847b48439a6029d03e9f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 4 Feb 2026 14:02:28 +0100 Subject: [PATCH 18/31] Use adb emu screenrecord instead of adb shell screenrecord --- .github/workflows/integration-tests-ui-critical.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 042d5773f86..59e95ccf3d0 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,8 +137,9 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb shell "screenrecord --size 720x1280 --bit-rate 2000000 /sdcard/recording.mp4 &" && sleep 2 && maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb shell "kill -2 \$(pgrep screenrecord)" 2>/dev/null || true; sleep 3; adb pull /sdcard/recording.mp4 "${{env.BASE_PATH}}/maestro-logs/recording.mp4" || true; adb shell rm /sdcard/recording.mp4 || true; exit ${MAESTRO_EXIT_CODE:-0} + adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/recording.webm" && maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logs.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results + if: ${{ always() }} uses: actions/upload-artifact@v6 with: name: maestro-logs-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} From d2678dd5fb678dd9c4d04141a33049fa6ad61162 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 4 Feb 2026 14:26:19 +0100 Subject: [PATCH 19/31] switch to mac-os --- .github/workflows/integration-tests-ui-critical.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 59e95ccf3d0..f11522f3ea1 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -20,7 +20,7 @@ env: jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: macos-latest env: GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From c948a8828e3f51c616fc6597686f15a937de724b Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 09:04:41 +0100 Subject: [PATCH 20/31] Ensure paths exist --- .github/workflows/integration-tests-ui-critical.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index f11522f3ea1..2f05aefd655 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -137,7 +137,8 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/recording.webm" && maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logs.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} + mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir "${{env.BASE_PATH}}/maestro-logs/test-output" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logs.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} + - name: Upload Maestro test results if: ${{ always() }} uses: actions/upload-artifact@v6 From 6f2140420f340d780d8586a3e79ff6c50cc3338d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 09:19:26 +0100 Subject: [PATCH 21/31] bump maestro version --- .github/workflows/integration-tests-ui-critical.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 2f05aefd655..b6ec778b2ef 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -15,7 +15,7 @@ env: BUILD_PATH: "build/outputs/apk/release" APK_NAME: "sentry-uitest-android-critical-release.apk" APK_ARTIFACT_NAME: "sentry-uitest-android-critical-release" - MAESTRO_VERSION: "1.39.0" + MAESTRO_VERSION: "2.1.0" jobs: build: @@ -137,7 +137,7 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir "${{env.BASE_PATH}}/maestro-logs/test-output" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logs.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} + mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir="${{env.BASE_PATH}}/maestro-logs/test-output" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logs.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: ${{ always() }} From 1688287c927822bdcc55152a970e27923cb617f0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 09:43:56 +0100 Subject: [PATCH 22/31] Improve swipe --- .../maestro/appStart.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 23ad747b391..4284b03e580 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -1,5 +1,5 @@ appId: io.sentry.uitest.android.critical -name: App Launch Tests +name: App Start Tests --- # Test 1: A fresh start is considered a cold start - launchApp: @@ -26,12 +26,12 @@ name: App Launch Tests - tapOn: "Finish Activity" - assertNotVisible: "Welcome!" - swipe: - start: 50%, 1% - end: 50%, 90% + from: + id: status_bar + direction: DOWN - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: WARM" - # Test 4: Notification (COLD start) - launchApp: stopApp: true @@ -39,14 +39,14 @@ name: App Launch Tests all: allow - assertVisible: "Welcome!" - tapOn: "Trigger Notification" +- tapOn: "Finish Activity" - killApp - swipe: - start: 50%, 1% - end: 50%, 90% + from: + id: status_bar + direction: DOWN - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: COLD" - - # Test 5: Launch app after a broadcast receiver already created the application # Uncomment once https://github.com/mobile-dev-inc/Maestro/pull/2925 is merged # - killApp From ea305a2746d72b52e18ac489df5196750503069a Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 10:21:29 +0100 Subject: [PATCH 23/31] Use ubuntu everywhere, disable maestro debug logs --- .github/workflows/integration-tests-ui-critical.yml | 5 ++--- .../maestro/appStart.yaml | 10 ++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index b6ec778b2ef..1a1f84f483f 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -20,7 +20,7 @@ env: jobs: build: name: Build - runs-on: macos-latest + runs-on: ubuntu-latest env: GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -137,13 +137,12 @@ jobs: script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" - mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir="${{env.BASE_PATH}}/maestro-logs/test-output" --debug-output "${{env.BASE_PATH}}/maestro-logs/debug-logs" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logs.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} + mkdir "${{env.BASE_PATH}}/maestro-logs/" || true; adb emu screenrecord start --time-limit 360 "${{env.BASE_PATH}}/maestro-logs/recording.webm" || true; maestro test "${{env.BASE_PATH}}/maestro" --test-output-dir="${{env.BASE_PATH}}/maestro-logs/test-output" || MAESTRO_EXIT_CODE=$?; adb emu screenrecord stop || true; adb logcat -d > "${{env.BASE_PATH}}/maestro-logs/logcat.txt" || true; exit ${MAESTRO_EXIT_CODE:-0} - name: Upload Maestro test results if: ${{ always() }} uses: actions/upload-artifact@v6 with: name: maestro-logs-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} - include-hidden-files: true # maestro debug logs are stored within maestro-logs/.maestro/ path: "${{env.BASE_PATH}}/maestro-logs" retention-days: 1 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 index 4284b03e580..2db622dad32 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -26,9 +26,8 @@ name: App Start Tests - tapOn: "Finish Activity" - assertNotVisible: "Welcome!" - swipe: - from: - id: status_bar - direction: DOWN + start: 10%, 0% + end: 10%, 100% - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: WARM" @@ -42,9 +41,8 @@ name: App Start Tests - tapOn: "Finish Activity" - killApp - swipe: - from: - id: status_bar - direction: DOWN + start: 10%, 0% + end: 10%, 100% - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: COLD" # Test 5: Launch app after a broadcast receiver already created the application From 4d1e949424f47172a3679e2b2ec082fbf66956cb Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 10:50:29 +0100 Subject: [PATCH 24/31] slow swipe --- .../maestro/appStart.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 index 2db622dad32..9a7be448cf7 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -26,8 +26,9 @@ name: App Start Tests - tapOn: "Finish Activity" - assertNotVisible: "Welcome!" - swipe: - start: 10%, 0% - end: 10%, 100% + start: 50%, 0% + end: 50%, 90% + duration: 2000 - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: WARM" @@ -41,8 +42,9 @@ name: App Start Tests - tapOn: "Finish Activity" - killApp - swipe: - start: 10%, 0% - end: 10%, 100% + start: 50%, 0% + end: 50%, 90% + duration: 2000 - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: COLD" # Test 5: Launch app after a broadcast receiver already created the application From d7d42367f8612dde8565848e2b77d269e94bbdfe Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 15:19:21 +0100 Subject: [PATCH 25/31] retry swiping --- .../maestro/.maestro/config.yaml | 3 +++ .../maestro/appStart.yaml | 26 +++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml new file mode 100644 index 00000000000..ebb5a3a8e6d --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/.maestro/config.yaml @@ -0,0 +1,3 @@ +platform: + android: + disableAnimations: true 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 index 9a7be448cf7..0c5d88dc256 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -25,10 +25,15 @@ name: App Start Tests - tapOn: "Trigger Notification" - tapOn: "Finish Activity" - assertNotVisible: "Welcome!" -- swipe: - start: 50%, 0% - end: 50%, 90% - duration: 2000 +- repeat: + times: 3 + while: + notVisible: "Sentry Test Notification" + commands: + - swipe: + start: 0%, 0% + end: 0%, 60% + duration: 4000 - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: WARM" @@ -41,10 +46,15 @@ name: App Start Tests - tapOn: "Trigger Notification" - tapOn: "Finish Activity" - killApp -- swipe: - start: 50%, 0% - end: 50%, 90% - duration: 2000 +- repeat: + times: 3 + while: + notVisible: "Sentry Test Notification" + commands: + - swipe: + start: 0%, 0% + end: 0%, 60% + duration: 4000 - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: COLD" # Test 5: Launch app after a broadcast receiver already created the application From e1818788905367cb47a5c36e46b5db7bf29c6086 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 5 Feb 2026 15:41:43 +0100 Subject: [PATCH 26/31] try different swipe params --- .../maestro/appStart.yaml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index 0c5d88dc256..7356bd4bf08 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/appStart.yaml @@ -25,14 +25,15 @@ name: App Start Tests - tapOn: "Trigger Notification" - tapOn: "Finish Activity" - assertNotVisible: "Welcome!" +- waitForAnimationToEnd - repeat: times: 3 while: notVisible: "Sentry Test Notification" commands: - swipe: - start: 0%, 0% - end: 0%, 60% + start: 90%, 0% + end: 90%, 100% duration: 4000 - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: WARM" @@ -45,15 +46,17 @@ name: App Start Tests - assertVisible: "Welcome!" - tapOn: "Trigger Notification" - tapOn: "Finish Activity" +- assertNotVisible: "Welcome!" - killApp +- waitForAnimationToEnd - repeat: times: 3 while: notVisible: "Sentry Test Notification" commands: - swipe: - start: 0%, 0% - end: 0%, 60% + start: 90%, 0% + end: 90%, 100% duration: 4000 - tapOn: "Sentry Test Notification" - assertVisible: "App Start Type: COLD" From 744f5b076e94543fc9394006e86eef035524cae6 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 6 Feb 2026 09:05:35 +0100 Subject: [PATCH 27/31] Increase emulator memory --- .github/workflows/integration-tests-ui-critical.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 1a1f84f483f..792583937ba 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -109,7 +109,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu auto -noaudio -no-boot-anim -camera-back none + emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." @@ -133,7 +133,7 @@ jobs: force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save + emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none -no-snapshot-save script: | adb uninstall io.sentry.uitest.android.critical || echo "Already uninstalled (or not found)" adb install -r -d "${{env.APK_NAME}}" From fec23421c148d6098a5e40ce903d254bac52b0bd Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 6 Feb 2026 09:36:47 +0100 Subject: [PATCH 28/31] Update Changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf28902a14..7bc7cc22384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes -- Fix warm app start type detection for edge cases ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) +- Fix cold/warm app start type detection for Android devices running API level 34+ ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) ### Features From 7af3e593eb359c72247965b3d7a1473e89c8ea5c Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 6 Feb 2026 13:29:38 +0100 Subject: [PATCH 29/31] Fix Changelog --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 125affe6032..96d429d0dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,6 @@ ## Unreleased -### Fixes - -- Fix cold/warm app start type detection for Android devices running API level 34+ ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) - ### Features - Add `installGroupsOverride` parameter and `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062)) @@ -32,6 +28,7 @@ - Fix scroll target detection for Jetpack Compose ([#5017](https://github.com/getsentry/sentry-java/pull/5017)) - No longer fork Sentry `Scopes` for `reactor-kafka` consumer poll `Runnable` ([#5080](https://github.com/getsentry/sentry-java/pull/5080)) - This was causing a memory leak because `reactor-kafka`'s poll event reschedules itself infinitely, and each invocation of `SentryScheduleHook` created forked scopes with a parent reference, building an unbounded chain that couldn't be garbage collected. +- Fix cold/warm app start type detection for Android devices running API level 34+ ([#4999](https://github.com/getsentry/sentry-java/pull/4999)) ### Internal From 51be24b4f0084a5c4706116efa2aaab533e22bef Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 6 Feb 2026 14:02:37 +0100 Subject: [PATCH 30/31] test(android): Add API 35 tests for app start detection Add custom SentryShadowActivityManager to mock ApplicationStartInfo for testing cold/warm start detection on Android 15 (API 35). Tests verify the new getHistoricalProcessStartReasons() code path and fallback behavior when the list is empty or invalid. Co-Authored-By: Claude Sonnet 4.5 --- .../core/SentryShadowActivityManager.kt | 27 ++++++ .../performance/AppStartMetricsTestApi35.kt | 83 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt new file mode 100644 index 00000000000..e7079bd46d0 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -0,0 +1,27 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ApplicationStartInfo +import android.os.Build +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(ActivityManager::class, minSdk = Build.VERSION_CODES.VANILLA_ICE_CREAM) +class SentryShadowActivityManager { + companion object { + private var historicalProcessStartReasons: List = emptyList() + + fun setHistoricalProcessStartReasons(startReasons: List) { + historicalProcessStartReasons = startReasons + } + + fun reset() { + historicalProcessStartReasons = emptyList() + } + } + + @Implementation + fun getHistoricalProcessStartReasons(maxNum: Int): List { + return historicalProcessStartReasons.take(maxNum) + } +} 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 new file mode 100644 index 00000000000..53724a083b6 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -0,0 +1,83 @@ +package io.sentry.android.core.performance + +import android.app.Application +import android.app.ApplicationStartInfo +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryShadowActivityManager +import io.sentry.android.core.SentryShadowProcess +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM], + shadows = [SentryShadowProcess::class, SentryShadowActivityManager::class], +) +class AppStartMetricsTestApi35 { + @Before + fun setup() { + AppStartMetrics.getInstance().clear() + SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + } + + @Test + fun `detects cold start using ApplicationStartInfo on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val app = ApplicationProvider.getApplicationContext() + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `detects warm start using ApplicationStartInfo on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val app = ApplicationProvider.getApplicationContext() + AppStartMetrics.getInstance().registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `does not set app start type when ApplicationStartInfo list is invalid`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + } + + @Test + fun `does not set app start type when ApplicationStartInfo list is empty`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + } +} From 8e4eccaabeca5fcc95dbe9a965b3f731557a0052 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 6 Feb 2026 13:04:37 +0000 Subject: [PATCH 31/31] Format code --- .../android/core/performance/AppStartMetricsTestApi35.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 53724a083b6..d3738943a2c 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 @@ -7,13 +7,13 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import kotlin.test.Test +import kotlin.test.assertEquals import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) @Config( @@ -58,7 +58,8 @@ class AppStartMetricsTestApi35 { @Test fun `does not set app start type when ApplicationStartInfo list is invalid`() { val mockStartInfo = mock() - whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN) + whenever(mockStartInfo.startupState) + .thenReturn(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN) whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo))