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 @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

a small trick we use to not expose Impl classes is to create a property (in this case it will be a function create(arg)) in the companion object of 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 @@ -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 <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
*/
Copy link
Member

Choose a reason for hiding this comment

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

Do we really clear it? if so what's the case?

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.
Copy link
Member

Choose a reason for hiding this comment

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

nit: probably we can omit 32 hex chars from the description, it is implementation detail

* @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"
Copy link
Member

Choose a reason for hiding this comment

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

I think this is quite dangerous. What is the purpose to store such object in the feature context given that we have direct access to the type?

}
}
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(
Copy link
Member

Choose a reason for hiding this comment

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

I don't think synchronizedMap is a good choice here, maybe ConcurrentHashMap is better, given that after some time the number of reads will be much bigger than number of writes.

object : LinkedHashMap<Int, String>(RESOURCE_NAME_CACHE_SIZE, DEFAULT_LOAD_FACTOR, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>?): Boolean {
Copy link
Member

Choose a reason for hiding this comment

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

there is androidx.cache.LruCache class though

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> =
Comment on lines +53 to +61
Copy link
Member

Choose a reason for hiding this comment

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

We shouldn't do this. Views shouldn't wait for the GC to be removed, they should be removed once related screen (activity, fragment, etc.) is destroyed.

Since it is relying on the reference, why not to store class hashcode then?

Collections.synchronizedMap(WeakHashMap())
Copy link
Member

Choose a reason for hiding this comment

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

The same comment as above: synchronizedMap is not a good choice.


/**
* 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> =
Copy link
Member

Choose a reason for hiding this comment

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

I feel the name is a bit confusing here, "screen" is a concept of heatmaps which represents the whole visible interface according to the RFC, "rootView" is a Android term meaning the root of the view tree hireacy. I think here is more like "screenNamespaceCache indexed by rootView"

Copy link
Member

Choose a reason for hiding this comment

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

same as above: caching views itself is not a good idea.

Collections.synchronizedMap(WeakHashMap())

/** The current RUM view identifier, set via setCurrentScreen(). */
private val currentRumViewIdentifier = AtomicReference<String?>(null)

@Synchronized
Copy link
Member

Choose a reason for hiding this comment

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

there is no need for the @Synchronized, AtomicReference.getAndSet already does atomic check guaranteeing the absence of concurency.

override fun setCurrentScreen(identifier: String?) {
@Suppress("UnsafeThirdPartyFunctionCall") // type-safe: generics prevent VarHandle type mismatches
Copy link
Member

Choose a reason for hiding this comment

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

We should declare this call as safe instead, there is already java.util.concurrent.atomic.AtomicBoolean.getAndSet(kotlin.Boolean) declared.

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? {
Comment on lines +88 to +94
Copy link
Member

Choose a reason for hiding this comment

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

given these will be called on the main thread, we should avoid using Synchronized and use more granular synchronization primitives

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 ->
Copy link
Member

Choose a reason for hiding this comment

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

There was a point about SHA-1 vs MD5 in the RFC, did we expore it?

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 ->
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return currentRumViewIdentifier.get()?.let { viewName ->
return currentRumViewIdentifier.get()?.let { viewUrl ->

"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewName)}"
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewName)}"
"$NAMESPACE_VIEW_PREFIX${escapePathComponent(viewUrl)}"

}
}

/** Priority 2: Fall back to Activity class name if root view has Activity context. */
Copy link
Member

Choose a reason for hiding this comment

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

nit: probably we need to remove such comments, they don't really add any value

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? {
Copy link
Member

Choose a reason for hiding this comment

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

we have the exact same logic in MD5HashGenerator, maybe it's time to move it into internal and reuse it here.

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