diff --git a/CHANGELOG.md b/CHANGELOG.md index 72431094bc..bc6d115124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - Add `installGroupsOverride` parameter and `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062)) - Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016)) - Add AndroidManifest support for Spotlight configuration via `io.sentry.spotlight.enable` and `io.sentry.spotlight.url` ([#5064](https://github.com/getsentry/sentry-java/pull/5064)) +- Add ApplicationStartInfo API support for Android 15+ ([#5055](https://github.com/getsentry/sentry-java/pull/5055)) + - Captures detailed app startup timing data based on [ApplicationStartInfo APIs](https://developer.android.com/reference/android/app/ApplicationStartInfo) + - Opt-in via `SentryAndroidOptions.setEnableApplicationStartInfo(boolean)` (disabled by default) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ff9a0c7597..feb216e40e 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 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..bb27fc092e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java @@ -0,0 +1,492 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.ApplicationStartInfo; +import android.content.Context; +import android.os.Build; +import android.os.SystemClock; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Integration; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.SpanContext; +import io.sentry.SpanDataConvention; +import io.sentry.SpanId; +import io.sentry.SpanStatus; +import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.internal.util.AndroidThreadChecker; +import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; +import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.core.performance.TimeSpan; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.SentrySpan; +import io.sentry.protocol.SentryTransaction; +import io.sentry.protocol.TransactionInfo; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.IntegrationUtils; +import io.sentry.util.Objects; +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.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@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 boolean isClosed; + + public ApplicationStartInfoIntegration( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = ContextUtils.getApplicationContext(context); + this.buildInfoProvider = + Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); + } + + @Override + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + register(scopes, (SentryAndroidOptions) options); + } + + @SuppressLint("NewApi") + private void register( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + 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 { + final Executor executor = + new Executor() { + @Override + public void execute(Runnable command) { + options.getExecutorService().submit(command); + } + }; + + 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 ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + + final long currentUnixMs = System.currentTimeMillis(); + final long currentRealtimeMs = SystemClock.elapsedRealtime(); + final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; + + final Map tags = extractTags(startInfo); + final long startRealtimeMs = getStartTimestampMs(startInfo); + final long ttidRealtimeMs = getFirstFrameTimestampMs(startInfo); + final long ttfdRealtimeMs = getFullyDrawnTimestampMs(startInfo); + final long bindApplicationRealtimeMs = getBindApplicationTimestampMs(startInfo); + + final SentryDate startDate = dateFromUnixTime(unixTimeOffsetMs + startRealtimeMs); + + final long endTimestamp = ttidRealtimeMs > 0 ? ttidRealtimeMs : ttfdRealtimeMs; + final SentryDate endDate = + endTimestamp > 0 + ? dateFromUnixTime(unixTimeOffsetMs + endTimestamp) + : options.getDateProvider().now(); + + final SentryId traceId = new SentryId(); + final SpanId spanId = new SpanId(); + final SpanContext traceContext = + new SpanContext(traceId, spanId, "app.start", null, new TracesSamplingDecision(true)); + traceContext.setStatus(SpanStatus.OK); + + final double startTimestampSecs = dateToSeconds(startDate); + final double endTimestampSecs = dateToSeconds(endDate); + + final SentryTransaction transaction = + new SentryTransaction( + "app.start", + startTimestampSecs, + endTimestampSecs, + new java.util.ArrayList<>(), + new HashMap<>(), + new TransactionInfo(TransactionNameSource.COMPONENT.apiName())); + + transaction.getContexts().setTrace(traceContext); + + for (Map.Entry entry : tags.entrySet()) { + transaction.setTag(entry.getKey(), entry.getValue()); + } + + if (bindApplicationRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "bind_application", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + bindApplicationRealtimeMs))); + } + + if (startInfo.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + attachColdStartInstrumentations(transaction, traceId, spanId); + } + + attachActivitySpans(transaction, traceId, spanId); + + if (ttidRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "ttid", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + ttidRealtimeMs))); + } + + if (ttfdRealtimeMs > 0) { + transaction + .getSpans() + .add( + createSpan( + traceId, + spanId, + "ttfd", + null, + startDate, + dateFromUnixTime(unixTimeOffsetMs + ttfdRealtimeMs))); + } + + scopes.captureTransaction(transaction, null, null); + } + + private @NotNull SentrySpan createSpan( + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId, + final @NotNull String operation, + final String description, + final @NotNull SentryDate startDate, + final @NotNull SentryDate endDate) { + + final Map spanData = new HashMap<>(); + spanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); + spanData.put(SpanDataConvention.THREAD_NAME, "main"); + + final double startTimestampSecs = dateToSeconds(startDate); + final double endTimestampSecs = dateToSeconds(endDate); + + return new SentrySpan( + startTimestampSecs, + endTimestampSecs, + traceId, + new SpanId(), + parentSpanId, + operation, + description, + SpanStatus.OK, + "manual", + new ConcurrentHashMap<>(), + new ConcurrentHashMap<>(), + spanData); + } + + private static double dateToSeconds(final @NotNull SentryDate date) { + return date.nanoTimestamp() / 1e9; + } + + @RequiresApi(api = 35) + private void attachColdStartInstrumentations( + final @NotNull SentryTransaction transaction, + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId) { + + 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 = dateFromUnixTime(cpSpan.getStartTimestampMs()); + final SentryDate cpEndDate = dateFromUnixTime(cpSpan.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "contentprovider.on_create", + cpSpan.getDescription(), + cpStartDate, + cpEndDate)); + } + } + + final TimeSpan appOnCreateSpan = appStartMetrics.getApplicationOnCreateTimeSpan(); + final String appOnCreateDescription = + appOnCreateSpan.hasStarted() ? appOnCreateSpan.getDescription() : null; + + if (appOnCreateSpan.hasStarted() && appOnCreateSpan.hasStopped()) { + final SentryDate appOnCreateStart = dateFromUnixTime(appOnCreateSpan.getStartTimestampMs()); + final SentryDate appOnCreateEnd = + dateFromUnixTime(appOnCreateSpan.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "application.on_create", + appOnCreateDescription, + appOnCreateStart, + appOnCreateEnd)); + } + } + + @RequiresApi(api = 35) + private void attachActivitySpans( + final @NotNull SentryTransaction transaction, + final @NotNull SentryId traceId, + final @NotNull SpanId parentSpanId) { + + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + final @NotNull List activityLifecycleTimeSpans = + appStartMetrics.getActivityLifecycleTimeSpans(); + + for (final ActivityLifecycleTimeSpan span : activityLifecycleTimeSpans) { + final TimeSpan onCreate = span.getOnCreate(); + final TimeSpan onStart = span.getOnStart(); + + if (onCreate.hasStarted() && onCreate.hasStopped()) { + final SentryDate start = dateFromUnixTime(onCreate.getStartTimestampMs()); + final SentryDate end = dateFromUnixTime(onCreate.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "activity.on_create", + onCreate.getDescription(), + start, + end)); + } + + if (onStart.hasStarted() && onStart.hasStopped()) { + final SentryDate start = dateFromUnixTime(onStart.getStartTimestampMs()); + final SentryDate end = dateFromUnixTime(onStart.getProjectedStopTimestampMs()); + + transaction + .getSpans() + .add( + createSpan( + traceId, + parentSpanId, + "activity.on_start", + onStart.getDescription(), + start, + end)); + } + } + } + + @RequiresApi(api = 35) + private @NotNull Map extractTags( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map tags = new HashMap<>(); + tags.put("start.reason", getReasonLabel(startInfo.getReason())); + tags.put("start.type", getStartupTypeLabel(startInfo.getStartType())); + tags.put("start.launch_mode", getLaunchModeLabel(startInfo.getLaunchMode())); + return tags; + } + + @RequiresApi(api = 35) + private @NotNull String getStartupTypeLabel(final int startType) { + switch (startType) { + case ApplicationStartInfo.START_TYPE_COLD: + return "cold"; + case ApplicationStartInfo.START_TYPE_WARM: + return "warm"; + case ApplicationStartInfo.START_TYPE_HOT: + return "hot"; + default: + return "unknown"; + } + } + + @RequiresApi(api = 35) + private @NotNull String getLaunchModeLabel(final int launchMode) { + switch (launchMode) { + case ApplicationStartInfo.LAUNCH_MODE_STANDARD: + return "standard"; + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_TOP: + return "single_top"; + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_INSTANCE: + return "single_instance"; + case ApplicationStartInfo.LAUNCH_MODE_SINGLE_TASK: + return "single_task"; + case 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 ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case ApplicationStartInfo.START_REASON_JOB: + return "job"; + case ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + case ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + default: + return "unknown"; + } + } + + @RequiresApi(api = 35) + private long getStartTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long forkTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FORK); + return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; + } + + @RequiresApi(api = 35) + private long getBindApplicationTimestampMs( + final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + + final Long bindTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION); + return bindTime != null ? TimeUnit.NANOSECONDS.toMillis(bindTime) : 0; + } + + @RequiresApi(api = 35) + private long getFirstFrameTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long firstFrameTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + return firstFrameTime != null ? TimeUnit.NANOSECONDS.toMillis(firstFrameTime) : 0; + } + + @RequiresApi(api = 35) + private long getFullyDrawnTimestampMs(final @NotNull android.app.ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long fullyDrawnTime = timestamps.get(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; + } + } + + private static @NotNull SentryDate dateFromUnixTime(final long timeMillis) { + return new SentryNanotimeDate(new Date(timeMillis), 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..262039b56b --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationStartInfoIntegrationTest.kt @@ -0,0 +1,279 @@ +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.protocol.SentryTransaction +import java.util.concurrent.Callable +import java.util.function.Consumer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +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.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()) + + val mockDateProvider = mock() + val mockDate = mock() + whenever(mockDate.nanoTimestamp()).thenReturn(System.currentTimeMillis() * 1_000_000L) + whenever(mockDateProvider.now()).thenReturn(mockDate) + options.dateProvider = mockDateProvider + + // 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 transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = createMockApplicationStartInfo() + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val transaction = transactionCaptor.firstValue + assertNotNull(transaction.tags) + assertTrue(transaction.tags!!.containsKey("start.reason")) + assertTrue(transaction.tags!!.containsKey("start.type")) + assertTrue(transaction.tags!!.containsKey("start.launch_mode")) + } + + @Test + fun `transaction includes start type from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = + createMockApplicationStartInfo(startType = android.app.ApplicationStartInfo.START_TYPE_COLD) + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + assertEquals("cold", transactionCaptor.firstValue.tags!!["start.type"]) + } + + @Test + fun `transaction includes launch mode from ApplicationStartInfo`() { + val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = + createMockApplicationStartInfo( + launchMode = android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD + ) + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + assertEquals("standard", transactionCaptor.firstValue.tags!!["start.launch_mode"]) + } + + @Test + fun `creates bind_application span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, bindApplicationTime = 1100000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val spans = transactionCaptor.firstValue.spans + assertTrue(spans.any { it.op == "bind_application" }) + } + + @Test + fun `creates ttid span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, firstFrameTime = 1500000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val spans = transactionCaptor.firstValue.spans + assertTrue(spans.any { it.op == "ttid" }) + } + + @Test + fun `creates ttfd span when timestamp available`() { + val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = + createMockApplicationStartInfo(forkTime = 1000000000L, fullyDrawnTime = 2000000000L) + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + val spans = transactionCaptor.firstValue.spans + assertTrue(spans.any { it.op == "ttfd" }) + } + + @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 is app_start`() { + val listenerCaptor = argumentCaptor>() + val transactionCaptor = argumentCaptor() + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + integration.register(scopes, options) + + verify(activityManager) + .addApplicationStartInfoCompletionListener(any(), listenerCaptor.capture()) + + val startInfo = createMockApplicationStartInfo() + listenerCaptor.firstValue.accept(startInfo) + + verify(scopes).captureTransaction(transactionCaptor.capture(), anyOrNull(), anyOrNull()) + assertEquals("app.start", transactionCaptor.firstValue.transaction) + } + + @Test + fun `does not register on API lower than 35`() { + whenever(buildInfoProvider.sdkInfoVersion).thenReturn(34) + val integration = ApplicationStartInfoIntegration(context, buildInfoProvider) + + integration.register(scopes, options) + + verify(activityManager, never()).addApplicationStartInfoCompletionListener(any(), any()) + } + + // Helper methods + private fun createMockApplicationStartInfo( + forkTime: Long = 1000000000L, // nanoseconds + bindApplicationTime: Long = 0L, + firstFrameTime: Long = 0L, + fullyDrawnTime: Long = 0L, + reason: Int = android.app.ApplicationStartInfo.START_REASON_LAUNCHER, + startType: Int = android.app.ApplicationStartInfo.START_TYPE_COLD, + launchMode: Int = android.app.ApplicationStartInfo.LAUNCH_MODE_STANDARD, + ): android.app.ApplicationStartInfo { + val startInfo = mock() + + val timestamps = mutableMapOf() + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_FORK] = forkTime + if (bindApplicationTime > 0) { + timestamps[android.app.ApplicationStartInfo.START_TIMESTAMP_BIND_APPLICATION] = + bindApplicationTime + } + 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(reason) + whenever(startInfo.startType).thenReturn(startType) + whenever(startInfo.launchMode).thenReturn(launchMode) + whenever(startInfo.startupTimestamps).thenReturn(timestamps) + + return startInfo + } +}