diff --git a/CHANGELOG.md b/CHANGELOG.md index c945a7d78e..9763c70e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Features +- Add ApplicationStartInfo API support for Android 15+ ([#5055](https://github.com/getsentry/sentry-java/pull/5055)) + - Captures detailed app startup timing data from Android system + - Creates transactions with milestone spans (bind_application, application_oncreate, ttid, ttfd) + - Enriches with AppStartMetrics data (content provider spans, class names) + - Opt-in via `SentryAndroidOptions.setEnableApplicationStartInfo(boolean)` (disabled by default) - Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016)) ### Internal diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ff9a0c7597..ee37ceb032 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -215,6 +215,12 @@ public final class io/sentry/android/core/ApplicationExitInfoEventProcessor : io public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } +public final class io/sentry/android/core/ApplicationStartInfoIntegration : io/sentry/Integration, java/io/Closeable { + public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V + public fun close ()V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + public final class io/sentry/android/core/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -353,6 +359,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableActivityLifecycleTracingAutoFinish ()Z public fun isEnableAppComponentBreadcrumbs ()Z public fun isEnableAppLifecycleBreadcrumbs ()Z + public fun isEnableApplicationStartInfo ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z @@ -381,6 +388,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableActivityLifecycleTracingAutoFinish (Z)V public fun setEnableAppComponentBreadcrumbs (Z)V public fun setEnableAppLifecycleBreadcrumbs (Z)V + public fun setEnableApplicationStartInfo (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index b7bb5bf21a..7734d7ce9a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -380,6 +380,8 @@ static void installDefaultIntegrations( options.addIntegration(new TombstoneIntegration(context)); } + options.addIntegration(new ApplicationStartInfoIntegration(context, buildInfoProvider)); + // this integration uses android.os.FileObserver, we can't move to sentry // before creating a pure java impl. options.addIntegration(EnvelopeFileObserverIntegration.getOutboxFileObserver()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java new file mode 100644 index 0000000000..89244ccc70 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java @@ -0,0 +1,404 @@ +package io.sentry.android.core; + +import android.app.ActivityManager; +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.Integration; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.IntegrationUtils; +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class ApplicationStartInfoIntegration implements Integration, Closeable { + + private final @NotNull Context context; + private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); + private @Nullable SentryAndroidOptions options; + private @Nullable IScopes scopes; + private boolean isClosed = false; + + public ApplicationStartInfoIntegration( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.buildInfoProvider = + java.util.Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + } + + @Override + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + register(scopes, (SentryAndroidOptions) options); + } + + private void register( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + this.scopes = java.util.Objects.requireNonNull(scopes, "Scopes are required"); + this.options = java.util.Objects.requireNonNull(options, "SentryAndroidOptions is required"); + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "ApplicationStartInfoIntegration enabled: %s", + options.isEnableApplicationStartInfo()); + + if (!options.isEnableApplicationStartInfo()) { + return; + } + + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + options + .getLogger() + .log( + SentryLevel.INFO, + "ApplicationStartInfo requires API level 35+. Current: %d", + buildInfoProvider.getSdkInfoVersion()); + return; + } + + try { + options + .getExecutorService() + .submit( + () -> { + try (final ISentryLifecycleToken ignored = startLock.acquire()) { + if (!isClosed) { + registerAppStartListener(scopes, options); + } + } + }); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Failed to start ApplicationStartInfoIntegration.", e); + } + + IntegrationUtils.addIntegrationToSdkVersion("ApplicationStartInfo"); + } + + @RequiresApi(api = 35) + private void registerAppStartListener( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager == null) { + options.getLogger().log(SentryLevel.ERROR, "Failed to retrieve ActivityManager."); + return; + } + + try { + // Wrap ISentryExecutorService as Executor for Android API + final java.util.concurrent.Executor executor = options.getExecutorService()::submit; + + activityManager.addApplicationStartInfoCompletionListener( + executor, + startInfo -> { + try { + onApplicationStartInfoAvailable(startInfo, scopes, options); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Error reporting ApplicationStartInfo.", e); + } + }); + + options + .getLogger() + .log(SentryLevel.DEBUG, "ApplicationStartInfo completion listener registered."); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to register ApplicationStartInfo listener.", e); + } + } + + @RequiresApi(api = 35) + private void onApplicationStartInfoAvailable( + final @NotNull android.app.ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + // Extract tags + final Map tags = extractTags(startInfo); + + // Create transaction name based on reason + final String transactionName = "app.start." + getReasonLabel(startInfo.getReason()); + + // Create timestamp + final SentryDate startTimestamp = dateFromMillis(getStartTimestamp(startInfo)); + + // Calculate duration (use first frame or fully drawn as end) + long endTimestamp = + getFirstFrameTimestamp(startInfo) > 0 + ? getFirstFrameTimestamp(startInfo) + : getFullyDrawnTimestamp(startInfo); + + final SentryDate endDate = + endTimestamp > 0 ? dateFromMillis(endTimestamp) : options.getDateProvider().now(); + + // Create transaction + final TransactionContext transactionContext = + new TransactionContext(transactionName, TransactionNameSource.COMPONENT, "app.start.info"); + + final TransactionOptions transactionOptions = new TransactionOptions(); + transactionOptions.setStartTimestamp(startTimestamp); + + final ITransaction transaction = + scopes.startTransaction(transactionContext, transactionOptions); + + // Add tags + for (Map.Entry entry : tags.entrySet()) { + transaction.setTag(entry.getKey(), entry.getValue()); + } + + // Create child spans for startup milestones (all start from app launch timestamp) + attachAppStartMetricData(transaction, startInfo, startTimestamp); + + // Finish transaction + transaction.finish(SpanStatus.OK, endDate); + } + + @RequiresApi(api = 35) + private void attachAppStartMetricData( + final @NotNull ITransaction transaction, + final @NotNull android.app.ApplicationStartInfo startInfo, + final @NotNull SentryDate startTimestamp) { + final long startMs = getStartTimestamp(startInfo); + + // Span 1: app.start.bind_application (from fork to bind application) + if (getBindApplicationTimestamp(startInfo) > 0) { + final io.sentry.ISpan bindSpan = + transaction.startChild( + "app.start.bind_application", null, startTimestamp, io.sentry.Instrumenter.SENTRY); + bindSpan.finish(SpanStatus.OK, dateFromMillis(getBindApplicationTimestamp(startInfo))); + } + + // Add content provider onCreate spans from AppStartMetrics + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + final @NotNull List contentProviderSpans = + appStartMetrics.getContentProviderOnCreateTimeSpans(); + for (final TimeSpan cpSpan : contentProviderSpans) { + if (cpSpan.hasStarted() && cpSpan.hasStopped()) { + final SentryDate cpStartDate = dateFromMillis(cpSpan.getStartTimestampMs()); + final SentryDate cpEndDate = dateFromMillis(cpSpan.getProjectedStopTimestampMs()); + + final io.sentry.ISpan contentProviderSpan = + transaction.startChild( + "contentprovider.load", + cpSpan.getDescription(), + cpStartDate, + io.sentry.Instrumenter.SENTRY); + contentProviderSpan.finish(SpanStatus.OK, cpEndDate); + } + } + + // Span 2: app.start.application_oncreate (from fork to Application.onCreate) + // Use ApplicationStartInfo timestamp if available, enriched with AppStartMetrics description + final TimeSpan appOnCreateSpan = appStartMetrics.getApplicationOnCreateTimeSpan(); + final String appOnCreateDescription = + appOnCreateSpan.hasStarted() ? appOnCreateSpan.getDescription() : null; + + if (getApplicationOnCreateTimestamp(startInfo) > 0) { + // Use precise timestamp from ApplicationStartInfo + final io.sentry.ISpan onCreateSpan = + transaction.startChild( + "app.start.application_oncreate", + appOnCreateDescription, + startTimestamp, + io.sentry.Instrumenter.SENTRY); + onCreateSpan.finish( + SpanStatus.OK, dateFromMillis(getApplicationOnCreateTimestamp(startInfo))); + } else if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { + // Fallback to AppStartMetrics timing + final SentryDate appOnCreateStart = dateFromMillis(appOnCreateSpan.getStartTimestampMs()); + final SentryDate appOnCreateEnd = + dateFromMillis(appOnCreateSpan.getProjectedStopTimestampMs()); + + final io.sentry.ISpan onCreateSpan = + transaction.startChild( + "app.start.application_oncreate", + appOnCreateDescription, + appOnCreateStart, + io.sentry.Instrumenter.SENTRY); + onCreateSpan.finish(SpanStatus.OK, appOnCreateEnd); + } + + // Span 3: app.start.ttid (from fork to first frame - time to initial display) + if (getFirstFrameTimestamp(startInfo) > 0) { + final io.sentry.ISpan ttidSpan = + transaction.startChild( + "app.start.ttid", null, startTimestamp, io.sentry.Instrumenter.SENTRY); + ttidSpan.finish(SpanStatus.OK, dateFromMillis(getFirstFrameTimestamp(startInfo))); + } + + // Span 4: app.start.ttfd (from fork to fully drawn - time to full display) + if (getFullyDrawnTimestamp(startInfo) > 0) { + final io.sentry.ISpan ttfdSpan = + transaction.startChild( + "app.start.ttfd", null, startTimestamp, io.sentry.Instrumenter.SENTRY); + ttfdSpan.finish(SpanStatus.OK, dateFromMillis(getFullyDrawnTimestamp(startInfo))); + } + } + + @RequiresApi(api = 35) + private @NotNull Map extractTags( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map tags = new HashMap<>(); + + // Add reason + tags.put("start.reason", getReasonLabel(startInfo.getReason())); + + // Add startup type from ApplicationStartInfo + tags.put("start.type", getStartupTypeLabel(startInfo.getStartType())); + + // Add launch mode from ApplicationStartInfo + tags.put("start.launch_mode", getLaunchModeLabel(startInfo.getLaunchMode())); + + // Note: Additional properties like component type, importance, etc. may be added + // when they become available in future Android API levels + + return tags; + } + + @RequiresApi(api = 35) + private @NotNull String getStartupTypeLabel(final int startType) { + switch (startType) { + case android.app.ApplicationStartInfo.START_TYPE_COLD: + return "cold"; + case android.app.ApplicationStartInfo.START_TYPE_WARM: + return "warm"; + case android.app.ApplicationStartInfo.START_TYPE_HOT: + return "hot"; + default: + return "unknown"; + } + } + + @RequiresApi(api = 35) + private @NotNull String getLaunchModeLabel(final int launchMode) { + switch (launchMode) { + case android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD: + return "standard"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_TOP: + return "single_top"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE: + return "single_instance"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_TASK: + return "single_task"; + case android.app.ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE_PER_TASK: + return "single_instance_per_task"; + default: + return "unknown"; + } + } + + @RequiresApi(api = 35) + private @NotNull String getReasonLabel(final int reason) { + switch (reason) { + case android.app.ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case android.app.ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case android.app.ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case android.app.ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case android.app.ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case android.app.ApplicationStartInfo.START_REASON_JOB: + return "job"; + case android.app.ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case android.app.ApplicationStartInfo.START_REASON_OTHER: + return "other"; + case android.app.ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case android.app.ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case android.app.ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + default: + return "unknown"; + } + } + + // Helper methods to access timestamps from the startupTimestamps map + @RequiresApi(api = 35) + private long getStartTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long forkTime = timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FORK); + return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; + } + + @RequiresApi(api = 35) + private long getBindApplicationTimestamp( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long bindTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); + return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; + } + + @RequiresApi(api = 35) + private long getApplicationOnCreateTimestamp( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long onCreateTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + return onCreateTime != null ? TimeUnit.NANOSECONDS.toMillis(onCreateTime) : 0; + } + + @RequiresApi(api = 35) + private long getFirstFrameTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long firstFrameTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + return firstFrameTime != null ? TimeUnit.NANOSECONDS.toMillis(firstFrameTime) : 0; + } + + @RequiresApi(api = 35) + private long getFullyDrawnTimestamp(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long fullyDrawnTime = + timestamps.get(android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN); + return fullyDrawnTime != null ? TimeUnit.NANOSECONDS.toMillis(fullyDrawnTime) : 0; + } + + @Override + public void close() throws IOException { + try (final ISentryLifecycleToken ignored = startLock.acquire()) { + isClosed = true; + } + } + + /** + * Creates a SentryDate from milliseconds timestamp. Uses SentryNanotimeDate for compatibility + * with older Android versions. + */ + private static @NotNull SentryDate dateFromMillis(final long millis) { + return new SentryNanotimeDate(new Date(millis), 0); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 12917ed4b7..adad161743 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -243,6 +243,17 @@ public interface BeforeCaptureCallback { private boolean enableTombstone = false; + /** + * Controls whether to collect and report application startup information from the {@link + * android.app.ApplicationStartInfo} system API (Android 15+). When enabled, creates transactions + * and metrics for each application start event. + * + *

Requires API level 35 (Android 15) or higher. + * + *

Default is false (opt-in). + */ + private boolean enableApplicationStartInfo = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -337,6 +348,27 @@ public boolean isTombstoneEnabled() { return enableTombstone; } + /** + * Sets ApplicationStartInfo collection to enabled or disabled. Requires API level 35 (Android 15) + * or higher. + * + * @param enableApplicationStartInfo true for enabled and false for disabled + */ + @ApiStatus.Experimental + public void setEnableApplicationStartInfo(final boolean enableApplicationStartInfo) { + this.enableApplicationStartInfo = enableApplicationStartInfo; + } + + /** + * Checks if ApplicationStartInfo collection is enabled or disabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableApplicationStartInfo() { + return enableApplicationStartInfo; + } + public boolean isEnableActivityLifecycleBreadcrumbs() { return enableActivityLifecycleBreadcrumbs; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 348075ff90..9d6a77b9cc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -936,4 +936,12 @@ class AndroidOptionsInitializerTest { fixture.initSut() assertIs(fixture.sentryOptions.runtimeManager) } + + @Test + fun `ApplicationStartInfoIntegration is added to integration list`() { + fixture.initSut() + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ApplicationStartInfoIntegration } + assertNotNull(actual) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt new file mode 100644 index 0000000000..6a521764d3 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt @@ -0,0 +1,330 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IScopes +import io.sentry.ISentryExecutorService +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.TransactionContext +import java.util.concurrent.Callable +import java.util.function.Consumer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class ApplicationStartInfoIntegrationTest { + + private lateinit var context: Context + private lateinit var options: SentryAndroidOptions + private lateinit var scopes: IScopes + private lateinit var activityManager: ActivityManager + private lateinit var executor: ISentryExecutorService + private lateinit var buildInfoProvider: BuildInfoProvider + + @Before + fun setup() { + context = mock() + options = SentryAndroidOptions() + scopes = mock() + activityManager = mock() + executor = mock() + buildInfoProvider = mock() + + // Setup default options + options.isEnableApplicationStartInfo = true + options.executorService = executor + options.setLogger(mock()) + options.dateProvider = mock() + + // Mock BuildInfoProvider to return API 35+ + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.VANILLA_ICE_CREAM) + + // Execute tasks immediately for testing + whenever(executor.submit(any>())).thenAnswer { + val callable = it.arguments[0] as Callable<*> + callable.call() + mock>() + } + whenever(executor.submit(any())).thenAnswer { + val runnable = it.arguments[0] as Runnable + runnable.run() + mock>() + } + + // Mock ActivityManager as system service + whenever(context.getSystemService(Context.ACTIVITY_SERVICE)).thenReturn(activityManager) + } + + @Test + fun `integration does not register when disabled`() { + options.isEnableApplicationStartInfo = false + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + + integration.register(scopes, options) + + verify(executor, never()).submit(any()) + } + + @Test + fun `integration registers completion listener on API 35+`() { + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager).addApplicationStartInfoCompletionListener(any(), any()) + } + + @Test + fun `transaction includes correct tags from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + + val startInfo = createMockApplicationStartInfo() + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).setTag(eq("start.reason"), any()) + } + + @Test + fun `transaction includes start type from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + + val startInfo = createMockApplicationStartInfo() + whenever(startInfo.startType) + .thenReturn( + if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.START_TYPE_COLD else 0 + ) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).setTag("start.type", "cold") + } + + @Test + fun `transaction includes launch mode from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + + val startInfo = createMockApplicationStartInfo() + whenever(startInfo.launchMode) + .thenReturn( + if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD + else 0 + ) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).setTag("start.launch_mode", "standard") + } + + @Test + fun `creates bind_application span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever( + mockTransaction.startChild(eq("app.start.bind_application"), anyOrNull(), any(), any()) + ) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, bindApplicationTime = 1100000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).startChild(eq("app.start.bind_application"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `creates application_oncreate span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever( + mockTransaction.startChild(eq("app.start.application_oncreate"), anyOrNull(), any(), any()) + ) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, applicationOnCreateTime = 1200000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction) + .startChild(eq("app.start.application_oncreate"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `creates ttid span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever(mockTransaction.startChild(eq("app.start.ttid"), anyOrNull(), any(), any())) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, firstFrameTime = 1500000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).startChild(eq("app.start.ttid"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `creates ttfd span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val mockTransaction = mock() + val mockSpan = mock() + whenever(scopes.startTransaction(any(), any())) + .thenReturn(mockTransaction) + whenever(mockTransaction.startChild(eq("app.start.ttfd"), anyOrNull(), any(), any())) + .thenReturn(mockSpan) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, fullyDrawnTime = 2000000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(mockTransaction).startChild(eq("app.start.ttfd"), anyOrNull(), any(), any()) + verify(mockSpan).finish(any(), any()) + } + + @Test + fun `closes integration without errors`() { + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + integration.close() + // Should not throw exception + } + + @Test + fun `transaction name includes reason label`() { + val listenerCaptor = argumentCaptor>() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + var capturedContext: TransactionContext? = null + whenever(scopes.startTransaction(any(), any())).thenAnswer { + capturedContext = it.arguments[0] as TransactionContext + mock() + } + + val startInfo = createMockApplicationStartInfo() + whenever(startInfo.reason) + .thenReturn( + if (Build.VERSION.SDK_INT >= 35) android.app.ApplicationStartInfo.START_REASON_LAUNCHER + else 0 + ) + listenerCaptor.firstValue.accept(startInfo) + + assertNotNull(capturedContext) + assertEquals("app.start.launcher", capturedContext!!.name) + } + + // Helper methods + private fun createMockApplicationStartInfo( + forkTime: Long = 1000000000L, // nanoseconds + bindApplicationTime: Long = 0L, + applicationOnCreateTime: Long = 0L, + firstFrameTime: Long = 0L, + fullyDrawnTime: Long = 0L, + ): android.app.ApplicationStartInfo { + val startInfo = mock() + + val timestamps = mutableMapOf() + if (Build.VERSION.SDK_INT >= 35) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FORK] = forkTime + if (bindApplicationTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION] = + bindApplicationTime + } + if (applicationOnCreateTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE] = + applicationOnCreateTime + } + if (firstFrameTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME] = firstFrameTime + } + if (fullyDrawnTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FULLY_DRAWN] = fullyDrawnTime + } + + whenever(startInfo.reason).thenReturn(android.app.ApplicationStartInfo.START_REASON_LAUNCHER) + } + + whenever(startInfo.startupTimestamps).thenReturn(timestamps) + + return startInfo + } +}