diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index d1820bd560..d8b664f630 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -51,6 +51,18 @@ class com.datadog.android.internal.data.SharedPreferencesStorage : PreferencesSt override fun clear() data class com.datadog.android.internal.flags.RumFlagEvaluationMessage constructor(String, Any) +interface com.datadog.android.internal.identity.ViewIdentityResolver + fun setCurrentScreen(String?) + fun onWindowRefreshed(android.view.View) + fun resolveViewIdentity(android.view.View): String? + companion object + const val FEATURE_CONTEXT_KEY: String +class com.datadog.android.internal.identity.ViewIdentityResolverImpl : ViewIdentityResolver + constructor(String) + override fun setCurrentScreen(String?) + override fun onWindowRefreshed(android.view.View) + override fun resolveViewIdentity(android.view.View): String? + companion object enum com.datadog.android.internal.network.GraphQLHeaders constructor(String) - DD_GRAPHQL_NAME_HEADER 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 d8364f0540..9af6e0ca08 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -106,6 +106,36 @@ public final class com/datadog/android/internal/flags/RumFlagEvaluationMessage { public fun toString ()Ljava/lang/String; } +public final class com/datadog/android/internal/identity/NoOpViewIdentityResolver : com/datadog/android/internal/identity/ViewIdentityResolver { + public fun ()V + public fun onWindowRefreshed (Landroid/view/View;)V + public fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String; + public fun setCurrentScreen (Ljava/lang/String;)V +} + +public abstract interface class com/datadog/android/internal/identity/ViewIdentityResolver { + public static final field Companion Lcom/datadog/android/internal/identity/ViewIdentityResolver$Companion; + public static final field FEATURE_CONTEXT_KEY Ljava/lang/String; + public abstract fun onWindowRefreshed (Landroid/view/View;)V + public abstract fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String; + public abstract fun setCurrentScreen (Ljava/lang/String;)V +} + +public final class com/datadog/android/internal/identity/ViewIdentityResolver$Companion { + public static final field FEATURE_CONTEXT_KEY Ljava/lang/String; +} + +public final class com/datadog/android/internal/identity/ViewIdentityResolverImpl : com/datadog/android/internal/identity/ViewIdentityResolver { + public static final field Companion Lcom/datadog/android/internal/identity/ViewIdentityResolverImpl$Companion; + public fun (Ljava/lang/String;)V + public fun onWindowRefreshed (Landroid/view/View;)V + public fun resolveViewIdentity (Landroid/view/View;)Ljava/lang/String; + public fun setCurrentScreen (Ljava/lang/String;)V +} + +public final class com/datadog/android/internal/identity/ViewIdentityResolverImpl$Companion { +} + public final class com/datadog/android/internal/network/GraphQLHeaders : java/lang/Enum { public static final field DD_GRAPHQL_NAME_HEADER Lcom/datadog/android/internal/network/GraphQLHeaders; public static final field DD_GRAPHQL_PAYLOAD_HEADER Lcom/datadog/android/internal/network/GraphQLHeaders; diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/identity/ViewIdentityResolver.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/identity/ViewIdentityResolver.kt new file mode 100644 index 0000000000..ab936dac74 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/identity/ViewIdentityResolver.kt @@ -0,0 +1,43 @@ +/* + * 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.identity + +import android.view.View +import com.datadog.tools.annotation.NoOpImplementation + +/** + * Resolves globally unique, stable identities for Android Views based on their canonical path + * in the view hierarchy. Used for heatmap correlation between RUM actions and Session Replay. + */ +@NoOpImplementation(publicNoOpImplementation = true) +interface ViewIdentityResolver { + + /** + * Sets the current screen identifier. Takes precedence over Activity-based detection. + * @param identifier the screen identifier (typically RUM view URL), or null to clear + */ + fun setCurrentScreen(identifier: String?) + + /** + * Indexes a view tree for efficient identity lookups. + * @param root the root view of the window + */ + fun onWindowRefreshed(root: View) + + /** + * Resolves the stable identity for a view (32 hex chars), or null if the view is detached. + * @param view the view to identify + * @return the stable identity hash, or null if it cannot be computed + */ + fun resolveViewIdentity(view: View): String? + + companion object { + /** + * Key used to store the ViewIdentityResolver instance in the feature context. + */ + const val FEATURE_CONTEXT_KEY: String = "_dd.view_identity_resolver" + } +} diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/identity/ViewIdentityResolverImpl.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/identity/ViewIdentityResolverImpl.kt new file mode 100644 index 0000000000..4502c37132 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/identity/ViewIdentityResolverImpl.kt @@ -0,0 +1,259 @@ +/* + * 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.identity + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import android.view.View +import android.view.ViewGroup +import com.datadog.android.internal.utils.toHexString +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.Collections +import java.util.Stack +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicReference + +/** + * Implementation of [ViewIdentityResolver] that generates globally unique, stable identifiers + * for Android Views by computing and hashing their canonical path in the view hierarchy. + * + * Thread-safe: [setCurrentScreen] is called from a RUM worker thread while [onWindowRefreshed] + * and [resolveViewIdentity] are called from the main thread. + * + * @param appIdentifier The application package name used as the root of canonical paths + */ +@Suppress("TooManyFunctions") +class ViewIdentityResolverImpl( + private val appIdentifier: String +) : ViewIdentityResolver { + + /** + * Cache: Resource ID (Int) → Resource name (String). + * Example: 2131230001 → "com.example.app:id/login_button" + * + * LRU cache with fixed size. Never explicitly cleared - resource ID mappings + * are global and don't change based on screen. Avoids repeated calls to + * resources.getResourceName() which is expensive. + */ + @Suppress("UnsafeThirdPartyFunctionCall") // LinkedHashMap constructor doesn't throw + private val resourceNameCache: MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(RESOURCE_NAME_CACHE_SIZE, DEFAULT_LOAD_FACTOR, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > RESOURCE_NAME_CACHE_SIZE + } + } + ) + + /** + * Cache: View reference → PathData (canonical path + identity hash). + * Example: Button@0x7f3a → PathData("com.app/view:Home/login_button", "a1b2c3...") + * + * WeakHashMap so entries are removed when Views are garbage collected. + * Cleared when screen changes (paths include screen namespace, so become invalid). + */ + @Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null + private val viewPathDataCache: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + + /** + * Cache: Root view reference → Screen namespace string. + * Example: DecorView@0x1a2b → "view:HomeScreen" + * + * Avoids recomputing namespace (which may involve walking context chain). + * Cleared when screen changes (namespace depends on currentRumViewIdentifier). + */ + @Suppress("UnsafeThirdPartyFunctionCall") // WeakHashMap() is never null + private val rootScreenNamespaceCache: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + + /** The current RUM view identifier, set via setCurrentScreen(). */ + private val currentRumViewIdentifier = AtomicReference(null) + + @Synchronized + override fun setCurrentScreen(identifier: String?) { + @Suppress("UnsafeThirdPartyFunctionCall") // type-safe: generics prevent VarHandle type mismatches + val previous = currentRumViewIdentifier.getAndSet(identifier) + if (previous != identifier) { + rootScreenNamespaceCache.clear() + viewPathDataCache.clear() + } + } + + @Synchronized + override fun onWindowRefreshed(root: View) { + indexTree(root) + } + + @Synchronized + override fun resolveViewIdentity(view: View): String? { + return viewPathDataCache[view]?.identityHash + } + + private fun indexTree(root: View) { + val screenNamespace = getScreenNamespace(root) + val rootCanonicalPath = buildRootCanonicalPath(root, screenNamespace) + + traverseAndIndexViews(root, rootCanonicalPath) + } + + /** Builds the canonical path for the root view (used as prefix for all descendants). */ + private fun buildRootCanonicalPath(root: View, screenNamespace: String): String { + val rootPathSegment = getViewPathSegment(root, null) + // Root view (e.g., DecorView) is not interactable, so we don't cache its identity. + // We only need its path as the prefix for descendant paths. + return "$appIdentifier/$screenNamespace/$rootPathSegment" + } + + /** Depth-first traversal of view hierarchy, computing and caching identity for each view. */ + private fun traverseAndIndexViews(root: View, rootCanonicalPath: String) { + // Index the root view (all cache insertions happen here for consistency) + md5Hex(rootCanonicalPath)?.let { hash -> + viewPathDataCache[root] = PathData(rootCanonicalPath, hash) + } + + val stack = Stack() + stack.push(ViewWithCanonicalPath(root, rootCanonicalPath)) + + while (stack.isNotEmpty()) { + val (parent, parentPath) = stack.pop() + if (parent is ViewGroup) { + indexChildrenOf(parent, parentPath, stack) + } + } + } + + private fun indexChildrenOf( + parent: ViewGroup, + parentPath: String, + stack: Stack + ) { + for (i in 0 until parent.childCount) { + val child = parent.getChildAt(i) + val childPath = "$parentPath/${getViewPathSegment(child, parent)}" + val childHash = md5Hex(childPath) ?: continue + + viewPathDataCache[child] = PathData(childPath, childHash) + stack.push(ViewWithCanonicalPath(child, childPath)) + } + } + + private fun getScreenNamespace(rootView: View): String { + rootScreenNamespaceCache[rootView]?.let { return it } + + val screenNamespace = getNamespaceFromRumView() + ?: getNamespaceFromActivity(rootView) + ?: getNamespaceFromRootResourceId(rootView) + ?: getNamespaceFromRootClassName(rootView) + + rootScreenNamespaceCache[rootView] = screenNamespace + return screenNamespace + } + + /** Priority 1: Use RUM view identifier if available (set via RumMonitor.startView). */ + private fun getNamespaceFromRumView(): String? { + return currentRumViewIdentifier.get()?.let { viewName -> + "$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewName)}" + } + } + + /** Priority 2: Fall back to Activity class name if root view has Activity context. */ + private fun getNamespaceFromActivity(rootView: View): String? { + return findActivity(rootView)?.let { activity -> + "$NAMESPACE_ACTIVITY_PREFIX${escapePathComponent(activity::class.java.name)}" + } + } + + /** Priority 3: Fall back to root view's resource ID if it has one. */ + private fun getNamespaceFromRootResourceId(rootView: View): String? { + return getResourceName(rootView)?.let { resourceName -> + "$NAMESPACE_ROOT_ID_PREFIX${escapePathComponent(resourceName)}" + } + } + + /** Priority 4: Last resort - use root view's class name. */ + private fun getNamespaceFromRootClassName(rootView: View): String { + return "$NAMESPACE_ROOT_CLASS_PREFIX${escapePathComponent(rootView.javaClass.name)}" + } + + private fun getViewPathSegment(view: View, parentView: ViewGroup?): String { + val resourceName = getResourceName(view) + if (resourceName != null) return escapePathComponent(resourceName) + + val siblingIndex = countPrecedingSiblingsOfSameClass(view, parentView) + return "$LOCAL_KEY_CLASS_PREFIX${escapePathComponent(view.javaClass.name)}#$siblingIndex" + } + + /** + * Counts how many siblings of the same class appear before this view in the parent. + * Used to disambiguate views without resource IDs (e.g., "TextView#0", "TextView#1"). + * Returns 0 if parentView is null (root view case). + */ + private fun countPrecedingSiblingsOfSameClass(view: View, parentView: ViewGroup?): Int { + if (parentView == null) return 0 + + var count = 0 + val viewClass = view.javaClass + for (i in 0 until parentView.childCount) { + val sibling = parentView.getChildAt(i) + if (sibling === view) break + if (sibling.javaClass == viewClass) count++ + } + return count + } + + private fun getResourceName(view: View): String? { + val id = view.id + if (id == View.NO_ID) return null + + return resourceNameCache[id] ?: try { + view.resources?.getResourceName(id)?.also { name -> + resourceNameCache[id] = name + } + } catch (_: Resources.NotFoundException) { + null + } + } + + private data class ViewWithCanonicalPath(val view: View, val canonicalPath: String) + private data class PathData(val canonicalPath: String, val identityHash: String) + + companion object { + private const val RESOURCE_NAME_CACHE_SIZE = 500 + private const val DEFAULT_LOAD_FACTOR = 0.75f + private const val NAMESPACE_VIEW_PREFIX = "view:" + private const val NAMESPACE_ACTIVITY_PREFIX = "act:" + private const val NAMESPACE_ROOT_ID_PREFIX = "root-id:" + private const val NAMESPACE_ROOT_CLASS_PREFIX = "root-cls:" + private const val LOCAL_KEY_CLASS_PREFIX = "cls:" + } +} + +private fun escapePathComponent(input: String): String { + return input.replace("%", "%25").replace("/", "%2F") +} + +private fun md5Hex(input: String): String? { + return try { + val messageDigest = MessageDigest.getInstance("MD5") + messageDigest.update(input.toByteArray(Charsets.UTF_8)) + messageDigest.digest().toHexString() + } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: NoSuchAlgorithmException) { + null + } +} + +@Suppress("ReturnCount") +private fun findActivity(view: View): Activity? { + var ctx: Context? = view.context ?: return null + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/identity/ViewIdentityResolverTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/identity/ViewIdentityResolverTest.kt new file mode 100644 index 0000000000..078aa31a37 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/identity/ViewIdentityResolverTest.kt @@ -0,0 +1,700 @@ +/* + * 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.identity + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import android.view.View +import android.view.ViewGroup +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.StringForgery +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 +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.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class ViewIdentityResolverTest { + + private lateinit var testedManager: ViewIdentityResolverImpl + + @Mock + lateinit var mockContext: Context + + @StringForgery + lateinit var fakePackageName: String + + @BeforeEach + fun `set up`() { + whenever(mockContext.applicationContext) doReturn mockContext + whenever(mockContext.packageName) doReturn fakePackageName + testedManager = ViewIdentityResolverImpl(fakePackageName) + } + + // region resolveViewIdentity + + @Test + fun `M return consistent id W resolveViewIdentity { same view }`() { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity1 = testedManager.resolveViewIdentity(mockView) + val viewIdentity2 = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity1).isEqualTo(viewIdentity2) + } + + @Test + fun `M return different ids W resolveViewIdentity { different views }`() { + // Given + val mockView1 = mockSimpleView(viewId = 100) + val mockView2 = mockSimpleView(viewId = 200) + val mockRoot = mockViewGroupWithChildren(mockView1, mockView2) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity1 = testedManager.resolveViewIdentity(mockView1) + val viewIdentity2 = testedManager.resolveViewIdentity(mockView2) + + // Then + assertThat(viewIdentity1).isNotEqualTo(viewIdentity2) + } + + @Test + fun `M return 32 hex chars W resolveViewIdentity`() { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).hasSize(32) + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M return null W resolveViewIdentity { view not indexed }`() { + // Given + val mockChild = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockChild) + mockActivityContext(mockRoot) + // Note: onWindowRefreshed is NOT called, so the view is not indexed + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockChild) + + // Then + assertThat(viewIdentity).isNull() + } + + @Test + fun `M return valid id W resolveViewIdentity { root view }`() { + // Given + val mockRoot = mockViewGroupWithChildren() + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockRoot) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M return same ids W onWindowRefreshed { called multiple times }`() { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + + testedManager.onWindowRefreshed(mockRoot) + val firstId = testedManager.resolveViewIdentity(mockView) + + // When + testedManager.onWindowRefreshed(mockRoot) + val secondId = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(secondId).isEqualTo(firstId) + } + + @Test + fun `M return valid id W resolveViewIdentity { empty ViewGroup }`() { + // Given + val mockRoot = mockViewGroupWithChildren() + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockRoot) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + // endregion + + // region setCurrentScreen + + @Test + fun `M return valid id W resolveViewIdentity { screen identifier set }`(forge: Forge) { + // Given + val screenIdentifier = forge.aString() + testedManager.setCurrentScreen(screenIdentifier) + + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M clear cache W setCurrentScreen { new identifier }`(forge: Forge) { + // Given + val firstScreen = forge.aString() + testedManager.setCurrentScreen(firstScreen) + + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + val firstViewIdentity = testedManager.resolveViewIdentity(mockView) + + // When + val secondScreen = forge.aString() + testedManager.setCurrentScreen(secondScreen) + testedManager.onWindowRefreshed(mockRoot) + val secondViewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(secondViewIdentity).isNotEqualTo(firstViewIdentity) + } + + @Test + fun `M not clear cache W setCurrentScreen { same identifier }`(forge: Forge) { + // Given + val screenIdentifier = forge.aString() + testedManager.setCurrentScreen(screenIdentifier) + + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + val firstViewIdentity = testedManager.resolveViewIdentity(mockView) + + // When + testedManager.setCurrentScreen(screenIdentifier) + val secondViewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(secondViewIdentity).isEqualTo(firstViewIdentity) + } + + @Test + fun `M fall back to activity namespace W setCurrentScreen { null after identifier }`(forge: Forge) { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + + testedManager.onWindowRefreshed(mockRoot) + val idWithActivity = testedManager.resolveViewIdentity(mockView) + + testedManager.setCurrentScreen(forge.aString()) + testedManager.onWindowRefreshed(mockRoot) + val idWithScreen = testedManager.resolveViewIdentity(mockView) + + // When + testedManager.setCurrentScreen(null) + testedManager.onWindowRefreshed(mockRoot) + val idAfterClear = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(idWithScreen).isNotEqualTo(idWithActivity) + assertThat(idAfterClear).isEqualTo(idWithActivity) + } + + // endregion + + // region Screen Namespace Priority + + @Test + fun `M return valid id W resolveViewIdentity { no screen identifier, has activity }`() { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M return valid id W resolveViewIdentity { no activity, has root id }`() { + // Given + val rootResourceName = "com.example:id/root_container" + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockRootWithResourceId(mockRoot, rootResourceName) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M return valid id W resolveViewIdentity { no activity, no root id }`() { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M produce different id W resolveViewIdentity { screen identifier vs activity namespace }`(forge: Forge) { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + val idWithActivity = testedManager.resolveViewIdentity(mockView) + + // When + testedManager.setCurrentScreen(forge.aString()) + val idWithScreenId = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(idWithScreenId).isNotEqualTo(idWithActivity) + } + + @Test + fun `M return valid id W resolveViewIdentity { screen identifier contains slashes }`() { + // Given + val screenIdentifier = "home/settings/profile" + testedManager.setCurrentScreen(screenIdentifier) + + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + assertThat(testedManager.resolveViewIdentity(mockView)).isEqualTo(viewIdentity) + } + + @Test + fun `M return valid id W resolveViewIdentity { screen identifier contains percent }`() { + // Given + val screenIdentifier = "discount%20offer" + testedManager.setCurrentScreen(screenIdentifier) + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + assertThat(testedManager.resolveViewIdentity(mockView)).isEqualTo(viewIdentity) + } + + // endregion + + // region Local Key Resolution + + @Test + fun `M use resource id name W resolveViewIdentity { view has resource id }`() { + // Given + val resourceName = "com.example:id/my_button" + val mockView = mockViewWithResourceId(resourceName) + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + } + + @Test + fun `M use class and index W resolveViewIdentity { view has no resource id }`() { + // Given + val mockView = mockSimpleView(viewId = View.NO_ID) + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + } + + @Test + fun `M differentiate by index W resolveViewIdentity { siblings without resource ids }`() { + // Given + val mockView1 = mockSimpleView(viewId = View.NO_ID) + val mockView2 = mockSimpleView(viewId = View.NO_ID) + val mockRoot = mockViewGroupWithChildren(mockView1, mockView2) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity1 = testedManager.resolveViewIdentity(mockView1) + val viewIdentity2 = testedManager.resolveViewIdentity(mockView2) + + // Then + assertThat(viewIdentity1).isNotEqualTo(viewIdentity2) + } + + // endregion + + // region Nested Hierarchy + + @Test + fun `M handle nested hierarchy W resolveViewIdentity`() { + // Given + val deepChild = mockSimpleView() + val middleGroup = mockViewGroupWithChildren(deepChild) + val mockRoot = mockViewGroupWithChildren(middleGroup) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(deepChild) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).hasSize(32) + } + + @Test + fun `M produce different ids for same depth different parents W resolveViewIdentity`() { + // Given + val child1 = mockSimpleView() + val child2 = mockSimpleView() + val group1 = mockViewGroupWithChildren(child1) + val group2 = mockViewGroupWithChildren(child2) + val mockRoot = mockViewGroupWithChildren(group1, group2) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity1 = testedManager.resolveViewIdentity(child1) + val viewIdentity2 = testedManager.resolveViewIdentity(child2) + + // Then + assertThat(viewIdentity1).isNotEqualTo(viewIdentity2) + } + + // endregion + + // region Edge Cases + + @Test + fun `M handle view with null context W resolveViewIdentity`() { + // Given + val mockView: View = mock { + whenever(it.id) doReturn View.NO_ID + whenever(it.resources) doReturn null + whenever(it.context) doReturn null + whenever(it.parent) doReturn null + } + val mockRoot = mockViewGroupWithChildren(mockView) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + } + + @Test + fun `M handle Resources NotFoundException W resolveViewIdentity`() { + // Given + val viewId = 12345 + val mockResources: Resources = mock { + whenever(it.getResourceName(viewId)).thenThrow(Resources.NotFoundException()) + } + val mockView: View = mock { + whenever(it.id) doReturn viewId + whenever(it.resources) doReturn mockResources + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + } + val mockRoot = mockViewGroupWithChildren(mockView) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + } + + @Test + fun `M handle detached view W resolveViewIdentity { view not in hierarchy }`() { + // Given + val mockView: View = mock { + whenever(it.id) doReturn View.NO_ID + whenever(it.resources) doReturn null + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + } + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNull() + } + + @Test + fun `M use cached ancestor path as base W resolveViewIdentity { ancestor cached but child not }`() { + // Given + val deepChild = mockSimpleView() + val middleGroup = mockViewGroupWithChildren(deepChild) + val mockRoot = mockViewGroupWithChildren(middleGroup) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + val rootId = testedManager.resolveViewIdentity(mockRoot) + val middleId = testedManager.resolveViewIdentity(middleGroup) + + // When + val deepChildId = testedManager.resolveViewIdentity(deepChild) + + // Then + assertThat(deepChildId).isNotNull() + assertThat(deepChildId).isNotEqualTo(rootId) + assertThat(deepChildId).isNotEqualTo(middleId) + assertThat(testedManager.resolveViewIdentity(deepChild)).isEqualTo(deepChildId) + } + + @Test + fun `M return null W resolveViewIdentity { view not yet indexed }`() { + // Given + val existingChild = mockSimpleView(viewId = 100) + val mockRoot = mockViewGroupWithChildren(existingChild) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + val existingChildId = testedManager.resolveViewIdentity(existingChild) + + // Add a new child without triggering onWindowRefreshed + val newChild = mockSimpleView(viewId = 200) + whenever(mockRoot.childCount) doReturn 2 + whenever(mockRoot.getChildAt(1)) doReturn newChild + whenever(newChild.parent) doReturn mockRoot + + // When + val newChildId = testedManager.resolveViewIdentity(newChild) + + // Then - resolveViewIdentity only returns cached values, no on-demand computation + assertThat(newChildId).isNull() + assertThat(existingChildId).isNotNull() + } + + @Test + fun `M find activity W resolveViewIdentity { context wrapped in ContextWrapper }`() { + // Given + val mockView = mockSimpleView() + val mockRoot = mockViewGroupWithChildren(mockView) + mockWrappedActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val viewIdentity = testedManager.resolveViewIdentity(mockView) + + // Then + assertThat(viewIdentity).isNotNull() + assertThat(viewIdentity).matches("[0-9a-f]{32}") + } + + @Test + fun `M return different ids W resolveViewIdentity { different root views }`() { + // Given + val view1 = mockSimpleView() + val root1 = mockViewGroupWithChildren(view1) + mockActivityContext(root1) + + val view2 = mockSimpleView() + val root2 = mockViewGroupWithChildren(view2) + mockRootWithResourceId(root2, "com.example:id/second_root") + + testedManager.onWindowRefreshed(root1) + testedManager.onWindowRefreshed(root2) + + // When + val id1 = testedManager.resolveViewIdentity(view1) + val id2 = testedManager.resolveViewIdentity(view2) + + // Then + assertThat(id1).isNotNull() + assertThat(id2).isNotNull() + assertThat(id1).isNotEqualTo(id2) + } + + @Test + fun `M use cached resource name W resolveViewIdentity { same resource id queried twice }`() { + // Given + val resourceName = "com.example:id/shared_button" + val viewId = resourceName.hashCode() + val mockResources: Resources = mock { + whenever(it.getResourceName(viewId)) doReturn resourceName + } + + val view1: View = mock { + whenever(it.id) doReturn viewId + whenever(it.resources) doReturn mockResources + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + } + val view2: View = mock { + whenever(it.id) doReturn viewId + whenever(it.resources) doReturn mockResources + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + } + + val mockRoot = mockViewGroupWithChildren(view1, view2) + mockActivityContext(mockRoot) + testedManager.onWindowRefreshed(mockRoot) + + // When + val id1 = testedManager.resolveViewIdentity(view1) + val id2 = testedManager.resolveViewIdentity(view2) + + // Then + assertThat(id1).isNotNull() + assertThat(id2).isNotNull() + } + + // endregion + + // region Helper Methods + + private fun mockSimpleView(viewId: Int = View.NO_ID): View { + return mock { + whenever(it.id) doReturn viewId + whenever(it.resources) doReturn null + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + } + } + + private fun mockViewWithResourceId(resourceName: String): View { + val viewId = resourceName.hashCode() + val mockResources: Resources = mock { + whenever(it.getResourceName(viewId)) doReturn resourceName + } + return mock { + whenever(it.id) doReturn viewId + whenever(it.resources) doReturn mockResources + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + } + } + + private fun mockViewGroupWithChildren(vararg children: View): ViewGroup { + val mockGroup: ViewGroup = mock { + whenever(it.id) doReturn View.NO_ID + whenever(it.resources) doReturn null + whenever(it.context) doReturn mockContext + whenever(it.parent) doReturn null + whenever(it.childCount) doReturn children.size + children.forEachIndexed { index, child -> + whenever(it.getChildAt(index)) doReturn child + whenever(child.parent) doReturn it + } + } + return mockGroup + } + + private fun mockActivityContext(view: View): Activity { + val mockActivity: Activity = mock() + whenever(view.context) doReturn mockActivity + return mockActivity + } + + private fun mockRootWithResourceId(root: ViewGroup, resourceName: String) { + val viewId = resourceName.hashCode() + val mockResources: Resources = mock { + whenever(it.getResourceName(viewId)) doReturn resourceName + } + whenever(root.id) doReturn viewId + whenever(root.resources) doReturn mockResources + } + + private fun mockWrappedActivityContext(view: View): Activity { + val mockActivity: Activity = mock() + val mockWrapper: ContextWrapper = mock { + whenever(it.baseContext) doReturn mockActivity + } + whenever(view.context) doReturn mockWrapper + return mockActivity + } + + // endregion +} diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index c91b356cc5..957e0cd5ef 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -487,6 +487,7 @@ datadog: - "java.util.Stack.constructor()" - "java.util.Stack.isNotEmpty()" - "java.util.Stack.pop()" + - "java.util.Stack.push(com.datadog.android.internal.identity.ViewIdentityResolverImpl.ViewWithCanonicalPath?)" - "java.util.Stack.push(com.datadog.android.sessionreplay.internal.recorder.Node?)" - "java.util.stream.IntStream.forEach(java.util.function.IntConsumer?)" # endregion @@ -917,8 +918,10 @@ datadog: - "kotlin.collections.MutableList.toTypedArray()" - "kotlin.collections.MutableList.withIndex()" - "kotlin.collections.MutableList.firstOrNull()" + - "kotlin.collections.MutableList.lastOrNull()" - "kotlin.collections.MutableMap.asSequence()" - "kotlin.collections.MutableMap.clear()" + - "kotlin.collections.MutableMap.containsKey(android.view.View)" - "kotlin.collections.MutableMap.containsKey(android.view.Window)" - "kotlin.collections.MutableMap.containsKey(com.datadog.android.api.SdkCore)" - "kotlin.collections.MutableMap.containsKey(com.datadog.android.api.storage.RawBatchEvent)" @@ -1192,6 +1195,7 @@ datadog: - "kotlin.String.padStart(kotlin.Int, kotlin.Char)" - "kotlin.String.plus(kotlin.Any?)" - "kotlin.String.replace(kotlin.Char, kotlin.Char, kotlin.Boolean)" + - "kotlin.String.replace(kotlin.String, kotlin.String, kotlin.Boolean)" - "kotlin.String.replace(kotlin.text.Regex, kotlin.String)" - "kotlin.String.replaceFirstChar(kotlin.Function1)" - "kotlin.String.split(kotlin.Array, kotlin.Boolean, kotlin.Int)" diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index b1a08be0db..c09f42a657 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -528,7 +528,7 @@ data class com.datadog.android.rum.model.ActionEvent fun fromJson(kotlin.String): Position fun fromJsonObject(com.google.gson.JsonObject): Position data class DdActionTarget - constructor(kotlin.String? = null, kotlin.Long? = null, kotlin.Long? = null) + constructor(kotlin.String? = null, kotlin.String? = null, kotlin.Long? = null, kotlin.Long? = null) fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): DdActionTarget 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 6ec2c5cff5..975013a240 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 @@ -1021,17 +1021,19 @@ public final class com/datadog/android/rum/model/ActionEvent$DdAction$Companion public final class com/datadog/android/rum/model/ActionEvent$DdActionTarget { public static final field Companion Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget$Companion; public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/Long; + public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/Long; - public final fun copy (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget; - public static synthetic fun copy$default (Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILjava/lang/Object;)Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget; + public final fun component4 ()Ljava/lang/Long; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget; + public static synthetic fun copy$default (Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;ILjava/lang/Object;)Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/rum/model/ActionEvent$DdActionTarget; public final fun getHeight ()Ljava/lang/Long; + public final fun getPermanentId ()Ljava/lang/String; public final fun getSelector ()Ljava/lang/String; public final fun getWidth ()Ljava/lang/Long; public fun hashCode ()I diff --git a/features/dd-sdk-android-rum/src/main/json/rum/action-schema.json b/features/dd-sdk-android-rum/src/main/json/rum/action-schema.json index cf2cc7bd70..f3a5bdca22 100644 --- a/features/dd-sdk-android-rum/src/main/json/rum/action-schema.json +++ b/features/dd-sdk-android-rum/src/main/json/rum/action-schema.json @@ -180,6 +180,11 @@ "description": "CSS selector path of the target element", "readOnly": true }, + "permanent_id": { + "type": "string", + "description": "Mobile-only: a globally unique and stable identifier for this UI element, computed as the hash of the element's path (32 lowercase hex characters). Used to correlate actions with mobile session replay wireframes.", + "readOnly": true + }, "width": { "type": "integer", "description": "Width of the target element (in pixels)", 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 0537f21fa6..1ffc57248b 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 @@ -164,7 +164,8 @@ object Rum { rumAppStartupTelemetryReporter = rumAppStartupTelemetryReporter ) }, - insightsCollector = rumFeature.insightsCollector + insightsCollector = rumFeature.insightsCollector, + viewIdentityResolver = rumFeature.viewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt index e73367e892..44e960b743 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt @@ -79,6 +79,31 @@ object RumAttributes { */ internal const val INTERNAL_ALL_THREADS: String = "_dd.error.threads" + /** + * Stable view identity for heatmap correlation (maps to schema field permanent_id). + */ + internal const val INTERNAL_ACTION_TARGET_IDENTITY: String = "_dd.action.target.permanent_id" + + /** + * Width of the action target element (in pixels). + */ + internal const val INTERNAL_ACTION_TARGET_WIDTH: String = "_dd.action.target.width" + + /** + * Height of the action target element (in pixels). + */ + internal const val INTERNAL_ACTION_TARGET_HEIGHT: String = "_dd.action.target.height" + + /** + * X coordinate of the touch position relative to the target element (in pixels). + */ + internal const val INTERNAL_ACTION_POSITION_X: String = "_dd.action.position.x" + + /** + * Y coordinate of the touch position relative to the target element (in pixels). + */ + internal const val INTERNAL_ACTION_POSITION_Y: String = "_dd.action.position.y" + // endregion // region Resource 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 1f7db5dd47..16013068d0 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 @@ -34,6 +34,9 @@ import com.datadog.android.event.EventMapper import com.datadog.android.event.MapperSerializer import com.datadog.android.event.NoOpEventMapper import com.datadog.android.internal.flags.RumFlagEvaluationMessage +import com.datadog.android.internal.identity.NoOpViewIdentityResolver +import com.datadog.android.internal.identity.ViewIdentityResolver +import com.datadog.android.internal.identity.ViewIdentityResolverImpl import com.datadog.android.internal.system.BuildSdkVersionProvider import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.GlobalRumMonitor @@ -175,6 +178,7 @@ internal class RumFeature( internal var displayInfoProvider: InfoProvider = NoOpDisplayInfoProvider() internal val rumContextUpdateReceivers = mutableSetOf() internal var insightsCollector: InsightsCollector = NoOpInsightsCollector() + internal var viewIdentityResolver: ViewIdentityResolver = NoOpViewIdentityResolver() private val lateCrashEventHandler by lazy { lateCrashReporterFactory(sdkCore as InternalSdkCore) } private var rumAppStartupDetector: RumAppStartupDetector? = null @@ -219,6 +223,12 @@ internal class RumFeature( telemetryConfigurationSampleRate = configuration.telemetryConfigurationSampleRate backgroundEventTracking = configuration.backgroundEventTracking trackFrustrations = configuration.trackFrustrations + viewIdentityResolver = ViewIdentityResolverImpl(appContext.packageName) + // Store in feature context for cross-feature access (e.g., Session Replay) + sdkCore.updateFeatureContext(name) { context -> + context[ViewIdentityResolver.FEATURE_CONTEXT_KEY] = viewIdentityResolver + } + batteryInfoProvider = DefaultBatteryInfoProvider( applicationContext = appContext, timeProvider = sdkCore.timeProvider @@ -235,7 +245,8 @@ internal class RumFeature( configuration.interactionPredicate, composeActionTrackingStrategy = configuration.composeActionTrackingStrategy, buildSdkVersionProvider, - sdkCore.internalLogger + sdkCore.internalLogger, + viewIdentityResolver ) } else { NoOpUserActionTrackingStrategy() @@ -351,6 +362,7 @@ internal class RumFeature( anrDetectorRunnable?.stop() vitalExecutorService = NoOpScheduledExecutorService() sessionListener = NoOpRumSessionListener() + viewIdentityResolver = NoOpViewIdentityResolver() cleanupInfoProviders() @@ -855,14 +867,16 @@ internal class RumFeature( interactionPredicate: InteractionPredicate, composeActionTrackingStrategy: ActionTrackingStrategy, buildSdkVersionProvider: BuildSdkVersionProvider, - internalLogger: InternalLogger + internalLogger: InternalLogger, + viewIdentityResolver: ViewIdentityResolver ): UserActionTrackingStrategy { val gesturesTracker = provideGestureTracker( customProviders = touchTargetExtraAttributesProviders, interactionPredicate = interactionPredicate, composeActionTrackingStrategy = composeActionTrackingStrategy, - internalLogger = internalLogger + internalLogger = internalLogger, + viewIdentityResolver = viewIdentityResolver ) return if (buildSdkVersionProvider.isAtLeastQ) { UserActionTrackingStrategyApi29(gesturesTracker) @@ -875,7 +889,8 @@ internal class RumFeature( customProviders: Array, interactionPredicate: InteractionPredicate, composeActionTrackingStrategy: ActionTrackingStrategy, - internalLogger: InternalLogger + internalLogger: InternalLogger, + viewIdentityResolver: ViewIdentityResolver ): DatadogGesturesTracker { val defaultProviders = arrayOf(JetpackViewAttributesProvider()) val providers = customProviders + defaultProviders @@ -883,7 +898,8 @@ internal class RumFeature( providers, interactionPredicate, composeActionsTrackingStrategy = composeActionTrackingStrategy, - internalLogger + internalLogger, + viewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt index ea867f6cc9..6f53fd0f8f 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt @@ -322,7 +322,12 @@ internal class RumEventSerializer( RumAttributes.INTERNAL_TIMESTAMP, RumAttributes.INTERNAL_ERROR_TYPE, RumAttributes.INTERNAL_ERROR_SOURCE_TYPE, - RumAttributes.INTERNAL_ERROR_IS_CRASH + RumAttributes.INTERNAL_ERROR_IS_CRASH, + RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY, + RumAttributes.INTERNAL_ACTION_POSITION_X, + RumAttributes.INTERNAL_ACTION_POSITION_Y, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH, + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT ) // this are attributes which may come after the calls made by cross-platform SDKs (they are diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt index a049b8a634..fb0ed409f7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScope.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.rum.RumActionType +import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver import com.datadog.android.rum.internal.domain.RumContext @@ -28,7 +29,7 @@ import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.math.max -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") internal class RumActionScope( override val parentScope: RumScope, private val sdkCore: InternalSdkCore, @@ -71,8 +72,6 @@ internal class RumActionScope( private var sent = false internal var stopped = false - // endregion - @WorkerThread override fun handleEvent( event: RumRawEvent, @@ -270,6 +269,12 @@ internal class RumActionScope( frustrations.add(ActionEvent.Type.ERROR_TAP) } + val viewIdentity = actionAttributes[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] as? String + val positionX = actionAttributes[RumAttributes.INTERNAL_ACTION_POSITION_X] as? Long + val positionY = actionAttributes[RumAttributes.INTERNAL_ACTION_POSITION_Y] as? Long + val targetWidth = actionAttributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] as? Long + val targetHeight = actionAttributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] as? Long + sdkCore.newRumEventWriteOperation(datadogContext, writeScope, writer) { val user = datadogContext.userInfo val hasReplay = featuresContextResolver.resolveViewHasReplay( @@ -351,7 +356,19 @@ internal class RumActionScope( session = ActionEvent.DdSession( sessionPrecondition = rumContext.sessionStartReason.toActionSessionPrecondition() ), - configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate) + configuration = ActionEvent.Configuration(sessionSampleRate = sampleRate), + action = ActionEvent.DdAction( + position = positionX?.let { x -> + positionY?.let { y -> + ActionEvent.Position(x = x, y = y) + } + }, + target = ActionEvent.DdActionTarget( + permanentId = viewIdentity, + width = targetWidth, + height = targetHeight + ) + ) ), connectivity = networkInfo.toActionConnectivity(), service = datadogContext.service, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt index 00fa888c30..0d1d261616 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScope.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumSessionListener @@ -54,7 +55,8 @@ internal class RumApplicationScope( private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val viewIdentityResolver: ViewIdentityResolver ) : RumScope, RumViewChangedListener { override val parentScope: RumScope? = null @@ -84,7 +86,8 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) ) @@ -206,7 +209,8 @@ internal class RumApplicationScope( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) childScopes.add(newSession) if (event !is RumRawEvent.StartView) { diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index f308397e7e..1fa5fbcd4c 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.profiling.ProfilerStopEvent import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.RumSessionType @@ -61,7 +62,8 @@ internal class RumSessionScope( private val sessionMaxDurationNanos: Long = DEFAULT_SESSION_MAX_DURATION_NS, rumSessionTypeOverride: RumSessionType?, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + viewIdentityResolver: ViewIdentityResolver ) : RumScope { internal var sessionId = RumContext.NULL_UUID @@ -99,7 +101,8 @@ internal class RumSessionScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) internal val activeView: RumViewScope? diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt index 179b1c885b..5965e79c97 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -15,6 +15,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.metrics.MethodCallSamplingRate +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.RumSessionType @@ -60,7 +61,8 @@ internal class RumViewManagerScope( private val accessibilitySnapshotManager: AccessibilitySnapshotManager, private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val viewIdentityResolver: ViewIdentityResolver ) : RumScope { private val interactionToNextViewMetricResolver: InteractionToNextViewMetricResolver = @@ -288,7 +290,8 @@ internal class RumViewManagerScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) applicationDisplayed = true childrenScopes.add(viewScope) @@ -372,7 +375,8 @@ internal class RumViewManagerScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) } @@ -415,7 +419,8 @@ internal class RumViewManagerScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index 68ea62ba1e..f9ed584f86 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -18,6 +18,7 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.internal.attributes.LocalAttribute import com.datadog.android.internal.attributes.ViewScopeInstrumentationType +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumActionType @@ -89,7 +90,8 @@ internal open class RumViewScope( private val accessibilitySnapshotManager: AccessibilitySnapshotManager, private val batteryInfoProvider: InfoProvider, private val displayInfoProvider: InfoProvider, - private val insightsCollector: InsightsCollector + private val insightsCollector: InsightsCollector, + private val viewIdentityResolver: ViewIdentityResolver ) : RumScope { internal val url = key.url.replace('.', '/') @@ -176,6 +178,8 @@ internal open class RumViewScope( memoryVitalMonitor.register(memoryVitalListener) frameRateVitalMonitor.register(frameRateVitalListener) + viewIdentityResolver.setCurrentScreen(url) + val rumContext = parentScope.getRumContext() if (rumContext.syntheticsTestId != null) { logSynthetics("_dd.application.id", rumContext.applicationId) @@ -470,7 +474,8 @@ internal open class RumViewScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) } @@ -1654,7 +1659,8 @@ internal open class RumViewScope( accessibilitySnapshotManager: AccessibilitySnapshotManager, batteryInfoProvider: InfoProvider, displayInfoProvider: InfoProvider, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + viewIdentityResolver: ViewIdentityResolver ): RumViewScope { val networkSettledMetricResolver = NetworkSettledMetricResolver( networkSettledResourceIdentifier, @@ -1691,7 +1697,8 @@ internal open class RumViewScope( accessibilitySnapshotManager = accessibilitySnapshotManager, batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt index 8b4fecb2fd..da11344c64 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTracker.kt @@ -10,6 +10,7 @@ import android.content.Context import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.api.SdkCore +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate import com.datadog.android.rum.tracking.ViewAttributesProvider @@ -19,7 +20,8 @@ internal class DatadogGesturesTracker( internal val targetAttributesProviders: Array, internal val interactionPredicate: InteractionPredicate, private val composeActionsTrackingStrategy: ActionTrackingStrategy, - private val internalLogger: InternalLogger + private val internalLogger: InternalLogger, + private val viewIdentityResolver: ViewIdentityResolver ) : GesturesTracker { // region GesturesTracker @@ -109,7 +111,8 @@ internal class DatadogGesturesTracker( interactionPredicate = interactionPredicate, contextRef = WeakReference(context), composeActionTrackingStrategy = composeActionsTrackingStrategy, - internalLogger = internalLogger + internalLogger = internalLogger, + viewIdentityResolver = viewIdentityResolver ) ) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt index 34dc2e2a1f..b8e63c5d5c 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListener.kt @@ -14,6 +14,7 @@ import android.view.Window import androidx.core.view.isVisible import com.datadog.android.api.InternalLogger import com.datadog.android.api.SdkCore +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes @@ -37,7 +38,8 @@ internal class GesturesListener( private val contextRef: Reference, private val internalLogger: InternalLogger, private val composeActionTrackingStrategy: ActionTrackingStrategy = NoOpActionTrackingStrategy(), - private val androidActionTrackingStrategy: ActionTrackingStrategy = AndroidActionTrackingStrategy() + private val androidActionTrackingStrategy: ActionTrackingStrategy = AndroidActionTrackingStrategy(), + private val viewIdentityResolver: ViewIdentityResolver ) : GestureListenerCompat() { private var scrollEventType: RumActionType? = null @@ -226,7 +228,7 @@ internal class GesturesListener( onUpEvent.y ) downTarget?.takeIf { it == upTarget }?.let { target -> - sendTapEventWithTarget(target) + sendTapEventWithTarget(target, onUpEvent.x, onUpEvent.y) } } } @@ -257,19 +259,26 @@ internal class GesturesListener( private fun handleTapUp(decorView: View?, e: MotionEvent) { if (decorView != null) { findTarget(decorView, e.x, e.y)?.let { target -> - sendTapEventWithTarget(target) + sendTapEventWithTarget(target, e.x, e.y) } } } - private fun sendTapEventWithTarget(target: ViewTarget) { + private fun sendTapEventWithTarget(target: ViewTarget, touchX: Float = 0f, touchY: Float = 0f) { val attributes = mutableMapOf() target.viewRef.get()?.let { view -> - val targetId: String = contextRef.get().resourceIdName(view.id) - attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] = view.targetClassName() - attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] = targetId - attributesProviders.forEach { - it.extractAttributes(view, attributes) + addViewAttributes(view, attributes) + + if (view.isAttachedToWindow) { + val locationOnScreen = IntArray(2) + @Suppress("UnsafeThirdPartyFunctionCall") // locationOnScreen is non-null with exactly 2 elements + view.getLocationOnScreen(locationOnScreen) + + val relativeX = (touchX - locationOnScreen[0]).toLong() + val relativeY = (touchY - locationOnScreen[1]).toLong() + + attributes[RumAttributes.INTERNAL_ACTION_POSITION_X] = relativeX + attributes[RumAttributes.INTERNAL_ACTION_POSITION_Y] = relativeY } } target.node?.let { @@ -288,12 +297,7 @@ internal class GesturesListener( ): MutableMap { val attributes = mutableMapOf() scrollTarget.viewRef.get()?.let { view -> - val targetId: String = contextRef.get().resourceIdName(view.id) - attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] = view.targetClassName() - attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] = targetId - attributesProviders.forEach { - it.extractAttributes(view, attributes) - } + addViewAttributes(view, attributes) } scrollTarget.node?.let { attributes.putAll(it.customAttributes) @@ -305,6 +309,20 @@ internal class GesturesListener( return attributes } + private fun addViewAttributes(view: View, attributes: MutableMap) { + val targetId: String = contextRef.get().resourceIdName(view.id) + attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] = view.targetClassName() + attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] = targetId + attributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] = view.width.toLong() + attributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] = view.height.toLong() + viewIdentityResolver.resolveViewIdentity(view)?.let { viewIdentity -> + attributes[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] = viewIdentity + } + attributesProviders.forEach { + it.extractAttributes(view, attributes) + } + } + private fun resolveGestureDirection(endEvent: MotionEvent): String { val diffX = endEvent.x - onTouchDownXPos val diffY = endEvent.y - onTouchDownYPos diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index b83046ff8e..c2e3bcfefe 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -23,6 +23,7 @@ import com.datadog.android.core.internal.utils.executeSafe import com.datadog.android.core.internal.utils.getSafe import com.datadog.android.core.internal.utils.submitSafe import com.datadog.android.core.metrics.MethodCallSamplingRate +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage.AddOperationStepVital.ActionType import com.datadog.android.internal.thread.NamedCallable @@ -99,7 +100,8 @@ internal class DatadogRumMonitor( batteryInfoProvider: InfoProvider, displayInfoProvider: InfoProvider, private val rumSessionScopeStartupManagerFactory: () -> RumSessionScopeStartupManager, - insightsCollector: InsightsCollector + insightsCollector: InsightsCollector, + viewIdentityResolver: ViewIdentityResolver ) : RumMonitor, AdvancedRumMonitor { internal var rootScope = RumApplicationScope( @@ -122,7 +124,8 @@ internal class DatadogRumMonitor( batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionScopeStartupManagerFactory = rumSessionScopeStartupManagerFactory, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) internal val keepAliveRunnable = Runnable { @@ -202,14 +205,14 @@ internal class DatadogRumMonitor( override fun addAction(type: RumActionType, name: String, attributes: Map) { val eventTime = getEventTime(attributes) handleEvent( - RumRawEvent.StartAction(type, name, false, attributes.toMap(), eventTime) + RumRawEvent.StartAction(type, name, false, attributes, eventTime) ) } override fun startAction(type: RumActionType, name: String, attributes: Map) { val eventTime = getEventTime(attributes) handleEvent( - RumRawEvent.StartAction(type, name, true, attributes.toMap(), eventTime) + RumRawEvent.StartAction(type, name, true, attributes, eventTime) ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt index 5ba5e4329c..8e247b576c 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/assertj/ActionEventAssert.kt @@ -541,6 +541,56 @@ internal class ActionEventAssert(actual: ActionEvent) : return this } + fun hasPermanentId(expected: String?): ActionEventAssert { + assertThat(actual.dd.action?.target?.permanentId) + .overridingErrorMessage( + "Expected event data to have dd.action.target.permanent_id $expected " + + "but was ${actual.dd.action?.target?.permanentId}" + ) + .isEqualTo(expected) + return this + } + + fun hasTargetWidth(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.target?.width) + .overridingErrorMessage( + "Expected event data to have dd.action.target.width $expected " + + "but was ${actual.dd.action?.target?.width}" + ) + .isEqualTo(expected) + return this + } + + fun hasTargetHeight(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.target?.height) + .overridingErrorMessage( + "Expected event data to have dd.action.target.height $expected " + + "but was ${actual.dd.action?.target?.height}" + ) + .isEqualTo(expected) + return this + } + + fun hasPositionX(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.position?.x) + .overridingErrorMessage( + "Expected event data to have dd.action.position.x $expected " + + "but was ${actual.dd.action?.position?.x}" + ) + .isEqualTo(expected) + return this + } + + fun hasPositionY(expected: Long?): ActionEventAssert { + assertThat(actual.dd.action?.position?.y) + .overridingErrorMessage( + "Expected event data to have dd.action.position.y $expected " + + "but was ${actual.dd.action?.position?.y}" + ) + .isEqualTo(expected) + return this + } + companion object { internal const val TIMESTAMP_THRESHOLD_MS = 50L internal fun assertThat(actual: ActionEvent): ActionEventAssert = diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt index c300358b40..0a34a9e083 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumActionScopeTest.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.rum.RumActionType +import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumResourceKind import com.datadog.android.rum.RumResourceMethod @@ -2934,7 +2935,7 @@ internal class RumActionScopeTest { name, false, emptyMap(), - timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) + eventTime = timeWithOffset(TEST_INACTIVITY_MS * 2 + 1) ) val result2 = testedScope.handleEvent(fakeEvent, fakeDatadogContext, mockEventWriteScope, mockWriter) @@ -3046,6 +3047,110 @@ internal class RumActionScopeTest { ) } + @Test + fun `M populate heatmap fields W sendAction() {viewIdentity present}`( + @StringForgery fakeViewIdentity: String, + @LongForgery fakeWidth: Long, + @LongForgery fakeHeight: Long, + @LongForgery fakePosX: Long, + @LongForgery fakePosY: Long + ) { + // Given + val attributesWithDimensions = fakeAttributes + + (RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY to fakeViewIdentity) + + (RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to fakeWidth) + + (RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to fakeHeight) + + (RumAttributes.INTERNAL_ACTION_POSITION_X to fakePosX) + + (RumAttributes.INTERNAL_ACTION_POSITION_Y to fakePosY) + testedScope = RumActionScope( + parentScope = mockParentScope, + sdkCore = rumMonitor.mockSdkCore, + waitForStop = false, + eventTime = fakeEventTime, + initialType = fakeType, + initialName = fakeName, + initialAttributes = attributesWithDimensions, + serverTimeOffsetInMs = fakeServerOffset, + inactivityThresholdMs = TEST_INACTIVITY_MS, + maxDurationMs = TEST_MAX_DURATION_MS, + featuresContextResolver = mockFeaturesContextResolver, + trackFrustrations = true, + sampleRate = fakeSampleRate, + rumSessionTypeOverride = fakeRumSessionType, + insightsCollector = mockInsightsCollector + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue) + .hasPermanentId(fakeViewIdentity) + .hasTargetWidth(fakeWidth) + .hasTargetHeight(fakeHeight) + .hasPositionX(fakePosX) + .hasPositionY(fakePosY) + } + } + + @Test + fun `M have null viewIdentity W sendAction() {viewIdentity not present}`( + @LongForgery fakeWidth: Long, + @LongForgery fakeHeight: Long, + @LongForgery fakePosX: Long, + @LongForgery fakePosY: Long + ) { + // Given + val attributesWithDimensions = fakeAttributes + + (RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to fakeWidth) + + (RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to fakeHeight) + + (RumAttributes.INTERNAL_ACTION_POSITION_X to fakePosX) + + (RumAttributes.INTERNAL_ACTION_POSITION_Y to fakePosY) + testedScope = RumActionScope( + parentScope = mockParentScope, + sdkCore = rumMonitor.mockSdkCore, + waitForStop = false, + eventTime = fakeEventTime, + initialType = fakeType, + initialName = fakeName, + initialAttributes = attributesWithDimensions, + serverTimeOffsetInMs = fakeServerOffset, + inactivityThresholdMs = TEST_INACTIVITY_MS, + maxDurationMs = TEST_MAX_DURATION_MS, + featuresContextResolver = mockFeaturesContextResolver, + trackFrustrations = true, + sampleRate = fakeSampleRate, + rumSessionTypeOverride = fakeRumSessionType, + insightsCollector = mockInsightsCollector + ) + + // When + testedScope.handleEvent( + mockEvent(TEST_INACTIVITY_MS + 1), + fakeDatadogContext, + mockEventWriteScope, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue) + .hasPermanentId(null) + .hasTargetWidth(fakeWidth) + .hasTargetHeight(fakeHeight) + .hasPositionX(fakePosX) + .hasPositionY(fakePosY) + } + } + // region Internal private fun mockEvent(timeOffset: Long = 0L): RumRawEvent { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeAttributePropagationTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeAttributePropagationTest.kt index 85f5dc45fe..4684428fd4 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeAttributePropagationTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeAttributePropagationTest.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.feature.EventWriteScope import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver @@ -129,6 +130,9 @@ internal class RumApplicationScopeAttributePropagationTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockSlowFramesListener: SlowFramesListener @@ -213,7 +217,8 @@ internal class RumApplicationScopeAttributePropagationTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt index 8c82526a3d..7a53668673 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumApplicationScopeTest.kt @@ -16,6 +16,7 @@ import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumSessionListener @@ -105,6 +106,9 @@ internal class RumApplicationScopeTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockFrameRateVitalMonitor: VitalMonitor @@ -189,7 +193,8 @@ internal class RumApplicationScopeTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeAttributePropagationTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeAttributePropagationTest.kt index d84a972e05..8f76e56bee 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeAttributePropagationTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeAttributePropagationTest.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver @@ -110,6 +111,9 @@ internal class RumSessionScopeAttributePropagationTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockRumFeatureScope: FeatureScope @@ -188,7 +192,8 @@ internal class RumSessionScopeAttributePropagationTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index e45020987e..5d5841ce06 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -18,6 +18,7 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.profiling.ProfilerStopEvent import com.datadog.android.internal.tests.stub.StubTimeProvider import com.datadog.android.rum.RumSessionListener @@ -125,6 +126,9 @@ internal class RumSessionScopeTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockSessionListener: RumSessionListener @@ -1707,7 +1711,8 @@ internal class RumSessionScopeTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = { mockRumSessionScopeStartupManager }, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) if (withMockChildScope) { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeAttributePropagationTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeAttributePropagationTest.kt index 41ff05e093..8ff0dc0629 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeAttributePropagationTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeAttributePropagationTest.kt @@ -14,6 +14,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.RumSessionType import com.datadog.android.rum.internal.FeaturesContextResolver import com.datadog.android.rum.internal.domain.InfoProvider @@ -148,6 +149,9 @@ internal class RumViewManagerScopeAttributePropagationTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @BeforeEach fun `set up`(forge: Forge) { fakeParentAttributes = forge.exhaustiveAttributes() @@ -176,7 +180,8 @@ internal class RumViewManagerScopeAttributePropagationTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionTypeOverride = fakeRumSessionType, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt index 96829e0819..ff24be6212 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt @@ -15,6 +15,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.RumErrorSource @@ -147,6 +148,9 @@ internal class RumViewManagerScopeTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @BoolForgery var fakeTrackFrustrations: Boolean = true @@ -189,7 +193,8 @@ internal class RumViewManagerScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) } @@ -583,7 +588,8 @@ internal class RumViewManagerScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedScope.applicationDisplayed = true val fakeEvent = forge.validBackgroundEvent() @@ -620,7 +626,8 @@ internal class RumViewManagerScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedScope.childrenScopes.add(mockChildScope) whenever(mockChildScope.isActive()) doReturn true @@ -660,7 +667,8 @@ internal class RumViewManagerScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedScope.applicationDisplayed = true val fakeEvent = forge.validBackgroundEvent() @@ -733,7 +741,8 @@ internal class RumViewManagerScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedScope.childrenScopes.add(mockChildScope) whenever(mockChildScope.isActive()) doReturn true @@ -774,7 +783,8 @@ internal class RumViewManagerScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedScope.stopped = true val fakeEvent = forge.applicationStartedEvent() diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeAttributePropagationTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeAttributePropagationTest.kt index d9f8cb8119..4b1aeae333 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeAttributePropagationTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeAttributePropagationTest.kt @@ -18,10 +18,10 @@ import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumSessionType -import com.datadog.android.rum.assertj.ActionEventAssert.Companion.assertThat import com.datadog.android.rum.assertj.ErrorEventAssert.Companion.assertThat import com.datadog.android.rum.assertj.LongTaskEventAssert.Companion.assertThat import com.datadog.android.rum.assertj.ViewEventAssert.Companion.assertThat @@ -111,6 +111,9 @@ internal class RumViewScopeAttributePropagationTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockResolver: FirstPartyHostHeaderTypeResolver @@ -652,7 +655,8 @@ internal class RumViewScopeAttributePropagationTest { accessibilitySnapshotManager: AccessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider: InfoProvider = mockBatteryInfoProvider, displayInfoProvider: InfoProvider = mockDisplayInfoProvider, - insightsCollector: InsightsCollector = mockInsightsCollector + insightsCollector: InsightsCollector = mockInsightsCollector, + viewIdentityResolver: ViewIdentityResolver = mockViewIdentityResolver ) = RumViewScope( parentScope = parentScope, sdkCore = sdkCore, @@ -677,7 +681,8 @@ internal class RumViewScopeAttributePropagationTest { batteryInfoProvider = batteryInfoProvider, displayInfoProvider = displayInfoProvider, rumSessionTypeOverride = rumSessionType, - insightsCollector = insightsCollector + insightsCollector = insightsCollector, + viewIdentityResolver = viewIdentityResolver ) // endregion diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 92a34301af..1d8ab94261 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -18,6 +18,7 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumActionType @@ -173,6 +174,9 @@ internal class RumViewScopeTest { @Mock private lateinit var mockInsightsCollector: InsightsCollector + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockMemoryVitalMonitor: VitalMonitor @@ -9186,7 +9190,8 @@ internal class RumViewScopeTest { accessibilitySnapshotManager = mockAccessibilitySnapshotManager, batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) data class RumRawEventData(val event: RumRawEvent, val viewKey: RumScopeKey) diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt index d23c139ea3..2f69ab6ff4 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/AbstractGesturesListenerTest.kt @@ -14,6 +14,7 @@ import android.view.View import android.view.Window import com.datadog.android.Datadog import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.rum.utils.forge.Configurator import com.datadog.tools.unit.annotations.TestConfigurationsProvider @@ -62,6 +63,9 @@ internal abstract class AbstractGesturesListenerTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + // region Tests @BeforeEach @@ -116,6 +120,7 @@ internal abstract class AbstractGesturesListenerTest { whenever(it.id).thenReturn(id) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) + whenever(it.isAttachedToWindow).thenReturn(true) whenever(it.getLocationInWindow(any())).doAnswer { val array = it.arguments[0] as IntArray @@ -124,6 +129,13 @@ internal abstract class AbstractGesturesListenerTest { null } + whenever(it.getLocationOnScreen(any())).doAnswer { + val array = it.arguments[0] as IntArray + array[0] = locationOnScreenArray[0] + array[1] = locationOnScreenArray[1] + null + } + val diffPosX = abs(forEvent.x - locationOnScreenArray[0]).toInt() val diffPosY = abs(forEvent.y - locationOnScreenArray[1]).toInt() if (!hitTest && failHitTestBecauseOfWidthHeight) { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt index 008823d453..6242f19f53 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/DatadogGesturesTrackerTest.kt @@ -11,6 +11,7 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.rum.internal.tracking.NoOpInteractionPredicate import com.datadog.android.rum.tracking.ActionTrackingStrategy import com.datadog.android.rum.tracking.InteractionPredicate @@ -65,6 +66,9 @@ internal class DatadogGesturesTrackerTest : ObjectTest() @Mock lateinit var mockSdkCore: FeatureSdkCore + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @BeforeEach fun `set up`() { testedTracker = @@ -72,7 +76,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() emptyArray(), mockInteractionPredicate, mockActionTrackingStrategy, - mockInternalLogger + mockInternalLogger, + mockViewIdentityResolver ) whenever(mockActivity.window).thenReturn(mockWindow) whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger @@ -83,7 +88,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() forge.aList { StubViewAttributesProvider(anAlphabeticalString()) }.toTypedArray(), NoOpInteractionPredicate(), NoOpActionTrackingStrategy(), - mockInternalLogger + mockInternalLogger, + mockViewIdentityResolver ) } @@ -98,7 +104,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() }.toTypedArray(), NoOpInteractionPredicate(), NoOpActionTrackingStrategy(), - mockInternalLogger + mockInternalLogger, + mockViewIdentityResolver ) } @@ -113,7 +120,8 @@ internal class DatadogGesturesTrackerTest : ObjectTest() }.toTypedArray(), StubInteractionPredicate(), mockActionTrackingStrategy, - mockInternalLogger + mockInternalLogger, + mockViewIdentityResolver ) } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt index 0d29169607..4fc2245ef9 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerScrollSwipeTest.kt @@ -36,6 +36,7 @@ import org.junit.jupiter.params.provider.ValueSource 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.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -100,7 +101,9 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes = expectedStartAttributes + (RumAttributes.ACTION_GESTURE_DIRECTION to expectedDirection) @@ -108,7 +111,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -171,7 +175,9 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes = expectedStartAttributes + (RumAttributes.ACTION_GESTURE_DIRECTION to expectedDirection) @@ -179,7 +185,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -234,11 +241,15 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes1 = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStartAttributes2 = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes1 = expectedStartAttributes1 + (RumAttributes.ACTION_GESTURE_DIRECTION to expectedDirection1) @@ -249,7 +260,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -322,7 +334,9 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes1 = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes1 = expectedStartAttributes1 + @@ -332,7 +346,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -399,13 +414,16 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(nonScrollingTarget, expectedResourceName) val expectedStartAttributes = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to nonScrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to nonScrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to nonScrollingTarget.height.toLong() ) testedListener = GesturesListener( rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -416,8 +434,23 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() testedListener.onUp(endUpEvent) // Then - verify(rumMonitor.mockInstance) - .addAction(RumActionType.TAP, "", expectedStartAttributes) + verify(rumMonitor.mockInstance).addAction( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedStartAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedStartAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + val widthMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] == + expectedStartAttributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] + val heightMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] == + expectedStartAttributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] + classMatches && resourceMatches && widthMatches && heightMatches && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_X) && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_Y) + } + ) verifyNoMoreInteractions(rumMonitor.mockInstance) } @@ -452,7 +485,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -505,7 +539,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -568,7 +603,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -634,7 +670,9 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes = expectedStartAttributes + (RumAttributes.ACTION_GESTURE_DIRECTION to expectedDirection) @@ -642,7 +680,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -712,7 +751,9 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.simpleName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes = expectedStartAttributes + (RumAttributes.ACTION_GESTURE_DIRECTION to expectedDirection) @@ -720,7 +761,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -780,7 +822,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -843,7 +886,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -906,7 +950,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -969,11 +1014,15 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() mockResourcesForTarget(scrollingTarget, expectedResourceName) val expectedStartAttributes1 = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStartAttributes2 = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to scrollingTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to scrollingTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to scrollingTarget.height.toLong() ) val expectedStopAttributes1 = expectedStartAttributes1 + (RumAttributes.ACTION_GESTURE_DIRECTION to expectedDirection1) @@ -983,7 +1032,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -1043,7 +1093,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) testedListener.onUp(startDownEvent) testedListener.onDown(endUpEvent) @@ -1088,7 +1139,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() contextRef = WeakReference(mockAppContext), androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -1165,7 +1217,8 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + viewIdentityResolver = mockViewIdentityResolver ) stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) whenever( @@ -1205,6 +1258,242 @@ internal class GesturesListenerScrollSwipeTest : AbstractGesturesListenerTest() verifyNoMoreInteractions(rumMonitor.mockInstance) } + // region View Identity Tests + + @Test + fun `M include view identity W onScroll() { viewIdentityResolver returns value }`( + forge: Forge + ) { + // Given + val startDownEvent: MotionEvent = forge.getForgery() + val scrollEvent: MotionEvent = forge.getForgery() + val endUpEvent: MotionEvent = forge.getForgery() + val fakeViewIdentity = forge.anAlphabeticalString(size = 32) + val expectedDirection = GesturesListener.SCROLL_DIRECTION_DOWN + stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) + + val scrollingTarget: ScrollableView = mockView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(scrollingTarget) + } + whenever(mockViewIdentityResolver.resolveViewIdentity(scrollingTarget)).thenReturn(fakeViewIdentity) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onDown(startDownEvent) + testedListener.onScroll(startDownEvent, scrollEvent, forge.aFloat(), forge.aFloat()) + testedListener.onUp(endUpEvent) + + // Then + verify(rumMonitor.mockInstance).startAction( + eq(RumActionType.SCROLL), + any(), + argThat { + this[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] == fakeViewIdentity + } + ) + verify(rumMonitor.mockInstance).stopAction( + eq(RumActionType.SCROLL), + any(), + argThat { + this[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] == fakeViewIdentity + } + ) + } + + @Test + fun `M not include view identity W onScroll() { viewIdentityResolver returns null }`( + forge: Forge + ) { + // Given + val startDownEvent: MotionEvent = forge.getForgery() + val scrollEvent: MotionEvent = forge.getForgery() + val endUpEvent: MotionEvent = forge.getForgery() + val expectedDirection = GesturesListener.SCROLL_DIRECTION_DOWN + stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) + + val scrollingTarget: ScrollableView = mockView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(scrollingTarget) + } + whenever(mockViewIdentityResolver.resolveViewIdentity(scrollingTarget)).thenReturn(null) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onDown(startDownEvent) + testedListener.onScroll(startDownEvent, scrollEvent, forge.aFloat(), forge.aFloat()) + testedListener.onUp(endUpEvent) + + // Then + verify(rumMonitor.mockInstance).startAction( + eq(RumActionType.SCROLL), + any(), + argThat { + !this.containsKey(RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY) + } + ) + verify(rumMonitor.mockInstance).stopAction( + eq(RumActionType.SCROLL), + any(), + argThat { + !this.containsKey(RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY) + } + ) + } + + @Test + fun `M include view identity W onSwipe() { viewIdentityResolver returns value }`( + forge: Forge + ) { + // Given + val startDownEvent: MotionEvent = forge.getForgery() + val scrollEvent: MotionEvent = forge.getForgery() + val endUpEvent: MotionEvent = forge.getForgery() + val fakeViewIdentity = forge.anAlphabeticalString(size = 32) + val expectedDirection = GesturesListener.SCROLL_DIRECTION_DOWN + stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) + + val scrollingTarget: ScrollableView = mockView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(scrollingTarget) + } + whenever(mockViewIdentityResolver.resolveViewIdentity(scrollingTarget)).thenReturn(fakeViewIdentity) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onDown(startDownEvent) + testedListener.onScroll(startDownEvent, scrollEvent, forge.aFloat(), forge.aFloat()) + testedListener.onFling(startDownEvent, endUpEvent, forge.aFloat(), forge.aFloat()) + testedListener.onUp(endUpEvent) + + // Then + verify(rumMonitor.mockInstance).startAction( + eq(RumActionType.SCROLL), + any(), + argThat { + this[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] == fakeViewIdentity + } + ) + verify(rumMonitor.mockInstance).stopAction( + eq(RumActionType.SWIPE), + any(), + argThat { + this[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] == fakeViewIdentity + } + ) + } + + @Test + fun `M not include view identity W onSwipe() { viewIdentityResolver returns null }`( + forge: Forge + ) { + // Given + val startDownEvent: MotionEvent = forge.getForgery() + val scrollEvent: MotionEvent = forge.getForgery() + val endUpEvent: MotionEvent = forge.getForgery() + val expectedDirection = GesturesListener.SCROLL_DIRECTION_DOWN + stubStopMotionEvent(endUpEvent, startDownEvent, expectedDirection) + + val scrollingTarget: ScrollableView = mockView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = startDownEvent, + hitTest = true, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(scrollingTarget) + } + whenever(mockViewIdentityResolver.resolveViewIdentity(scrollingTarget)).thenReturn(null) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onDown(startDownEvent) + testedListener.onScroll(startDownEvent, scrollEvent, forge.aFloat(), forge.aFloat()) + testedListener.onFling(startDownEvent, endUpEvent, forge.aFloat(), forge.aFloat()) + testedListener.onUp(endUpEvent) + + // Then + verify(rumMonitor.mockInstance).startAction( + eq(RumActionType.SCROLL), + any(), + argThat { + !this.containsKey(RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY) + } + ) + verify(rumMonitor.mockInstance).stopAction( + eq(RumActionType.SWIPE), + any(), + argThat { + !this.containsKey(RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY) + } + ) + } + + // endregion + // endregion // region internal diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt index aef4cb1ad5..028d989256 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/instrumentation/gestures/GesturesListenerTapTest.kt @@ -36,6 +36,7 @@ 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.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -60,7 +61,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -130,7 +132,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -170,7 +173,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -215,7 +219,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -259,7 +264,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -296,7 +302,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -324,7 +331,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -372,7 +380,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -404,7 +413,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) val expectedResourceName = forge.anAlphabeticalString() mockResourcesForTarget(mockDecorView, expectedResourceName) @@ -446,7 +456,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -486,7 +497,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(mockWindow), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -508,7 +520,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { rumMonitor.mockSdkCore, WeakReference(null), contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -542,7 +555,9 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { mockResourcesForTarget(validTarget, expectedResourceName) var expectedAttributes: MutableMap = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to validTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to validTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to validTarget.height.toLong() ) val providers = Array(forge.anInt(min = 0, max = 10)) { mock { @@ -561,16 +576,29 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), providers, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When testedListener.onSingleTapUp(mockEvent) // Then verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + val widthMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] + val heightMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] + classMatches && resourceMatches && widthMatches && heightMatches && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_X) && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_Y) + } ) } @@ -605,7 +633,9 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { mockResourcesForTarget(validTarget, expectedResourceName) var expectedAttributes: MutableMap = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to validTarget.javaClass.simpleName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to validTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to validTarget.height.toLong() ) val providers = Array(forge.anInt(min = 0, max = 10)) { @@ -625,16 +655,29 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), providers, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When testedListener.onSingleTapUp(mockEvent) // Then verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + val widthMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] + val heightMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] + classMatches && resourceMatches && widthMatches && heightMatches && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_X) && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_Y) + } ) } @@ -668,7 +711,9 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { mockResourcesForTarget(validTarget, expectedResourceName) val expectedAttributes: MutableMap = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to validTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to validTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to validTarget.height.toLong() ) testedListener = GesturesListener( @@ -676,7 +721,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -684,9 +730,21 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { // Then verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - fakeCustomTargetName, - expectedAttributes + eq(RumActionType.TAP), + eq(fakeCustomTargetName), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + val widthMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] + val heightMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] + classMatches && resourceMatches && widthMatches && heightMatches && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_X) && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_Y) + } ) } @@ -719,7 +777,9 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { mockResourcesForTarget(validTarget, expectedResourceName) val expectedAttributes: MutableMap = mutableMapOf( RumAttributes.ACTION_TARGET_CLASS_NAME to validTarget.javaClass.canonicalName, - RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName + RumAttributes.ACTION_TARGET_RESOURCE_ID to expectedResourceName, + RumAttributes.INTERNAL_ACTION_TARGET_WIDTH to validTarget.width.toLong(), + RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT to validTarget.height.toLong() ) testedListener = GesturesListener( @@ -727,7 +787,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { WeakReference(mockWindow), interactionPredicate = mockInteractionPredicate, contextRef = WeakReference(mockAppContext), - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -735,9 +796,139 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { // Then verify(rumMonitor.mockInstance).addAction( - RumActionType.TAP, - "", - expectedAttributes + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + val classMatches = attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == + expectedAttributes[RumAttributes.ACTION_TARGET_CLASS_NAME] + val resourceMatches = attributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] == + expectedAttributes[RumAttributes.ACTION_TARGET_RESOURCE_ID] + val widthMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_WIDTH] + val heightMatches = attributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] == + expectedAttributes[RumAttributes.INTERNAL_ACTION_TARGET_HEIGHT] + classMatches && resourceMatches && widthMatches && heightMatches && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_X) && + attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_Y) + } + ) + } + + @Test + fun `M calculate correct relative position W tap { position relative to target }`( + forge: Forge + ) { + // Given + val targetX = 100 + val targetY = 200 + val touchX = 150f + val touchY = 250f + + val mockEvent: MotionEvent = mock { + whenever(it.x).thenReturn(touchX) + whenever(it.y).thenReturn(touchY) + } + + val targetId = forge.anInt() + val validTarget: View = mockView( + id = targetId, + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + // Mock getLocationOnScreen to return a specific position + whenever(validTarget.getLocationOnScreen(any())).doAnswer { invocation -> + val array = invocation.arguments[0] as IntArray + array[0] = targetX + array[1] = targetY + null + } + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + val expectedResourceName = forge.anAlphabeticalString() + mockResourcesForTarget(validTarget, expectedResourceName) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then - position should be relative to the target element + val expectedRelativeX = (touchX - targetX).toLong() + val expectedRelativeY = (touchY - targetY).toLong() + verify(rumMonitor.mockInstance).addAction( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + attributes[RumAttributes.INTERNAL_ACTION_POSITION_X] == expectedRelativeX && + attributes[RumAttributes.INTERNAL_ACTION_POSITION_Y] == expectedRelativeY + } + ) + } + + @Test + fun `M not include position W tap { view not attached to window }`( + forge: Forge + ) { + // Given + val mockEvent: MotionEvent = forge.getForgery() + val targetId = forge.anInt() + val validTarget: View = mockView( + id = targetId, + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + // Mock view as not attached to window + whenever(validTarget.isAttachedToWindow).thenReturn(false) + + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + val expectedResourceName = forge.anAlphabeticalString() + mockResourcesForTarget(validTarget, expectedResourceName) + + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then - position attributes should NOT be included when view is not attached + verify(rumMonitor.mockInstance).addAction( + eq(RumActionType.TAP), + eq(""), + argThat { attributes -> + !attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_X) && + !attributes.containsKey(RumAttributes.INTERNAL_ACTION_POSITION_Y) && + attributes[RumAttributes.ACTION_TARGET_CLASS_NAME] == validTarget.javaClass.canonicalName + } ) } @@ -776,7 +967,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { contextRef = WeakReference(mockAppContext), androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, composeActionTrackingStrategy = mockComposeActionTrackingStrategy, - internalLogger = mockInternalLogger + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -837,7 +1029,8 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { contextRef = WeakReference(mockAppContext), internalLogger = mockInternalLogger, androidActionTrackingStrategy = mockAndroidActionTrackingStrategy, - composeActionTrackingStrategy = mockComposeActionTrackingStrategy + composeActionTrackingStrategy = mockComposeActionTrackingStrategy, + viewIdentityResolver = mockViewIdentityResolver ) whenever( @@ -867,6 +1060,136 @@ internal class GesturesListenerTapTest : AbstractGesturesListenerTest() { ) } + // region View Identity Tests + + @Test + fun `M include view identity W onTap() { viewIdentityResolver returns value }`( + forge: Forge + ) { + // Given + val mockEvent: MotionEvent = forge.getForgery() + val fakeViewIdentity = forge.anAlphabeticalString(size = 32) + val validTarget: View = mockView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + whenever(mockViewIdentityResolver.resolveViewIdentity(validTarget)).thenReturn(fakeViewIdentity) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + verify(rumMonitor.mockInstance).addAction( + eq(RumActionType.TAP), + any(), + argThat { + this[RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY] == fakeViewIdentity + } + ) + } + + @Test + fun `M not include view identity W onTap() { viewIdentityResolver returns null }`( + forge: Forge + ) { + // Given + val mockEvent: MotionEvent = forge.getForgery() + val validTarget: View = mockView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + whenever(mockViewIdentityResolver.resolveViewIdentity(validTarget)).thenReturn(null) + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + verify(rumMonitor.mockInstance).addAction( + eq(RumActionType.TAP), + any(), + argThat { + !this.containsKey(RumAttributes.INTERNAL_ACTION_TARGET_IDENTITY) + } + ) + } + + @Test + fun `M call resolveViewIdentity W onTap() { view is tapped }`( + forge: Forge + ) { + // Given + val mockEvent: MotionEvent = forge.getForgery() + val validTarget: View = mockView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = true, + forge = forge, + clickable = true + ) + mockDecorView = mockDecorView( + id = forge.anInt(), + forEvent = mockEvent, + hitTest = false, + forge = forge + ) { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(validTarget) + } + testedListener = GesturesListener( + rumMonitor.mockSdkCore, + WeakReference(mockWindow), + contextRef = WeakReference(mockAppContext), + internalLogger = mockInternalLogger, + viewIdentityResolver = mockViewIdentityResolver + ) + + // When + testedListener.onSingleTapUp(mockEvent) + + // Then + verify(mockViewIdentityResolver).resolveViewIdentity(validTarget) + } + + // endregion + // region Internal private fun verifyMonitorCalledWithUserAction( diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt index b41d962fd5..4e4dab86d5 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.identity.ViewIdentityResolver import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.ExperimentalRumApi @@ -185,6 +186,9 @@ internal class DatadogRumMonitorTest { @Mock lateinit var mockSlowFramesListener: SlowFramesListener + @Mock + lateinit var mockViewIdentityResolver: ViewIdentityResolver + @Mock lateinit var mockRumFeatureScope: FeatureScope @@ -288,7 +292,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedMonitor.rootScope = mockApplicationScope } @@ -320,7 +325,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -395,7 +401,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedMonitor.start() val mockCallback = mock<(String?) -> Unit>() @@ -438,7 +445,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedMonitor.start() val mockCallback = mock<(String?) -> Unit>() @@ -2080,7 +2088,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -2120,7 +2129,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) // When @@ -2161,7 +2171,8 @@ internal class DatadogRumMonitorTest { displayInfoProvider = mockDisplayInfoProvider, rumSessionTypeOverride = null, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) whenever(mockExecutorService.isShutdown).thenReturn(true) @@ -2334,7 +2345,8 @@ internal class DatadogRumMonitorTest { batteryInfoProvider = mockBatteryInfoProvider, displayInfoProvider = mockDisplayInfoProvider, rumSessionScopeStartupManagerFactory = mock(), - insightsCollector = mockInsightsCollector + insightsCollector = mockInsightsCollector, + viewIdentityResolver = mockViewIdentityResolver ) testedMonitor.startView(key, name, attributes) // When diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt index ead043700d..5b49d14d73 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt @@ -20,7 +20,8 @@ internal class MappingContextForgeryFactory : ForgeryFactory { imagePrivacy = forge.getForgery(), textAndInputPrivacy = forge.getForgery(), touchPrivacyManager = mock(), - interopViewCallback = mock() + interopViewCallback = mock(), + viewIdentityProvider = mock() ) } } diff --git a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt index 8d2c51853d..e8cd6358f5 100644 --- a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt @@ -20,7 +20,8 @@ internal class MappingContextForgeryFactory : ForgeryFactory { imagePrivacy = forge.getForgery(), hasOptionSelectorParent = forge.aBool(), touchPrivacyManager = mock(), - interopViewCallback = mock() + interopViewCallback = mock(), + viewIdentityProvider = mock() ) } } diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 535d530ea3..408a568d03 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -86,16 +86,18 @@ interface com.datadog.android.sessionreplay.internal.recorder.obfuscator.StringO interface com.datadog.android.sessionreplay.recorder.InteropViewCallback fun map(android.view.View, MappingContext): List data class com.datadog.android.sessionreplay.recorder.MappingContext - constructor(SystemInformation, com.datadog.android.sessionreplay.utils.ImageWireframeHelper, com.datadog.android.sessionreplay.TextAndInputPrivacy, com.datadog.android.sessionreplay.ImagePrivacy, com.datadog.android.sessionreplay.internal.TouchPrivacyManager, Boolean = false, InteropViewCallback) + constructor(SystemInformation, com.datadog.android.sessionreplay.utils.ImageWireframeHelper, com.datadog.android.sessionreplay.TextAndInputPrivacy, com.datadog.android.sessionreplay.ImagePrivacy, com.datadog.android.sessionreplay.internal.TouchPrivacyManager, Boolean = false, InteropViewCallback, ViewIdentityProvider) interface com.datadog.android.sessionreplay.recorder.OptionSelectorDetector fun isOptionSelector(android.view.ViewGroup): Boolean data class com.datadog.android.sessionreplay.recorder.SystemInformation constructor(com.datadog.android.sessionreplay.utils.GlobalBounds, Int = Configuration.ORIENTATION_UNDEFINED, Float, String? = null) +interface com.datadog.android.sessionreplay.recorder.ViewIdentityProvider + fun resolveIdentity(android.view.View): String? abstract class com.datadog.android.sessionreplay.recorder.mapper.BaseAsyncBackgroundWireframeMapper : BaseWireframeMapper constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) override fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List protected open fun resolveViewBackground(android.view.View, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? - protected open fun resolveBackgroundAsShapeWireframe(android.view.View, com.datadog.android.sessionreplay.utils.GlobalBounds, Int, Int, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle?): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ShapeWireframe? + protected open fun resolveBackgroundAsShapeWireframe(android.view.View, com.datadog.android.sessionreplay.utils.GlobalBounds, Int, Int, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle?, com.datadog.android.sessionreplay.recorder.MappingContext): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ShapeWireframe? protected open fun resolveBackgroundAsImageWireframe(android.view.View, com.datadog.android.sessionreplay.utils.GlobalBounds, Int, Int, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? companion object open class com.datadog.android.sessionreplay.recorder.mapper.BaseViewGroupMapper : BaseAsyncBackgroundWireframeMapper, TraverseAllChildrenMapper @@ -103,6 +105,7 @@ open class com.datadog.android.sessionreplay.recorder.mapper.BaseViewGroupMapper abstract class com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper : WireframeMapper constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) protected fun resolveViewId(android.view.View): Long + protected fun resolveViewIdentity(android.view.View, com.datadog.android.sessionreplay.recorder.ViewIdentityProvider): String? protected fun resolveShapeStyle(android.graphics.drawable.Drawable, Float, com.datadog.android.api.InternalLogger): com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? class com.datadog.android.sessionreplay.recorder.mapper.EditTextMapper : TextViewMapper constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) @@ -295,35 +298,35 @@ data class com.datadog.android.sessionreplay.model.MobileSegment sealed class Wireframe abstract fun toJson(): com.google.gson.JsonElement data class ShapeWireframe : Wireframe - constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null) + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, kotlin.String? = null, ShapeStyle? = null, ShapeBorder? = null) val type: kotlin.String override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): ShapeWireframe fun fromJsonObject(com.google.gson.JsonObject): ShapeWireframe data class TextWireframe : Wireframe - constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, TextStyle, TextPosition? = null) + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, kotlin.String? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, TextStyle, TextPosition? = null) val type: kotlin.String override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): TextWireframe fun fromJsonObject(com.google.gson.JsonObject): TextWireframe data class ImageWireframe : Wireframe - constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String? = null, kotlin.String? = null, kotlin.String? = null, kotlin.Boolean? = null) + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, kotlin.String? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String? = null, kotlin.String? = null, kotlin.String? = null, kotlin.Boolean? = null) val type: kotlin.String override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): ImageWireframe fun fromJsonObject(com.google.gson.JsonObject): ImageWireframe data class PlaceholderWireframe : Wireframe - constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, kotlin.String? = null) + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, kotlin.String? = null, kotlin.String? = null) val type: kotlin.String override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): PlaceholderWireframe fun fromJsonObject(com.google.gson.JsonObject): PlaceholderWireframe data class WebviewWireframe : Wireframe - constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, kotlin.Boolean? = null) + constructor(kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long, WireframeClip? = null, kotlin.String? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, kotlin.Boolean? = null) val type: kotlin.String override fun toJson(): com.google.gson.JsonElement companion object diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index 5676f43536..0b334e93ee 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -958,22 +958,23 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe$Companion; - public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V - public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V + public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component10 ()Ljava/lang/String; public final fun component11 ()Ljava/lang/String; - public final fun component12 ()Ljava/lang/Boolean; + public final fun component12 ()Ljava/lang/String; + public final fun component13 ()Ljava/lang/Boolean; public final fun component2 ()J public final fun component3 ()J public final fun component4 ()J public final fun component5 ()J public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; - public final fun component7 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; - public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; - public final fun component9 ()Ljava/lang/String; - public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component9 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ImageWireframe; @@ -983,6 +984,7 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun getHeight ()J public final fun getId ()J public final fun getMimeType ()Ljava/lang/String; + public final fun getPermanentId ()Ljava/lang/String; public final fun getResourceId ()Ljava/lang/String; public final fun getShapeStyle ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public final fun getType ()Ljava/lang/String; @@ -1006,8 +1008,8 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe$Companion; - public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;)V - public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component2 ()J public final fun component3 ()J @@ -1015,8 +1017,9 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun component5 ()J public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; public final fun component7 ()Ljava/lang/String; - public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe; + public final fun component8 ()Ljava/lang/String; + public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$PlaceholderWireframe; @@ -1024,6 +1027,7 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun getHeight ()J public final fun getId ()J public final fun getLabel ()Ljava/lang/String; + public final fun getPermanentId ()Ljava/lang/String; public final fun getType ()Ljava/lang/String; public final fun getWidth ()J public final fun getX ()J @@ -1041,18 +1045,19 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe$Companion; - public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)V - public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)V + public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component2 ()J public final fun component3 ()J public final fun component4 ()J public final fun component5 ()J public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; - public final fun component7 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; - public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; - public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component9 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; @@ -1060,6 +1065,7 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; public final fun getHeight ()J public final fun getId ()J + public final fun getPermanentId ()Ljava/lang/String; public final fun getShapeStyle ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public final fun getType ()Ljava/lang/String; public final fun getWidth ()J @@ -1077,21 +1083,22 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe$Companion; - public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;)V - public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;)V + public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun component10 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle; - public final fun component11 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition; + public final fun component10 ()Ljava/lang/String; + public final fun component11 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle; + public final fun component12 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition; public final fun component2 ()J public final fun component3 ()J public final fun component4 ()J public final fun component5 ()J public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; - public final fun component7 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; - public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; - public final fun component9 ()Ljava/lang/String; - public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component9 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; @@ -1099,6 +1106,7 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; public final fun getHeight ()J public final fun getId ()J + public final fun getPermanentId ()Ljava/lang/String; public final fun getShapeStyle ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public final fun getText ()Ljava/lang/String; public final fun getTextPosition ()Lcom/datadog/android/sessionreplay/model/MobileSegment$TextPosition; @@ -1120,20 +1128,21 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe : com/datadog/android/sessionreplay/model/MobileSegment$Wireframe { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe$Companion; - public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;)V - public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;)V + public synthetic fun (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun component10 ()Ljava/lang/Boolean; + public final fun component10 ()Ljava/lang/String; + public final fun component11 ()Ljava/lang/Boolean; public final fun component2 ()J public final fun component3 ()J public final fun component4 ()J public final fun component5 ()J public final fun component6 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; - public final fun component7 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; - public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; - public final fun component9 ()Ljava/lang/String; - public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; + public final fun component9 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun copy (JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe;JJJJJLcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Ljava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; @@ -1141,6 +1150,7 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; public final fun getHeight ()J public final fun getId ()J + public final fun getPermanentId ()Ljava/lang/String; public final fun getShapeStyle ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; public final fun getSlotId ()Ljava/lang/String; public final fun getType ()Ljava/lang/String; @@ -1468,8 +1478,8 @@ public abstract interface class com/datadog/android/sessionreplay/recorder/Inter } public final class com/datadog/android/sessionreplay/recorder/MappingContext { - public fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;)V - public synthetic fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider;)V + public synthetic fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/datadog/android/sessionreplay/recorder/SystemInformation; public final fun component2 ()Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper; public final fun component3 ()Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; @@ -1477,8 +1487,9 @@ public final class com/datadog/android/sessionreplay/recorder/MappingContext { public final fun component5 ()Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager; public final fun component6 ()Z public final fun component7 ()Lcom/datadog/android/sessionreplay/recorder/InteropViewCallback; - public final fun copy (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;)Lcom/datadog/android/sessionreplay/recorder/MappingContext; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/recorder/MappingContext; + public final fun component8 ()Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider; + public final fun copy (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider;)Lcom/datadog/android/sessionreplay/recorder/MappingContext; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager;ZLcom/datadog/android/sessionreplay/recorder/InteropViewCallback;Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/recorder/MappingContext; public fun equals (Ljava/lang/Object;)Z public final fun getHasOptionSelectorParent ()Z public final fun getImagePrivacy ()Lcom/datadog/android/sessionreplay/ImagePrivacy; @@ -1487,6 +1498,7 @@ public final class com/datadog/android/sessionreplay/recorder/MappingContext { public final fun getSystemInformation ()Lcom/datadog/android/sessionreplay/recorder/SystemInformation; public final fun getTextAndInputPrivacy ()Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; public final fun getTouchPrivacyManager ()Lcom/datadog/android/sessionreplay/internal/TouchPrivacyManager; + public final fun getViewIdentityProvider ()Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1513,12 +1525,16 @@ public final class com/datadog/android/sessionreplay/recorder/SystemInformation public fun toString ()Ljava/lang/String; } +public abstract interface class com/datadog/android/sessionreplay/recorder/ViewIdentityProvider { + public abstract fun resolveIdentity (Landroid/view/View;)Ljava/lang/String; +} + public abstract class com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper : com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper { public static final field Companion Lcom/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper$Companion; public fun (Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver;Lcom/datadog/android/sessionreplay/utils/ColorStringFormatter;Lcom/datadog/android/sessionreplay/utils/ViewBoundsResolver;Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper;)V public fun map (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List; protected fun resolveBackgroundAsImageWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; - protected fun resolveBackgroundAsShapeWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; + protected fun resolveBackgroundAsShapeWireframe (Landroid/view/View;Lcom/datadog/android/sessionreplay/utils/GlobalBounds;IILcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/recorder/MappingContext;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$ShapeWireframe; protected fun resolveViewBackground (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } @@ -1537,6 +1553,7 @@ public abstract class com/datadog/android/sessionreplay/recorder/mapper/BaseWire protected final fun getViewIdentifierResolver ()Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver; protected final fun resolveShapeStyle (Landroid/graphics/drawable/Drawable;FLcom/datadog/android/api/InternalLogger;)Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle; protected final fun resolveViewId (Landroid/view/View;)J + protected final fun resolveViewIdentity (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/ViewIdentityProvider;)Ljava/lang/String; } public final class com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper : com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper { diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/_common-wireframe-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/_common-wireframe-schema.json index bd677668f0..42bfca0d43 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/_common-wireframe-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/_common-wireframe-schema.json @@ -33,6 +33,11 @@ }, "clip": { "$ref": "wireframe-clip-schema.json" + }, + "permanentId": { + "type": "string", + "description": "A globally unique and stable identifier for this UI element, computed as the hash of the element's path (32 lowercase hex characters). Used to correlate wireframes with RUM action events.", + "readOnly": true } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/NoOpViewIdentityProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/NoOpViewIdentityProvider.kt new file mode 100644 index 0000000000..fa5e8c5e49 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/NoOpViewIdentityProvider.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.sessionreplay.internal.recorder + +import android.view.View +import com.datadog.android.sessionreplay.recorder.ViewIdentityProvider + +/** + * No-op implementation used when view identity tracking is disabled. + */ +internal object NoOpViewIdentityProvider : ViewIdentityProvider { + + override fun resolveIdentity(view: View): String? = null +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index b50b0c6197..dbdb87e627 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -91,6 +91,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { internalCallback: SessionReplayInternalCallback ) { val internalLogger = sdkCore.internalLogger + val rumContextDataHandler = RumContextDataHandler( rumContextProvider, timeProvider, @@ -159,14 +160,15 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { internalLogger = internalLogger, onDrawListenerProducer = DefaultOnDrawListenerProducer( snapshotProducer = SnapshotProducer( - DefaultImageWireframeHelper( + imageWireframeHelper = DefaultImageWireframeHelper( logger = internalLogger, resourceResolver = resourceResolver, viewIdentifierResolver = viewIdentifierResolver, viewUtilsInternal = ViewUtilsInternal(), - imageTypeResolver = ImageTypeResolver() + imageTypeResolver = ImageTypeResolver(), + sdkCore = sdkCore ), - TreeViewTraversal( + treeViewTraversal = TreeViewTraversal( mappers = mappers, defaultViewMapper = defaultVWM, decorViewMapper = DecorViewMapper(defaultVWM, viewIdentifierResolver), @@ -177,10 +179,10 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { viewUtilsInternal = ViewUtilsInternal(), internalLogger = internalLogger ), - ComposedOptionSelectorDetector( + optionSelectorDetector = ComposedOptionSelectorDetector( customOptionSelectorDetectors + DefaultOptionSelectorDetector() ), - touchPrivacyManager, + touchPrivacyManager = touchPrivacyManager, internalLogger = internalLogger ), recordedDataQueueHandler = recordedDataQueueHandler, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt index a450f28390..5c5eca425b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt @@ -20,6 +20,7 @@ import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector import com.datadog.android.sessionreplay.recorder.SystemInformation +import com.datadog.android.sessionreplay.recorder.ViewIdentityProvider import com.datadog.android.sessionreplay.utils.ImageWireframeHelper import java.util.LinkedList @@ -37,7 +38,8 @@ internal class SnapshotProducer( systemInformation: SystemInformation, textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, - recordedDataQueueRefs: RecordedDataQueueRefs + recordedDataQueueRefs: RecordedDataQueueRefs, + viewIdentityProvider: ViewIdentityProvider ): Node? { return convertViewToNode( rootView, @@ -50,7 +52,8 @@ internal class SnapshotProducer( interopViewCallback = DefaultInteropViewCallback( treeViewTraversal, recordedDataQueueRefs - ) + ), + viewIdentityProvider = viewIdentityProvider ), LinkedList(), recordedDataQueueRefs diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewIdentityResolverAdapter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewIdentityResolverAdapter.kt new file mode 100644 index 0000000000..b280c06acd --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewIdentityResolverAdapter.kt @@ -0,0 +1,24 @@ +/* + * 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.sessionreplay.internal.recorder + +import android.view.View +import com.datadog.android.internal.identity.ViewIdentityResolver +import com.datadog.android.sessionreplay.recorder.ViewIdentityProvider + +/** + * Adapter that wraps [ViewIdentityResolver] from the internal module + * to expose it as session-replay's public [ViewIdentityProvider] interface. + */ +internal class ViewIdentityResolverAdapter( + private val resolver: ViewIdentityResolver +) : ViewIdentityProvider { + + override fun resolveIdentity(view: View): String? { + return resolver.resolveViewIdentity(view) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt index 9d75b12fda..55dec61d4e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt @@ -19,8 +19,10 @@ import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.Debouncer import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer +import com.datadog.android.sessionreplay.internal.recorder.ViewIdentityResolverAdapter import com.datadog.android.sessionreplay.internal.recorder.withinSRBenchmarkSpan import com.datadog.android.sessionreplay.internal.utils.MiscUtils +import com.datadog.android.sessionreplay.internal.utils.getViewIdentityResolver import java.lang.ref.WeakReference internal class WindowsOnDrawListener( @@ -54,11 +56,20 @@ internal class WindowsOnDrawListener( override fun run() { val rootViews = weakReferencedDecorViews.mapNotNull { it.get() } - // is is very important to have the windows sorted by their z-order + // It is very important to have the windows sorted by their z-order val context = rootViews.firstOrNull()?.context ?: return val systemInformation = miscUtils.resolveSystemInformation(context) val item = recordedDataQueueHandler.addSnapshotItem(systemInformation) ?: return + // Fetch viewIdentityResolver lazily from RUM's feature context (see getViewIdentityResolver docs) + val viewIdentityResolver = sdkCore.getViewIdentityResolver() + + // onWindowRefreshed is a no-op when using NoOpViewIdentityResolver (heatmap tracking disabled) + rootViews.forEach { viewIdentityResolver.onWindowRefreshed(it) } + + // Wrap the internal resolver with the public adapter interface + val viewIdentityProvider = ViewIdentityResolverAdapter(viewIdentityResolver) + val nodes = sdkCore.internalLogger.measureMethodCallPerf( METHOD_CALL_CALLER_CLASS, METHOD_CALL_CAPTURE_RECORD, @@ -73,7 +84,8 @@ internal class WindowsOnDrawListener( systemInformation = systemInformation, textAndInputPrivacy = textAndInputPrivacy, imagePrivacy = imagePrivacy, - recordedDataQueueRefs = recordedDataQueueRefs + recordedDataQueueRefs = recordedDataQueueRefs, + viewIdentityProvider = viewIdentityProvider ) } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt index 29595f696a..740ba3a6d7 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ActionBarContainerMapper.kt @@ -60,6 +60,7 @@ internal class ActionBarContainerMapper( y = bounds.y, width = bounds.width, height = bounds.height, + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), shapeStyle = shapeStyle, border = null ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt index c4fb1f894b..f8b7b5e799 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt @@ -84,8 +84,9 @@ internal abstract class CheckableTextViewMapper( y = checkBoxBounds.y, width = checkBoxBounds.width, height = checkBoxBounds.height, - border = shapeBorder, - shapeStyle = shapeStyle + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), + shapeStyle = shapeStyle, + border = shapeBorder ) ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt index 507c1a9f06..3490cb7aae 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt @@ -38,6 +38,7 @@ internal class HiddenViewMapper( y = viewGlobalBounds.y, width = viewGlobalBounds.width, height = viewGlobalBounds.height, + permanentId = mappingContext.viewIdentityProvider.resolveIdentity(view), label = HIDDEN_VIEW_PLACEHOLDER_TEXT ) ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt index fdc949fbc6..c1f3b709c2 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt @@ -67,7 +67,7 @@ internal open class ProgressBarWireframeMapper

( val defaultColor = getDefaultColor(view) val trackColor = getColor(view.progressTintList, view.drawableState) ?: defaultColor - buildNonActiveTrackWireframe(view, trackBounds, trackColor)?.let(wireframes::add) + buildNonActiveTrackWireframe(view, trackBounds, trackColor, mappingContext)?.let(wireframes::add) val hasProgress = !view.isIndeterminate val showProgress = @@ -110,7 +110,8 @@ internal open class ProgressBarWireframeMapper

( private fun buildNonActiveTrackWireframe( view: P, trackBounds: GlobalBounds, - trackColor: Int + trackColor: Int, + mappingContext: MappingContext ): MobileSegment.Wireframe? { val nonActiveTrackId = viewIdentifierResolver.resolveChildUniqueIdentifier(view, NON_ACTIVE_TRACK_KEY_NAME) ?: return null @@ -124,6 +125,7 @@ internal open class ProgressBarWireframeMapper

( y = trackBounds.y, width = trackBounds.width, height = trackBounds.height, + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), shapeStyle = MobileSegment.ShapeStyle( backgroundColor = backgroundColor, opacity = view.alpha diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt index c25c7ce16d..21ca22c17d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt @@ -204,8 +204,9 @@ internal open class SwitchCompatMapper( y = trackBounds.y.densityNormalized(pixelsDensity).toLong(), width = trackBounds.width.densityNormalized(pixelsDensity).toLong(), height = trackBounds.height.densityNormalized(pixelsDensity).toLong(), - border = null, - shapeStyle = trackShapeStyle + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), + shapeStyle = trackShapeStyle, + border = null ) wireframes.add(trackWireframe) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/UnsupportedViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/UnsupportedViewMapper.kt index cb25e76431..918e07926b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/UnsupportedViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/UnsupportedViewMapper.kt @@ -48,6 +48,7 @@ internal class UnsupportedViewMapper( y = viewGlobalBounds.y, width = viewGlobalBounds.width, height = viewGlobalBounds.height, + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), label = resolveViewTitle(view) ) ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt index da6c35ea8b..bdde055076 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ViewWireframeMapper.kt @@ -50,6 +50,7 @@ internal class ViewWireframeMapper( viewGlobalBounds.y, viewGlobalBounds.width, viewGlobalBounds.height, + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), shapeStyle = shapeStyle, border = null ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt index 2f84e4ab0a..35ba3aa547 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt @@ -16,9 +16,11 @@ import android.widget.TextView import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.internal.utils.densityNormalized import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.internal.recorder.ViewUtilsInternal +import com.datadog.android.sessionreplay.internal.utils.getViewIdentityResolver import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.resources.DrawableCopier @@ -36,7 +38,8 @@ internal class DefaultImageWireframeHelper( private val resourceResolver: ResourceResolver, private val viewIdentifierResolver: ViewIdentifierResolver, private val viewUtilsInternal: ViewUtilsInternal, - private val imageTypeResolver: ImageTypeResolver + private val imageTypeResolver: ImageTypeResolver, + private val sdkCore: FeatureSdkCore ) : ImageWireframeHelper { @Suppress("ReturnCount") @@ -280,6 +283,7 @@ internal class DefaultImageWireframeHelper( y, width = drawableWidthDp, height = drawableHeightDp, + permanentId = sdkCore.getViewIdentityResolver().resolveViewIdentity(view), shapeStyle = shapeStyle, border = border, clip = clipping, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/ViewIdentityResolverExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/ViewIdentityResolverExt.kt new file mode 100644 index 0000000000..db282ae9e8 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/ViewIdentityResolverExt.kt @@ -0,0 +1,21 @@ +/* + * 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.sessionreplay.internal.utils + +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.internal.identity.NoOpViewIdentityResolver +import com.datadog.android.internal.identity.ViewIdentityResolver + +/** + * Retrieves [ViewIdentityResolver] from RUM's feature context, or [NoOpViewIdentityResolver] if unavailable. + */ +internal fun FeatureSdkCore.getViewIdentityResolver(): ViewIdentityResolver { + val rumContext = getFeatureContext(Feature.RUM_FEATURE_NAME) + return rumContext[ViewIdentityResolver.FEATURE_CONTEXT_KEY] as? ViewIdentityResolver + ?: NoOpViewIdentityResolver() +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt index e02e0e1692..d28a55413a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt @@ -24,6 +24,8 @@ import com.datadog.android.sessionreplay.utils.ImageWireframeHelper * is an option selector type (e.g. time picker, date picker, drop - down list) * @param interopViewCallback the callback for Jetpack Compose semantics tree to call * when there is an interop view to map. + * @param viewIdentityProvider provides stable view identities for heatmap correlation + * (uses no-op implementation if heatmap tracking is disabled) */ data class MappingContext( val systemInformation: SystemInformation, @@ -32,5 +34,6 @@ data class MappingContext( val imagePrivacy: ImagePrivacy, val touchPrivacyManager: TouchPrivacyManager, val hasOptionSelectorParent: Boolean = false, - val interopViewCallback: InteropViewCallback + val interopViewCallback: InteropViewCallback, + val viewIdentityProvider: ViewIdentityProvider ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ViewIdentityProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ViewIdentityProvider.kt new file mode 100644 index 0000000000..fadbc5f3b7 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ViewIdentityProvider.kt @@ -0,0 +1,25 @@ +/* + * 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.sessionreplay.recorder + +import android.view.View + +/** + * Provides stable identity hashes for Views, enabling correlation between + * Session Replay wireframes and RUM action events (heatmaps). + */ +interface ViewIdentityProvider { + + /** + * Resolves a stable identity hash for the given view. + * + * @param view the view to resolve identity for + * @return the stable identity hash, or null if the view's identity cannot be determined + * (e.g., detached view, or identity tracking is disabled) + */ + fun resolveIdentity(view: View): String? +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt index 75d2eb57b9..6a02ecd1d3 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt @@ -93,7 +93,8 @@ abstract class BaseAsyncBackgroundWireframeMapper ( bounds = bounds, width = width, height = height, - shapeStyle = shapeStyle + shapeStyle = shapeStyle, + mappingContext = mappingContext ) } } @@ -106,13 +107,15 @@ abstract class BaseAsyncBackgroundWireframeMapper ( * @param width the view width. * @param height the view height. * @param shapeStyle the optional [MobileSegment.ShapeStyle] to use. + * @param mappingContext the [MappingContext] which contains contextual data, useful for mapping. */ protected open fun resolveBackgroundAsShapeWireframe( view: View, bounds: GlobalBounds, width: Int, height: Int, - shapeStyle: MobileSegment.ShapeStyle? + shapeStyle: MobileSegment.ShapeStyle?, + mappingContext: MappingContext ): MobileSegment.Wireframe.ShapeWireframe? { val id = uniqueIdentifierGenerator.resolveChildUniqueIdentifier( view, @@ -127,6 +130,7 @@ abstract class BaseAsyncBackgroundWireframeMapper ( y = bounds.y, width = width.densityNormalized(density).toLong(), height = height.densityNormalized(density).toLong(), + permanentId = resolveViewIdentity(view, mappingContext.viewIdentityProvider), shapeStyle = shapeStyle, border = null ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt index aff15ffee3..2e3d667a9f 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseWireframeMapper.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable import android.view.View import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.ViewIdentityProvider import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import com.datadog.android.sessionreplay.utils.ViewBoundsResolver @@ -42,6 +43,30 @@ abstract class BaseWireframeMapper( return viewIdentifierResolver.resolveViewId(view) } + /** + * Resolves the stable identity for a view, used for heatmap correlation. + * This identity is stable across sessions and is based on the view's canonical path. + * + * ## When to use this method + * + * Use `resolveViewIdentity(view, viewIdentityProvider)` when the wireframe directly corresponds + * to a View in the Android hierarchy. This includes both interactive elements (buttons, etc.) + * and non-interactive elements (text labels, images, etc.) - any View the user might tap on. + * + * Omit the identity (defaults to null) when the wireframe is synthetic - i.e., it doesn't + * correspond to a real View: + * - Visual sub-components (e.g., progress bar fill, seek bar thumb, picker dividers) + * - System-level decorations (e.g., window background in DecorViewMapper) + * + * @param view the view to resolve identity for + * @param viewIdentityProvider the provider for generating stable view identities + * @return the stable identity hash, or null if the view's canonical path cannot be determined + * (e.g., detached view) + */ + protected fun resolveViewIdentity(view: View, viewIdentityProvider: ViewIdentityProvider): String? { + return viewIdentityProvider.resolveIdentity(view) + } + /** * Resolves the [MobileSegment.ShapeStyle] based on the [View] drawables. */ diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt index fbe67b00e7..3a8124d6bd 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt @@ -136,6 +136,7 @@ open class TextViewMapper( y = viewGlobalBounds.y, width = viewGlobalBounds.width, height = viewGlobalBounds.height, + permanentId = resolveViewIdentity(textView, mappingContext.viewIdentityProvider), shapeStyle = null, border = null, text = capturedText, diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt index 22fca29ec6..0bf2b8d8e8 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt @@ -20,7 +20,8 @@ internal class MappingContextForgeryFactory : ForgeryFactory { textAndInputPrivacy = forge.getForgery(), imagePrivacy = forge.getForgery(), touchPrivacyManager = mock(), - interopViewCallback = mock() + interopViewCallback = mock(), + viewIdentityProvider = mock() ) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt index c85287ca79..ae4aacef68 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Comp import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation +import com.datadog.android.sessionreplay.recorder.ViewIdentityProvider import com.datadog.android.sessionreplay.setSessionReplayImagePrivacy import com.datadog.android.sessionreplay.setSessionReplayTextAndInputPrivacy import com.datadog.android.sessionreplay.utils.ImageWireframeHelper @@ -72,6 +73,9 @@ internal class SnapshotProducerTest { @Mock lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock + lateinit var mockViewIdentityProvider: ViewIdentityProvider + @Forgery lateinit var fakeSystemInformation: SystemInformation @@ -112,7 +116,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -139,7 +144,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -166,7 +172,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -193,7 +200,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -224,7 +232,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -261,7 +270,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) val argumentCaptor = argumentCaptor() @@ -296,7 +306,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) val argumentCaptor = argumentCaptor() @@ -340,7 +351,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -373,7 +385,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -413,7 +426,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then @@ -460,7 +474,8 @@ internal class SnapshotProducerTest { fakeSystemInformation, fakeTextAndInputPrivacy, fakeImagePrivacy, - mockRecordedDataQueueRefs + mockRecordedDataQueueRefs, + mockViewIdentityProvider ) // Then diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt index cb9b30eefe..afc0a109f8 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt @@ -145,6 +145,7 @@ internal class WindowsOnDrawListenerTest { eq(fakeSystemInformation), eq(fakeTextAndInputPrivacy), eq(fakeImagePrivacy), + any(), any() ) ) @@ -212,7 +213,8 @@ internal class WindowsOnDrawListenerTest { systemInformation = any(), textAndInputPrivacy = eq(fakeTextAndInputPrivacy), imagePrivacy = eq(fakeImagePrivacy), - recordedDataQueueRefs = argCaptor.capture() + recordedDataQueueRefs = argCaptor.capture(), + viewIdentityProvider = any() ) assertThat(argCaptor.firstValue.recordedDataQueueItem).isEqualTo(fakeSnapshotQueueItem) verify(mockRecordedDataQueueHandler).tryToConsumeItems() @@ -304,7 +306,7 @@ internal class WindowsOnDrawListenerTest { "Capture Record" ) ).thenReturn(mockPerformanceMetric) - whenever(mockSnapshotProducer.produce(any(), any(), any(), any(), any())).thenReturn(null) + whenever(mockSnapshotProducer.produce(any(), any(), any(), any(), any(), any())).thenReturn(null) whenever(mockRecordedDataQueueHandler.addSnapshotItem(any())) .thenReturn(fakeSnapshotQueueItem) fakeSnapshotQueueItem.pendingJobs.set(0) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt index b4dc7e8d76..6097420bc1 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapperTest.kt @@ -13,6 +13,7 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapp import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation +import com.datadog.android.sessionreplay.recorder.ViewIdentityProvider import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.GlobalBounds import com.datadog.android.sessionreplay.utils.ViewBoundsResolver @@ -59,6 +60,9 @@ internal class HiddenViewMapperTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockViewIdentityProvider: ViewIdentityProvider + private lateinit var testedViewMapper: HiddenViewMapper @BeforeEach @@ -69,6 +73,9 @@ internal class HiddenViewMapperTest { whenever(mockMappingContext.systemInformation) .thenReturn(mockSystemInformation) + whenever(mockMappingContext.viewIdentityProvider) + .thenReturn(mockViewIdentityProvider) + whenever(mockSystemInformation.screenDensity) .thenReturn(1f) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt index b044c865f9..8b26b6ab36 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelperTest.kt @@ -19,6 +19,7 @@ import android.util.DisplayMetrics import android.view.View import android.widget.TextView import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.IMAGE_DIMEN_CONSIDERED_PII_IN_DP import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator @@ -119,6 +120,9 @@ internal class DefaultImageWireframeHelperTest { @Mock lateinit var mockContext: Context + @Mock + lateinit var mockSdkCore: FeatureSdkCore + @LongForgery var fakeViewId: Long = 0L @@ -189,7 +193,8 @@ internal class DefaultImageWireframeHelperTest { resourceResolver = mockResourceResolver, viewIdentifierResolver = mockViewIdentifierResolver, viewUtilsInternal = mockViewUtilsInternal, - imageTypeResolver = mockImageTypeResolver + imageTypeResolver = mockImageTypeResolver, + sdkCore = mockSdkCore ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/mapper/AbstractWireframeMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/mapper/AbstractWireframeMapperTest.kt index fe03e71a4d..2b5d86b877 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/mapper/AbstractWireframeMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/mapper/AbstractWireframeMapperTest.kt @@ -159,7 +159,13 @@ internal abstract class AbstractWireframeMapperTest