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
4 changes: 4 additions & 0 deletions dd-sdk-android-internal/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistry
fun getHeatmapIdentifier(Long, String): HeatmapIdentifier?
companion object
fun create(): HeatmapIdentifierRegistry
interface com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider
val heatmapIdentifierRegistry: HeatmapIdentifierRegistry
fun heatmapViewKey(android.view.View): Long
fun android.view.View.isValidTapTarget(): Boolean
class com.datadog.android.internal.lifecycle.ProcessLifecycleMonitor : android.app.Application.ActivityLifecycleCallbacks
constructor(Callback)
val activitiesResumedCounter: java.util.concurrent.atomic.AtomicInteger
Expand Down
12 changes: 12 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 @@ -150,12 +150,24 @@ public final class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegist
public final fun create ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry;
}

public abstract interface class com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistryProvider {
public abstract fun getHeatmapIdentifierRegistry ()Lcom/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry;
}

public final class com/datadog/android/internal/heatmaps/HeatmapViewKeyKt {
public static final fun heatmapViewKey (Landroid/view/View;)J
}

public final class com/datadog/android/internal/heatmaps/NoOpHeatmapIdentifierRegistry : com/datadog/android/internal/heatmaps/HeatmapIdentifierRegistry {
public fun <init> ()V
public fun getHeatmapIdentifier (JLjava/lang/String;)Lcom/datadog/android/internal/heatmaps/HeatmapIdentifier;
public fun setHeatmapIdentifiers (Ljava/util/Map;Ljava/lang/String;)V
}

public final class com/datadog/android/internal/heatmaps/TapTargetUtilsKt {
public static final fun isValidTapTarget (Landroid/view/View;)Z
}

