diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index ea340b7fac..4622ec6a09 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -60,6 +60,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 d24f2023a1..f35115f2a4 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -123,6 +123,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 d12d9bda96..fb2c4b654f 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -496,6 +496,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 @@ -935,8 +936,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)" @@ -1212,6 +1215,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)"