diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d429d0da..8f7afbc22e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ sentry: enable-database-transaction-tracing: true ``` +- 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..3a23118900 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,9 @@ 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 isEnableApplicationStartInfoMetrics ()Z + public fun isEnableApplicationStartInfoTracing ()Z public fun isEnableAutoActivityLifecycleTracing ()Z public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z @@ -381,6 +390,9 @@ 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 setEnableApplicationStartInfoMetrics (Z)V + public fun setEnableApplicationStartInfoTracing (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V @@ -536,14 +548,18 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { public static final field LAST_ANR_MARKER_LABEL Ljava/lang/String; public static final field LAST_ANR_REPORT Ljava/lang/String; + public static final field LAST_APP_START_MARKER_LABEL Ljava/lang/String; + public static final field LAST_APP_START_REPORT Ljava/lang/String; public static final field LAST_TOMBSTONE_MARKER_LABEL Ljava/lang/String; public static final field LAST_TOMBSTONE_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z public static fun lastReportedAnr (Lio/sentry/SentryOptions;)Ljava/lang/Long; + public static fun lastReportedAppStart (Lio/sentry/SentryOptions;)Ljava/lang/Long; public static fun lastReportedTombstone (Lio/sentry/SentryOptions;)Ljava/lang/Long; public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public static fun storeAppStartTimestamp (Lio/sentry/SentryOptions;J)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z } @@ -594,6 +610,9 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getApplicationStartInfo ()Landroid/app/ApplicationStartInfo; + public fun getApplicationStartInfoTags ()Ljava/util/Map; + public fun getApplicationStartInfoUnixOffsetMs ()J public fun getClassLoadedUptimeMs ()J public fun getContentProviderOnCreateTimeSpans ()Ljava/util/List; public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; @@ -616,6 +635,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setApplicationStartInfo (Landroid/app/ApplicationStartInfo;Ljava/util/Map;J)V public fun setClassLoadedUptimeMs (J)V public fun shouldSendStartMeasurements ()Z } 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..cc2bc233d9 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoIntegration.java @@ -0,0 +1,329 @@ +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.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.performance.AppStartMetrics; +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +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 final @NotNull List processors = + new CopyOnWriteArrayList<>(); + 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; + } + + if (options.isEnableApplicationStartInfoTracing()) { + addProcessor(new ApplicationStartInfoTracingProcessor(options)); + } + if (options.isEnableApplicationStartInfoMetrics()) { + addProcessor(new ApplicationStartInfoMetricsProcessor(options)); + } + + try { + options + .getExecutorService() + .submit( + () -> { + try (final ISentryLifecycleToken ignored = startLock.acquire()) { + if (!isClosed) { + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + if (activityManager == null) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to retrieve ActivityManager."); + return; + } + + processHistoricalAppStarts(activityManager, scopes, options); + registerAppStartListener(activityManager, 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 ActivityManager activityManager, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + 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 processHistoricalAppStarts( + final @NotNull ActivityManager activityManager, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + + try { + final List historicalStarts = + activityManager.getHistoricalProcessStartReasons(0); + + if (historicalStarts.isEmpty()) { + options.getLogger().log(SentryLevel.DEBUG, "No historical app starts available."); + return; + } + + final Long lastReportedTimestamp = AndroidEnvelopeCache.lastReportedAppStart(options); + + for (ApplicationStartInfo startInfo : historicalStarts) { + if (lastReportedTimestamp != null) { + final Long forkTime = + startInfo.getStartupTimestamps().get(ApplicationStartInfo.START_TIMESTAMP_FORK); + if (forkTime != null && forkTime <= lastReportedTimestamp) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Skipping already reported historical app start: %d", + forkTime); + continue; + } + } + + if (!isCompleted(startInfo)) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Historical app start not completed, skipping"); + continue; + } + + processAppStartInfo(startInfo, scopes, options); + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to process historical ApplicationStartInfo.", e); + } + } + + @RequiresApi(api = 35) + private void onApplicationStartInfoAvailable( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + + if (!isCompleted(startInfo)) { + options.getLogger().log(SentryLevel.DEBUG, "App start not completed (no TTID), skipping"); + return; + } + + processAppStartInfo(startInfo, scopes, options); + } + + @RequiresApi(api = 35) + private boolean isCompleted(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long ttid = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME); + return ttid != null && ttid > 0; + } + + @RequiresApi(api = 35) + private void processAppStartInfo( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull IScopes scopes, + final @NotNull SentryAndroidOptions options) { + + final Map tags = extractTags(startInfo); + + final long currentUnixMs = System.currentTimeMillis(); + final long currentRealtimeMs = SystemClock.elapsedRealtime(); + final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; + AppStartMetrics.getInstance().setApplicationStartInfo(startInfo, tags, unixTimeOffsetMs); + + for (IApplicationStartInfoProcessor processor : processors) { + try { + processor.process(startInfo, tags, scopes); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Processor failed", e); + } + } + + final Long forkTime = + startInfo.getStartupTimestamps().get(ApplicationStartInfo.START_TIMESTAMP_FORK); + if (forkTime != null) { + AndroidEnvelopeCache.storeAppStartTimestamp(options, forkTime); + } + } + + private void addProcessor(final @NotNull IApplicationStartInfoProcessor processor) { + processors.add(processor); + } + + @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"; + } + } + + @Override + public void close() throws IOException { + try (final ISentryLifecycleToken ignored = startLock.acquire()) { + isClosed = true; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java new file mode 100644 index 0000000000..6fe63032e1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoMetricsProcessor.java @@ -0,0 +1,37 @@ +package io.sentry.android.core; + +import android.app.ApplicationStartInfo; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.metrics.SentryMetricsParameters; +import java.util.Map; +import org.jetbrains.annotations.NotNull; + +/** + * Processor that emits counter metrics from ApplicationStartInfo data. + * + *

This processor emits an app.launch counter metric with attributes for start reason, start + * type, and launch mode. + * + *

Requires API level 35 (Android 15) or higher. + */ +@RequiresApi(api = 35) +final class ApplicationStartInfoMetricsProcessor implements IApplicationStartInfoProcessor { + + private final @NotNull SentryAndroidOptions options; + + ApplicationStartInfoMetricsProcessor(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + @Override + public void process( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull Map tags, + final @NotNull IScopes scopes) { + + @SuppressWarnings("unchecked") + final Map attributes = (Map) (Map) tags; + scopes.metrics().count("app.launch", 1.0, null, SentryMetricsParameters.create(attributes)); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java new file mode 100644 index 0000000000..5b7dcf08f5 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationStartInfoTracingProcessor.java @@ -0,0 +1,301 @@ +package io.sentry.android.core; + +import android.app.ApplicationStartInfo; +import android.os.SystemClock; +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import io.sentry.SentryDate; +import io.sentry.SentryNanotimeDate; +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 java.util.ArrayList; +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.TimeUnit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Processor that generates app.start transactions from ApplicationStartInfo data. + * + *

This processor creates a transaction with spans representing the app startup timeline, + * including bind_application, content provider initialization, application onCreate, activity + * lifecycle, TTID, and TTFD. + * + *

Requires API level 35 (Android 15) or higher. + */ +@RequiresApi(api = 35) +final class ApplicationStartInfoTracingProcessor implements IApplicationStartInfoProcessor { + + private final @NotNull SentryAndroidOptions options; + + ApplicationStartInfoTracingProcessor(final @NotNull SentryAndroidOptions options) { + this.options = options; + } + + @Override + public void process( + final @NotNull ApplicationStartInfo startInfo, + final @NotNull Map tags, + final @NotNull IScopes scopes) { + + final long currentUnixMs = System.currentTimeMillis(); + final long currentRealtimeMs = SystemClock.elapsedRealtime(); + final long unixTimeOffsetMs = currentUnixMs - currentRealtimeMs; + + 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 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 @Nullable 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 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)); + } + } + + 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)); + } + } + } + + private static double dateToSeconds(final @NotNull SentryDate date) { + return date.nanoTimestamp() / 1e9; + } + + private static @NotNull SentryDate dateFromUnixTime(final long timeMillis) { + return new SentryNanotimeDate(new Date(timeMillis), 0); + } + + private long getStartTimestampMs(final @NotNull ApplicationStartInfo startInfo) { + final Map timestamps = startInfo.getStartupTimestamps(); + final Long forkTime = timestamps.get(ApplicationStartInfo.START_TIMESTAMP_FORK); + return forkTime != null ? TimeUnit.NANOSECONDS.toMillis(forkTime) : 0; + } + + private long getBindApplicationTimestampMs(final @NotNull 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; + } + + private long getFirstFrameTimestampMs(final @NotNull 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; + } + + private long getFullyDrawnTimestampMs(final @NotNull 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; + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java new file mode 100644 index 0000000000..3eb868914e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/IApplicationStartInfoProcessor.java @@ -0,0 +1,32 @@ +package io.sentry.android.core; + +import androidx.annotation.RequiresApi; +import io.sentry.IScopes; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Interface for processing ApplicationStartInfo data from Android system. + * + *

Processors are registered with {@link ApplicationStartInfoIntegration} and are called when app + * start data becomes available, either from historical data or the current app start. + * + *

Requires API level 35 (Android 15) or higher. + */ +@ApiStatus.Internal +interface IApplicationStartInfoProcessor { + + /** + * Process the ApplicationStartInfo data. + * + * @param startInfo The ApplicationStartInfo from Android system + * @param tags Extracted tags (start.reason, start.type, start.launch_mode) + * @param scopes The Sentry scopes for capturing events + */ + @RequiresApi(api = 35) + void process( + @NotNull android.app.ApplicationStartInfo startInfo, + @NotNull Map tags, + @NotNull IScopes scopes); +} 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..85cd612d7b 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,37 @@ 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; + + /** + * Controls whether to generate transactions from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + *

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

Default is false (opt-in). + */ + @ApiStatus.Experimental private boolean enableApplicationStartInfoTracing = false; + + /** + * Controls whether to emit metrics from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + *

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

Default is false (opt-in). + */ + @ApiStatus.Experimental private boolean enableApplicationStartInfoMetrics = false; + public SentryAndroidOptions() { setSentryClientName(BuildConfig.SENTRY_ANDROID_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); @@ -337,6 +368,69 @@ 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; + } + + /** + * Controls whether to generate transactions from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + * @param enable true to enable transaction generation, false to disable + */ + @ApiStatus.Experimental + public void setEnableApplicationStartInfoTracing(final boolean enable) { + this.enableApplicationStartInfoTracing = enable; + } + + /** + * Checks if ApplicationStartInfo tracing is enabled or disabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableApplicationStartInfoTracing() { + return enableApplicationStartInfoTracing; + } + + /** + * Controls whether to emit metrics from ApplicationStartInfo data. Requires {@link + * #enableApplicationStartInfo} to be true. + * + * @param enable true to enable metrics emission, false to disable + */ + @ApiStatus.Experimental + public void setEnableApplicationStartInfoMetrics(final boolean enable) { + this.enableApplicationStartInfoMetrics = enable; + } + + /** + * Checks if ApplicationStartInfo metrics are enabled or disabled. Default is disabled. + * + * @return true if enabled or false otherwise + */ + @ApiStatus.Experimental + public boolean isEnableApplicationStartInfoMetrics() { + return enableApplicationStartInfoMetrics; + } + public boolean isEnableActivityLifecycleBreadcrumbs() { return enableActivityLifecycleBreadcrumbs; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 5aad7ef1b2..74bba7c25f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -35,6 +35,7 @@ public final class AndroidEnvelopeCache extends EnvelopeCache { public static final String LAST_ANR_REPORT = "last_anr_report"; public static final String LAST_TOMBSTONE_REPORT = "last_tombstone_report"; + public static final String LAST_APP_START_REPORT = "last_app_start_report"; private final @NotNull ICurrentDateProvider currentDateProvider; @@ -209,6 +210,29 @@ private void writeLastReportedMarker( return lastReportedMarker(options, LAST_TOMBSTONE_REPORT, LAST_TOMBSTONE_MARKER_LABEL); } + public static @Nullable Long lastReportedAppStart(final @NotNull SentryOptions options) { + return lastReportedMarker(options, LAST_APP_START_REPORT, LAST_APP_START_MARKER_LABEL); + } + + public static void storeAppStartTimestamp( + final @NotNull SentryOptions options, final long timestamp) { + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + options + .getLogger() + .log(DEBUG, "Cache dir path is null, the App Start marker will not be written"); + return; + } + + final File marker = new File(cacheDirPath, LAST_APP_START_REPORT); + try (final OutputStream outputStream = new FileOutputStream(marker)) { + outputStream.write(String.valueOf(timestamp).getBytes(UTF_8)); + outputStream.flush(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Error writing the App Start marker to the disk", e); + } + } + private static final class TimestampMarkerHandler { interface TimestampExtractor { @NotNull @@ -254,6 +278,7 @@ void handle( public static final String LAST_TOMBSTONE_MARKER_LABEL = "Tombstone"; public static final String LAST_ANR_MARKER_LABEL = "ANR"; + public static final String LAST_APP_START_MARKER_LABEL = "App Start"; private static final List> TIMESTAMP_MARKER_HANDLERS = Arrays.asList( new TimestampMarkerHandler<>( 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 1bb95b9061..a9587f57c9 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 @@ -85,6 +85,11 @@ public enum AppStartType { private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + // ApplicationStartInfo data (API 35+) + private volatile @Nullable ApplicationStartInfo applicationStartInfo = null; + private volatile @Nullable Map applicationStartInfoTags = null; + private volatile long applicationStartInfoUnixOffsetMs = 0; + public static @NotNull AppStartMetrics getInstance() { if (instance == null) { try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { @@ -235,6 +240,43 @@ long getFirstIdle() { return firstIdle; } + /** + * Store ApplicationStartInfo data for later access. + * + * @param info The ApplicationStartInfo from Android system + * @param tags Extracted tags (start.reason, start.type, start.launch_mode) + * @param unixOffsetMs Offset to convert realtime to unix time + */ + public void setApplicationStartInfo( + final @Nullable ApplicationStartInfo info, + final @Nullable Map tags, + final long unixOffsetMs) { + this.applicationStartInfo = info; + this.applicationStartInfoTags = tags; + this.applicationStartInfoUnixOffsetMs = unixOffsetMs; + } + + /** + * @return The stored ApplicationStartInfo, or null if not available + */ + public @Nullable ApplicationStartInfo getApplicationStartInfo() { + return applicationStartInfo; + } + + /** + * @return The extracted tags from ApplicationStartInfo, or null if not available + */ + public @Nullable Map getApplicationStartInfoTags() { + return applicationStartInfoTags; + } + + /** + * @return The unix time offset for ApplicationStartInfo timestamps + */ + public long getApplicationStartInfoUnixOffsetMs() { + return applicationStartInfoUnixOffsetMs; + } + @TestOnly public void clear() { appStartType = AppStartType.UNKNOWN; @@ -258,6 +300,9 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + applicationStartInfo = null; + applicationStartInfoTags = null; + applicationStartInfoUnixOffsetMs = 0; } public @Nullable ITransactionProfiler getAppStartProfiler() { 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 + } +}