From 8211378e53e3ad7d397f91c7915d84d85399c683 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Wed, 15 Apr 2026 17:24:38 +0200 Subject: [PATCH] feat: TTID pre-launch capture for cross-platfrom SDKs support --- dd-sdk-android-internal/api/apiSurface | 34 + .../api/dd-sdk-android-internal.api | 59 ++ .../android/rum/AppLaunchPreInitCollector.kt | 408 +++++++++ .../rum/startup/RumFirstDrawTimeReporter.kt | 17 + .../startup/RumFirstDrawTimeReporterImpl.kt | 33 +- .../rum/startup/WindowCallbacksRegistry.kt | 32 +- ...ppLaunchPreInitCollectorConcurrencyTest.kt | 134 +++ .../rum/AppLaunchPreInitCollectorTest.kt | 849 ++++++++++++++++++ .../RumFirstDrawTimeReporterImplTest.kt | 78 +- .../startup/WindowCallbacksRegistryTest.kt | 24 +- .../api/apiSurface | 0 .../api/compiler-meta.txt | 2 + .../api/dd-sdk-android-rum-prelaunch.api | 0 .../build.gradle.kts | 78 ++ .../src/main/AndroidManifest.xml | 18 + .../prelaunch/AppLaunchCollectorProvider.kt | 77 ++ .../AppLaunchCollectorProviderTest.kt | 126 +++ .../rum/prelaunch/forge/Configurator.kt | 18 + .../transitiveDependencies | 7 + .../android/rum/internal/RumFeature.kt | 161 +++- .../startup/RumFirstDrawTimeReporter.kt | 32 - .../internal/RumFeaturePreInitStartupTest.kt | 547 +++++++++++ .../internal/RumFeatureStartupDetectorTest.kt | 2 +- 23 files changed, 2584 insertions(+), 152 deletions(-) create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/rum/AppLaunchPreInitCollector.kt create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporter.kt rename {features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal => dd-sdk-android-internal/src/main/java/com/datadog/android/rum}/startup/RumFirstDrawTimeReporterImpl.kt (74%) rename features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/utils/window/RumWindowCallbacksRegistry.kt => dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt (61%) create mode 100644 dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorConcurrencyTest.kt create mode 100644 dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorTest.kt rename features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterTest.kt => dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt (74%) rename features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/utils/RumWindowCallbacksRegistryTest.kt => dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/WindowCallbacksRegistryTest.kt (85%) create mode 100644 features/dd-sdk-android-rum-prelaunch/api/apiSurface create mode 100644 features/dd-sdk-android-rum-prelaunch/api/compiler-meta.txt create mode 100644 features/dd-sdk-android-rum-prelaunch/api/dd-sdk-android-rum-prelaunch.api create mode 100644 features/dd-sdk-android-rum-prelaunch/build.gradle.kts create mode 100644 features/dd-sdk-android-rum-prelaunch/src/main/AndroidManifest.xml create mode 100644 features/dd-sdk-android-rum-prelaunch/src/main/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProvider.kt create mode 100644 features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProviderTest.kt create mode 100644 features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/forge/Configurator.kt create mode 100644 features/dd-sdk-android-rum-prelaunch/transitiveDependencies delete mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporter.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeaturePreInitStartupTest.kt diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 99b41ea986..c146a38931 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -246,6 +246,23 @@ fun Thread.State.asString(): String fun Array.loggableStackTrace(): String fun Throwable.loggableStackTrace(): String fun formatIsoUtc(Long): String +object com.datadog.android.rum.AppLaunchPreInitCollector + enum State + - NOT_INSTALLED + - IDLE + - CAPTURING + - COMPLETE + - CLAIMED + val state: State + var processStartNs: Long + var activityOnCreateNs: Long + var firstFrameNs: Long + var hasSavedInstanceState: Boolean + var isFirstActivityForProcess: Boolean + var activity: java.lang.ref.WeakReference? + fun install(android.app.Application) + fun claim(): Boolean + fun addFirstFrameCallback((Long) -> Unit) class com.datadog.android.rum.DdRumContentProvider : android.content.ContentProvider override fun onCreate(): Boolean override fun query(android.net.Uri, Array?, String?, Array?, String?): android.database.Cursor? @@ -256,5 +273,22 @@ class com.datadog.android.rum.DdRumContentProvider : android.content.ContentProv companion object var processImportance: Int var createTimeNs: Long +interface com.datadog.android.rum.startup.RumFirstDrawTimeReporter + interface Callback + fun onFirstFrameDrawn(Long) + fun subscribeToFirstFrameDrawn(android.app.Activity, Callback) +class com.datadog.android.rum.startup.RumFirstDrawTimeReporterImpl : RumFirstDrawTimeReporter + constructor(() -> Long, WindowCallbacksRegistry, android.os.Handler, String = "DD/AppLaunch", (String) -> Unit = { message, throwable -> + Log.w(logTag, message, throwable) + }) + override fun subscribeToFirstFrameDrawn(android.app.Activity, RumFirstDrawTimeReporter.Callback) +interface com.datadog.android.rum.startup.WindowCallbackListener + fun onContentChanged() +interface com.datadog.android.rum.startup.WindowCallbacksRegistry + fun addListener(android.app.Activity, WindowCallbackListener) + fun removeListener(android.app.Activity, WindowCallbackListener) +class com.datadog.android.rum.startup.WindowCallbacksRegistryImpl : WindowCallbacksRegistry + override fun addListener(android.app.Activity, WindowCallbackListener) + override fun removeListener(android.app.Activity, WindowCallbackListener) annotation com.datadog.tools.annotation.NoOpImplementation constructor(Boolean = false) diff --git a/dd-sdk-android-internal/api/dd-sdk-android-internal.api b/dd-sdk-android-internal/api/dd-sdk-android-internal.api index b7841409bd..ffdc8dc722 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -572,6 +572,36 @@ public final class com/datadog/android/internal/utils/TimeExtKt { public static final fun formatIsoUtc (J)Ljava/lang/String; } +public final class com/datadog/android/rum/AppLaunchPreInitCollector { + public static final field INSTANCE Lcom/datadog/android/rum/AppLaunchPreInitCollector; + public final fun addFirstFrameCallback (Lkotlin/jvm/functions/Function1;)V + public final fun claim ()Z + public final fun getActivity ()Ljava/lang/ref/WeakReference; + public final fun getActivityOnCreateNs ()J + public final fun getFirstFrameNs ()J + public final fun getHasSavedInstanceState ()Z + public final fun getProcessStartNs ()J + public final fun getState ()Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public final fun install (Landroid/app/Application;)V + public final fun isFirstActivityForProcess ()Z + public final fun setActivity (Ljava/lang/ref/WeakReference;)V + public final fun setActivityOnCreateNs (J)V + public final fun setFirstActivityForProcess (Z)V + public final fun setFirstFrameNs (J)V + public final fun setHasSavedInstanceState (Z)V + public final fun setProcessStartNs (J)V +} + +public final class com/datadog/android/rum/AppLaunchPreInitCollector$State : java/lang/Enum { + public static final field CAPTURING Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public static final field CLAIMED Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public static final field COMPLETE Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public static final field IDLE Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public static final field NOT_INSTALLED Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; + public static fun values ()[Lcom/datadog/android/rum/AppLaunchPreInitCollector$State; +} + public final class com/datadog/android/rum/DdRumContentProvider : android/content/ContentProvider { public static final field Companion Lcom/datadog/android/rum/DdRumContentProvider$Companion; public fun ()V @@ -590,6 +620,35 @@ public final class com/datadog/android/rum/DdRumContentProvider$Companion { public final fun setProcessImportance (I)V } +public abstract interface class com/datadog/android/rum/startup/RumFirstDrawTimeReporter { + public abstract fun subscribeToFirstFrameDrawn (Landroid/app/Activity;Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Callback;)V +} + +public abstract interface class com/datadog/android/rum/startup/RumFirstDrawTimeReporter$Callback { + public abstract fun onFirstFrameDrawn (J)V +} + +public final class com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl : com/datadog/android/rum/startup/RumFirstDrawTimeReporter { + public fun (Lkotlin/jvm/functions/Function0;Lcom/datadog/android/rum/startup/WindowCallbacksRegistry;Landroid/os/Handler;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lkotlin/jvm/functions/Function0;Lcom/datadog/android/rum/startup/WindowCallbacksRegistry;Landroid/os/Handler;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun subscribeToFirstFrameDrawn (Landroid/app/Activity;Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Callback;)V +} + +public abstract interface class com/datadog/android/rum/startup/WindowCallbackListener { + public abstract fun onContentChanged ()V +} + +public abstract interface class com/datadog/android/rum/startup/WindowCallbacksRegistry { + public abstract fun addListener (Landroid/app/Activity;Lcom/datadog/android/rum/startup/WindowCallbackListener;)V + public abstract fun removeListener (Landroid/app/Activity;Lcom/datadog/android/rum/startup/WindowCallbackListener;)V +} + +public final class com/datadog/android/rum/startup/WindowCallbacksRegistryImpl : com/datadog/android/rum/startup/WindowCallbacksRegistry { + public fun ()V + public fun addListener (Landroid/app/Activity;Lcom/datadog/android/rum/startup/WindowCallbackListener;)V + public fun removeListener (Landroid/app/Activity;Lcom/datadog/android/rum/startup/WindowCallbackListener;)V +} + public abstract interface annotation class com/datadog/tools/annotation/NoOpImplementation : java/lang/annotation/Annotation { public abstract fun publicNoOpImplementation ()Z } diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/AppLaunchPreInitCollector.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/AppLaunchPreInitCollector.kt new file mode 100644 index 0000000000..659b23033a --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/AppLaunchPreInitCollector.kt @@ -0,0 +1,408 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.os.SystemClock +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.datadog.android.internal.system.BuildSdkVersionProvider +import com.datadog.android.rum.startup.RumFirstDrawTimeReporter +import com.datadog.android.rum.startup.RumFirstDrawTimeReporterImpl +import com.datadog.android.rum.startup.WindowCallbacksRegistryImpl +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration.Companion.seconds + +/** + * Singleton state machine that collects app launch timing data before the RUM SDK is initialized. + * + * Designed for cross-platform scenarios (React Native, Flutter) where the native Android RUM SDK + * may be initialized after the first Activity has already been created. By installing this + * collector in a [ContentProvider] that runs before SDK initialization, timing data (process start, + * first Activity onCreate, first frame drawn) is captured unconditionally and then handed off to + * the RUM SDK when it eventually initializes. + * + * **State machine:** + * ``` + * NOT_INSTALLED --> IDLE --> CAPTURING --> COMPLETE + * \-> CLAIMED (SDK claimed before first Activity) + * ``` + * + * All state transitions use CAS (compare-and-set) for lock-free thread safety. + * + * This collector intentionally stores NO RUM SDK types — only primitives, [Boolean] flags, and a + * [java.lang.ref.WeakReference] to an [android.app.Activity]. It is single-use per process: there + * is no automatic reset for warm re-launches, as each process has exactly one cold-start lifetime. + */ +object AppLaunchPreInitCollector { + + /** + * States of the pre-init collector's lifecycle state machine. + */ + enum class State { + /** Initial state before [install] is called. */ + NOT_INSTALLED, + + /** [install] has been called; waiting for first Activity onCreate. */ + IDLE, + + /** First Activity onCreate has been intercepted; capturing timing data. */ + CAPTURING, + + /** First frame has been drawn; all timing data is complete and ready for consumption. */ + COMPLETE, + + /** + * The RUM SDK claimed the collector before the first Activity was created. + * Timing data will be provided by the SDK's own instrumentation instead. + */ + CLAIMED + } + + private val _state = AtomicReference(State.NOT_INSTALLED) + + /** + * The current state of the collector. + * + * Thread-safe: backed by an [AtomicReference]; the returned value reflects the most + * recently committed state transition. + */ + val state: State get() = _state.get() + + // region Data fields — public so RumFeature (dd-sdk-android-rum, separate Gradle module) can read them. + // These are write-once: written during the IDLE->CAPTURING transition, then read after COMPLETE. + + /** + * The process start time in nanoseconds (monotonic clock, [System.nanoTime] epoch). + * + * Written once during the IDLE→CAPTURING transition. Read-only after [State.COMPLETE]. + * `0L` if the collector has not yet entered [State.CAPTURING]. + */ + @Volatile + var processStartNs: Long = 0L + + /** + * The timestamp of the first Activity's `onCreate` in nanoseconds ([System.nanoTime]). + * + * Written once during the IDLE→CAPTURING transition. Read-only after [State.COMPLETE]. + * `0L` if the collector has not yet entered [State.CAPTURING]. + */ + @Volatile + var activityOnCreateNs: Long = 0L + + /** + * The timestamp when the first frame was drawn in nanoseconds ([System.nanoTime]). + * + * Written once when the [android.view.ViewTreeObserver.OnDrawListener] fires for the first + * time. Read-only after [State.COMPLETE]. `0L` until [State.COMPLETE] is reached. + */ + @Volatile + var firstFrameNs: Long = 0L + + /** + * Whether the first Activity was restored from a saved instance state. + * + * `true` if `savedInstanceState` was non-null in the Activity lifecycle callback. + * Written once during the IDLE→CAPTURING transition. + */ + @Volatile + var hasSavedInstanceState: Boolean = false + + /** + * Whether the first Activity was the very first Activity created in this process. + * + * `false` if any Activity was destroyed before the collector captured the first frame, + * indicating a warm re-launch rather than a cold start. + * Written once during the IDLE→CAPTURING transition. + */ + @Volatile + var isFirstActivityForProcess: Boolean = true + + /** + * Weak reference to the first Activity captured during launch. + * + * Written once during the IDLE→CAPTURING transition. The referent may have been + * garbage-collected by the time the RUM SDK reads it; callers must null-check [WeakReference.get]. + */ + @Volatile + var activity: WeakReference? = null + + // endregion + + private val firstFrameCallbacks = CopyOnWriteArrayList<(Long) -> Unit>() + private var _application: Application? = null + + /** Private flag tracking whether any Activity has been destroyed (process is warm). */ + private var _isFirstActivityForProcess: Boolean = true + + /** SDK version provider — injectable for testing. */ + internal var buildSdkVersionProvider: BuildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT + + /** Handler factory — injectable for testing. */ + internal var handlerFactory: () -> Handler = { Handler(Looper.getMainLooper()) } + + /** First-draw time reporter factory — injectable for testing. */ + internal var firstDrawTimeReporterFactory: (Handler) -> RumFirstDrawTimeReporter = { handler -> + RumFirstDrawTimeReporterImpl( + timeProviderNs = { System.nanoTime() }, + windowCallbacksRegistry = WindowCallbacksRegistryImpl(), + handler = handler + ) + } + + // region Lifecycle callback + + private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + + override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) { + if (buildSdkVersionProvider.isAtLeastQ) { + onBeforeActivityCreated(activity, savedInstanceState) + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (!buildSdkVersionProvider.isAtLeastQ) { + onBeforeActivityCreated(activity, savedInstanceState) + } + } + + override fun onActivityStarted(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivityStopped(activity: Activity) {} + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + + override fun onActivityDestroyed(activity: Activity) { + // Once an Activity is destroyed, subsequent activities are not "first for process" + _isFirstActivityForProcess = false + } + } + + // endregion + + // region Private helpers + + private fun onBeforeActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + // CAS: only the first caller transitions IDLE -> CAPTURING; concurrent claim() loses + if (!_state.compareAndSet(State.IDLE, State.CAPTURING)) { + Log.d(TAG, "onBeforeActivityCreated: CAS failed — state is ${_state.get()}, not IDLE; skipping") + return + } + + activityOnCreateNs = System.nanoTime() + hasSavedInstanceState = savedInstanceState != null + this.activity = WeakReference(activity) + isFirstActivityForProcess = _isFirstActivityForProcess + processStartNs = computeProcessStartNs() + + Log.d( + TAG, + "IDLE→CAPTURING: activity=${activity.javaClass.simpleName}" + + " hasSavedInstanceState=$hasSavedInstanceState" + + " isFirstActivityForProcess=$isFirstActivityForProcess" + + " processStartNs=$processStartNs" + + " activityOnCreateNs=$activityOnCreateNs" + + " gapMs=${(activityOnCreateNs - processStartNs) / 1_000_000}" + ) + + // Unregister lifecycle callbacks — we've captured what we need from the first Activity + _application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + + // Subscribe to first frame drawn — transitions CAPTURING -> COMPLETE + val handler = handlerFactory() + val reporter = firstDrawTimeReporterFactory(handler) + reporter.subscribeToFirstFrameDrawn(activity, object : RumFirstDrawTimeReporter.Callback { + override fun onFirstFrameDrawn(timestampNs: Long) { + firstFrameNs = timestampNs + _state.compareAndSet(State.CAPTURING, State.COMPLETE) + + Log.d( + TAG, + "CAPTURING->COMPLETE: first frame drawn" + + " firstFrameNs=$firstFrameNs" + + " ttidMs=${(firstFrameNs - activityOnCreateNs) / 1_000_000}" + + " totalMs=${(firstFrameNs - processStartNs) / 1_000_000}" + + " pendingCallbacks=${firstFrameCallbacks.size}" + ) + + // Drain all enqueued callbacks + val callbacks = firstFrameCallbacks.toList() + firstFrameCallbacks.clear() + callbacks.forEach { cb -> cb(firstFrameNs) } + } + }) + } + + /** + * Compute the process start time in nanoseconds. + * + * On API 24+, uses Process.getStartElapsedRealtime() to back-compute from the current + * elapsed realtime clock. Applies a two-direction OEM sanity check: + * - If computed time is after DdRumContentProvider.createTimeNs (impossible), fall back. + * - If computed time is more than 10s before createTimeNs (unreasonable), fall back. + * + * On API 23, falls back directly to DdRumContentProvider.createTimeNs. + */ + @Suppress("NewApi") // Process.getStartElapsedRealtime is guarded by isAtLeastN check + internal fun computeProcessStartNs(): Long { + if (!buildSdkVersionProvider.isAtLeastN) { + return DdRumContentProvider.createTimeNs + } + val nowNs = System.nanoTime() + val nowElapsedMs = SystemClock.elapsedRealtime() + val diffMs = nowElapsedMs - Process.getStartElapsedRealtime() + val computed = nowNs - TimeUnit.MILLISECONDS.toNanos(diffMs) + val fallback = DdRumContentProvider.createTimeNs + val isAfterFallback = computed > fallback + val isTooFarBefore = fallback - computed > PROCESS_START_TO_CP_START_DIFF_THRESHOLD_NS + return if (isAfterFallback || isTooFarBefore) fallback else computed + } + + // endregion + + // region Public API + + /** + * Install the collector into the given [Application]. + * + * Transitions [State.NOT_INSTALLED] → [State.IDLE] and registers + * [Application.ActivityLifecycleCallbacks] to capture the first Activity's creation. + * + * **Idempotent** — if the collector is already in any state other than [State.NOT_INSTALLED], + * this call is a no-op. + * + * Must be called as early as possible in the process lifetime, typically from a + * [android.content.ContentProvider.onCreate] that is registered before the RUM SDK initializes. + * + * @param application The [Application] instance to register lifecycle callbacks on. + */ + fun install(application: Application) { + if (!_state.compareAndSet(State.NOT_INSTALLED, State.IDLE)) { + Log.d(TAG, "install() called but already in state ${_state.get()}, skipping") + return + } + _application = application + application.registerActivityLifecycleCallbacks(lifecycleCallbacks) + Log.d(TAG, "Installed — state: IDLE, ActivityLifecycleCallbacks registered") + } + + /** + * Claim the pre-launch data for use by the RUM SDK. + * + * Transitions [State.IDLE] → [State.CLAIMED] using a CAS operation, and unregisters + * lifecycle callbacks. Called by `RumFeature` when the SDK initializes before the first + * Activity has been created, so the SDK will perform its own TTID measurement instead. + * + * **Race semantics:** Only the first caller that finds the state as [State.IDLE] will succeed. + * Concurrent calls from the Activity lifecycle callbacks lose the CAS and are ignored. + * + * @return `true` if the claim succeeded (state was [State.IDLE]); `false` if the collector was + * in any other state (e.g., already [State.CAPTURING] or [State.CLAIMED]). + */ + fun claim(): Boolean { + val success = _state.compareAndSet(State.IDLE, State.CLAIMED) + if (success) { + _application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + Log.d(TAG, "Claimed — SDK initialized before first Activity (IDLE→CLAIMED); callbacks unregistered") + } else { + Log.d(TAG, "claim() CAS failed — state is ${_state.get()}, not IDLE; collector already in use") + } + return success + } + + /** + * Register a callback to be invoked with the first-frame timestamp. + * + * **TOCTOU-safe:** if the collector is already in [State.COMPLETE], [cb] is invoked + * synchronously on the calling thread before this method returns. Otherwise, [cb] is enqueued + * in a [java.util.concurrent.CopyOnWriteArrayList] and will be drained (on the main thread) + * the moment [State.COMPLETE] is reached. + * + * A double-check pattern closes the TOCTOU window: after enqueuing, the state is re-read. + * If it has since transitioned to [State.COMPLETE], the callback is removed from the list and + * invoked immediately, guaranteeing exactly-once delivery. + * + * @param cb Callback that receives the first-frame timestamp in nanoseconds ([System.nanoTime]). + */ + fun addFirstFrameCallback(cb: (Long) -> Unit) { + if (_state.get() == State.COMPLETE) { + Log.d(TAG, "addFirstFrameCallback: already COMPLETE, invoking callback synchronously") + cb(firstFrameNs) + return + } + firstFrameCallbacks.add(cb) + // Double-check: state may have transitioned to COMPLETE between the first check and the add + if (_state.get() == State.COMPLETE) { + if (firstFrameCallbacks.remove(cb)) { + Log.d(TAG, "addFirstFrameCallback: TOCTOU race — COMPLETE during enqueue, invoking synchronously") + cb(firstFrameNs) + } + } else { + Log.d(TAG, "addFirstFrameCallback: state=${_state.get()}, callback enqueued (${firstFrameCallbacks.size} total)") + } + } + + // endregion + + // region Constants + + internal const val TAG = "DD/AppLaunch" + + /** + * Threshold for the two-direction OEM clock sanity check. + * If computed process start time is more than 10s before DdRumContentProvider.createTimeNs, + * it is considered an OEM bug and createTimeNs is used as fallback. + */ + internal val PROCESS_START_TO_CP_START_DIFF_THRESHOLD_NS = 10.seconds.inWholeNanoseconds + + // endregion + + // region Testing support + + /** + * Reset the collector to its initial state. + * + * Restores all fields to their default values, equivalent to a fresh process start. + * **For use in unit tests only** — production code must never call this method. + */ + @VisibleForTesting + internal fun reset() { + _state.set(State.NOT_INSTALLED) + processStartNs = 0L + activityOnCreateNs = 0L + firstFrameNs = 0L + hasSavedInstanceState = false + isFirstActivityForProcess = true + _isFirstActivityForProcess = true + activity = null + firstFrameCallbacks.clear() + _application = null + buildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT + handlerFactory = { Handler(Looper.getMainLooper()) } + firstDrawTimeReporterFactory = { handler -> + RumFirstDrawTimeReporterImpl( + timeProviderNs = { System.nanoTime() }, + windowCallbacksRegistry = WindowCallbacksRegistryImpl(), + handler = handler + ) + } + } + + // endregion +} diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporter.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporter.kt new file mode 100644 index 0000000000..19a92f5789 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporter.kt @@ -0,0 +1,17 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.startup + +import android.app.Activity + +interface RumFirstDrawTimeReporter { + interface Callback { + fun onFirstFrameDrawn(timestampNs: Long) + } + + fun subscribeToFirstFrameDrawn(activity: Activity, callback: Callback) +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt similarity index 74% rename from features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt rename to dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt index 67967c50a6..b1b4149e31 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -4,22 +4,23 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.startup +package com.datadog.android.rum.startup import android.app.Activity import android.os.Handler import android.os.Message +import android.util.Log import android.view.View import android.view.ViewTreeObserver -import com.datadog.android.api.InternalLogger -import com.datadog.android.rum.internal.utils.window.RumWindowCallbackListener -import com.datadog.android.rum.internal.utils.window.RumWindowCallbacksRegistry -internal class RumFirstDrawTimeReporterImpl( - private val internalLogger: InternalLogger, +class RumFirstDrawTimeReporterImpl( private val timeProviderNs: () -> Long, - private val windowCallbacksRegistry: RumWindowCallbacksRegistry, - private val handler: Handler + private val windowCallbacksRegistry: WindowCallbacksRegistry, + private val handler: Handler, + private val logTag: String = "DD/AppLaunch", + private val warnLogger: (message: String, throwable: Throwable) -> Unit = { message, throwable -> + Log.w(logTag, message, throwable) + } ) : RumFirstDrawTimeReporter { override fun subscribeToFirstFrameDrawn( @@ -30,7 +31,7 @@ internal class RumFirstDrawTimeReporterImpl( val decorView = window.peekDecorView() if (decorView == null) { - val listener = object : RumWindowCallbackListener { + val listener = object : WindowCallbackListener { override fun onContentChanged() { windowCallbacksRegistry.removeListener(activity, this) onDecorViewReady(activity, callback) @@ -90,12 +91,7 @@ internal class RumFirstDrawTimeReporterImpl( try { decorView.viewTreeObserver.removeOnDrawListener(this) } catch (e: IllegalStateException) { - internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.TELEMETRY, - { "RumTTIDReporterImpl unable to remove onDrawListener from viewTreeObserver" }, - e - ) + warnLogger("RumTTIDReporterImpl unable to remove onDrawListener from viewTreeObserver", e) } } } @@ -106,12 +102,7 @@ internal class RumFirstDrawTimeReporterImpl( try { decorView.viewTreeObserver.addOnDrawListener(listener) } catch (e: IllegalStateException) { - internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.TELEMETRY, - { "RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver" }, - e - ) + warnLogger("RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver", e) } } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/utils/window/RumWindowCallbacksRegistry.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt similarity index 61% rename from features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/utils/window/RumWindowCallbacksRegistry.kt rename to dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt index e0c1ceddb4..b282eb3306 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/utils/window/RumWindowCallbacksRegistry.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.utils.window +package com.datadog.android.rum.startup import android.app.Activity import android.view.Window @@ -14,19 +14,19 @@ import java.util.WeakHashMap import kotlin.collections.getOrPut import kotlin.let -internal interface RumWindowCallbackListener { +interface WindowCallbackListener { fun onContentChanged() } -internal interface RumWindowCallbacksRegistry { - fun addListener(activity: Activity, listener: RumWindowCallbackListener) - fun removeListener(activity: Activity, listener: RumWindowCallbackListener) +interface WindowCallbacksRegistry { + fun addListener(activity: Activity, listener: WindowCallbackListener) + fun removeListener(activity: Activity, listener: WindowCallbackListener) } -internal class RumWindowCallbacksRegistryImpl : RumWindowCallbacksRegistry { - private val callbacks = WeakHashMap() +class WindowCallbacksRegistryImpl : WindowCallbacksRegistry { + private val callbacks = WeakHashMap() - override fun addListener(activity: Activity, listener: RumWindowCallbackListener) { + override fun addListener(activity: Activity, listener: WindowCallbackListener) { val callback = callbacks.getOrPut(activity) { activity.window.wrapCallback() } @@ -34,7 +34,7 @@ internal class RumWindowCallbacksRegistryImpl : RumWindowCallbacksRegistry { callback.addListener(listener) } - override fun removeListener(activity: Activity, listener: RumWindowCallbackListener) { + override fun removeListener(activity: Activity, listener: WindowCallbackListener) { callbacks[activity]?.let { it.removeListener(listener) @@ -45,9 +45,9 @@ internal class RumWindowCallbacksRegistryImpl : RumWindowCallbacksRegistry { } } - private fun Window.wrapCallback(): RumWindowCallback { + private fun Window.wrapCallback(): WindowCallback { val currentCallback = callback - val newCallback = RumWindowCallback( + val newCallback = WindowCallback( wrapped = currentCallback ) callback = newCallback @@ -56,23 +56,23 @@ internal class RumWindowCallbacksRegistryImpl : RumWindowCallbacksRegistry { private fun Window.tryToRemoveCallback() { val currentCallback = callback - if (currentCallback is RumWindowCallback && currentCallback in callbacks.values) { + if (currentCallback is WindowCallback && currentCallback in callbacks.values) { callback = currentCallback.wrapped } } } -private class RumWindowCallback( +private class WindowCallback( val wrapped: Window.Callback ) : FixedWindowCallback(wrapped) { - val subscription = DDCoreSubscription.create() + val subscription = DDCoreSubscription.create() - fun addListener(listener: RumWindowCallbackListener) { + fun addListener(listener: WindowCallbackListener) { subscription.addListener(listener) } - fun removeListener(listener: RumWindowCallbackListener) { + fun removeListener(listener: WindowCallbackListener) { subscription.removeListener(listener) } diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorConcurrencyTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorConcurrencyTest.kt new file mode 100644 index 0000000000..d8662fa848 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorConcurrencyTest.kt @@ -0,0 +1,134 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum + +import android.app.Activity +import android.app.Application +import android.os.Handler +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import com.datadog.android.internal.forge.Configurator +import com.datadog.android.internal.system.BuildSdkVersionProvider +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class AppLaunchPreInitCollectorConcurrencyTest { + + @Mock + lateinit var mockApplication: Application + + @Mock + lateinit var mockActivity: Activity + + @Mock + lateinit var mockWindow: Window + + @Mock + lateinit var mockDecorView: View + + @Mock + lateinit var mockViewTreeObserver: ViewTreeObserver + + @Mock + lateinit var mockHandler: Handler + + @Mock + lateinit var stubBuildSdkVersionProvider: BuildSdkVersionProvider + + @BeforeEach + fun `set up`() { + AppLaunchPreInitCollector.buildSdkVersionProvider = stubBuildSdkVersionProvider + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(true) + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(true) + + DdRumContentProvider.createTimeNs = System.nanoTime() + + whenever(mockActivity.window).thenReturn(mockWindow) + whenever(mockWindow.peekDecorView()).thenReturn(mockDecorView) + whenever(mockWindow.decorView).thenReturn(mockDecorView) + whenever(mockDecorView.viewTreeObserver).thenReturn(mockViewTreeObserver) + whenever(mockViewTreeObserver.isAlive).thenReturn(true) + whenever(mockDecorView.isAttachedToWindow).thenReturn(true) + + // CRITICAL: addOnDrawListener must NOT auto-fire onDraw — keeps state at CAPTURING + // long enough for the CAS race to be observable + whenever(mockViewTreeObserver.addOnDrawListener(any())).doAnswer { Unit } + + // Handler must NOT run runnables immediately — deferred so state stays stable during race + whenever(mockHandler.post(any())).doAnswer { true } + + AppLaunchPreInitCollector.handlerFactory = { mockHandler } + } + + @AfterEach + fun `tear down`() { + AppLaunchPreInitCollector.reset() + } + + @RepeatedTest(50) + fun `M exactly one winner W concurrent IDLE to CAPTURING vs IDLE to CLAIMED`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + val gate = CountDownLatch(1) + val winnerCount = AtomicInteger(0) + + val t1 = thread(isDaemon = false) { + gate.await() + callbacks.onActivityPreCreated(mockActivity, null) + if (AppLaunchPreInitCollector.state == AppLaunchPreInitCollector.State.CAPTURING) { + winnerCount.incrementAndGet() + } + } + val t2 = thread(isDaemon = false) { + gate.await() + if (AppLaunchPreInitCollector.claim()) winnerCount.incrementAndGet() + } + + // When + gate.countDown() + t1.join(2_000) + t2.join(2_000) + + // Then + assertThat(t1.isAlive).isFalse() + assertThat(t2.isAlive).isFalse() + assertThat(winnerCount.get()).isEqualTo(1) + assertThat(AppLaunchPreInitCollector.state).isIn( + AppLaunchPreInitCollector.State.CAPTURING, + AppLaunchPreInitCollector.State.CLAIMED + ) + } +} diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorTest.kt new file mode 100644 index 0000000000..2c46230061 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorTest.kt @@ -0,0 +1,849 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.os.Handler +import android.os.Message +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import com.datadog.android.internal.forge.Configurator +import com.datadog.android.internal.system.BuildSdkVersionProvider +import com.datadog.android.rum.startup.RumFirstDrawTimeReporter +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class AppLaunchPreInitCollectorTest { + + @Mock + lateinit var mockApplication: Application + + @Mock + lateinit var mockActivity: Activity + + @Mock + lateinit var mockWindow: Window + + @Mock + lateinit var mockDecorView: View + + @Mock + lateinit var mockViewTreeObserver: ViewTreeObserver + + @Mock + lateinit var mockHandler: Handler + + @Mock + lateinit var stubBuildSdkVersionProvider: BuildSdkVersionProvider + + private var fakeCreateTimeNs: Long = 0L + + @BeforeEach + fun `set up`() { + // Inject mock build SDK version provider + AppLaunchPreInitCollector.buildSdkVersionProvider = stubBuildSdkVersionProvider + + // Default: API 29+ + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(true) + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(true) + + // Set up a known createTimeNs value + fakeCreateTimeNs = System.nanoTime() + DdRumContentProvider.createTimeNs = fakeCreateTimeNs + + // Set up activity -> window -> decorView chain + whenever(mockActivity.window).thenReturn(mockWindow) + whenever(mockWindow.peekDecorView()).thenReturn(mockDecorView) + whenever(mockWindow.decorView).thenReturn(mockDecorView) + whenever(mockDecorView.viewTreeObserver).thenReturn(mockViewTreeObserver) + whenever(mockViewTreeObserver.isAlive).thenReturn(true) + whenever(mockDecorView.isAttachedToWindow).thenReturn(true) + + // Inject handler factory so tests can control handler behavior + AppLaunchPreInitCollector.handlerFactory = { mockHandler } + + // By default, handler.post runs the runnable immediately (synchronous in tests) + whenever(mockHandler.post(any())).doAnswer { + val runnable = it.getArgument(0) + runnable.run() + true + } + + // handler.sendMessageAtFrontOfQueue runs the message callback immediately (synchronous in tests) + // RumFirstDrawTimeReporterImpl uses this to invoke onFirstFrameDrawn + whenever(mockHandler.sendMessageAtFrontOfQueue(any())).doAnswer { + val message = it.getArgument(0) + message.callback?.run() + true + } + + // By default, addOnDrawListener immediately fires onDraw (simulates first frame) + // Tests that need to control timing should override this behavior + whenever(mockViewTreeObserver.addOnDrawListener(any())).doAnswer { invocation -> + val listener = invocation.getArgument(0) + listener.onDraw() + Unit + } + } + + @AfterEach + fun `tear down`() { + AppLaunchPreInitCollector.reset() + } + + // region install() + + @Test + fun `M transition to IDLE W install() {NOT_INSTALLED state}`() { + // When + AppLaunchPreInitCollector.install(mockApplication) + + // Then + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.IDLE) + } + + @Test + fun `M register ActivityLifecycleCallbacks W install()`() { + // When + AppLaunchPreInitCollector.install(mockApplication) + + // Then + verify(mockApplication).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `M remain in current state W install() {already IDLE}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + + // When + AppLaunchPreInitCollector.install(mockApplication) + + // Then + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.IDLE) + verify(mockApplication).registerActivityLifecycleCallbacks(any()) + } + + @Test + fun `M remain in current state W install() {already CAPTURING}`() { + // Given: install to IDLE, then test second install is no-op while IDLE + AppLaunchPreInitCollector.install(mockApplication) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.IDLE) + + // install again - should be no-op since state is IDLE (not NOT_INSTALLED) + AppLaunchPreInitCollector.install(mockApplication) + + // registerActivityLifecycleCallbacks should only be called once + verify(mockApplication).registerActivityLifecycleCallbacks(any()) + } + + // endregion + + // region claim() + + @Test + fun `M transition to CLAIMED W claim() {IDLE state}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + + // When + val result = AppLaunchPreInitCollector.claim() + + // Then + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + assertThat(result).isTrue() + } + + @Test + fun `M unregister ActivityLifecycleCallbacks W claim() {IDLE state}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + + // When + AppLaunchPreInitCollector.claim() + + // Then + verify(mockApplication).unregisterActivityLifecycleCallbacks(any()) + } + + @Test + fun `M return true W claim() {IDLE state}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + + // When + val result = AppLaunchPreInitCollector.claim() + + // Then + assertThat(result).isTrue() + } + + @Test + fun `M return false W claim() {NOT_INSTALLED state}`() { + // When + val result = AppLaunchPreInitCollector.claim() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.NOT_INSTALLED) + } + + @Test + fun `M return false W claim() {CLAIMED state}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + AppLaunchPreInitCollector.claim() + + // When + val result = AppLaunchPreInitCollector.claim() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + } + + @Test + fun `M return false W claim() {COMPLETE state}`() { + // Given: drive to COMPLETE via install -> lifecycle -> onDraw (auto-fires in @BeforeEach) + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + callbacks.onActivityPreCreated(mockActivity, null) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + + // When + val result = AppLaunchPreInitCollector.claim() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + } + + @Test + fun `M return false W claim() {CAPTURING state}`() { + // Given: override addOnDrawListener to NOT fire onDraw, keeping state at CAPTURING + val drawListenerCaptor = argumentCaptor() + whenever(mockViewTreeObserver.addOnDrawListener(drawListenerCaptor.capture())).doAnswer { Unit } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + callbacks.onActivityPreCreated(mockActivity, null) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CAPTURING) + + // When + val result = AppLaunchPreInitCollector.claim() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CAPTURING) + } + + @Test + fun `M ignore subsequent activity events W claim() {IDLE to CLAIMED, then onActivityPreCreated}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + AppLaunchPreInitCollector.claim() + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + + // When — fire lifecycle callback directly (exercises CAS guard in onBeforeActivityCreated) + callbacks.onActivityPreCreated(mockActivity, null) + + // Then — state unchanged, no data fields written + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + assertThat(AppLaunchPreInitCollector.activityOnCreateNs).isEqualTo(0L) + assertThat(AppLaunchPreInitCollector.processStartNs).isEqualTo(0L) + assertThat(AppLaunchPreInitCollector.activity).isNull() + } + + // endregion + + // region reset() + + @Test + fun `M restore all fields to initial values W reset()`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + AppLaunchPreInitCollector.processStartNs = 12345L + AppLaunchPreInitCollector.activityOnCreateNs = 67890L + AppLaunchPreInitCollector.firstFrameNs = 11111L + AppLaunchPreInitCollector.hasSavedInstanceState = true + AppLaunchPreInitCollector.isFirstActivityForProcess = false + + // When + AppLaunchPreInitCollector.reset() + + // Then + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.NOT_INSTALLED) + assertThat(AppLaunchPreInitCollector.processStartNs).isEqualTo(0L) + assertThat(AppLaunchPreInitCollector.activityOnCreateNs).isEqualTo(0L) + assertThat(AppLaunchPreInitCollector.firstFrameNs).isEqualTo(0L) + assertThat(AppLaunchPreInitCollector.hasSavedInstanceState).isFalse() + assertThat(AppLaunchPreInitCollector.isFirstActivityForProcess).isTrue() + assertThat(AppLaunchPreInitCollector.activity).isNull() + } + + // endregion + + // region Task 1: API-level lifecycle dispatch and timing + + @Test + fun `M capture activityOnCreateNs W onActivityPreCreated() {API 29+, IDLE state}`() { + // Given + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(true) + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.activityOnCreateNs).isGreaterThan(0L) + } + + @Test + fun `M capture activityOnCreateNs W onActivityCreated() {API 23-28, IDLE state}`() { + // Given + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(false) + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(false) + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.activityOnCreateNs).isGreaterThan(0L) + } + + @Test + fun `M not capture W onActivityCreated() {API 29+}`() { + // Given + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(true) + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When -- onActivityCreated should be a no-op on API 29+ + callbacks.onActivityCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.IDLE) + assertThat(AppLaunchPreInitCollector.activityOnCreateNs).isEqualTo(0L) + } + + @Test + fun `M fall back to DdRumContentProvider createTimeNs W onBeforeActivityCreated() {API 23}`() { + // Given + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(false) + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(false) + val knownCreateTimeNs = 999_000_000L + DdRumContentProvider.createTimeNs = knownCreateTimeNs + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityCreated(mockActivity, null) + + // Then: on API 23, processStartNs must equal DdRumContentProvider.createTimeNs + assertThat(AppLaunchPreInitCollector.processStartNs).isEqualTo(knownCreateTimeNs) + } + + @Test + fun `M fall back to createTimeNs W computeProcessStartNs() {computed gt createTimeNs}`() { + // Given: test the OEM bug guard (computed > fallback should return fallback) + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(false) + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(false) + val knownCreateTimeNs = 500_000_000L + DdRumContentProvider.createTimeNs = knownCreateTimeNs + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When: API 23 path always returns createTimeNs (same as OEM fallback direction 1 test) + callbacks.onActivityCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.processStartNs).isEqualTo(knownCreateTimeNs) + } + + @Test + fun `M compute processStartNs via Process getStartElapsedRealtime W onBeforeActivityCreated() {API 24+}`() { + // Given: API 24+ (isAtLeastN=true, isAtLeastQ=false for simplicity) + whenever(stubBuildSdkVersionProvider.isAtLeastQ).thenReturn(false) + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(true) + // Set createTimeNs to a value in the future so computed (which is ~now - uptime) fits + DdRumContentProvider.createTimeNs = System.nanoTime() + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityCreated(mockActivity, null) + + // Then: processStartNs should be > 0 (either computed or fallback createTimeNs) + assertThat(AppLaunchPreInitCollector.processStartNs).isGreaterThan(0L) + } + + @Test + fun `M set hasSavedInstanceState true W onBeforeActivityCreated() {non-null savedInstanceState}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + val fakeSavedInstanceState = Bundle() + + // When + callbacks.onActivityPreCreated(mockActivity, fakeSavedInstanceState) + + // Then + assertThat(AppLaunchPreInitCollector.hasSavedInstanceState).isTrue() + } + + @Test + fun `M set hasSavedInstanceState false W onBeforeActivityCreated() {null savedInstanceState}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.hasSavedInstanceState).isFalse() + } + + @Test + fun `M set activity weak reference W onBeforeActivityCreated()`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.activity?.get()).isSameAs(mockActivity) + } + + @Test + fun `M set isFirstActivityForProcess true W onBeforeActivityCreated() {first activity}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.isFirstActivityForProcess).isTrue() + } + + @Test + fun `M transition to CAPTURING W onBeforeActivityCreated() {IDLE state}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: state should be CAPTURING or COMPLETE (if onDraw fired synchronously) + // In test environment, handler.post is synchronous, so onDraw fires immediately + // State may be COMPLETE after subscribe since handler.post is immediate + assertThat(AppLaunchPreInitCollector.state).isIn( + AppLaunchPreInitCollector.State.CAPTURING, + AppLaunchPreInitCollector.State.COMPLETE + ) + } + + @Test + fun `M not transition W onBeforeActivityCreated() {CLAIMED state}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + AppLaunchPreInitCollector.claim() + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: state should remain CLAIMED; no data written + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + assertThat(AppLaunchPreInitCollector.activityOnCreateNs).isEqualTo(0L) + } + + @Test + fun `M unregister lifecycle callbacks W onBeforeActivityCreated() {IDLE to CAPTURING}`() { + // Given + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: lifecycle callbacks should be unregistered + verify(mockApplication).unregisterActivityLifecycleCallbacks(any()) + } + + // endregion + + // region Task 2: OnDrawListener chain and addFirstFrameCallback + + @Test + fun `M record firstFrameNs W onDraw() fires {CAPTURING state}`() { + // Given: @BeforeEach configures addOnDrawListener to fire onDraw immediately + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When: trigger lifecycle which sets up the OnDrawListener (fires immediately via mock) + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: firstFrameNs > 0 since onDraw was fired immediately by the mock + assertThat(AppLaunchPreInitCollector.firstFrameNs).isGreaterThan(0L) + } + + @Test + fun `M transition to COMPLETE W onDraw() fires {CAPTURING state}`() { + // Given: @BeforeEach configures addOnDrawListener to fire onDraw immediately + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + } + + @Test + fun `M record firstFrameNs only once W onDraw() fires multiple times`() { + // Given: override default to capture listener without auto-firing so we control timing + val drawListenerCaptor = argumentCaptor() + whenever(mockViewTreeObserver.addOnDrawListener(drawListenerCaptor.capture())).doAnswer { Unit } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + callbacks.onActivityPreCreated(mockActivity, null) + verify(mockViewTreeObserver).addOnDrawListener(any()) + + // Fire onDraw once + drawListenerCaptor.firstValue.onDraw() + val firstFrameNsAfterFirstDraw = AppLaunchPreInitCollector.firstFrameNs + assertThat(firstFrameNsAfterFirstDraw).isGreaterThan(0L) + + // When: fire onDraw again manually + drawListenerCaptor.firstValue.onDraw() + + // Then: firstFrameNs should not change (invoked guard prevents overwrite) + assertThat(AppLaunchPreInitCollector.firstFrameNs).isEqualTo(firstFrameNsAfterFirstDraw) + } + + @Test + fun `M defer removeOnDrawListener via Handler post W onDraw()`() { + // Given: use a handler that does NOT run immediately so we can observe the post call + val deferredRunnables = mutableListOf() + whenever(mockHandler.post(any())).doAnswer { + deferredRunnables.add(it.getArgument(0)) + true + } + // Override to capture listener without auto-firing, so we can fire onDraw manually + val drawListenerCaptor = argumentCaptor() + whenever(mockViewTreeObserver.addOnDrawListener(drawListenerCaptor.capture())).doAnswer { Unit } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + callbacks.onActivityPreCreated(mockActivity, null) + verify(mockViewTreeObserver).addOnDrawListener(any()) + + // Now manually fire onDraw + drawListenerCaptor.firstValue.onDraw() + + // Then: removeOnDrawListener not yet called (deferred) + assertThat(deferredRunnables).isNotEmpty() + + // Run the deferred runnable + deferredRunnables.forEach { it.run() } + + verify(mockViewTreeObserver).removeOnDrawListener(any()) + } + + @Test + fun `M guard removeOnDrawListener with isAlive W onDraw()`() { + // Given: use a deferred handler to control timing + val deferredRunnables = mutableListOf() + whenever(mockHandler.post(any())).doAnswer { + deferredRunnables.add(it.getArgument(0)) + true + } + // Override to capture listener without auto-firing + val drawListenerCaptor = argumentCaptor() + whenever(mockViewTreeObserver.addOnDrawListener(drawListenerCaptor.capture())).doAnswer { Unit } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + callbacks.onActivityPreCreated(mockActivity, null) + verify(mockViewTreeObserver).addOnDrawListener(any()) + drawListenerCaptor.firstValue.onDraw() + + // Simulate viewTreeObserver becoming dead + whenever(mockViewTreeObserver.isAlive).thenReturn(false) + + // When: run deferred runnable + deferredRunnables.forEach { it.run() } + + // Then: removeOnDrawListener should NOT be called since isAlive is false + verify(mockViewTreeObserver, never()).removeOnDrawListener(any()) + } + + @Test + fun `M handle null decor view W subscribeToFirstFrameDrawn() {peekDecorView returns null}`() { + // Given: peekDecorView returns null; inject a mock reporter that fires the callback immediately + whenever(mockWindow.peekDecorView()).thenReturn(null) + AppLaunchPreInitCollector.firstDrawTimeReporterFactory = { _ -> + mock().also { reporter -> + whenever(reporter.subscribeToFirstFrameDrawn(any(), any())).doAnswer { invocation -> + val callback = invocation.getArgument(1) + callback.onFirstFrameDrawn(System.nanoTime()) + Unit + } + } + } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When: should not crash and reporter handles the null decorView case + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: state transitions to COMPLETE (reporter fired the callback) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + assertThat(AppLaunchPreInitCollector.firstFrameNs).isGreaterThan(0L) + } + + @Test + fun `M handle not-yet-attached decor view W subscribeToFirstFrameDrawn() {isAttachedToWindow false}`() { + // Given: decor view exists but not attached + whenever(mockDecorView.isAttachedToWindow).thenReturn(false) + + // Simulate addOnAttachStateChangeListener calling onViewAttachedToWindow immediately + whenever(mockDecorView.addOnAttachStateChangeListener(any())).doAnswer { + val listener = it.getArgument(0) + listener.onViewAttachedToWindow(mockDecorView) + Unit + } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: addOnAttachStateChangeListener was used, and OnDrawListener was registered after attach + verify(mockDecorView).addOnAttachStateChangeListener(any()) + verify(mockViewTreeObserver).addOnDrawListener(any()) + } + + @Test + fun `M guard addOnDrawListener with isAlive and try catch W registerOnDrawListener()`() { + // Given: viewTreeObserver is not alive + whenever(mockViewTreeObserver.isAlive).thenReturn(false) + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // When: should not crash + callbacks.onActivityPreCreated(mockActivity, null) + + // Then: addOnDrawListener is never called + verify(mockViewTreeObserver, never()).addOnDrawListener(any()) + } + + @Test + fun `M invoke callback immediately W addFirstFrameCallback() {COMPLETE state}`() { + // Given: reach COMPLETE state + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + callbacks.onActivityPreCreated(mockActivity, null) + + // Verify we are in COMPLETE state + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + val firstFrameNs = AppLaunchPreInitCollector.firstFrameNs + + // When: add callback after COMPLETE + var capturedNs = -1L + AppLaunchPreInitCollector.addFirstFrameCallback { ns -> capturedNs = ns } + + // Then: callback was invoked immediately with firstFrameNs + assertThat(capturedNs).isEqualTo(firstFrameNs) + } + + @Test + fun `M enqueue callback W addFirstFrameCallback() {CAPTURING state}`() { + // Given: override addOnDrawListener to capture without firing, keeping state at CAPTURING + val drawListenerCaptor = argumentCaptor() + whenever(mockViewTreeObserver.addOnDrawListener(drawListenerCaptor.capture())).doAnswer { Unit } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + callbacks.onActivityPreCreated(mockActivity, null) + verify(mockViewTreeObserver).addOnDrawListener(any()) + + // State should be CAPTURING since onDraw hasn't fired + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CAPTURING) + + var capturedNs = -1L + // When: add callback while CAPTURING + AppLaunchPreInitCollector.addFirstFrameCallback { ns -> capturedNs = ns } + + // Then: callback should not be invoked yet + assertThat(capturedNs).isEqualTo(-1L) + } + + @Test + fun `M drain callbacks on COMPLETE transition W onDraw() {callbacks enqueued}`() { + // Given: override addOnDrawListener to capture without firing, keeping state at CAPTURING + val drawListenerCaptor = argumentCaptor() + whenever(mockViewTreeObserver.addOnDrawListener(drawListenerCaptor.capture())).doAnswer { Unit } + + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + callbacks.onActivityPreCreated(mockActivity, null) + verify(mockViewTreeObserver).addOnDrawListener(any()) + + // Enqueue two callbacks before onDraw fires + var capturedNs1 = -1L + var capturedNs2 = -1L + AppLaunchPreInitCollector.addFirstFrameCallback { ns -> capturedNs1 = ns } + AppLaunchPreInitCollector.addFirstFrameCallback { ns -> capturedNs2 = ns } + + // When: manually fire onDraw + drawListenerCaptor.firstValue.onDraw() + + // Then: both callbacks should have been called with firstFrameNs + assertThat(capturedNs1).isEqualTo(AppLaunchPreInitCollector.firstFrameNs) + assertThat(capturedNs2).isEqualTo(AppLaunchPreInitCollector.firstFrameNs) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + } + + @Test + fun `M close TOCTOU window W addFirstFrameCallback() {race with COMPLETE transition}`() { + // Given: reach COMPLETE state first + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + callbacks.onActivityPreCreated(mockActivity, null) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.COMPLETE) + val firstFrameNs = AppLaunchPreInitCollector.firstFrameNs + + // When: add callback after COMPLETE (TOCTOU: state was COMPLETE when checked) + var callCount = 0 + var capturedNs = -1L + AppLaunchPreInitCollector.addFirstFrameCallback { ns -> + callCount++ + capturedNs = ns + } + + // Then: callback was called exactly once with the correct firstFrameNs + assertThat(callCount).isEqualTo(1) + assertThat(capturedNs).isEqualTo(firstFrameNs) + } + + @Test + fun `M not invoke callback W addFirstFrameCallback() {NOT_INSTALLED state}`() { + // Given: NOT_INSTALLED, no install + var capturedNs = -1L + + // When + AppLaunchPreInitCollector.addFirstFrameCallback { ns -> capturedNs = ns } + + // Then: callback not fired since NOT_INSTALLED / no COMPLETE transition + assertThat(capturedNs).isEqualTo(-1L) + } + + // endregion +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt similarity index 74% rename from features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterTest.kt rename to dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt index d7b0dc21a4..f3f9ac3c7f 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterTest.kt +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.startup +package com.datadog.android.rum.startup import android.app.Activity import android.os.Handler @@ -12,49 +12,32 @@ import android.os.Message import android.view.View import android.view.ViewTreeObserver import android.view.Window -import com.datadog.android.api.InternalLogger -import com.datadog.android.rum.internal.domain.Time -import com.datadog.android.rum.internal.utils.window.RumWindowCallbackListener -import com.datadog.android.rum.internal.utils.window.RumWindowCallbacksRegistry -import com.datadog.android.rum.utils.forge.Configurator -import com.datadog.tools.unit.extensions.TestConfigurationExtension -import fr.xgouchet.elmyr.junit5.ForgeConfiguration -import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow -import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness -import java.lang.ref.WeakReference import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(TestConfigurationExtension::class) -) +@ExtendWith(MockitoExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(Configurator::class) -class RumFirstDrawTimeReporterTest { +class RumFirstDrawTimeReporterImplTest { private var currentTime: Duration = 0.seconds @Mock - private lateinit var windowCallbackRegistry: RumWindowCallbacksRegistry + private lateinit var windowCallbackRegistry: WindowCallbacksRegistry @Mock private lateinit var handler: Handler @@ -74,28 +57,11 @@ class RumFirstDrawTimeReporterTest { @Mock private lateinit var viewTreeObserver: ViewTreeObserver - @Mock - private lateinit var internalLogger: InternalLogger - private lateinit var reporter: RumFirstDrawTimeReporterImpl - private lateinit var weakActivity: WeakReference - - private lateinit var scenario: RumStartupScenario - @BeforeEach fun `set up`() { - weakActivity = WeakReference(activity) - - scenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = true, - activity = weakActivity, - appStartActivityOnCreateGapNs = 0.seconds.inWholeNanoseconds - ) - reporter = RumFirstDrawTimeReporterImpl( - internalLogger = internalLogger, timeProviderNs = { currentTime.inWholeNanoseconds }, windowCallbacksRegistry = windowCallbackRegistry, handler = handler @@ -108,7 +74,7 @@ class RumFirstDrawTimeReporterTest { whenever(decorView.viewTreeObserver) doReturn viewTreeObserver whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { - val argListener = it.getArgument(1) + val argListener = it.getArgument(1) argListener.onContentChanged() } @@ -137,8 +103,8 @@ class RumFirstDrawTimeReporterTest { // Then inOrder(windowCallbackRegistry, callback, viewTreeObserver) { - verify(windowCallbackRegistry).addListener(eq(activity), any()) - verify(windowCallbackRegistry).removeListener(eq(activity), any()) + verify(windowCallbackRegistry).addListener(any(), any()) + verify(windowCallbackRegistry).removeListener(any(), any()) verify(viewTreeObserver).isAlive argumentCaptor { @@ -257,7 +223,7 @@ class RumFirstDrawTimeReporterTest { } @Test - fun `M call internalLogger W addOnDrawListener { if it throws IllegalStateException }`() { + fun `M not call callback W addOnDrawListener { if it throws IllegalStateException }`() { // Given val illegalStateException = IllegalStateException() whenever(viewTreeObserver.addOnDrawListener(any())) doThrow illegalStateException @@ -268,25 +234,15 @@ class RumFirstDrawTimeReporterTest { // Then verifyNoInteractions(callback) - inOrder(viewTreeObserver, internalLogger) { + inOrder(viewTreeObserver) { verify(viewTreeObserver).isAlive verify(viewTreeObserver).addOnDrawListener(any()) - - verify(internalLogger).log( - level = eq(InternalLogger.Level.WARN), - target = eq(InternalLogger.Target.TELEMETRY), - messageBuilder = any(), - throwable = eq(illegalStateException), - onlyOnce = eq(false), - additionalProperties = anyOrNull() - ) - verifyNoMoreInteractions() } } @Test - fun `M call internalLogger W removeOnDrawListener { if it throws IllegalStateException }`() { + fun `M call callback W removeOnDrawListener { if it throws IllegalStateException }`() { // Given val illegalStateException = IllegalStateException() whenever(viewTreeObserver.removeOnDrawListener(any())) doThrow illegalStateException @@ -297,9 +253,7 @@ class RumFirstDrawTimeReporterTest { reporter.subscribeToFirstFrameDrawn(activity, callback) // Then - verifyNoInteractions(callback) - - inOrder(callback, viewTreeObserver, internalLogger) { + inOrder(callback, viewTreeObserver) { verify(viewTreeObserver).isAlive argumentCaptor { @@ -313,16 +267,6 @@ class RumFirstDrawTimeReporterTest { verify(viewTreeObserver).isAlive verify(viewTreeObserver).removeOnDrawListener(any()) - - verify(internalLogger).log( - level = eq(InternalLogger.Level.WARN), - target = eq(InternalLogger.Target.TELEMETRY), - messageBuilder = any(), - throwable = eq(illegalStateException), - onlyOnce = eq(false), - additionalProperties = anyOrNull() - ) - verifyNoMoreInteractions() } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/utils/RumWindowCallbacksRegistryTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/WindowCallbacksRegistryTest.kt similarity index 85% rename from features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/utils/RumWindowCallbacksRegistryTest.kt rename to dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/WindowCallbacksRegistryTest.kt index 56a005476c..af61def639 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/utils/RumWindowCallbacksRegistryTest.kt +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/WindowCallbacksRegistryTest.kt @@ -4,13 +4,11 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.utils +package com.datadog.android.rum.startup import android.app.Activity import android.view.Window import com.datadog.android.internal.utils.FixedWindowCallback -import com.datadog.android.rum.internal.utils.window.RumWindowCallbackListener -import com.datadog.android.rum.internal.utils.window.RumWindowCallbacksRegistryImpl import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -32,7 +30,7 @@ import org.mockito.quality.Strictness ExtendWith(MockitoExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) -class RumWindowCallbacksRegistryTest { +class WindowCallbacksRegistryTest { @Mock private lateinit var activity: Activity @@ -44,11 +42,11 @@ class RumWindowCallbacksRegistryTest { private lateinit var existingCallback: Window.Callback @Mock - private lateinit var listener: RumWindowCallbackListener + private lateinit var listener: WindowCallbackListener private lateinit var callback: Window.Callback - private val registry = RumWindowCallbacksRegistryImpl() + private val registry = WindowCallbacksRegistryImpl() @BeforeEach fun setUp() { @@ -100,7 +98,7 @@ class RumWindowCallbacksRegistryTest { registry.addListener(activity, listener) val callbackAfterListener1 = window.callback - val listener2 = mock() + val listener2 = mock() registry.addListener(activity, listener2) // When @@ -132,10 +130,10 @@ class RumWindowCallbacksRegistryTest { } @Test - fun `M not restore the callback W removeListener { if there is another RumWindowCallbacksRegistry }`() { + fun `M not restore the callback W removeListener { if there is another WindowCallbacksRegistry }`() { // Given - val anotherRegistry = RumWindowCallbacksRegistryImpl() - val anotherListener = mock() + val anotherRegistry = WindowCallbacksRegistryImpl() + val anotherListener = mock() registry.addListener(activity, listener) val callback1 = window.callback @@ -152,10 +150,10 @@ class RumWindowCallbacksRegistryTest { } @Test - fun `M call listeners from both RumWindowCallbacksRegistries W addListener { onContentChanged called }`() { + fun `M call listeners from both WindowCallbacksRegistries W addListener { onContentChanged called }`() { // Given - val anotherRegistry = RumWindowCallbacksRegistryImpl() - val anotherListener = mock() + val anotherRegistry = WindowCallbacksRegistryImpl() + val anotherListener = mock() registry.addListener(activity, listener) val callback1 = window.callback diff --git a/features/dd-sdk-android-rum-prelaunch/api/apiSurface b/features/dd-sdk-android-rum-prelaunch/api/apiSurface new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/dd-sdk-android-rum-prelaunch/api/compiler-meta.txt b/features/dd-sdk-android-rum-prelaunch/api/compiler-meta.txt new file mode 100644 index 0000000000..80973b105c --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/api/compiler-meta.txt @@ -0,0 +1,2 @@ +kotlin_abi_version=1.8.0 +jvm_bytecode_version=11 diff --git a/features/dd-sdk-android-rum-prelaunch/api/dd-sdk-android-rum-prelaunch.api b/features/dd-sdk-android-rum-prelaunch/api/dd-sdk-android-rum-prelaunch.api new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/dd-sdk-android-rum-prelaunch/build.gradle.kts b/features/dd-sdk-android-rum-prelaunch/build.gradle.kts new file mode 100644 index 0000000000..fc757c4373 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/build.gradle.kts @@ -0,0 +1,78 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import com.datadog.gradle.config.androidLibraryConfig +import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig +import com.datadog.gradle.config.javadocConfig +import com.datadog.gradle.config.junitConfig +import com.datadog.gradle.config.kotlinConfig +import com.datadog.gradle.config.publishingConfig +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + // Build + id("com.android.library") + kotlin("android") + + // Publish + `maven-publish` + signing + id("org.jetbrains.dokka-javadoc") + + // Analysis tools + id("com.github.ben-manes.versions") + + // Tests + id("de.mobilej.unmock") + id("org.jetbrains.kotlinx.kover") + + // Internal Generation + id("apiSurface") + id("transitiveDependencies") + id("verificationXml") + id("binary-compatibility-validator") +} + +android { + namespace = "com.datadog.android.rumprelaunch" +} + +dependencies { + implementation(project(":dd-sdk-android-internal")) + implementation(libs.kotlin) + + testImplementation(project(":tools:unit")) { + attributes { + attribute( + com.android.build.api.attributes.ProductFlavorAttr.of("platform"), + objects.named("jvm") + ) + } + } + testImplementation(libs.bundles.jUnit5) + testImplementation(libs.bundles.testTools) + unmock(libs.robolectric) +} + +unMock { + keepStartingWith("org.json") + keep("android.content.ContentProvider") + keep("android.content.IContentProvider") + keep("android.content.ContentProviderNative") + keep("android.net.Uri") +} + +kotlinConfig(jvmBytecodeTarget = JvmTarget.JVM_11) +androidLibraryConfig() +junitConfig() +javadocConfig() +dependencyUpdateConfig() +publishingConfig( + "The RUM pre-launch module that captures app launch timing data before " + + "the Datadog SDK is initialized." +) +detektCustomConfig() diff --git a/features/dd-sdk-android-rum-prelaunch/src/main/AndroidManifest.xml b/features/dd-sdk-android-rum-prelaunch/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..27929b7cc7 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/features/dd-sdk-android-rum-prelaunch/src/main/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProvider.kt b/features/dd-sdk-android-rum-prelaunch/src/main/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProvider.kt new file mode 100644 index 0000000000..e43eeaa872 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/src/main/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProvider.kt @@ -0,0 +1,77 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.prelaunch + +import android.app.ActivityManager +import android.app.Application +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.util.Log +import com.datadog.android.rum.AppLaunchPreInitCollector +import com.datadog.android.rum.DdRumContentProvider + +/** + * [ContentProvider] that auto-installs [AppLaunchPreInitCollector] at process start. + * + * Declared in this module's AndroidManifest so it is automatically merged into any + * app that declares a dependency on `dd-sdk-android-rum-prelaunch`. Apps that do not + * include this module are unaffected: [AppLaunchPreInitCollector] remains in its + * initial [AppLaunchPreInitCollector.State.NOT_INSTALLED] state and the full legacy + * `RumAppStartupDetector` path runs unchanged. + * + * No public API is exposed by this class; users interact with this module by adding + * it as a Gradle dependency only. + */ +internal class AppLaunchCollectorProvider : ContentProvider() { + + override fun onCreate(): Boolean { + val application = context?.applicationContext as? Application ?: run { + Log.w(TAG, "onCreate: applicationContext is null, skipping install") + return false + } + val importance = DdRumContentProvider.processImportance + if (importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { + Log.d(TAG, "onCreate: process is not foreground (importance=$importance), skipping install") + return false + } + Log.d(TAG, "onCreate: foreground process detected, installing AppLaunchPreInitCollector") + AppLaunchPreInitCollector.install(application) + return true + } + + companion object { + // Must match AppLaunchPreInitCollector.TAG for unified logcat filtering + private const val TAG = "DD/AppLaunch" + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array? + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 +} diff --git a/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProviderTest.kt b/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProviderTest.kt new file mode 100644 index 0000000000..560c3a33b2 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProviderTest.kt @@ -0,0 +1,126 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.prelaunch + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import com.datadog.android.rum.AppLaunchPreInitCollector +import com.datadog.android.rum.DdRumContentProvider +import com.datadog.android.rum.prelaunch.forge.Configurator +import com.datadog.tools.unit.setFieldValue +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.atomic.AtomicReference + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class AppLaunchCollectorProviderTest { + + lateinit var testedProvider: AppLaunchCollectorProvider + + @Mock + lateinit var mockContext: Context + + @Mock + lateinit var mockApplication: Application + + @BeforeEach + fun `set up`() { + DdRumContentProvider.processImportance = + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + + // Reset collector state via reflection since reset() is internal to dd-sdk-android-internal + val stateField = AppLaunchPreInitCollector::class.java.getDeclaredField("_state") + stateField.isAccessible = true + @Suppress("UNCHECKED_CAST") + (stateField.get(AppLaunchPreInitCollector) as AtomicReference) + .set(AppLaunchPreInitCollector.State.NOT_INSTALLED) + // Clear any stored application reference + AppLaunchPreInitCollector.setFieldValue("_application", null) + + testedProvider = AppLaunchCollectorProvider() + testedProvider.setFieldValue("mContext", mockContext) + whenever(mockContext.applicationContext).thenReturn(mockApplication) + } + + @AfterEach + fun `tear down`() { + DdRumContentProvider.processImportance = 0 + + // Reset collector state via reflection + val stateField = AppLaunchPreInitCollector::class.java.getDeclaredField("_state") + stateField.isAccessible = true + @Suppress("UNCHECKED_CAST") + (stateField.get(AppLaunchPreInitCollector) as AtomicReference) + .set(AppLaunchPreInitCollector.State.NOT_INSTALLED) + AppLaunchPreInitCollector.setFieldValue("_application", null) + } + + // region onCreate + + @Test + fun `M return false W onCreate() {null applicationContext}`() { + // Given + whenever(mockContext.applicationContext).thenReturn(null) + + // When + val result = testedProvider.onCreate() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state) + .isEqualTo(AppLaunchPreInitCollector.State.NOT_INSTALLED) + } + + @Test + fun `M return false W onCreate() {background process}`() { + // Given + DdRumContentProvider.processImportance = + ActivityManager.RunningAppProcessInfo.IMPORTANCE_CACHED + + // When + val result = testedProvider.onCreate() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state) + .isEqualTo(AppLaunchPreInitCollector.State.NOT_INSTALLED) + } + + @Test + fun `M call install and return true W onCreate() {foreground process}`() { + // Given + DdRumContentProvider.processImportance = + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + + // When + val result = testedProvider.onCreate() + + // Then + assertThat(result).isTrue() + assertThat(AppLaunchPreInitCollector.state) + .isEqualTo(AppLaunchPreInitCollector.State.IDLE) + } + + // endregion +} diff --git a/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/forge/Configurator.kt b/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/forge/Configurator.kt new file mode 100644 index 0000000000..fde97e0d96 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/forge/Configurator.kt @@ -0,0 +1,18 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.prelaunch.forge + +import com.datadog.tools.unit.forge.BaseConfigurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.jvm.useJvmFactories + +internal class Configurator : BaseConfigurator() { + override fun configure(forge: Forge) { + super.configure(forge) + forge.useJvmFactories() + } +} diff --git a/features/dd-sdk-android-rum-prelaunch/transitiveDependencies b/features/dd-sdk-android-rum-prelaunch/transitiveDependencies new file mode 100644 index 0000000000..85d3888238 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/transitiveDependencies @@ -0,0 +1,7 @@ +Dependencies List + +org.jetbrains.kotlin:kotlin-stdlib:2.0.21 : 1706 Kb +org.jetbrains:annotations:13.0 : 17 Kb + +Total transitive dependencies size : 1723 Kb + diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index 6fb8dbb1b3..5468e5e29b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -14,6 +14,7 @@ import android.content.Context import android.os.Build import android.os.Handler import android.os.Looper +import android.util.Log import androidx.annotation.AnyThread import androidx.annotation.RequiresApi import com.datadog.android.api.InternalLogger @@ -36,6 +37,7 @@ import com.datadog.android.event.NoOpEventMapper import com.datadog.android.internal.flags.RumFlagEvaluationMessage import com.datadog.android.internal.system.BuildSdkVersionProvider import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.rum.AppLaunchPreInitCollector import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumSessionListener @@ -45,6 +47,7 @@ import com.datadog.android.rum.configuration.VitalsUpdateFrequency import com.datadog.android.rum.internal.anr.ANRDetectorRunnable import com.datadog.android.rum.internal.debug.UiRumDebugListener import com.datadog.android.rum.internal.domain.InfoProvider +import com.datadog.android.rum.internal.domain.asTimeNs import com.datadog.android.rum.internal.domain.RumDataWriter import com.datadog.android.rum.internal.domain.accessibility.AccessibilityInfo import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager @@ -77,7 +80,9 @@ import com.datadog.android.rum.internal.monitor.DatadogRumMonitor import com.datadog.android.rum.internal.net.RumRequestFactory import com.datadog.android.rum.internal.startup.DefaultAppStartupActivityPredicate import com.datadog.android.rum.internal.startup.RumAppStartupDetector -import com.datadog.android.rum.internal.startup.RumFirstDrawTimeReporter +import com.datadog.android.rum.startup.RumFirstDrawTimeReporter +import com.datadog.android.rum.startup.RumFirstDrawTimeReporterImpl +import com.datadog.android.rum.startup.WindowCallbacksRegistryImpl import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.android.rum.internal.thread.NoOpScheduledExecutorService @@ -121,6 +126,7 @@ import com.datadog.android.rum.tracking.ViewAttributesProvider import com.datadog.android.rum.tracking.ViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import java.util.Locale +import kotlin.time.Duration.Companion.seconds import java.util.concurrent.ExecutorService import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit @@ -695,11 +701,86 @@ internal class RumFeature( } private fun initRumAppStartupDetector() { + val collector = AppLaunchPreInitCollector + Log.d(TTID_LOG_TAG, "initRumAppStartupDetector: collector state=${collector.state}") + when (collector.state) { + AppLaunchPreInitCollector.State.NOT_INSTALLED -> { + Log.d(TTID_LOG_TAG, "NOT_INSTALLED — prelaunch module not present; using default RumAppStartupDetector") + createDefaultRumAppStartupDetector() + } + AppLaunchPreInitCollector.State.IDLE -> { + Log.d(TTID_LOG_TAG, "IDLE — SDK init before first Activity; claiming collector, using default RumAppStartupDetector") + collector.claim() + createDefaultRumAppStartupDetector() + } + AppLaunchPreInitCollector.State.CAPTURING -> { + Log.d(TTID_LOG_TAG, "CAPTURING — SDK init during Activity capture; will subscribe to first-frame callback for TTID") + val scenario = constructScenario(collector) + ?: run { + Log.d(TTID_LOG_TAG, "CAPTURING — constructScenario returned null; falling back to default detector") + createDefaultRumAppStartupDetector() + return + } + Log.d(TTID_LOG_TAG, "CAPTURING — scenario=${scenario::class.simpleName}; sending AppStart event, awaiting first frame") + val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return + rumMonitor.sendAppStartEvent(scenario) + collector.addFirstFrameCallback { firstFrameNs -> + val durationNs = firstFrameNs - scenario.initialTime.nanoTime + Log.d(TTID_LOG_TAG, "CAPTURING→COMPLETE callback: TTID durationMs=${durationNs / 1_000_000}") + rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) + } + } + AppLaunchPreInitCollector.State.COMPLETE -> { + Log.d( + TTID_LOG_TAG, + "COMPLETE — full timing available:" + + " processStartNs=${collector.processStartNs}" + + " activityOnCreateNs=${collector.activityOnCreateNs}" + + " firstFrameNs=${collector.firstFrameNs}" + + " totalMs=${(collector.firstFrameNs - collector.processStartNs) / 1_000_000}" + ) + val scenario = constructScenario(collector) + ?: run { + Log.d(TTID_LOG_TAG, "COMPLETE — constructScenario returned null; falling back to default detector") + createDefaultRumAppStartupDetector() + return + } + val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return + val durationNs = collector.firstFrameNs - scenario.initialTime.nanoTime + Log.d(TTID_LOG_TAG, "COMPLETE — scenario=${scenario::class.simpleName}; TTID durationMs=${durationNs / 1_000_000}; sending events") + rumMonitor.sendAppStartEvent(scenario) + rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) + } + AppLaunchPreInitCollector.State.CLAIMED -> { + Log.d(TTID_LOG_TAG, "CLAIMED — collector already claimed by another RumFeature instance; falling back to default detector") + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { COLLECTOR_ALREADY_CLAIMED_MESSAGE } + ) + createDefaultRumAppStartupDetector() + } + } + } + + private fun createDefaultRumAppStartupDetector() { rumAppStartupDetector = RumAppStartupDetector.create( application = appContext.applicationContext as Application, sdkCore = sdkCore as InternalSdkCore, listener = object : RumAppStartupDetector.Listener { - private val rumFirstDrawTimeReporter = RumFirstDrawTimeReporter.create(sdkCore = sdkCore) + private val rumFirstDrawTimeReporter: RumFirstDrawTimeReporter = RumFirstDrawTimeReporterImpl( + timeProviderNs = { sdkCore.timeProvider.getDeviceElapsedTimeNanos() }, + windowCallbacksRegistry = WindowCallbacksRegistryImpl(), + handler = Handler(Looper.getMainLooper()), + warnLogger = { message, throwable -> + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + { message }, + throwable + ) + } + ) override fun onAppStartupDetected(scenario: RumStartupScenario) { val activity = scenario.activity.get() ?: return @@ -751,6 +832,69 @@ internal class RumFeature( ) } + private fun constructScenario(collector: AppLaunchPreInitCollector): RumStartupScenario? { + val activity = collector.activity?.get() + if (activity == null) { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { COLLECTOR_ACTIVITY_NULL_MESSAGE } + ) + return null + } + + if (!configuration.appStartupActivityPredicate.shouldTrackStartup(activity)) { + return null + } + + val processStartTime = collector.processStartNs.asTimeNs() + val activityOnCreateTime = collector.activityOnCreateNs.asTimeNs() + val weakActivity = collector.activity!! + val hasSavedInstanceStateBundle = collector.hasSavedInstanceState + + return if (collector.isFirstActivityForProcess) { + val gapNs = activityOnCreateTime.nanoTime - processStartTime.nanoTime + if (gapNs > START_GAP_THRESHOLD_NS) { + Log.d( + TTID_LOG_TAG, + "constructScenario: WarmFirstActivity" + + " (gapMs=${gapNs / 1_000_000} > threshold ${START_GAP_THRESHOLD_NS / 1_000_000}ms)" + + " hasSavedInstanceState=$hasSavedInstanceStateBundle" + ) + RumStartupScenario.WarmFirstActivity( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = weakActivity, + appStartActivityOnCreateGapNs = gapNs, + initialTime = activityOnCreateTime + ) + } else { + Log.d( + TTID_LOG_TAG, + "constructScenario: Cold" + + " (gapMs=${gapNs / 1_000_000} ≤ threshold ${START_GAP_THRESHOLD_NS / 1_000_000}ms)" + + " hasSavedInstanceState=$hasSavedInstanceStateBundle" + ) + RumStartupScenario.Cold( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = weakActivity, + appStartActivityOnCreateGapNs = gapNs, + initialTime = processStartTime + ) + } + } else { + Log.d( + TTID_LOG_TAG, + "constructScenario: WarmAfterActivityDestroyed" + + " hasSavedInstanceState=$hasSavedInstanceStateBundle" + ) + RumStartupScenario.WarmAfterActivityDestroyed( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = weakActivity, + initialTime = activityOnCreateTime + ) + } + } + // endregion internal data class Configuration( @@ -791,6 +935,8 @@ internal class RumFeature( internal companion object { + private val START_GAP_THRESHOLD_NS = 10.seconds.inWholeNanoseconds + internal const val NDK_CRASH_BUS_MESSAGE_TYPE = "ndk_crash" internal const val LOGGER_ERROR_BUS_MESSAGE_TYPE = "logger_error" internal const val LOGGER_ERROR_WITH_STACK_TRACE_MESSAGE_TYPE = "logger_error_with_stacktrace" @@ -874,6 +1020,17 @@ internal class RumFeature( internal const val FAILED_TO_ENABLE_JANK_STATS_TRACKING_MANUALLY = "Manually enabling JankStats tracking threw an exception." + internal const val COLLECTOR_ALREADY_CLAIMED_MESSAGE = + "AppLaunchPreInitCollector was already claimed before RumFeature initialized;" + + " falling back to default startup detector." + internal const val COLLECTOR_ACTIVITY_NULL_MESSAGE = + "AppLaunchPreInitCollector activity WeakReference was null (GC'd);" + + " falling back to default startup detector." + + // Must match TTID_LOG_TAG for unified logcat filtering with: + // adb logcat -s "DD/AppLaunch" + private const val TTID_LOG_TAG = "DD/AppLaunch" + private fun provideUserTrackingStrategy( touchTargetExtraAttributesProviders: Array, interactionPredicate: InteractionPredicate, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporter.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporter.kt deleted file mode 100644 index 245c488544..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporter.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.rum.internal.startup - -import android.app.Activity -import android.os.Handler -import android.os.Looper -import com.datadog.android.core.InternalSdkCore -import com.datadog.android.rum.internal.utils.window.RumWindowCallbacksRegistryImpl - -internal interface RumFirstDrawTimeReporter { - interface Callback { - fun onFirstFrameDrawn(timestampNs: Long) - } - - fun subscribeToFirstFrameDrawn(activity: Activity, callback: Callback) - - companion object { - fun create(sdkCore: InternalSdkCore): RumFirstDrawTimeReporter { - return RumFirstDrawTimeReporterImpl( - internalLogger = sdkCore.internalLogger, - timeProviderNs = { sdkCore.timeProvider.getDeviceElapsedTimeNanos() }, - windowCallbacksRegistry = RumWindowCallbacksRegistryImpl(), - handler = Handler(Looper.getMainLooper()) - ) - } - } -} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeaturePreInitStartupTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeaturePreInitStartupTest.kt new file mode 100644 index 0000000000..a5d7a21bc7 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeaturePreInitStartupTest.kt @@ -0,0 +1,547 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal + +import android.app.Activity +import android.app.Application +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.InternalSdkCore +import com.datadog.android.rum.AppLaunchPreInitCollector +import com.datadog.android.rum.GlobalRumMonitor +import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor +import com.datadog.android.rum.internal.startup.RumAppStartupDetector +import com.datadog.android.rum.internal.startup.RumStartupScenario +import com.datadog.android.rum.internal.startup.RumTTIDInfo +import com.datadog.android.rum.startup.AppStartupActivityPredicate +import com.datadog.android.rum.utils.config.ApplicationContextTestConfiguration +import com.datadog.android.rum.utils.config.MainLooperTestConfiguration +import com.datadog.android.rum.utils.forge.Configurator +import com.datadog.tools.unit.annotations.TestConfigurationsProvider +import com.datadog.tools.unit.extensions.TestConfigurationExtension +import com.datadog.tools.unit.extensions.config.TestConfiguration +import com.datadog.tools.unit.getFieldValue +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.lang.ref.WeakReference +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class), + ExtendWith(TestConfigurationExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class RumFeaturePreInitStartupTest { + + private lateinit var testedFeature: RumFeature + + @Forgery + lateinit var fakeApplicationId: UUID + + @Forgery + lateinit var fakeConfiguration: RumFeature.Configuration + + @Mock + lateinit var mockSdkCore: InternalSdkCore + + @Mock + lateinit var mockRumMonitor: AdvancedRumMonitor + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Mock + lateinit var mockLateCrashReporter: LateCrashReporter + + @Mock + lateinit var mockAppStartupActivityPredicate: AppStartupActivityPredicate + + @BeforeEach + fun `set up`() { + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockSdkCore.timeProvider) doReturn mock() + whenever(mockSdkCore.createScheduledExecutorService(any())) doReturn mock() + + val mockContentResolver = mock() + whenever(appContext.mockInstance.contentResolver) doReturn mockContentResolver + doNothing().whenever(appContext.mockInstance).registerComponentCallbacks(any()) + doNothing().whenever(appContext.mockInstance).unregisterComponentCallbacks(any()) + + val mockResources = mock() + whenever(appContext.mockInstance.resources) doReturn mockResources + whenever(mockResources.configuration) doReturn mock() + + whenever(mockAppStartupActivityPredicate.shouldTrackStartup(any())) doReturn true + + testedFeature = RumFeature( + mockSdkCore, + fakeApplicationId.toString(), + fakeConfiguration.copy(appStartupActivityPredicate = mockAppStartupActivityPredicate), + lateCrashReporterFactory = { mockLateCrashReporter } + ) + GlobalRumMonitor.registerIfAbsent(mockRumMonitor, mockSdkCore) + } + + @AfterEach + fun `tear down`() { + GlobalRumMonitor.clear() + + // Reset AppLaunchPreInitCollector singleton state via reflection + // (reset() is internal to dd-sdk-android-internal, not accessible from this module) + val stateField = AppLaunchPreInitCollector::class.java.getDeclaredField("_state") + stateField.isAccessible = true + @Suppress("UNCHECKED_CAST") + (stateField.get(AppLaunchPreInitCollector) as AtomicReference) + .set(AppLaunchPreInitCollector.State.NOT_INSTALLED) + + // Reset data fields directly (they are public vars) + AppLaunchPreInitCollector.processStartNs = 0L + AppLaunchPreInitCollector.activityOnCreateNs = 0L + AppLaunchPreInitCollector.firstFrameNs = 0L + AppLaunchPreInitCollector.hasSavedInstanceState = false + AppLaunchPreInitCollector.isFirstActivityForProcess = true + AppLaunchPreInitCollector.activity = null + + // Clear firstFrameCallbacks via reflection + val callbacksField = AppLaunchPreInitCollector::class.java.getDeclaredField("firstFrameCallbacks") + callbacksField.isAccessible = true + @Suppress("UNCHECKED_CAST") + (callbacksField.get(AppLaunchPreInitCollector) as CopyOnWriteArrayList<*>).clear() + + // Reset _application via reflection + val appField = AppLaunchPreInitCollector::class.java.getDeclaredField("_application") + appField.isAccessible = true + appField.set(AppLaunchPreInitCollector, null) + } + + // region Helpers + + /** + * Sets AppLaunchPreInitCollector state via reflection on the private _state AtomicReference. + */ + private fun setCollectorState(state: AppLaunchPreInitCollector.State) { + val stateField = AppLaunchPreInitCollector::class.java.getDeclaredField("_state") + stateField.isAccessible = true + @Suppress("UNCHECKED_CAST") + (stateField.get(AppLaunchPreInitCollector) as AtomicReference).set(state) + } + + /** + * Sets all public data fields on AppLaunchPreInitCollector directly. + * Fields are public vars so no reflection needed. + */ + private fun configureCollectorData( + processStartNs: Long, + activityOnCreateNs: Long, + firstFrameNs: Long = 0L, + hasSavedInstanceState: Boolean = false, + isFirstActivityForProcess: Boolean = true, + activity: Activity? = null + ) { + AppLaunchPreInitCollector.processStartNs = processStartNs + AppLaunchPreInitCollector.activityOnCreateNs = activityOnCreateNs + AppLaunchPreInitCollector.firstFrameNs = firstFrameNs + AppLaunchPreInitCollector.hasSavedInstanceState = hasSavedInstanceState + AppLaunchPreInitCollector.isFirstActivityForProcess = isFirstActivityForProcess + AppLaunchPreInitCollector.activity = if (activity != null) WeakReference(activity) else null + } + + /** + * Reads firstFrameCallbacks from AppLaunchPreInitCollector via reflection. + */ + @Suppress("UNCHECKED_CAST") + private fun getFirstFrameCallbacks(): List<(Long) -> Unit> { + val callbacksField = AppLaunchPreInitCollector::class.java.getDeclaredField("firstFrameCallbacks") + callbacksField.isAccessible = true + return (callbacksField.get(AppLaunchPreInitCollector) as CopyOnWriteArrayList<(Long) -> Unit>).toList() + } + + // endregion + + // region INT-01: 5-branch dispatch exists + + @Test + fun `M call default detector W initRumAppStartupDetector() { collector NOT_INSTALLED }`() { + // Given — state is already NOT_INSTALLED (default) + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.NOT_INSTALLED) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — default detector is created (field is non-null) + val detector = testedFeature.getFieldValue( + "rumAppStartupDetector", + RumFeature::class.java + ) + assertThat(detector).isNotNull() + } + + @Test + fun `M call default detector W initRumAppStartupDetector() { collector IDLE }`() { + // Given + setCollectorState(AppLaunchPreInitCollector.State.IDLE) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — default detector created AND claim() was called (state -> CLAIMED) + val detector = testedFeature.getFieldValue( + "rumAppStartupDetector", + RumFeature::class.java + ) + assertThat(detector).isNotNull() + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + } + + // endregion + + // region INT-02: NOT_INSTALLED/IDLE route to default; IDLE calls claim + + @Test + fun `M call claim and use default W initRumAppStartupDetector() { collector IDLE }`() { + // Given + setCollectorState(AppLaunchPreInitCollector.State.IDLE) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — claim() transitions IDLE -> CLAIMED + assertThat(AppLaunchPreInitCollector.state).isEqualTo(AppLaunchPreInitCollector.State.CLAIMED) + } + + // endregion + + // region INT-03: CAPTURING branch + + @Test + fun `M send app start event immediately W initRumAppStartupDetector() { collector CAPTURING }`() { + // Given + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — sendAppStartEvent is called once + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) + } + + @Test + fun `M send TTID on first frame callback W initRumAppStartupDetector() { collector CAPTURING }`() { + // Given + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Simulate first frame callback by transitioning to COMPLETE and draining + val fakeFirstFrameNs = 300_000L + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + AppLaunchPreInitCollector.firstFrameNs = fakeFirstFrameNs + val callbacks = getFirstFrameCallbacks() + callbacks.forEach { it(fakeFirstFrameNs) } + + // Then — sendTTIDEvent is called once + val ttidCaptor = argumentCaptor() + verify(mockRumMonitor, times(1)).sendTTIDEvent(ttidCaptor.capture()) + assertThat(ttidCaptor.firstValue.durationNs).isEqualTo(fakeFirstFrameNs - 100_000L) + } + + // endregion + + // region INT-04: COMPLETE branch + + @Test + fun `M send app start and TTID synchronously W initRumAppStartupDetector() { collector COMPLETE }`() { + // Given + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — both events called synchronously + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) + verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) + } + + // endregion + + // region INT-05: constructScenario Cold vs Warm + + @Test + fun `M construct Cold scenario W initRumAppStartupDetector() { COMPLETE + gap less than 10s }`() { + // Given — processStartNs=100_000_000L, activityOnCreateNs=100_500_000L, gap=500_000L (< 10s in ns) + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 100_500_000L, + firstFrameNs = 101_000_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — scenario is Cold with initialTime.nanoTime == processStartNs + val scenarioCaptor = argumentCaptor() + verify(mockRumMonitor).sendAppStartEvent(scenarioCaptor.capture()) + val scenario = scenarioCaptor.firstValue + assertThat(scenario).isInstanceOf(RumStartupScenario.Cold::class.java) + assertThat(scenario.initialTime.nanoTime).isEqualTo(100_000_000L) + } + + @Test + fun `M construct WarmFirstActivity scenario W initRumAppStartupDetector() { COMPLETE + gap greater than 10s }`() { + // Given — gap > 10s: activityOnCreateNs - processStartNs > 10_000_000_000L + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 111_000_000_000L, // gap = ~110.9s >> 10s + firstFrameNs = 111_500_000_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — scenario is WarmFirstActivity with initialTime.nanoTime == activityOnCreateNs + val scenarioCaptor = argumentCaptor() + verify(mockRumMonitor).sendAppStartEvent(scenarioCaptor.capture()) + val scenario = scenarioCaptor.firstValue + assertThat(scenario).isInstanceOf(RumStartupScenario.WarmFirstActivity::class.java) + assertThat(scenario.initialTime.nanoTime).isEqualTo(111_000_000_000L) + } + + @Test + fun `M construct WarmAfterActivityDestroyed scenario W initRumAppStartupDetector() { COMPLETE + not first activity }`() { + // Given + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 100_500_000L, + firstFrameNs = 101_000_000L, + isFirstActivityForProcess = false, // not first — warm + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — scenario is WarmAfterActivityDestroyed + val scenarioCaptor = argumentCaptor() + verify(mockRumMonitor).sendAppStartEvent(scenarioCaptor.capture()) + val scenario = scenarioCaptor.firstValue + assertThat(scenario).isInstanceOf(RumStartupScenario.WarmAfterActivityDestroyed::class.java) + } + + // endregion + + // region INT-06: predicate mismatch + + @Test + fun `M fall back to default W initRumAppStartupDetector() { COMPLETE + predicate rejects activity }`() { + // Given — predicate returns false for all activities + whenever(mockAppStartupActivityPredicate.shouldTrackStartup(any())) doReturn false + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 100_500_000L, + firstFrameNs = 101_000_000L, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — sendAppStartEvent never called AND default detector is created + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + val detector = testedFeature.getFieldValue( + "rumAppStartupDetector", + RumFeature::class.java + ) + assertThat(detector).isNotNull() + } + + @Test + fun `M fall back to default W initRumAppStartupDetector() { CAPTURING + predicate rejects activity }`() { + // Given — predicate returns false for all activities + whenever(mockAppStartupActivityPredicate.shouldTrackStartup(any())) doReturn false + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 100_500_000L, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — sendAppStartEvent never called AND default detector is created + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + val detector = testedFeature.getFieldValue( + "rumAppStartupDetector", + RumFeature::class.java + ) + assertThat(detector).isNotNull() + } + + // endregion + + // region INT-07: CLAIMED branch + + @Test + fun `M log warn and use default W initRumAppStartupDetector() { collector CLAIMED }`() { + // Given + setCollectorState(AppLaunchPreInitCollector.State.CLAIMED) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — WARN log emitted and default detector created + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.MAINTAINER), + any(), + isNull(), + eq(false), + isNull() + ) + val detector = testedFeature.getFieldValue( + "rumAppStartupDetector", + RumFeature::class.java + ) + assertThat(detector).isNotNull() + } + + // endregion + + // region INT-08: null activity.get() + + @Test + fun `M log warn and not send events W initRumAppStartupDetector() { COMPLETE + activity GCd }`() { + // Given — activity has been GC'd (WeakReference.get() returns null) + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 100_500_000L, + firstFrameNs = 101_000_000L, + activity = null // simulates GC — WeakReference(null) + ) + AppLaunchPreInitCollector.activity = WeakReference(null) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — sendAppStartEvent never called, WARN logged + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.MAINTAINER), + any(), + isNull(), + eq(false), + isNull() + ) + } + + @Test + fun `M log warn and fall back to default W initRumAppStartupDetector() { CAPTURING + activity GCd }`() { + // Given — CAPTURING state, activity GC'd + setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) + configureCollectorData( + processStartNs = 100_000_000L, + activityOnCreateNs = 100_500_000L, + activity = null // simulates GC + ) + AppLaunchPreInitCollector.activity = WeakReference(null) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — sendAppStartEvent never called, WARN logged, default detector falls back + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockInternalLogger).log( + eq(InternalLogger.Level.WARN), + eq(InternalLogger.Target.MAINTAINER), + any(), + isNull(), + eq(false), + isNull() + ) + val detector = testedFeature.getFieldValue( + "rumAppStartupDetector", + RumFeature::class.java + ) + assertThat(detector).isNotNull() + } + + // endregion + + companion object { + val appContext = ApplicationContextTestConfiguration(Application::class.java) + private val mainLooper = MainLooperTestConfiguration() + + @TestConfigurationsProvider + @JvmStatic + fun getTestConfigurations(): List { + return listOf(appContext, mainLooper) + } + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureStartupDetectorTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureStartupDetectorTest.kt index 6a4e891ef6..bd7ab3d790 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureStartupDetectorTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureStartupDetectorTest.kt @@ -16,7 +16,7 @@ import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.internal.startup.RumAppStartupDetector import com.datadog.android.rum.internal.startup.RumAppStartupDetectorImpl -import com.datadog.android.rum.internal.startup.RumFirstDrawTimeReporter +import com.datadog.android.rum.startup.RumFirstDrawTimeReporter import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.android.rum.utils.config.ApplicationContextTestConfiguration