From 3cf0ddf387a9db3ee42d6c1384820ab64e014805 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Wed, 15 Apr 2026 17:24:38 +0200 Subject: [PATCH 01/13] 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 | 123 +++ .../rum/startup/WindowCallbacksRegistry.kt | 32 +- ...ppLaunchPreInitCollectorConcurrencyTest.kt | 134 +++ .../rum/AppLaunchPreInitCollectorTest.kt | 849 ++++++++++++++++++ .../RumFirstDrawTimeReporterImplTest.kt | 273 ++++++ .../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 | 159 ++++ .../startup/RumFirstDrawTimeReporter.kt | 36 - .../RumFirstDrawTimeReporterHandleImpl.kt | 159 ---- .../startup/RumFirstDrawTimeReporterImpl.kt | 34 - .../internal/RumFeaturePreInitStartupTest.kt | 547 +++++++++++ .../internal/RumFeatureStartupDetectorTest.kt | 1 + .../RumFirstDrawTimeReporterHandleImplTest.kt | 411 --------- 26 files changed, 2957 insertions(+), 669 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 create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt 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 create mode 100644 dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt 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 delete mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImpl.kt delete mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeaturePreInitStartupTest.kt delete mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 416360cf42..557ef73f0e 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -315,6 +315,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? @@ -325,5 +342,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, String = "") 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 b6e45c7997..e677fef993 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -789,6 +789,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 @@ -807,6 +837,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 customName ()Ljava/lang/String; 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/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt new file mode 100644 index 0000000000..b1b4149e31 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -0,0 +1,123 @@ +/* + * 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 +import android.os.Handler +import android.os.Message +import android.util.Log +import android.view.View +import android.view.ViewTreeObserver + +class RumFirstDrawTimeReporterImpl( + private val timeProviderNs: () -> Long, + 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( + activity: Activity, + callback: RumFirstDrawTimeReporter.Callback + ) { + val window = activity.window + val decorView = window.peekDecorView() + + if (decorView == null) { + val listener = object : WindowCallbackListener { + override fun onContentChanged() { + windowCallbacksRegistry.removeListener(activity, this) + onDecorViewReady(activity, callback) + } + } + windowCallbacksRegistry.addListener(activity, listener) + } else { + onDecorViewReady(activity, callback) + } + } + + private fun onDecorViewReady( + activity: Activity, + callback: RumFirstDrawTimeReporter.Callback + ) { + val window = activity.window + val decorView = window.decorView + + if (decorView.isAttachedToWindow) { + registerOnDrawListener( + decorView = decorView, + callback = callback + ) + } else { + val attachListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + registerOnDrawListener( + decorView = decorView, + callback = callback + ) + decorView.removeOnAttachStateChangeListener(this) + } + + override fun onViewDetachedFromWindow(v: View) { + } + } + decorView.addOnAttachStateChangeListener(attachListener) + } + } + + private fun registerOnDrawListener( + decorView: View, + callback: RumFirstDrawTimeReporter.Callback + ) { + val listener = object : ViewTreeObserver.OnDrawListener { + private var invoked = false + + override fun onDraw() { + if (invoked) { + return + } + invoked = true + onFirstDraw(callback) + + handler.post { + if (decorView.viewTreeObserver.isAlive) { + try { + decorView.viewTreeObserver.removeOnDrawListener(this) + } catch (e: IllegalStateException) { + warnLogger("RumTTIDReporterImpl unable to remove onDrawListener from viewTreeObserver", e) + } + } + } + } + } + + if (decorView.viewTreeObserver.isAlive) { + try { + decorView.viewTreeObserver.addOnDrawListener(listener) + } catch (e: IllegalStateException) { + warnLogger("RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver", e) + } + } + } + + private fun onFirstDraw(callback: RumFirstDrawTimeReporter.Callback) { + val nowNs = timeProviderNs() + + val block = Runnable { + callback.onFirstFrameDrawn(nowNs) + } + + handler.sendMessageAtFrontOfQueue( + Message.obtain(handler, block).apply { + isAsynchronous = true + } + ) + } +} 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/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt new file mode 100644 index 0000000000..f3f9ac3c7f --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt @@ -0,0 +1,273 @@ +/* + * 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 +import android.os.Handler +import android.os.Message +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +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.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RumFirstDrawTimeReporterImplTest { + + private var currentTime: Duration = 0.seconds + + @Mock + private lateinit var windowCallbackRegistry: WindowCallbacksRegistry + + @Mock + private lateinit var handler: Handler + + @Mock + private lateinit var callback: RumFirstDrawTimeReporter.Callback + + @Mock + private lateinit var activity: Activity + + @Mock + private lateinit var window: Window + + @Mock + private lateinit var decorView: View + + @Mock + private lateinit var viewTreeObserver: ViewTreeObserver + + private lateinit var reporter: RumFirstDrawTimeReporterImpl + + @BeforeEach + fun `set up`() { + reporter = RumFirstDrawTimeReporterImpl( + timeProviderNs = { currentTime.inWholeNanoseconds }, + windowCallbacksRegistry = windowCallbackRegistry, + handler = handler + ) + + whenever(activity.window) doReturn window + whenever(window.peekDecorView()) doReturn null + whenever(window.decorView) doReturn decorView + + whenever(decorView.viewTreeObserver) doReturn viewTreeObserver + + whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { + val argListener = it.getArgument(1) + argListener.onContentChanged() + } + + whenever(viewTreeObserver.isAlive) doReturn true + + whenever(handler.post(any())).doAnswer { + val argRunnable = it.getArgument(0) + argRunnable.run() + true + } + whenever(handler.sendMessageAtFrontOfQueue(any())).doAnswer { + val argMessage = it.getArgument(0) + argMessage.callback.run() + true + } + whenever(decorView.isAttachedToWindow) doReturn true + } + + @Test + fun `M call onTTIDCalculated W RumTTIDReporter { decorView doesn't exist yet }`() { + // Given + currentTime += 1.seconds + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + inOrder(windowCallbackRegistry, callback, viewTreeObserver) { + verify(windowCallbackRegistry).addListener(any(), any()) + verify(windowCallbackRegistry).removeListener(any(), any()) + verify(viewTreeObserver).isAlive + + argumentCaptor { + verify(viewTreeObserver).addOnDrawListener(capture()) + firstValue.onDraw() + } + + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify(viewTreeObserver).isAlive + verify(viewTreeObserver).removeOnDrawListener(any()) + verifyNoMoreInteractions() + } + } + + @Test + fun `M call onTTIDCalculated W RumTTIDReporter { decorView exists }`() { + // Given + whenever(window.peekDecorView()) doReturn decorView + + currentTime += 1.seconds + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + inOrder(windowCallbackRegistry, callback, viewTreeObserver) { + verify(viewTreeObserver).isAlive + + argumentCaptor { + verify(viewTreeObserver).addOnDrawListener(capture()) + firstValue.onDraw() + } + + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify(viewTreeObserver).isAlive + verify(viewTreeObserver).removeOnDrawListener(any()) + verifyNoMoreInteractions() + } + } + + @Test + fun `M call onTTIDCalculated W RumTTIDReporter { decorView exists but not attached to window }`() { + // Given + whenever(window.peekDecorView()) doReturn decorView + whenever(decorView.isAttachedToWindow) doReturn false + + currentTime += 1.seconds + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + inOrder(windowCallbackRegistry, callback, viewTreeObserver, decorView) { + argumentCaptor { + verify(decorView).addOnAttachStateChangeListener(capture()) + firstValue.onViewAttachedToWindow(decorView) + } + + verify(viewTreeObserver).isAlive + + argumentCaptor { + verify(viewTreeObserver).addOnDrawListener(capture()) + firstValue.onDraw() + } + + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify(viewTreeObserver).isAlive + verify(viewTreeObserver).removeOnDrawListener(any()) + verifyNoMoreInteractions() + } + } + + @Test + fun `M not call onTTIDCalculated W RumTTIDReporter { viewTreeObserver is not alive }`() { + // Given + whenever(viewTreeObserver.isAlive) doReturn false + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + verifyNoInteractions(callback) + } + + @Test + fun `M call onTTIDCalculated only once W RumTTIDReporter { onDraw is called twice }`() { + // Given + currentTime += 1.seconds + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + inOrder(callback, viewTreeObserver) { + verify(viewTreeObserver).isAlive + + argumentCaptor { + verify(viewTreeObserver).addOnDrawListener(capture()) + firstValue.onDraw() + firstValue.onDraw() + } + + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify(viewTreeObserver).isAlive + verify(viewTreeObserver).removeOnDrawListener(any()) + verifyNoMoreInteractions() + } + } + + @Test + fun `M not call callback W addOnDrawListener { if it throws IllegalStateException }`() { + // Given + val illegalStateException = IllegalStateException() + whenever(viewTreeObserver.addOnDrawListener(any())) doThrow illegalStateException + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + verifyNoInteractions(callback) + + inOrder(viewTreeObserver) { + verify(viewTreeObserver).isAlive + verify(viewTreeObserver).addOnDrawListener(any()) + verifyNoMoreInteractions() + } + } + + @Test + fun `M call callback W removeOnDrawListener { if it throws IllegalStateException }`() { + // Given + val illegalStateException = IllegalStateException() + whenever(viewTreeObserver.removeOnDrawListener(any())) doThrow illegalStateException + + currentTime += 1.seconds + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + // Then + inOrder(callback, viewTreeObserver) { + verify(viewTreeObserver).isAlive + + argumentCaptor { + verify(viewTreeObserver).addOnDrawListener(capture()) + firstValue.onDraw() + } + + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + + verify(viewTreeObserver).isAlive + verify(viewTreeObserver).removeOnDrawListener(any()) + 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 35f39977e2..c441f59ebc 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 @@ -45,6 +46,7 @@ import com.datadog.android.internal.thread.isMainThread import com.datadog.android.internal.utils.asString import com.datadog.android.internal.utils.getSystemServiceAs import com.datadog.android.internal.utils.loggableStackTrace +import com.datadog.android.rum.AppLaunchPreInitCollector import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource @@ -56,6 +58,7 @@ import com.datadog.android.rum.internal.anr.ANRDetectorRunnable import com.datadog.android.rum.internal.anr.ANRException 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 @@ -90,6 +93,9 @@ import com.datadog.android.rum.internal.startup.DefaultAppStartupActivityPredica 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.RumFirstDrawTimeReporter +import com.datadog.android.rum.startup.RumFirstDrawTimeReporterImpl +import com.datadog.android.rum.startup.WindowCallbacksRegistryImpl import com.datadog.android.rum.internal.thread.NoOpScheduledExecutorService import com.datadog.android.rum.internal.tracking.JetpackViewAttributesProvider import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate @@ -131,6 +137,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 @@ -738,10 +745,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 = 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 rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return @@ -767,6 +850,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( @@ -807,6 +953,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" @@ -890,6 +1038,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 ad3e4dc585..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporter.kt +++ /dev/null @@ -1,36 +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 Handle { - fun unsubscribe() - } - - interface Callback { - fun onFirstFrameDrawn(timestampNs: Long) - } - - fun subscribeToFirstFrameDrawn(activity: Activity, callback: Callback): Handle - - 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/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImpl.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImpl.kt deleted file mode 100644 index 5cbf56a2dc..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImpl.kt +++ /dev/null @@ -1,159 +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.Message -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 RumFirstDrawTimeReporterHandleImpl( - private val callback: RumFirstDrawTimeReporter.Callback, - private val activity: Activity, - private val internalLogger: InternalLogger, - private val timeProviderNs: () -> Long, - private val windowCallbacksRegistry: RumWindowCallbacksRegistry, - private val handler: Handler -) : RumFirstDrawTimeReporter.Handle, - RumWindowCallbackListener, - View.OnAttachStateChangeListener, - ViewTreeObserver.OnDrawListener { - - private var isCancelled: Boolean = false - - private var onDrawInvoked = false - - init { - val window = activity.window - val decorView = window.peekDecorView() - - if (decorView == null) { - windowCallbacksRegistry.addListener(activity, this) - } else { - onDecorViewReady(decorView) - } - } - - override fun unsubscribe() { - if (isCancelled) { - return - } - isCancelled = true - - windowCallbacksRegistry.removeListener(activity, this) - - val decorView = activity.window.peekDecorView() - if (decorView != null) { - decorView.removeOnAttachStateChangeListener(this) - removeOnDrawListener(decorView) - } - } - - override fun onContentChanged() { - windowCallbacksRegistry.removeListener(activity, this) - - onDecorViewReady(getDecorView()) - } - - private fun reportFirstFrame() { - if (isCancelled) { - return - } - - val nowNs = timeProviderNs() - callback.onFirstFrameDrawn(nowNs) - } - - private fun onDecorViewReady(decorView: View) { - if (isCancelled) { - return - } - - if (decorView.isAttachedToWindow) { - registerOnDrawListener(decorView) - } else { - decorView.addOnAttachStateChangeListener(this) - } - } - - private fun registerOnDrawListener(decorView: View) { - if (isCancelled) { - return - } - - if (decorView.viewTreeObserver.isAlive) { - try { - decorView.viewTreeObserver.addOnDrawListener(this) - } catch (e: IllegalStateException) { - internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.TELEMETRY, - { "RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver" }, - e - ) - } - } - } - - private fun onFirstDraw() { - val block = Runnable { - reportFirstFrame() - } - - handler.sendMessageAtFrontOfQueue( - Message.obtain(handler, block).apply { - isAsynchronous = true - } - ) - } - - override fun onViewAttachedToWindow(v: View) { - val decorView = getDecorView() - - registerOnDrawListener(decorView) - decorView.removeOnAttachStateChangeListener(this) - } - - override fun onViewDetachedFromWindow(v: View) { - } - - override fun onDraw() { - if (onDrawInvoked) { - return - } - onDrawInvoked = true - onFirstDraw() - - val decorView = getDecorView() - handler.post { - removeOnDrawListener(decorView) - } - } - - private fun getDecorView(): View { - return activity.window.decorView - } - - private fun removeOnDrawListener(decorView: View) { - if (decorView.viewTreeObserver.isAlive) { - try { - decorView.viewTreeObserver.removeOnDrawListener(this) - } catch (e: IllegalStateException) { - internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.TELEMETRY, - { "RumTTIDReporterImpl unable to remove onDrawListener from viewTreeObserver" }, - e - ) - } - } - } -} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt deleted file mode 100644 index 4d192123ef..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterImpl.kt +++ /dev/null @@ -1,34 +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 com.datadog.android.api.InternalLogger -import com.datadog.android.rum.internal.utils.window.RumWindowCallbacksRegistry - -internal class RumFirstDrawTimeReporterImpl( - private val internalLogger: InternalLogger, - private val timeProviderNs: () -> Long, - private val windowCallbacksRegistry: RumWindowCallbacksRegistry, - private val handler: Handler -) : RumFirstDrawTimeReporter { - - override fun subscribeToFirstFrameDrawn( - activity: Activity, - callback: RumFirstDrawTimeReporter.Callback - ): RumFirstDrawTimeReporter.Handle { - return RumFirstDrawTimeReporterHandleImpl( - callback = callback, - activity = activity, - internalLogger = internalLogger, - timeProviderNs = timeProviderNs, - windowCallbacksRegistry = windowCallbacksRegistry, - handler = handler - ) - } -} 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 ebba1e6b31..6791ffaa76 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,6 +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.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 diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt deleted file mode 100644 index b64bdba413..0000000000 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt +++ /dev/null @@ -1,411 +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.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.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.times -import org.mockito.kotlin.verifyNoInteractions -import org.mockito.kotlin.whenever -import org.mockito.quality.Strictness -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(TestConfigurationExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(Configurator::class) -class RumFirstDrawTimeReporterHandleImplTest { - - private var currentTime: Duration = 0.seconds - - @Mock - private lateinit var windowCallbackRegistry: RumWindowCallbacksRegistry - - @Mock - private lateinit var handler: Handler - - @Mock - private lateinit var callback: RumFirstDrawTimeReporter.Callback - - @Mock - private lateinit var activity: Activity - - @Mock - private lateinit var window: Window - - @Mock - private lateinit var decorView: View - - @Mock - private lateinit var viewTreeObserver: ViewTreeObserver - - @Mock - private lateinit var internalLogger: InternalLogger - - @BeforeEach - fun `set up`() { - whenever(activity.window) doReturn window - whenever(window.peekDecorView()) doReturn null - whenever(window.decorView) doReturn decorView - - whenever(decorView.viewTreeObserver) doReturn viewTreeObserver - - whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { - val argListener = it.getArgument(1) - argListener.onContentChanged() - } - - whenever(viewTreeObserver.isAlive) doReturn true - - whenever(handler.post(any())).doAnswer { - val argRunnable = it.getArgument(0) - argRunnable.run() - true - } - whenever(handler.sendMessageAtFrontOfQueue(any())).doAnswer { - val argMessage = it.getArgument(0) - argMessage.callback.run() - true - } - whenever(decorView.isAttachedToWindow) doReturn true - } - - // region first frame detection - - @Test - fun `M call onFirstFrameDrawn W decorView doesn't exist yet`() { - // Given - currentTime += 1.seconds - - // When - createHandle() - - // Then - inOrder(windowCallbackRegistry, callback, viewTreeObserver) { - verify(windowCallbackRegistry).addListener(eq(activity), any()) - verify(windowCallbackRegistry).removeListener(eq(activity), any()) - verify(viewTreeObserver).isAlive - - argumentCaptor { - verify(viewTreeObserver).addOnDrawListener(capture()) - firstValue.onDraw() - } - - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) - verify(viewTreeObserver).isAlive - verify(viewTreeObserver).removeOnDrawListener(any()) - verifyNoMoreInteractions() - } - } - - @Test - fun `M call onFirstFrameDrawn W decorView exists`() { - // Given - whenever(window.peekDecorView()) doReturn decorView - currentTime += 1.seconds - - // When - createHandle() - - // Then - inOrder(windowCallbackRegistry, callback, viewTreeObserver) { - verify(viewTreeObserver).isAlive - - argumentCaptor { - verify(viewTreeObserver).addOnDrawListener(capture()) - firstValue.onDraw() - } - - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) - verify(viewTreeObserver).isAlive - verify(viewTreeObserver).removeOnDrawListener(any()) - verifyNoMoreInteractions() - } - } - - @Test - fun `M call onFirstFrameDrawn W decorView exists but not attached to window`() { - // Given - whenever(window.peekDecorView()) doReturn decorView - whenever(decorView.isAttachedToWindow) doReturn false - currentTime += 1.seconds - - // When - createHandle() - - // Then - inOrder(windowCallbackRegistry, callback, viewTreeObserver, decorView) { - argumentCaptor { - verify(decorView).addOnAttachStateChangeListener(capture()) - firstValue.onViewAttachedToWindow(decorView) - } - - verify(viewTreeObserver).isAlive - - argumentCaptor { - verify(viewTreeObserver).addOnDrawListener(capture()) - firstValue.onDraw() - } - - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) - verify(viewTreeObserver).isAlive - verify(viewTreeObserver).removeOnDrawListener(any()) - verifyNoMoreInteractions() - } - } - - @Test - fun `M not call onFirstFrameDrawn W viewTreeObserver is not alive`() { - // Given - whenever(viewTreeObserver.isAlive) doReturn false - - // When - createHandle() - - // Then - verifyNoInteractions(callback) - } - - @Test - fun `M call onFirstFrameDrawn only once W onDraw is called twice`() { - // Given - currentTime += 1.seconds - - // When - createHandle() - - // Then - inOrder(callback, viewTreeObserver) { - verify(viewTreeObserver).isAlive - - argumentCaptor { - verify(viewTreeObserver).addOnDrawListener(capture()) - firstValue.onDraw() - firstValue.onDraw() - } - - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) - verify(viewTreeObserver).isAlive - verify(viewTreeObserver).removeOnDrawListener(any()) - verifyNoMoreInteractions() - } - } - - @Test - fun `M not add listener to registry W decorView exists`() { - // Given - whenever(window.peekDecorView()) doReturn decorView - - // When - createHandle() - - // Then - verifyNoInteractions(windowCallbackRegistry) - } - - // endregion - - // region error handling - - @Test - fun `M call internalLogger W addOnDrawListener throws IllegalStateException`() { - // Given - val illegalStateException = IllegalStateException() - whenever(viewTreeObserver.addOnDrawListener(any())) doThrow illegalStateException - - // When - createHandle() - - // Then - verifyNoInteractions(callback) - - inOrder(viewTreeObserver, internalLogger) { - 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 throws IllegalStateException`() { - // Given - val illegalStateException = IllegalStateException() - whenever(viewTreeObserver.removeOnDrawListener(any())) doThrow illegalStateException - currentTime += 1.seconds - - // When - createHandle() - - // Then - verifyNoInteractions(callback) - - inOrder(callback, viewTreeObserver, internalLogger) { - verify(viewTreeObserver).isAlive - - argumentCaptor { - verify(viewTreeObserver).addOnDrawListener(capture()) - firstValue.onDraw() - } - - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) - - 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() - } - } - - // endregion - - // region unsubscribe - - @Test - fun `M remove all listeners W unsubscribe called`() { - // Given - whenever(window.peekDecorView()) doReturn decorView - - val handle = createHandle() - - // When - handle.unsubscribe() - - // Then - inOrder(windowCallbackRegistry, viewTreeObserver, decorView) { - verify(windowCallbackRegistry).removeListener(eq(activity), any()) - verify(decorView).removeOnAttachStateChangeListener(eq(handle)) - verify(viewTreeObserver).removeOnDrawListener(eq(handle)) - verifyNoMoreInteractions() - } - } - - @Test - fun `M remove all listeners once W unsubscribe called twice`() { - // Given - whenever(window.peekDecorView()) doReturn decorView - - val handle = createHandle() - - // When - handle.unsubscribe() - handle.unsubscribe() - - // Then - inOrder(windowCallbackRegistry, viewTreeObserver, decorView) { - verify(windowCallbackRegistry).removeListener(eq(activity), any()) - verify(decorView).removeOnAttachStateChangeListener(eq(handle)) - verify(viewTreeObserver).removeOnDrawListener(eq(handle)) - verifyNoMoreInteractions() - } - } - - @Test - fun `M not register drawListener W unsubscribe called before onContentChanged`() { - // Given - whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { - val argHandle = it.getArgument(1) - argHandle.unsubscribe() - argHandle.onContentChanged() - } - - // When - createHandle() - - // Then - inOrder(windowCallbackRegistry, callback, viewTreeObserver) { - verify(windowCallbackRegistry).addListener(eq(activity), any()) - verify(windowCallbackRegistry, times(2)).removeListener(eq(activity), any()) - - verifyNoMoreInteractions() - } - } - - @Test - fun `M not call callback W unsubscribe called after drawListener registered`() { - // Given - val handle = createHandle() - - // Then - inOrder(windowCallbackRegistry, callback, viewTreeObserver) { - verify(windowCallbackRegistry).addListener(eq(activity), any()) - verify(windowCallbackRegistry).removeListener(eq(activity), any()) - verify(viewTreeObserver).isAlive - - argumentCaptor { - verify(viewTreeObserver).addOnDrawListener(capture()) - handle.unsubscribe() - firstValue.onDraw() - } - - verify(viewTreeObserver).isAlive - verify(viewTreeObserver).removeOnDrawListener(any()) - verifyNoMoreInteractions() - } - } - - // endregion - - private fun createHandle(): RumFirstDrawTimeReporterHandleImpl { - return RumFirstDrawTimeReporterHandleImpl( - callback = callback, - activity = activity, - internalLogger = internalLogger, - timeProviderNs = { currentTime.inWholeNanoseconds }, - windowCallbacksRegistry = windowCallbackRegistry, - handler = handler - ) - } -} From 0333b4b821ce291c737b822abd954856b72ba381 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Thu, 9 Jul 2026 16:02:15 +0200 Subject: [PATCH 02/13] Add Pre launch module to report TTID on Cross Platform SDKs --- .../android/rum/AppLaunchPreInitCollector.kt | 70 +-- .../rum/startup/RumFirstDrawTimeReporter.kt | 23 + .../startup/RumFirstDrawTimeReporterImpl.kt | 43 ++ .../rum/startup/WindowCallbacksRegistry.kt | 36 ++ .../RumFirstDrawTimeReporterImplTest.kt | 59 ++ .../prelaunch/AppLaunchCollectorProvider.kt | 15 +- .../kotlin/com/datadog/android/rum/Rum.kt | 9 + .../android/rum/internal/RumFeature.kt | 258 ++++++--- .../internal/startup/RumAppStartupDetector.kt | 12 +- .../startup/RumAppStartupDetectorImpl.kt | 54 +- .../internal/RumFeaturePreInitStartupTest.kt | 159 +++++- .../internal/RumFeatureStartupDetectorTest.kt | 219 ++++++- .../startup/RumAppStartupDetectorImplTest.kt | 532 ++++++++---------- settings.gradle.kts | 1 + 14 files changed, 957 insertions(+), 533 deletions(-) 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 index 659b23033a..018cb13afc 100644 --- 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 @@ -13,7 +13,6 @@ 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 @@ -46,6 +45,7 @@ import kotlin.time.Duration.Companion.seconds * [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. */ +@Suppress("PreferTimeProvider", "UnsafeThirdPartyFunctionCall") object AppLaunchPreInitCollector { /** @@ -142,7 +142,7 @@ object AppLaunchPreInitCollector { // endregion private val firstFrameCallbacks = CopyOnWriteArrayList<(Long) -> Unit>() - private var _application: Application? = null + private var registeredApplication: Application? = null /** Private flag tracking whether any Activity has been destroyed (process is warm). */ private var _isFirstActivityForProcess: Boolean = true @@ -201,7 +201,6 @@ object AppLaunchPreInitCollector { 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 } @@ -211,49 +210,33 @@ object AppLaunchPreInitCollector { 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) + registeredApplication?.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) } + reporter.subscribeToFirstFrameDrawn( + activity, + object : RumFirstDrawTimeReporter.Callback { + override fun onFirstFrameDrawn(timestampNs: Long) { + firstFrameNs = timestampNs + _state.compareAndSet(State.CAPTURING, State.COMPLETE) + + // 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: + * elapsed realtime clock. Applies a two-direction OEM coherence check: * - If computed time is after DdRumContentProvider.createTimeNs (impossible), fall back. * - If computed time is more than 10s before createTimeNs (unreasonable), fall back. * @@ -294,12 +277,10 @@ object AppLaunchPreInitCollector { */ 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 + registeredApplication = application application.registerActivityLifecycleCallbacks(lifecycleCallbacks) - Log.d(TAG, "Installed — state: IDLE, ActivityLifecycleCallbacks registered") } /** @@ -318,10 +299,7 @@ object AppLaunchPreInitCollector { 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") + registeredApplication?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) } return success } @@ -342,7 +320,6 @@ object AppLaunchPreInitCollector { */ fun addFirstFrameCallback(cb: (Long) -> Unit) { if (_state.get() == State.COMPLETE) { - Log.d(TAG, "addFirstFrameCallback: already COMPLETE, invoking callback synchronously") cb(firstFrameNs) return } @@ -350,11 +327,8 @@ object AppLaunchPreInitCollector { // 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)") } } @@ -362,10 +336,8 @@ object AppLaunchPreInitCollector { // region Constants - internal const val TAG = "DD/AppLaunch" - /** - * Threshold for the two-direction OEM clock sanity check. + * Threshold for the two-direction OEM clock coherence 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. */ @@ -392,7 +364,7 @@ object AppLaunchPreInitCollector { _isFirstActivityForProcess = true activity = null firstFrameCallbacks.clear() - _application = null + registeredApplication = null buildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT handlerFactory = { Handler(Looper.getMainLooper()) } firstDrawTimeReporterFactory = { handler -> 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 index 19a92f5789..c985574505 100644 --- 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 @@ -8,10 +8,33 @@ package com.datadog.android.rum.startup import android.app.Activity +/** + * Reports the timestamp of the first drawn frame for a given [Activity]. + * + * Used by the RUM SDK to measure Time To Initial Display (TTID) by observing when the + * activity's window first renders its content on screen. + */ interface RumFirstDrawTimeReporter { + + /** + * Callback invoked when the first frame of an activity's window has been drawn. + */ interface Callback { + /** + * Called once the first frame has been drawn. + * + * @param timestampNs The elapsed realtime timestamp of the first draw, in nanoseconds. + */ fun onFirstFrameDrawn(timestampNs: Long) } + /** + * Subscribes to receive a callback when the first frame of [activity]'s window is drawn. + * + * The [callback] is guaranteed to be invoked at most once per subscription. + * + * @param activity The activity whose first frame draw should be observed. + * @param callback The callback to invoke when the first frame is drawn. + */ fun subscribeToFirstFrameDrawn(activity: Activity, callback: Callback) } diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt index b1b4149e31..97439584a3 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -7,12 +7,23 @@ package com.datadog.android.rum.startup import android.app.Activity +import android.app.Application +import android.os.Bundle import android.os.Handler import android.os.Message import android.util.Log import android.view.View import android.view.ViewTreeObserver +import java.lang.ref.WeakReference +/** + * Default implementation of [RumFirstDrawTimeReporter]. + * + * Hooks into the activity's window via [ViewTreeObserver.OnDrawListener] to capture the first + * frame draw timestamp, handling both the case where the decor view is already attached and + * the case where it becomes available later via [WindowCallbacksRegistry]. + */ +@Suppress("UnsafeThirdPartyFunctionCall") class RumFirstDrawTimeReporterImpl( private val timeProviderNs: () -> Long, private val windowCallbacksRegistry: WindowCallbacksRegistry, @@ -38,6 +49,7 @@ class RumFirstDrawTimeReporterImpl( } } windowCallbacksRegistry.addListener(activity, listener) + registerDestroyCleanup(activity, listener) } else { onDecorViewReady(activity, callback) } @@ -107,6 +119,37 @@ class RumFirstDrawTimeReporterImpl( } } + // WindowCallbacksRegistryImpl stores Activity→WindowCallback in a WeakHashMap, but + // WindowCallback holds a strong reference back to the Activity via FixedWindowCallback.delegate + // (the Activity is its own Window.Callback). This circular reference prevents GC. + // When an Activity is destroyed before setContentView is called (e.g. an interstitial that + // just calls startActivity + finish), the listener never fires via onContentChanged, so the + // entry is never cleaned up. Registering a lifecycle callback to remove it on destroy breaks + // the strong reference and lets GC collect the Activity. + private fun registerDestroyCleanup(activity: Activity, listener: WindowCallbackListener) { + val application = activity.application ?: return + val weakActivity = WeakReference(activity) + // WeakReference so this callback (held by Application) does not itself keep the + // listener (and through it, the Activity) alive. + val weakListener = WeakReference(listener) + application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(a: Activity, b: Bundle?) {} + override fun onActivityStarted(a: Activity) {} + override fun onActivityResumed(a: Activity) {} + override fun onActivityPaused(a: Activity) {} + override fun onActivityStopped(a: Activity) {} + override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {} + override fun onActivityDestroyed(destroyed: Activity) { + if (destroyed === weakActivity.get()) { + weakListener.get()?.let { l -> + windowCallbacksRegistry.removeListener(destroyed, l) + } + application.unregisterActivityLifecycleCallbacks(this) + } + } + }) + } + private fun onFirstDraw(callback: RumFirstDrawTimeReporter.Callback) { val nowNs = timeProviderNs() diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt index b282eb3306..798894e9ee 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt @@ -14,15 +14,50 @@ import java.util.WeakHashMap import kotlin.collections.getOrPut import kotlin.let +/** + * Listener that is notified when the content of an activity's window changes. + * + * Used to detect when the decor view becomes available after [Activity.setContentView] is called. + */ interface WindowCallbackListener { + /** + * Called when the window's content has changed (i.e., [Window.Callback.onContentChanged]). + */ fun onContentChanged() } +/** + * Manages [WindowCallbackListener] registrations on a per-[Activity] basis. + * + * Wraps the activity's [Window.Callback] so that content-change events can be forwarded to + * registered listeners without replacing any existing callback logic. + */ interface WindowCallbacksRegistry { + /** + * Registers [listener] to receive content-change callbacks for [activity]. + * + * @param activity The activity whose window callback should be observed. + * @param listener The listener to register. + */ fun addListener(activity: Activity, listener: WindowCallbackListener) + + /** + * Unregisters [listener] from content-change callbacks for [activity]. + * + * If no more listeners remain for the activity, the wrapped window callback is removed. + * + * @param activity The activity whose window callback is being observed. + * @param listener The listener to unregister. + */ fun removeListener(activity: Activity, listener: WindowCallbackListener) } +/** + * Default implementation of [WindowCallbacksRegistry]. + * + * Maintains a weak mapping from [Activity] to a wrapped [Window.Callback] so that listeners + * can be added and removed without leaking activity references. + */ class WindowCallbacksRegistryImpl : WindowCallbacksRegistry { private val callbacks = WeakHashMap() @@ -62,6 +97,7 @@ class WindowCallbacksRegistryImpl : WindowCallbacksRegistry { } } +@Suppress("PackageNameVisibility") private class WindowCallback( val wrapped: Window.Callback ) : FixedWindowCallback(wrapped) { diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt index f3f9ac3c7f..fadbea11c7 100644 --- a/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.rum.startup import android.app.Activity +import android.app.Application import android.os.Handler import android.os.Message import android.view.View @@ -24,6 +25,7 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.inOrder +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -48,6 +50,9 @@ class RumFirstDrawTimeReporterImplTest { @Mock private lateinit var activity: Activity + @Mock + private lateinit var mockApplication: Application + @Mock private lateinit var window: Window @@ -68,6 +73,7 @@ class RumFirstDrawTimeReporterImplTest { ) whenever(activity.window) doReturn window + whenever(activity.application) doReturn mockApplication whenever(window.peekDecorView()) doReturn null whenever(window.decorView) doReturn decorView @@ -270,4 +276,57 @@ class RumFirstDrawTimeReporterImplTest { verifyNoMoreInteractions() } } + + @Test + fun `M remove WindowCallbackListener W activity destroyed { before setContentView is called }`() { + // Simulates InterstitialSplashActivity: decorView is null and setContentView is never called. + // The addListener stub must NOT fire onContentChanged immediately, because we want to test + // the path where the Activity is destroyed before any content is set. + var capturedListener: WindowCallbackListener? = null + whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { + capturedListener = it.getArgument(1) + } + var registeredLifecycleCallback: Application.ActivityLifecycleCallbacks? = null + whenever(mockApplication.registerActivityLifecycleCallbacks(any())).doAnswer { + registeredLifecycleCallback = it.getArgument(0) + } + + // When + reporter.subscribeToFirstFrameDrawn(activity, callback) + + checkNotNull(registeredLifecycleCallback) { + "Expected a lifecycle callback to be registered for destroy cleanup" + } + checkNotNull(capturedListener) { "Expected addListener to be called" } + + // When the activity is destroyed before setContentView fires + registeredLifecycleCallback!!.onActivityDestroyed(activity) + + // Then — the WindowCallbackListener is removed (breaks the strong reference in WeakHashMap value) + verify(windowCallbackRegistry).removeListener(activity, capturedListener!!) + // And the lifecycle callback unregisters itself to avoid accumulation + verify(mockApplication).unregisterActivityLifecycleCallbacks(registeredLifecycleCallback) + verifyNoInteractions(callback) + } + + @Test + fun `M not remove WindowCallbackListener W activity destroyed { for a different activity }`() { + // Given — addListener does not fire onContentChanged + var registeredLifecycleCallback: Application.ActivityLifecycleCallbacks? = null + whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { } + whenever(mockApplication.registerActivityLifecycleCallbacks(any())).doAnswer { + registeredLifecycleCallback = it.getArgument(0) + } + reporter.subscribeToFirstFrameDrawn(activity, callback) + checkNotNull(registeredLifecycleCallback) + + val otherActivity = org.mockito.kotlin.mock() + + // When destroyed fires for a different activity + registeredLifecycleCallback!!.onActivityDestroyed(otherActivity) + + // Then — nothing is cleaned up for our activity + verify(windowCallbackRegistry).addListener(any(), any()) + org.mockito.kotlin.verifyNoMoreInteractions(windowCallbackRegistry) + } } 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 index e43eeaa872..75ba181883 100644 --- 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 @@ -12,7 +12,6 @@ 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 @@ -28,28 +27,20 @@ import com.datadog.android.rum.DdRumContentProvider * No public API is exposed by this class; users interact with this module by adding * it as a Gradle dependency only. */ +@Suppress("PackageNameVisibility") internal class AppLaunchCollectorProvider : ContentProvider() { + @Suppress("ReturnCount") override fun onCreate(): Boolean { - val application = context?.applicationContext as? Application ?: run { - Log.w(TAG, "onCreate: applicationContext is null, skipping install") - return false - } + val application = context?.applicationContext as? Application ?: 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?, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt index b155c5aec5..7158279325 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/Rum.kt @@ -100,6 +100,15 @@ object Rum { sdkCore ) + // If the pre-launch collector was COMPLETE at init time, RumFeature deferred sending + // AppStart + TTID until after monitor registration. Post to the main thread now so the + // action runs with the real monitor guaranteed to be available, regardless of which thread + // Rum.enable() was called on (main thread for native Android, background for RN/Flutter). + rumFeature.pendingPreLaunchAction?.let { action -> + rumFeature.pendingPreLaunchAction = null + Handler(Looper.getMainLooper()).post(action) + } + // TODO RUM-3794 there is a small chance of application crashing between RUM monitor // registration and the moment SDK init is processed, in this case we will miss this crash // (it won't activate new session). Ideally we should start session when monitor is created 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 c441f59ebc..0ac1e6901f 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,7 +14,6 @@ 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 @@ -58,8 +57,8 @@ import com.datadog.android.rum.internal.anr.ANRDetectorRunnable import com.datadog.android.rum.internal.anr.ANRException 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.Time import com.datadog.android.rum.internal.domain.accessibility.AccessibilityInfo import com.datadog.android.rum.internal.domain.accessibility.AccessibilitySnapshotManager import com.datadog.android.rum.internal.domain.accessibility.DefaultAccessibilityReader @@ -93,9 +92,6 @@ import com.datadog.android.rum.internal.startup.DefaultAppStartupActivityPredica 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.RumFirstDrawTimeReporter -import com.datadog.android.rum.startup.RumFirstDrawTimeReporterImpl -import com.datadog.android.rum.startup.WindowCallbacksRegistryImpl import com.datadog.android.rum.internal.thread.NoOpScheduledExecutorService import com.datadog.android.rum.internal.tracking.JetpackViewAttributesProvider import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate @@ -126,9 +122,13 @@ import com.datadog.android.rum.model.ViewEvent import com.datadog.android.rum.model.VitalAppLaunchEvent import com.datadog.android.rum.model.VitalOperationStepEvent import com.datadog.android.rum.startup.AppStartupActivityPredicate +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.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.ActivityViewTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate +import com.datadog.android.rum.tracking.NavigationViewTrackingStrategy import com.datadog.android.rum.tracking.NoOpActionTrackingStrategy import com.datadog.android.rum.tracking.NoOpTrackingStrategy import com.datadog.android.rum.tracking.NoOpViewTrackingStrategy @@ -137,17 +137,17 @@ 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 import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration.Companion.seconds /** * RUM feature class, which needs to be registered with Datadog SDK instance. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") internal class RumFeature( private val sdkCore: FeatureSdkCore, internal val applicationId: String, @@ -169,6 +169,11 @@ internal class RumFeature( internal var trackFrustrations: Boolean = false internal var viewTrackingStrategy: ViewTrackingStrategy = NoOpViewTrackingStrategy() + + // Set by initRumAppStartupDetector() when the collector is COMPLETE at SDK init time. + // Rum.kt invokes this on the main thread after GlobalRumMonitor.registerIfAbsent() returns, + // ensuring the real monitor is available regardless of which thread Rum.enable() is called on. + internal var pendingPreLaunchAction: (() -> Unit)? = null internal var actionTrackingStrategy: UserActionTrackingStrategy = NoOpUserActionTrackingStrategy() internal var longTaskTrackingStrategy: TrackingStrategy = NoOpTrackingStrategy() @@ -746,57 +751,30 @@ 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") + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: 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 + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "TTID: IDLE — SDK init before first Activity;" + + " claiming collector, using default RumAppStartupDetector" } - 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)) + collector.claim() + createDefaultRumAppStartupDetector() } + AppLaunchPreInitCollector.State.CAPTURING -> handleCapturingState(collector) + AppLaunchPreInitCollector.State.COMPLETE -> handleCompleteState(collector) 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, @@ -807,6 +785,102 @@ internal class RumFeature( } } + private fun handleCapturingState(collector: AppLaunchPreInitCollector) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: CAPTURING — SDK init during Activity capture; subscribing to first-frame callback" } + ) + val scenario = constructScenario(collector) + ?: run { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: CAPTURING — constructScenario returned null; fallback to default" } + ) + createDefaultRumAppStartupDetector() + return + } + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: CAPTURING — scenario=${scenario::class.simpleName}; awaiting first frame" } + ) + // GlobalRumMonitor is not yet registered during onInitialize — the real monitor is + // registered in Rum.enable() after onInitialize returns. Re-get the monitor inside + // the first-frame callback, which fires after Rum.enable() has completed. + collector.addFirstFrameCallback { firstFrameNs -> + val durationNs = firstFrameNs - scenario.initialTime.nanoTime + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: CAPTURING→COMPLETE callback: durationMs=${durationNs / NS_PER_MS}" } + ) + val rumMonitor = + GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return@addFirstFrameCallback + rumMonitor.sendAppStartEvent(scenario) + rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) + } + } + + // ThreadSafety: pendingPreLaunchAction is dispatched on the main thread in Rum.kt via + // Handler(Looper.getMainLooper()).post(), so @MainThread calls inside are safe. + @Suppress("ThreadSafety") + private fun handleCompleteState(collector: AppLaunchPreInitCollector) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "TTID: COMPLETE — processStartNs=${collector.processStartNs}" + + " activityOnCreateNs=${collector.activityOnCreateNs}" + + " firstFrameNs=${collector.firstFrameNs}" + + " totalMs=${(collector.firstFrameNs - collector.processStartNs) / NS_PER_MS}" + } + ) + val scenario = constructScenario(collector) + ?: run { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: COMPLETE — constructScenario returned null; fallback to default" } + ) + createDefaultRumAppStartupDetector() + return + } + val durationNs = collector.firstFrameNs - scenario.initialTime.nanoTime + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "TTID: COMPLETE — scenario=${scenario::class.simpleName};" + + " durationMs=${durationNs / NS_PER_MS}; deferring events" + } + ) + // GlobalRumMonitor is not yet registered during onInitialize. Rum.kt calls + // pendingPreLaunchAction on the main thread after GlobalRumMonitor.registerIfAbsent(), + // guaranteeing the real monitor is available regardless of which thread Rum.enable() + // is called on (main thread for native Android, background thread for RN/Flutter). + // + // Additionally, the Activity has already completed its full lifecycle before the + // SDK initialized (e.g. a cross-platform bridge delay). The view tracking strategy + // missed onActivityStarted/onActivityResumed, so no RUM view has been started yet. + // We replay the relevant lifecycle callback here so startView is queued before + // AppStart/TTID. + val capturedStrategy = viewTrackingStrategy + pendingPreLaunchAction = action@{ + val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return@action + scenario.activity.get()?.let { activity -> + when (capturedStrategy) { + is NavigationViewTrackingStrategy -> capturedStrategy.onActivityStarted(activity) + is ActivityViewTrackingStrategy -> capturedStrategy.onActivityResumed(activity) + else -> Unit + } + } + rumMonitor.sendAppStartEvent(scenario) + rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) + } + } + private fun createDefaultRumAppStartupDetector() { rumAppStartupDetector = RumAppStartupDetector.create( application = appContext.applicationContext as Application, @@ -827,29 +901,56 @@ internal class RumFeature( ) override fun onAppStartupDetected(scenario: RumStartupScenario) { + val activity = scenario.activity.get() ?: return val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return + rumMonitor.sendAppStartEvent(scenario) + subscribeToFirstFrameDrawn(scenario, activity, rumMonitor, wasForwarded = false) } - override fun onTTIDComputed( + override fun onNextActivityCreated( + pendingScenario: RumStartupScenario, + activity: Activity + ) { + val rumMonitor = (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) ?: return + subscribeToFirstFrameDrawn(pendingScenario, activity, rumMonitor, wasForwarded = true) + } + + private fun subscribeToFirstFrameDrawn( scenario: RumStartupScenario, - durationNs: Long, + activity: Activity, + rumMonitor: AdvancedRumMonitor, wasForwarded: Boolean ) { - val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return - val info = RumTTIDInfo( - scenario = scenario, - durationNs = durationNs, - wasForwarded = wasForwarded - ) + val callback = object : RumFirstDrawTimeReporter.Callback { + override fun onFirstFrameDrawn(timestampNs: Long) { + // Another activity may have already reported TTID + val pending = rumAppStartupDetector?.getPendingScenario() + if (pending !== scenario) return + + val durationNs = timestampNs - scenario.initialTime.nanoTime + val info = RumTTIDInfo( + scenario = scenario, + durationNs = durationNs, + wasForwarded = wasForwarded + ) + + rumMonitor.sendTTIDEvent(info) + rumAppStartupDetector?.clearPendingScenario() + } + } - rumMonitor.sendTTIDEvent(info) + rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn( + activity = activity, + callback = callback + ) } }, appStartupActivityPredicate = configuration.appStartupActivityPredicate ) } + @Suppress("ReturnCount", "StringLiteralDuplication", "UnsafeCallOnNullableType", "LongMethod") private fun constructScenario(collector: AppLaunchPreInitCollector): RumStartupScenario? { val activity = collector.activity?.get() if (activity == null) { @@ -865,19 +966,21 @@ internal class RumFeature( return null } - val processStartTime = collector.processStartNs.asTimeNs() - val activityOnCreateTime = collector.activityOnCreateNs.asTimeNs() + val processStartTime = Time.fromNanoTime(collector.processStartNs, sdkCore.timeProvider) + val activityOnCreateTime = Time.fromNanoTime(collector.activityOnCreateNs, sdkCore.timeProvider) 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" + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "TTID: WarmFirstActivity" + + " gapMs=${gapNs / NS_PER_MS} hasSavedInstanceState=$hasSavedInstanceStateBundle" + } ) RumStartupScenario.WarmFirstActivity( hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, @@ -886,11 +989,13 @@ internal class RumFeature( 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" + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "TTID: Cold" + + " gapMs=${gapNs / NS_PER_MS} hasSavedInstanceState=$hasSavedInstanceStateBundle" + } ) RumStartupScenario.Cold( hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, @@ -900,10 +1005,10 @@ internal class RumFeature( ) } } else { - Log.d( - TTID_LOG_TAG, - "constructScenario: WarmAfterActivityDestroyed" + - " hasSavedInstanceState=$hasSavedInstanceStateBundle" + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: WarmAfterActivityDestroyed hasSavedInstanceState=$hasSavedInstanceStateBundle" } ) RumStartupScenario.WarmAfterActivityDestroyed( hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, @@ -954,6 +1059,7 @@ internal class RumFeature( internal companion object { private val START_GAP_THRESHOLD_NS = 10.seconds.inWholeNanoseconds + private const val NS_PER_MS = 1_000_000L internal const val NDK_CRASH_BUS_MESSAGE_TYPE = "ndk_crash" internal const val LOGGER_ERROR_BUS_MESSAGE_TYPE = "logger_error" @@ -1045,10 +1151,6 @@ internal class RumFeature( "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/RumAppStartupDetector.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt index 693cdd1ceb..a93128bbb2 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt @@ -6,8 +6,8 @@ package com.datadog.android.rum.internal.startup +import android.app.Activity import android.app.Application -import androidx.annotation.UiThread import com.datadog.android.core.InternalSdkCore import com.datadog.android.internal.system.BuildSdkVersionProvider import com.datadog.android.rum.internal.domain.Time @@ -19,11 +19,12 @@ internal interface RumAppStartupDetector { * Called when a startup scenario is detected. */ fun onAppStartupDetected(scenario: RumStartupScenario) - fun onTTIDComputed(scenario: RumStartupScenario, durationNs: Long, wasForwarded: Boolean) + fun onNextActivityCreated(pendingScenario: RumStartupScenario, activity: Activity) } - @UiThread fun destroy() + fun getPendingScenario(): RumStartupScenario? + fun clearPendingScenario() companion object { fun create( @@ -32,16 +33,13 @@ internal interface RumAppStartupDetector { listener: Listener, appStartupActivityPredicate: AppStartupActivityPredicate ): RumAppStartupDetector { - val rumFirstDrawTimeReporter = RumFirstDrawTimeReporter.create(sdkCore = sdkCore) - return RumAppStartupDetectorImpl( application = application, buildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT, appStartupTime = { Time.fromNanoTime(sdkCore.appStartTimeNs, sdkCore.timeProvider) }, currentTime = { Time.now(sdkCore.timeProvider) }, listener = listener, - appStartupActivityPredicate = appStartupActivityPredicate, - rumFirstDrawTimeReporter = rumFirstDrawTimeReporter + appStartupActivityPredicate = appStartupActivityPredicate ) } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt index c176cfcda5..5f11442b30 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt @@ -24,8 +24,7 @@ internal class RumAppStartupDetectorImpl( private val appStartupTime: () -> Time, private val currentTime: () -> Time, private val listener: RumAppStartupDetector.Listener, - private val appStartupActivityPredicate: AppStartupActivityPredicate, - private val rumFirstDrawTimeReporter: RumFirstDrawTimeReporter + private val appStartupActivityPredicate: AppStartupActivityPredicate ) : RumAppStartupDetector, Application.ActivityLifecycleCallbacks { private var numberOfActivities: Int = 0 @@ -35,7 +34,6 @@ internal class RumAppStartupDetectorImpl( @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty private val trackedActivities = Collections.newSetFromMap(WeakHashMap()) - private val firstFrameHandles = WeakHashMap() init { application.registerActivityLifecycleCallbacks(this) @@ -57,8 +55,6 @@ internal class RumAppStartupDetectorImpl( numberOfActivities-- trackedActivities.remove(activity) - firstFrameHandles.remove(activity)?.unsubscribe() - if (numberOfActivities == 0) { isChangingConfigurations = activity.isChangingConfigurations } @@ -79,7 +75,6 @@ internal class RumAppStartupDetectorImpl( override fun onActivityStopped(activity: Activity) { } - @Suppress("LongMethod") private fun onBeforeActivityCreated(activity: Activity, savedInstanceState: Bundle?) { numberOfActivities++ val now = currentTime() @@ -137,15 +132,7 @@ internal class RumAppStartupDetectorImpl( } pendingScenario = scenario - listener.onAppStartupDetected(scenario) - - subscribeToFirstFrameDrawn( - scenario = scenario, - activity = activity, - wasForwarded = false - ) - isFirstActivityForProcess = false } @@ -155,50 +142,19 @@ internal class RumAppStartupDetectorImpl( if (currentPendingScenario != null && shouldTrackStartup && currentPendingScenario.activity.get() !== activity ) { - subscribeToFirstFrameDrawn( - scenario = currentPendingScenario, - activity = activity, - wasForwarded = true - ) + listener.onNextActivityCreated(currentPendingScenario, activity) } } - private fun subscribeToFirstFrameDrawn( - scenario: RumStartupScenario, - activity: Activity, - wasForwarded: Boolean - ) { - val callback = object : RumFirstDrawTimeReporter.Callback { - override fun onFirstFrameDrawn(timestampNs: Long) { - firstFrameHandles.remove(activity) + override fun getPendingScenario(): RumStartupScenario? = pendingScenario - // Another activity may have already reported TTID - if (pendingScenario !== scenario) return - - val durationNs = timestampNs - scenario.initialTime.nanoTime - - listener.onTTIDComputed( - scenario = scenario, - durationNs = durationNs, - wasForwarded = wasForwarded - ) - - pendingScenario = null - } - } - - firstFrameHandles[activity] = rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn( - activity = activity, - callback = callback - ) + override fun clearPendingScenario() { + pendingScenario = null } override fun destroy() { pendingScenario = null application.unregisterActivityLifecycleCallbacks(this) - - firstFrameHandles.forEach { (_, handle) -> handle.unsubscribe() } - firstFrameHandles.clear() } companion object { 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 index a5d7a21bc7..142b48985e 100644 --- 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 @@ -17,6 +17,8 @@ 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.tracking.ActivityViewTrackingStrategy +import com.datadog.android.rum.tracking.NavigationViewTrackingStrategy import com.datadog.android.rum.utils.config.ApplicationContextTestConfiguration import com.datadog.android.rum.utils.config.MainLooperTestConfiguration import com.datadog.android.rum.utils.forge.Configurator @@ -40,9 +42,9 @@ 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.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -137,8 +139,8 @@ internal class RumFeaturePreInitStartupTest { @Suppress("UNCHECKED_CAST") (callbacksField.get(AppLaunchPreInitCollector) as CopyOnWriteArrayList<*>).clear() - // Reset _application via reflection - val appField = AppLaunchPreInitCollector::class.java.getDeclaredField("_application") + // Reset registeredApplication via reflection + val appField = AppLaunchPreInitCollector::class.java.getDeclaredField("registeredApplication") appField.isAccessible = true appField.set(AppLaunchPreInitCollector, null) } @@ -243,8 +245,10 @@ internal class RumFeaturePreInitStartupTest { // region INT-03: CAPTURING branch @Test - fun `M send app start event immediately W initRumAppStartupDetector() { collector CAPTURING }`() { - // Given + fun `M not send app start event during onInitialize W initRumAppStartupDetector() { collector CAPTURING }`() { + // Given — GlobalRumMonitor is registered before onInitialize in tests, but in production + // the real monitor is registered AFTER onInitialize returns. The CAPTURING branch now + // defers events to the first-frame callback to avoid sending to a NoOp monitor. val mockActivity = mock() setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) configureCollectorData( @@ -257,12 +261,12 @@ internal class RumFeaturePreInitStartupTest { // When testedFeature.onInitialize(appContext.mockInstance) - // Then — sendAppStartEvent is called once - verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) + // Then — sendAppStartEvent is NOT called immediately; deferred to first-frame callback + verify(mockRumMonitor, never()).sendAppStartEvent(any()) } @Test - fun `M send TTID on first frame callback W initRumAppStartupDetector() { collector CAPTURING }`() { + fun `M send app start and TTID on first frame callback W initRumAppStartupDetector() { collector CAPTURING }`() { // Given val mockActivity = mock() setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) @@ -276,14 +280,15 @@ internal class RumFeaturePreInitStartupTest { // When testedFeature.onInitialize(appContext.mockInstance) - // Simulate first frame callback by transitioning to COMPLETE and draining + // Simulate first frame callback firing (after Rum.enable() would have returned in prod) val fakeFirstFrameNs = 300_000L setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) AppLaunchPreInitCollector.firstFrameNs = fakeFirstFrameNs val callbacks = getFirstFrameCallbacks() callbacks.forEach { it(fakeFirstFrameNs) } - // Then — sendTTIDEvent is called once + // Then — both sendAppStartEvent and sendTTIDEvent are called from the callback + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) val ttidCaptor = argumentCaptor() verify(mockRumMonitor, times(1)).sendTTIDEvent(ttidCaptor.capture()) assertThat(ttidCaptor.firstValue.durationNs).isEqualTo(fakeFirstFrameNs - 100_000L) @@ -294,9 +299,90 @@ internal class RumFeaturePreInitStartupTest { // region INT-04: COMPLETE branch @Test - fun `M send app start and TTID synchronously W initRumAppStartupDetector() { collector COMPLETE }`() { + fun `M not send events during onInitialize W initRumAppStartupDetector() { collector COMPLETE }`() { + // Given — events are deferred to pendingPreLaunchAction (invoked by Rum.kt after registerIfAbsent) + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When — onInitialize runs but pendingPreLaunchAction has not been invoked yet + testedFeature.onInitialize(appContext.mockInstance) + + // Then — events NOT sent yet (action pending Rum.kt dispatch) + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockRumMonitor, never()).sendTTIDEvent(any()) + } + + @Test + fun `M send app start and TTID W initRumAppStartupDetector() { collector COMPLETE + action invoked }`() { + // Given + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When — simulate what Rum.kt does after registerIfAbsent + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() + + // Then — both events sent + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) + verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) + } + + @Test + fun `M call onActivityStarted on NavigationViewTrackingStrategy W initRumAppStartupDetector() { COMPLETE }`() { + // Given + val mockActivity = mock() + val mockNavStrategy = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + // Rebuild feature with NavigationViewTrackingStrategy so viewTrackingStrategy is set + testedFeature = RumFeature( + mockSdkCore, + fakeApplicationId.toString(), + fakeConfiguration.copy( + appStartupActivityPredicate = mockAppStartupActivityPredicate, + viewTrackingStrategy = mockNavStrategy + ), + lateCrashReporterFactory = { mockLateCrashReporter } + ) + GlobalRumMonitor.clear() + GlobalRumMonitor.registerIfAbsent(mockRumMonitor, mockSdkCore) + + // When — simulate Rum.kt: onInitialize then dispatch pendingPreLaunchAction + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() + + // Then — view tracking strategy is primed before startup events so the RUM executor + // has a StartView in the queue before AppStart/TTID + verify(mockNavStrategy).onActivityStarted(mockActivity) + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) + verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) + } + + @Test + fun `M call onActivityResumed on ActivityViewTrackingStrategy W initRumAppStartupDetector() { COMPLETE }`() { // Given val mockActivity = mock() + val mockActivityStrategy = mock() setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) configureCollectorData( processStartNs = 100_000L, @@ -305,15 +391,59 @@ internal class RumFeaturePreInitStartupTest { isFirstActivityForProcess = true, activity = mockActivity ) + testedFeature = RumFeature( + mockSdkCore, + fakeApplicationId.toString(), + fakeConfiguration.copy( + appStartupActivityPredicate = mockAppStartupActivityPredicate, + viewTrackingStrategy = mockActivityStrategy + ), + lateCrashReporterFactory = { mockLateCrashReporter } + ) + GlobalRumMonitor.clear() + GlobalRumMonitor.registerIfAbsent(mockRumMonitor, mockSdkCore) // When testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() - // Then — both events called synchronously + // Then + verify(mockActivityStrategy).onActivityResumed(mockActivity) verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) } + @Test + fun `M skip view tracking strategy W initRumAppStartupDetector() { collector COMPLETE + activity GCd }`() { + // Given — constructScenario returns null when activity is GC'd, falls back to default detector + val mockNavStrategy = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L + ) + AppLaunchPreInitCollector.activity = WeakReference(null) // GC'd + testedFeature = RumFeature( + mockSdkCore, + fakeApplicationId.toString(), + fakeConfiguration.copy( + appStartupActivityPredicate = mockAppStartupActivityPredicate, + viewTrackingStrategy = mockNavStrategy + ), + lateCrashReporterFactory = { mockLateCrashReporter } + ) + GlobalRumMonitor.clear() + GlobalRumMonitor.registerIfAbsent(mockRumMonitor, mockSdkCore) + + // When — pendingPreLaunchAction is null because constructScenario returned null + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() + + // Then — no attempt to prime view tracking + verify(mockNavStrategy, never()).onActivityStarted(any()) + } + // endregion // region INT-05: constructScenario Cold vs Warm @@ -333,6 +463,7 @@ internal class RumFeaturePreInitStartupTest { // When testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() // Then — scenario is Cold with initialTime.nanoTime == processStartNs val scenarioCaptor = argumentCaptor() @@ -357,6 +488,7 @@ internal class RumFeaturePreInitStartupTest { // When testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() // Then — scenario is WarmFirstActivity with initialTime.nanoTime == activityOnCreateNs val scenarioCaptor = argumentCaptor() @@ -367,7 +499,7 @@ internal class RumFeaturePreInitStartupTest { } @Test - fun `M construct WarmAfterActivityDestroyed scenario W initRumAppStartupDetector() { COMPLETE + not first activity }`() { + fun `M construct WarmAfterActivityDestroyed W initRumAppStartupDetector() { COMPLETE + notFirstActivity }`() { // Given val mockActivity = mock() setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) @@ -381,6 +513,7 @@ internal class RumFeaturePreInitStartupTest { // When testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() // Then — scenario is WarmAfterActivityDestroyed val scenarioCaptor = argumentCaptor() 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 6791ffaa76..9c77f82c6f 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,9 +16,9 @@ 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.startup.RumFirstDrawTimeReporter import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo +import com.datadog.android.rum.startup.RumFirstDrawTimeReporter import com.datadog.android.rum.utils.config.ApplicationContextTestConfiguration import com.datadog.android.rum.utils.config.MainLooperTestConfiguration import com.datadog.android.rum.utils.forge.Configurator @@ -26,6 +26,7 @@ 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 com.datadog.tools.unit.setFieldValue import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -42,8 +43,10 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doNothing import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock 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 @@ -114,11 +117,13 @@ internal class RumFeatureStartupDetectorTest { // region onAppStartupDetected @Test - fun `M send app start event W onAppStartupDetected`() { + fun `M send TTID with wasForwarded=false W onAppStartupDetected + first frame drawn`() { // Given testedFeature.onInitialize(appContext.mockInstance) val listener = extractStartupDetectorListener() + val mockFirstDrawReporter = replaceFirstDrawReporterWithMock(listener) + val mockDetector = replaceDetectorWithMock() val fakeActivity = mock() val fakeScenario = RumStartupScenario.Cold( @@ -128,11 +133,58 @@ internal class RumFeatureStartupDetectorTest { initialTime = fakeTime ) + whenever(mockDetector.getPendingScenario()) doReturn fakeScenario + + val callbackCaptor = argumentCaptor() + // When listener.onAppStartupDetected(fakeScenario) - // Then + // Then — sendAppStartEvent should be called verify(mockRumMonitor).sendAppStartEvent(fakeScenario) + + verify(mockFirstDrawReporter).subscribeToFirstFrameDrawn( + eq(fakeActivity), + callbackCaptor.capture() + ) + + // When — simulate first frame drawn + val fakeTimestampNs = 200_000L + callbackCaptor.firstValue.onFirstFrameDrawn(fakeTimestampNs) + + // Then + val ttidInfoCaptor = argumentCaptor() + verify(mockRumMonitor).sendTTIDEvent(ttidInfoCaptor.capture()) + + val ttidInfo = ttidInfoCaptor.firstValue + assertThat(ttidInfo.scenario).isSameAs(fakeScenario) + assertThat(ttidInfo.durationNs).isEqualTo(fakeTimestampNs - fakeTime.nanoTime) + assertThat(ttidInfo.wasForwarded).isFalse() + verify(mockDetector).clearPendingScenario() + } + + @Test + fun `M do nothing W onAppStartupDetected + activity GCd`() { + // Given + testedFeature.onInitialize(appContext.mockInstance) + + val listener = extractStartupDetectorListener() + val mockFirstDrawReporter = replaceFirstDrawReporterWithMock(listener) + + val fakeTime = Time(timestamp = 0L, nanoTime = 100_000L) + val fakeScenario = RumStartupScenario.Cold( + hasSavedInstanceStateBundle = false, + activity = WeakReference(null), // GC'd — get() returns null + appStartActivityOnCreateGapNs = 0L, + initialTime = fakeTime + ) + + // When + listener.onAppStartupDetected(fakeScenario) + + // Then + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockFirstDrawReporter, never()).subscribeToFirstFrameDrawn(any(), any()) } @Test @@ -141,11 +193,15 @@ internal class RumFeatureStartupDetectorTest { testedFeature.onInitialize(appContext.mockInstance) val listener = extractStartupDetectorListener() + val mockFirstDrawReporter = replaceFirstDrawReporterWithMock(listener) + // Replace the registered AdvancedRumMonitor with a plain RumMonitor + // so the cast in onAppStartupDetected returns null GlobalRumMonitor.clear() GlobalRumMonitor.registerIfAbsent(mock(), mockSdkCore) val fakeActivity = mock() + val fakeTime = Time(timestamp = 0L, nanoTime = 100_000L) val fakeScenario = RumStartupScenario.Cold( hasSavedInstanceStateBundle = false, activity = WeakReference(fakeActivity), @@ -157,60 +213,82 @@ internal class RumFeatureStartupDetectorTest { listener.onAppStartupDetected(fakeScenario) // Then - verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockFirstDrawReporter, never()).subscribeToFirstFrameDrawn(any(), any()) } - // endregion - - // region onTTIDComputed - @Test - fun `M send TTID event W onTTIDComputed`() { + fun `M call sendAppStartEvent only in onAppStartupDetected W startup flow`() { // Given testedFeature.onInitialize(appContext.mockInstance) val listener = extractStartupDetectorListener() + replaceFirstDrawReporterWithMock(listener) + replaceDetectorWithMock() - val fakeActivity = mock() + val fakeOriginalActivity = mock() + val fakeForwardedActivity = mock() + val fakeTime = Time(timestamp = 0L, nanoTime = 100_000L) val fakeScenario = RumStartupScenario.Cold( hasSavedInstanceStateBundle = false, - activity = WeakReference(fakeActivity), + activity = WeakReference(fakeOriginalActivity), appStartActivityOnCreateGapNs = 0L, initialTime = fakeTime ) - val fakeDurationNs = 100_000L // When - listener.onTTIDComputed(fakeScenario, fakeDurationNs, wasForwarded = false) + listener.onAppStartupDetected(fakeScenario) - // Then - val ttidInfoCaptor = argumentCaptor() - verify(mockRumMonitor).sendTTIDEvent(ttidInfoCaptor.capture()) + // Then — sendAppStartEvent called once + verify(mockRumMonitor, times(1)).sendAppStartEvent(fakeScenario) - val ttidInfo = ttidInfoCaptor.firstValue - assertThat(ttidInfo.scenario).isSameAs(fakeScenario) - assertThat(ttidInfo.durationNs).isEqualTo(fakeDurationNs) - assertThat(ttidInfo.wasForwarded).isFalse() + // When + listener.onNextActivityCreated(fakeScenario, fakeForwardedActivity) + + // Then — still only called once (not from onNextActivityCreated) + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) } + // endregion + + // region onNextActivityCreated + @Test - fun `M send TTID event with wasForwarded=true W onTTIDComputed`() { + fun `M send TTID with wasForwarded=true W onNextActivityCreated + first frame drawn`() { // Given testedFeature.onInitialize(appContext.mockInstance) val listener = extractStartupDetectorListener() + val mockFirstDrawReporter = replaceFirstDrawReporterWithMock(listener) + val mockDetector = replaceDetectorWithMock() - val fakeActivity = mock() + val fakeOriginalActivity = mock() + val fakeForwardedActivity = mock() + val fakeTime = Time(timestamp = 0L, nanoTime = 100_000L) val fakeScenario = RumStartupScenario.Cold( hasSavedInstanceStateBundle = false, - activity = WeakReference(fakeActivity), + activity = WeakReference(fakeOriginalActivity), appStartActivityOnCreateGapNs = 0L, initialTime = fakeTime ) - val fakeDurationNs = 100_000L + + whenever(mockDetector.getPendingScenario()) doReturn fakeScenario + + val callbackCaptor = argumentCaptor() // When - listener.onTTIDComputed(fakeScenario, fakeDurationNs, wasForwarded = true) + listener.onNextActivityCreated(fakeScenario, fakeForwardedActivity) + + // Then — sendAppStartEvent should NOT be called via onNextActivityCreated + verify(mockRumMonitor, times(0)).sendAppStartEvent(any()) + + verify(mockFirstDrawReporter).subscribeToFirstFrameDrawn( + eq(fakeForwardedActivity), + callbackCaptor.capture() + ) + + // When — simulate first frame drawn + val fakeTimestampNs = 200_000L + callbackCaptor.firstValue.onFirstFrameDrawn(fakeTimestampNs) // Then val ttidInfoCaptor = argumentCaptor() @@ -218,20 +296,26 @@ internal class RumFeatureStartupDetectorTest { val ttidInfo = ttidInfoCaptor.firstValue assertThat(ttidInfo.scenario).isSameAs(fakeScenario) - assertThat(ttidInfo.durationNs).isEqualTo(fakeDurationNs) + assertThat(ttidInfo.durationNs).isEqualTo(fakeTimestampNs - fakeTime.nanoTime) assertThat(ttidInfo.wasForwarded).isTrue() + verify(mockDetector).clearPendingScenario() } @Test - fun `M do nothing W onTTIDComputed + monitor not AdvancedRumMonitor`() { + fun `M do nothing W onNextActivityCreated + monitor not AdvancedRumMonitor`() { // Given testedFeature.onInitialize(appContext.mockInstance) val listener = extractStartupDetectorListener() + val mockFirstDrawReporter = replaceFirstDrawReporterWithMock(listener) + // Replace the registered AdvancedRumMonitor with a plain RumMonitor + // so the cast in onNextActivityCreated returns null GlobalRumMonitor.clear() GlobalRumMonitor.registerIfAbsent(mock(), mockSdkCore) + val fakeForwardedActivity = mock() + val fakeTime = Time(timestamp = 0L, nanoTime = 100_000L) val fakeScenario = RumStartupScenario.Cold( hasSavedInstanceStateBundle = false, activity = WeakReference(mock()), @@ -240,10 +324,63 @@ internal class RumFeatureStartupDetectorTest { ) // When - listener.onTTIDComputed(fakeScenario, 100_000L, wasForwarded = false) + listener.onNextActivityCreated(fakeScenario, fakeForwardedActivity) // Then - verify(mockRumMonitor, never()).sendTTIDEvent(any()) + verify(mockFirstDrawReporter, never()).subscribeToFirstFrameDrawn(any(), any()) + } + + // endregion + + // region duplicate prevention + + @Test + fun `M only send TTID once W both original and forwarded activity draw`() { + // Given + testedFeature.onInitialize(appContext.mockInstance) + + val listener = extractStartupDetectorListener() + val mockFirstDrawReporter = replaceFirstDrawReporterWithMock(listener) + val mockDetector = replaceDetectorWithMock() + + val fakeOriginalActivity = mock() + val fakeForwardedActivity = mock() + val fakeTime = Time(timestamp = 0L, nanoTime = 100_000L) + val fakeScenario = RumStartupScenario.Cold( + hasSavedInstanceStateBundle = false, + activity = WeakReference(fakeOriginalActivity), + appStartActivityOnCreateGapNs = 0L, + initialTime = fakeTime + ) + + whenever(mockDetector.getPendingScenario()) doReturn fakeScenario + + val callbackCaptor = argumentCaptor() + + // When — trigger both callbacks + listener.onAppStartupDetected(fakeScenario) + listener.onNextActivityCreated(fakeScenario, fakeForwardedActivity) + + verify(mockFirstDrawReporter, times(2)).subscribeToFirstFrameDrawn( + any(), + callbackCaptor.capture() + ) + + val originalCallback = callbackCaptor.allValues[0] + val forwardedCallback = callbackCaptor.allValues[1] + + // First draw fires (original activity) + originalCallback.onFirstFrameDrawn(200_000L) + + // After first TTID is sent, scenario is cleared + verify(mockDetector).clearPendingScenario() + whenever(mockDetector.getPendingScenario()) doReturn null + + // Second draw fires (forwarded activity) — should be no-op + forwardedCallback.onFirstFrameDrawn(300_000L) + + // Then — sendTTIDEvent should only be called once + verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) } // endregion @@ -266,6 +403,30 @@ internal class RumFeatureStartupDetectorTest { ) } + /** + * Replaces the `rumFirstDrawTimeReporter` field inside the anonymous listener with a mock, + * so we can capture the [RumFirstDrawTimeReporter.Callback] passed to + * [RumFirstDrawTimeReporter.subscribeToFirstFrameDrawn]. + */ + private fun replaceFirstDrawReporterWithMock( + listener: RumAppStartupDetector.Listener + ): RumFirstDrawTimeReporter { + val mockReporter = mock() + listener.setFieldValue("rumFirstDrawTimeReporter", mockReporter) + return mockReporter + } + + /** + * Replaces the `rumAppStartupDetector` field on [RumFeature] with a mock so + * [RumAppStartupDetector.getPendingScenario] and [RumAppStartupDetector.clearPendingScenario] + * can be controlled. + */ + private fun replaceDetectorWithMock(): RumAppStartupDetector { + val mockDetector = mock() + testedFeature.setFieldValue("rumAppStartupDetector", mockDetector) + return mockDetector + } + // endregion companion object { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt index df51c5d848..9996f8681f 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt @@ -19,6 +19,7 @@ import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -28,14 +29,13 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argThat -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -64,9 +64,6 @@ internal class RumAppStartupDetectorImplTest { @Mock private lateinit var buildSdkVersionProvider: BuildSdkVersionProvider - @Mock - private lateinit var rumFirstDrawTimeReporter: RumFirstDrawTimeReporter - @Mock private lateinit var activity: Activity @@ -75,10 +72,6 @@ internal class RumAppStartupDetectorImplTest { @BeforeEach fun `set up`() { whenever(activity.isChangingConfigurations) doReturn false - whenever(rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn(any(), any())).doAnswer { - val handle = mock() - handle - } } @Test @@ -95,7 +88,6 @@ internal class RumAppStartupDetectorImplTest { ) { // Given val detector = createDetector() - autoDrawFirstFrame(activity) currentTime += 3.seconds @@ -108,20 +100,14 @@ internal class RumAppStartupDetectorImplTest { ) // Then - val expectedScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds - ) - inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedScenario), - eq(3.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds ) - } + ) verifyNoMoreInteractions(listener) } @@ -131,7 +117,6 @@ internal class RumAppStartupDetectorImplTest { @BoolForgery hasSavedInstanceStateBundle: Boolean ) { val detector = createDetector() - autoDrawFirstFrame(activity) currentTime += 11.seconds triggerBeforeCreated( @@ -141,23 +126,17 @@ internal class RumAppStartupDetectorImplTest { hasSavedInstanceStateBundle = hasSavedInstanceStateBundle ) - val expectedScenario = RumStartupScenario.WarmFirstActivity( - initialTime = Time( - nanoTime = currentTime.inWholeNanoseconds, - timestamp = currentTime.inWholeMilliseconds - ), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 11.seconds.inWholeNanoseconds - ) - inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedScenario), - eq(0.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.WarmFirstActivity( + initialTime = Time( + nanoTime = currentTime.inWholeNanoseconds, + timestamp = currentTime.inWholeMilliseconds + ), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 11.seconds.inWholeNanoseconds ) - } + ) verifyNoMoreInteractions(listener) } @@ -169,7 +148,6 @@ internal class RumAppStartupDetectorImplTest { ) { // Given val detector = createDetector() - autoDrawFirstFrame(activity) currentTime += 3.seconds @@ -183,6 +161,9 @@ internal class RumAppStartupDetectorImplTest { detector.onActivityDestroyed(activity) + // Simulate RumFeature reporting TTID and clearing the pending scenario + detector.clearPendingScenario() + currentTime += 30.seconds triggerBeforeCreated( @@ -193,33 +174,25 @@ internal class RumAppStartupDetectorImplTest { ) // Then - val expectedColdScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 30.seconds.inWholeNanoseconds - ) - val expectedWarmScenario = RumStartupScenario.WarmAfterActivityDestroyed( - initialTime = Time( - nanoTime = currentTime.inWholeNanoseconds, - timestamp = currentTime.inWholeMilliseconds - ), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle2, - activity = activity.wrapWeak() - ) inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedColdScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedColdScenario), - eq(3.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 30.seconds.inWholeNanoseconds + ) ) - verify(listener).onAppStartupDetected(matchingScenario(expectedWarmScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedWarmScenario), - eq(0L), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.WarmAfterActivityDestroyed( + initialTime = Time( + nanoTime = currentTime.inWholeNanoseconds, + timestamp = currentTime.inWholeMilliseconds + ), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle2, + activity = activity.wrapWeak() + ) ) } verifyNoMoreInteractions(listener) @@ -255,14 +228,12 @@ internal class RumAppStartupDetectorImplTest { ) // Then - verify(listener).onAppStartupDetected( - matchingScenario( - RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds - ) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds ) ) verifyNoMoreInteractions(listener) @@ -302,16 +273,15 @@ internal class RumAppStartupDetectorImplTest { ) // Then - verify(listener).onAppStartupDetected( - matchingScenario( - RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds - ) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds ) ) + verify(listener).onNextActivityCreated(any(), eq(activity2)) verifyNoMoreInteractions(listener) } @@ -323,7 +293,6 @@ internal class RumAppStartupDetectorImplTest { ) { // Given val detector = createDetector() - autoDrawFirstFrame(activity) currentTime += 3.seconds @@ -337,10 +306,12 @@ internal class RumAppStartupDetectorImplTest { // When destroyActivity(detector, activity) + // Simulate RumFeature reporting TTID and clearing the pending scenario + detector.clearPendingScenario() + currentTime += 30.seconds val activity2 = mock() - autoDrawFirstFrame(activity2) triggerBeforeCreated( forge = forge, @@ -350,35 +321,27 @@ internal class RumAppStartupDetectorImplTest { ) // Then - val expectedColdScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 30.seconds.inWholeNanoseconds - ) - val expectedWarmScenario = RumStartupScenario.WarmAfterActivityDestroyed( - initialTime = Time( - nanoTime = currentTime.inWholeNanoseconds, - timestamp = currentTime.inWholeMilliseconds - ), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle2, - activity = activity2.wrapWeak() - ) inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedColdScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedColdScenario), - eq(3.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 30.seconds.inWholeNanoseconds + ) ) - - verify(listener).onAppStartupDetected(matchingScenario(expectedWarmScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedWarmScenario), - eq(0L), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.WarmAfterActivityDestroyed( + initialTime = Time( + nanoTime = currentTime.inWholeNanoseconds, + timestamp = currentTime.inWholeMilliseconds + ), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle2, + activity = activity2.wrapWeak() + ) ) } + verifyNoMoreInteractions(listener) } @@ -392,8 +355,6 @@ internal class RumAppStartupDetectorImplTest { // Given val detector = createDetector() - autoDrawFirstFrame(activity) - currentTime += 3.seconds // When @@ -424,10 +385,12 @@ internal class RumAppStartupDetectorImplTest { detector.onActivityDestroyed(activity) + // Simulate RumFeature reporting TTID and clearing the pending scenario + detector.clearPendingScenario() + currentTime += 30.seconds val activity3 = mock() - autoDrawFirstFrame(activity3) triggerBeforeCreated( forge = forge, @@ -437,35 +400,30 @@ internal class RumAppStartupDetectorImplTest { ) // Then - val expectedColdScenario = RumStartupScenario.Cold( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 30.seconds.inWholeNanoseconds, - initialTime = Time(0, 0) - ) - val expectedWarmScenario = RumStartupScenario.WarmAfterActivityDestroyed( - initialTime = Time( - nanoTime = currentTime.inWholeNanoseconds, - timestamp = currentTime.inWholeMilliseconds - ), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle3, - activity = activity3.wrapWeak() - ) inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedColdScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedColdScenario), - eq(3.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 30.seconds.inWholeNanoseconds, + initialTime = Time(0, 0) + ) ) - verify(listener).onAppStartupDetected(matchingScenario(expectedWarmScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedWarmScenario), - eq(0L), - eq(false) + verify(listener).onNextActivityCreated(any(), eq(activity2)) + + listener.verifyScenarioDetected( + RumStartupScenario.WarmAfterActivityDestroyed( + initialTime = Time( + nanoTime = currentTime.inWholeNanoseconds, + timestamp = currentTime.inWholeMilliseconds + ), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle3, + activity = activity3.wrapWeak() + ) ) } + verifyNoMoreInteractions(listener) } @@ -479,8 +437,6 @@ internal class RumAppStartupDetectorImplTest { val interstitialActivity = mock() val mainActivity = mock() - autoDrawFirstFrame(mainActivity) - val predicate = AppStartupActivityPredicate { activity -> activity != interstitialActivity } @@ -513,20 +469,14 @@ internal class RumAppStartupDetectorImplTest { ) // Then - scenario detected for main activity (first non-excluded) - val expectedScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle2, - activity = mainActivity.wrapWeak(), - appStartActivityOnCreateGapNs = 4.seconds.inWholeNanoseconds - ) - inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedScenario), - eq(4.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle2, + activity = mainActivity.wrapWeak(), + appStartActivityOnCreateGapNs = 4.seconds.inWholeNanoseconds ) - } + ) verifyNoMoreInteractions(listener) } @@ -551,7 +501,7 @@ internal class RumAppStartupDetectorImplTest { ) // Then - no scenario detected - verifyNoInteractions(listener) + verifyNoMoreInteractions(listener) } @Test @@ -566,8 +516,6 @@ internal class RumAppStartupDetectorImplTest { val excludedActivity2 = mock() val includedActivity = mock() - autoDrawFirstFrame(includedActivity) - val predicate = AppStartupActivityPredicate { activity -> activity != excludedActivity1 && activity != excludedActivity2 } @@ -611,20 +559,14 @@ internal class RumAppStartupDetectorImplTest { ) // Then - scenario detected for included activity - val expectedScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle3, - activity = includedActivity.wrapWeak(), - appStartActivityOnCreateGapNs = 5.seconds.inWholeNanoseconds - ) - inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedScenario), - eq(5.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle3, + activity = includedActivity.wrapWeak(), + appStartActivityOnCreateGapNs = 5.seconds.inWholeNanoseconds ) - } + ) verifyNoMoreInteractions(listener) } @@ -648,14 +590,12 @@ internal class RumAppStartupDetectorImplTest { ) // Then - scenario detected (backward compatibility) - verify(listener).onAppStartupDetected( - matchingScenario( - RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds - ) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity.wrapWeak(), + appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds ) ) @@ -672,9 +612,6 @@ internal class RumAppStartupDetectorImplTest { val activity2 = mock() var shouldTrackActivity1 = true - autoDrawFirstFrame(activity1) - autoDrawFirstFrame(activity2) - val mutablePredicate = AppStartupActivityPredicate { activity -> if (activity == activity1) shouldTrackActivity1 else true } @@ -692,17 +629,13 @@ internal class RumAppStartupDetectorImplTest { ) // Then - scenario detected - val expectedColdScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = activity1.wrapWeak(), - appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds - ) - verify(listener).onAppStartupDetected(matchingScenario(expectedColdScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedColdScenario), - eq(3.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.Cold( + initialTime = Time(0, 0), + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity1.wrapWeak(), + appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds + ) ) // When - predicate changes to return false for activity1 @@ -711,6 +644,9 @@ internal class RumAppStartupDetectorImplTest { // And - activity is destroyed (predicate now returns false, but stored value was true) destroyActivity(detector, activity1) + // Simulate RumFeature reporting TTID and clearing the pending scenario + detector.clearPendingScenario() + // When - second activity is created currentTime += 1.seconds @@ -723,19 +659,15 @@ internal class RumAppStartupDetectorImplTest { // Then - scenario detected because counter correctly went from 1 -> 0 -> 1 // (not stuck at 1 due to predicate mismatch) - val expectedWarmScenario = RumStartupScenario.WarmAfterActivityDestroyed( - initialTime = Time( - timestamp = 4.seconds.inWholeMilliseconds, - nanoTime = 4.seconds.inWholeNanoseconds - ), - hasSavedInstanceStateBundle = false, - activity = activity2.wrapWeak() - ) - verify(listener).onAppStartupDetected(matchingScenario(expectedWarmScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedWarmScenario), - eq(0.seconds.inWholeNanoseconds), - eq(false) + listener.verifyScenarioDetected( + RumStartupScenario.WarmAfterActivityDestroyed( + initialTime = Time( + timestamp = 4.seconds.inWholeMilliseconds, + nanoTime = 4.seconds.inWholeNanoseconds + ), + hasSavedInstanceStateBundle = false, + activity = activity2.wrapWeak() + ) ) verifyNoMoreInteractions(listener) @@ -743,6 +675,29 @@ internal class RumAppStartupDetectorImplTest { // region pendingScenario management tests + @Test + fun `M set pendingScenario W onAppStartupDetected`( + forge: Forge + ) { + // Given + val detector = createDetector() + currentTime += 3.seconds + + // When + triggerBeforeCreated( + forge = forge, + detector = detector, + activity = activity, + hasSavedInstanceStateBundle = false + ) + + // Then + val pending = detector.getPendingScenario() + assertThat(pending).isNotNull + assertThat(pending).isInstanceOf(RumStartupScenario.Cold::class.java) + assertThat(pending!!.activity.get()).isSameAs(activity) + } + @Test fun `M create fresh startup scenario W stale pendingScenario exists on re-launch`( forge: Forge @@ -756,6 +711,8 @@ internal class RumAppStartupDetectorImplTest { activity = activity, hasSavedInstanceStateBundle = false ) + val staleScenario = detector.getPendingScenario() + assertThat(staleScenario).isNotNull // Simulate the interstitial activity being fully destroyed (app goes background) destroyActivity(detector, activity) @@ -773,19 +730,19 @@ internal class RumAppStartupDetectorImplTest { hasSavedInstanceStateBundle = false ) - // Then - a fresh scenario was detected for the new activity - verify(listener, times(2)).onAppStartupDetected(any()) - verifyNoMoreInteractions(listener) + // Then - stale scenario was discarded and a fresh one created for the new activity + val freshScenario = detector.getPendingScenario() + assertThat(freshScenario).isNotNull + assertThat(freshScenario).isNotSameAs(staleScenario) + assertThat(freshScenario!!.activity.get()).isSameAs(secondActivity) } @Test - fun `M not subscribe second activity W second activity fails predicate`( + fun `M clear pendingScenario W clearPendingScenario`( forge: Forge ) { // Given - val secondActivity: Activity = mock() - val predicate = AppStartupActivityPredicate { it !== secondActivity } - val detector = createDetector(appStartupActivityPredicate = predicate) + val detector = createDetector() currentTime += 3.seconds triggerBeforeCreated( forge = forge, @@ -793,31 +750,21 @@ internal class RumAppStartupDetectorImplTest { activity = activity, hasSavedInstanceStateBundle = false ) + assertThat(detector.getPendingScenario()).isNotNull // When - currentTime += 1.seconds - triggerBeforeCreated( - forge = forge, - detector = detector, - activity = secondActivity, - hasSavedInstanceStateBundle = false - ) + detector.clearPendingScenario() // Then - inOrder(listener, rumFirstDrawTimeReporter) { - verify(listener).onAppStartupDetected(any()) - verify(rumFirstDrawTimeReporter).subscribeToFirstFrameDrawn(eq(activity), any()) - } - verifyNoMoreInteractions(listener, rumFirstDrawTimeReporter) + assertThat(detector.getPendingScenario()).isNull() } @Test - fun `M not subscribe second activity W pendingScenario cleared by first frame draw`( + fun `M call onNextActivityCreated W second qualifying activity created while pending`( forge: Forge ) { // Given val detector = createDetector() - autoDrawFirstFrame(activity) currentTime += 3.seconds triggerBeforeCreated( forge = forge, @@ -838,21 +785,22 @@ internal class RumAppStartupDetectorImplTest { ) // Then - inOrder(listener, rumFirstDrawTimeReporter) { - verify(listener).onAppStartupDetected(any()) - verify(rumFirstDrawTimeReporter).subscribeToFirstFrameDrawn(eq(activity), any()) - verify(listener).onTTIDComputed(any(), any(), any()) - } - verifyNoMoreInteractions(listener, rumFirstDrawTimeReporter) + val capturedScenario = detector.getPendingScenario() + verify(listener).onAppStartupDetected(any()) + verify(listener).onNextActivityCreated( + argThat { this === capturedScenario }, + eq(secondActivity) + ) } @Test - fun `M not emit second startup W first activity destroyed before next created (async interstitial)`( + fun `M not call onNextActivityCreated W second activity fails predicate`( forge: Forge ) { - // Given - first activity created, startup detected, then fully destroyed before - // the next activity is created (async interstitial pattern: finish() + Handler.postDelayed) - val detector = createDetector() + // Given + val secondActivity: Activity = mock() + val predicate = AppStartupActivityPredicate { it !== secondActivity } + val detector = createDetector(appStartupActivityPredicate = predicate) currentTime += 3.seconds triggerBeforeCreated( forge = forge, @@ -861,12 +809,8 @@ internal class RumAppStartupDetectorImplTest { hasSavedInstanceStateBundle = false ) - destroyActivity(detector, activity) - + // When currentTime += 1.seconds - val secondActivity: Activity = mock() - - // When - next activity created while pendingScenario still exists triggerBeforeCreated( forge = forge, detector = detector, @@ -874,28 +818,36 @@ internal class RumAppStartupDetectorImplTest { hasSavedInstanceStateBundle = false ) - // Then - onAppStartupDetected must NOT be called a second time - inOrder(listener, rumFirstDrawTimeReporter) { - verify(listener, times(1)).onAppStartupDetected(any()) - verify(rumFirstDrawTimeReporter).subscribeToFirstFrameDrawn(eq(activity), any()) - verify(rumFirstDrawTimeReporter).subscribeToFirstFrameDrawn(eq(secondActivity), any()) - } - verifyNoMoreInteractions(listener, rumFirstDrawTimeReporter) + // Then + verify(listener).onAppStartupDetected(any()) + verify(listener, never()).onNextActivityCreated(any(), any()) } - // endregion + @Test + fun `M not call onNextActivityCreated W same activity as scenario`( + forge: Forge + ) { + // Given + val detector = createDetector() + currentTime += 3.seconds + triggerBeforeCreated( + forge = forge, + detector = detector, + activity = activity, + hasSavedInstanceStateBundle = false + ) - // region unsubscribe and TTID callback tests + // Then - onNextActivityCreated should not have been called for the original activity + verify(listener).onAppStartupDetected(any()) + verify(listener, never()).onNextActivityCreated(any(), any()) + } @Test - fun `M call onTTIDComputed with wasForwarded=true W forwarded activity first frame drawn`( + fun `M not call onNextActivityCreated W pendingScenario cleared`( forge: Forge ) { // Given val detector = createDetector() - val secondActivity: Activity = mock() - autoDrawFirstFrame(secondActivity, delay = 1.seconds) - currentTime += 3.seconds triggerBeforeCreated( forge = forge, @@ -903,6 +855,9 @@ internal class RumAppStartupDetectorImplTest { activity = activity, hasSavedInstanceStateBundle = false ) + detector.clearPendingScenario() + + val secondActivity: Activity = mock() // When currentTime += 1.seconds @@ -914,24 +869,17 @@ internal class RumAppStartupDetectorImplTest { ) // Then - inOrder(listener) { - verify(listener).onAppStartupDetected(any()) - verify(listener).onTTIDComputed(any(), eq(5.seconds.inWholeNanoseconds), eq(true)) - } - verifyNoMoreInteractions(listener) + verify(listener).onAppStartupDetected(any()) + verify(listener, never()).onNextActivityCreated(any(), any()) } @Test - fun `M only call onTTIDComputed once W both first and forwarded activity draw`( + fun `M not emit second startup W first activity destroyed before next created (async interstitial)`( forge: Forge ) { - // Given + // Given - first activity created, startup detected, then fully destroyed before + // the next activity is created (async interstitial pattern: finish() + Handler.postDelayed) val detector = createDetector() - val secondActivity: Activity = mock() - autoDrawFirstFrame(activity, delay = 1.seconds) - autoDrawFirstFrame(secondActivity, delay = 2.seconds) - - // When currentTime += 3.seconds triggerBeforeCreated( forge = forge, @@ -939,8 +887,15 @@ internal class RumAppStartupDetectorImplTest { activity = activity, hasSavedInstanceStateBundle = false ) + val originalScenario = detector.getPendingScenario() + assertThat(originalScenario).isNotNull + + destroyActivity(detector, activity) currentTime += 1.seconds + val secondActivity: Activity = mock() + + // When - next activity created while pendingScenario still exists triggerBeforeCreated( forge = forge, detector = detector, @@ -948,36 +903,20 @@ internal class RumAppStartupDetectorImplTest { hasSavedInstanceStateBundle = false ) - // Then - onTTIDComputed should only be called once (first activity drew, clearing scenario) - val expectedScenario = RumStartupScenario.Cold( - initialTime = Time(0, 0), - hasSavedInstanceStateBundle = false, - activity = activity.wrapWeak(), - appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds + // Then - onAppStartupDetected must NOT be called a second time + verify(listener, times(1)).onAppStartupDetected(any()) + // pendingScenario must still be the original (not replaced by a new scenario) + assertThat(detector.getPendingScenario()).isSameAs(originalScenario) + // onNextActivityCreated must be called with the original scenario so RumFeature + // can subscribe to the second activity's first frame (the async forwarding path) + verify(listener).onNextActivityCreated( + argThat { this === originalScenario }, + eq(secondActivity) ) - inOrder(listener) { - verify(listener).onAppStartupDetected(matchingScenario(expectedScenario)) - verify(listener).onTTIDComputed( - matchingScenario(expectedScenario), - eq(4.seconds.inWholeNanoseconds), - eq(false) - ) - } - verifyNoMoreInteractions(listener) } // endregion - private fun autoDrawFirstFrame(activity: Activity, delay: Duration = 0.seconds) { - whenever(rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn(eq(activity), any())).doAnswer { - val callback = it.getArgument(1) - val handle = mock() - currentTime += delay - callback.onFirstFrameDrawn(currentTime.inWholeNanoseconds) - handle - } - } - private fun createDetector( appStartupActivityPredicate: AppStartupActivityPredicate = AppStartupActivityPredicate { true } ): RumAppStartupDetectorImpl { @@ -994,8 +933,7 @@ internal class RumAppStartupDetectorImplTest { ) }, listener = listener, - appStartupActivityPredicate = appStartupActivityPredicate, - rumFirstDrawTimeReporter = rumFirstDrawTimeReporter + appStartupActivityPredicate = appStartupActivityPredicate ) return detector @@ -1029,13 +967,15 @@ internal class RumAppStartupDetectorImplTest { detector.onActivityDestroyed(activity) } - private fun matchingScenario(expected: RumStartupScenario): RumStartupScenario { - return argThat { actual -> - (actual.activity.get() == expected.activity.get()) && - (actual.hasSavedInstanceStateBundle == expected.hasSavedInstanceStateBundle) && - (actual.initialTime == expected.initialTime) && - (actual.javaClass == expected.javaClass) - } + private fun RumAppStartupDetector.Listener.verifyScenarioDetected(expected: RumStartupScenario) { + verify(this).onAppStartupDetected( + argThat { actual -> + (actual.activity.get() == expected.activity.get()) && + (actual.hasSavedInstanceStateBundle == expected.hasSavedInstanceStateBundle) && + (actual.initialTime == expected.initialTime) && + (actual.javaClass == expected.javaClass) + } + ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 51bfb3c0d7..1b401c7e47 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include(":dd-sdk-android-internal") include(":features:dd-sdk-android-trace-api") include(":features:dd-sdk-android-trace-internal") include(":features:dd-sdk-android-rum") +include(":features:dd-sdk-android-rum-prelaunch") include(":features:dd-sdk-android-rum-debug-widget") include(":features:dd-sdk-android-logs") include(":features:dd-sdk-android-ndk") From eac82ebe4ce9b6e04689717246afeb4443030829 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Thu, 16 Jul 2026 11:54:19 +0200 Subject: [PATCH 03/13] Add extra unit test coverage --- .../rum/AppLaunchPreInitCollectorTest.kt | 58 ++++++++++++ .../AppLaunchCollectorProviderTest.kt | 50 +++++++++++ .../internal/RumFeaturePreInitStartupTest.kt | 90 +++++++++++++++++++ 3 files changed, 198 insertions(+) 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 index 2c46230061..c61e7c6b47 100644 --- 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 @@ -846,4 +846,62 @@ internal class AppLaunchPreInitCollectorTest { } // endregion + + // region computeProcessStartNs OEM guard branches + + @Test + fun `M fall back to createTimeNs W computeProcessStartNs() {API 24+, computed is after createTimeNs}`() { + // Given: createTimeNs set to 1 so computed (≈ nanoTime - uptime, a large value) > fallback + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(true) + DdRumContentProvider.createTimeNs = 1L + + // When + val result = AppLaunchPreInitCollector.computeProcessStartNs() + + // Then: computed > fallback guard → returns fallback + assertThat(result).isEqualTo(1L) + } + + @Test + fun `M fall back to createTimeNs W computeProcessStartNs() {API 24+, computed too far before createTimeNs}`() { + // Given: createTimeNs set to far future so (fallback - computed) >> 10s threshold + whenever(stubBuildSdkVersionProvider.isAtLeastN).thenReturn(true) + val farFuture = Long.MAX_VALUE / 2 + DdRumContentProvider.createTimeNs = farFuture + + // When + val result = AppLaunchPreInitCollector.computeProcessStartNs() + + // Then: isTooFarBefore guard → returns fallback + assertThat(result).isEqualTo(farFuture) + } + + // endregion + + // region isFirstActivityForProcess tracking + + @Test + fun `M set isFirstActivityForProcess false W onBeforeActivityCreated() {previous activity was destroyed}`() { + // Given: install, then simulate a prior activity being destroyed before our capture + AppLaunchPreInitCollector.install(mockApplication) + val captor = argumentCaptor() + verify(mockApplication).registerActivityLifecycleCallbacks(captor.capture()) + val callbacks = captor.firstValue + + // Simulate an activity destroyed before the first capture + callbacks.onActivityDestroyed(mockActivity) + + // A new activity is then created (the one we capture) + val mockNewActivity = mock() + whenever(mockNewActivity.window).thenReturn(mockWindow) + whenever(mockNewActivity.application).thenReturn(mockApplication) + + // When + callbacks.onActivityPreCreated(mockNewActivity, null) + + // Then: because _isFirstActivityForProcess was set to false, isFirstActivityForProcess is false + assertThat(AppLaunchPreInitCollector.isFirstActivityForProcess).isFalse() + } + + // endregion } 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 index 560c3a33b2..f33b276fa3 100644 --- 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 @@ -9,6 +9,7 @@ package com.datadog.android.rum.prelaunch import android.app.ActivityManager import android.app.Application import android.content.Context +import android.net.Uri import com.datadog.android.rum.AppLaunchPreInitCollector import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.prelaunch.forge.Configurator @@ -24,6 +25,7 @@ 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.mock import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.atomic.AtomicReference @@ -122,5 +124,53 @@ internal class AppLaunchCollectorProviderTest { .isEqualTo(AppLaunchPreInitCollector.State.IDLE) } + @Test + fun `M return false W onCreate() {applicationContext is not Application}`() { + // Given: applicationContext returns a plain Context, not Application + whenever(mockContext.applicationContext).thenReturn(mockContext) + + // When + val result = testedProvider.onCreate() + + // Then + assertThat(result).isFalse() + assertThat(AppLaunchPreInitCollector.state) + .isEqualTo(AppLaunchPreInitCollector.State.NOT_INSTALLED) + } + + // endregion + + // region stub methods + + @Test + fun `M return null W query() {stub}`() { + val mockUri = mock() + assertThat(testedProvider.query(mockUri, null, null, null, null)).isNull() + } + + @Test + fun `M return null W getType() {stub}`() { + val mockUri = mock() + assertThat(testedProvider.getType(mockUri)).isNull() + } + + @Test + fun `M return null W insert() {stub}`() { + val mockUri = mock() + assertThat(testedProvider.insert(mockUri, null)).isNull() + } + + @Test + fun `M return 0 W delete() {stub}`() { + val mockUri = mock() + assertThat(testedProvider.delete(mockUri, null, null)).isEqualTo(0) + } + + @Test + fun `M return 0 W update() {stub}`() { + val mockUri = mock() + assertThat(testedProvider.update(mockUri, null, null, null)).isEqualTo(0) + } + // endregion } 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 index 142b48985e..dec649bc00 100644 --- 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 @@ -667,6 +667,96 @@ internal class RumFeaturePreInitStartupTest { // endregion + // region monitor not available at dispatch time + + @Test + fun `M skip events W pendingPreLaunchAction() { GlobalRumMonitor not an AdvancedRumMonitor }`() { + // Given — COMPLETE state with valid data + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + testedFeature.onInitialize(appContext.mockInstance) + + // Clear the registered monitor so GlobalRumMonitor.get() returns a NoOp (not AdvancedRumMonitor) + GlobalRumMonitor.clear() + + // When + testedFeature.pendingPreLaunchAction?.invoke() + + // Then — early return: no events sent, no crash + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockRumMonitor, never()).sendTTIDEvent(any()) + } + + @Test + fun `M skip first-frame events W addFirstFrameCallback() { CAPTURING + monitor not AdvancedRumMonitor }`() { + // Given — CAPTURING state + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.CAPTURING) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + testedFeature.onInitialize(appContext.mockInstance) + + // Clear monitor so GlobalRumMonitor.get() inside the callback returns NoOp + GlobalRumMonitor.clear() + + // When — fire the first-frame callback + val fakeFirstFrameNs = 300_000L + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + AppLaunchPreInitCollector.firstFrameNs = fakeFirstFrameNs + val callbacks = getFirstFrameCallbacks() + callbacks.forEach { it(fakeFirstFrameNs) } + + // Then — early return inside callback: no events sent, no crash + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + verify(mockRumMonitor, never()).sendTTIDEvent(any()) + } + + @Test + fun `M not call view tracking strategy W pendingPreLaunchAction() { strategy is null }`() { + // Given — null viewTrackingStrategy hits the else -> Unit branch + val mockActivity = mock() + setCollectorState(AppLaunchPreInitCollector.State.COMPLETE) + configureCollectorData( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + testedFeature = RumFeature( + mockSdkCore, + fakeApplicationId.toString(), + fakeConfiguration.copy( + appStartupActivityPredicate = mockAppStartupActivityPredicate, + viewTrackingStrategy = null + ), + lateCrashReporterFactory = { mockLateCrashReporter } + ) + GlobalRumMonitor.clear() + GlobalRumMonitor.registerIfAbsent(mockRumMonitor, mockSdkCore) + + // When + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.pendingPreLaunchAction?.invoke() + + // Then — no NPE, events still sent + verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) + verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) + } + + // endregion + companion object { val appContext = ApplicationContextTestConfiguration(Application::class.java) private val mainLooper = MainLooperTestConfiguration() From 9bbeb22814a58a63c3575f7bb175b495346e171d Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:25:41 +0200 Subject: [PATCH 04/13] RUM-16664: Add Handle to RumFirstDrawTimeReporter to fix memory leak on activity destroy --- .../rum/startup/RumFirstDrawTimeReporter.kt | 21 +- .../startup/RumFirstDrawTimeReporterImpl.kt | 191 ++++++++---------- .../RumFirstDrawTimeReporterImplTest.kt | 54 ++--- 3 files changed, 118 insertions(+), 148 deletions(-) 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 index c985574505..41e7f33cd8 100644 --- 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 @@ -16,6 +16,22 @@ import android.app.Activity */ interface RumFirstDrawTimeReporter { + /** + * An opaque handle returned by [subscribeToFirstFrameDrawn]. + * + * Call [unsubscribe] to cancel the subscription and release all internal listener + * registrations, breaking any retain cycles before the Activity is garbage-collected. + */ + interface Handle { + /** + * Cancels this subscription. Idempotent — safe to call multiple times. + * + * After this call the [Callback] will never fire, and all internal listener + * registrations (WindowCallback, OnAttachStateChange, OnDraw) are removed. + */ + fun unsubscribe() + } + /** * Callback invoked when the first frame of an activity's window has been drawn. */ @@ -32,9 +48,12 @@ interface RumFirstDrawTimeReporter { * Subscribes to receive a callback when the first frame of [activity]'s window is drawn. * * The [callback] is guaranteed to be invoked at most once per subscription. + * Callers must store the returned [Handle] and call [Handle.unsubscribe] when the + * Activity is destroyed to prevent memory leaks. * * @param activity The activity whose first frame draw should be observed. * @param callback The callback to invoke when the first frame is drawn. + * @return A [Handle] that can cancel this subscription. */ - fun subscribeToFirstFrameDrawn(activity: Activity, callback: Callback) + fun subscribeToFirstFrameDrawn(activity: Activity, callback: Callback): Handle } diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt index 97439584a3..18ab797cbe 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -7,14 +7,11 @@ package com.datadog.android.rum.startup import android.app.Activity -import android.app.Application -import android.os.Bundle import android.os.Handler import android.os.Message import android.util.Log import android.view.View import android.view.ViewTreeObserver -import java.lang.ref.WeakReference /** * Default implementation of [RumFirstDrawTimeReporter]. @@ -37,130 +34,102 @@ class RumFirstDrawTimeReporterImpl( override fun subscribeToFirstFrameDrawn( activity: Activity, callback: RumFirstDrawTimeReporter.Callback - ) { - val window = activity.window - val decorView = window.peekDecorView() - - if (decorView == null) { - val listener = object : WindowCallbackListener { - override fun onContentChanged() { - windowCallbacksRegistry.removeListener(activity, this) - onDecorViewReady(activity, callback) - } - } - windowCallbacksRegistry.addListener(activity, listener) - registerDestroyCleanup(activity, listener) - } else { - onDecorViewReady(activity, callback) - } + ): RumFirstDrawTimeReporter.Handle { + val handle = HandleImpl(activity, callback) + handle.init() + return handle } - private fun onDecorViewReady( - activity: Activity, - callback: RumFirstDrawTimeReporter.Callback - ) { - val window = activity.window - val decorView = window.decorView - - if (decorView.isAttachedToWindow) { - registerOnDrawListener( - decorView = decorView, - callback = callback - ) - } else { - val attachListener = object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - registerOnDrawListener( - decorView = decorView, - callback = callback - ) - decorView.removeOnAttachStateChangeListener(this) - } + // By implementing all listener interfaces, HandleImpl registers *itself* everywhere. + // unsubscribe() then simply removes `this` from each registration point — no external + // lifecycle callback needed. This matches the pattern in Aleksandr's PR #3349. + private inner class HandleImpl( + private val activity: Activity, + private val callback: RumFirstDrawTimeReporter.Callback + ) : RumFirstDrawTimeReporter.Handle, + WindowCallbackListener, + View.OnAttachStateChangeListener, + ViewTreeObserver.OnDrawListener { + + @Volatile + private var isCancelled = false + private var onDrawInvoked = false + + fun init() { + val decorView = activity.window.peekDecorView() + if (decorView == null) { + windowCallbacksRegistry.addListener(activity, this) + } else { + onDecorViewReady(decorView) + } + } - override fun onViewDetachedFromWindow(v: View) { - } + override fun unsubscribe() { + if (isCancelled) return + isCancelled = true + windowCallbacksRegistry.removeListener(activity, this) + val decorView = activity.window.peekDecorView() + if (decorView != null) { + decorView.removeOnAttachStateChangeListener(this) + removeOnDrawListener(decorView) } - decorView.addOnAttachStateChangeListener(attachListener) } - } - private fun registerOnDrawListener( - decorView: View, - callback: RumFirstDrawTimeReporter.Callback - ) { - val listener = object : ViewTreeObserver.OnDrawListener { - private var invoked = false + override fun onContentChanged() { + windowCallbacksRegistry.removeListener(activity, this) + onDecorViewReady(activity.window.decorView) + } - override fun onDraw() { - if (invoked) { - return - } - invoked = true - onFirstDraw(callback) - - handler.post { - if (decorView.viewTreeObserver.isAlive) { - try { - decorView.viewTreeObserver.removeOnDrawListener(this) - } catch (e: IllegalStateException) { - warnLogger("RumTTIDReporterImpl unable to remove onDrawListener from viewTreeObserver", e) - } - } - } - } + override fun onViewAttachedToWindow(v: View) { + registerOnDrawListener(activity.window.decorView) + activity.window.decorView.removeOnAttachStateChangeListener(this) + } + + override fun onViewDetachedFromWindow(v: View) {} + + override fun onDraw() { + if (onDrawInvoked) return + onDrawInvoked = true + + val nowNs = timeProviderNs() + handler.sendMessageAtFrontOfQueue( + Message.obtain(handler, Runnable { + callback.onFirstFrameDrawn(nowNs) + }).apply { isAsynchronous = true } + ) + + val currentDecorView = activity.window.decorView + handler.post { removeOnDrawListener(currentDecorView) } } - if (decorView.viewTreeObserver.isAlive) { - try { - decorView.viewTreeObserver.addOnDrawListener(listener) - } catch (e: IllegalStateException) { - warnLogger("RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver", e) + private fun onDecorViewReady(decorView: View) { + if (isCancelled) return + if (decorView.isAttachedToWindow) { + registerOnDrawListener(decorView) + } else { + decorView.addOnAttachStateChangeListener(this) } } - } - // WindowCallbacksRegistryImpl stores Activity→WindowCallback in a WeakHashMap, but - // WindowCallback holds a strong reference back to the Activity via FixedWindowCallback.delegate - // (the Activity is its own Window.Callback). This circular reference prevents GC. - // When an Activity is destroyed before setContentView is called (e.g. an interstitial that - // just calls startActivity + finish), the listener never fires via onContentChanged, so the - // entry is never cleaned up. Registering a lifecycle callback to remove it on destroy breaks - // the strong reference and lets GC collect the Activity. - private fun registerDestroyCleanup(activity: Activity, listener: WindowCallbackListener) { - val application = activity.application ?: return - val weakActivity = WeakReference(activity) - // WeakReference so this callback (held by Application) does not itself keep the - // listener (and through it, the Activity) alive. - val weakListener = WeakReference(listener) - application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { - override fun onActivityCreated(a: Activity, b: Bundle?) {} - override fun onActivityStarted(a: Activity) {} - override fun onActivityResumed(a: Activity) {} - override fun onActivityPaused(a: Activity) {} - override fun onActivityStopped(a: Activity) {} - override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {} - override fun onActivityDestroyed(destroyed: Activity) { - if (destroyed === weakActivity.get()) { - weakListener.get()?.let { l -> - windowCallbacksRegistry.removeListener(destroyed, l) - } - application.unregisterActivityLifecycleCallbacks(this) + private fun registerOnDrawListener(decorView: View) { + if (isCancelled) return + if (decorView.viewTreeObserver.isAlive) { + try { + decorView.viewTreeObserver.addOnDrawListener(this) + } catch (e: IllegalStateException) { + warnLogger("RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver", e) } } - }) - } - - private fun onFirstDraw(callback: RumFirstDrawTimeReporter.Callback) { - val nowNs = timeProviderNs() - - val block = Runnable { - callback.onFirstFrameDrawn(nowNs) } - handler.sendMessageAtFrontOfQueue( - Message.obtain(handler, block).apply { - isAsynchronous = true + private fun removeOnDrawListener(decorView: View) { + if (decorView.viewTreeObserver.isAlive) { + try { + decorView.viewTreeObserver.removeOnDrawListener(this) + } catch (e: IllegalStateException) { + warnLogger("RumTTIDReporterImpl unable to remove onDrawListener from viewTreeObserver", e) + } } - ) + } } } diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt index fadbea11c7..94f1ccd350 100644 --- a/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt @@ -7,7 +7,6 @@ package com.datadog.android.rum.startup import android.app.Activity -import android.app.Application import android.os.Handler import android.os.Message import android.view.View @@ -25,6 +24,7 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.inOrder +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -50,9 +50,6 @@ class RumFirstDrawTimeReporterImplTest { @Mock private lateinit var activity: Activity - @Mock - private lateinit var mockApplication: Application - @Mock private lateinit var window: Window @@ -73,7 +70,6 @@ class RumFirstDrawTimeReporterImplTest { ) whenever(activity.window) doReturn window - whenever(activity.application) doReturn mockApplication whenever(window.peekDecorView()) doReturn null whenever(window.decorView) doReturn decorView @@ -177,6 +173,8 @@ class RumFirstDrawTimeReporterImplTest { argumentCaptor { verify(viewTreeObserver).addOnDrawListener(capture()) + // HandleImpl removes itself from the attach-state listener after registering the draw listener + verify(decorView).removeOnAttachStateChangeListener(any()) firstValue.onDraw() } @@ -278,55 +276,39 @@ class RumFirstDrawTimeReporterImplTest { } @Test - fun `M remove WindowCallbackListener W activity destroyed { before setContentView is called }`() { + fun `M remove WindowCallbackListener W handle unsubscribe { decorView null at subscribe time }`() { // Simulates InterstitialSplashActivity: decorView is null and setContentView is never called. - // The addListener stub must NOT fire onContentChanged immediately, because we want to test - // the path where the Activity is destroyed before any content is set. + // The addListener stub must NOT fire onContentChanged, so the listener stays in the registry + // until the caller explicitly cancels via Handle.unsubscribe(). var capturedListener: WindowCallbackListener? = null whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { capturedListener = it.getArgument(1) } - var registeredLifecycleCallback: Application.ActivityLifecycleCallbacks? = null - whenever(mockApplication.registerActivityLifecycleCallbacks(any())).doAnswer { - registeredLifecycleCallback = it.getArgument(0) - } - - // When - reporter.subscribeToFirstFrameDrawn(activity, callback) - checkNotNull(registeredLifecycleCallback) { - "Expected a lifecycle callback to be registered for destroy cleanup" - } + val handle = reporter.subscribeToFirstFrameDrawn(activity, callback) checkNotNull(capturedListener) { "Expected addListener to be called" } - // When the activity is destroyed before setContentView fires - registeredLifecycleCallback!!.onActivityDestroyed(activity) + // When + handle.unsubscribe() - // Then — the WindowCallbackListener is removed (breaks the strong reference in WeakHashMap value) + // Then — the WindowCallbackListener is removed, breaking the retain cycle verify(windowCallbackRegistry).removeListener(activity, capturedListener!!) - // And the lifecycle callback unregisters itself to avoid accumulation - verify(mockApplication).unregisterActivityLifecycleCallbacks(registeredLifecycleCallback) verifyNoInteractions(callback) } @Test - fun `M not remove WindowCallbackListener W activity destroyed { for a different activity }`() { + fun `M not remove listener twice W handle unsubscribe { called multiple times }`() { // Given — addListener does not fire onContentChanged - var registeredLifecycleCallback: Application.ActivityLifecycleCallbacks? = null whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { } - whenever(mockApplication.registerActivityLifecycleCallbacks(any())).doAnswer { - registeredLifecycleCallback = it.getArgument(0) - } - reporter.subscribeToFirstFrameDrawn(activity, callback) - checkNotNull(registeredLifecycleCallback) - val otherActivity = org.mockito.kotlin.mock() + val handle = reporter.subscribeToFirstFrameDrawn(activity, callback) - // When destroyed fires for a different activity - registeredLifecycleCallback!!.onActivityDestroyed(otherActivity) + // When unsubscribe is called twice + handle.unsubscribe() + handle.unsubscribe() - // Then — nothing is cleaned up for our activity - verify(windowCallbackRegistry).addListener(any(), any()) - org.mockito.kotlin.verifyNoMoreInteractions(windowCallbackRegistry) + // Then — removeListener is called exactly once (isCancelled guard) + verify(windowCallbackRegistry, times(1)).removeListener(any(), any()) + verifyNoInteractions(callback) } } From f288419463d217a0bb338bf24a919ee908e910ea Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:26:31 +0200 Subject: [PATCH 05/13] RUM-16664: Extract guardedProcessStartNs to remove process start time guard duplication --- .../time/DefaultAppStartTimeProvider.kt | 31 +++++++------------ dd-sdk-android-internal/api/apiSurface | 7 +++-- .../api/dd-sdk-android-internal.api | 12 +++++-- .../internal/utils/ProcessStartUtils.kt | 18 +++++++++++ .../android/rum/AppLaunchPreInitCollector.kt | 21 ++++++++++--- .../rum/AppLaunchPreInitCollectorTest.kt | 2 +- 6 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ProcessStartUtils.kt diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/time/DefaultAppStartTimeProvider.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/time/DefaultAppStartTimeProvider.kt index 9c7f8d28cd..c7695adf04 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/time/DefaultAppStartTimeProvider.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/time/DefaultAppStartTimeProvider.kt @@ -9,6 +9,7 @@ package com.datadog.android.core.internal.time import android.os.Process import com.datadog.android.internal.system.BuildSdkVersionProvider import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.internal.utils.guardedProcessStartNs import com.datadog.android.rum.DdRumContentProvider import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.seconds @@ -22,28 +23,18 @@ internal class DefaultAppStartTimeProvider( when { buildSdkVersionProvider.isAtLeastN -> { val timeProvider = timeProviderFactory() + // Uses the uptime clock (excludes device sleep time) to measure the gap between + // process start and now, then back-projects to an elapsed-time nanosecond value. + // See AppLaunchPreInitCollector.computeProcessStartNs() for the parallel impl that + // uses the elapsedRealtime clock instead (required when storing System.nanoTime()). val diffMs = timeProvider.getDeviceUptimeMillis() - Process.getStartUptimeMillis() - val computedAppStartTimeNs = + val computed = timeProvider.getDeviceElapsedTimeNanos() - TimeUnit.MILLISECONDS.toNanos(diffMs) - val contentProviderCreateTimeNs = DdRumContentProvider.createTimeNs - val isAfterContentProviderInit = computedAppStartTimeNs > contentProviderCreateTimeNs - val isTooFarBeforeContentProviderInit = - contentProviderCreateTimeNs - computedAppStartTimeNs > - PROCESS_START_TO_CP_START_DIFF_THRESHOLD_NS - - /** - * Guard against unexpected values from [Process.getStartUptimeMillis]. - * Two directions are checked and fall back to [DdRumContentProvider] creation time: - * - computedAppStartTimeNs > createTimeNs: app start appears to be after content provider init, - * which is impossible. - * - computedAppStartTimeNs is more than the threshold before createTimeNs: app start appears - * unreasonably far in the past. - */ - if (isAfterContentProviderInit || isTooFarBeforeContentProviderInit) { - contentProviderCreateTimeNs - } else { - computedAppStartTimeNs - } + guardedProcessStartNs( + computed = computed, + fallback = DdRumContentProvider.createTimeNs, + thresholdNs = PROCESS_START_TO_CP_START_DIFF_THRESHOLD_NS + ) } else -> DdRumContentProvider.createTimeNs } diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 557ef73f0e..2ff5a70e02 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -303,6 +303,7 @@ val NULL_MAP_VALUE: Object fun Int.toHexString(): String fun Long.toHexString(): String fun java.math.BigInteger.toHexString(): String +fun guardedProcessStartNs(Long, Long, Long): Long fun allowThreadDiskReads(() -> T): T fun allowThreadDiskWrites(() -> T): T fun StringBuilder.appendIfNotEmpty(String) @@ -343,14 +344,16 @@ class com.datadog.android.rum.DdRumContentProvider : android.content.ContentProv var processImportance: Int var createTimeNs: Long interface com.datadog.android.rum.startup.RumFirstDrawTimeReporter + interface Handle + fun unsubscribe() interface Callback fun onFirstFrameDrawn(Long) - fun subscribeToFirstFrameDrawn(android.app.Activity, Callback) + fun subscribeToFirstFrameDrawn(android.app.Activity, Callback): Handle 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) + override fun subscribeToFirstFrameDrawn(android.app.Activity, RumFirstDrawTimeReporter.Callback): RumFirstDrawTimeReporter.Handle interface com.datadog.android.rum.startup.WindowCallbackListener fun onContentChanged() interface com.datadog.android.rum.startup.WindowCallbacksRegistry 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 e677fef993..61b01ee9fd 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -756,6 +756,10 @@ public final class com/datadog/android/internal/utils/NumberExtKt { public static final fun toHexString (Ljava/math/BigInteger;)Ljava/lang/String; } +public final class com/datadog/android/internal/utils/ProcessStartUtilsKt { + public static final fun guardedProcessStartNs (JJJ)J +} + public final class com/datadog/android/internal/utils/StrictModeExtKt { public static final fun allowThreadDiskReads (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public static final fun allowThreadDiskWrites (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; @@ -838,17 +842,21 @@ public final class com/datadog/android/rum/DdRumContentProvider$Companion { } 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 fun subscribeToFirstFrameDrawn (Landroid/app/Activity;Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Callback;)Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Handle; } public abstract interface class com/datadog/android/rum/startup/RumFirstDrawTimeReporter$Callback { public abstract fun onFirstFrameDrawn (J)V } +public abstract interface class com/datadog/android/rum/startup/RumFirstDrawTimeReporter$Handle { + public abstract fun unsubscribe ()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 fun subscribeToFirstFrameDrawn (Landroid/app/Activity;Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Callback;)Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Handle; } public abstract interface class com/datadog/android/rum/startup/WindowCallbackListener { diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ProcessStartUtils.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ProcessStartUtils.kt new file mode 100644 index 0000000000..5030290382 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ProcessStartUtils.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.internal.utils + +/** + * Returns [computed] if it falls within a plausible range relative to [fallback]; otherwise + * returns [fallback]. + * + * Two failure modes are guarded: + * - computed > fallback → impossible (process started after the reference point) + * - fallback - computed > thresholdNs → unreasonably far in the past (OEM clock bug) + */ +fun guardedProcessStartNs(computed: Long, fallback: Long, thresholdNs: Long): Long = + if (computed > fallback || fallback - computed > thresholdNs) fallback else computed 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 index 018cb13afc..891cacc1a8 100644 --- 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 @@ -15,6 +15,7 @@ import android.os.Process import android.os.SystemClock import androidx.annotation.VisibleForTesting import com.datadog.android.internal.system.BuildSdkVersionProvider +import com.datadog.android.internal.utils.guardedProcessStartNs import com.datadog.android.rum.startup.RumFirstDrawTimeReporter import com.datadog.android.rum.startup.RumFirstDrawTimeReporterImpl import com.datadog.android.rum.startup.WindowCallbacksRegistryImpl @@ -143,6 +144,7 @@ object AppLaunchPreInitCollector { private val firstFrameCallbacks = CopyOnWriteArrayList<(Long) -> Unit>() private var registeredApplication: Application? = null + private var firstFrameHandle: RumFirstDrawTimeReporter.Handle? = null /** Private flag tracking whether any Activity has been destroyed (process is warm). */ private var _isFirstActivityForProcess: Boolean = true @@ -191,6 +193,10 @@ object AppLaunchPreInitCollector { override fun onActivityDestroyed(activity: Activity) { // Once an Activity is destroyed, subsequent activities are not "first for process" _isFirstActivityForProcess = false + // If the Activity is destroyed before its first frame fires, cancel the subscription + // to release the WindowCallback listener and prevent a memory leak. + firstFrameHandle?.unsubscribe() + firstFrameHandle = null } } @@ -216,10 +222,11 @@ object AppLaunchPreInitCollector { // Subscribe to first frame drawn — transitions CAPTURING -> COMPLETE val handler = handlerFactory() val reporter = firstDrawTimeReporterFactory(handler) - reporter.subscribeToFirstFrameDrawn( + firstFrameHandle = reporter.subscribeToFirstFrameDrawn( activity, object : RumFirstDrawTimeReporter.Callback { override fun onFirstFrameDrawn(timestampNs: Long) { + firstFrameHandle = null firstFrameNs = timestampNs _state.compareAndSet(State.CAPTURING, State.COMPLETE) @@ -247,14 +254,18 @@ object AppLaunchPreInitCollector { if (!buildSdkVersionProvider.isAtLeastN) { return DdRumContentProvider.createTimeNs } + // Uses the elapsedRealtime clock so the result aligns with System.nanoTime() values stored + // elsewhere in this collector. See DefaultAppStartTimeProvider for the parallel impl that + // uses the uptime clock instead (required when a TimeProvider is available). 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 + return guardedProcessStartNs( + computed = computed, + fallback = DdRumContentProvider.createTimeNs, + thresholdNs = PROCESS_START_TO_CP_START_DIFF_THRESHOLD_NS + ) } // endregion 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 index c61e7c6b47..d672fe6fc7 100644 --- 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 @@ -674,7 +674,7 @@ internal class AppLaunchPreInitCollectorTest { whenever(reporter.subscribeToFirstFrameDrawn(any(), any())).doAnswer { invocation -> val callback = invocation.getArgument(1) callback.onFirstFrameDrawn(System.nanoTime()) - Unit + mock() } } } From 5a6267f28d6ec7ed628c0cab05f6ec93e47fecf9 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:27:18 +0200 Subject: [PATCH 06/13] RUM-16664: Centralize startup scenario classification in RumStartupScenario.build() --- .../internal/startup/RumAppStartupDetector.kt | 7 +++ .../startup/RumAppStartupDetectorImpl.kt | 40 ++++------------- .../internal/startup/RumStartupScenario.kt | 44 +++++++++++++++++++ .../startup/RumAppStartupDetectorImplTest.kt | 7 +++ 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt index a93128bbb2..9a64d7186b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetector.kt @@ -20,6 +20,13 @@ internal interface RumAppStartupDetector { */ fun onAppStartupDetected(scenario: RumStartupScenario) fun onNextActivityCreated(pendingScenario: RumStartupScenario, activity: Activity) + + /** + * Called when an Activity is destroyed. Listeners that hold per-activity resources + * (e.g. a [com.datadog.android.rum.startup.RumFirstDrawTimeReporter.Handle]) should + * release them here to avoid memory leaks. + */ + fun onActivityDestroyed(activity: Activity) = Unit } fun destroy() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt index 5f11442b30..261c853996 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt @@ -16,7 +16,6 @@ import com.datadog.android.rum.startup.AppStartupActivityPredicate import java.lang.ref.WeakReference import java.util.Collections import java.util.WeakHashMap -import kotlin.time.Duration.Companion.seconds internal class RumAppStartupDetectorImpl( private val application: Application, @@ -54,6 +53,7 @@ internal class RumAppStartupDetectorImpl( override fun onActivityDestroyed(activity: Activity) { numberOfActivities-- trackedActivities.remove(activity) + listener.onActivityDestroyed(activity) if (numberOfActivities == 0) { isChangingConfigurations = activity.isChangingConfigurations @@ -102,34 +102,13 @@ internal class RumAppStartupDetectorImpl( if (isFirstTrackedActivityWithNoPendingStartup) { val processStartTime = appStartupTime() - - val gapNs = now.nanoTime - processStartTime.nanoTime - val hasSavedInstanceStateBundle = savedInstanceState != null - val weakActivity = WeakReference(activity) - - val scenario = if (isFirstActivityForProcess) { - if (gapNs > START_GAP_THRESHOLD_NS) { - RumStartupScenario.WarmFirstActivity( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = weakActivity, - appStartActivityOnCreateGapNs = gapNs, - initialTime = now - ) - } else { - RumStartupScenario.Cold( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = weakActivity, - appStartActivityOnCreateGapNs = gapNs, - initialTime = processStartTime - ) - } - } else { - RumStartupScenario.WarmAfterActivityDestroyed( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = weakActivity, - initialTime = now - ) - } + val scenario = RumStartupScenario.build( + isFirstActivityForProcess = isFirstActivityForProcess, + hasSavedInstanceStateBundle = savedInstanceState != null, + activity = WeakReference(activity), + processStartTime = processStartTime, + activityOnCreateTime = now + ) pendingScenario = scenario listener.onAppStartupDetected(scenario) @@ -157,7 +136,4 @@ internal class RumAppStartupDetectorImpl( application.unregisterActivityLifecycleCallbacks(this) } - companion object { - private val START_GAP_THRESHOLD_NS = 10.seconds.inWholeNanoseconds - } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumStartupScenario.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumStartupScenario.kt index 0ea1c62efc..e9cc0b9615 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumStartupScenario.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumStartupScenario.kt @@ -9,6 +9,7 @@ package com.datadog.android.rum.internal.startup import android.app.Activity import com.datadog.android.rum.internal.domain.Time import java.lang.ref.WeakReference +import kotlin.time.Duration.Companion.seconds internal sealed interface RumStartupScenario { val initialTime: Time @@ -34,6 +35,49 @@ internal sealed interface RumStartupScenario { override val activity: WeakReference, override val initialTime: Time ) : RumStartupScenario + + companion object { + internal val START_GAP_THRESHOLD_NS = 10.seconds.inWholeNanoseconds + + /** + * Builds the correct [RumStartupScenario] subtype from the raw timing data captured at + * Activity creation time. This is the single source of truth for the Cold / WarmFirstActivity + * / WarmAfterActivityDestroyed classification, shared by [RumAppStartupDetectorImpl] and + * [com.datadog.android.rum.internal.RumFeature]. + */ + fun build( + isFirstActivityForProcess: Boolean, + hasSavedInstanceStateBundle: Boolean, + activity: WeakReference, + processStartTime: Time, + activityOnCreateTime: Time + ): RumStartupScenario { + return if (isFirstActivityForProcess) { + val gapNs = activityOnCreateTime.nanoTime - processStartTime.nanoTime + if (gapNs > START_GAP_THRESHOLD_NS) { + WarmFirstActivity( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity, + appStartActivityOnCreateGapNs = gapNs, + initialTime = activityOnCreateTime + ) + } else { + Cold( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity, + appStartActivityOnCreateGapNs = gapNs, + initialTime = processStartTime + ) + } + } else { + WarmAfterActivityDestroyed( + hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, + activity = activity, + initialTime = activityOnCreateTime + ) + } + } + } } internal val RumStartupScenario.name: String get() = when (this) { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt index 9996f8681f..23c7b34b99 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImplTest.kt @@ -195,6 +195,7 @@ internal class RumAppStartupDetectorImplTest { ) ) } + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -236,6 +237,7 @@ internal class RumAppStartupDetectorImplTest { appStartActivityOnCreateGapNs = 3.seconds.inWholeNanoseconds ) ) + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -342,6 +344,7 @@ internal class RumAppStartupDetectorImplTest { ) } + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -424,6 +427,8 @@ internal class RumAppStartupDetectorImplTest { ) } + verify(listener).onActivityDestroyed(activity2) + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -478,6 +483,7 @@ internal class RumAppStartupDetectorImplTest { ) ) + verify(listener).onActivityDestroyed(interstitialActivity) verifyNoMoreInteractions(listener) } @@ -670,6 +676,7 @@ internal class RumAppStartupDetectorImplTest { ) ) + verify(listener).onActivityDestroyed(activity1) verifyNoMoreInteractions(listener) } From f1dfbd9d22e480d7a926b178d7387adc0a707d25 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:28:07 +0200 Subject: [PATCH 07/13] RUM-16664: Replace viewTrackingStrategy type checks with ReplayableViewTrackingStrategy --- .../ReplayableViewTrackingStrategy.kt | 27 +++++++++++++++++++ .../tracking/ActivityViewTrackingStrategy.kt | 12 ++++++++- .../NavigationViewTrackingStrategy.kt | 12 ++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/tracking/ReplayableViewTrackingStrategy.kt diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/tracking/ReplayableViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/tracking/ReplayableViewTrackingStrategy.kt new file mode 100644 index 0000000000..01e81fe2ba --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/tracking/ReplayableViewTrackingStrategy.kt @@ -0,0 +1,27 @@ +/* + * 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.tracking + +import android.app.Activity + +/** + * Opt-in interface for [com.datadog.android.rum.tracking.ViewTrackingStrategy] implementations + * that can handle the late-init scenario: the RUM SDK initialised after the first Activity had + * already started (e.g. a cross-platform bridge delay), so the normal lifecycle callbacks were + * never delivered for that Activity. + * + * Implementing this interface lets [com.datadog.android.rum.internal.RumFeature] replay the + * relevant lifecycle event without coupling to concrete strategy types. + */ +internal interface ReplayableViewTrackingStrategy { + /** + * Called when the SDK initialises after [activity] has already started/resumed and no RUM + * view has been opened for it yet. Implementations should start a view as if the normal + * lifecycle callback had fired. + */ + fun onLateActivityReady(activity: Activity) +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt index c22d3abb71..e5a6dc7c95 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt @@ -16,6 +16,7 @@ import com.datadog.android.internal.attributes.ViewScopeInstrumentationType import com.datadog.android.internal.attributes.enrichWithConstantAttribute import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumMonitor +import com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy import com.datadog.android.rum.internal.utils.resolveViewName import com.datadog.android.rum.internal.utils.runIfValid import java.util.concurrent.ScheduledExecutorService @@ -37,7 +38,8 @@ constructor( internal val componentPredicate: ComponentPredicate = AcceptAllActivities() ) : ActivityLifecycleTrackingStrategy(), - ViewTrackingStrategy { + ViewTrackingStrategy, + ReplayableViewTrackingStrategy { private val executor: ScheduledExecutorService by lazy { sdkCore.createScheduledExecutorService( @@ -83,6 +85,14 @@ constructor( // endregion + // region ReplayableViewTrackingStrategy + + override fun onLateActivityReady(activity: Activity) { + onActivityResumed(activity) + } + + // endregion + // region Object override fun equals(other: Any?): Boolean { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt index 4a29143c37..012383cbe4 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt @@ -23,6 +23,7 @@ import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.NoOpRumMonitor import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.tracking.AndroidXFragmentLifecycleCallbacks +import com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy import com.datadog.android.rum.internal.utils.resolveViewName import com.datadog.android.rum.internal.utils.runIfValid import java.util.WeakHashMap @@ -42,7 +43,8 @@ class NavigationViewTrackingStrategy( ) : ActivityLifecycleTrackingStrategy(), ViewTrackingStrategy, - NavController.OnDestinationChangedListener { + NavController.OnDestinationChangedListener, + ReplayableViewTrackingStrategy { private var startedActivity: Activity? = null @@ -87,6 +89,14 @@ class NavigationViewTrackingStrategy( // endregion + // region ReplayableViewTrackingStrategy + + override fun onLateActivityReady(activity: Activity) { + onActivityStarted(activity) + } + + // endregion + // region OnDestinationChangedListener override fun onDestinationChanged( From c3e59cef3bff8976406a70a7ae71d33977153847 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:28:54 +0200 Subject: [PATCH 08/13] RUM-16664: Update RumFeature to use new shared factories and interfaces --- features/dd-sdk-android-rum/api/apiSurface | 6 +- .../api/dd-sdk-android-rum.api | 6 +- .../android/rum/internal/RumFeature.kt | 103 +++++++----------- .../internal/RumFeaturePreInitStartupTest.kt | 4 +- 4 files changed, 50 insertions(+), 69 deletions(-) diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index d12ed04b88..44c8e2358a 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -343,10 +343,11 @@ abstract class com.datadog.android.rum.tracking.ActivityLifecycleTrackingStrateg override fun onActivityCreated(android.app.Activity, android.os.Bundle?) override fun onActivityResumed(android.app.Activity) protected fun withSdkCore((com.datadog.android.api.feature.FeatureSdkCore) -> T): T? -class com.datadog.android.rum.tracking.ActivityViewTrackingStrategy : ActivityLifecycleTrackingStrategy, ViewTrackingStrategy +class com.datadog.android.rum.tracking.ActivityViewTrackingStrategy : ActivityLifecycleTrackingStrategy, ViewTrackingStrategy, com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy constructor(Boolean, ComponentPredicate = AcceptAllActivities()) override fun onActivityResumed(android.app.Activity) override fun onActivityStopped(android.app.Activity) + override fun onLateActivityReady(android.app.Activity) override fun equals(Any?): Boolean override fun hashCode(): Int fun android.os.Bundle?.convertToRumViewAttributes(): Map @@ -367,11 +368,12 @@ class com.datadog.android.rum.tracking.MixedViewTrackingStrategy : ActivityLifec override fun unregister(android.content.Context?) override fun equals(Any?): Boolean override fun hashCode(): Int -class com.datadog.android.rum.tracking.NavigationViewTrackingStrategy : ActivityLifecycleTrackingStrategy, ViewTrackingStrategy, androidx.navigation.NavController.OnDestinationChangedListener +class com.datadog.android.rum.tracking.NavigationViewTrackingStrategy : ActivityLifecycleTrackingStrategy, ViewTrackingStrategy, androidx.navigation.NavController.OnDestinationChangedListener, com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy constructor(Int, Boolean, ComponentPredicate = AcceptAllNavDestinations()) override fun onActivityStarted(android.app.Activity) override fun onActivityStopped(android.app.Activity) override fun onActivityPaused(android.app.Activity) + override fun onLateActivityReady(android.app.Activity) override fun onDestinationChanged(androidx.navigation.NavController, androidx.navigation.NavDestination, android.os.Bundle?) fun startTracking() fun stopTracking() diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index 169e0d059b..6dab447632 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -8293,7 +8293,7 @@ public abstract class com/datadog/android/rum/tracking/ActivityLifecycleTracking protected final fun withSdkCore (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; } -public final class com/datadog/android/rum/tracking/ActivityViewTrackingStrategy : com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy, com/datadog/android/rum/tracking/ViewTrackingStrategy { +public final class com/datadog/android/rum/tracking/ActivityViewTrackingStrategy : com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy, com/datadog/android/rum/internal/tracking/ReplayableViewTrackingStrategy, com/datadog/android/rum/tracking/ViewTrackingStrategy { public fun (Z)V public fun (ZLcom/datadog/android/rum/tracking/ComponentPredicate;)V public synthetic fun (ZLcom/datadog/android/rum/tracking/ComponentPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -8301,6 +8301,7 @@ public final class com/datadog/android/rum/tracking/ActivityViewTrackingStrategy public fun hashCode ()I public fun onActivityResumed (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V + public fun onLateActivityReady (Landroid/app/Activity;)V } public final class com/datadog/android/rum/tracking/BundleExtKt { @@ -8339,13 +8340,14 @@ public final class com/datadog/android/rum/tracking/MixedViewTrackingStrategy : public fun unregister (Landroid/content/Context;)V } -public final class com/datadog/android/rum/tracking/NavigationViewTrackingStrategy : com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy, androidx/navigation/NavController$OnDestinationChangedListener, com/datadog/android/rum/tracking/ViewTrackingStrategy { +public final class com/datadog/android/rum/tracking/NavigationViewTrackingStrategy : com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy, androidx/navigation/NavController$OnDestinationChangedListener, com/datadog/android/rum/internal/tracking/ReplayableViewTrackingStrategy, com/datadog/android/rum/tracking/ViewTrackingStrategy { public fun (IZLcom/datadog/android/rum/tracking/ComponentPredicate;)V public synthetic fun (IZLcom/datadog/android/rum/tracking/ComponentPredicate;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun onActivityPaused (Landroid/app/Activity;)V public fun onActivityStarted (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V public fun onDestinationChanged (Landroidx/navigation/NavController;Landroidx/navigation/NavDestination;Landroid/os/Bundle;)V + public fun onLateActivityReady (Landroid/app/Activity;)V public final fun startTracking ()V public final fun stopTracking ()V } 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 0ac1e6901f..079b78e55b 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 @@ -94,6 +94,7 @@ import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.android.rum.internal.thread.NoOpScheduledExecutorService import com.datadog.android.rum.internal.tracking.JetpackViewAttributesProvider +import com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.internal.tracking.NoOpUserActionTrackingStrategy import com.datadog.android.rum.internal.tracking.UserActionTrackingStrategy @@ -137,12 +138,12 @@ 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 java.util.WeakHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration.Companion.seconds /** * RUM feature class, which needs to be registered with Datadog SDK instance. @@ -861,20 +862,15 @@ internal class RumFeature( // guaranteeing the real monitor is available regardless of which thread Rum.enable() // is called on (main thread for native Android, background thread for RN/Flutter). // - // Additionally, the Activity has already completed its full lifecycle before the - // SDK initialized (e.g. a cross-platform bridge delay). The view tracking strategy - // missed onActivityStarted/onActivityResumed, so no RUM view has been started yet. - // We replay the relevant lifecycle callback here so startView is queued before - // AppStart/TTID. - val capturedStrategy = viewTrackingStrategy + // Cross-platform scenario (RN/Flutter): the Activity drew its first frame before the + // SDK initialized, so the view tracking strategy never received onActivityStarted/ + // onActivityResumed for it — no RUM view is open. We call onLateActivityReady() so the + // strategy can open the view before AppStart/TTID are sent. + val capturedStrategy = viewTrackingStrategy as? ReplayableViewTrackingStrategy pendingPreLaunchAction = action@{ val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return@action scenario.activity.get()?.let { activity -> - when (capturedStrategy) { - is NavigationViewTrackingStrategy -> capturedStrategy.onActivityStarted(activity) - is ActivityViewTrackingStrategy -> capturedStrategy.onActivityResumed(activity) - else -> Unit - } + capturedStrategy?.onLateActivityReady(activity) } rumMonitor.sendAppStartEvent(scenario) rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) @@ -900,6 +896,10 @@ internal class RumFeature( } ) + @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty + private val firstFrameHandles = + WeakHashMap() + override fun onAppStartupDetected(scenario: RumStartupScenario) { val activity = scenario.activity.get() ?: return val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return @@ -916,6 +916,10 @@ internal class RumFeature( subscribeToFirstFrameDrawn(pendingScenario, activity, rumMonitor, wasForwarded = true) } + override fun onActivityDestroyed(activity: Activity) { + firstFrameHandles.remove(activity)?.unsubscribe() + } + private fun subscribeToFirstFrameDrawn( scenario: RumStartupScenario, activity: Activity, @@ -924,6 +928,7 @@ internal class RumFeature( ) { val callback = object : RumFirstDrawTimeReporter.Callback { override fun onFirstFrameDrawn(timestampNs: Long) { + firstFrameHandles.remove(activity) // Another activity may have already reported TTID val pending = rumAppStartupDetector?.getPendingScenario() if (pending !== scenario) return @@ -940,7 +945,7 @@ internal class RumFeature( } } - rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn( + firstFrameHandles[activity] = rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn( activity = activity, callback = callback ) @@ -950,7 +955,7 @@ internal class RumFeature( ) } - @Suppress("ReturnCount", "StringLiteralDuplication", "UnsafeCallOnNullableType", "LongMethod") + @Suppress("ReturnCount", "UnsafeCallOnNullableType") private fun constructScenario(collector: AppLaunchPreInitCollector): RumStartupScenario? { val activity = collector.activity?.get() if (activity == null) { @@ -963,59 +968,32 @@ internal class RumFeature( } if (!configuration.appStartupActivityPredicate.shouldTrackStartup(activity)) { - return null - } - - val processStartTime = Time.fromNanoTime(collector.processStartNs, sdkCore.timeProvider) - val activityOnCreateTime = Time.fromNanoTime(collector.activityOnCreateNs, sdkCore.timeProvider) - val weakActivity = collector.activity!! - val hasSavedInstanceStateBundle = collector.hasSavedInstanceState - - return if (collector.isFirstActivityForProcess) { - val gapNs = activityOnCreateTime.nanoTime - processStartTime.nanoTime - if (gapNs > START_GAP_THRESHOLD_NS) { - sdkCore.internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { - "TTID: WarmFirstActivity" + - " gapMs=${gapNs / NS_PER_MS} hasSavedInstanceState=$hasSavedInstanceStateBundle" - } - ) - RumStartupScenario.WarmFirstActivity( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = weakActivity, - appStartActivityOnCreateGapNs = gapNs, - initialTime = activityOnCreateTime - ) - } else { - sdkCore.internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { - "TTID: Cold" + - " gapMs=${gapNs / NS_PER_MS} hasSavedInstanceState=$hasSavedInstanceStateBundle" - } - ) - RumStartupScenario.Cold( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = weakActivity, - appStartActivityOnCreateGapNs = gapNs, - initialTime = processStartTime - ) - } - } else { sdkCore.internalLogger.log( InternalLogger.Level.DEBUG, InternalLogger.Target.MAINTAINER, - { "TTID: WarmAfterActivityDestroyed hasSavedInstanceState=$hasSavedInstanceStateBundle" } - ) - RumStartupScenario.WarmAfterActivityDestroyed( - hasSavedInstanceStateBundle = hasSavedInstanceStateBundle, - activity = weakActivity, - initialTime = activityOnCreateTime + { + "TTID: pre-launch activity excluded by predicate — " + + "falling back to default detector for subsequent activities" + } ) + return null } + + val processStartTime = Time.fromNanoTime(collector.processStartNs, sdkCore.timeProvider) + val activityOnCreateTime = Time.fromNanoTime(collector.activityOnCreateNs, sdkCore.timeProvider) + val scenario = RumStartupScenario.build( + isFirstActivityForProcess = collector.isFirstActivityForProcess, + hasSavedInstanceStateBundle = collector.hasSavedInstanceState, + activity = collector.activity!!, + processStartTime = processStartTime, + activityOnCreateTime = activityOnCreateTime + ) + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: pre-launch scenario=${scenario::class.simpleName}" } + ) + return scenario } // endregion @@ -1058,7 +1036,6 @@ internal class RumFeature( internal companion object { - private val START_GAP_THRESHOLD_NS = 10.seconds.inWholeNanoseconds private const val NS_PER_MS = 1_000_000L internal const val NDK_CRASH_BUS_MESSAGE_TYPE = "ndk_crash" 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 index dec649bc00..b17dab821f 100644 --- 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 @@ -373,7 +373,7 @@ internal class RumFeaturePreInitStartupTest { // Then — view tracking strategy is primed before startup events so the RUM executor // has a StartView in the queue before AppStart/TTID - verify(mockNavStrategy).onActivityStarted(mockActivity) + verify(mockNavStrategy).onLateActivityReady(mockActivity) verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) } @@ -408,7 +408,7 @@ internal class RumFeaturePreInitStartupTest { testedFeature.pendingPreLaunchAction?.invoke() // Then - verify(mockActivityStrategy).onActivityResumed(mockActivity) + verify(mockActivityStrategy).onLateActivityReady(mockActivity) verify(mockRumMonitor, times(1)).sendAppStartEvent(any()) verify(mockRumMonitor, times(1)).sendTTIDEvent(any()) } From 732b1f4dc5e720d89276ecad7a47996f9a7572dd Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:29:41 +0200 Subject: [PATCH 09/13] RUM-16664: Remove obsolete detektCustomConfig from prelaunch build file --- features/dd-sdk-android-rum-prelaunch/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/features/dd-sdk-android-rum-prelaunch/build.gradle.kts b/features/dd-sdk-android-rum-prelaunch/build.gradle.kts index fc757c4373..e78a87fb59 100644 --- a/features/dd-sdk-android-rum-prelaunch/build.gradle.kts +++ b/features/dd-sdk-android-rum-prelaunch/build.gradle.kts @@ -6,7 +6,6 @@ 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 @@ -75,4 +74,3 @@ publishingConfig( "The RUM pre-launch module that captures app launch timing data before " + "the Datadog SDK is initialized." ) -detektCustomConfig() From 1f27c85088be59f4a115217b9ea6e694f00a001d Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:46:20 +0200 Subject: [PATCH 10/13] RUM-16664: Fix ktlint formatting in RumFirstDrawTimeReporterImpl --- .../android/rum/startup/RumFirstDrawTimeReporterImpl.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt index 18ab797cbe..78fa1ee250 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -93,9 +93,10 @@ class RumFirstDrawTimeReporterImpl( val nowNs = timeProviderNs() handler.sendMessageAtFrontOfQueue( - Message.obtain(handler, Runnable { - callback.onFirstFrameDrawn(nowNs) - }).apply { isAsynchronous = true } + Message.obtain( + handler, + Runnable { callback.onFirstFrameDrawn(nowNs) } + ).apply { isAsynchronous = true } ) val currentDecorView = activity.window.decorView From 859ab0dfc1aa8ca3360ca7700ec73e7fc6cf5017 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 12:55:49 +0200 Subject: [PATCH 11/13] RUM-16664: Fix detekt ThreadSafety and UnusedImports violations --- .../main/kotlin/com/datadog/android/rum/internal/RumFeature.kt | 1 - .../datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt | 1 + .../android/rum/tracking/NavigationViewTrackingStrategy.kt | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) 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 079b78e55b..2e02157903 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 @@ -129,7 +129,6 @@ import com.datadog.android.rum.startup.WindowCallbacksRegistryImpl import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.ActivityViewTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate -import com.datadog.android.rum.tracking.NavigationViewTrackingStrategy import com.datadog.android.rum.tracking.NoOpActionTrackingStrategy import com.datadog.android.rum.tracking.NoOpTrackingStrategy import com.datadog.android.rum.tracking.NoOpViewTrackingStrategy diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt index e5a6dc7c95..54b6bdf08e 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt @@ -87,6 +87,7 @@ constructor( // region ReplayableViewTrackingStrategy + @MainThread override fun onLateActivityReady(activity: Activity) { onActivityResumed(activity) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt index 012383cbe4..9744c7d453 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt @@ -91,6 +91,7 @@ class NavigationViewTrackingStrategy( // region ReplayableViewTrackingStrategy + @MainThread override fun onLateActivityReady(activity: Activity) { onActivityStarted(activity) } From 8779ea6aed1841c5695b244af791dc41e4054f6b Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 14:42:13 +0200 Subject: [PATCH 12/13] Fix ktlint issues --- .../main/kotlin/com/datadog/android/rum/internal/RumFeature.kt | 2 +- .../android/rum/internal/startup/RumAppStartupDetectorImpl.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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 2e02157903..c8c92bef36 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 @@ -94,9 +94,9 @@ import com.datadog.android.rum.internal.startup.RumStartupScenario import com.datadog.android.rum.internal.startup.RumTTIDInfo import com.datadog.android.rum.internal.thread.NoOpScheduledExecutorService import com.datadog.android.rum.internal.tracking.JetpackViewAttributesProvider -import com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.internal.tracking.NoOpUserActionTrackingStrategy +import com.datadog.android.rum.internal.tracking.ReplayableViewTrackingStrategy import com.datadog.android.rum.internal.tracking.UserActionTrackingStrategy import com.datadog.android.rum.internal.vitals.AggregatingVitalMonitor import com.datadog.android.rum.internal.vitals.CPUVitalReader diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt index 261c853996..7e627b420c 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/startup/RumAppStartupDetectorImpl.kt @@ -135,5 +135,4 @@ internal class RumAppStartupDetectorImpl( pendingScenario = null application.unregisterActivityLifecycleCallbacks(this) } - } From 01391aa86fc2f3082fee0701769274204adf066d Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 31 Jul 2026 14:57:17 +0200 Subject: [PATCH 13/13] Fix isCancelledguard in onDraw and missing onLateActivityReady in CAPTURING path --- .../android/rum/startup/RumFirstDrawTimeReporterImpl.kt | 6 ++++-- .../kotlin/com/datadog/android/rum/internal/RumFeature.kt | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt index 78fa1ee250..2196f3d221 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -88,14 +88,16 @@ class RumFirstDrawTimeReporterImpl( override fun onViewDetachedFromWindow(v: View) {} override fun onDraw() { - if (onDrawInvoked) return + if (onDrawInvoked || isCancelled) return onDrawInvoked = true val nowNs = timeProviderNs() handler.sendMessageAtFrontOfQueue( Message.obtain( handler, - Runnable { callback.onFirstFrameDrawn(nowNs) } + Runnable { + if (!isCancelled) callback.onFirstFrameDrawn(nowNs) + } ).apply { isAsynchronous = true } ) 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 c8c92bef36..f9bf6d7d1d 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 @@ -809,6 +809,7 @@ internal class RumFeature( // GlobalRumMonitor is not yet registered during onInitialize — the real monitor is // registered in Rum.enable() after onInitialize returns. Re-get the monitor inside // the first-frame callback, which fires after Rum.enable() has completed. + val capturedStrategy = viewTrackingStrategy as? ReplayableViewTrackingStrategy collector.addFirstFrameCallback { firstFrameNs -> val durationNs = firstFrameNs - scenario.initialTime.nanoTime sdkCore.internalLogger.log( @@ -818,6 +819,9 @@ internal class RumFeature( ) val rumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return@addFirstFrameCallback + scenario.activity.get()?.let { activity -> + capturedStrategy?.onLateActivityReady(activity) + } rumMonitor.sendAppStartEvent(scenario) rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) }