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 416360cf42..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) @@ -315,6 +316,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 +343,24 @@ 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 Handle + fun unsubscribe() + interface Callback + fun onFirstFrameDrawn(Long) + 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): RumFirstDrawTimeReporter.Handle +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..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; @@ -789,6 +793,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 +841,39 @@ 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;)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;)Lcom/datadog/android/rum/startup/RumFirstDrawTimeReporter$Handle; +} + +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/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 new file mode 100644 index 0000000000..891cacc1a8 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/AppLaunchPreInitCollector.kt @@ -0,0 +1,391 @@ +/* + * 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 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 +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. + */ +@Suppress("PreferTimeProvider", "UnsafeThirdPartyFunctionCall") +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 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 + + /** 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 + // 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 + } + } + + // 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)) { + return + } + + activityOnCreateNs = System.nanoTime() + hasSavedInstanceState = savedInstanceState != null + this.activity = WeakReference(activity) + isFirstActivityForProcess = _isFirstActivityForProcess + processStartNs = computeProcessStartNs() + + // Unregister lifecycle callbacks — we've captured what we need from the first Activity + registeredApplication?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + + // Subscribe to first frame drawn — transitions CAPTURING -> COMPLETE + val handler = handlerFactory() + val reporter = firstDrawTimeReporterFactory(handler) + firstFrameHandle = reporter.subscribeToFirstFrameDrawn( + activity, + object : RumFirstDrawTimeReporter.Callback { + override fun onFirstFrameDrawn(timestampNs: Long) { + firstFrameHandle = null + 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 coherence 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 + } + // 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) + return guardedProcessStartNs( + computed = computed, + fallback = DdRumContentProvider.createTimeNs, + thresholdNs = PROCESS_START_TO_CP_START_DIFF_THRESHOLD_NS + ) + } + + // 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)) { + return + } + registeredApplication = application + application.registerActivityLifecycleCallbacks(lifecycleCallbacks) + } + + /** + * 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) { + registeredApplication?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + } + 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) { + 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)) { + cb(firstFrameNs) + } + } + } + + // endregion + + // region Constants + + /** + * 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. + */ + 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() + registeredApplication = 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..41e7f33cd8 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporter.kt @@ -0,0 +1,59 @@ +/* + * 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 + +/** + * 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 { + + /** + * 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. + */ + 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. + * 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): 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 new file mode 100644 index 0000000000..2196f3d221 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImpl.kt @@ -0,0 +1,138 @@ +/* + * 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 + +/** + * 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, + 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 + ): RumFirstDrawTimeReporter.Handle { + val handle = HandleImpl(activity, callback) + handle.init() + return handle + } + + // 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 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(activity.window.decorView) + } + + override fun onViewAttachedToWindow(v: View) { + registerOnDrawListener(activity.window.decorView) + activity.window.decorView.removeOnAttachStateChangeListener(this) + } + + override fun onViewDetachedFromWindow(v: View) {} + + override fun onDraw() { + if (onDrawInvoked || isCancelled) return + onDrawInvoked = true + + val nowNs = timeProviderNs() + handler.sendMessageAtFrontOfQueue( + Message.obtain( + handler, + Runnable { + if (!isCancelled) callback.onFirstFrameDrawn(nowNs) + } + ).apply { isAsynchronous = true } + ) + + val currentDecorView = activity.window.decorView + handler.post { removeOnDrawListener(currentDecorView) } + } + + 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) { + warnLogger("RumFirstDrawTimeReporterImpl unable to add onDrawListener onto viewTreeObserver", e) + } + } + } + + 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/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt new file mode 100644 index 0000000000..798894e9ee --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/rum/startup/WindowCallbacksRegistry.kt @@ -0,0 +1,122 @@ +/* + * 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.view.Window +import com.datadog.android.internal.utils.DDCoreSubscription +import com.datadog.android.internal.utils.FixedWindowCallback +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() + + override fun addListener(activity: Activity, listener: WindowCallbackListener) { + val callback = callbacks.getOrPut(activity) { + activity.window.wrapCallback() + } + + callback.addListener(listener) + } + + override fun removeListener(activity: Activity, listener: WindowCallbackListener) { + callbacks[activity]?.let { + it.removeListener(listener) + + if (it.subscription.listenersCount == 0) { + activity.window.tryToRemoveCallback() + callbacks.remove(activity) + } + } + } + + private fun Window.wrapCallback(): WindowCallback { + val currentCallback = callback + val newCallback = WindowCallback( + wrapped = currentCallback + ) + callback = newCallback + return newCallback + } + + private fun Window.tryToRemoveCallback() { + val currentCallback = callback + if (currentCallback is WindowCallback && currentCallback in callbacks.values) { + callback = currentCallback.wrapped + } + } +} + +@Suppress("PackageNameVisibility") +private class WindowCallback( + val wrapped: Window.Callback +) : FixedWindowCallback(wrapped) { + + val subscription = DDCoreSubscription.create() + + fun addListener(listener: WindowCallbackListener) { + subscription.addListener(listener) + } + + fun removeListener(listener: WindowCallbackListener) { + subscription.removeListener(listener) + } + + override fun onContentChanged() { + super.onContentChanged() + + subscription.notifyListeners { + this@notifyListeners.onContentChanged() + } + } +} 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..d672fe6fc7 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/AppLaunchPreInitCollectorTest.kt @@ -0,0 +1,907 @@ +/* + * 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()) + mock() + } + } + } + + 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 + + // 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/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt similarity index 51% rename from features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt rename to dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt index b64bdba413..94f1ccd350 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/startup/RumFirstDrawTimeReporterHandleImplTest.kt +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/rum/startup/RumFirstDrawTimeReporterImplTest.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.startup +package com.datadog.android.rum.startup import android.app.Activity import android.os.Handler @@ -12,48 +12,34 @@ 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.verify 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) -) +@ExtendWith(MockitoExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(Configurator::class) -class RumFirstDrawTimeReporterHandleImplTest { +class RumFirstDrawTimeReporterImplTest { private var currentTime: Duration = 0.seconds @Mock - private lateinit var windowCallbackRegistry: RumWindowCallbacksRegistry + private lateinit var windowCallbackRegistry: WindowCallbacksRegistry @Mock private lateinit var handler: Handler @@ -73,11 +59,16 @@ class RumFirstDrawTimeReporterHandleImplTest { @Mock private lateinit var viewTreeObserver: ViewTreeObserver - @Mock - private lateinit var internalLogger: InternalLogger + 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 @@ -85,7 +76,7 @@ class RumFirstDrawTimeReporterHandleImplTest { whenever(decorView.viewTreeObserver) doReturn viewTreeObserver whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { - val argListener = it.getArgument(1) + val argListener = it.getArgument(1) argListener.onContentChanged() } @@ -104,20 +95,18 @@ class RumFirstDrawTimeReporterHandleImplTest { whenever(decorView.isAttachedToWindow) doReturn true } - // region first frame detection - @Test - fun `M call onFirstFrameDrawn W decorView doesn't exist yet`() { + fun `M call onTTIDCalculated W RumTTIDReporter { decorView doesn't exist yet }`() { // Given currentTime += 1.seconds // When - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then inOrder(windowCallbackRegistry, callback, viewTreeObserver) { - verify(windowCallbackRegistry).addListener(eq(activity), any()) - verify(windowCallbackRegistry).removeListener(eq(activity), any()) + verify(windowCallbackRegistry).addListener(any(), any()) + verify(windowCallbackRegistry).removeListener(any(), any()) verify(viewTreeObserver).isAlive argumentCaptor { @@ -125,7 +114,9 @@ class RumFirstDrawTimeReporterHandleImplTest { firstValue.onDraw() } - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) verify(viewTreeObserver).isAlive verify(viewTreeObserver).removeOnDrawListener(any()) verifyNoMoreInteractions() @@ -133,13 +124,14 @@ class RumFirstDrawTimeReporterHandleImplTest { } @Test - fun `M call onFirstFrameDrawn W decorView exists`() { + fun `M call onTTIDCalculated W RumTTIDReporter { decorView exists }`() { // Given whenever(window.peekDecorView()) doReturn decorView + currentTime += 1.seconds // When - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then inOrder(windowCallbackRegistry, callback, viewTreeObserver) { @@ -150,7 +142,9 @@ class RumFirstDrawTimeReporterHandleImplTest { firstValue.onDraw() } - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) verify(viewTreeObserver).isAlive verify(viewTreeObserver).removeOnDrawListener(any()) verifyNoMoreInteractions() @@ -158,14 +152,15 @@ class RumFirstDrawTimeReporterHandleImplTest { } @Test - fun `M call onFirstFrameDrawn W decorView exists but not attached to window`() { + 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 - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then inOrder(windowCallbackRegistry, callback, viewTreeObserver, decorView) { @@ -178,10 +173,14 @@ class RumFirstDrawTimeReporterHandleImplTest { argumentCaptor { verify(viewTreeObserver).addOnDrawListener(capture()) + // HandleImpl removes itself from the attach-state listener after registering the draw listener + verify(decorView).removeOnAttachStateChangeListener(any()) firstValue.onDraw() } - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) verify(viewTreeObserver).isAlive verify(viewTreeObserver).removeOnDrawListener(any()) verifyNoMoreInteractions() @@ -189,24 +188,24 @@ class RumFirstDrawTimeReporterHandleImplTest { } @Test - fun `M not call onFirstFrameDrawn W viewTreeObserver is not alive`() { + fun `M not call onTTIDCalculated W RumTTIDReporter { viewTreeObserver is not alive }`() { // Given whenever(viewTreeObserver.isAlive) doReturn false // When - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then verifyNoInteractions(callback) } @Test - fun `M call onFirstFrameDrawn only once W onDraw is called twice`() { + fun `M call onTTIDCalculated only once W RumTTIDReporter { onDraw is called twice }`() { // Given currentTime += 1.seconds // When - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then inOrder(callback, viewTreeObserver) { @@ -218,7 +217,9 @@ class RumFirstDrawTimeReporterHandleImplTest { firstValue.onDraw() } - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + verify( + callback + ).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) verify(viewTreeObserver).isAlive verify(viewTreeObserver).removeOnDrawListener(any()) verifyNoMoreInteractions() @@ -226,64 +227,37 @@ class RumFirstDrawTimeReporterHandleImplTest { } @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`() { + fun `M not call callback W addOnDrawListener { if it throws IllegalStateException }`() { // Given val illegalStateException = IllegalStateException() whenever(viewTreeObserver.addOnDrawListener(any())) doThrow illegalStateException // When - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then verifyNoInteractions(callback) - inOrder(viewTreeObserver, internalLogger) { + inOrder(viewTreeObserver) { verify(viewTreeObserver).isAlive verify(viewTreeObserver).addOnDrawListener(any()) - - verify(internalLogger).log( - level = eq(InternalLogger.Level.WARN), - target = eq(InternalLogger.Target.TELEMETRY), - messageBuilder = any(), - throwable = eq(illegalStateException), - onlyOnce = eq(false), - additionalProperties = anyOrNull() - ) - verifyNoMoreInteractions() } } @Test - fun `M call internalLogger W removeOnDrawListener throws IllegalStateException`() { + fun `M call callback W removeOnDrawListener { if it throws IllegalStateException }`() { // Given val illegalStateException = IllegalStateException() whenever(viewTreeObserver.removeOnDrawListener(any())) doThrow illegalStateException + currentTime += 1.seconds // When - createHandle() + reporter.subscribeToFirstFrameDrawn(activity, callback) // Then - verifyNoInteractions(callback) - - inOrder(callback, viewTreeObserver, internalLogger) { + inOrder(callback, viewTreeObserver) { verify(viewTreeObserver).isAlive argumentCaptor { @@ -291,121 +265,50 @@ class RumFirstDrawTimeReporterHandleImplTest { firstValue.onDraw() } - verify(callback).onFirstFrameDrawn(1.seconds.inWholeNanoseconds) + 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 + 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, 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) + } - val handle = createHandle() + val handle = reporter.subscribeToFirstFrameDrawn(activity, callback) + checkNotNull(capturedListener) { "Expected addListener to be called" } // 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() - } + // Then — the WindowCallbackListener is removed, breaking the retain cycle + verify(windowCallbackRegistry).removeListener(activity, capturedListener!!) + verifyNoInteractions(callback) } @Test - fun `M remove all listeners once W unsubscribe called twice`() { - // Given - whenever(window.peekDecorView()) doReturn decorView + fun `M not remove listener twice W handle unsubscribe { called multiple times }`() { + // Given — addListener does not fire onContentChanged + whenever(windowCallbackRegistry.addListener(any(), any())).doAnswer { } - val handle = createHandle() + val handle = reporter.subscribeToFirstFrameDrawn(activity, callback) - // When + // When unsubscribe is called twice 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 - ) + // Then — removeListener is called exactly once (isCancelled guard) + verify(windowCallbackRegistry, times(1)).removeListener(any(), any()) + verifyNoInteractions(callback) } } 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..e78a87fb59 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/build.gradle.kts @@ -0,0 +1,76 @@ +/* + * 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.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." +) 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..75ba181883 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/src/main/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProvider.kt @@ -0,0 +1,68 @@ +/* + * 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 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. + */ +@Suppress("PackageNameVisibility") +internal class AppLaunchCollectorProvider : ContentProvider() { + + @Suppress("ReturnCount") + override fun onCreate(): Boolean { + val application = context?.applicationContext as? Application ?: return false + val importance = DdRumContentProvider.processImportance + if (importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { + return false + } + AppLaunchPreInitCollector.install(application) + return true + } + + 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..f33b276fa3 --- /dev/null +++ b/features/dd-sdk-android-rum-prelaunch/src/test/kotlin/com/datadog/android/rum/prelaunch/AppLaunchCollectorProviderTest.kt @@ -0,0 +1,176 @@ +/* + * 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 android.net.Uri +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.mock +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) + } + + @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-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/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/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 35f39977e2..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 @@ -45,6 +45,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 @@ -57,6 +58,7 @@ 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.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 @@ -94,6 +96,7 @@ import com.datadog.android.rum.internal.thread.NoOpScheduledExecutorService import com.datadog.android.rum.internal.tracking.JetpackViewAttributesProvider 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 @@ -120,6 +123,9 @@ 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 @@ -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 java.util.WeakHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit @@ -140,7 +147,7 @@ import java.util.concurrent.atomic.AtomicReference /** * 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, @@ -162,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() @@ -738,35 +750,255 @@ internal class RumFeature( } private fun initRumAppStartupDetector() { + val collector = AppLaunchPreInitCollector + when (collector.state) { + AppLaunchPreInitCollector.State.NOT_INSTALLED -> { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "TTID: NOT_INSTALLED — prelaunch module not present; using default RumAppStartupDetector" } + ) + createDefaultRumAppStartupDetector() + } + AppLaunchPreInitCollector.State.IDLE -> { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "TTID: IDLE — SDK init before first Activity;" + + " claiming collector, using default RumAppStartupDetector" + } + ) + collector.claim() + createDefaultRumAppStartupDetector() + } + AppLaunchPreInitCollector.State.CAPTURING -> handleCapturingState(collector) + AppLaunchPreInitCollector.State.COMPLETE -> handleCompleteState(collector) + AppLaunchPreInitCollector.State.CLAIMED -> { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { COLLECTOR_ALREADY_CLAIMED_MESSAGE } + ) + createDefaultRumAppStartupDetector() + } + } + } + + 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. + val capturedStrategy = viewTrackingStrategy as? ReplayableViewTrackingStrategy + 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 + scenario.activity.get()?.let { activity -> + capturedStrategy?.onLateActivityReady(activity) + } + 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). + // + // 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 -> + capturedStrategy?.onLateActivityReady(activity) + } + rumMonitor.sendAppStartEvent(scenario) + rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) + } + } + + 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 + ) + } + ) + + @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 + rumMonitor.sendAppStartEvent(scenario) + subscribeToFirstFrameDrawn(scenario, activity, rumMonitor, wasForwarded = false) + } + + override fun onNextActivityCreated( + pendingScenario: RumStartupScenario, + activity: Activity + ) { + val rumMonitor = (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) ?: return + subscribeToFirstFrameDrawn(pendingScenario, activity, rumMonitor, wasForwarded = true) + } + + override fun onActivityDestroyed(activity: Activity) { + firstFrameHandles.remove(activity)?.unsubscribe() } - override fun onTTIDComputed( + 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) { + firstFrameHandles.remove(activity) + // 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() + } + } + + firstFrameHandles[activity] = rumFirstDrawTimeReporter.subscribeToFirstFrameDrawn( + activity = activity, + callback = callback ) - - rumMonitor.sendTTIDEvent(info) } }, appStartupActivityPredicate = configuration.appStartupActivityPredicate ) } + @Suppress("ReturnCount", "UnsafeCallOnNullableType") + 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)) { + sdkCore.internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { + "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 internal data class Configuration( @@ -807,6 +1039,8 @@ internal class RumFeature( internal companion object { + 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" internal const val LOGGER_ERROR_WITH_STACK_TRACE_MESSAGE_TYPE = "logger_error_with_stacktrace" @@ -890,6 +1124,13 @@ 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." + 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..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 @@ -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,19 @@ 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) + + /** + * 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 } - @UiThread fun destroy() + fun getPendingScenario(): RumStartupScenario? + fun clearPendingScenario() companion object { fun create( @@ -32,16 +40,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..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 @@ -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, @@ -24,8 +23,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 +33,6 @@ internal class RumAppStartupDetectorImpl( @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty private val trackedActivities = Collections.newSetFromMap(WeakHashMap()) - private val firstFrameHandles = WeakHashMap() init { application.registerActivityLifecycleCallbacks(this) @@ -56,8 +53,7 @@ internal class RumAppStartupDetectorImpl( override fun onActivityDestroyed(activity: Activity) { numberOfActivities-- trackedActivities.remove(activity) - - firstFrameHandles.remove(activity)?.unsubscribe() + listener.onActivityDestroyed(activity) 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() @@ -107,45 +102,16 @@ 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) - - subscribeToFirstFrameDrawn( - scenario = scenario, - activity = activity, - wasForwarded = false - ) - isFirstActivityForProcess = false } @@ -155,53 +121,18 @@ 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) - - // Another activity may have already reported TTID - if (pendingScenario !== scenario) return + override fun getPendingScenario(): RumStartupScenario? = pendingScenario - 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 { - 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/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/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/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/internal/utils/window/RumWindowCallbacksRegistry.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/utils/window/RumWindowCallbacksRegistry.kt deleted file mode 100644 index e0c1ceddb4..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/utils/window/RumWindowCallbacksRegistry.kt +++ /dev/null @@ -1,86 +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.utils.window - -import android.app.Activity -import android.view.Window -import com.datadog.android.internal.utils.DDCoreSubscription -import com.datadog.android.internal.utils.FixedWindowCallback -import java.util.WeakHashMap -import kotlin.collections.getOrPut -import kotlin.let - -internal interface RumWindowCallbackListener { - fun onContentChanged() -} - -internal interface RumWindowCallbacksRegistry { - fun addListener(activity: Activity, listener: RumWindowCallbackListener) - fun removeListener(activity: Activity, listener: RumWindowCallbackListener) -} - -internal class RumWindowCallbacksRegistryImpl : RumWindowCallbacksRegistry { - private val callbacks = WeakHashMap() - - override fun addListener(activity: Activity, listener: RumWindowCallbackListener) { - val callback = callbacks.getOrPut(activity) { - activity.window.wrapCallback() - } - - callback.addListener(listener) - } - - override fun removeListener(activity: Activity, listener: RumWindowCallbackListener) { - callbacks[activity]?.let { - it.removeListener(listener) - - if (it.subscription.listenersCount == 0) { - activity.window.tryToRemoveCallback() - callbacks.remove(activity) - } - } - } - - private fun Window.wrapCallback(): RumWindowCallback { - val currentCallback = callback - val newCallback = RumWindowCallback( - wrapped = currentCallback - ) - callback = newCallback - return newCallback - } - - private fun Window.tryToRemoveCallback() { - val currentCallback = callback - if (currentCallback is RumWindowCallback && currentCallback in callbacks.values) { - callback = currentCallback.wrapped - } - } -} - -private class RumWindowCallback( - val wrapped: Window.Callback -) : FixedWindowCallback(wrapped) { - - val subscription = DDCoreSubscription.create() - - fun addListener(listener: RumWindowCallbackListener) { - subscription.addListener(listener) - } - - fun removeListener(listener: RumWindowCallbackListener) { - subscription.removeListener(listener) - } - - override fun onContentChanged() { - super.onContentChanged() - - subscription.notifyListeners { - this@notifyListeners.onContentChanged() - } - } -} 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..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 @@ -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,15 @@ constructor( // endregion + // region ReplayableViewTrackingStrategy + + @MainThread + 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..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 @@ -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,15 @@ class NavigationViewTrackingStrategy( // endregion + // region ReplayableViewTrackingStrategy + + @MainThread + override fun onLateActivityReady(activity: Activity) { + onActivityStarted(activity) + } + + // endregion + // region OnDestinationChangedListener override fun onDestinationChanged( 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..b17dab821f --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeaturePreInitStartupTest.kt @@ -0,0 +1,770 @@ +/* + * 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.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 +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.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 +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 registeredApplication via reflection + val appField = AppLaunchPreInitCollector::class.java.getDeclaredField("registeredApplication") + 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 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( + processStartNs = 100_000L, + activityOnCreateNs = 200_000L, + isFirstActivityForProcess = true, + activity = mockActivity + ) + + // When + testedFeature.onInitialize(appContext.mockInstance) + + // Then — sendAppStartEvent is NOT called immediately; deferred to first-frame callback + verify(mockRumMonitor, never()).sendAppStartEvent(any()) + } + + @Test + fun `M send app start and 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 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 — 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) + } + + // endregion + + // region INT-04: COMPLETE branch + + @Test + 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).onLateActivityReady(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, + activityOnCreateNs = 200_000L, + firstFrameNs = 300_000L, + 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 + verify(mockActivityStrategy).onLateActivityReady(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 + + @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) + testedFeature.pendingPreLaunchAction?.invoke() + + // 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) + testedFeature.pendingPreLaunchAction?.invoke() + + // 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 W initRumAppStartupDetector() { COMPLETE + notFirstActivity }`() { + // 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) + testedFeature.pendingPreLaunchAction?.invoke() + + // 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 + + // 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() + + @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..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 @@ -18,6 +18,7 @@ import com.datadog.android.rum.internal.startup.RumAppStartupDetector import com.datadog.android.rum.internal.startup.RumAppStartupDetectorImpl 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 @@ -25,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 @@ -41,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 @@ -113,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( @@ -127,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 @@ -140,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), @@ -156,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() @@ -217,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()), @@ -239,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 @@ -265,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..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 @@ -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,35 +174,28 @@ 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() + ) ) } + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -255,16 +229,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).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -302,16 +275,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 +295,6 @@ internal class RumAppStartupDetectorImplTest { ) { // Given val detector = createDetector() - autoDrawFirstFrame(activity) currentTime += 3.seconds @@ -337,10 +308,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 +323,28 @@ 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() + ) ) } + + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -392,8 +358,6 @@ internal class RumAppStartupDetectorImplTest { // Given val detector = createDetector() - autoDrawFirstFrame(activity) - currentTime += 3.seconds // When @@ -424,10 +388,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 +403,32 @@ 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() + ) ) } + + verify(listener).onActivityDestroyed(activity2) + verify(listener).onActivityDestroyed(activity) verifyNoMoreInteractions(listener) } @@ -479,8 +442,6 @@ internal class RumAppStartupDetectorImplTest { val interstitialActivity = mock() val mainActivity = mock() - autoDrawFirstFrame(mainActivity) - val predicate = AppStartupActivityPredicate { activity -> activity != interstitialActivity } @@ -513,21 +474,16 @@ 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 ) - } + ) + verify(listener).onActivityDestroyed(interstitialActivity) verifyNoMoreInteractions(listener) } @@ -551,7 +507,7 @@ internal class RumAppStartupDetectorImplTest { ) // Then - no scenario detected - verifyNoInteractions(listener) + verifyNoMoreInteractions(listener) } @Test @@ -566,8 +522,6 @@ internal class RumAppStartupDetectorImplTest { val excludedActivity2 = mock() val includedActivity = mock() - autoDrawFirstFrame(includedActivity) - val predicate = AppStartupActivityPredicate { activity -> activity != excludedActivity1 && activity != excludedActivity2 } @@ -611,20 +565,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 +596,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 +618,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 +635,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 +650,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,26 +665,46 @@ 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() + ) ) + verify(listener).onActivityDestroyed(activity1) verifyNoMoreInteractions(listener) } // 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 +718,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 +737,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 +757,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 +792,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 +816,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 +825,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 +862,9 @@ internal class RumAppStartupDetectorImplTest { activity = activity, hasSavedInstanceStateBundle = false ) + detector.clearPendingScenario() + + val secondActivity: Activity = mock() // When currentTime += 1.seconds @@ -914,24 +876,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 +894,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 +910,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 +940,7 @@ internal class RumAppStartupDetectorImplTest { ) }, listener = listener, - appStartupActivityPredicate = appStartupActivityPredicate, - rumFirstDrawTimeReporter = rumFirstDrawTimeReporter + appStartupActivityPredicate = appStartupActivityPredicate ) return detector @@ -1029,13 +974,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")