diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6a04f83c8..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)) 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..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; @@ -21,6 +23,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 +59,15 @@ 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 firstIdle = -1; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -89,7 +100,6 @@ public AppStartMetrics() { applicationOnCreate = new TimeSpan(); contentProviderOnCreates = new HashMap<>(); activityLifecycles = new ArrayList<>(); - appLaunchedInForeground = ContextUtils.isForegroundImportance(); } /** @@ -140,12 +150,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); } /** @@ -176,7 +186,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground; + return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); } public long getClassLoadedUptimeMs() { @@ -191,7 +201,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(); @@ -212,6 +222,16 @@ public long getClassLoadedUptimeMs() { return new TimeSpan(); } + @TestOnly + void setFirstIdle(final long firstIdle) { + this.firstIdle = firstIdle; + } + + @TestOnly + long getFirstIdle() { + return firstIdle; + } + @TestOnly public void clear() { appStartType = AppStartType.UNKNOWN; @@ -229,11 +249,12 @@ public void clear() { } appStartContinuousProfiler = null; appStartSamplingDecision = null; - appLaunchedInForeground = false; + appLaunchedInForeground.resetValue(); isCallbackRegistered = false; shouldSendStartMeasurements = true; firstDrawDone.set(false); activeActivitiesCounter.set(0); + firstIdle = -1; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -301,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 */ @@ -310,61 +332,85 @@ 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 - // 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()); + + 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 = 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 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 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)) { + 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 (firstIdle != -1 && activityCreatedUptimeMillis > firstIdle) { + appStartType = AppStartType.WARM; } else { - appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + appStartType = AppStartType.COLD; } } - appLaunchedInForeground = true; + appLaunchedInForeground.setValue(true); } @Override @@ -403,9 +449,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 24159cab5cb..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) } @@ -434,6 +440,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 @@ -537,4 +544,320 @@ class AppStartMetricsTest { assertEquals(secondActivity, CurrentActivityHolder.getInstance().activity) } + + @Test + fun `firstIdle is properly cleared`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.firstIdle > 0) + + metrics.clear() + + assertEquals(-1, metrics.firstIdle) + } + + @Test + fun `firstIdle is set when registerLifecycleCallbacks is called`() { + SystemClock.setCurrentTimeMillis(90) + + val metrics = AppStartMetrics.getInstance() + val beforeRegister = SystemClock.uptimeMillis() + + SystemClock.setCurrentTimeMillis(100) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(110) + val afterIdle = SystemClock.uptimeMillis() + + assertTrue(metrics.firstIdle >= beforeRegister) + assertTrue(metrics.firstIdle <= afterIdle) + } + + @Test + 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()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.isAppLaunchedInForeground = true + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `Sets app launch type to COLD when activity created before firstIdle executes`() { + val metrics = AppStartMetrics.getInstance() + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + metrics.registerLifecycleCallbacks(mock()) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `savedInstanceState check takes precedence over firstIdle timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `timeout check takes precedence over firstIdle timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + 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 `firstIdle timing does not affect subsequent activity creations`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + 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 firstIdle with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + // Manually set firstIdle to a known value + val testTime = SystemClock.uptimeMillis() + metrics.firstIdle = testTime + + // Set current time to exactly match firstIdle time + SystemClock.setCurrentTimeMillis(testTime) + metrics.onActivityCreated(mock(), null) + + // When nowUptimeMs <= firstIdle, should be COLD + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `WARM start when activity created 1ms after firstIdle with null savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + val beforeRegister = SystemClock.uptimeMillis() + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + // Activity created just 1ms after firstIdle executed + SystemClock.setCurrentTimeMillis(beforeRegister + 1) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + 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 firstIdle executes + + // Even if we advance wall time significantly + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 1000) + metrics.onActivityCreated(mock(), null) + + // Should still be COLD because firstIdle hasn't executed yet + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + // 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 firstIdle indicate WARM`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + // 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 firstIdle`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't idle - activity created before firstIdle + + // savedInstanceState check takes precedence + metrics.onActivityCreated(mock(), mock()) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + 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.firstIdle + assertEquals(-1, initialValue) + + // Register and let it set + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + val afterRegister = metrics.firstIdle + assertTrue(afterRegister > 0) + + // Clear should reset it + metrics.clear() + val afterClear = metrics.firstIdle + assertEquals(-1, afterClear) + } + + @Test + fun `COLD start when firstIdle is still -1 and no savedInstanceState`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + // Don't idle - firstIdle will still be -1 + + // Verify firstIdle hasn't executed yet + assertEquals(-1, metrics.firstIdle) + + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + } + + @Test + fun `App start type priority order is timeout, savedInstanceState, then firstIdle timing`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + // Test timeout takes precedence over everything + val futureTime = SystemClock.uptimeMillis() + TimeUnit.MINUTES.toMillis(2) + SystemClock.setCurrentTimeMillis(futureTime) + metrics.onActivityCreated(mock(), null) // null savedInstanceState + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `Multiple consecutive warm starts are correctly detected`() { + val metrics = AppStartMetrics.getInstance() + + metrics.registerLifecycleCallbacks(mock()) + + // 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() + waitForMainLooperIdle() + + // 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()) + } + + @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) + + waitForMainLooperIdle() + + // 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 firstIdle 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 + val mainActivity = mock() + metrics.onActivityCreated(mainActivity, null) + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + + waitForMainLooperIdle() + + metrics.onActivityDestroyed(mainActivity) + + // Later, user navigates to another activity + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 200) + metrics.onActivityCreated(mock(), null) + + 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..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 @@ -1,21 +1,37 @@ + 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/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") + } +} 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)") + } } } }