public final class com/datadog/android/internal/lifecycle/ProcessLifecycleMonitor : android/app/Application$ActivityLifecycleCallbacks {
public fun <init> (Lcom/datadog/android/internal/lifecycle/ProcessLifecycleMonitor$Callback;)V
public final fun getActivitiesResumedCounter ()Ljava/util/concurrent/atomic/AtomicInteger;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,37 @@ package com.datadog.android.internal.heatmaps
import com.datadog.tools.annotation.NoOpImplementation

/**
* Stores and retrieves [HeatmapIdentifier]s keyed by view identity.
* Stores and retrieves [HeatmapIdentifier]s keyed by composite view identity key
* (see [heatmapViewKey]).
*
* Implementations must be thread-safe: the write side (Session Replay) may call
* [setHeatmapIdentifiers] from a background traversal thread while the read side (RUM) calls
* [getHeatmapIdentifier] from the main thread.
*/
@NoOpImplementation(publicNoOpImplementation = true)
interface HeatmapIdentifierRegistry {

/**
* Replaces the current snapshot with [identifiers], scoped to [screenName].
*
* @param identifiers a map of [android.view.View.getId] values to their [HeatmapIdentifier]s,
* computed during the most recent Session Replay view tree traversal.
* @param identifiers a map of view identity keys (see [heatmapViewKey]) to their
* [HeatmapIdentifier]s. Keys must be computed with [heatmapViewKey] at the time the
* snapshot is captured so they match the keys produced at lookup time.
* @param screenName the RUM view URL active when the snapshot was computed. Used to
* guard against stale reads after screen navigation.
*/
fun setHeatmapIdentifiers(identifiers: Map<Long, HeatmapIdentifier>, screenName: String)

/**
* Returns the [HeatmapIdentifier] for the view with the given [viewId], or null if the
* view is unknown or if [currentScreenName] does not match the screen that produced the
* Returns the [HeatmapIdentifier] for the view with the given [heatmapViewKey], or null if
* the view is unknown or if [currentScreenName] does not match the screen that produced the
* current snapshot (indicating the snapshot is stale).
*
* @param viewId the Android view ID of the tapped view.
* @param heatmapViewKey the composite identity key of the tapped view, as returned by [heatmapViewKey].
* Must match the key used when populating [setHeatmapIdentifiers].
* @param currentScreenName the RUM view URL active at the time of the tap.
*/
fun getHeatmapIdentifier(viewId: Long, currentScreenName: String): HeatmapIdentifier?
fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier?

companion object {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* 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.heatmaps

/**
* Implemented by SDK features that own a [HeatmapIdentifierRegistry], allowing peer features
* to obtain a typed reference via [com.datadog.android.api.feature.FeatureScope.unwrap].
*/
interface HeatmapIdentifierRegistryProvider {
/** The [HeatmapIdentifierRegistry] owned by this feature. */
val heatmapIdentifierRegistry: HeatmapIdentifierRegistry
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ internal class HeatmapIdentifierStore : HeatmapIdentifierRegistry {
}
}

override fun getHeatmapIdentifier(viewId: Long, currentScreenName: String): HeatmapIdentifier? {
override fun getHeatmapIdentifier(heatmapViewKey: Long, currentScreenName: String): HeatmapIdentifier? {
return lock.read {
if (snapshotScreenName == currentScreenName) identifiers[viewId] else null
if (snapshotScreenName == currentScreenName) identifiers[heatmapViewKey] else null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.heatmaps

import android.view.View

// Polynomial coefficient — matches Java's standard hashCode convention.
internal const val HEATMAP_VIEW_KEY_COEFFICIENT = 31L

/**
* Returns the key used to store and look up a view's [HeatmapIdentifier] in a
* [HeatmapIdentifierRegistry].
*
* The key combines the identity hash of the view with the identity hash of its direct parent
* using a polynomial combination with coefficient 31 (the same coefficient used by Java's
* standard `hashCode` convention). This makes the combinator non-commutative: swapping the
* view and parent hashes produces a different key.
*
* Note: the returned value is an opaque 64-bit quantity and may be negative, since
* [System.identityHashCode] returns a signed [Int] that is sign-extended on widening to [Long].
* Callers must not assume the key is non-negative.
*/
fun heatmapViewKey(view: View): Long {
val parentHash = view.parent?.let { System.identityHashCode(it) } ?: 0
return HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(view).toLong() + parentHash.toLong()
}
Original file line number Diff line number Diff line change
@@ -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.internal.heatmaps

import android.view.View

/**
* Single canonical tap-target predicate shared by the RUM gesture layer and Session Replay,
* so both subsystems stay in sync automatically.
*
* Note: [View.isEnabled] is intentionally not checked. Disabled views retain [View.isClickable]
* = true on Android but never receive touch events, so the RUM gesture layer never fires for
* them. Excluding `isEnabled` here keeps the predicate identical to RUM's existing behaviour.
*/
fun View.isValidTapTarget(): Boolean {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we need to have it in -internal module? seems like it is used only in -rum.

normally -internal module shouldn't be visible on the user side, but if they add it, they will have this extension.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It will be used also by session replay in the next pr

return isClickable && visibility == View.VISIBLE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* 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.heatmaps

import android.view.View
import android.view.ViewParent
import com.datadog.android.internal.forge.Configurator
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.mockito.quality.Strictness

@Extensions(
ExtendWith(MockitoExtension::class),
ExtendWith(ForgeExtension::class)
)
@MockitoSettings(strictness = Strictness.LENIENT)
@ForgeConfiguration(Configurator::class)
internal class HeatmapViewKeyTest {

@Test
fun `M include parent hash W heatmapViewKey() {view has parent}`() {
// Given
val fakeView: View = mock()
val fakeParent: ViewParent = mock()
whenever(fakeView.parent).thenReturn(fakeParent)

// When
val key = heatmapViewKey(fakeView)

// Then
val expected = HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(fakeView).toLong() +
System.identityHashCode(fakeParent).toLong()
assertThat(key).isEqualTo(expected)
}

@Test
fun `M use zero for parent hash W heatmapViewKey() {view has no parent}`() {
// Given
val fakeView: View = mock()
whenever(fakeView.parent).thenReturn(null)

// When
val key = heatmapViewKey(fakeView)

// Then
assertThat(key).isEqualTo(HEATMAP_VIEW_KEY_COEFFICIENT * System.identityHashCode(fakeView).toLong())
}

@Test
fun `M return distinct keys W heatmapViewKey() {two views with same parent}`() {
// Given
val fakeParent: ViewParent = mock()
val fakeView1: View = mock()
val fakeView2: View = mock()
whenever(fakeView1.parent).thenReturn(fakeParent)
whenever(fakeView2.parent).thenReturn(fakeParent)

// When / Then
assertThat(heatmapViewKey(fakeView1)).isNotEqualTo(heatmapViewKey(fakeView2))
}

@Test
fun `M return distinct keys W heatmapViewKey() {view and parent hashes swapped}`() {
// Given — verifies the combinator is non-commutative:
// key(view=A, parent=B) must not equal key(view=B, parent=A).
// We wire fakeViewA's parent to fakeParentB and fakeViewB's parent to fakeParentA,
// so the two keys use the same pair of identity hashes but in opposite roles.
//
// Note: the assertion relies on the four mock objects having distinct identity hashes.
// Two mocks could theoretically collide (probability < 1/2^32 per run), which would make
// the assertion trivially true but for the wrong reason. This is considered acceptable.
val fakeViewA: View = mock()
val fakeViewB: View = mock()
val fakeParentA: ViewParent = mock()
val fakeParentB: ViewParent = mock()
whenever(fakeViewA.parent).thenReturn(fakeParentB)
whenever(fakeViewB.parent).thenReturn(fakeParentA)

// When / Then
assertThat(heatmapViewKey(fakeViewA)).isNotEqualTo(heatmapViewKey(fakeViewB))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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.heatmaps

import android.view.View
import com.datadog.android.internal.forge.Configurator
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.mockito.quality.Strictness

@Extensions(
ExtendWith(MockitoExtension::class),
ExtendWith(ForgeExtension::class)
)
@MockitoSettings(strictness = Strictness.LENIENT)
@ForgeConfiguration(Configurator::class)
internal class TapTargetUtilsTest {

@Test
fun `M return true W isValidTapTarget() {clickable and VISIBLE}`() {
// Given
val fakeView: View = mock()
whenever(fakeView.isClickable).thenReturn(true)
whenever(fakeView.visibility).thenReturn(View.VISIBLE)

// When / Then
assertThat(fakeView.isValidTapTarget()).isTrue()
}

@Test
fun `M return false W isValidTapTarget() {clickable and INVISIBLE}`() {
// Given
val fakeView: View = mock()
whenever(fakeView.isClickable).thenReturn(true)
whenever(fakeView.visibility).thenReturn(View.INVISIBLE)

// When / Then
assertThat(fakeView.isValidTapTarget()).isFalse()
}

@Test
fun `M return false W isValidTapTarget() {clickable and GONE}`() {
// Given
val fakeView: View = mock()
whenever(fakeView.isClickable).thenReturn(true)
whenever(fakeView.visibility).thenReturn(View.GONE)

// When / Then
assertThat(fakeView.isValidTapTarget()).isFalse()
}

@Test
fun `M return false W isValidTapTarget() {not clickable and VISIBLE}`() {
// Given
val fakeView: View = mock()
whenever(fakeView.isClickable).thenReturn(false)
whenever(fakeView.visibility).thenReturn(View.VISIBLE)

// When / Then
assertThat(fakeView.isValidTapTarget()).isFalse()
}

@Test
fun `M return true W isValidTapTarget() {disabled but clickable and VISIBLE}`() {
// Given
val fakeView: View = mock()
whenever(fakeView.isEnabled).thenReturn(false)
whenever(fakeView.isClickable).thenReturn(true)
whenever(fakeView.visibility).thenReturn(View.VISIBLE)

// When / Then
assertThat(fakeView.isValidTapTarget()).isTrue()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ 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.heatmaps.HeatmapIdentifierRegistry
import com.datadog.android.internal.heatmaps.HeatmapIdentifierRegistryProvider
import com.datadog.android.internal.system.BuildSdkVersionProvider
import com.datadog.android.internal.telemetry.InternalTelemetryEvent
import com.datadog.android.internal.thread.isMainThread
Expand Down Expand Up @@ -140,7 +142,7 @@ internal class RumFeature(
},
private val buildSdkVersionProvider: BuildSdkVersionProvider = BuildSdkVersionProvider.DEFAULT,
private val handler: Handler = Handler(Looper.getMainLooper())
) : StorageBackedFeature, FeatureEventReceiver {
) : StorageBackedFeature, FeatureEventReceiver, HeatmapIdentifierRegistryProvider {

internal var dataWriter: DataWriter<Any> = NoOpDataWriter()
internal val initialized = AtomicBoolean(false)
Expand Down Expand Up @@ -178,6 +180,7 @@ internal class RumFeature(
internal var displayInfoProvider: InfoProvider<DisplayInfo> = NoOpDisplayInfoProvider()
internal val rumContextUpdateReceivers = mutableSetOf<FeatureContextUpdateReceiver>()
internal var insightsCollector: InsightsCollector = NoOpInsightsCollector()
override val heatmapIdentifierRegistry: HeatmapIdentifierRegistry = HeatmapIdentifierRegistry.create()

private val lateCrashEventHandler by lazy { lateCrashReporterFactory(sdkCore as InternalSdkCore) }
internal var rumAppStartupDetector: RumAppStartupDetector? = null
Expand Down Expand Up @@ -891,7 +894,7 @@ internal class RumFeature(
providers,
interactionPredicate,
composeActionsTrackingStrategy = composeActionTrackingStrategy,
internalLogger
internalLogger = internalLogger
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.rum.internal.domain.scope

internal data class HeatmapActionData(
val viewKey: Long,
val positionX: Long,
val positionY: Long,
val targetWidth: Long?,
val targetHeight: Long?
)
Loading
Loading