Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions dd-sdk-android-internal/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions dd-sdk-android-internal/api/dd-sdk-android-internal.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()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 <init> (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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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<Int, String> = Collections.synchronizedMap(
object : LinkedHashMap<Int, String>(RESOURCE_NAME_CACHE_SIZE, DEFAULT_LOAD_FACTOR, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>?): 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<View, PathData> =
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<View, String> =
Collections.synchronizedMap(WeakHashMap())

/** The current RUM view identifier, set via setCurrentScreen(). */
private val currentRumViewIdentifier = AtomicReference<String?>(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"
Comment on lines +106 to +110

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disambiguate root view identity across windows

When multiple windows are present (e.g., a dialog over an activity), buildRootCanonicalPath only prefixes the canonical path with appIdentifier/screenNamespace plus the root view path segment, but it does not incorporate any window-specific identifier. For root views like DecorView (no resource id, same class name), different windows on the same screen can yield identical root paths, which then produce identical permanentId values for corresponding view hierarchies. That breaks the “globally unique” guarantee and can merge heatmap taps across different windows. Consider including a window-specific token (e.g., window hash/z-order) or another root-unique discriminator in the root canonical path.

Useful? React with 👍 / 👎.

}

/** 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<ViewWithCanonicalPath>()
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<ViewWithCanonicalPath>
) {
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
}
Loading
Loading